From 74c13f83f68669eff2edfcec8f2984e9cb87e1db Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 25 Sep 2026 14:52:54 +0200 Subject: [PATCH] feat: let native triggers act through per-user or shared connections Co-Authored-By: Claude Opus 5.5 --- .agents/skills/native-trigger/SKILL.md | 46 +- ...2f000ae6cf37928508a82f51d3b3a325f3d1a.json | 23 + ...75a4e26c766a15b0ec209504a24b2460575be.json | 24 + ...940ac9e6d7a28a27e576b5dda4d7e709ad1a.json} | 11 +- ...075c6ae51404037b0a7d4d33faf6a7d6a6d8.json} | 5 +- ...08cc81ee85cf98696d9e29a34557587eaf86.json} | 12 +- ...0d4f366724e0d4040faeccf443ef9ce30665c.json | 28 + ...2f20bb5a0aa921a614bd02898d3f162660c2.json} | 14 +- ...81208165ba7414fc592ca8dfffc9838ac715e.json | 16 - ...15a59165009048a7c93942ee2ecd78524fb5a.json | 15 + ...b6349376f09dad74380302cb815fc2626c69c.json | 30 + ...17efc6c8c87ba5542bce9c5f85b1da07e9a4b.json | 16 + ...125cb2f383a53645d7c48cbd0478c3364663.json} | 7 +- ...39369e386f6a552c6626be48ecaa0e5ab78a7.json | 16 - ...69ed368469c40f48bf25243fd96002ac4c7c5.json | 15 + ...beb6769dc6fc97269beee607a5625a495a121.json | 15 - ...0101e124fcf9d12e93d84b44457c72c7604a5.json | 16 - ...ba774a6ded50ab047d0b0302c7430cee5b70.json} | 7 +- ...9f9697cdefaa1f45ca665f72bc6fcf2797c7e.json | 34 -- ...5fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json | 23 + ...35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json | 16 + ...04a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json} | 12 +- ...5298eef6f75ec3852e0f007b98f71bfe56d53.json | 29 + ...ceed91c54f592d0e1f5139d5e111241ca451.json} | 12 +- ...51a4d3bbe6d1309cd8205e853097c9f81a471.json | 16 + ...b3efce1740b7037cac9983529bc052cf2dd9.json} | 12 +- ...5122051_native_trigger_connection.down.sql | 1 + ...925122051_native_trigger_connection.up.sql | 10 + .../tests/native_triggers.rs | 166 +++--- backend/windmill-api/openapi.yaml | 136 ++++- backend/windmill-api/src/offboarding.rs | 2 +- backend/windmill-api/src/users.rs | 5 +- .../windmill-common/src/global_settings.rs | 2 + .../src/github/external.rs | 62 +- .../src/github/mod.rs | 1 + .../src/github/routes.rs | 35 +- .../src/google/external.rs | 114 +++- .../src/google/mod.rs | 1 + .../src/google/routes.rs | 73 ++- .../windmill-native-triggers/src/handler.rs | 102 +++- backend/windmill-native-triggers/src/lib.rs | 254 +++++--- .../src/nextcloud/external.rs | 23 +- .../src/nextcloud/mod.rs | 1 + .../src/nextcloud/routes.rs | 21 +- .../windmill-native-triggers/src/rename.rs | 25 +- backend/windmill-native-triggers/src/sync.rs | 55 +- .../src/workspace_integrations.rs | 556 ++++++++++-------- cli/src/commands/trigger/trigger.ts | 2 + .../native/NativeConnectionPicker.svelte | 110 ++++ .../native/NativeOAuthCallback.svelte | 69 +++ .../native/NativeTriggerEditor.svelte | 58 +- .../services/github/GitHubTriggerForm.svelte | 5 +- .../google/GoogleCalendarPicker.svelte | 5 +- .../services/google/GoogleDrivePicker.svelte | 6 +- .../nextcloud/NextcloudTriggerForm.svelte | 6 +- .../lib/components/triggers/native/utils.ts | 85 ++- .../WorkspaceIntegrations.svelte | 415 ++++++------- .../(logged)/workspace_settings/+page.svelte | 14 +- .../[client_name]/native_trigger/+page.svelte | 8 + 59 files changed, 1914 insertions(+), 984 deletions(-) create mode 100644 backend/.sqlx/query-00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a.json create mode 100644 backend/.sqlx/query-17a29b1db3b9aea8ce4158ed1e475a4e26c766a15b0ec209504a24b2460575be.json rename backend/.sqlx/{query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json => query-1ecd50aa13f5d4babdfaaba6ff56940ac9e6d7a28a27e576b5dda4d7e709ad1a.json} (64%) rename backend/.sqlx/{query-a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c.json => query-2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8.json} (55%) rename backend/.sqlx/{query-0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578.json => query-30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86.json} (67%) create mode 100644 backend/.sqlx/query-3419911d3acc46540ad818015390d4f366724e0d4040faeccf443ef9ce30665c.json rename backend/.sqlx/{query-7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef.json => query-3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2.json} (76%) delete mode 100644 backend/.sqlx/query-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json create mode 100644 backend/.sqlx/query-4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a.json create mode 100644 backend/.sqlx/query-5cb209fe91eeccdc4c208db7655b6349376f09dad74380302cb815fc2626c69c.json create mode 100644 backend/.sqlx/query-61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b.json rename backend/.sqlx/{query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json => query-6e1231d0db1d3736b05a41aa0a3f125cb2f383a53645d7c48cbd0478c3364663.json} (65%) delete mode 100644 backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json create mode 100644 backend/.sqlx/query-7316c6eef49c12d959329ee611f69ed368469c40f48bf25243fd96002ac4c7c5.json delete mode 100644 backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json delete mode 100644 backend/.sqlx/query-89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5.json rename backend/.sqlx/{query-64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63.json => query-8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70.json} (61%) delete mode 100644 backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json create mode 100644 backend/.sqlx/query-9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json create mode 100644 backend/.sqlx/query-c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json rename backend/.sqlx/{query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json => query-c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json} (65%) create mode 100644 backend/.sqlx/query-c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53.json rename backend/.sqlx/{query-0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd.json => query-d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451.json} (82%) create mode 100644 backend/.sqlx/query-daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471.json rename backend/.sqlx/{query-c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1.json => query-efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9.json} (83%) create mode 100644 backend/migrations/20260925122051_native_trigger_connection.down.sql create mode 100644 backend/migrations/20260925122051_native_trigger_connection.up.sql create mode 100644 frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte create mode 100644 frontend/src/lib/components/triggers/native/NativeOAuthCallback.svelte create mode 100644 frontend/src/routes/oauth/callback/[client_name]/native_trigger/+page.svelte diff --git a/.agents/skills/native-trigger/SKILL.md b/.agents/skills/native-trigger/SKILL.md index ae38f70b32..552da33912 100644 --- a/.agents/skills/native-trigger/SKILL.md +++ b/.agents/skills/native-trigger/SKILL.md @@ -84,7 +84,7 @@ pub trait External: Send + Sync + 'static { async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result; fn service_config_from_create_response(&self, data, resp) -> Option; fn additional_routes(&self) -> axum::Router; - async fn http_client_request(&self, url, method, workspace_id, tx, db, headers, body) -> Result; + async fn http_client_request(&self, url, method, workspace_id, connection_path, db, headers, body) -> Result; } ``` @@ -115,28 +115,44 @@ Use this when the external_id is known before the create call (e.g., Google gene Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation. -### OAuth Token Storage (Three-Table Pattern) +### OAuth App and Connections -OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly: +The OAuth **app** (client) is configured per workspace and service; **connections** (connected +accounts) are many per service. Each trigger acts through one connection, named by +`native_trigger.connection_path`: | Table | What's Stored | |-------|---------------| -| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable | -| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column | -| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` | +| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag. One row per (workspace, service); it must exist for every connection, since token refresh reads the client from it | +| `variable` | Encrypted `access_token` at the connection path, linked to `account` via its `account` column | +| `account` | `refresh_token`, with `client` = service name and `is_workspace_integration = true` | +| `resource` | The connection's resource at the same path, `{ "token": "$var:" }` | -The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct: +A user may use a connection when they can read its variable (checked with a user transaction, +so the variable's row-level security decides): `resolve_usable_connection` / +`list_usable_connections` in `lib.rs`. Background work (sync, renewal, rename re-registration, +webhook handling) uses the stored `connection_path` directly. + +`decrypt_oauth_data(db, w_id, service, connection_path)` assembles these into one struct, and +every service's `OAuthData` carries `connection_path` so `http_client_request` can refresh and +write back the right connection: ```rust pub struct OAuthConfig { pub base_url: String, - pub access_token: String, // decrypted from variable - pub refresh_token: Option, // from account table + pub access_token: String, // decrypted from the connection's variable + pub refresh_token: Option, // from its account pub client_id: String, // from oauth_data or instance settings pub client_secret: String, // from oauth_data or instance settings + pub connection_path: String, } ``` -Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations. +A refreshed token is written back by connection path and account id, never by service: providers +like Nextcloud rotate refresh tokens, so writing one onto another account breaks its next refresh. + +Instance-level sharing: when the workspace has no OAuth app of its own and the instance admin +shares one (`share_with_workspaces` in the `oauths` setting; not available for Nextcloud), the +connect flow uses it and records `{"instance_shared": true}` in `workspace_integrations`. ### URL Resolution @@ -311,7 +327,7 @@ impl External for NewService { }); let response: CreateTriggerResponse = self - .http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload)) + .http_client_request(&url, Method::POST, w_id, &oauth_data.connection_path, db, None, Some(&payload)) .await?; Ok(response) @@ -343,7 +359,7 @@ impl External for NewService { }); let _: serde_json::Value = self - .http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload)) + .http_client_request(&url, Method::PUT, w_id, &oauth_data.connection_path, db, None, Some(&payload)) .await?; // Fetch back the updated state to get the resolved config @@ -361,7 +377,7 @@ impl External for NewService { tx: &mut PgConnection, ) -> Result { let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); - self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await + self.http_client_request::<_, ()>(&url, Method::GET, w_id, &oauth_data.connection_path, db, None, None).await } async fn delete( @@ -374,7 +390,7 @@ impl External for NewService { ) -> Result<()> { let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); let _: serde_json::Value = self - .http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None) + .http_client_request::<_, ()>(&url, Method::DELETE, w_id, &oauth_data.connection_path, db, None, None) .await .or_else(|e| match &e { Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null), @@ -749,7 +765,7 @@ pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces. -The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`. +`generate_connect_url` picks the client (`resolve_oauth_client`): the workspace's own app when configured, the instance's otherwise. A GitHub OAuth app only redirects below its single registered callback, so connections made with the instance GitHub app land on `/oauth/callback/github/native_trigger` instead of the workspace settings page. --- diff --git a/backend/.sqlx/query-00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a.json b/backend/.sqlx/query-00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a.json new file mode 100644 index 0000000000..ce07d4fada --- /dev/null +++ b/backend/.sqlx/query-00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2) RETURNING path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a" +} diff --git a/backend/.sqlx/query-17a29b1db3b9aea8ce4158ed1e475a4e26c766a15b0ec209504a24b2460575be.json b/backend/.sqlx/query-17a29b1db3b9aea8ce4158ed1e475a4e26c766a15b0ec209504a24b2460575be.json new file mode 100644 index 0000000000..44554fde78 --- /dev/null +++ b/backend/.sqlx/query-17a29b1db3b9aea8ce4158ed1e475a4e26c766a15b0ec209504a24b2460575be.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3 RETURNING account", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "17a29b1db3b9aea8ce4158ed1e475a4e26c766a15b0ec209504a24b2460575be" +} diff --git a/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json b/backend/.sqlx/query-1ecd50aa13f5d4babdfaaba6ff56940ac9e6d7a28a27e576b5dda4d7e709ad1a.json similarity index 64% rename from backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json rename to backend/.sqlx/query-1ecd50aa13f5d4babdfaaba6ff56940ac9e6d7a28a27e576b5dda4d7e709ad1a.json index 45605c04dd..031c041870 100644 --- a/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json +++ b/backend/.sqlx/query-1ecd50aa13f5d4babdfaaba6ff56940ac9e6d7a28a27e576b5dda4d7e709ad1a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "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)", "describe": { "columns": [ { @@ -10,7 +10,7 @@ }, { "ordinal": 1, - "name": "webhook_token_hash", + "name": "connection_path", "type_info": "Varchar" } ], @@ -28,13 +28,14 @@ ] } } - } + }, + "Text" ] }, "nullable": [ false, - false + true ] }, - "hash": "6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5" + "hash": "1ecd50aa13f5d4babdfaaba6ff56940ac9e6d7a28a27e576b5dda4d7e709ad1a" } diff --git a/backend/.sqlx/query-a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c.json b/backend/.sqlx/query-2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8.json similarity index 55% rename from backend/.sqlx/query-a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c.json rename to backend/.sqlx/query-2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8.json index 2412e1f5ee..8a131d8465 100644 --- a/backend/.sqlx/query-a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c.json +++ b/backend/.sqlx/query-2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n resource_path,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, $5, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n resource_path = $4,\n updated_at = now()\n ", + "query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n updated_at = now()\n ", "describe": { "columns": [], "parameters": { @@ -19,11 +19,10 @@ } }, "Jsonb", - "Text", "Varchar" ] }, "nullable": [] }, - "hash": "a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c" + "hash": "2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8" } diff --git a/backend/.sqlx/query-0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578.json b/backend/.sqlx/query-30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86.json similarity index 67% rename from backend/.sqlx/query-0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578.json rename to backend/.sqlx/query-30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86.json index cf0754d720..4c0cbba06c 100644 --- a/backend/.sqlx/query-0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578.json +++ b/backend/.sqlx/query-30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n oauth_data as \"oauth_data: sqlx::types::Json\",\n service_name as \"service_name!: ServiceName\",\n resource_path\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ", + "query": "\n SELECT\n oauth_data as \"oauth_data: sqlx::types::Json\",\n service_name as \"service_name!: ServiceName\"\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ", "describe": { "columns": [ { @@ -23,11 +23,6 @@ } } } - }, - { - "ordinal": 2, - "name": "resource_path", - "type_info": "Text" } ], "parameters": { @@ -37,9 +32,8 @@ }, "nullable": [ true, - false, - true + false ] }, - "hash": "0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578" + "hash": "30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86" } diff --git a/backend/.sqlx/query-3419911d3acc46540ad818015390d4f366724e0d4040faeccf443ef9ce30665c.json b/backend/.sqlx/query-3419911d3acc46540ad818015390d4f366724e0d4040faeccf443ef9ce30665c.json new file mode 100644 index 0000000000..ee4d590492 --- /dev/null +++ b/backend/.sqlx/query-3419911d3acc46540ad818015390d4f366724e0d4040faeccf443ef9ce30665c.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_integrations (workspace_id, service_name, oauth_data, created_by)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (workspace_id, service_name) DO UPDATE SET oauth_data = EXCLUDED.oauth_data\n WHERE workspace_integrations.oauth_data IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Jsonb", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "3419911d3acc46540ad818015390d4f366724e0d4040faeccf443ef9ce30665c" +} diff --git a/backend/.sqlx/query-7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef.json b/backend/.sqlx/query-3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2.json similarity index 76% rename from backend/.sqlx/query-7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef.json rename to backend/.sqlx/query-3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2.json index 4ac58627e7..984c702467 100644 --- a/backend/.sqlx/query-7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef.json +++ b/backend/.sqlx/query-3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n resource_path,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ", + "query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ", "describe": { "columns": [ { @@ -31,21 +31,16 @@ }, { "ordinal": 3, - "name": "resource_path", - "type_info": "Text" - }, - { - "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 5, + "ordinal": 4, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 5, "name": "created_by", "type_info": "Varchar" } @@ -71,11 +66,10 @@ false, false, true, - true, false, false, false ] }, - "hash": "7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef" + "hash": "3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2" } diff --git a/backend/.sqlx/query-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json b/backend/.sqlx/query-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json deleted file mode 100644 index 66d7fd5e1f..0000000000 --- a/backend/.sqlx/query-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e" -} diff --git a/backend/.sqlx/query-4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a.json b/backend/.sqlx/query-4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a.json new file mode 100644 index 0000000000..430c45c995 --- /dev/null +++ b/backend/.sqlx/query-4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM resource WHERE workspace_id = $1 AND path = ANY($2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a" +} diff --git a/backend/.sqlx/query-5cb209fe91eeccdc4c208db7655b6349376f09dad74380302cb815fc2626c69c.json b/backend/.sqlx/query-5cb209fe91eeccdc4c208db7655b6349376f09dad74380302cb815fc2626c69c.json new file mode 100644 index 0000000000..d35d072611 --- /dev/null +++ b/backend/.sqlx/query-5cb209fe91eeccdc4c208db7655b6349376f09dad74380302cb815fc2626c69c.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.value, a.refresh_token\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n WHERE v.workspace_id = $1 AND v.path = $2\n AND a.client = $3 AND a.is_workspace_integration = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "refresh_token", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "5cb209fe91eeccdc4c208db7655b6349376f09dad74380302cb815fc2626c69c" +} diff --git a/backend/.sqlx/query-61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b.json b/backend/.sqlx/query-61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b.json new file mode 100644 index 0000000000..17347df159 --- /dev/null +++ b/backend/.sqlx/query-61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE account SET\n refresh_token = COALESCE($1, refresh_token),\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $2 AND id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b" +} diff --git a/backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json b/backend/.sqlx/query-6e1231d0db1d3736b05a41aa0a3f125cb2f383a53645d7c48cbd0478c3364663.json similarity index 65% rename from backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json rename to backend/.sqlx/query-6e1231d0db1d3736b05a41aa0a3f125cb2f383a53645d7c48cbd0478c3364663.json index ebb5cee72c..84fb956583 100644 --- a/backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json +++ b/backend/.sqlx/query-6e1231d0db1d3736b05a41aa0a3f125cb2f383a53645d7c48cbd0478c3364663.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2 RETURNING webhook_token_hash", + "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", "describe": { "columns": [ { @@ -23,12 +23,13 @@ ] } } - } + }, + "Text" ] }, "nullable": [ false ] }, - "hash": "b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a" + "hash": "6e1231d0db1d3736b05a41aa0a3f125cb2f383a53645d7c48cbd0478c3364663" } diff --git a/backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json b/backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json deleted file mode 100644 index b765a5eb28..0000000000 --- a/backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE account SET\n refresh_token = $1,\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7" -} diff --git a/backend/.sqlx/query-7316c6eef49c12d959329ee611f69ed368469c40f48bf25243fd96002ac4c7c5.json b/backend/.sqlx/query-7316c6eef49c12d959329ee611f69ed368469c40f48bf25243fd96002ac4c7c5.json new file mode 100644 index 0000000000..9a3783142b --- /dev/null +++ b/backend/.sqlx/query-7316c6eef49c12d959329ee611f69ed368469c40f48bf25243fd96002ac4c7c5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM account WHERE workspace_id = $1 AND id = $2 AND is_workspace_integration = true", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "7316c6eef49c12d959329ee611f69ed368469c40f48bf25243fd96002ac4c7c5" +} diff --git a/backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json b/backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json deleted file mode 100644 index 97ab6a03d5..0000000000 --- a/backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE account SET\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121" -} diff --git a/backend/.sqlx/query-89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5.json b/backend/.sqlx/query-89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5.json deleted file mode 100644 index ca3059dbd8..0000000000 --- a/backend/.sqlx/query-89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5" -} diff --git a/backend/.sqlx/query-64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63.json b/backend/.sqlx/query-8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70.json similarity index 61% rename from backend/.sqlx/query-64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63.json rename to backend/.sqlx/query-8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70.json index d234750140..9416cd684c 100644 --- a/backend/.sqlx/query-64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63.json +++ b/backend/.sqlx/query-8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary,\n enabled\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()\n ", + "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary,\n enabled,\n connection_path\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, connection_path = $10, error = NULL, updated_at = NOW()\n ", "describe": { "columns": [], "parameters": { @@ -24,10 +24,11 @@ "Varchar", "Jsonb", "Varchar", - "Bool" + "Bool", + "Varchar" ] }, "nullable": [] }, - "hash": "64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63" + "hash": "8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70" } diff --git a/backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json b/backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json deleted file mode 100644 index 25a4d5b723..0000000000 --- a/backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "resource_path", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - { - "Custom": { - "name": "native_trigger_service", - "kind": { - "Enum": [ - "nextcloud", - "google", - "github" - ] - } - } - } - ] - }, - "nullable": [ - true - ] - }, - "hash": "993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e" -} diff --git a/backend/.sqlx/query-9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json b/backend/.sqlx/query-9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json new file mode 100644 index 0000000000..bb3d0d83b9 --- /dev/null +++ b/backend/.sqlx/query-9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM variable WHERE workspace_id = $1 AND path = $2 RETURNING account", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08" +} diff --git a/backend/.sqlx/query-c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json b/backend/.sqlx/query-c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json new file mode 100644 index 0000000000..f468c212dd --- /dev/null +++ b/backend/.sqlx/query-c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1" +} diff --git a/backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json b/backend/.sqlx/query-c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json similarity index 65% rename from backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json rename to backend/.sqlx/query-c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json index 6964dfa8d6..48435310f8 100644 --- a/backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json +++ b/backend/.sqlx/query-c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ", + "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled,\n nt.connection_path\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ", "describe": { "columns": [ { @@ -73,6 +73,11 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "connection_path", + "type_info": "Varchar" } ], "parameters": { @@ -111,8 +116,9 @@ false, false, true, - false + false, + true ] }, - "hash": "f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff" + "hash": "c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc" } diff --git a/backend/.sqlx/query-c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53.json b/backend/.sqlx/query-c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53.json new file mode 100644 index 0000000000..9428c71bc3 --- /dev/null +++ b/backend/.sqlx/query-c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.path, r.created_by AS \"owner?\"\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true\n ORDER BY v.path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "owner?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53" +} diff --git a/backend/.sqlx/query-0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd.json b/backend/.sqlx/query-d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451.json similarity index 82% rename from backend/.sqlx/query-0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd.json rename to backend/.sqlx/query-d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451.json index 8aecbed333..0fe46de01d 100644 --- a/backend/.sqlx/query-0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd.json +++ b/backend/.sqlx/query-d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled,\n connection_path\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", "describe": { "columns": [ { @@ -73,6 +73,11 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "connection_path", + "type_info": "Varchar" } ], "parameters": { @@ -106,8 +111,9 @@ false, false, true, - false + false, + true ] }, - "hash": "0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd" + "hash": "d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451" } diff --git a/backend/.sqlx/query-daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471.json b/backend/.sqlx/query-daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471.json new file mode 100644 index 0000000000..b2e7a8668c --- /dev/null +++ b/backend/.sqlx/query-daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471" +} diff --git a/backend/.sqlx/query-c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1.json b/backend/.sqlx/query-efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9.json similarity index 83% rename from backend/.sqlx/query-c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1.json rename to backend/.sqlx/query-efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9.json index 6a9de7e35b..66edee8f0b 100644 --- a/backend/.sqlx/query-c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1.json +++ b/backend/.sqlx/query-efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled,\n connection_path\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", "describe": { "columns": [ { @@ -73,6 +73,11 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "connection_path", + "type_info": "Varchar" } ], "parameters": { @@ -105,8 +110,9 @@ false, false, true, - false + false, + true ] }, - "hash": "c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1" + "hash": "efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9" } diff --git a/backend/migrations/20260925122051_native_trigger_connection.down.sql b/backend/migrations/20260925122051_native_trigger_connection.down.sql new file mode 100644 index 0000000000..3951ab2a64 --- /dev/null +++ b/backend/migrations/20260925122051_native_trigger_connection.down.sql @@ -0,0 +1 @@ +ALTER TABLE native_trigger DROP COLUMN IF EXISTS connection_path; diff --git a/backend/migrations/20260925122051_native_trigger_connection.up.sql b/backend/migrations/20260925122051_native_trigger_connection.up.sql new file mode 100644 index 0000000000..c5a2ae4695 --- /dev/null +++ b/backend/migrations/20260925122051_native_trigger_connection.up.sql @@ -0,0 +1,10 @@ +-- Each native trigger names the connection (OAuth resource) it acts through, instead of every +-- trigger of a service sharing the single workspace connection. +ALTER TABLE native_trigger ADD COLUMN connection_path VARCHAR(255); + +UPDATE native_trigger nt +SET connection_path = wi.resource_path +FROM workspace_integrations wi +WHERE wi.workspace_id = nt.workspace_id + AND wi.service_name = nt.service_name + AND wi.resource_path IS NOT NULL; diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index e7f0147323..faa7efbe74 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -26,8 +26,8 @@ use windmill_native_triggers::{ native_trigger_is_enabled, nextcloud::NextCloud, require_native_integration_use, set_native_trigger_enabled, store_native_trigger, - store_workspace_integration, update_native_trigger, External, ExternalReadFailure, - HttpRequestError, NativeTriggerConfig, OAuthConfig, ServiceName, + store_workspace_integration, update_native_trigger, update_oauth_token_resource, External, + ExternalReadFailure, HttpRequestError, NativeTriggerConfig, OAuthConfig, ServiceName, }; // ============================================================================ @@ -133,7 +133,6 @@ async fn setup_oauth_integration( "test-workspace", service_name, oauth_data, - Some(resource_path), ) .await?; tx.commit().await?; @@ -149,73 +148,70 @@ fn now_ms() -> i64 { } // ============================================================================ -// 1. Resource Path Change +// 1. Several connections of one service // ============================================================================ +/// Each connection keeps its own tokens: a refresh written back for one, or disconnecting one, +/// must not touch another. Providers that rotate refresh tokens invalidate the old one, so a +/// refresh token copied onto the wrong account breaks that account's next refresh. #[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn test_resource_path_change(db: Pool) -> anyhow::Result<()> { - let path_a = "u/test-user/native_gworkspace"; - setup_oauth_integration( +async fn test_connections_are_isolated(db: Pool) -> anyhow::Result<()> { + let path_a = "u/test-user/native_nextcloud"; + let path_b = "u/other-user/native_nextcloud"; + for (path, token) in [(path_a, "a"), (path_b, "b")] { + setup_oauth_integration( + &db, + ServiceName::Nextcloud, + path, + &format!("access-{token}"), + &format!("refresh-{token}"), + None, + ) + .await?; + } + + update_oauth_token_resource( &db, - ServiceName::Google, - path_a, - "token-a", - "refresh-a", - None, - ) - .await?; - - // Verify decrypt works at path A - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; - assert_eq!(config.access_token, "token-a"); - - // Cleanup old path - let mut tx = db.begin().await?; - windmill_native_triggers::workspace_integrations::cleanup_oauth_resource( - &mut *tx, "test-workspace", - ServiceName::Google, + path_a, + "access-a2", + Some("refresh-a2"), ) .await; - tx.commit().await?; - // Recreate at path B - let path_b = "u/test-user/native_gworkspace_v2"; - setup_oauth_integration( - &db, - ServiceName::Google, - path_b, - "token-b", - "refresh-b", - None, + let a: OAuthConfig = + decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_a)).await?; + let b: OAuthConfig = + decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_b)).await?; + assert_eq!( + (a.access_token.as_str(), a.refresh_token.as_deref()), + ("access-a2", Some("refresh-a2")) + ); + assert_eq!( + (b.access_token.as_str(), b.refresh_token.as_deref()), + ("access-b", Some("refresh-b")) + ); + + let mut tx = db.begin().await?; + windmill_native_triggers::workspace_integrations::cleanup_connection( + &mut *tx, + "test-workspace", + path_a, ) .await?; + tx.commit().await?; - // Path A resources should be gone - let var_count: i64 = sqlx::query_scalar!( - "SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1", - path_a, + assert!(decrypt_oauth_data::( + &db, + "test-workspace", + ServiceName::Nextcloud, + Some(path_a) ) - .fetch_one(&db) - .await? - .unwrap_or(0); - assert_eq!(var_count, 0, "variable at old path should be deleted"); - - let res_count: i64 = sqlx::query_scalar!( - "SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1", - path_a, - ) - .fetch_one(&db) - .await? - .unwrap_or(0); - assert_eq!(res_count, 0, "resource at old path should be deleted"); - - // Path B should work - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; - assert_eq!(config.access_token, "token-b"); - assert_eq!(config.refresh_token.as_deref(), Some("refresh-b")); + .await + .is_err()); + let b: OAuthConfig = + decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_b)).await?; + assert_eq!(b.refresh_token.as_deref(), Some("refresh-b")); Ok(()) } @@ -237,8 +233,13 @@ async fn test_decrypt_workspace_level(db: Pool) -> anyhow::Result<()> ) .await?; - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; + let config: OAuthConfig = decrypt_oauth_data( + &db, + "test-workspace", + ServiceName::Google, + Some(resource_path), + ) + .await?; assert_eq!(config.access_token, "ws-access-token"); assert_eq!(config.refresh_token.as_deref(), Some("ws-refresh-token")); @@ -282,8 +283,13 @@ async fn test_decrypt_instance_level(db: Pool) -> anyhow::Result<()> { ) .await?; - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; + let config: OAuthConfig = decrypt_oauth_data( + &db, + "test-workspace", + ServiceName::Google, + Some(resource_path), + ) + .await?; assert_eq!(config.client_id, "instance-client-id"); assert_eq!(config.client_secret, "instance-client-secret"); @@ -306,8 +312,13 @@ async fn test_token_update_persists(db: Pool) -> anyhow::Result<()> { .await?; // Verify old tokens - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; + let config: OAuthConfig = decrypt_oauth_data( + &db, + "test-workspace", + ServiceName::Google, + Some(resource_path), + ) + .await?; assert_eq!(config.access_token, "old-access-token"); // Simulate token refresh: update variable + account @@ -330,8 +341,13 @@ async fn test_token_update_persists(db: Pool) -> anyhow::Result<()> { .await?; // Verify new tokens - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; + let config: OAuthConfig = decrypt_oauth_data( + &db, + "test-workspace", + ServiceName::Google, + Some(resource_path), + ) + .await?; assert_eq!(config.access_token, "new-access-token"); assert_eq!(config.refresh_token.as_deref(), Some("new-refresh-token")); @@ -458,6 +474,7 @@ async fn test_delete_integration_full_cascade(db: Pool) -> anyhow::Res json!({"triggerType": "drive"}), None, true, + "u/test-user/native_conn", ) .await?; @@ -468,12 +485,12 @@ async fn test_delete_integration_full_cascade(db: Pool) -> anyhow::Res // Step 2: Cleanup OAuth resources let mut tx = db.begin().await?; - windmill_native_triggers::workspace_integrations::cleanup_oauth_resource( + windmill_native_triggers::workspace_integrations::cleanup_service_connections( &mut *tx, "test-workspace", ServiceName::Google, ) - .await; + .await?; tx.commit().await?; // Step 3: Delete workspace integration @@ -549,17 +566,18 @@ async fn test_cleanup_preserves_triggers(db: Pool) -> anyhow::Result<( json!({"triggerType": "drive"}), None, true, + "u/test-user/native_conn", ) .await?; // Cleanup OAuth only — should NOT remove the trigger let mut tx = db.begin().await?; - windmill_native_triggers::workspace_integrations::cleanup_oauth_resource( + windmill_native_triggers::workspace_integrations::cleanup_connection( &mut *tx, "test-workspace", - ServiceName::Google, + resource_path, ) - .await; + .await?; tx.commit().await?; // OAuth resources gone @@ -607,6 +625,7 @@ async fn test_rename_moves_native_trigger(db: Pool) -> anyhow::Result< json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), None, true, + "u/test-user/native_conn", ) .await?; // An unrelated trigger already sitting on the target path must not be reported as moved. @@ -624,6 +643,7 @@ async fn test_rename_moves_native_trigger(db: Pool) -> anyhow::Result< json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), None, true, + "u/test-user/native_conn", ) .await?; @@ -696,6 +716,7 @@ async fn test_list_native_triggers_scope_filter(db: Pool) -> anyhow::R json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), None, true, + "u/test-user/native_conn", ) .await?; } @@ -931,6 +952,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool) -> anyhow::Resul json!({"event": "OCA\\Files\\Event\\LoadAdditionalScriptsEvent"}), None, true, + "u/test-user/native_conn", ) .await?; @@ -980,6 +1002,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool) -> anyhow::Resul json!({}), None, false, + "u/test-user/native_conn", ) .await?; assert!( @@ -997,6 +1020,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool) -> anyhow::Resul json!({}), None, true, + "u/test-user/native_conn", ) .await?; assert!( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e9b5732f0a..db9cf0d9c3 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -20638,12 +20638,22 @@ paths: schema: $ref: "#/components/schemas/NativeServiceName" requestBody: - description: redirect_uri + description: >- + redirect_uri, and where to save the connection (defaults to + u/{caller}/native_{resource_type}). Uses the workspace OAuth client, or the instance + one when the instance shares it. required: true content: application/json: schema: - $ref: "#/components/schemas/RedirectUri" + type: object + properties: + redirect_uri: + type: string + resource_path: + type: string + required: + - redirect_uri responses: "200": description: native trigger service connect url @@ -20673,10 +20683,10 @@ paths: schema: type: boolean - /w/{workspace}/native_triggers/integrations/{service_name}/generate_instance_connect_url: - post: - summary: generate connect url using instance-level credentials - operationId: generateInstanceConnectUrl + /w/{workspace}/native_triggers/integrations/{service_name}/connections: + get: + summary: list the native trigger connections of a service the caller can use + operationId: listNativeTriggerConnections tags: - workspace_integration parameters: @@ -20686,16 +20696,37 @@ paths: required: true schema: $ref: "#/components/schemas/NativeServiceName" - requestBody: - description: redirect_uri - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RedirectUri" responses: "200": - description: authorization URL using instance credentials + description: connections + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/NativeTriggerConnection" + + /w/{workspace}/native_triggers/integrations/{service_name}/connections/delete: + delete: + summary: disconnect a native trigger connection and delete the triggers using it + operationId: deleteNativeTriggerConnection + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + - name: path + in: query + required: true + schema: + type: string + responses: + "200": + description: connection deleted content: application/json: schema: @@ -20749,17 +20780,15 @@ paths: type: string redirect_uri: type: string - resource_path: - type: string required: - code - state - redirect_uri responses: "200": - description: native trigger service oauth completed + description: path of the saved connection content: - text/plain: + application/json: schema: type: string @@ -21034,6 +21063,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string responses: "200": description: list of available NextCloud events @@ -21056,6 +21091,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string responses: "200": description: list of Google Calendars @@ -21078,6 +21119,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string - name: q in: query description: search query to filter files by name @@ -21119,6 +21166,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string responses: "200": description: list of shared drives @@ -21141,6 +21194,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string responses: "200": description: list of GitHub repositories @@ -39081,6 +39140,10 @@ components: enabled: type: boolean description: Whether the trigger starts a job when it fires + connection_path: + type: string + nullable: true + description: Path of the connection the trigger acts through on the external service required: - external_id - workspace_id @@ -39123,6 +39186,10 @@ components: enabled: type: boolean description: Whether the trigger starts a job when it fires + connection_path: + type: string + nullable: true + description: Path of the connection the trigger acts through on the external service external_data: type: object nullable: true @@ -39154,10 +39221,6 @@ components: oauth_data: nullable: true $ref: "#/components/schemas/WorkspaceOAuthConfig" - resource_path: - type: string - nullable: true - description: Path to the resource storing the OAuth token required: - service_name @@ -39203,14 +39266,6 @@ components: - async - sync - RedirectUri: - type: object - properties: - redirect_uri: - type: string - required: - - redirect_uri - NativeTriggerData: type: object description: Data for creating or updating a native trigger @@ -39235,11 +39290,32 @@ components: Whether the trigger starts a job when it fires. Honoured on create only, so a trigger can be registered already paused; an update ignores it and setenabled is the only way to change an existing trigger's state. Defaults to true. + connection_path: + type: string + nullable: true + description: >- + Path of the connection the trigger acts through. Honoured on create only; an update + keeps the trigger's connection. When omitted on create, the caller's only usable + connection for the service is used. required: - script_path - is_flow - service_config + NativeTriggerConnection: + type: object + description: An account connected for native triggers, stored as an OAuth resource + properties: + path: + type: string + description: Path of the resource and variable holding the connection + owner: + type: string + nullable: true + description: Username of whoever connected the account, when the caller can read its resource + required: + - path + CreateTriggerResponse: type: object description: Response returned when a native trigger is created diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index b7f83dfd96..d8354e0f39 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -1188,7 +1188,7 @@ async fn offboard_user_from_workspace<'c>( // ---- Related path tables ---- sqlx::query!( - r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + r#"UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id ).execute(&mut **tx).await?; diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 5b26cf57b8..32fc6c1fa8 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -297,8 +297,7 @@ async fn rename_user( // keeps a like-named member of some other workspace out of it. if let Some(old_username) = old_instance_username.filter(|u| *u != ru.new_username) { let old_principal = windmill_common::users::username_to_permissioned_as(&old_username); - let new_principal = - windmill_common::users::username_to_permissioned_as(&ru.new_username); + let new_principal = windmill_common::users::username_to_permissioned_as(&ru.new_username); sqlx::query!( "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of' = $2 AND policy->>'on_behalf_of_email' = $3", @@ -618,7 +617,7 @@ async fn update_username_in_workpsace<'c>( .await?; sqlx::query!( - r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + r#"UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, new_username, old_username, w_id diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 2bc303f5c2..4b05ddca80 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -924,6 +924,7 @@ pub fn workspace_integration_oauth_key(client_name: &str) -> &str { pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) -> String { match client_name { "google" => "https://oauth2.googleapis.com/token".to_string(), + "github" => "https://github.com/login/oauth/access_token".to_string(), _ => format!("{}/apps/oauth2/api/v1/token", base_url), } } @@ -932,6 +933,7 @@ pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) - pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> String { match client_name { "google" => "https://accounts.google.com/o/oauth2/v2/auth".to_string(), + "github" => "https://github.com/login/oauth/authorize".to_string(), _ => format!("{}/apps/oauth2/authorize", base_url), } } diff --git a/backend/windmill-native-triggers/src/github/external.rs b/backend/windmill-native-triggers/src/github/external.rs index 2c23e02bef..72ae5959e4 100644 --- a/backend/windmill-native-triggers/src/github/external.rs +++ b/backend/windmill-native-triggers/src/github/external.rs @@ -33,7 +33,7 @@ impl External for GitHub { async fn create( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, webhook_token: &str, data: &NativeTriggerData, db: &DB, @@ -69,7 +69,15 @@ impl External for GitHub { }); let response: CreateWebhookResponse = self - .http_client_request(&url, Method::POST, w_id, db, None, Some(&payload)) + .http_client_request( + &url, + Method::POST, + w_id, + &oauth_data.connection_path, + db, + None, + Some(&payload), + ) .await?; Ok(response) @@ -78,7 +86,7 @@ impl External for GitHub { async fn update( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, external_id: &str, webhook_token: &str, data: &NativeTriggerData, @@ -114,7 +122,15 @@ impl External for GitHub { }); let _: serde_json::Value = self - .http_client_request(&url, Method::PATCH, w_id, db, None, Some(&payload)) + .http_client_request( + &url, + Method::PATCH, + w_id, + &oauth_data.connection_path, + db, + None, + Some(&payload), + ) .await?; // Return the resolved service_config @@ -125,7 +141,7 @@ impl External for GitHub { async fn get( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, external_id: &str, db: &DB, _tx: &mut PgConnection, @@ -137,7 +153,14 @@ impl External for GitHub { }; let data = self - .get_webhook(w_id, &owner, &repo, external_id, db) + .get_webhook( + w_id, + &oauth_data.connection_path, + &owner, + &repo, + external_id, + db, + ) .await?; Ok(Some(data)) } @@ -145,7 +168,7 @@ impl External for GitHub { async fn delete( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, external_id: &str, db: &DB, _tx: &mut PgConnection, @@ -165,7 +188,15 @@ impl External for GitHub { // Swallow 404 only (webhook may already be deleted on GitHub); propagate // other errors so callers don't think cleanup succeeded when it didn't. let result: std::result::Result = self - .http_client_request::<_, ()>(&url, Method::DELETE, w_id, db, None, None) + .http_client_request::<_, ()>( + &url, + Method::DELETE, + w_id, + &oauth_data.connection_path, + db, + None, + None, + ) .await; match result { @@ -180,7 +211,7 @@ impl External for GitHub { db: &DB, workspace_id: &str, triggers: &[NativeTrigger], - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, synced: &mut Vec, errors: &mut Vec, ) { @@ -208,7 +239,15 @@ impl External for GitHub { ); let result: std::result::Result = self - .http_client_request::<_, ()>(&url, Method::GET, workspace_id, db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + workspace_id, + &oauth_data.connection_path, + db, + None, + None, + ) .await; match result { @@ -363,6 +402,7 @@ impl GitHub { async fn get_webhook( &self, w_id: &str, + connection_path: &str, owner: &str, repo: &str, external_id: &str, @@ -374,7 +414,7 @@ impl GitHub { ); let response: GithubWebhookApiResponse = self - .http_client_request::<_, ()>(&url, Method::GET, w_id, db, None, None) + .http_client_request::<_, ()>(&url, Method::GET, w_id, connection_path, db, None, None) .await?; Ok(GithubTriggerData { diff --git a/backend/windmill-native-triggers/src/github/mod.rs b/backend/windmill-native-triggers/src/github/mod.rs index d01fa0020b..45d0ebf257 100644 --- a/backend/windmill-native-triggers/src/github/mod.rs +++ b/backend/windmill-native-triggers/src/github/mod.rs @@ -17,6 +17,7 @@ pub struct GithubOAuthData { pub access_token: String, pub refresh_token: Option, pub token_expires_at: Option>, + pub connection_path: String, } /// User-provided configuration for a GitHub webhook trigger. diff --git a/backend/windmill-native-triggers/src/github/routes.rs b/backend/windmill-native-triggers/src/github/routes.rs index 68de93b6a9..e5a665a85c 100644 --- a/backend/windmill-native-triggers/src/github/routes.rs +++ b/backend/windmill-native-triggers/src/github/routes.rs @@ -1,14 +1,15 @@ use std::sync::Arc; -use axum::{extract::Path, routing::get, Extension, Json, Router}; +use axum::{ + extract::{Path, Query}, + routing::get, + Extension, Json, Router, +}; use http::Method; use windmill_api_auth::ApiAuthed; -use windmill_common::{error::JsonResult, DB}; +use windmill_common::{db::UserDB, error::JsonResult, DB}; -use crate::{ - get_workspace_integration, map_external_error, require_native_integration_use, External, - ServiceName, -}; +use crate::{map_external_error, picker_connection, ConnectionQuery, External, ServiceName}; use super::{GitHub, GithubApiRepoResponse, GithubRepoEntry}; @@ -19,10 +20,18 @@ async fn list_repos( authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, + Extension(user_db): Extension, Path(workspace_id): Path, + Query(query): Query, ) -> JsonResult> { - require_native_integration_use(&authed)?; - get_workspace_integration(&db, &workspace_id, ServiceName::Github).await?; + let connection_path = picker_connection( + &authed, + user_db, + &workspace_id, + ServiceName::Github, + query.connection_path.as_deref(), + ) + .await?; let mut all_entries = Vec::new(); @@ -35,7 +44,15 @@ async fn list_repos( ); let repos: Vec = handler - .http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + &workspace_id, + &connection_path, + &db, + None, + None, + ) .await .map_err(map_external_error)?; diff --git a/backend/windmill-native-triggers/src/google/external.rs b/backend/windmill-native-triggers/src/google/external.rs index 1c5a2dd9ec..c2e4bc462f 100644 --- a/backend/windmill-native-triggers/src/google/external.rs +++ b/backend/windmill-native-triggers/src/google/external.rs @@ -42,7 +42,7 @@ impl External for Google { async fn create( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, webhook_token: &str, data: &NativeTriggerData, db: &DB, @@ -51,8 +51,16 @@ impl External for Google { // At creation time, channel_id also becomes the trigger's external_id // (see external_id_and_metadata_from_response). let channel_id = uuid::Uuid::new_v4().to_string(); - self.create_watch_channel(w_id, &channel_id, &channel_id, webhook_token, data, db) - .await + self.create_watch_channel( + w_id, + &oauth_data.connection_path, + &channel_id, + &channel_id, + webhook_token, + data, + db, + ) + .await } async fn update( @@ -72,7 +80,15 @@ impl External for Google { // external_id stays the stable routing key used in the webhook URL. let channel_id = uuid::Uuid::new_v4().to_string(); let resp = self - .create_watch_channel(w_id, external_id, &channel_id, webhook_token, data, db) + .create_watch_channel( + w_id, + &oauth_data.connection_path, + external_id, + &channel_id, + webhook_token, + data, + db, + ) .await?; self.service_config_from_create_response(data, &resp) @@ -86,7 +102,7 @@ impl External for Google { async fn delete( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, external_id: &str, db: &DB, tx: &mut PgConnection, @@ -122,7 +138,15 @@ impl External for Google { // Stop the channel (ignore errors - channel may have already expired) let result: std::result::Result = self - .http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request)) + .http_client_request( + &url, + Method::POST, + w_id, + &oauth_data.connection_path, + db, + None, + Some(&stop_request), + ) .await; if let Err(e) = result { @@ -231,6 +255,7 @@ impl Google { async fn create_watch_channel( &self, w_id: &str, + connection_path: &str, external_id: &str, channel_id: &str, webhook_token: &str, @@ -263,12 +288,24 @@ impl Google { match data.service_config.trigger_type { GoogleTriggerType::Drive => { - self.create_drive_watch(w_id, &data.service_config, &watch_request, db) - .await + self.create_drive_watch( + w_id, + connection_path, + &data.service_config, + &watch_request, + db, + ) + .await } GoogleTriggerType::Calendar => { - self.create_calendar_watch(w_id, &data.service_config, &watch_request, db) - .await + self.create_calendar_watch( + w_id, + connection_path, + &data.service_config, + &watch_request, + db, + ) + .await } } } @@ -276,6 +313,7 @@ impl Google { async fn create_drive_watch( &self, w_id: &str, + connection_path: &str, config: &GoogleServiceConfig, watch_request: &WatchRequest, db: &DB, @@ -285,14 +323,30 @@ impl Google { // Specific file: use files.watch let url = format!("{}/files/{}/watch", endpoints::DRIVE_API_BASE, resource_id); - self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request)) - .await + self.http_client_request( + &url, + Method::POST, + w_id, + connection_path, + db, + None, + Some(watch_request), + ) + .await } None => { // All changes: use changes.watch let token_url = format!("{}/changes/startPageToken", endpoints::DRIVE_API_BASE); let token_response: serde_json::Value = self - .http_client_request::<_, ()>(&token_url, Method::GET, w_id, db, None, None) + .http_client_request::<_, ()>( + &token_url, + Method::GET, + w_id, + connection_path, + db, + None, + None, + ) .await?; let start_page_token = token_response @@ -313,6 +367,7 @@ impl Google { &watch_url, Method::POST, w_id, + connection_path, db, None, Some(&watch_body), @@ -325,6 +380,7 @@ impl Google { async fn create_calendar_watch( &self, w_id: &str, + connection_path: &str, config: &GoogleServiceConfig, watch_request: &WatchRequest, db: &DB, @@ -339,8 +395,16 @@ impl Google { urlencoding::encode(calendar_id) ); - self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request)) - .await + self.http_client_request( + &url, + Method::POST, + w_id, + connection_path, + db, + None, + Some(watch_request), + ) + .await } /// Renew an expiring Google watch channel. @@ -362,6 +426,12 @@ impl Google { .map(|v| serde_json::from_value(v.clone())) .transpose()? .ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?; + let connection_path = trigger.connection_path.as_deref().ok_or_else(|| { + Error::BadRequest(format!( + "Cannot renew channel {}: the trigger has no Google connection", + trigger.external_id + )) + })?; let rotated = match crate::rotate_webhook_token( db, @@ -430,7 +500,15 @@ impl Google { } }; let result: std::result::Result = self - .http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request)) + .http_client_request( + &url, + Method::POST, + w_id, + connection_path, + db, + None, + Some(&stop_request), + ) .await; if let Err(e) = result { tracing::warn!( @@ -444,11 +522,11 @@ impl Google { // Create new watch channel with a fresh channel ID let resp = match config.trigger_type { GoogleTriggerType::Drive => { - self.create_drive_watch(w_id, &config, &watch_request, db) + self.create_drive_watch(w_id, connection_path, &config, &watch_request, db) .await? } GoogleTriggerType::Calendar => { - self.create_calendar_watch(w_id, &config, &watch_request, db) + self.create_calendar_watch(w_id, connection_path, &config, &watch_request, db) .await? } }; diff --git a/backend/windmill-native-triggers/src/google/mod.rs b/backend/windmill-native-triggers/src/google/mod.rs index 5bd07e5dab..4e8e398cc7 100644 --- a/backend/windmill-native-triggers/src/google/mod.rs +++ b/backend/windmill-native-triggers/src/google/mod.rs @@ -134,6 +134,7 @@ pub struct GoogleOAuthData { pub refresh_token: Option, /// When the access token expires pub token_expires_at: Option>, + pub connection_path: String, } /// Google API endpoints diff --git a/backend/windmill-native-triggers/src/google/routes.rs b/backend/windmill-native-triggers/src/google/routes.rs index 306a7e54e2..a0e6bb9fd6 100644 --- a/backend/windmill-native-triggers/src/google/routes.rs +++ b/backend/windmill-native-triggers/src/google/routes.rs @@ -10,10 +10,9 @@ use serde::{Deserialize, Serialize}; use windmill_api_auth::ApiAuthed; use windmill_common::{error::JsonResult, DB}; -use crate::{ - get_workspace_integration, map_external_error, require_native_integration_use, External, - ServiceName, -}; +use windmill_common::db::UserDB; + +use crate::{map_external_error, picker_connection, ConnectionQuery, External, ServiceName}; use super::Google; @@ -85,16 +84,25 @@ pub struct DriveFilesQuery { pub page_token: Option, #[serde(default)] pub shared_with_me: bool, + pub connection_path: Option, } async fn list_calendars( authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, + Extension(user_db): Extension, Path(workspace_id): Path, + Query(query): Query, ) -> JsonResult> { - require_native_integration_use(&authed)?; - get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; + let connection_path = picker_connection( + &authed, + user_db, + &workspace_id, + ServiceName::Google, + query.connection_path.as_deref(), + ) + .await?; let url = format!( "{}/users/me/calendarList", @@ -102,7 +110,15 @@ async fn list_calendars( ); let response: GoogleCalendarListResponse = handler - .http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + &workspace_id, + &connection_path, + &db, + None, + None, + ) .await .map_err(map_external_error)?; @@ -123,11 +139,18 @@ async fn list_drive_files( authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, + Extension(user_db): Extension, Path(workspace_id): Path, Query(query): Query, ) -> JsonResult { - require_native_integration_use(&authed)?; - get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; + let connection_path = picker_connection( + &authed, + user_db, + &workspace_id, + ServiceName::Google, + query.connection_path.as_deref(), + ) + .await?; let drive_query = if query.shared_with_me { "sharedWithMe = true and trashed = false".to_string() @@ -156,7 +179,15 @@ async fn list_drive_files( } let response: DriveApiResponse = handler - .http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + &workspace_id, + &connection_path, + &db, + None, + None, + ) .await .map_err(map_external_error)?; @@ -199,10 +230,18 @@ async fn list_shared_drives( authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, + Extension(user_db): Extension, Path(workspace_id): Path, + Query(query): Query, ) -> JsonResult> { - require_native_integration_use(&authed)?; - get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; + let connection_path = picker_connection( + &authed, + user_db, + &workspace_id, + ServiceName::Google, + query.connection_path.as_deref(), + ) + .await?; let url = format!( "{}/drives?pageSize=100&fields=drives(id,name)", @@ -210,7 +249,15 @@ async fn list_shared_drives( ); let response: SharedDrivesApiResponse = handler - .http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + &workspace_id, + &connection_path, + &db, + None, + None, + ) .await .map_err(map_external_error)?; diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 2c785a4e26..5cea257955 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -1,11 +1,11 @@ use crate::{ classify_read_failure, decrypt_oauth_data, delete_native_trigger, delete_token_by_hash, get_native_trigger, list_native_triggers, lock::TriggerLock, map_external_error, - map_external_error_with, rotate_webhook_token, set_native_trigger_enabled, - store_native_trigger, sync::EXTERNAL_TRIGGER_MISSING_ERROR, update_native_trigger_error, - update_native_trigger_if_runnable_unchanged, webhook_token_label, webhook_token_scopes, - External, ExternalReadFailure, NativeTrigger, NativeTriggerConfig, NativeTriggerData, - ServiceName, + map_external_error_with, resolve_usable_connection, rotate_webhook_token, + set_native_trigger_enabled, store_native_trigger, sync::EXTERNAL_TRIGGER_MISSING_ERROR, + update_native_trigger_error, update_native_trigger_if_runnable_unchanged, webhook_token_label, + webhook_token_scopes, External, ExternalReadFailure, NativeTrigger, NativeTriggerConfig, + NativeTriggerData, ServiceName, }; use axum::{ extract::{Path, Query}, @@ -187,9 +187,20 @@ async fn create_native_trigger( ) .await?; - let integration_service = service_name.integration_service(); - let oauth_data: T::OAuthData = - decrypt_oauth_data(&db, &workspace_id, integration_service).await?; + let connection_path = resolve_usable_connection( + &mut tx, + &workspace_id, + service_name.integration_service(), + data.connection_path.as_deref(), + ) + .await?; + let oauth_data: T::OAuthData = decrypt_oauth_data( + &db, + &workspace_id, + service_name.integration_service(), + Some(&connection_path), + ) + .await?; let resp = handler .create( @@ -241,6 +252,7 @@ async fn create_native_trigger( service_config, data.summary.as_deref(), data.enabled, + &connection_path, ) .await?; @@ -282,10 +294,6 @@ async fn update_native_trigger_handler( .await?; require_runnable_exists(&db, &workspace_id, &data.script_path, data.is_flow).await?; - let integration_service = service_name.integration_service(); - let oauth_data: T::OAuthData = - decrypt_oauth_data(&db, &workspace_id, integration_service).await?; - let lock = TriggerLock::acquire(&db, &workspace_id, service_name, &external_id).await?; let mut tx = user_db.clone().begin(&authed).await?; @@ -294,6 +302,20 @@ async fn update_native_trigger_handler( .await? .ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?; + // Saving re-registers the webhook as the connection's account, so the editor must be allowed + // to act as it, not just to write the runnable. + let integration_service = service_name.integration_service(); + if let Some(path) = existing.connection_path.as_deref() { + resolve_usable_connection(&mut tx, &workspace_id, integration_service, Some(path)).await?; + } + let oauth_data: T::OAuthData = decrypt_oauth_data( + &db, + &workspace_id, + integration_service, + existing.connection_path.as_deref(), + ) + .await?; + let runnable_changed = existing.script_path != data.script_path || existing.is_flow != data.is_flow; @@ -446,9 +468,13 @@ async fn get_native_trigger_handler( ) .await?; - let integration_service = service_name.integration_service(); - let oauth_data: T::OAuthData = - decrypt_oauth_data(&db, &workspace_id, integration_service).await?; + let oauth_data: T::OAuthData = decrypt_oauth_data( + &db, + &workspace_id, + service_name.integration_service(), + windmill_trigger.connection_path.as_deref(), + ) + .await?; let native_trigger = handler .get(&workspace_id, &oauth_data, &external_id, &db, &mut tx) @@ -554,23 +580,35 @@ async fn delete_native_trigger_handler( ) .await?; - let integration_service = service_name.integration_service(); - let oauth_data: T::OAuthData = - decrypt_oauth_data(&db, &workspace_id, integration_service).await?; - - handler - .delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx) - .await - .map_err(|e| { - map_external_error_with(e, |m| { - let end = if m.ends_with(['.', '!', '?']) { - "" - } else { - "." - }; - format!("{m}{end} The trigger was kept in Windmill, so it can still fire.") - }) - })?; + // A connection can be deleted from under its triggers. Without it the registration cannot be + // removed, but deleting the row still revokes the webhook token, so the service's deliveries + // stop starting jobs; refusing would leave a trigger nobody can remove. + match decrypt_oauth_data::( + &db, + &workspace_id, + service_name.integration_service(), + existing.connection_path.as_deref(), + ) + .await + { + Ok(oauth_data) => handler + .delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx) + .await + .map_err(|e| { + map_external_error_with(e, |m| { + let end = if m.ends_with(['.', '!', '?']) { + "" + } else { + "." + }; + format!("{m}{end} The trigger was kept in Windmill, so it can still fire.") + }) + })?, + Err(e @ (Error::NotFound(_) | Error::BadRequest(_))) => tracing::warn!( + "Deleting {service_name} trigger {external_id} without removing its registration: {e}" + ), + Err(e) => return Err(e), + } let deleted = delete_native_trigger(&mut *tx, &workspace_id, service_name, &external_id).await?; diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 6398e8a6d9..ed7089f51b 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -45,6 +45,7 @@ use std::{collections::HashMap, fmt::Debug}; use strum::{EnumIter, IntoEnumIterator}; use tokio::task; use windmill_common::{ + db::UserDB, error::{to_anyhow, Error, Result}, triggers::TriggerKind, utils::HTTP_CLIENT, @@ -230,6 +231,9 @@ pub struct NativeTrigger { /// its initial value and `setenabled` is its only mutator afterwards, so saving a /// configuration can never silently re-enable a trigger someone paused. pub enabled: bool, + /// Path of the connection (the OAuth resource created by the connect flow) the trigger acts + /// through on the external service. + pub connection_path: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -253,6 +257,11 @@ pub struct NativeTriggerData { /// An update ignores it: `setenabled` is the only way to change an existing trigger's state. #[serde(default = "default_true")] pub enabled: bool, + /// Honoured on create only: the webhook is registered under this connection's account, so + /// switching it would leave the registration owned by an account the trigger no longer uses. + /// When omitted, the caller's only readable connection for the service is used. + #[serde(default)] + pub connection_path: Option, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] @@ -260,7 +269,6 @@ pub struct WorkspaceIntegration { pub workspace_id: String, pub service_name: ServiceName, pub oauth_data: serde_json::Value, - pub resource_path: Option, pub created_at: DateTime, pub updated_at: DateTime, pub created_by: String, @@ -399,12 +407,18 @@ pub trait External: Send + Sync + 'static { url: &str, method: Method, workspace_id: &str, + connection_path: &str, db: &DB, headers: Option>, body: Option<&B>, ) -> Result { - let oauth_config: OAuthConfig = - decrypt_oauth_data(db, workspace_id, Self::SERVICE_NAME).await?; + let oauth_config: OAuthConfig = decrypt_oauth_data( + db, + workspace_id, + Self::SERVICE_NAME.integration_service(), + Some(connection_path), + ) + .await?; let result = make_http_request( url, @@ -446,14 +460,14 @@ pub trait External: Send + Sync + 'static { task::spawn({ let db_clone = db.clone(); let workspace_id_clone = workspace_id.to_string(); - let service_name = Self::SERVICE_NAME; + let connection_path = connection_path.to_string(); let new_access_token = refreshed_oauth_config.access_token.clone(); let new_refresh_token = refreshed_oauth_config.refresh_token.clone(); async move { update_oauth_token_resource( &db_clone, &workspace_id_clone, - service_name, + &connection_path, &new_access_token, new_refresh_token.as_deref(), ) @@ -594,6 +608,7 @@ pub struct OAuthConfig { pub refresh_token: Option, pub client_id: String, pub client_secret: String, + pub connection_path: String, } pub async fn make_http_request( @@ -776,51 +791,50 @@ async fn get_instance_oauth_credentials( .await } +/// Assemble the credentials of one connection: its token from the variable and account at +/// `connection_path`, and the OAuth client from the workspace (or instance) configuration. +/// +/// Only an account made by the connect flow is accepted, since token refresh goes through this +/// service's OAuth client and any other account would be refreshed against the wrong one. pub async fn decrypt_oauth_data( db: &DB, workspace_id: &str, service_name: ServiceName, + connection_path: Option<&str>, ) -> Result { - let integration = get_workspace_integration(db, workspace_id, service_name).await?; - let oauth_data = integration.oauth_data; - - let resource_path = integration.resource_path.as_deref().ok_or_else(|| { - Error::InternalErr(format!( - "No resource_path in {} integration config. Please reconnect the integration.", - service_name + let connection_path = connection_path.ok_or_else(|| { + Error::BadRequest(format!( + "This trigger has no {service_name} connection. Delete it and create it again with a connection." )) })?; + let integration = get_workspace_integration(db, workspace_id, service_name).await?; + let oauth_data = integration.oauth_data; + let mc = build_crypt(db, workspace_id).await?; - let var_row = sqlx::query!( - "SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2", + let row = sqlx::query!( + "SELECT v.value, a.refresh_token + FROM variable v + JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account + WHERE v.workspace_id = $1 AND v.path = $2 + AND a.client = $3 AND a.is_workspace_integration = true", workspace_id, - resource_path, + connection_path, + service_name.as_str(), ) .fetch_optional(db) .await? .ok_or_else(|| { - Error::InternalErr(format!( - "Variable at {} not found for {} integration", - resource_path, service_name + Error::NotFound(format!( + "No {service_name} connection at {connection_path}. Connect an account there or pick another connection." )) })?; - let access_token = decrypt(&mc, var_row.value) + let access_token = decrypt(&mc, row.value) .map_err(|e| Error::InternalErr(format!("Failed to decrypt access token: {}", e)))?; - let refresh_token = if let Some(account_id) = var_row.account { - sqlx::query_scalar!( - "SELECT refresh_token FROM account WHERE workspace_id = $1 AND id = $2", - workspace_id, - account_id, - ) - .fetch_optional(db) - .await? - } else { - None - }; + let refresh_token = Some(row.refresh_token); let (client_id, client_secret) = if oauth_data .get("instance_shared") @@ -853,6 +867,7 @@ pub async fn decrypt_oauth_data( "refresh_token": refresh_token, "client_id": client_id, "client_secret": client_secret, + "connection_path": connection_path, }); serde_json::from_value(assembled) @@ -978,6 +993,7 @@ pub async fn refresh_oauth_tokens( .or_else(|| oauth_config.refresh_token.clone()), client_id: oauth_config.client_id.clone(), client_secret: oauth_config.client_secret.clone(), + connection_path: oauth_config.connection_path.clone(), }) } @@ -994,70 +1010,58 @@ pub async fn refresh_oauth_tokens( }) } -async fn update_oauth_token_resource( +/// Write a refreshed token back to the one connection it was refreshed for. The account is found +/// through that connection's variable, never by service: several connections share a service, and +/// a provider that rotates refresh tokens invalidates the old one, so writing it onto another +/// account would break that account's next refresh. +pub async fn update_oauth_token_resource( db: &DB, workspace_id: &str, - service_name: ServiceName, + connection_path: &str, new_access_token: &str, new_refresh_token: Option<&str>, ) { let result = async { - let integration = get_workspace_integration(db, workspace_id, service_name).await?; - let resource_path = integration.resource_path.ok_or_else(|| { - Error::InternalErr(format!( - "No resource_path in {} integration config", - service_name - )) - })?; - let mc = build_crypt(db, workspace_id).await?; let encrypted_token = encrypt(&mc, new_access_token); - sqlx::query!( - "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3", + let account_id = sqlx::query_scalar!( + "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3 RETURNING account", encrypted_token, workspace_id, - resource_path, + connection_path, + ) + .fetch_optional(db) + .await? + .flatten(); + + let Some(account_id) = account_id else { + return Ok(()); + }; + + // Even without a new refresh token, expires_at moves so the background refresh does not + // re-refresh immediately. + sqlx::query!( + "UPDATE account SET + refresh_token = COALESCE($1, refresh_token), + expires_at = now() + interval '1 hour', + refresh_error = NULL + WHERE workspace_id = $2 AND id = $3", + new_refresh_token, + workspace_id, + account_id, ) .execute(db) .await?; - if let Some(refresh_token) = new_refresh_token { - sqlx::query!( - "UPDATE account SET - refresh_token = $1, - expires_at = now() + interval '1 hour', - refresh_error = NULL - WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true", - refresh_token, - workspace_id, - service_name.as_str(), - ) - .execute(db) - .await?; - } else { - // Even without a new refresh token, update expires_at to prevent - // the background refresh from re-refreshing immediately - sqlx::query!( - "UPDATE account SET - expires_at = now() + interval '1 hour', - refresh_error = NULL - WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true", - workspace_id, - service_name.as_str(), - ) - .execute(db) - .await?; - } - Ok::<(), Error>(()) } .await; if let Err(e) = result { tracing::error!( - "Failed to update OAuth tokens for {} in workspace {}: {}", - service_name, + "Failed to update OAuth tokens of connection {} in workspace {}: {}", + connection_path, workspace_id, e ); @@ -1192,6 +1196,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> service_config: C, summary: Option<&str>, enabled: bool, + connection_path: &str, ) -> Result<()> { use windmill_common::auth::hash_token; @@ -1211,12 +1216,13 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> webhook_token_hash, service_config, summary, - enabled + enabled, + connection_path ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) ON CONFLICT (external_id, workspace_id, service_name) - DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW() + DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, connection_path = $10, error = NULL, updated_at = NOW() "#, external_id, workspace_id, @@ -1227,6 +1233,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> sqlx::types::Json(service_config) as _, summary, enabled, + connection_path, ) .execute(db) .await?; @@ -1424,7 +1431,8 @@ pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( created_at, updated_at, summary, - enabled + enabled, + connection_path FROM native_trigger WHERE @@ -1464,7 +1472,8 @@ pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = P created_at, updated_at, summary, - enabled + enabled, + connection_path FROM native_trigger WHERE @@ -1520,7 +1529,8 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> nt.created_at, nt.updated_at, nt.summary, - nt.enabled + nt.enabled, + nt.connection_path FROM native_trigger nt WHERE @@ -1699,7 +1709,6 @@ pub async fn store_workspace_integration( workspace_id: &str, service_name: ServiceName, oauth_data: serde_json::Value, - resource_path: Option<&str>, ) -> Result<()> { sqlx::query!( r#" @@ -1707,23 +1716,20 @@ pub async fn store_workspace_integration( workspace_id, service_name, oauth_data, - resource_path, created_by, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, now(), now() + $1, $2, $3, $4, now(), now() ) ON CONFLICT (workspace_id, service_name) DO UPDATE SET oauth_data = $3, - resource_path = $4, updated_at = now() "#, workspace_id, service_name as ServiceName, oauth_data, - resource_path, authed.username, ) .execute(&mut *tx) @@ -1746,6 +1752,85 @@ pub fn require_native_integration_use(authed: &ApiAuthed) -> Result<()> { Ok(()) } +#[derive(Debug, Serialize)] +pub struct Connection { + pub path: String, + /// Username of whoever connected the account, when the caller can read its resource. + pub owner: Option, +} + +/// The connections of a service the caller may use. `tx` must be a user transaction: using a +/// connection means acting as the account behind it, so it is gated on reading its token +/// variable, which the variable's row-level security decides. +pub async fn list_usable_connections( + tx: &mut PgConnection, + workspace_id: &str, + service_name: ServiceName, +) -> Result> { + let connections = sqlx::query_as!( + Connection, + r#"SELECT v.path, r.created_by AS "owner?" + FROM variable v + JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account + LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path + WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true + ORDER BY v.path"#, + workspace_id, + service_name.as_str(), + ) + .fetch_all(&mut *tx) + .await?; + Ok(connections) +} + +/// Pick the connection a new trigger acts through: the one asked for if the caller may use it, +/// otherwise the caller's only usable connection. `tx` must be a user transaction. +pub async fn resolve_usable_connection( + tx: &mut PgConnection, + workspace_id: &str, + service_name: ServiceName, + requested: Option<&str>, +) -> Result { + let usable = list_usable_connections(tx, workspace_id, service_name).await?; + match requested { + Some(path) if usable.iter().any(|c| c.path == path) => Ok(path.to_string()), + Some(path) => Err(Error::NotAuthorized(format!( + "No {service_name} connection you can use at {path}. Connect your own account or ask \ + for read access to that connection." + ))), + None => match usable.as_slice() { + [only] => Ok(only.path.clone()), + [] => Err(Error::BadRequest(format!( + "You have no {service_name} connection. Connect your account first." + ))), + _ => Err(Error::BadRequest(format!( + "You can use several {service_name} connections; pick one with connection_path." + ))), + }, + } +} + +#[derive(Debug, Deserialize)] +pub struct ConnectionQuery { + pub connection_path: Option, +} + +/// Gate for the pickers that browse the service while configuring a trigger: returns the +/// connection to browse through, which the caller must be allowed to use. +pub async fn picker_connection( + authed: &ApiAuthed, + user_db: UserDB, + workspace_id: &str, + service_name: ServiceName, + requested: Option<&str>, +) -> Result { + require_native_integration_use(authed)?; + let mut tx = user_db.begin(authed).await?; + let path = resolve_usable_connection(&mut tx, workspace_id, service_name, requested).await?; + tx.commit().await?; + Ok(path) +} + pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Postgres>>( db: E, workspace_id: &str, @@ -1758,7 +1843,6 @@ pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Post workspace_id, service_name AS "service_name!: ServiceName", oauth_data, - resource_path, created_at, updated_at, created_by diff --git a/backend/windmill-native-triggers/src/nextcloud/external.rs b/backend/windmill-native-triggers/src/nextcloud/external.rs index 763ad6fbb6..e662a981ee 100644 --- a/backend/windmill-native-triggers/src/nextcloud/external.rs +++ b/backend/windmill-native-triggers/src/nextcloud/external.rs @@ -131,6 +131,7 @@ impl External for NextCloud { &url, Method::POST, w_id, + &oauth_data.connection_path, db, Some(headers), Some(&full_nextcloud_payload), @@ -167,6 +168,7 @@ impl External for NextCloud { &url, Method::POST, w_id, + &oauth_data.connection_path, db, Some(headers), Some(&full_nextcloud_payload), @@ -214,7 +216,15 @@ impl External for NextCloud { headers.insert("OCS-APIRequest".to_string(), "true".to_string()); let ocs_response: OcsResponse = self - .http_client_request::<_, ()>(&url, Method::GET, w_id, db, Some(headers), None) + .http_client_request::<_, ()>( + &url, + Method::GET, + w_id, + &oauth_data.connection_path, + db, + Some(headers), + None, + ) .await?; Ok(Some(ocs_response.ocs.data)) @@ -239,7 +249,15 @@ impl External for NextCloud { // A webhook already removed in Nextcloud is the outcome this call wants, so only 404 is // swallowed; anything else would leave a live webhook behind while Windmill forgets it. let _: serde_json::Value = self - .http_client_request::<_, ()>(&url, Method::DELETE, w_id, db, Some(headers), None) + .http_client_request::<_, ()>( + &url, + Method::DELETE, + w_id, + &oauth_data.connection_path, + db, + Some(headers), + None, + ) .await .or_else(|e| match http_error_status(&e) { Some(StatusCode::NOT_FOUND) => Ok(serde_json::Value::Null), @@ -353,6 +371,7 @@ impl NextCloud { &url, Method::GET, w_id, + &oauth_data.connection_path, db, Some(headers), None, diff --git a/backend/windmill-native-triggers/src/nextcloud/mod.rs b/backend/windmill-native-triggers/src/nextcloud/mod.rs index fda8ad9f6e..7546211283 100644 --- a/backend/windmill-native-triggers/src/nextcloud/mod.rs +++ b/backend/windmill-native-triggers/src/nextcloud/mod.rs @@ -9,6 +9,7 @@ pub struct NextCloudOAuthData { pub access_token: String, pub refresh_token: Option, pub token_expires_at: Option>, + pub connection_path: String, } #[derive(Debug, Serialize, Deserialize)] diff --git a/backend/windmill-native-triggers/src/nextcloud/routes.rs b/backend/windmill-native-triggers/src/nextcloud/routes.rs index a1c679602c..b17dc6a709 100644 --- a/backend/windmill-native-triggers/src/nextcloud/routes.rs +++ b/backend/windmill-native-triggers/src/nextcloud/routes.rs @@ -1,8 +1,13 @@ use std::{collections::HashMap, sync::Arc}; -use axum::{extract::Path, routing::get, Extension, Json, Router}; +use axum::{ + extract::{Path, Query}, + routing::get, + Extension, Json, Router, +}; use http::Method; use windmill_common::{ + db::UserDB, error::{Error, JsonResult}, DB, }; @@ -12,16 +17,25 @@ use windmill_api_auth::ApiAuthed; use crate::{ get_workspace_integration, map_external_error, nextcloud::{NextCloudEventType, OcsResponse}, - require_native_integration_use, External, ServiceName, + picker_connection, ConnectionQuery, External, ServiceName, }; async fn list_available_events( authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, + Extension(user_db): Extension, Path(workspace_id): Path, + Query(query): Query, ) -> JsonResult> { - require_native_integration_use(&authed)?; + let connection_path = picker_connection( + &authed, + user_db, + &workspace_id, + ServiceName::Nextcloud, + query.connection_path.as_deref(), + ) + .await?; let integration = get_workspace_integration(&db, &workspace_id, ServiceName::Nextcloud).await?; let base_url = integration @@ -43,6 +57,7 @@ async fn list_available_events( &url, Method::GET, &workspace_id, + &connection_path, &db, Some(headers), None, diff --git a/backend/windmill-native-triggers/src/rename.rs b/backend/windmill-native-triggers/src/rename.rs index caae99028c..3c273bc57f 100644 --- a/backend/windmill-native-triggers/src/rename.rs +++ b/backend/windmill-native-triggers/src/rename.rs @@ -69,19 +69,8 @@ async fn reregister_service( moved: &[&MovedNativeTrigger], handler: T, ) { - let oauth_data: T::OAuthData = - match decrypt_oauth_data(db, w_id, T::SERVICE_NAME.integration_service()).await { - Ok(oauth_data) => oauth_data, - Err(e) => { - for trigger in moved { - record_failure::(db, w_id, &trigger.external_id, &e).await; - } - return; - } - }; - for trigger in moved { - if let Err(e) = reregister_one(db, authed, w_id, &handler, &oauth_data, trigger).await { + if let Err(e) = reregister_one(db, authed, w_id, &handler, trigger).await { record_failure::(db, w_id, &trigger.external_id, &e).await; } } @@ -99,7 +88,6 @@ async fn reregister_one( authed: &ApiAuthed, w_id: &str, handler: &T, - oauth_data: &T::OAuthData, moved: &MovedNativeTrigger, ) -> Result<()> { let external_id = moved.external_id.as_str(); @@ -136,8 +124,17 @@ async fn reregister_one( service_config, summary: trigger.summary.clone(), enabled: trigger.enabled, + connection_path: trigger.connection_path.clone(), }; + let oauth_data: T::OAuthData = decrypt_oauth_data( + db, + w_id, + T::SERVICE_NAME.integration_service(), + trigger.connection_path.as_deref(), + ) + .await?; + // The token is scoped to the runnable path and only its hash is kept, so pointing the webhook // at the new path means minting a replacement rather than reusing the old one. Commit it // before handing it out: a service may call back the moment it accepts the new URL. @@ -157,7 +154,7 @@ async fn reregister_one( let updated = handler .update( w_id, - oauth_data, + &oauth_data, external_id, &webhook_token, &data, diff --git a/backend/windmill-native-triggers/src/sync.rs b/backend/windmill-native-triggers/src/sync.rs index 4f75845429..074703f1b8 100644 --- a/backend/windmill-native-triggers/src/sync.rs +++ b/backend/windmill-native-triggers/src/sync.rs @@ -6,6 +6,9 @@ use serde::Serialize; use crate::ServiceName; +#[cfg(feature = "native_trigger")] +use itertools::Itertools; + #[cfg(feature = "native_trigger")] use crate::{ decrypt_oauth_data, list_native_triggers, update_native_trigger_error, @@ -238,38 +241,50 @@ pub async fn sync_workspace_triggers( let mut synced = Vec::new(); let mut errors = Vec::new(); - // Use the integration service for OAuth lookup (e.g., GoogleDrive/GoogleCalendar -> Google) - let integration_service = T::SERVICE_NAME.integration_service(); + // Maintenance runs per connection: each one's triggers are registered under its account, and + // Nextcloud reconciles against that account's own list of webhooks. + let by_connection = windmill_triggers + .into_iter() + .into_group_map_by(|t| t.connection_path.clone()); - let oauth_data = { - match decrypt_oauth_data(db, workspace_id, integration_service).await { + for (connection_path, triggers) in by_connection { + let oauth_data = match decrypt_oauth_data( + db, + workspace_id, + T::SERVICE_NAME.integration_service(), + connection_path.as_deref(), + ) + .await + { Ok(oauth_data) => oauth_data, Err(e) => { tracing::error!( - "Failed to get workspace integration OAuth data for {}: {}", + "Failed to get the OAuth data of connection {:?} in {}: {}", + connection_path, workspace_id, e ); errors.push(SyncError { - resource_path: format!("workspace:{}", workspace_id), - error_message: format!("Failed to get workspace integration OAuth data: {}", e), + resource_path: connection_path + .unwrap_or_else(|| format!("workspace:{}", workspace_id)), + error_message: format!("Failed to get connection OAuth data: {}", e), error_type: "oauth_error".to_string(), }); - return Ok((Vec::new(), errors)); + continue; } - } - }; + }; - handler - .maintain_triggers( - db, - workspace_id, - &windmill_triggers, - &oauth_data, - &mut synced, - &mut errors, - ) - .await; + handler + .maintain_triggers( + db, + workspace_id, + &triggers, + &oauth_data, + &mut synced, + &mut errors, + ) + .await; + } tracing::info!( "Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}", diff --git a/backend/windmill-native-triggers/src/workspace_integrations.rs b/backend/windmill-native-triggers/src/workspace_integrations.rs index 9bb8c6aa7a..d7223d57c4 100644 --- a/backend/windmill-native-triggers/src/workspace_integrations.rs +++ b/backend/windmill-native-triggers/src/workspace_integrations.rs @@ -2,7 +2,7 @@ use axum::Router; #[cfg(feature = "native_trigger")] use axum::{ - extract::Path, + extract::{Path, Query}, routing::{delete, get, post}, Extension, Json, }; @@ -30,14 +30,18 @@ use windmill_common::{ }; #[cfg(feature = "native_trigger")] -use windmill_api_auth::ApiAuthed; +use windmill_api_auth::{require_is_writer, ApiAuthed}; #[cfg(feature = "native_trigger")] use crate::{ - decrypt_oauth_data, delete_token_by_hash, delete_workspace_integration, nextcloud::OcsResponse, - resolve_endpoint, store_workspace_integration, ServiceName, + decrypt_oauth_data, delete_token_by_hash, delete_workspace_integration, + list_usable_connections, nextcloud::OcsResponse, require_native_integration_use, + resolve_endpoint, Connection, ServiceName, }; +#[cfg(feature = "native_trigger")] +use std::collections::HashMap; + #[cfg(feature = "native_trigger")] use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT}; @@ -57,24 +61,27 @@ type HmacSha256 = Hmac; const STATE_EXPIRATION_SECONDS: i64 = 600; // 10 minutes /// Generate a signed OAuth state that is cluster-safe. -/// The state contains: workspace_id, service_name, timestamp, and nonce. +/// The state contains: workspace_id, service_name, timestamp, nonce, and the path the connection +/// will be saved at, so the callback needs nothing from the page the provider redirects to. /// It's signed with HMAC-SHA256 using the workspace key. #[cfg(feature = "native_trigger")] async fn generate_signed_state( db: &DB, workspace_id: &str, service_name: ServiceName, + connection_path: &str, ) -> Result { use windmill_common::variables::get_workspace_key; let nonce = uuid::Uuid::new_v4().to_string(); let timestamp = chrono::Utc::now().timestamp(); let payload = format!( - "{}:{}:{}:{}", + "{}:{}:{}:{}:{}", workspace_id, service_name.as_str(), timestamp, - nonce + nonce, + URL_SAFE_NO_PAD.encode(connection_path) ); // Get workspace key for signing @@ -91,65 +98,58 @@ async fn generate_signed_state( Ok(format!("{}:{}", encoded_payload, encoded_signature)) } -/// Validate a signed OAuth state. -/// Returns true if the state is valid (correct signature and not expired). +/// Validate a signed OAuth state (correct signature, workspace and service, not expired) and +/// return the connection path it was issued for. #[cfg(feature = "native_trigger")] -async fn validate_signed_state(db: &DB, state: &str, workspace_id: &str) -> Result { +async fn validate_signed_state( + db: &DB, + state: &str, + workspace_id: &str, + service_name: ServiceName, +) -> Result { use windmill_common::variables::get_workspace_key; - let parts: Vec<&str> = state.split(':').collect(); - if parts.len() != 2 { - return Ok(false); + let invalid = || Error::BadRequest("Invalid or expired state parameter".to_string()); + + let (encoded_payload, encoded_signature) = state.split_once(':').ok_or_else(invalid)?; + + let payload = URL_SAFE_NO_PAD + .decode(encoded_payload) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(invalid)?; + + // Parse payload: workspace_id:service_name:timestamp:nonce:connection_path + let [state_workspace_id, state_service, timestamp, _nonce, encoded_path] = payload + .split(':') + .collect::>() + .try_into() + .map_err(|_| invalid())?; + + if state_workspace_id != workspace_id || state_service != service_name.as_str() { + return Err(invalid()); } - let encoded_payload = parts[0]; - let encoded_signature = parts[1]; - - // Decode payload - let payload_bytes = match URL_SAFE_NO_PAD.decode(encoded_payload) { - Ok(bytes) => bytes, - Err(_) => return Ok(false), - }; - let payload = match String::from_utf8(payload_bytes) { - Ok(s) => s, - Err(_) => return Ok(false), - }; - - // Parse payload: workspace_id:service_name:timestamp:nonce - let payload_parts: Vec<&str> = payload.split(':').collect(); - if payload_parts.len() != 4 { - return Ok(false); + let timestamp: i64 = timestamp.parse().map_err(|_| invalid())?; + if chrono::Utc::now().timestamp() - timestamp > STATE_EXPIRATION_SECONDS { + return Err(invalid()); } - let state_workspace_id = payload_parts[0]; - let timestamp: i64 = match payload_parts[2].parse() { - Ok(ts) => ts, - Err(_) => return Ok(false), - }; - - // Verify workspace_id matches - if state_workspace_id != workspace_id { - return Ok(false); - } - - // Check expiration - let now = chrono::Utc::now().timestamp(); - if now - timestamp > STATE_EXPIRATION_SECONDS { - return Ok(false); - } - - // Verify signature let key = get_workspace_key(workspace_id, db).await?; let mut mac = HmacSha256::new_from_slice(key.as_bytes()) .map_err(|e| Error::InternalErr(e.to_string()))?; mac.update(payload.as_bytes()); + let received_signature = URL_SAFE_NO_PAD + .decode(encoded_signature) + .map_err(|_| invalid())?; + mac.verify_slice(&received_signature) + .map_err(|_| invalid())?; - let received_signature = match URL_SAFE_NO_PAD.decode(encoded_signature) { - Ok(bytes) => bytes, - Err(_) => return Ok(false), - }; - - Ok(mac.verify_slice(&received_signature).is_ok()) + URL_SAFE_NO_PAD + .decode(encoded_path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(invalid) } #[cfg(feature = "native_trigger")] @@ -194,20 +194,65 @@ pub struct OAuthConfigResponse { pub redirect_uri: Option, } +/// The OAuth client a connection is made with: the workspace's own when an admin configured one, +/// otherwise the instance's when the instance admin shares it. The flag says which. +#[cfg(feature = "native_trigger")] +async fn resolve_oauth_client( + db: &DB, + workspace_id: &str, + service_name: ServiceName, +) -> Result<(WorkspaceOAuthConfig, bool)> { + let workspace_config = + get_workspace_oauth_config::(db, workspace_id, service_name) + .await + .ok() + .filter(|c| !c.instance_shared && !c.client_id.is_empty()); + match workspace_config { + Some(config) => Ok((config, false)), + None => Ok((get_instance_oauth_config(db, service_name).await?, true)), + } +} + +#[cfg(feature = "native_trigger")] +fn default_connection_path(authed: &ApiAuthed, service_name: ServiceName) -> String { + format!( + "u/{}/native_{}", + authed.username, + service_name.resource_type() + ) +} + #[cfg(feature = "native_trigger")] async fn generate_connect_url( authed: ApiAuthed, Extension(db): Extension, Path((workspace_id, service_name)): Path<(String, ServiceName)>, - Json(RedirectUri { redirect_uri }): Json, + Json(body): Json, ) -> JsonResult { - require_admin(authed.is_admin, &workspace_id)?; + require_native_integration_use(&authed)?; - let oauth_config = - get_workspace_oauth_config_as_oauth_config(&db, &workspace_id, service_name).await?; + let (oauth_config, is_instance_shared) = + resolve_oauth_client(&db, &workspace_id, service_name).await?; + + let connection_path = body + .resource_path + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| default_connection_path(&authed, service_name)); + + // A GitHub OAuth app only redirects under its one registered callback, and the instance app's + // is the resource connect callback, so its connections land on a page below that one. + let redirect_uri = if is_instance_shared && service_name == ServiceName::Github { + let origin = Url::parse(&body.redirect_uri) + .map_err(|e| Error::BadRequest(format!("Invalid redirect URI: {e}")))? + .origin() + .ascii_serialization(); + format!("{origin}/oauth/callback/github/native_trigger") + } else { + body.redirect_uri + }; // Generate a signed state that is cluster-safe - let state = generate_signed_state(&db, &workspace_id, service_name).await?; + let state = generate_signed_state(&db, &workspace_id, service_name, &connection_path).await?; let auth_url = build_authorization_url(&oauth_config, service_name, &state, &redirect_uri); Ok(Json(auth_url)) } @@ -287,14 +332,22 @@ async fn fetch_nextcloud_user_id(base_url: &str, access_token: &str) -> anyhow:: Ok(ocs.ocs.data.id) } -/// Delete all native triggers for a workspace+service, including remote webhook cleanup. +/// Delete the native triggers of a workspace+service — only those using `connection_path` 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) { +async fn delete_triggers_for_service( + db: &DB, + workspace_id: &str, + service_name: ServiceName, + connection_path: Option<&str>, +) { let triggers = sqlx::query!( - "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "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)", workspace_id, - service_name as ServiceName + service_name as ServiceName, + connection_path, ) .fetch_all(db) .await; @@ -311,27 +364,33 @@ async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: return; } - // For Nextcloud: try to delete webhooks on the remote instance (best-effort) - if service_name == ServiceName::Nextcloud { - if let Ok(oauth_data) = - decrypt_oauth_data::(db, workspace_id, service_name).await - { - for trigger in &triggers { + // Nextcloud and GitHub: try to delete the webhooks remotely (best-effort). + // Google: skip remote cleanup (watch channels expire naturally). + if service_name != ServiceName::Google { + let mut credentials: HashMap, Option> = HashMap::new(); + for trigger in &triggers { + if !credentials.contains_key(&trigger.connection_path) { + let oauth_data = decrypt_oauth_data::( + db, + workspace_id, + service_name, + trigger.connection_path.as_deref(), + ) + .await + .ok(); + credentials.insert(trigger.connection_path.clone(), oauth_data); + } + let Some(Some(oauth_data)) = credentials.get(&trigger.connection_path) else { + continue; + }; + if service_name == ServiceName::Nextcloud { try_delete_nextcloud_webhook( &oauth_data.base_url, &oauth_data.access_token, &trigger.external_id, ) .await; - } - } - } - // For GitHub: try to delete webhooks on GitHub (best-effort) - if service_name == ServiceName::Github { - if let Ok(oauth_data) = - decrypt_oauth_data::(db, workspace_id, service_name).await - { - for trigger in &triggers { + } else { try_delete_github_webhook( db, workspace_id, @@ -342,15 +401,17 @@ async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: } } } - // For Google: skip remote cleanup (watch channels expire naturally) // Revoke the tokens the deleted rows actually named, not the ones listed above: a // re-registration running concurrently mints a replacement, and leaving that alive would let a // disconnected integration keep starting jobs. let deleted_token_hashes = sqlx::query_scalar!( - "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2 RETURNING webhook_token_hash", + "DELETE FROM native_trigger + WHERE workspace_id = $1 AND service_name = $2 AND ($3::text IS NULL OR connection_path = $3) + RETURNING webhook_token_hash", workspace_id, - service_name as ServiceName + service_name as ServiceName, + connection_path, ) .fetch_all(db) .await @@ -378,13 +439,12 @@ async fn delete_integration( ) -> JsonResult { require_admin(authed.is_admin, &workspace_id)?; - // Delete triggers first (needs OAuth data that cleanup_oauth_resource will remove) - delete_triggers_for_service(&db, &workspace_id, service_name).await; + // Delete triggers first: removing their registrations needs the connections cleaned up below + delete_triggers_for_service(&db, &workspace_id, service_name, None).await; let mut tx = user_db.begin(&authed).await?; - // Clean up account+variable+resource - cleanup_oauth_resource(&mut *tx, &workspace_id, service_name).await; + cleanup_service_connections(&mut *tx, &workspace_id, service_name).await?; let deleted = delete_workspace_integration(&mut *tx, &workspace_id, service_name).await?; @@ -419,7 +479,6 @@ async fn delete_integration( struct WorkspaceIntegrations { service_name: ServiceName, oauth_data: Option>, - resource_path: Option, } #[cfg(feature = "native_trigger")] @@ -436,8 +495,7 @@ async fn list_integrations( r#" SELECT oauth_data as "oauth_data: sqlx::types::Json", - service_name as "service_name!: ServiceName", - resource_path + service_name as "service_name!: ServiceName" FROM workspace_integrations WHERE @@ -450,23 +508,14 @@ async fn list_integrations( let key_value = integrations .into_iter() - .map(|integration| { - ( - integration.service_name, - (integration.oauth_data, integration.resource_path), - ) - }) + .map(|integration| (integration.service_name, integration.oauth_data)) .collect::>(); use strum::IntoEnumIterator; let integrations = ServiceName::iter() .map(|service_name| { - let (oauth_data, resource_path) = key_value - .get(&service_name) - .cloned() - .map(|(od, rp)| (od, rp)) - .unwrap_or((None, None)); - WorkspaceIntegrations { service_name, oauth_data, resource_path } + let oauth_data = key_value.get(&service_name).cloned().flatten(); + WorkspaceIntegrations { service_name, oauth_data } }) .collect::>(); @@ -475,12 +524,18 @@ async fn list_integrations( Ok(Json(integrations)) } +/// Whether members can connect an account for this service: the workspace has an OAuth client, +/// or the instance shares one. #[cfg(feature = "native_trigger")] async fn integration_exist( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path((workspace_id, service_name)): Path<(String, ServiceName)>, ) -> JsonResult { + if is_instance_sharing_enabled(&db, service_name).await? { + return Ok(Json(true)); + } let mut tx = user_db.begin(&authed).await?; let exists = sqlx::query_scalar!( r#" @@ -504,8 +559,10 @@ async fn integration_exist( #[cfg(feature = "native_trigger")] #[derive(Debug, Deserialize)] -struct RedirectUri { +struct ConnectUrlBody { redirect_uri: String, + /// Where to save the connection; defaults to `u//native_`. + resource_path: Option, } #[cfg(feature = "native_trigger")] @@ -514,7 +571,6 @@ struct OAuthCallbackBody { redirect_uri: String, code: String, state: String, - resource_path: Option, } #[cfg(feature = "native_trigger")] @@ -531,60 +587,26 @@ async fn oauth_callback( Path((workspace_id, service_name)): Path<(String, ServiceName)>, Json(body): Json, ) -> JsonResult { - require_admin(authed.is_admin, &workspace_id)?; + require_native_integration_use(&authed)?; - let state_was_valid = validate_signed_state(&db, &body.state, &workspace_id).await?; + let resource_path = + validate_signed_state(&db, &body.state, &workspace_id, service_name).await?; - if !state_was_valid { - return Err(Error::BadRequest( - "Invalid or expired state parameter".to_string(), - )); - } - - // Check if this integration uses instance-shared credentials - let existing_oauth_data = sqlx::query_scalar!( - r#"SELECT oauth_data FROM workspace_integrations - WHERE workspace_id = $1 AND service_name = $2"#, - workspace_id, - service_name as ServiceName - ) - .fetch_optional(&db) - .await? - .flatten(); - - let is_instance_shared = existing_oauth_data - .as_ref() - .and_then(|v| v.get("instance_shared")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let oauth_config = if is_instance_shared { - get_instance_oauth_config(&db, service_name).await? - } else { - get_workspace_oauth_config::(&db, &workspace_id, service_name).await? - }; + let (oauth_config, is_instance_shared) = + resolve_oauth_client(&db, &workspace_id, service_name).await?; let token_response = exchange_code_for_token(&oauth_config, service_name, &body.code, &body.redirect_uri) .await?; - let resource_path = body - .resource_path - .filter(|p| !p.is_empty()) - .unwrap_or_else(|| { - format!( - "u/{}/native_{}", - authed.username, - service_name.resource_type() - ) - }); - let expires_in = token_response.expires_in.unwrap_or(3600); + // A user transaction, so the variable and resource policies decide whether the caller may + // write a connection at this path. let mut tx = user_db.begin(&authed).await?; - // Clean up any previous account+variable+resource for this integration - cleanup_oauth_resource(&mut *tx, &workspace_id, service_name).await; + // Reconnecting at a path replaces the connection there; triggers using it keep working. + cleanup_connection(&mut *tx, &workspace_id, &resource_path).await?; // 1. Create account record for token refresh let account_id = sqlx::query_scalar!( @@ -659,25 +681,22 @@ async fn oauth_callback( .await .map_err(|e| Error::InternalErr(format!("Failed to create resource: {}", e)))?; - // 4. Store config + resource_path in workspace_integrations (no tokens). - // For instance-shared integrations, store the flag instead of credentials. - let stored_data = if is_instance_shared { - json!({ - "instance_shared": true, - "base_url": "", - }) - } else { - to_value(&oauth_config).unwrap() - }; - store_workspace_integration( - &mut *tx, - &authed, - &workspace_id, - service_name, - stored_data, - Some(&resource_path), - ) - .await?; + // 4. The first connection made with the instance's client records that in the workspace. + // Token refresh reads the client from this row, so it must exist for every connection. + if is_instance_shared { + sqlx::query!( + "INSERT INTO workspace_integrations (workspace_id, service_name, oauth_data, created_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (workspace_id, service_name) DO UPDATE SET oauth_data = EXCLUDED.oauth_data + WHERE workspace_integrations.oauth_data IS NULL", + workspace_id, + service_name as ServiceName, + json!({ "instance_shared": true, "base_url": "" }), + authed.username, + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -685,17 +704,14 @@ async fn oauth_callback( &format!("workspace_integrations.{}.connect", service_name), ActionKind::Create, &workspace_id, - Some(&format!("Connected {} integration via OAuth", service_name)), + Some(&resource_path), None, ) .await?; tx.commit().await?; - Ok(Json(format!( - "{} integration connected successfully via OAuth", - service_name - ))) + Ok(Json(resource_path)) } /// Token response from OAuth token exchange @@ -807,7 +823,6 @@ pub async fn create_workspace_integration( &workspace_id, service_name, to_value(oauth_data).unwrap(), - None, ) .await?; @@ -816,16 +831,6 @@ pub async fn create_workspace_integration( Ok(()) } -#[cfg(feature = "native_trigger")] -#[inline] -async fn get_workspace_oauth_config_as_oauth_config( - db: &DB, - workspace_id: &str, - service_name: ServiceName, -) -> Result { - get_workspace_oauth_config::(db, workspace_id, service_name).await -} - #[cfg(feature = "native_trigger")] fn build_authorization_url( config: &WorkspaceOAuthConfig, @@ -856,73 +861,82 @@ fn build_authorization_url( format!("{}?{}", base_auth_url, query_string) } +/// Remove the connection at `path`: its variable, the account behind it, and its resource. #[cfg(feature = "native_trigger")] -pub async fn cleanup_oauth_resource( +pub async fn cleanup_connection( + tx: &mut sqlx::PgConnection, + workspace_id: &str, + path: &str, +) -> Result<()> { + let account_id = sqlx::query_scalar!( + "DELETE FROM variable WHERE workspace_id = $1 AND path = $2 RETURNING account", + workspace_id, + path, + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + + if let Some(account_id) = account_id { + sqlx::query!( + "DELETE FROM account WHERE workspace_id = $1 AND id = $2 AND is_workspace_integration = true", + workspace_id, + account_id, + ) + .execute(&mut *tx) + .await?; + } + + sqlx::query!( + "DELETE FROM resource WHERE workspace_id = $1 AND path = $2", + workspace_id, + path, + ) + .execute(&mut *tx) + .await?; + + Ok(()) +} + +/// Remove every connection of a service in the workspace. +#[cfg(feature = "native_trigger")] +pub async fn cleanup_service_connections( tx: &mut sqlx::PgConnection, workspace_id: &str, service_name: ServiceName, -) { - // Look up the stored resource_path from workspace_integrations - let stored_resource_path: Option = sqlx::query_scalar!( - r#"SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2"#, - workspace_id, - service_name as ServiceName, - ) - .fetch_optional(&mut *tx) - .await - .ok() - .flatten() - .flatten(); - - // Find and delete any existing account+variable+resource for this integration - let account_ids: Vec = sqlx::query_scalar!( +) -> Result<()> { + let account_ids = sqlx::query_scalar!( "DELETE FROM account WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true RETURNING id", workspace_id, service_name.as_str(), ) .fetch_all(&mut *tx) - .await - .unwrap_or_default(); + .await?; - if !account_ids.is_empty() { - // Delete variables linked to these accounts - let _ = sqlx::query!( - "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2)", - workspace_id, - &account_ids, - ) - .execute(&mut *tx) - .await; - } + let paths = sqlx::query_scalar!( + "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2) RETURNING path", + workspace_id, + &account_ids, + ) + .fetch_all(&mut *tx) + .await?; - // Delete resource by exact stored path, or fall back to legacy pattern - let resource_type = service_name.resource_type(); - if let Some(ref path) = stored_resource_path { - let _ = sqlx::query!( - "DELETE FROM resource WHERE workspace_id = $1 AND path = $2", - workspace_id, - path, - ) - .execute(&mut *tx) - .await; - } else { - // Legacy fallback for integrations created before user-chosen paths - let _ = sqlx::query!( - "DELETE FROM resource WHERE workspace_id = $1 AND resource_type = $2 AND path LIKE 'u/%/native_%'", - workspace_id, - resource_type, - ) - .execute(&mut *tx) - .await; - } + sqlx::query!( + "DELETE FROM resource WHERE workspace_id = $1 AND path = ANY($2)", + workspace_id, + &paths, + ) + .execute(&mut *tx) + .await?; + + Ok(()) } /// Check if the instance admin has enabled sharing of OAuth credentials for a given service. -/// Currently only supported for Google (gworkspace). #[cfg(feature = "native_trigger")] async fn is_instance_sharing_enabled(db: &DB, service_name: ServiceName) -> Result { - // Only Google supports instance sharing for now - if service_name != ServiceName::Google { + // Each Nextcloud client belongs to one Nextcloud server, which is a workspace choice. + if service_name == ServiceName::Nextcloud { return Ok(false); } @@ -931,7 +945,7 @@ async fn is_instance_sharing_enabled(db: &DB, service_name: ServiceName) -> Resu None => return Ok(false), }; - let key = service_name.resource_type(); // "gworkspace" + let key = service_name.resource_type(); let entry = match oauths_value.get(key) { Some(v) => v, None => return Ok(false), @@ -986,36 +1000,73 @@ async fn check_instance_sharing_available( } #[cfg(feature = "native_trigger")] -async fn generate_instance_connect_url( +async fn list_connections( + authed: ApiAuthed, + Extension(user_db): Extension, + Path((workspace_id, service_name)): Path<(String, ServiceName)>, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + let connections = list_usable_connections(&mut tx, &workspace_id, service_name).await?; + tx.commit().await?; + Ok(Json(connections)) +} + +#[cfg(feature = "native_trigger")] +#[derive(Debug, Deserialize)] +struct ConnectionPathQuery { + path: String, +} + +/// Disconnect one account: its triggers are deleted with it, since nothing else can act on +/// their registrations. +#[cfg(feature = "native_trigger")] +async fn delete_connection( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Path((workspace_id, service_name)): Path<(String, ServiceName)>, - Json(RedirectUri { redirect_uri }): Json, + Query(ConnectionPathQuery { path }): Query, ) -> JsonResult { - require_admin(authed.is_admin, &workspace_id)?; - - let instance_config = get_instance_oauth_config(&db, service_name).await?; - - // Store a marker in workspace_integrations — NOT the actual credentials. - // The callback and token refresh will read credentials from global settings - // when they see instance_shared=true. - let mut tx = user_db.begin(&authed).await?; - crate::store_workspace_integration( - &mut tx, + require_is_writer( &authed, + &path, &workspace_id, - service_name, - json!({ "instance_shared": true, "base_url": "" }), + db.clone(), + "SELECT extra_perms FROM variable WHERE path = $1 AND workspace_id = $2", + "variable", + ) + .await?; + + let mut tx = user_db.begin(&authed).await?; + if !list_usable_connections(&mut tx, &workspace_id, service_name) + .await? + .iter() + .any(|c| c.path == path) + { + return Err(Error::NotFound(format!( + "No {service_name} connection at {path}" + ))); + } + + // Before the connection goes: removing the registrations needs its token. + delete_triggers_for_service(&db, &workspace_id, service_name, Some(&path)).await; + + cleanup_connection(&mut *tx, &workspace_id, &path).await?; + + audit_log( + &mut *tx, + &authed, + &format!("workspace_integrations.{}.disconnect", service_name), + ActionKind::Delete, + &workspace_id, + Some(&path), None, ) .await?; + tx.commit().await?; - // Generate signed state and build authorization URL - let state = generate_signed_state(&db, &workspace_id, service_name).await?; - let auth_url = build_authorization_url(&instance_config, service_name, &state, &redirect_uri); - Ok(Json(auth_url)) + Ok(Json(format!("Disconnected {path}"))) } #[cfg(feature = "native_trigger")] @@ -1032,9 +1083,10 @@ pub fn workspaced_service() -> Router { "/{service_name}/instance_sharing_available", get(check_instance_sharing_available), ) + .route("/{service_name}/connections", get(list_connections)) .route( - "/{service_name}/generate_instance_connect_url", - post(generate_instance_connect_url), + "/{service_name}/connections/delete", + delete(delete_connection), ) .route("/{service_name}/delete", delete(delete_integration)) .route("/{service_name}/callback", post(oauth_callback)); diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index 4e6b0eeb16..b46897b9b5 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -281,6 +281,8 @@ export async function pushNativeTrigger( is_flow: localTrigger.is_flow, service_config: localTrigger.service_config, summary: localTrigger.summary, + // Used on create only; an update keeps the connection the webhook was registered under. + connection_path: localTrigger.connection_path, }; if (remoteTrigger) { diff --git a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte new file mode 100644 index 0000000000..42f3196c5d --- /dev/null +++ b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte @@ -0,0 +1,110 @@ + + +
+
+