fix: delete only the scope-checked triggers when disconnecting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-28 09:01:28 +02:00
co-authored by Claude Opus 5.5
parent 33719ae1f1
commit b552922036
4 changed files with 31 additions and 21 deletions
@@ -1,10 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "SELECT script_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3",
"query": "SELECT external_id, script_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "external_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "script_path",
"type_info": "Varchar"
}
@@ -28,8 +33,9 @@
]
},
"nullable": [
false,
false
]
},
"hash": "387aebd876ef240f9c7c99c977d0b66587f50a70c24f331aa5838a754d13ada9"
"hash": "4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT external_id, connection_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text IS NULL OR connection_path = $3)",
"query": "SELECT external_id, connection_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL OR external_id = ANY($3))",
"describe": {
"columns": [
{
@@ -29,7 +29,7 @@
}
}
},
"Text"
"TextArray"
]
},
"nullable": [
@@ -37,5 +37,5 @@
true
]
},
"hash": "1ecd50aa13f5d4babdfaaba6ff56940ac9e6d7a28a27e576b5dda4d7e709ad1a"
"hash": "c0a782d90433c27c97eeb49cf620e993320337f046e95efbd5bc21adf69e3d7d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text IS NULL OR connection_path = $3)\n RETURNING webhook_token_hash",
"query": "DELETE FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL OR external_id = ANY($3))\n RETURNING webhook_token_hash",
"describe": {
"columns": [
{
@@ -24,12 +24,12 @@
}
}
},
"Text"
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "6e1231d0db1d3736b05a41aa0a3f125cb2f383a53645d7c48cbd0478c3364663"
"hash": "e4407cb73d41652088bb6dcef2503e91a190217cc7875ebb07928b25720ae20b"
}
@@ -348,22 +348,22 @@ async fn fetch_nextcloud_user_id(base_url: &str, access_token: &str) -> anyhow::
Ok(ocs.ocs.data.id)
}
/// Delete the native triggers of a workspace+service — only those using `connection_path` when
/// given — including remote webhook cleanup.
/// Delete the native triggers of a workspace+service — only those in `external_ids` when given —
/// including remote webhook cleanup.
/// This is best-effort: errors during remote cleanup or token deletion are logged but ignored.
#[cfg(feature = "native_trigger")]
async fn delete_triggers_for_service(
db: &DB,
workspace_id: &str,
service_name: ServiceName,
connection_path: Option<&str>,
external_ids: Option<&[String]>,
) {
let triggers = sqlx::query!(
"SELECT external_id, connection_path FROM native_trigger
WHERE workspace_id = $1 AND service_name = $2 AND ($3::text IS NULL OR connection_path = $3)",
WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL OR external_id = ANY($3))",
workspace_id,
service_name as ServiceName,
connection_path,
external_ids,
)
.fetch_all(db)
.await;
@@ -423,11 +423,11 @@ async fn delete_triggers_for_service(
// disconnected integration keep starting jobs.
let deleted_token_hashes = sqlx::query_scalar!(
"DELETE FROM native_trigger
WHERE workspace_id = $1 AND service_name = $2 AND ($3::text IS NULL OR connection_path = $3)
WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL OR external_id = ANY($3))
RETURNING webhook_token_hash",
workspace_id,
service_name as ServiceName,
connection_path,
external_ids,
)
.fetch_all(db)
.await
@@ -1084,9 +1084,10 @@ async fn delete_connection(
)));
}
// Disconnecting deletes these triggers, so a path-scoped token must cover each of them.
let trigger_paths = sqlx::query_scalar!(
"SELECT script_path FROM native_trigger
// Disconnecting deletes these triggers, so a path-scoped token must cover each of them. Only
// the rows checked here are deleted: one created meanwhile is never removed unchecked.
let triggers = sqlx::query!(
"SELECT external_id, script_path FROM native_trigger
WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3",
workspace_id,
service_name as ServiceName,
@@ -1094,12 +1095,15 @@ async fn delete_connection(
)
.fetch_all(&db)
.await?;
for script_path in &trigger_paths {
check_scopes(&authed, || format!("native_triggers:write:{script_path}"))?;
for trigger in &triggers {
check_scopes(&authed, || {
format!("native_triggers:write:{}", trigger.script_path)
})?;
}
let external_ids: Vec<String> = triggers.into_iter().map(|t| t.external_id).collect();
// Before the connection goes: removing the registrations needs its token.
delete_triggers_for_service(&db, &workspace_id, service_name, Some(&path)).await;
delete_triggers_for_service(&db, &workspace_id, service_name, Some(&external_ids)).await;
let mut tx = user_db.begin(&authed).await?;
let account = cleanup_connection(&mut *tx, &workspace_id, &path).await?;