diff --git a/.agents/skills/native-trigger/SKILL.md b/.agents/skills/native-trigger/SKILL.md index ae38f70b32..a4ff8e121a 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,47 @@ 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`. The exception is the **workspace connection**: an admin +may share one connection per service (`workspace_integrations.resource_path`, set by +`POST .../workspace_connection`), and every member may then use it without reading its token. +Renames carry the marker along; disconnecting clears it. 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 +330,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 +362,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 +380,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 +393,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 +768,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-00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9.json b/backend/.sqlx/query-00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9.json new file mode 100644 index 0000000000..900023094d --- /dev/null +++ b/backend/.sqlx/query-00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE native_trigger SET connection_path = $1 WHERE workspace_id = $2 AND connection_path = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9" +} 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-1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb.json b/backend/.sqlx/query-1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb.json new file mode 100644 index 0000000000..7f1ebcf9dc --- /dev/null +++ b/backend/.sqlx/query-1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 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 AND a.client = $3\n AND a.is_workspace_integration = true)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb" +} diff --git a/backend/.sqlx/query-1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409.json b/backend/.sqlx/query-1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409.json new file mode 100644 index 0000000000..963ca738f2 --- /dev/null +++ b/backend/.sqlx/query-1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.path, r.created_by AS \"owner?\", true AS \"workspace!\"\n FROM workspace_integrations wi\n JOIN variable v ON v.workspace_id = wi.workspace_id AND v.path = wi.resource_path\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 wi.workspace_id = $1 AND wi.service_name = $2\n AND a.client = $3 AND a.is_workspace_integration = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "owner?", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "workspace!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409" +} 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-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-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json b/backend/.sqlx/query-4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10.json similarity index 68% rename from backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json rename to backend/.sqlx/query-4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10.json index 45605c04dd..b07b2ae6b5 100644 --- a/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json +++ b/backend/.sqlx/query-4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10.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, script_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3", "describe": { "columns": [ { @@ -10,7 +10,7 @@ }, { "ordinal": 1, - "name": "webhook_token_hash", + "name": "script_path", "type_info": "Varchar" } ], @@ -28,7 +28,8 @@ ] } } - } + }, + "Text" ] }, "nullable": [ @@ -36,5 +37,5 @@ false ] }, - "hash": "6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5" + "hash": "4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10" } 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-51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635.json b/backend/.sqlx/query-51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635.json new file mode 100644 index 0000000000..e6194747b2 --- /dev/null +++ b/backend/.sqlx/query-51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM account WHERE workspace_id = $1 AND id = $2 AND is_workspace_integration = true\n AND NOT EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1 AND account = $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635" +} diff --git a/backend/.sqlx/query-5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d.json b/backend/.sqlx/query-5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d.json new file mode 100644 index 0000000000..3c3870611e --- /dev/null +++ b/backend/.sqlx/query-5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d.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 NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Jsonb", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d" +} diff --git a/backend/.sqlx/query-59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9.json b/backend/.sqlx/query-59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9.json new file mode 100644 index 0000000000..802c83dd7f --- /dev/null +++ b/backend/.sqlx/query-59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL\n OR (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[])))\n RETURNING webhook_token_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "webhook_token_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9" +} diff --git a/backend/.sqlx/query-5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce.json b/backend/.sqlx/query-5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce.json new file mode 100644 index 0000000000..113d1d6ce0 --- /dev/null +++ b/backend/.sqlx/query-5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n (SELECT created_by FROM resource WHERE workspace_id = $1 AND path = $2) AS \"owner?\",\n EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2)\n OR EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2) AS \"taken!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "owner?", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "taken!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce" +} 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-649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae.json b/backend/.sqlx/query-649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae.json new file mode 100644 index 0000000000..7113138dd7 --- /dev/null +++ b/backend/.sqlx/query-649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_integrations SET resource_path = NULL\n WHERE workspace_id = $1 AND service_name = $2 AND resource_path = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae" +} diff --git a/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json b/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json new file mode 100644 index 0000000000..0a39db6822 --- /dev/null +++ b/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4" +} 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-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json b/backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json new file mode 100644 index 0000000000..d74d5e6ba3 --- /dev/null +++ b/backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.value, a.id AS account_id, 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": "account_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "refresh_token", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18" +} diff --git a/backend/.sqlx/query-74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b.json b/backend/.sqlx/query-74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b.json new file mode 100644 index 0000000000..7440566057 --- /dev/null +++ b/backend/.sqlx/query-74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b" +} diff --git a/backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json b/backend/.sqlx/query-78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f.json similarity index 64% rename from backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json rename to backend/.sqlx/query-78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f.json index 25a4d5b723..a8e33b105a 100644 --- a/backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json +++ b/backend/.sqlx/query-78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2", + "query": "SELECT EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = $2)", "describe": { "columns": [ { "ordinal": 0, - "name": "resource_path", - "type_info": "Text" + "name": "exists", + "type_info": "Bool" } ], "parameters": { @@ -27,8 +27,8 @@ ] }, "nullable": [ - true + null ] }, - "hash": "993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e" + "hash": "78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f" } diff --git a/backend/.sqlx/query-79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e.json b/backend/.sqlx/query-79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e.json new file mode 100644 index 0000000000..158f49102d --- /dev/null +++ b/backend/.sqlx/query-79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.path, r.created_by AS \"owner?\", false AS \"workspace!\"\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" + }, + { + "ordinal": 2, + "name": "workspace!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e" +} 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-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-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-bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727.json b/backend/.sqlx/query-bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727.json new file mode 100644 index 0000000000..381f5ba491 --- /dev/null +++ b/backend/.sqlx/query-bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_integrations SET resource_path = $3, updated_at = now()\n WHERE workspace_id = $1 AND service_name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727" +} 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-c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee.json b/backend/.sqlx/query-c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee.json new file mode 100644 index 0000000000..f851507f4e --- /dev/null +++ b/backend/.sqlx/query-c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND account = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee" +} 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-d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5.json b/backend/.sqlx/query-d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5.json new file mode 100644 index 0000000000..3f3a3f20e4 --- /dev/null +++ b/backend/.sqlx/query-d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.path 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 a.client = $2 AND a.is_workspace_integration = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5" +} 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-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json b/backend/.sqlx/query-e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675.json similarity index 68% rename from backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json rename to backend/.sqlx/query-e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675.json index ebb5cee72c..99ec12fd85 100644 --- a/backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json +++ b/backend/.sqlx/query-e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675.json @@ -1,11 +1,11 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2 RETURNING webhook_token_hash", + "query": "SELECT script_path FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "webhook_token_hash", + "name": "script_path", "type_info": "Varchar" } ], @@ -30,5 +30,5 @@ false ] }, - "hash": "b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a" + "hash": "e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675" } 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/.sqlx/query-f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade.json b/backend/.sqlx/query-f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade.json new file mode 100644 index 0000000000..da21731ae1 --- /dev/null +++ b/backend/.sqlx/query-f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT external_id, connection_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL\n OR (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[])))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "external_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "connection_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade" +} diff --git a/backend/migrations/20261022125411_native_trigger_connection.down.sql b/backend/migrations/20261022125411_native_trigger_connection.down.sql new file mode 100644 index 0000000000..3951ab2a64 --- /dev/null +++ b/backend/migrations/20261022125411_native_trigger_connection.down.sql @@ -0,0 +1 @@ +ALTER TABLE native_trigger DROP COLUMN IF EXISTS connection_path; diff --git a/backend/migrations/20261022125411_native_trigger_connection.up.sql b/backend/migrations/20261022125411_native_trigger_connection.up.sql new file mode 100644 index 0000000000..3b235a8d4d --- /dev/null +++ b/backend/migrations/20261022125411_native_trigger_connection.up.sql @@ -0,0 +1,24 @@ +-- 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); + +-- Saving the OAuth app, or starting a connect with the instance app, used to clear +-- resource_path while the connection itself stayed. A service then had at most one connection, +-- so when exactly one is found it is the one its triggers used. +UPDATE workspace_integrations wi +SET resource_path = ( + SELECT CASE WHEN count(*) = 1 THEN min(v.path) END + FROM variable v + JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account + WHERE v.workspace_id = wi.workspace_id + AND a.client = wi.service_name::text + AND a.is_workspace_integration = true +) +WHERE wi.resource_path IS NULL; + +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 26c59cf205..285d560588 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -14,6 +14,7 @@ use sqlx::{Pool, Postgres}; use axum::http::StatusCode; use windmill_api_auth::{ApiAuthed, ScopePathFilter}; use windmill_common::{ + db::UserDB, error::Error, variables::{build_crypt, encrypt}, }; @@ -22,12 +23,13 @@ use windmill_native_triggers::{ get_workspace_integration, github::GitHub, google::{parse_stop_channel_params, should_renew_channel}, - grant_refused, http_error_status, list_native_triggers, map_external_error, - native_trigger_is_enabled, + grant_refused, http_error_status, list_native_triggers, list_usable_connections, + map_external_error, 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, + require_native_integration_use, resolve_usable_connection, set_native_trigger_enabled, + store_native_trigger, store_workspace_integration, update_native_trigger, + update_oauth_token_resource, External, ExternalReadFailure, HttpRequestError, + NativeTriggerConfig, OAuthConfig, ServiceName, }; // ============================================================================ @@ -133,7 +135,6 @@ async fn setup_oauth_integration( "test-workspace", service_name, oauth_data, - Some(resource_path), ) .await?; tx.commit().await?; @@ -149,73 +150,125 @@ 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"; + let mut account_a = 0; + for (path, token) in [(path_a, "a"), (path_b, "b")] { + let account = setup_oauth_integration( + &db, + ServiceName::Nextcloud, + path, + &format!("access-{token}"), + &format!("refresh-{token}"), + None, + ) + .await?; + if path == path_a { + account_a = account; + } + } + + 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, + account_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"); + .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")); - let res_count: i64 = sqlx::query_scalar!( - "SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1", - path_a, + Ok(()) +} + +/// A member who cannot read the admin's connection may still use it once it is shared as the +/// workspace connection. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_workspace_connection_usable_by_members(db: Pool) -> anyhow::Result<()> { + let path = "u/test-user/native_nextcloud"; + setup_oauth_integration(&db, ServiceName::Nextcloud, path, "a", "r", None).await?; + let member = ApiAuthed { + email: "test2@windmill.dev".to_string(), + username: "test-user-2".to_string(), + is_admin: false, + ..test_authed() + }; + let usable_paths = || async { + let mut tx = UserDB::new(db.clone()).begin(&member).await?; + let connections = + list_usable_connections(&db, &mut tx, "test-workspace", ServiceName::Nextcloud).await?; + tx.commit().await?; + anyhow::Ok( + connections + .into_iter() + .map(|c| (c.path, c.workspace)) + .collect::>(), + ) + }; + + assert!(usable_paths().await?.is_empty()); + + sqlx::query( + "UPDATE workspace_integrations SET resource_path = $1 + WHERE workspace_id = 'test-workspace' AND service_name = 'nextcloud'", ) - .fetch_one(&db) - .await? - .unwrap_or(0); - assert_eq!(res_count, 0, "resource at old path should be deleted"); + .bind(path) + .execute(&db) + .await?; + assert_eq!(usable_paths().await?, vec![(path.to_string(), true)]); - // 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")); + // Clients and specs that predate connections name none: they keep getting the shared + // account once the member also has their own. + let own = "u/test-user-2/native_nextcloud"; + setup_oauth_integration(&db, ServiceName::Nextcloud, own, "b", "s", None).await?; + let mut tx = UserDB::new(db.clone()).begin(&member).await?; + let default = + resolve_usable_connection(&db, &mut tx, "test-workspace", ServiceName::Nextcloud, None) + .await?; + tx.commit().await?; + assert_eq!(usable_paths().await?.len(), 2); + assert_eq!(default, path); Ok(()) } @@ -237,8 +290,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 +340,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 +369,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 +398,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")); @@ -491,6 +564,7 @@ async fn test_delete_integration_full_cascade(db: Pool) -> anyhow::Res json!({"triggerType": "drive"}), None, true, + "u/test-user/native_conn", ) .await?; @@ -501,12 +575,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 @@ -582,17 +656,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 @@ -640,6 +715,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. @@ -657,6 +733,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?; @@ -729,6 +806,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?; } @@ -834,7 +912,7 @@ fn test_provider_rejection_is_readable_and_not_internal() { "the envelope should not reach the user: {message}" ); assert!( - message.contains("Workspace settings > Integrations"), + message.contains("the workspace's shared account"), "the hint should say what to do: {message}" ); } @@ -964,6 +1042,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?; @@ -1013,6 +1092,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool) -> anyhow::Resul json!({}), None, false, + "u/test-user/native_conn", ) .await?; assert!( @@ -1030,6 +1110,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 0118184af1..ddc7b70832 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -21102,12 +21102,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 @@ -21137,10 +21147,59 @@ paths: schema: type: boolean - /w/{workspace}/native_triggers/integrations/{service_name}/generate_instance_connect_url: + /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: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + responses: + "200": + 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: + type: string + + /w/{workspace}/native_triggers/integrations/{service_name}/workspace_connection: post: - summary: generate connect url using instance-level credentials - operationId: generateInstanceConnectUrl + summary: share a connection of this service with every member of the workspace (admin only), or stop sharing + operationId: setNativeTriggerWorkspaceConnection tags: - workspace_integration parameters: @@ -21151,15 +21210,19 @@ paths: schema: $ref: "#/components/schemas/NativeServiceName" requestBody: - description: redirect_uri required: true content: application/json: schema: - $ref: "#/components/schemas/RedirectUri" + type: object + properties: + path: + type: string + nullable: true + description: The connection to share, or null to stop sharing responses: "200": - description: authorization URL using instance credentials + description: workspace connection updated content: application/json: schema: @@ -21213,17 +21276,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 @@ -21498,6 +21559,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 @@ -21520,6 +21587,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 @@ -21542,6 +21615,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 @@ -21583,6 +21662,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 @@ -21605,6 +21690,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 @@ -39757,6 +39848,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 @@ -39799,6 +39894,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 @@ -39830,10 +39929,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 @@ -39879,14 +39974,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 @@ -39911,11 +39998,36 @@ 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 + workspace: + type: boolean + description: Whether this is the workspace connection, which every member may use + required: + - path + - workspace + 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 cc77f2bdbd..defaa05234 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -1209,6 +1209,11 @@ async fn offboard_user_from_workspace<'c>( // They are NOT done here for reassign-only (delete_user=false) since the user stays. // ---- Related path tables ---- + sqlx::query!( + 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?; + 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"#, &new_prefix, username, w_id diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 2bde7aab53..8c742b6d64 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -671,6 +671,15 @@ async fn update_username_in_workpsace<'c>( ).execute(&mut **tx) .await?; + sqlx::query!( + 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 + ) + .execute(&mut **tx) + .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"#, new_username, diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index ddbfb03dbf..ce33a8aefe 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -948,6 +948,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), } } @@ -956,6 +957,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..6e34154df3 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 { @@ -295,7 +334,7 @@ impl External for GitHub { fn error_hint(&self, status: StatusCode) -> Option<&'static str> { match status { StatusCode::UNAUTHORIZED => { - Some("reconnect the GitHub integration from Workspace settings > Integrations.") + Some("reconnect the GitHub account this uses with Reconnect next to it, or ask its owner or an admin to.") } StatusCode::FORBIDDEN => Some( "check that the OAuth app is authorized for the organization and that the \ @@ -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 0b56a132a2..8a20d72693 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,19 @@ 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(&db, &workspace_id, &authed).await?; - get_workspace_integration(&db, &workspace_id, ServiceName::Github).await?; + let connection_path = picker_connection( + &authed, + &db, + user_db, + &workspace_id, + ServiceName::Github, + query.connection_path.as_deref(), + ) + .await?; let mut all_entries = Vec::new(); @@ -35,7 +45,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..a456a0a0bb 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 { @@ -203,7 +227,7 @@ impl External for Google { fn error_hint(&self, status: http::StatusCode) -> Option<&'static str> { match status { http::StatusCode::UNAUTHORIZED => { - Some("reconnect the Google integration from Workspace settings > Integrations.") + Some("reconnect the Google account this uses with Reconnect next to it, or ask its owner or an admin to.") } http::StatusCode::FORBIDDEN => Some( "the connected Google account is missing access to this resource or the scope the \ @@ -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 40098142bf..dec2010ab2 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,26 @@ 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(&db, &workspace_id, &authed).await?; - get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; + let connection_path = picker_connection( + &authed, + &db, + user_db, + &workspace_id, + ServiceName::Google, + query.connection_path.as_deref(), + ) + .await?; let url = format!( "{}/users/me/calendarList", @@ -102,7 +111,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 +140,19 @@ 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(&db, &workspace_id, &authed).await?; - get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; + let connection_path = picker_connection( + &authed, + &db, + 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 +181,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 +232,19 @@ 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(&db, &workspace_id, &authed).await?; - get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; + let connection_path = picker_connection( + &authed, + &db, + user_db, + &workspace_id, + ServiceName::Google, + query.connection_path.as_deref(), + ) + .await?; let url = format!( "{}/drives?pageSize=100&fields=drives(id,name)", @@ -210,7 +252,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..c920c48195 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -1,8 +1,9 @@ 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, + get_native_trigger, list_native_triggers, list_usable_connections, lock::TriggerLock, + lock_connection, map_external_error, 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, @@ -187,9 +188,21 @@ 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( + &db, + &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( @@ -232,6 +245,23 @@ async fn create_native_trigger( webhook_token, }; + lock_connection(&mut tx, &workspace_id, &connection_path).await?; + // Through `db`: a member using the workspace connection cannot read its variable. + let still_connected = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)", + workspace_id, + connection_path, + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !still_connected { + return Err(Error::BadRequest(format!( + "{connection_path} was disconnected while this trigger was being created. Its \ + registration on {service_name} may remain; connect an account and create it again." + ))); + } + store_native_trigger( &mut *tx, &workspace_id, @@ -241,6 +271,7 @@ async fn create_native_trigger( service_config, data.summary.as_deref(), data.enabled, + &connection_path, ) .await?; @@ -282,10 +313,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 +321,21 @@ 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(&db, &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 +488,38 @@ 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?; + // Reading the registration acts as the connection's account, like saving does. + let usable = match windmill_trigger.connection_path.as_deref() { + Some(path) => list_usable_connections( + &db, + &mut tx, + &workspace_id, + service_name.integration_service(), + ) + .await? + .iter() + .any(|c| c.path == path), + None => false, + }; + if !usable { + tx.commit().await?; + return Ok(Json(FullTriggerResponse { + windmill_data: windmill_trigger, + external_data: None, + external_error: Some(format!( + "You cannot use this trigger's {service_name} connection, so its registration was \ + not read." + )), + })); + } + + 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 +625,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 ece41a17bf..c61a1ff335 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 account_id = refreshed_oauth_config.account_id; 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, + account_id, &new_access_token, new_refresh_token.as_deref(), ) @@ -594,6 +608,8 @@ pub struct OAuthConfig { pub refresh_token: Option, pub client_id: String, pub client_secret: String, + pub connection_path: String, + pub account_id: i32, } pub async fn make_http_request( @@ -776,51 +792,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.id AS account_id, 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 +868,8 @@ pub async fn decrypt_oauth_data( "refresh_token": refresh_token, "client_id": client_id, "client_secret": client_secret, + "connection_path": connection_path, + "account_id": row.account_id, }); serde_json::from_value(assembled) @@ -978,6 +995,8 @@ 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(), + account_id: oauth_config.account_id, }) } @@ -994,61 +1013,48 @@ pub async fn refresh_oauth_tokens( }) } -async fn update_oauth_token_resource( +/// Write a refreshed token back to the account it was refreshed from, by that account's id, never +/// by service or path: several connections share a service, and a connection's path can be +/// reconnected or renamed while the refresh is in flight. A provider that rotates refresh tokens +/// invalidates the old one, so writing it onto another account would break that account's next +/// refresh. +/// +/// No authorization happens here: it overwrites the account's secrets as the server. Call it only +/// with tokens just refreshed from that account's own refresh token. +pub async fn update_oauth_token_resource( db: &DB, workspace_id: &str, - service_name: ServiceName, + account_id: i32, 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", + "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND account = $3", encrypted_token, workspace_id, - resource_path, + 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?; - } + // 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?; Ok::<(), Error>(()) } @@ -1056,8 +1062,8 @@ async fn update_oauth_token_resource( if let Err(e) = result { tracing::error!( - "Failed to update OAuth tokens for {} in workspace {}: {}", - service_name, + "Failed to update OAuth tokens of account {} in workspace {}: {}", + account_id, workspace_id, e ); @@ -1183,6 +1189,8 @@ pub async fn delete_token_by_hash<'c, E: sqlx::Executor<'c, Database = Postgres> Ok(deleted > 0) } +/// Callers must hold [`lock_connection`] on `connection_path` in the same transaction and check +/// the connection still exists, or a concurrent disconnect can leave this trigger stranded. pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>, C: Serialize>( db: E, workspace_id: &str, @@ -1192,6 +1200,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 +1220,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 +1237,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 +1435,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 +1476,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 +1533,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 +1713,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 +1720,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) @@ -1732,10 +1742,11 @@ pub async fn store_workspace_integration( Ok(()) } -/// Authorization gate for the integration *use* routes (calendar/drive/repo/event -/// pickers). They drive the admin-connected account's upstream API and enumerate its data, so -/// they need both the right to manage triggers and the right to write code: a role that only -/// manages triggers (the default `operator`) must not browse the admin's Drive or repos. +/// Authorization gate for the integration *use* routes (connecting an account, and the +/// calendar/drive/repo/event pickers). They drive a connected account's upstream API and +/// enumerate its data, so they need both the right to manage triggers and the right to write +/// code: a role that only manages triggers (the default `operator`) must not browse a shared +/// account's Drive or repos. pub async fn require_native_integration_use(db: &DB, w_id: &str, authed: &ApiAuthed) -> Result<()> { let caps = windmill_common::roles::caller_capabilities(db, w_id, authed).await?; if caps.code && caps.manage_triggers { @@ -1747,6 +1758,141 @@ pub async fn require_native_integration_use(db: &DB, w_id: &str, authed: &ApiAut } } +#[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 workspace connection, which every member may use. + pub workspace: bool, +} + +/// 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. The one exception is the +/// workspace connection an admin shared, read through `db` since members cannot read its token. +pub async fn list_usable_connections( + db: &DB, + tx: &mut PgConnection, + workspace_id: &str, + service_name: ServiceName, +) -> Result> { + let mut connections = sqlx::query_as!( + Connection, + r#"SELECT v.path, r.created_by AS "owner?", false AS "workspace!" + 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?; + + if let Some(shared) = workspace_connection(db, workspace_id, service_name).await? { + connections.retain(|c| c.path != shared.path); + connections.insert(0, shared); + } + Ok(connections) +} + +/// The connection an admin shared with the whole workspace, if it still exists. +pub async fn workspace_connection( + db: &DB, + workspace_id: &str, + service_name: ServiceName, +) -> Result> { + let connection = sqlx::query_as!( + Connection, + r#"SELECT v.path, r.created_by AS "owner?", true AS "workspace!" + FROM workspace_integrations wi + JOIN variable v ON v.workspace_id = wi.workspace_id AND v.path = wi.resource_path + 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 wi.workspace_id = $1 AND wi.service_name = $2 + AND a.client = $3 AND a.is_workspace_integration = true"#, + workspace_id, + service_name as ServiceName, + service_name.as_str(), + ) + .fetch_optional(db) + .await?; + Ok(connection) +} + +/// Pick the connection a new trigger acts through: the one asked for if the caller may use it, +/// otherwise the shared account, otherwise the caller's only usable connection. Clients and +/// trigger specs that predate connections send none and expect the shared account, so it wins +/// over a lone personal one. `tx` must be a user transaction. +pub async fn resolve_usable_connection( + db: &DB, + tx: &mut PgConnection, + workspace_id: &str, + service_name: ServiceName, + requested: Option<&str>, +) -> Result { + let usable = list_usable_connections(db, 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 an account at that \ + path, or ask for read access to the connection there." + ))), + None if usable.first().is_some_and(|c| c.workspace) => Ok(usable[0].path.clone()), + 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." + ))), + }, + } +} + +/// Serialize, until `tx` ends, the operations that must agree on whether a connection exists: +/// storing a trigger that uses it and disconnecting it. Whichever comes second waits and then +/// sees the other's outcome. +pub async fn lock_connection( + tx: &mut PgConnection, + workspace_id: &str, + connection_path: &str, +) -> Result<()> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "native_connection:{workspace_id}:{connection_path}" + )) + .execute(&mut *tx) + .await?; + Ok(()) +} + +#[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, + db: &DB, + user_db: UserDB, + workspace_id: &str, + service_name: ServiceName, + requested: Option<&str>, +) -> Result { + require_native_integration_use(db, workspace_id, authed).await?; + let mut tx = user_db.begin(authed).await?; + let path = + resolve_usable_connection(db, &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, @@ -1759,7 +1905,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..4ae3798867 100644 --- a/backend/windmill-native-triggers/src/nextcloud/external.rs +++ b/backend/windmill-native-triggers/src/nextcloud/external.rs @@ -131,11 +131,25 @@ impl External for NextCloud { &url, Method::POST, w_id, + &oauth_data.connection_path, db, Some(headers), Some(&full_nextcloud_payload), ) - .await?; + .await + .map_err(|e| { + // Nextcloud lets only its admins register webhooks. + if http_error_status(&e) == Some(StatusCode::FORBIDDEN) { + Error::BadRequest( + "This Nextcloud account isn't a Nextcloud admin, so it can't register \ + webhooks. Use the shared account, or ask a workspace admin to share a \ + Nextcloud admin account." + .to_string(), + ) + } else { + e + } + })?; Ok(ocs_response.ocs.data) } @@ -167,6 +181,7 @@ impl External for NextCloud { &url, Method::POST, w_id, + &oauth_data.connection_path, db, Some(headers), Some(&full_nextcloud_payload), @@ -214,7 +229,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 +262,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), @@ -320,13 +351,13 @@ impl External for NextCloud { fn error_hint(&self, status: StatusCode) -> Option<&'static str> { match status { StatusCode::UNAUTHORIZED => { - Some("reconnect the Nextcloud integration from Workspace settings > Integrations.") + Some("reconnect the Nextcloud account this uses with Reconnect next to it, or ask its owner or an admin to.") } StatusCode::FORBIDDEN => Some( "Nextcloud grants webhook management to administrators only, and a Windmill \ admin is a different thing. If the connected Nextcloud account is not an admin \ - there and holds no delegated admin rights for the Webhooks setting, reconnect \ - the integration from Workspace settings > Integrations with one that does.", + there and holds no delegated admin rights for the Webhooks setting, use one that \ + does, such as the workspace's shared account.", ), _ => None, } @@ -353,6 +384,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 aefed1c11f..11e9f59227 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,26 @@ 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(&db, &workspace_id, &authed).await?; + let connection_path = picker_connection( + &authed, + &db, + 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 +58,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 eccfa56f19..f2e2e1cb23 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::{check_scopes, 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, lock_connection, 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,31 @@ 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, the path the connection +/// will be saved at, and the user it was issued to. The path makes the callback independent of the +/// page the provider redirects to; the user stops anyone else from finishing the flow, which would +/// save their account at a path the issuer chose. /// 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, + username: &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), + URL_SAFE_NO_PAD.encode(username) ); // Get workspace key for signing @@ -91,65 +102,63 @@ 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, service and user, 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, + username: &str, +) -> 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:username + let [state_workspace_id, state_service, timestamp, _nonce, encoded_path, encoded_user] = + payload + .split(':') + .collect::>() + .try_into() + .map_err(|_| invalid())?; + + if state_workspace_id != workspace_id + || state_service != service_name.as_str() + || encoded_user != URL_SAFE_NO_PAD.encode(username) + { + 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 +203,114 @@ 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)), + } +} + +/// Connecting writes the connection's variable and resource at `path`, so a path-scoped token must +/// cover both, whatever the user behind it may write. +#[cfg(feature = "native_trigger")] +fn check_connection_scopes(authed: &ApiAuthed, path: &str) -> Result<()> { + check_scopes(authed, || format!("variables:write:{path}"))?; + check_scopes(authed, || format!("resources:write:{path}")) +} + +/// Reconnecting at a path swaps the account every trigger using it acts as, including the shared +/// account's, so only whoever connected it or an admin may replace what is there. Writing the +/// path is not enough: a folder writer would otherwise turn other members' triggers into their own. +#[cfg(feature = "native_trigger")] +async fn check_can_replace( + db: &DB, + authed: &ApiAuthed, + workspace_id: &str, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + let existing = sqlx::query!( + r#"SELECT + (SELECT created_by FROM resource WHERE workspace_id = $1 AND path = $2) AS "owner?", + EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2) + OR EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2) AS "taken!""#, + workspace_id, + path, + ) + .fetch_one(db) + .await?; + if existing.taken && existing.owner.as_deref() != Some(authed.username.as_str()) { + return Err(Error::NotAuthorized(format!( + "{path} holds a connection someone else created. Connect at another path, or ask an \ + admin to reconnect it." + ))); + } + Ok(()) +} + +#[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(&db, &workspace_id, &authed).await?; - 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)); + check_connection_scopes(&authed, &connection_path)?; + check_can_replace(&db, &authed, &workspace_id, &connection_path).await?; + + // 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, + &authed.username, + ) + .await?; let auth_url = build_authorization_url(&oauth_config, service_name, &state, &redirect_uri); Ok(Json(auth_url)) } @@ -287,14 +390,26 @@ 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, including remote webhook cleanup. When `only` +/// gives `(external_ids, script_paths)`, just the triggers still at those paths: a trigger moved +/// since its path was checked is left alone. /// 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, + only: Option<(&[String], &[String])>, +) { + let (external_ids, script_paths) = only.unzip(); 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 (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[])))", workspace_id, - service_name as ServiceName + service_name as ServiceName, + external_ids, + script_paths, ) .fetch_all(db) .await; @@ -311,27 +426,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 +463,19 @@ 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 (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[]))) + RETURNING webhook_token_hash", workspace_id, - service_name as ServiceName + service_name as ServiceName, + external_ids, + script_paths, ) .fetch_all(db) .await @@ -378,13 +503,60 @@ 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; + // This deletes every trigger and connection of the service, so a path-scoped token must + // cover each of them, as for a single disconnect. + let script_paths = sqlx::query_scalar!( + "SELECT script_path FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + workspace_id, + service_name as ServiceName, + ) + .fetch_all(&db) + .await?; + for script_path in &script_paths { + check_scopes(&authed, || format!("native_triggers:write:{script_path}"))?; + } + let mut paths = sqlx::query_scalar!( + "SELECT v.path FROM variable v + JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account + WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true", + workspace_id, + service_name.as_str(), + ) + .fetch_all(&db) + .await?; + for path in &paths { + check_connection_scopes(&authed, path)?; + } + + // 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; + // Same serialization as a single disconnect: a trigger created meanwhile either committed + // before these locks, and is caught below, or waits and then finds its connection gone. + paths.sort(); + for path in &paths { + lock_connection(&mut tx, &workspace_id, path).await?; + } + + cleanup_service_connections(&mut *tx, &workspace_id, service_name).await?; + + let created_meanwhile = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = $2)", + workspace_id, + service_name as ServiceName, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if created_meanwhile { + tx.rollback().await?; + return Err(Error::BadRequest(format!( + "A {service_name} trigger was created while removing the integration, so it was kept. \ + Remove it again." + ))); + } let deleted = delete_workspace_integration(&mut *tx, &workspace_id, service_name).await?; @@ -419,7 +591,6 @@ async fn delete_integration( struct WorkspaceIntegrations { service_name: ServiceName, oauth_data: Option>, - resource_path: Option, } #[cfg(feature = "native_trigger")] @@ -436,8 +607,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 +620,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 +636,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 +671,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 +683,6 @@ struct OAuthCallbackBody { redirect_uri: String, code: String, state: String, - resource_path: Option, } #[cfg(feature = "native_trigger")] @@ -531,60 +699,34 @@ 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(&db, &workspace_id, &authed).await?; - let state_was_valid = validate_signed_state(&db, &body.state, &workspace_id).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 + let resource_path = validate_signed_state( + &db, + &body.state, + &workspace_id, + service_name, + &authed.username, ) - .fetch_optional(&db) - .await? - .flatten(); + .await?; + check_connection_scopes(&authed, &resource_path)?; + check_can_replace(&db, &authed, &workspace_id, &resource_path).await?; - 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. + let replaced_account = cleanup_connection(&mut *tx, &workspace_id, &resource_path).await?; // 1. Create account record for token refresh let account_id = sqlx::query_scalar!( @@ -658,25 +800,21 @@ 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 NOTHING", + workspace_id, + service_name as ServiceName, + json!({ "instance_shared": true, "base_url": "" }), + authed.username, + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -684,17 +822,15 @@ 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?; + delete_unlinked_account(&db, &workspace_id, replaced_account).await; - Ok(Json(format!( - "{} integration connected successfully via OAuth", - service_name - ))) + Ok(Json(resource_path)) } /// Token response from OAuth token exchange @@ -806,7 +942,6 @@ pub async fn create_workspace_integration( &workspace_id, service_name, to_value(oauth_data).unwrap(), - None, ) .await?; @@ -815,16 +950,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, @@ -855,73 +980,96 @@ fn build_authorization_url( format!("{}?{}", base_auth_url, query_string) } +/// Remove the connection at `path`: its variable and its resource. +/// +/// Returns the account the variable linked. The caller removes it with +/// [`delete_unlinked_account`] once `tx` is committed. #[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(); + + sqlx::query!( + "DELETE FROM resource WHERE workspace_id = $1 AND path = $2", + workspace_id, + path, + ) + .execute(&mut *tx) + .await?; + + Ok(account_id) +} + +/// Delete a connection's account once no variable links it any more. Any variable can link any +/// account, so another one may still hold it; the check needs `db`, since the caller's own +/// transaction does not see variables it cannot read. +#[cfg(feature = "native_trigger")] +pub async fn delete_unlinked_account(db: &DB, workspace_id: &str, account_id: Option) { + let Some(account_id) = account_id else { + return; + }; + if let Err(e) = sqlx::query!( + "DELETE FROM account WHERE workspace_id = $1 AND id = $2 AND is_workspace_integration = true + AND NOT EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1 AND account = $2)", + workspace_id, + account_id, + ) + .execute(db) + .await + { + tracing::error!("Failed to delete account {account_id} in {workspace_id}: {e}"); + } +} + +/// 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); } @@ -930,7 +1078,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), @@ -953,9 +1101,10 @@ async fn get_instance_oauth_config( service_name: ServiceName, ) -> Result { if !is_instance_sharing_enabled(db, service_name).await? { - return Err(Error::BadRequest( - "Instance credential sharing is not enabled for this service".to_string(), - )); + return Err(Error::BadRequest(format!( + "No {service_name} OAuth app is configured for this workspace. A workspace admin can \ + configure one in the workspace settings, under native triggers." + ))); } let (client_id, client_secret) = @@ -985,36 +1134,213 @@ async fn check_instance_sharing_available( } #[cfg(feature = "native_trigger")] -async fn generate_instance_connect_url( +async fn list_connections( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Path((workspace_id, service_name)): Path<(String, ServiceName)>, - Json(RedirectUri { redirect_uri }): Json, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + let connections = list_usable_connections(&db, &mut tx, &workspace_id, service_name).await?; + tx.commit().await?; + Ok(Json(connections)) +} + +#[cfg(feature = "native_trigger")] +#[derive(Debug, Deserialize)] +struct ConnectionPathQuery { + path: String, +} + +#[cfg(feature = "native_trigger")] +#[derive(Debug, Deserialize)] +struct WorkspaceConnectionBody { + /// The connection to share with every member, or none to stop sharing. + path: Option, +} + +/// Share one connection with the whole workspace: every member may then create and edit triggers +/// through it, without being able to read its token. +#[cfg(feature = "native_trigger")] +async fn set_workspace_connection( + authed: ApiAuthed, + Extension(db): Extension, + Path((workspace_id, service_name)): Path<(String, ServiceName)>, + Json(WorkspaceConnectionBody { path }): Json, ) -> JsonResult { require_admin(authed.is_admin, &workspace_id)?; - let instance_config = get_instance_oauth_config(&db, service_name).await?; + if let Some(path) = path.as_deref() { + // Sharing lets every member act as the account, so a path-scoped token must cover it. + check_connection_scopes(&authed, path)?; + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 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, + path, + service_name.as_str(), + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::BadRequest(format!( + "No {service_name} connection at {path}" + ))); + } + } - // 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, + let mut tx = db.begin().await?; + let updated = sqlx::query!( + "UPDATE workspace_integrations SET resource_path = $3, updated_at = now() + WHERE workspace_id = $1 AND service_name = $2", + workspace_id, + service_name as ServiceName, + path, + ) + .execute(&mut *tx) + .await? + .rows_affected(); + if updated == 0 { + return Err(Error::NotFound(format!( + "{service_name} is not set up in this workspace" + ))); + } + + audit_log( + &mut *tx, &authed, + &format!("workspace_integrations.{}.share", service_name), + ActionKind::Update, &workspace_id, - service_name, - json!({ "instance_shared": true, "base_url": "" }), + Some(path.as_deref().unwrap_or("none")), 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(match path { + Some(path) => format!("{path} is now the shared account"), + None => "No shared account".to_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)>, + Query(ConnectionPathQuery { path }): Query, +) -> JsonResult { + check_connection_scopes(&authed, &path)?; + require_is_writer( + &authed, + &path, + &workspace_id, + db.clone(), + "SELECT extra_perms FROM variable WHERE path = $1 AND workspace_id = $2", + "variable", + ) + .await?; + + let mut tx = user_db.clone().begin(&authed).await?; + let usable = list_usable_connections(&db, &mut tx, &workspace_id, service_name).await?; + tx.commit().await?; + if !usable.iter().any(|c| c.path == path) { + return Err(Error::NotFound(format!( + "No {service_name} connection at {path}" + ))); + } + + // Disconnecting deletes these triggers, so a path-scoped token must cover each of them. Only + // the rows checked here, at the paths checked, are deleted: one created or moved meanwhile is + // never removed unchecked. + let triggers = sqlx::query!( + "SELECT external_id, script_path FROM native_trigger + WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3", + workspace_id, + service_name as ServiceName, + path, + ) + .fetch_all(&db) + .await?; + for trigger in &triggers { + check_scopes(&authed, || { + format!("native_triggers:write:{}", trigger.script_path) + })?; + } + let (external_ids, script_paths): (Vec, Vec) = triggers + .into_iter() + .map(|t| (t.external_id, t.script_path)) + .unzip(); + + // Before the connection goes: removing the registrations needs its token. + delete_triggers_for_service( + &db, + &workspace_id, + service_name, + Some((&external_ids, &script_paths)), + ) + .await; + + let mut tx = user_db.begin(&authed).await?; + lock_connection(&mut tx, &workspace_id, &path).await?; + let account = cleanup_connection(&mut *tx, &workspace_id, &path).await?; + + // A trigger moved or created meanwhile was skipped above and still acts through this + // connection, so removing the connection would strand it. `lock_connection` is what makes + // this check sound: a create stores its trigger under the same lock, so it either committed + // before this transaction took the lock, and is seen here, or it waits and finds the + // connection gone. + let still_used = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM native_trigger + WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3)", + workspace_id, + service_name as ServiceName, + path, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if still_used { + tx.rollback().await?; + return Err(Error::BadRequest(format!( + "A trigger using {path} changed while disconnecting, so the connection was kept. \ + Disconnect again to remove it." + ))); + } + + // A connection made later at this path must not inherit the sharing. + sqlx::query!( + "UPDATE workspace_integrations SET resource_path = NULL + WHERE workspace_id = $1 AND service_name = $2 AND resource_path = $3", + workspace_id, + service_name as ServiceName, + path, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + &format!("workspace_integrations.{}.disconnect", service_name), + ActionKind::Delete, + &workspace_id, + Some(&path), + None, + ) + .await?; + + tx.commit().await?; + delete_unlinked_account(&db, &workspace_id, account).await; + + Ok(Json(format!("Disconnected {path}"))) } #[cfg(feature = "native_trigger")] @@ -1031,9 +1357,14 @@ 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}/workspace_connection", + post(set_workspace_connection), ) .route("/{service_name}/delete", delete(delete_integration)) .route("/{service_name}/callback", post(oauth_callback)); diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 1ee96881c2..a384351911 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -2224,6 +2224,15 @@ async fn update_resource( .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE native_trigger SET connection_path = $1 WHERE workspace_id = $2 AND connection_path = $3", + npath, + w_id, + path + ) + .execute(&mut *tx) + .await?; + sqlx::query!( "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3", npath, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 6a4c34e7dd..352a411214 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -1296,6 +1296,15 @@ async fn update_variable( .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE native_trigger SET connection_path = $1 WHERE workspace_id = $2 AND connection_path = $3", + npath, + w_id, + path + ) + .execute(&mut *tx) + .await?; + sqlx::query!( "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3", npath, 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/AppConnectDrawer.svelte b/frontend/src/lib/components/AppConnectDrawer.svelte index 8de484929e..6cdfdc2998 100644 --- a/frontend/src/lib/components/AppConnectDrawer.svelte +++ b/frontend/src/lib/components/AppConnectDrawer.svelte @@ -9,17 +9,20 @@ import GoogleSigninButton from './GoogleSigninButton.svelte' import IconedResourceType from './IconedResourceType.svelte' import { addResourceTitle } from './resourceTypeDisplay' + import type { NativeConnect } from './triggers/native/utils' interface Props { expressOAuthSetup?: boolean workspace?: string disableChatOffset?: boolean + native?: NativeConnect } let { expressOAuthSetup = false, workspace = undefined, - disableChatOffset = false + disableChatOffset = false, + native = undefined }: Props = $props() /** Set by `open(rt, fillPath)`, not by the parent: which resource this run fills is a @@ -82,7 +85,7 @@ {disableChatOffset} > 1 ? resourceType : undefined)} + title={native?.title ?? addResourceTitle(step > 1 ? resourceType : undefined)} id="add-resource-drawer" on:close={drawer?.closeDrawer} tooltip="Resources represent connections to third party systems. Learn more on how to integrate external APIs." @@ -105,6 +108,7 @@ express={expressOAuthSetup} {workspace} {fillPath} + {native} /> {#snippet actions()}
@@ -124,7 +128,7 @@ {disabled} onClick={() => appConnectInner?.next()} > - {#if step == 2 && !manual} + {#if (step == 2 && !manual) || native} Connect {:else if step == 1} Next diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 173de7e12b..d29d97a1dc 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -53,6 +53,7 @@ import ResourcePathHint from './ResourcePathHint.svelte' import SchemaForm from './SchemaForm.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import type { NativeConnect } from './triggers/native/utils' const operatingWorkspace = useOperatingWorkspace() @@ -73,6 +74,11 @@ * Opt-in: without it this flow still refuses to write over anything. */ fillPath?: string + /** + * Connect a native trigger account: only the path is asked, and `connect` runs the + * popup through the trigger service's OAuth app, whose callback saves the connection. + */ + native?: NativeConnect } let { @@ -83,7 +89,8 @@ manual = $bindable(true), express = false, workspace = undefined, - fillPath = undefined + fillPath = undefined, + native = undefined }: Props = $props() let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace!) @@ -419,6 +426,13 @@ let pathError = $state('') export async function open(rt?: string) { + if (native) { + resourceType = rt ?? '' + manual = false + path = '' + step = 4 + return + } if (!rt) { loadResourceTypes() loadPopularity() @@ -507,7 +521,7 @@ // authorize endpoint counts; `google` itself is a login provider, not a registry entry. run(() => { isGoogleSignin = - step == 2 && + (step == 2 || (!!native && step == 4)) && !manual && (resourceType == 'google' || (registryEntryFor(resourceType)?.auth_url?.startsWith('https://accounts.google.com/') ?? @@ -532,7 +546,7 @@ clientSecret.trim() == '' || (!!ccInstanceMeta && ccInstance.trim() == '')))) || step == 3 || - (step == 4 && pathError != '') || + (step == 4 && (pathError != '' || (!!native && !path))) || !isValid }) @@ -729,6 +743,16 @@ } } export async function next() { + if (native) { + step = 3 + try { + await native.connect(path) + dispatch('close') + } catch { + step = 4 + } + return + } if (step == 1) { linkedSecrets = [] // Both branches: the OAuth one fills `resourceFields` into the same map, and fields @@ -1653,47 +1677,56 @@ - - {#if deployTo} - - {/if} - - {#if step == 4 && !manual && !express && !fillPath && resourceFields.length > 0} - - {/if} - {#if apiTokenApps[resourceType] || !manual} -
    -
  • - 1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ?? 'token'} - {truncateRev(value, 5, '*****')} - will be stored a - {path}. -
  • -
  • - 2. The resource containing that token will be stored at the same path {path}. The Variable and Resource will be "linked together", they will be deleted and renamed - together. -
+ {#if step == 4 && !manual && !express && !fillPath && resourceFields.length > 0} + + {/if} + {#if apiTokenApps[resourceType] || !manual} +
    +
  • + 1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ?? + 'token'} + {truncateRev(value, 5, '*****')} + will be stored a + {path}. +
  • +
  • + 2. The resource containing that token will be stored at the same path {path}. The Variable and Resource will be "linked together", they will be deleted and renamed + together. +
+ {/if} {/if} {/if} {/if} 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..ad7d0badb2 --- /dev/null +++ b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte @@ -0,0 +1,245 @@ + + +
+ + {#if locked} + {@const current = connections?.find((c) => c.path === value)} +
+

+ The webhook is registered under this account. To use another one, create a new trigger. +

+ {#if current && canReconnect(current)} + + {/if} +
+ {/if} + {#if unusable} + + Saving acts as the account at {value}. Ask its owner for read access to it, or create a new + trigger with your own account. + + {/if} +
+ + + + diff --git a/frontend/src/lib/components/triggers/native/NativeOAuthCallback.svelte b/frontend/src/lib/components/triggers/native/NativeOAuthCallback.svelte new file mode 100644 index 0000000000..18c9428d76 --- /dev/null +++ b/frontend/src/lib/components/triggers/native/NativeOAuthCallback.svelte @@ -0,0 +1,69 @@ + + +
+ {#if outcome === undefined} +
+ + Finishing the connection... +
+ {:else if outcome.ok} + + Saved as {outcome.path}. You can close this window. + + {:else} + {outcome.error} + {/if} +
diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte index 3246b12a4d..bf8fe0f7d1 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte @@ -6,8 +6,10 @@ validateCommonFields, getServiceConfig, getTemplatePath, - saveNativeTriggerFromCfg + saveNativeTriggerFromCfg, + setNativeConnectionContext } from './utils' + import NativeConnectionPicker from './NativeConnectionPicker.svelte' import { usedTriggerKinds } from '$lib/stores' import { emptyString, sendUserToast } from '$lib/utils' import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights' @@ -125,6 +127,8 @@ let externalError = $state(undefined) let retryEdit = $state<(() => void) | undefined>(undefined) let enabled = $state(true) + let connectionPath = $state(undefined) + setNativeConnectionContext(() => connectionPath) export function openNew( nis_flow?: boolean, @@ -156,6 +160,7 @@ externalError = undefined retryEdit = undefined enabled = true + connectionPath = undefined } export function openRecreate(nativeTrigger: ExtendedNativeTrigger) { @@ -185,6 +190,7 @@ externalError = undefined retryEdit = undefined enabled = nativeTrigger.enabled + connectionPath = nativeTrigger.connection_path ?? undefined } export async function openEdit( @@ -219,6 +225,7 @@ initialScriptPath = '' summary = '' enabled = true + connectionPath = undefined try { const fullTrigger = await NativeTriggerService.getNativeTrigger({ @@ -236,6 +243,7 @@ externalData = fullTrigger.external_data externalError = fullTrigger.external_error ?? undefined enabled = fullTrigger.enabled + connectionPath = fullTrigger.connection_path ?? undefined // Apply default values if provided (for draft triggers) if (defaultValues) { @@ -263,7 +271,8 @@ script_path: scriptPath, is_flow: isFlow, service_config: serviceConfig, - summary: summary !== '' ? summary : undefined + summary: summary !== '' ? summary : undefined, + connection_path: connectionPath } } @@ -308,7 +317,8 @@ loadingForm || !can_write || !hasChanged || - loadError !== undefined + loadError !== undefined || + (isNew && !connectionPath) ) const saveCfg = $derived.by(getSaveCfg) @@ -557,6 +567,17 @@ {/if} + {#if !loadingConfig} +
+ +
+ {/if} + {#if loadingConfig}
@@ -564,34 +585,39 @@ Loading configuration from {serviceInfo?.serviceDisplayName}...
+ {:else if isNew && !connectionPath} + {:else if ServiceFormComponent} - + {#key connectionPath} + + {/key} {:else}
Failed to load service configuration component for {service}.
- Ensure your workspace has a connected {serviceInfo?.serviceDisplayName} integration. + Check that {serviceInfo?.serviceDisplayName} is configured in the native trigger settings.
diff --git a/frontend/src/lib/components/triggers/native/services/github/GitHubTriggerForm.svelte b/frontend/src/lib/components/triggers/native/services/github/GitHubTriggerForm.svelte index 27add7f797..c058a41560 100644 --- a/frontend/src/lib/components/triggers/native/services/github/GitHubTriggerForm.svelte +++ b/frontend/src/lib/components/triggers/native/services/github/GitHubTriggerForm.svelte @@ -7,8 +7,10 @@ import { sendUserToast } from '$lib/toast' import { Loader2 } from 'lucide-svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useNativeConnection } from '../../utils' const operatingWorkspace = useOperatingWorkspace() + const connectionPath = useNativeConnection() interface Props { serviceConfig: Record @@ -66,7 +68,8 @@ loading = true try { repos = await NativeTriggerService.listGithubRepos({ - workspace: $operatingWorkspace + workspace: $operatingWorkspace, + connectionPath: connectionPath() }) } catch (err: any) { console.error('Failed to load GitHub repositories:', err) diff --git a/frontend/src/lib/components/triggers/native/services/google/GoogleCalendarPicker.svelte b/frontend/src/lib/components/triggers/native/services/google/GoogleCalendarPicker.svelte index f4c6d9894b..3252d7c6aa 100644 --- a/frontend/src/lib/components/triggers/native/services/google/GoogleCalendarPicker.svelte +++ b/frontend/src/lib/components/triggers/native/services/google/GoogleCalendarPicker.svelte @@ -6,8 +6,10 @@ import { Loader2, X, RefreshCw } from 'lucide-svelte' import GoogleCalendarIcon from '$lib/components/icons/GoogleCalendarIcon.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useNativeConnection } from '../../utils' const operatingWorkspace = useOperatingWorkspace() + const connectionPath = useNativeConnection() interface Props { calendarId: string @@ -26,7 +28,8 @@ loading = true try { calendars = await NativeTriggerService.listGoogleCalendars({ - workspace: $operatingWorkspace + workspace: $operatingWorkspace, + connectionPath: connectionPath() }) if (calendarId && !calendarName) { const found = calendars.find((c) => c.id === calendarId) diff --git a/frontend/src/lib/components/triggers/native/services/google/GoogleDrivePicker.svelte b/frontend/src/lib/components/triggers/native/services/google/GoogleDrivePicker.svelte index 597eb7d514..26e82dbdde 100644 --- a/frontend/src/lib/components/triggers/native/services/google/GoogleDrivePicker.svelte +++ b/frontend/src/lib/components/triggers/native/services/google/GoogleDrivePicker.svelte @@ -9,8 +9,10 @@ import { Debounced, watch } from 'runed' import { untrack } from 'svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useNativeConnection } from '../../utils' const operatingWorkspace = useOperatingWorkspace() + const connectionPath = useNativeConnection() interface Props { resourceId: string @@ -53,6 +55,7 @@ try { const params: Parameters[0] = { workspace: $operatingWorkspace, + connectionPath: connectionPath(), pageToken } @@ -98,7 +101,8 @@ try { sharedDrives = await NativeTriggerService.listGoogleSharedDrives({ - workspace: $operatingWorkspace + workspace: $operatingWorkspace, + connectionPath: connectionPath() }) } catch (err: any) { sendUserToast(`Failed to load shared drives: ${err.body || err.message}`, true) diff --git a/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte b/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte index 780ccde9e8..d4f01478ef 100644 --- a/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte +++ b/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte @@ -6,10 +6,11 @@ import SchemaForm from '$lib/components/SchemaForm.svelte' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' - import { getNextcloudSchema } from '../../utils' + import { getNextcloudSchema, useNativeConnection } from '../../utils' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const connectionPath = useNativeConnection() interface Props { serviceConfig: Record @@ -49,7 +50,8 @@ eventsError = undefined try { const events = await NativeTriggerService.listNextCloudEvents({ - workspace: $operatingWorkspace! + workspace: $operatingWorkspace!, + connectionPath: connectionPath() }) availableEvents = events serviceSchema = getNextcloudSchema(events) @@ -116,16 +118,14 @@ {#if eventsError}
Could not load the available events: {eventsError}
{:else} -
No events available. Please ensure your workspace has a connected Nextcloud integration.
+
No events available. Check that the selected Nextcloud account can reach the server.
{/if}
-
@@ -140,8 +140,7 @@ /> {:else}
- Please ensure Nextcloud workspace integration is connected to load available configuration - options. + Select a Nextcloud account to load the available configuration options.
{/if} diff --git a/frontend/src/lib/components/triggers/native/utils.ts b/frontend/src/lib/components/triggers/native/utils.ts index b411429d74..5d0a7f0fca 100644 --- a/frontend/src/lib/components/triggers/native/utils.ts +++ b/frontend/src/lib/components/triggers/native/utils.ts @@ -2,11 +2,97 @@ import type { NativeServiceName, NativeTrigger, NativeTriggerData } from '$lib/g import { isCloudHosted } from '$lib/cloud' import { NativeTriggerService, WorkspaceIntegrationService } from '$lib/gen' import { get, type Writable } from 'svelte/store' +import { getContext, setContext } from 'svelte' import { sendUserToast } from '$lib/toast' +const CONNECTION_CONTEXT = Symbol('nativeTriggerConnection') + +/** Makes the connection a trigger editor acts through available to the pickers of its form. */ +export function setNativeConnectionContext(connectionPath: () => string | undefined) { + setContext(CONNECTION_CONTEXT, connectionPath) +} + +/** The connection the service pickers browse through; undefined lets the server pick. */ +export function useNativeConnection(): () => string | undefined { + return getContext<(() => string | undefined) | undefined>(CONNECTION_CONTEXT) ?? (() => undefined) +} + +const CONNECT_MESSAGE = 'native_trigger_oauth' + +/** Turns the resource connect drawer into the connect step of a native trigger account. */ +export type NativeConnect = { + title: string + defaultName: string + /** Rejects when the connection was not saved; the drawer then stays open on the path. */ + connect: (path: string) => Promise +} + +export type NativeOAuthMessage = + | { type: typeof CONNECT_MESSAGE; ok: true; path: string } + | { type: typeof CONNECT_MESSAGE; ok: false; error: string } + +/** The page the provider redirects to, as registered on the workspace's OAuth apps. */ +export function nativeOAuthRedirectUri(service: NativeServiceName): string { + return `${window.location.origin}/workspace_settings?tab=native_triggers&service=${service}` +} + +/** Hands the outcome of a connect popup back to the page that opened it. */ +export function postNativeOAuthResult( + result: { ok: true; path: string } | { ok: false; error: string } +) { + const message = { type: CONNECT_MESSAGE, ...result } as NativeOAuthMessage + window.opener?.postMessage(message, window.location.origin) +} + +/** + * Connect an account in a popup and resolve with the path of the saved connection. The + * connection is saved at `resourcePath`, or at the caller's default path when omitted. + */ +export async function connectNativeAccount( + workspace: string, + service: NativeServiceName, + resourcePath?: string +): Promise { + const authUrl = await WorkspaceIntegrationService.generateNativeTriggerServiceConnectUrl({ + workspace, + serviceName: service, + requestBody: { redirect_uri: nativeOAuthRedirectUri(service), resource_path: resourcePath } + }) + const popup = window.open(authUrl, '_blank', 'popup,width=600,height=750') + if (!popup) { + throw new Error('The connection window was blocked. Allow popups for this site and retry.') + } + return new Promise((resolve, reject) => { + const onMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin || event.data?.type !== CONNECT_MESSAGE) return + cleanup() + if (event.data.ok) resolve(event.data.path) + else reject(new Error(event.data.error)) + } + // The callback page posts its result and then closes itself, so a closed window may still + // have a message in flight: give it a moment before treating the close as a cancel. + const closedPoll = setInterval(() => { + if (popup.closed) { + clearInterval(closedPoll) + setTimeout(() => { + cleanup() + reject(new Error('The connection window was closed before finishing')) + }, 1000) + } + }, 500) + function cleanup() { + window.removeEventListener('message', onMessage) + clearInterval(closedPoll) + } + window.addEventListener('message', onMessage) + }) +} + export interface NativeTriggerConfig { readonly serviceDisplayName: string readonly serviceKey: NativeServiceName + /** The type of the resource a connection is saved as. */ + readonly resourceType: string readonly supportsSync: boolean readonly isCloudCompatible: boolean readonly templates?: { @@ -19,6 +105,7 @@ export const NATIVE_TRIGGER_SERVICES: Record + import type { Snippet } from 'svelte' import { sendUserToast } from '$lib/utils' import { Button, Alert } from '$lib/components/common' - import Label from '$lib/components/Label.svelte' + import Modal from '$lib/components/common/modal/Modal.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' - import { Save, ExternalLink } from 'lucide-svelte' + import { ExternalLink } from 'lucide-svelte' interface Props { + open: boolean serviceName: string serviceDisplayName: string existingConfig?: { @@ -16,178 +19,147 @@ redirectUri: string onConfigSaved: (oauthData: any) => Promise requiresBaseUrl?: boolean - baseUrlLabel?: string baseUrlPlaceholder?: string baseUrlSettingsPath?: string clientIdPlaceholder?: string clientSecretPlaceholder?: string setupInstructions?: string[] + /** Shown above the form, for what the service needs before it can be connected. */ + requirements?: Snippet } let { + open = $bindable(), serviceName, serviceDisplayName, existingConfig, onConfigSaved, redirectUri, requiresBaseUrl = true, - baseUrlLabel = `${serviceDisplayName} base URL`, baseUrlPlaceholder = `https://your-${serviceName}.example.com`, baseUrlSettingsPath = '/settings/admin/security', clientIdPlaceholder = `Generated by ${serviceDisplayName} OAuth app`, clientSecretPlaceholder = `Generated by ${serviceDisplayName} OAuth app`, - setupInstructions + setupInstructions, + requirements }: Props = $props() let saving = $state(false) let clientId = $state('') let clientSecret = $state('') let baseUrl = $state('') + $effect(() => { - if (existingConfig) { - clientId = existingConfig.client_id - clientSecret = existingConfig.client_secret - baseUrl = existingConfig.base_url + if (open) { + clientId = existingConfig?.client_id ?? '' + clientSecret = existingConfig?.client_secret ?? '' + baseUrl = existingConfig?.base_url ?? '' } }) - let canSave = $derived( - !saving && - !!clientId.trim() && - !!clientSecret.trim() && - (!requiresBaseUrl || !!baseUrl.trim()) + const baseUrlValid = $derived.by(() => { + try { + return ['http:', 'https:'].includes(new URL(baseUrl.trim()).protocol) + } catch { + return false + } + }) + + const canSave = $derived( + !saving && !!clientId.trim() && !!clientSecret.trim() && (!requiresBaseUrl || baseUrlValid) ) - async function saveConfig() { - if (!canSave) { - sendUserToast('Please fill in all required fields', true) - return - } - + async function save() { + if (!canSave) return saving = true try { - const oauthData: Record = { + await onConfigSaved({ client_id: clientId.trim(), client_secret: clientSecret.trim(), base_url: requiresBaseUrl ? baseUrl.trim().replace(/\/+$/, '') : '', redirect_uri: redirectUri.trim() - } - - await onConfigSaved(oauthData) - resetForm() + }) } catch (err: any) { - sendUserToast(`Failed to configure OAuth client: ${err.message}`, true) + sendUserToast(`Failed to save the OAuth app: ${err.message}`, true) } finally { saving = false } } - - function resetForm() { - clientId = '' - clientSecret = '' - } - - -
-
-

{serviceDisplayName} OAuth Client Configuration

-
+ +
+ {@render requirements?.()} - {#if !existingConfig} - -

- Before you can connect to {serviceDisplayName}, you need to configure an OAuth client. -

- {#if setupInstructions} -
    + {#if !existingConfig && setupInstructions} + +
      {#each setupInstructions as instruction}
    1. {@html instruction}
    2. {/each}
    - {/if} -
    - {/if} + + {/if} -
    -
    - {#if requiresBaseUrl} -
    -
    + {#snippet actions()} + + {/snippet} + diff --git a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte index 622a9620a4..e1b8672e03 100644 --- a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte +++ b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte @@ -4,22 +4,25 @@ import { Button, Alert } from '$lib/components/common' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' - import { Check, X, ExternalLink, Cog, Plug } from 'lucide-svelte' + import { Check, ExternalLink, KeyRound, Pencil, Trash2 } from 'lucide-svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import type { Item } from '$lib/utils' import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte' import GithubIcon from '$lib/components/icons/GithubIcon.svelte' import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte' - import { WorkspaceIntegrationService, type NativeServiceName } from '$lib/gen' - import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' + import { + WorkspaceIntegrationService, + type NativeServiceName, + type NativeTriggerConnection + } from '$lib/gen' import OAuthClientConfig from './OAuthClientConfig.svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' - import Path from '$lib/components/Path.svelte' - import { page } from '$app/state' - import { goto } from '$app/navigation' + import NativeConnectionPicker from '$lib/components/triggers/native/NativeConnectionPicker.svelte' + import { nativeOAuthRedirectUri } from '$lib/components/triggers/native/utils' interface WorkspaceIntegration { service_name: string - resource_path?: string oauth_data: { client_id: string client_secret: string @@ -44,7 +47,7 @@ nextcloud: { name: 'nextcloud', displayName: 'Nextcloud', - description: 'Connect to Nextcloud for file operations and webhook triggers', + description: 'File events', icon: NextcloudIcon, docsUrl: 'https://www.windmill.dev/docs/integrations/nextcloud', setupInstructions: [ @@ -56,7 +59,7 @@ google: { name: 'google', displayName: 'Google', - description: 'Connect to Google for Drive and Calendar triggers', + description: 'Drive and Calendar changes', icon: GoogleIcon, docsUrl: 'https://www.windmill.dev/docs/core_concepts/native_triggers#google-triggers', requiresBaseUrl: false, @@ -73,7 +76,7 @@ github: { name: 'github', displayName: 'GitHub', - description: 'Connect to GitHub for repository webhook triggers', + description: 'Repository events', icon: GithubIcon, docsUrl: 'https://www.windmill.dev/docs/core_concepts/native_triggers#github-triggers', requiresBaseUrl: false, @@ -89,18 +92,13 @@ } let integrations = $state([]) + let connections = $state>({}) let loading = $state(false) - let connecting = $state(null) - let showingConfig = $state(null) + // The service whose OAuth app dialog is open. + let configOpen = $state>( + Object.fromEntries(Object.keys(supportedServices).map((s) => [s, false])) + ) let instanceSharingAvailable = $state>({}) - let pendingCallback = $state<{ - serviceName: NativeServiceName - code: string - state: string - workspace: string - } | null>(null) - let resourcePath = $state(undefined) - let pathError = $state(undefined) let confirmationModal = createAsyncConfirmationModal() async function loadIntegrations() { @@ -113,9 +111,9 @@ }) integrations = response.map((item) => ({ service_name: item.service_name, - resource_path: item.resource_path ?? undefined, oauth_data: item.oauth_data || null })) + await Promise.all(Object.keys(supportedServices).map(loadConnections)) } catch (err: any) { console.error('Failed to load workspace integrations:', err) sendUserToast(`Failed to load integrations: ${err.message}`, true) @@ -124,59 +122,132 @@ } } + async function loadConnections(serviceName: string) { + if (!$workspaceStore) return + try { + connections[serviceName] = await WorkspaceIntegrationService.listNativeTriggerConnections({ + workspace: $workspaceStore, + serviceName: serviceName as NativeServiceName + }) + } catch { + connections[serviceName] = [] + } + } + async function deleteIntegration(serviceName: string) { if (!$workspaceStore) return const displayName = supportedServices[serviceName]?.displayName ?? serviceName const confirmed = await confirmationModal.ask({ - title: `Disconnect ${displayName}?`, - confirmationText: 'Disconnect', - children: `This will delete all ${displayName} triggers associated with this integration. This action cannot be undone.` + title: `Remove the ${displayName} configuration?`, + confirmationText: 'Remove', + children: `This deletes the workspace's ${displayName} OAuth app, every ${displayName} account connected through it and all ${displayName} triggers, which stop receiving events. This action cannot be undone.` }) if (!confirmed) return try { await WorkspaceIntegrationService.deleteNativeTriggerService({ workspace: $workspaceStore, - serviceName: serviceName as any + serviceName: serviceName as NativeServiceName }) - sendUserToast(`${displayName} disconnected successfully`) + sendUserToast(`${displayName} integration removed`) loadIntegrations() } catch (err: any) { - sendUserToast(`Failed to disconnect ${displayName}: ${err.message}`, true) + sendUserToast(`Failed to remove ${displayName}: ${err.body ?? err.message}`, true) } } - async function connectService(serviceName: string, redirectUri: string) { - if (!$workspaceStore) return + async function setWorkspaceConnection( + serviceName: string, + path: string | undefined + ): Promise { + if (!$workspaceStore) return false - connecting = serviceName - try { - const auth_url = await WorkspaceIntegrationService.generateNativeTriggerServiceConnectUrl({ - workspace: $workspaceStore, - serviceName: serviceName as any, - requestBody: { redirect_uri: redirectUri } + const displayName = supportedServices[serviceName]?.displayName ?? serviceName + if (path) { + const owner = connections[serviceName]?.find((c) => c.path === path)?.owner + const account = owner ? `this account, connected by ${owner}` : 'this account' + const browsable: string | undefined = ( + { google: 'its Drive files and calendars', github: 'its repositories' } as Record< + string, + string + > + )[serviceName] + const browse = browsable ? `, and browse ${browsable} in the trigger editor` : '' + const confirmed = await confirmationModal.ask({ + title: `Share ${path} with the workspace?`, + confirmationText: 'Share', + children: `Every member will be able to create and edit ${displayName} triggers that act as ${account}${browse}. They will not see its token. It replaces any connection shared before.` }) - - if (auth_url) { - window.location.href = auth_url - } - } catch (err: any) { - sendUserToast( - `Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.message}`, - true - ) - connecting = null + if (!confirmed) return false + } else { + const confirmed = await confirmationModal.ask({ + title: 'Stop sharing this account?', + confirmationText: 'Stop sharing', + children: `Members will no longer be able to create or edit ${displayName} triggers through it, unless they can read it. Its triggers keep running.` + }) + if (!confirmed) return false } + + try { + await WorkspaceIntegrationService.setNativeTriggerWorkspaceConnection({ + workspace: $workspaceStore, + serviceName: serviceName as NativeServiceName, + requestBody: { path: path ?? null } + }) + sendUserToast(path ? `${path} is now the shared account` : 'Stopped sharing') + await loadConnections(serviceName) + return true + } catch (err: any) { + sendUserToast(`Failed to update the shared account: ${err.body ?? err.message}`, true) + return false + } + } + + function docsItem(config: ServiceConfig): Item[] { + return config.docsUrl + ? [{ displayName: 'Docs', icon: ExternalLink, href: config.docsUrl, hrefTarget: '_blank' }] + : [] + } + + async function openConfig(serviceName: string, leavingInstanceApp: boolean) { + const displayName = supportedServices[serviceName]?.displayName ?? serviceName + // A refresh token only works with the OAuth app that issued it. + if (leavingInstanceApp && (connections[serviceName]?.length ?? 0) > 0) { + const confirmed = await confirmationModal.ask({ + title: `Use your own ${displayName} OAuth app?`, + confirmationText: 'Continue', + children: `Accounts connected with the instance app will have to be connected again, at the same path, before their triggers work again.` + }) + if (!confirmed) return + } + configOpen[serviceName] = true } async function createOrUpdateIntegration(serviceName: string, oauthData: any) { if (!$workspaceStore) return + const displayName = supportedServices[serviceName]?.displayName ?? serviceName + const current = getIntegrationByService(serviceName)?.oauth_data + // Leaving the instance app is confirmed when the form opens; this covers editing our own app. + const clientChanges = + !!current?.client_id && + !current.instance_shared && + current.client_id !== (oauthData.client_id ?? '') + // A refresh token only works with the OAuth app that issued it. + if (clientChanges && (connections[serviceName]?.length ?? 0) > 0) { + const confirmed = await confirmationModal.ask({ + title: `Change the ${displayName} OAuth app?`, + confirmationText: 'Change', + children: `Accounts connected with the current app must be connected again, at the same path, before their triggers work again.` + }) + if (!confirmed) return + } + try { await WorkspaceIntegrationService.createNativeTriggerService({ workspace: $workspaceStore, - serviceName: serviceName as any, + serviceName: serviceName as NativeServiceName, requestBody: oauthData }) sendUserToast( @@ -207,33 +278,9 @@ } } - async function connectWithInstanceCredentials(serviceName: string) { - if (!$workspaceStore) return - - connecting = serviceName - try { - const redirectUri = getRedirectUri(serviceName) - const auth_url = await WorkspaceIntegrationService.generateInstanceConnectUrl({ - workspace: $workspaceStore, - serviceName: serviceName as NativeServiceName, - requestBody: { redirect_uri: redirectUri } - }) - - if (auth_url) { - window.location.href = auth_url - } - } catch (err: any) { - sendUserToast( - `Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.message}`, - true - ) - connecting = null - } - } - - function isConfigured(integration: WorkspaceIntegration): boolean { - if (integration.oauth_data === null) return false - if (integration.oauth_data.instance_shared) return true + /** Whether the workspace has its own OAuth app for the service. */ + function hasWorkspaceApp(integration: WorkspaceIntegration | null): boolean { + if (!integration?.oauth_data || integration.oauth_data.instance_shared) return false const serviceConfig = supportedServices[integration.service_name] const needsBaseUrl = serviceConfig?.requiresBaseUrl !== false return ( @@ -243,82 +290,10 @@ ) } - function isConnected(integration: WorkspaceIntegration): boolean { - if (integration.oauth_data?.instance_shared) { - return !!integration.resource_path - } - return isConfigured(integration) && !!integration.resource_path - } - function getIntegrationByService(serviceName: string): WorkspaceIntegration | null { return integrations.find((integration) => integration.service_name === serviceName) || null } - function handleOAuthCallback( - workspace: string, - serviceName: NativeServiceName, - code: string, - state: string - ) { - // Phase 1: store callback params and clean URL — don't call backend yet - pendingCallback = { serviceName, code, state, workspace } - - // Pre-populate resource path from existing integration (for reconnect) - const integration = getIntegrationByService(serviceName) - resourcePath = integration?.resource_path ?? undefined - - const url = new URL(page.url) - url.searchParams.delete('code') - url.searchParams.delete('state') - url.searchParams.delete('service') - goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true }) - } - - async function finalizePendingCallback() { - if (!pendingCallback) return - - const { serviceName, code, state, workspace } = pendingCallback - try { - const redirectUri = getRedirectUri(serviceName) - await WorkspaceIntegrationService.nativeTriggerServiceCallback({ - serviceName, - workspace, - requestBody: { - code, - state, - redirect_uri: redirectUri, - resource_path: resourcePath - } - }) - sendUserToast(`${supportedServices[serviceName]?.displayName} connected successfully!`) - await loadIntegrations() - } catch (err: any) { - sendUserToast(`Failed to complete OAuth connection: ${err.message}`, true) - } finally { - pendingCallback = null - resourcePath = undefined - pathError = undefined - } - } - - $effect(() => { - if ( - page.url.searchParams.has('code') && - page.url.searchParams.has('state') && - page.url.searchParams.has('service') && - $workspaceStore - ) { - const service = page.url.searchParams.get('service')! as NativeServiceName - const code = page.url.searchParams.get('code')! - const state = page.url.searchParams.get('state')! - handleOAuthCallback($workspaceStore, service, code, state) - } - }) - - function getRedirectUri(serviceName: string): string { - return `${window.location.origin}/workspace_settings?tab=native_triggers&service=${serviceName}` - } - $effect(() => { if ($workspaceStore) { loadIntegrations() @@ -329,49 +304,12 @@
    - {#if pendingCallback} - {@const serviceName = pendingCallback.serviceName} - {@const config = supportedServices[serviceName]} -
    -
    - Save {config?.displayName ?? serviceName} credentials as resource -
    -
    - Choose where to save the OAuth resource. This resource will store the access token for the - integration. -
    - -
    - - -
    -
    - {:else if loading} + {#if loading}
    {#each new Array(3) as _} @@ -381,164 +319,144 @@
    {#each Object.entries(supportedServices) as [serviceName, config]} {@const integration = getIntegrationByService(serviceName)} - {@const isConnecting = connecting === serviceName} - {@const isOAuthConfigured = integration && isConfigured(integration)} - {@const isServiceConnected = integration && isConnected(integration)} - {@const isShowingConfig = showingConfig === serviceName} + {@const workspaceApp = hasWorkspaceApp(integration)} + {@const usesInstanceApp = !workspaceApp && instanceSharingAvailable[serviceName]} + {@const workspaceConnection = connections[serviceName]?.find((c) => c.workspace)?.path} -
    -
    -
    -
    - -
    -
    -
    {config.displayName}
    -
    {config.description}
    -
    -
    - -
    - {#if isServiceConnected} -
    - - Connected -
    - - - {:else if isOAuthConfigured} - - - {:else if instanceSharingAvailable[serviceName]} - - {:else} - - {/if} - - {#if config.docsUrl} - - {/if} +
    +
    + +
    +
    {config.displayName}
    +
    {config.description}
    + {#if workspaceApp || usesInstanceApp} + openConfig(serviceName, false) + }, + ...docsItem(config), + { + displayName: 'Remove configuration', + icon: Trash2, + type: 'delete', + action: () => deleteIntegration(serviceName) + } + ] + : [ + { + displayName: 'Use a workspace OAuth app', + icon: KeyRound, + action: () => openConfig(serviceName, true) + }, + ...docsItem(config) + ]} + /> + {:else} + + {/if}
    - {#if instanceSharingAvailable[serviceName] && !isOAuthConfigured} -
    - -

    - Your instance admin has configured Google OAuth for native triggers. Before - connecting, ensure the following redirect URI has been added to the - Google Cloud Console by the instance admin: -

    - + {#if !workspaceApp && !usesInstanceApp} +
    + Not configured. Members can't create {config.displayName} triggers yet. +
    + {:else} +
    + + + {workspaceApp ? 'Configured in workspace.' : 'Configured by instance.'} + Members connect their own {config.displayName} account when they create a trigger. + +
    + + {#if serviceName === 'nextcloud' && !workspaceConnection} + + We recommend connecting one below and sharing it with the workspace. + {/if} + +
    +
    Shared account
    +
    + Members can create triggers as this account and pick from what it can access, + without seeing its token. +
    +
    + + {#key workspaceConnection} + setWorkspaceConnection(serviceName, path)} + onConnectionsChange={() => loadConnections(serviceName)} + /> + {/key} +
    {/if} - {#if isShowingConfig} -
    + { + await createOrUpdateIntegration(serviceName, oauthData) + configOpen[serviceName] = false + }} + > + {#snippet requirements()} {#if serviceName === 'nextcloud'} - -

    Nextcloud integration requires:

    -
      + +
      • Nextcloud 33 or later.
      • The - Windmill integration app - to be installed on your Nextcloud instance. + class="underline">Windmill integration app installed.
      • - Pretty URLs - - to be enabled on your Nextcloud instance. + class="underline">Pretty URLs enabled.
      {/if} - { - await createOrUpdateIntegration(serviceName, oauthData) - showingConfig = null - }} - /> -
    - {/if} + {/snippet} +
    {/each}
    - - {#if integrations.length === 0} - - Connect to external services above to enable native triggers for your workspace. - - {/if} {/if}
    diff --git a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte index a428a91e7c..769cc2d5fb 100644 --- a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte @@ -110,7 +110,7 @@ serviceAvailable = await isServiceAvailable(serviceName, $workspaceStore) if (!serviceAvailable) { sendUserToast( - `${serviceName} integration not available, set it in the workspace settings`, + `${serviceConfig.serviceDisplayName} isn't configured in this workspace`, true ) } @@ -145,7 +145,10 @@ } if (!serviceAvailable) { - sendUserToast('Cannot sync triggers: workspace integration not connected', true) + sendUserToast( + `Cannot sync: ${serviceConfig.serviceDisplayName} isn't configured in this workspace`, + true + ) return } @@ -159,17 +162,7 @@ loadTriggers() } catch (err: any) { const errorMessage = err.body || err.message - if ( - errorMessage.includes('workspace integration') || - errorMessage.includes('not connected') - ) { - sendUserToast( - `Cannot sync: ${serviceConfig.serviceDisplayName} workspace integration not connected. Please connect it in workspace settings.`, - true - ) - } else { - sendUserToast(`Failed to sync triggers: ${errorMessage}`, true) - } + sendUserToast(`Failed to sync triggers: ${errorMessage}`, true) } } @@ -237,8 +230,7 @@ type="warning" >
    - The workspace doesn't have a {serviceConfig?.serviceDisplayName || serviceName} integration - connected + {serviceConfig?.serviceDisplayName || serviceName} isn't configured in this workspace yet.