diff --git a/.agents/skills/native-trigger/SKILL.md b/.agents/skills/native-trigger/SKILL.md index 552da33912..6342ec3e48 100644 --- a/.agents/skills/native-trigger/SKILL.md +++ b/.agents/skills/native-trigger/SKILL.md @@ -130,7 +130,10 @@ accounts) are many per service. Each trigger acts through one connection, named A user may use a connection when they can read its variable (checked with a user transaction, so the variable's row-level security decides): `resolve_usable_connection` / -`list_usable_connections` in `lib.rs`. Background work (sync, renewal, rename re-registration, +`list_usable_connections` in `lib.rs`. The exception is the **workspace connection**: an admin +may share one connection they made 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 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-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json b/backend/.sqlx/query-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json new file mode 100644 index 0000000000..66d7fd5e1f --- /dev/null +++ b/backend/.sqlx/query-3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3f77282b7e0611ef7e6e8de654381208165ba7414fc592ca8dfffc9838ac715e" +} diff --git a/backend/.sqlx/query-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-72c123eb6a0d66f4e57d86694581772154b09f4a89903462875e57c9760a19e8.json b/backend/.sqlx/query-72c123eb6a0d66f4e57d86694581772154b09f4a89903462875e57c9760a19e8.json new file mode 100644 index 0000000000..b143a4f1b1 --- /dev/null +++ b/backend/.sqlx/query-72c123eb6a0d66f4e57d86694581772154b09f4a89903462875e57c9760a19e8.json @@ -0,0 +1,25 @@ +{ + "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 JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE v.workspace_id = $1 AND v.path = $2 AND a.client = $3\n AND a.is_workspace_integration = true AND r.created_by = $4)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "72c123eb6a0d66f4e57d86694581772154b09f4a89903462875e57c9760a19e8" +} 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-89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5.json b/backend/.sqlx/query-89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5.json new file mode 100644 index 0000000000..ca3059dbd8 --- /dev/null +++ b/backend/.sqlx/query-89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "89791ad1a0862b6475ebfdeb54b0101e124fcf9d12e93d84b44457c72c7604a5" +} diff --git a/backend/.sqlx/query-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-c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53.json b/backend/.sqlx/query-c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53.json deleted file mode 100644 index 9428c71bc3..0000000000 --- a/backend/.sqlx/query-c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT v.path, r.created_by AS \"owner?\"\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true\n ORDER BY v.path", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "owner?", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - true - ] - }, - "hash": "c93af685f45ed641a9f2bb3beed5298eef6f75ec3852e0f007b98f71bfe56d53" -} diff --git a/backend/.sqlx/query-f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d.json b/backend/.sqlx/query-f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d.json new file mode 100644 index 0000000000..77f8950a4b --- /dev/null +++ b/backend/.sqlx/query-f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f5460eb13ea4f0e9896928a3266e419090f72e54f7347b35254a661116ba822d" +} diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 7ef2818f96..876518640d 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,8 +23,8 @@ 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, update_oauth_token_resource, External, @@ -220,6 +221,45 @@ async fn test_connections_are_isolated(db: Pool) -> anyhow::Result<()> 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'", + ) + .bind(path) + .execute(&db) + .await?; + assert_eq!(usable_paths().await?, vec![(path.to_string(), true)]); + + Ok(()) +} + // ============================================================================ // 2. Config Loading — workspace vs instance + token update // ============================================================================ diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d73219efcb..c7067f364d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -20732,6 +20732,38 @@ paths: schema: type: string + /w/{workspace}/native_triggers/integrations/{service_name}/workspace_connection: + post: + summary: share one of your connections with every member of the workspace, or stop sharing + operationId: setNativeTriggerWorkspaceConnection + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + path: + type: string + nullable: true + description: The connection to share, or null to stop sharing + responses: + "200": + description: workspace connection updated + content: + application/json: + schema: + type: string + /w/{workspace}/native_triggers/integrations/{service_name}/delete: delete: summary: delete native trigger service @@ -39312,8 +39344,12 @@ components: 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 diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index d8354e0f39..51621976ed 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -1192,6 +1192,11 @@ async fn offboard_user_from_workspace<'c>( &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 + ).execute(&mut **tx).await?; + sqlx::query!( r#"UPDATE workspace_runnable_dependencies SET flow_path = REGEXP_REPLACE(flow_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE flow_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 32fc6c1fa8..b802c5132f 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -625,6 +625,15 @@ async fn update_username_in_workpsace<'c>( .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, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + sqlx::query!( "UPDATE variable SET extra_perms = extra_perms - ('u/' || $2) || jsonb_build_object(('u/' || $1), extra_perms->('u/' || $2)) WHERE extra_perms ? ('u/' || $2) AND workspace_id = $3", new_username, diff --git a/backend/windmill-native-triggers/src/github/routes.rs b/backend/windmill-native-triggers/src/github/routes.rs index e5a665a85c..8a20d72693 100644 --- a/backend/windmill-native-triggers/src/github/routes.rs +++ b/backend/windmill-native-triggers/src/github/routes.rs @@ -26,6 +26,7 @@ async fn list_repos( ) -> JsonResult> { let connection_path = picker_connection( &authed, + &db, user_db, &workspace_id, ServiceName::Github, diff --git a/backend/windmill-native-triggers/src/google/routes.rs b/backend/windmill-native-triggers/src/google/routes.rs index a0e6bb9fd6..dec2010ab2 100644 --- a/backend/windmill-native-triggers/src/google/routes.rs +++ b/backend/windmill-native-triggers/src/google/routes.rs @@ -97,6 +97,7 @@ async fn list_calendars( ) -> JsonResult> { let connection_path = picker_connection( &authed, + &db, user_db, &workspace_id, ServiceName::Google, @@ -145,6 +146,7 @@ async fn list_drive_files( ) -> JsonResult { let connection_path = picker_connection( &authed, + &db, user_db, &workspace_id, ServiceName::Google, @@ -236,6 +238,7 @@ async fn list_shared_drives( ) -> JsonResult> { let connection_path = picker_connection( &authed, + &db, user_db, &workspace_id, ServiceName::Google, diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index fb6d8016d8..c920c48195 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -189,6 +189,7 @@ async fn create_native_trigger( .await?; let connection_path = resolve_usable_connection( + &db, &mut tx, &workspace_id, service_name.integration_service(), @@ -245,12 +246,13 @@ async fn create_native_trigger( }; 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(&mut *tx) + .fetch_one(&db) .await? .unwrap_or(false); if !still_connected { @@ -323,7 +325,8 @@ async fn update_native_trigger_handler( // to act as it, not just to write the runnable. let integration_service = service_name.integration_service(); if let Some(path) = existing.connection_path.as_deref() { - resolve_usable_connection(&mut tx, &workspace_id, integration_service, Some(path)).await?; + resolve_usable_connection(&db, &mut tx, &workspace_id, integration_service, Some(path)) + .await?; } let oauth_data: T::OAuthData = decrypt_oauth_data( &db, @@ -487,12 +490,15 @@ async fn get_native_trigger_handler( // 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(&mut tx, &workspace_id, service_name.integration_service()) - .await? - .iter() - .any(|c| c.path == path) - } + 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 { diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 37cd34dd87..b58ec2d629 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -1759,19 +1759,23 @@ 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. +/// 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 connections = sqlx::query_as!( + let mut connections = sqlx::query_as!( Connection, - r#"SELECT v.path, r.created_by AS "owner?" + 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 @@ -1782,18 +1786,48 @@ pub async fn list_usable_connections( ) .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 caller's only usable connection. `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(tx, workspace_id, service_name).await?; + 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!( @@ -1838,6 +1872,7 @@ pub struct ConnectionQuery { /// 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, @@ -1845,7 +1880,8 @@ pub async fn picker_connection( ) -> Result { require_native_integration_use(authed)?; let mut tx = user_db.begin(authed).await?; - let path = resolve_usable_connection(&mut tx, workspace_id, service_name, requested).await?; + let path = + resolve_usable_connection(db, &mut tx, workspace_id, service_name, requested).await?; tx.commit().await?; Ok(path) } diff --git a/backend/windmill-native-triggers/src/nextcloud/routes.rs b/backend/windmill-native-triggers/src/nextcloud/routes.rs index b17dc6a709..11e9f59227 100644 --- a/backend/windmill-native-triggers/src/nextcloud/routes.rs +++ b/backend/windmill-native-triggers/src/nextcloud/routes.rs @@ -30,6 +30,7 @@ async fn list_available_events( ) -> JsonResult> { let connection_path = picker_connection( &authed, + &db, user_db, &workspace_id, ServiceName::Nextcloud, diff --git a/backend/windmill-native-triggers/src/workspace_integrations.rs b/backend/windmill-native-triggers/src/workspace_integrations.rs index 3d52c5a15a..121b90e68a 100644 --- a/backend/windmill-native-triggers/src/workspace_integrations.rs +++ b/backend/windmill-native-triggers/src/workspace_integrations.rs @@ -1055,11 +1055,12 @@ async fn check_instance_sharing_available( #[cfg(feature = "native_trigger")] async fn list_connections( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path((workspace_id, service_name)): Path<(String, ServiceName)>, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; - let connections = list_usable_connections(&mut tx, &workspace_id, service_name).await?; + let connections = list_usable_connections(&db, &mut tx, &workspace_id, service_name).await?; tx.commit().await?; Ok(Json(connections)) } @@ -1070,6 +1071,83 @@ 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)?; + + // Sharing lets everyone act as the account behind it, so only the one who connected it may. + if let Some(path) = path.as_deref() { + let own = 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 + JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path + WHERE v.workspace_id = $1 AND v.path = $2 AND a.client = $3 + AND a.is_workspace_integration = true AND r.created_by = $4)", + workspace_id, + path, + service_name.as_str(), + authed.username, + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !own { + return Err(Error::BadRequest(format!( + "No {service_name} connection you made at {path}. You can only share an account \ + you connected yourself." + ))); + } + } + + 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, + Some(path.as_deref().unwrap_or("none")), + None, + ) + .await?; + tx.commit().await?; + + Ok(Json(match path { + Some(path) => format!("{path} is now the workspace connection"), + None => "No workspace connection".to_string(), + })) +} + /// Disconnect one account: its triggers are deleted with it, since nothing else can act on /// their registrations. #[cfg(feature = "native_trigger")] @@ -1092,7 +1170,7 @@ async fn delete_connection( .await?; let mut tx = user_db.clone().begin(&authed).await?; - let usable = list_usable_connections(&mut tx, &workspace_id, service_name).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!( @@ -1157,6 +1235,17 @@ async fn delete_connection( ))); } + // 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, @@ -1193,6 +1282,10 @@ pub fn workspaced_service() -> Router { "/{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 1bd9bb39ec..3dbaba1fd0 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -2174,6 +2174,15 @@ async fn update_resource( .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3", + npath, + w_id, + path + ) + .execute(&mut *tx) + .await?; + // Update ci_test_reference when a tested resource is renamed sqlx::query!( "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'resource'", diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 9d8c932d2c..061062b433 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -1321,6 +1321,15 @@ async fn update_variable( .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3", + npath, + w_id, + path + ) + .execute(&mut *tx) + .await?; + sqlx::query!( "UPDATE ws_specific SET path = $1 WHERE workspace_id = $2 AND item_kind = 'variable' AND path = $3", npath, diff --git a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte index 42f3196c5d..705e434869 100644 --- a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte +++ b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte @@ -61,7 +61,10 @@ } const items = $derived.by(() => { - const list = (connections ?? []).map((c) => ({ label: c.path, value: c.path })) + const list = (connections ?? []).map((c) => ({ + label: c.workspace ? `${c.path} (workspace connection)` : c.path, + value: c.path + })) if (value && !list.some((i) => i.value === value)) { list.push({ label: value, value }) } diff --git a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte index b46cadb809..d14c5a4c2d 100644 --- a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte +++ b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte @@ -1,7 +1,7 @@