From dc1fb5087be7169045aad57e955dabe3e29de82e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 22 Oct 2026 16:17:51 +0200 Subject: [PATCH] feat: let native triggers act through per-user or shared connections (#11352) * fix: only let an oauth account's creator or readers link it to a variable Co-Authored-By: Claude Opus 5.5 * fix: drop an oauth account link the caller may not hold instead of failing Co-Authored-By: Claude Opus 5.5 * test: cover relinking an oauth account through a variable update Co-Authored-By: Claude Opus 5.5 * test: cover a permitted relink through a variable update Co-Authored-By: Claude Opus 5.5 * fix: keep an oauth account's creator in step with the user's username Co-Authored-By: Claude Opus 5.5 * fix: follow a non-member superadmin's instance username on the OAuth accounts they created Co-Authored-By: Claude Opus 5.5 * refactor: link a variable to its OAuth account only in the connect flow The variable endpoints no longer take an account link from the caller. The connect flows create the token variable first and pass its path to create_account, which links it server-side. This replaces the creator check and the created_by bookkeeping, along with both migrations. Co-Authored-By: Claude Opus 5.5 * chore: keep the MCP secret variable test's formatting Co-Authored-By: Claude Opus 5.5 * fix: link the account to the token variable AppConnect created, even if the path is edited mid-save Co-Authored-By: Claude Opus 5.5 * fix: stop exporting the OAuth account a variable links, and regenerate the chat tool schema Co-Authored-By: Claude Opus 5.5 * chore: pin the EE refresh and linking checks Co-Authored-By: Claude Opus 5.5 * fix: delete AppConnect's token variable when linking its account fails, so Save can be retried Co-Authored-By: Claude Opus 5.5 * fix: keep the resource AppConnect fills when linking its account fails Deleting a variable also deletes the resource at its path, so the rollback is skipped when filling an existing resource. Co-Authored-By: Claude Opus 5.5 * refactor: create an OAuth token variable and its account in one request createAccount takes the variable and the store creates both in one transaction, so a connect is either fully saved or not at all. The connect flows make that single call instead of creating the variable and linking it afterwards. Co-Authored-By: Claude Opus 5.5 * fix: stop listing the ignored account input in the chat's write_variable instructions Co-Authored-By: Claude Opus 5.5 * chore: drop the account visibility helper the oauth refresh route no longer uses Co-Authored-By: Claude Opus 5.5 * feat: let native triggers act through per-user or shared connections Co-Authored-By: Claude Opus 5.5 * fix: bind native trigger oauth state to its user and follow connection renames Co-Authored-By: Claude Opus 5.5 * fix: authorize oauth account links and scope native trigger disconnects Co-Authored-By: Claude Opus 5.5 * chore: order the native trigger connection migration after account ownership Co-Authored-By: Claude Opus 5.5 * fix: delete only the scope-checked triggers when disconnecting Co-Authored-By: Claude Opus 5.5 * fix: skip triggers moved since their scope check when disconnecting Co-Authored-By: Claude Opus 5.5 * fix: scope-check the connection path when connecting, keep a connection still in use Co-Authored-By: Claude Opus 5.5 * fix: key token write-back by account and serialize trigger creation with disconnect Co-Authored-By: Claude Opus 5.5 * feat: let an admin share one native trigger connection with the whole workspace Members can use the shared connection without reading its token. It reuses workspace_integrations.resource_path, so existing workspace connections stay usable by members after upgrading. Co-Authored-By: Claude Opus 5.5 * feat: pick the workspace connection like other workspace resources, connect and disconnect from the picker Co-Authored-By: Claude Opus 5.5 * fix: label the disconnect action in the connection picker Co-Authored-By: Claude Opus 5.5 * fix: drop the own-account note from the workspace connection field Co-Authored-By: Claude Opus 5.5 * fix: let admins share any connection, serialize service removal with trigger creation An admin can already read every connection, so restricting sharing to their own added nothing. Removing a service now takes each connection's lock and keeps everything if a trigger was created meanwhile. Co-Authored-By: Claude Opus 5.5 * feat: show each native trigger service's OAuth app status and keep removal apart Remove now only appears with a workspace OAuth app, since a workspace using the instance app has nothing of its own to remove. Co-Authored-By: Claude Opus 5.5 * feat: configure a native trigger service's OAuth app in a dialog The card states once which OAuth app is in use, with a single Configure action, and the form follows the design system. Co-Authored-By: Claude Opus 5.5 * fix: drop a stray brace from the OAuth app dialog binding Co-Authored-By: Claude Opus 5.5 * fix: lead the OAuth app row with which app is in use, with a link to change it Co-Authored-By: Claude Opus 5.5 * fix: compact the native trigger service card Edit and Remove sit on the workspace OAuth app row, and the workspace connection reads as part of the same section. Co-Authored-By: Claude Opus 5.5 * feat: show the OAuth app status in each native trigger card's header App actions move into a menu, the card body holds only the shared account, and service descriptions name what fires the trigger. Co-Authored-By: Claude Opus 5.5 * fix: say who configured each native trigger service, and name removal for what it removes Co-Authored-By: Claude Opus 5.5 * feat: say what members can do with each native trigger service, keep the shared account optional Nextcloud, whose webhooks need an admin account, offers connecting and sharing one directly instead. Co-Authored-By: Claude Opus 5.5 * fix: pick the shared account with an inline select, and keep Nextcloud's own-account path in its wording Co-Authored-By: Claude Opus 5.5 * feat: one status line per native trigger service, a Nextcloud admin hint, and clearer refusals The shared account is a regular form field, Nextcloud shows an alert while no admin account is shared, a non-admin Nextcloud account gets a message saying why it cannot create triggers, and the OAuth dialog only accepts a full server URL. Co-Authored-By: Claude Opus 5.5 * fix: point native trigger error hints at the account picker and the shared account Co-Authored-By: Claude Opus 5.5 * fix: say members can browse what a shared native trigger account can access Co-Authored-By: Claude Opus 5.5 * fix: make the OAuth app dialog's open prop required Co-Authored-By: Claude Opus 5.5 * fix: keep pre-connection triggers working, and guard reconnects and service removal - The migration recovers a service's connection when the workspace row lost its path, which saving the OAuth app or an abandoned instance connect used to do. - A trigger created without a connection uses the shared account, as older clients and trigger specs expect. - Only whoever connected a path, or an admin, may reconnect over it. - Removing a service or sharing an account checks a path-scoped token against what it touches. Co-Authored-By: Claude Opus 5.5 * feat: connect native trigger accounts at a chosen path through the resource drawer, and reconnect from the picker Co-Authored-By: Claude Opus 5.5 * chore: date the native trigger connection migration after main's newest Co-Authored-By: Claude Opus 5.5 * fix: say a disconnect deletes the triggers other members created on the account Co-Authored-By: Claude Opus 5.5 * fix: offer Reconnect next to an existing trigger's account and point the refresh hints at it Co-Authored-By: Claude Opus 5.5 * chore: date the native trigger connection migration after main's newest Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to da49c12e3006fc77de789277f1462a4d8d7d92ce This commit updates the EE repository reference after PR #829 was merged in windmill-ee-private. Previous ee-repo-ref: 58681a2e7eafb5689ed739cd2560fa0f5d82c3dc New ee-repo-ref: da49c12e3006fc77de789277f1462a4d8d7d92ce Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- .agents/skills/native-trigger/SKILL.md | 49 +- ...7ef9e7e6b6b793147b1752ff07e800415bfa9.json | 16 + ...2f000ae6cf37928508a82f51d3b3a325f3d1a.json | 23 + ...d399c37957f832485156f703f59bd39cdc2bb.json | 24 + ...71d56af87b5b263f8484c886e7f51e0198409.json | 47 + ...075c6ae51404037b0a7d4d33faf6a7d6a6d8.json} | 5 +- ...08cc81ee85cf98696d9e29a34557587eaf86.json} | 12 +- ...2f20bb5a0aa921a614bd02898d3f162660c2.json} | 14 +- ...69c4fb77e2fa3b98d6b054995875f3fd5a10.json} | 9 +- ...15a59165009048a7c93942ee2ecd78524fb5a.json | 15 + ...f7c5090f6ed3b4d9d03fa5b5a827ffa39c635.json | 15 + ...c595c6f0d6f215725132fb7ef33bb1847831d.json | 28 + ...da7abd1836ec53fdc8086aa160735843f62b9.json | 36 + ...b1c3f543964d2c05d2f44f0a7716f890a3fce.json | 29 + ...17efc6c8c87ba5542bce9c5f85b1da07e9a4b.json | 16 + ...99322f13a7db686176c4a717651c1671b1bae.json | 27 + ...320bb3eff65255a44bd66799ae14288312ba4.json | 23 + ...39369e386f6a552c6626be48ecaa0e5ab78a7.json | 16 - ...5903c9fa60a5435fa73c587da2b913203cf18.json | 36 + ...0e465c202fa3784fd179bcd8963217327a03b.json | 35 + ...e235c9b3083b4c179f819c927342bc58651f.json} | 10 +- ...03654e796f71a876ea6d6ebf08c4f65b2156e.json | 35 + ...beb6769dc6fc97269beee607a5625a495a121.json | 15 - ...ba774a6ded50ab047d0b0302c7430cee5b70.json} | 7 +- ...5fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json | 23 + ...61272c2916540062d57d36c8471c40a398727.json | 27 + ...35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json | 16 + ...453197d7683bc8fd5a96be462e3493e957cee.json | 16 + ...04a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json} | 12 +- ...e6cc17f767055de943709192ef1ff5106eac5.json | 23 + ...ceed91c54f592d0e1f5139d5e111241ca451.json} | 12 +- ...51a4d3bbe6d1309cd8205e853097c9f81a471.json | 16 + ...c72164b36ccb5434bcc4babefa7f3363c675.json} | 6 +- ...b3efce1740b7037cac9983529bc052cf2dd9.json} | 12 +- ...2ceee71c5f0ed6aca603e7ec7aae47f7cdade.json | 42 + ...2125411_native_trigger_connection.down.sql | 1 + ...022125411_native_trigger_connection.up.sql | 24 + .../tests/native_triggers.rs | 225 +++-- backend/windmill-api/openapi.yaml | 160 +++- backend/windmill-api/src/offboarding.rs | 5 + backend/windmill-api/src/users.rs | 9 + .../windmill-common/src/global_settings.rs | 2 + .../src/github/external.rs | 64 +- .../src/github/mod.rs | 1 + .../src/github/routes.rs | 36 +- .../src/google/external.rs | 116 ++- .../src/google/mod.rs | 1 + .../src/google/routes.rs | 76 +- .../windmill-native-triggers/src/handler.rs | 143 ++- backend/windmill-native-triggers/src/lib.rs | 319 +++++-- .../src/nextcloud/external.rs | 44 +- .../src/nextcloud/mod.rs | 1 + .../src/nextcloud/routes.rs | 22 +- .../windmill-native-triggers/src/rename.rs | 25 +- backend/windmill-native-triggers/src/sync.rs | 55 +- .../src/workspace_integrations.rs | 833 ++++++++++++------ backend/windmill-store/src/resources.rs | 9 + backend/windmill-store/src/variables.rs | 9 + cli/src/commands/trigger/trigger.ts | 2 + .../lib/components/AppConnectDrawer.svelte | 10 +- .../src/lib/components/AppConnectInner.svelte | 109 ++- .../native/NativeConnectionPicker.svelte | 245 ++++++ .../native/NativeOAuthCallback.svelte | 69 ++ .../native/NativeTriggerEditor.svelte | 64 +- .../services/github/GitHubTriggerForm.svelte | 5 +- .../google/GoogleCalendarPicker.svelte | 5 +- .../services/google/GoogleDrivePicker.svelte | 6 +- .../nextcloud/NextcloudTriggerForm.svelte | 17 +- .../lib/components/triggers/native/utils.ts | 98 ++- .../OAuthClientConfig.svelte | 202 ++--- .../WorkspaceIntegrations.svelte | 548 +++++------- .../[service_name]/+page.svelte | 22 +- .../(logged)/workspace_settings/+page.svelte | 14 +- .../[client_name]/native_trigger/+page.svelte | 8 + 74 files changed, 3179 insertions(+), 1172 deletions(-) create mode 100644 backend/.sqlx/query-00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9.json create mode 100644 backend/.sqlx/query-00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a.json create mode 100644 backend/.sqlx/query-1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb.json create mode 100644 backend/.sqlx/query-1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409.json rename backend/.sqlx/{query-a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c.json => query-2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8.json} (55%) rename backend/.sqlx/{query-0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578.json => query-30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86.json} (67%) rename backend/.sqlx/{query-7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef.json => query-3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2.json} (76%) rename backend/.sqlx/{query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json => query-4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10.json} (68%) create mode 100644 backend/.sqlx/query-4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a.json create mode 100644 backend/.sqlx/query-51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635.json create mode 100644 backend/.sqlx/query-5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d.json create mode 100644 backend/.sqlx/query-59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9.json create mode 100644 backend/.sqlx/query-5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce.json create mode 100644 backend/.sqlx/query-61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b.json create mode 100644 backend/.sqlx/query-649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae.json create mode 100644 backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json delete mode 100644 backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json create mode 100644 backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json create mode 100644 backend/.sqlx/query-74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b.json rename backend/.sqlx/{query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json => query-78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f.json} (64%) create mode 100644 backend/.sqlx/query-79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e.json delete mode 100644 backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json rename backend/.sqlx/{query-64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63.json => query-8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70.json} (61%) create mode 100644 backend/.sqlx/query-9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json create mode 100644 backend/.sqlx/query-bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727.json create mode 100644 backend/.sqlx/query-c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json create mode 100644 backend/.sqlx/query-c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee.json rename backend/.sqlx/{query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json => query-c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json} (65%) create mode 100644 backend/.sqlx/query-d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5.json rename backend/.sqlx/{query-0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd.json => query-d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451.json} (82%) create mode 100644 backend/.sqlx/query-daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471.json rename backend/.sqlx/{query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json => query-e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675.json} (68%) rename backend/.sqlx/{query-c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1.json => query-efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9.json} (83%) create mode 100644 backend/.sqlx/query-f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade.json create mode 100644 backend/migrations/20261022125411_native_trigger_connection.down.sql create mode 100644 backend/migrations/20261022125411_native_trigger_connection.up.sql create mode 100644 frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte create mode 100644 frontend/src/lib/components/triggers/native/NativeOAuthCallback.svelte create mode 100644 frontend/src/routes/oauth/callback/[client_name]/native_trigger/+page.svelte diff --git a/.agents/skills/native-trigger/SKILL.md b/.agents/skills/native-trigger/SKILL.md index ae38f70b32..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.