From 8e508ea01a1b41bd48b2ec3db29123430938f444 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 21 Aug 2026 10:41:14 +0200 Subject: [PATCH] feat: support application default credentials for gcp pub/sub triggers (#10778) * feat: support application default credentials for gcp pub/sub triggers Co-Authored-By: Claude Opus 5 * fix: address review findings on gcp application default credentials Co-Authored-By: Claude Opus 5 * fix: address review nits on gcp application default credentials Co-Authored-By: Claude Opus 5 * fix: key the gcp credential-mode permission off the loaded mode Co-Authored-By: Claude Opus 5 * fix: gate enabling an ADC gcp trigger on workspace admin Co-Authored-By: Claude Opus 5 * fix: lock the gcp trigger row while authorizing a mode change Co-Authored-By: Claude Opus 5 * fix: skip admin-only gcp listing when the caller cannot use those credentials Co-Authored-By: Claude Opus 5 * chore: update ee-repo-ref to 54bf630681000c8ed87a7067e357118e015123b1 This commit updates the EE repository reference after PR #738 was merged in windmill-ee-private. Previous ee-repo-ref: 91d0e228a0ad226625278b400c64f96a61404a10 New ee-repo-ref: 54bf630681000c8ed87a7067e357118e015123b1 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- README.md | 1 + ...b413248cbb3bab75b0249a9fc4c2fd02da0bc.json | 15 ++ ...58402f2e627baa957e0fed3ca09ea9ca869b3.json | 52 ++++ ...1bf6f5d3d350db5e6cd98557d0280c56ff0fa.json | 23 ++ ...76b76c7377066a06dba98a2921a68928aa644.json | 2 +- ...c91e2db92d2d2f45fd07a354f30e6248f21e6.json | 53 +++++ ...e376d2cbb9a35ba68a5641ebce31aad10f513.json | 100 ++++++++ backend/Cargo.lock | 12 + backend/Cargo.toml | 5 +- backend/ee-repo-ref.txt | 2 +- ...r_application_default_credentials.down.sql | 25 ++ ...ger_application_default_credentials.up.sql | 25 ++ .../tests/triggers.rs | 69 +++++- .../windmill-api-workspaces/src/workspaces.rs | 4 +- backend/windmill-api/openapi.yaml | 98 +++++++- backend/windmill-api/src/capture.rs | 26 +- backend/windmill-trigger/src/handler.rs | 30 ++- cli/src/guidance/skills.gen.ts | 15 +- .../copilot/chat/workspaceToolsZod.gen.ts | 7 +- .../gcp/GcpTriggerEditorConfigSection.svelte | 222 ++++++++++++++---- .../triggers/gcp/GcpTriggerEditorInner.svelte | 69 ++++-- .../src/lib/components/triggers/gcp/utils.ts | 3 +- .../triggers/workspaceTriggersList.ts | 11 +- .../(root)/(logged)/gcp_triggers/+page.svelte | 20 +- .../schemas/gcp_trigger.schema.yaml | 15 +- 25 files changed, 797 insertions(+), 107 deletions(-) create mode 100644 backend/.sqlx/query-30aa4958c8ed0459c9f83e3963bb413248cbb3bab75b0249a9fc4c2fd02da0bc.json create mode 100644 backend/.sqlx/query-3382ba7f9f437d0def24f8cee2a58402f2e627baa957e0fed3ca09ea9ca869b3.json create mode 100644 backend/.sqlx/query-9900abe4112310ccdace809aecf1bf6f5d3d350db5e6cd98557d0280c56ff0fa.json create mode 100644 backend/.sqlx/query-c7de1fe77fa2be4701b7185a3b3c91e2db92d2d2f45fd07a354f30e6248f21e6.json create mode 100644 backend/.sqlx/query-d7a8660d977b1cb516871dff0f7e376d2cbb9a35ba68a5641ebce31aad10f513.json create mode 100644 backend/migrations/20260820073246_gcp_trigger_application_default_credentials.down.sql create mode 100644 backend/migrations/20260820073246_gcp_trigger_application_default_credentials.up.sql diff --git a/README.md b/README.md index 71d4d39a19..87fc66f399 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,7 @@ On self-hosted instances, you might want to import all the approved resource typ | MIN_FREE_DISK_SPACE_MB | 15000 | Minimum amount of free space on worker. Sends critical alert if worker has less free space. | Worker | | RUN_UPDATE_CA_CERTIFICATE_AT_START | false | If true, runs CA certificate update command at startup before other initialization | All | | RUN_UPDATE_CA_CERTIFICATE_PATH | /usr/sbin/update-ca-certificates | Path to the CA certificate update command/script to run when RUN_UPDATE_CA_CERTIFICATE_AT_START is true | All | +| GOOGLE_APPLICATION_CREDENTIALS | None | (ee only) Credentials file for GCP Pub/Sub triggers that authenticate as the instance rather than through a `gcloud` resource (workspace admins only). Application default credentials also resolve the gcloud well-known file and the GCE metadata server. Workload Identity Federation files work with the `file`, `url` and `aws` credential sources; the `executable` source is not supported. | Server | ## Run a local dev setup diff --git a/backend/.sqlx/query-30aa4958c8ed0459c9f83e3963bb413248cbb3bab75b0249a9fc4c2fd02da0bc.json b/backend/.sqlx/query-30aa4958c8ed0459c9f83e3963bb413248cbb3bab75b0249a9fc4c2fd02da0bc.json new file mode 100644 index 0000000000..90b78b509a --- /dev/null +++ b/backend/.sqlx/query-30aa4958c8ed0459c9f83e3963bb413248cbb3bab75b0249a9fc4c2fd02da0bc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO gcp_trigger (\n gcp_resource_path, project_id, topic_id, subscription_id, delivery_type,\n delivery_config, path, script_path, is_flow, workspace_id, edited_by,\n edited_at, extra_perms, server_id, last_server_ping, error,\n subscription_mode, error_handler_path, error_handler_args, retry,\n auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels\n )\n SELECT\n gcp_resource_path, project_id, topic_id, subscription_id, delivery_type,\n delivery_config, path, script_path, is_flow, $1, edited_by,\n edited_at, extra_perms, NULL, NULL, NULL,\n subscription_mode, error_handler_path, error_handler_args, retry,\n auto_acknowledge_msg, ack_deadline, 'disabled'::TRIGGER_MODE, permissioned_as, labels\n FROM gcp_trigger WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "30aa4958c8ed0459c9f83e3963bb413248cbb3bab75b0249a9fc4c2fd02da0bc" +} diff --git a/backend/.sqlx/query-3382ba7f9f437d0def24f8cee2a58402f2e627baa957e0fed3ca09ea9ca869b3.json b/backend/.sqlx/query-3382ba7f9f437d0def24f8cee2a58402f2e627baa957e0fed3ca09ea9ca869b3.json new file mode 100644 index 0000000000..f50463247b --- /dev/null +++ b/backend/.sqlx/query-3382ba7f9f437d0def24f8cee2a58402f2e627baa957e0fed3ca09ea9ca869b3.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO gcp_trigger (\n gcp_resource_path,\n project_id,\n subscription_id,\n topic_id,\n delivery_type,\n delivery_config,\n workspace_id,\n path,\n script_path,\n is_flow,\n permissioned_as,\n mode,\n edited_by,\n error_handler_path,\n error_handler_args,\n retry,\n auto_acknowledge_msg,\n ack_deadline\n )\n VALUES (\n $1,\n $2,\n $3,\n $4,\n $5,\n $6,\n $7,\n $8,\n $9,\n $10,\n $11,\n $12,\n $13,\n $14,\n $15,\n $16,\n $17,\n $18\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + { + "Custom": { + "name": "delivery_mode", + "kind": { + "Enum": [ + "push", + "pull" + ] + } + } + }, + "Jsonb", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar", + { + "Custom": { + "name": "trigger_mode", + "kind": { + "Enum": [ + "enabled", + "disabled", + "suspended" + ] + } + } + }, + "Varchar", + "Varchar", + "Jsonb", + "Jsonb", + "Bool", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "3382ba7f9f437d0def24f8cee2a58402f2e627baa957e0fed3ca09ea9ca869b3" +} diff --git a/backend/.sqlx/query-9900abe4112310ccdace809aecf1bf6f5d3d350db5e6cd98557d0280c56ff0fa.json b/backend/.sqlx/query-9900abe4112310ccdace809aecf1bf6f5d3d350db5e6cd98557d0280c56ff0fa.json new file mode 100644 index 0000000000..d634e46749 --- /dev/null +++ b/backend/.sqlx/query-9900abe4112310ccdace809aecf1bf6f5d3d350db5e6cd98557d0280c56ff0fa.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT gcp_resource_path IS NULL FROM gcp_trigger WHERE workspace_id = $1 AND path = $2 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9900abe4112310ccdace809aecf1bf6f5d3d350db5e6cd98557d0280c56ff0fa" +} diff --git a/backend/.sqlx/query-a80a17cda8ad722a97f16526d8576b76c7377066a06dba98a2921a68928aa644.json b/backend/.sqlx/query-a80a17cda8ad722a97f16526d8576b76c7377066a06dba98a2921a68928aa644.json index cd334ca6dd..6dcad040f4 100644 --- a/backend/.sqlx/query-a80a17cda8ad722a97f16526d8576b76c7377066a06dba98a2921a68928aa644.json +++ b/backend/.sqlx/query-a80a17cda8ad722a97f16526d8576b76c7377066a06dba98a2921a68928aa644.json @@ -72,7 +72,7 @@ ] }, "nullable": [ - false, + true, false, false, false, diff --git a/backend/.sqlx/query-c7de1fe77fa2be4701b7185a3b3c91e2db92d2d2f45fd07a354f30e6248f21e6.json b/backend/.sqlx/query-c7de1fe77fa2be4701b7185a3b3c91e2db92d2d2f45fd07a354f30e6248f21e6.json new file mode 100644 index 0000000000..fc2378576f --- /dev/null +++ b/backend/.sqlx/query-c7de1fe77fa2be4701b7185a3b3c91e2db92d2d2f45fd07a354f30e6248f21e6.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n gcp_trigger\n SET\n gcp_resource_path = $1,\n subscription_id = $2,\n topic_id = $3,\n delivery_type = $4,\n delivery_config = $5,\n is_flow = $6,\n edited_by = $7,\n permissioned_as = $8,\n script_path = $9,\n path = $10,\n mode = $11,\n edited_at = now(),\n error = NULL,\n server_id = NULL,\n error_handler_path = $14,\n error_handler_args = $15,\n retry = $16,\n auto_acknowledge_msg = $17,\n ack_deadline = $18,\n project_id = $19\n WHERE\n workspace_id = $12 AND\n path = $13\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + { + "Custom": { + "name": "delivery_mode", + "kind": { + "Enum": [ + "push", + "pull" + ] + } + } + }, + "Jsonb", + "Bool", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + { + "Custom": { + "name": "trigger_mode", + "kind": { + "Enum": [ + "enabled", + "disabled", + "suspended" + ] + } + } + }, + "Text", + "Text", + "Varchar", + "Jsonb", + "Jsonb", + "Bool", + "Int4", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "c7de1fe77fa2be4701b7185a3b3c91e2db92d2d2f45fd07a354f30e6248f21e6" +} diff --git a/backend/.sqlx/query-d7a8660d977b1cb516871dff0f7e376d2cbb9a35ba68a5641ebce31aad10f513.json b/backend/.sqlx/query-d7a8660d977b1cb516871dff0f7e376d2cbb9a35ba68a5641ebce31aad10f513.json new file mode 100644 index 0000000000..9f18e21c22 --- /dev/null +++ b/backend/.sqlx/query-d7a8660d977b1cb516871dff0f7e376d2cbb9a35ba68a5641ebce31aad10f513.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n gcp_resource_path AS \"gcp_resource_path: _\",\n script_path,\n is_flow,\n mode as \"mode: _\",\n workspace_id,\n path,\n edited_by,\n permissioned_as,\n delivery_config AS \"delivery_config: _\",\n retry as \"retry: _\",\n error_handler_path,\n error_handler_args as \"error_handler_args: _\"\n FROM\n gcp_trigger\n WHERE\n workspace_id = $1 AND\n path = $2 AND\n delivery_type = 'push'::DELIVERY_MODE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "gcp_resource_path: _", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_flow", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "mode: _", + "type_info": { + "Custom": { + "name": "trigger_mode", + "kind": { + "Enum": [ + "enabled", + "disabled", + "suspended" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "edited_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "delivery_config: _", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "retry: _", + "type_info": "Jsonb" + }, + { + "ordinal": 10, + "name": "error_handler_path", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "error_handler_args: _", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "d7a8660d977b1cb516871dff0f7e376d2cbb9a35ba68a5641ebce31aad10f513" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c67b6abcb0..ac8df0b6c9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -5612,15 +5612,21 @@ dependencies = [ "base64 0.21.7", "google-cloud-metadata", "google-cloud-token", + "hex", + "hmac", "home", "jsonwebtoken 9.3.1", + "path-clean", + "percent-encoding", "reqwest 0.12.28", "serde", "serde_json", + "sha2 0.10.9", "thiserror 1.0.69", "time", "tokio", "tracing", + "url", "urlencoding", ] @@ -8935,6 +8941,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "path-clean" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" + [[package]] name = "pathdiff" version = "0.2.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 9fc3c202bb..7019ce1638 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -522,7 +522,10 @@ rustls-pemfile = "2.2.0" # only used with special deno_core_mac feature to prevent ffi issue on macos, requires libffi to be installed libffi-sys = { version = "2.3.0", features = ["system"]} -google-cloud-pubsub = "0.30.0" +# `external-account` is off in the crate's defaults, which compiles out the token source for +# Workload Identity Federation credentials — the keyless setup for running outside GCP. Without it +# an ADC file of that type is rejected as an unsupported account type. +google-cloud-pubsub = { version = "0.30.0", features = ["external-account"] } google-cloud-googleapis = {version = "0.16.1", features = ["pubsub"]} # TODO: remove once deno fixes the issue on their end # https://github.com/denoland/deno/issues/28557 diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 40fb2fa235..8c0157e541 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -c6902ec2c51dc0ce30962afbfab3e456c5d9b831 +54bf630681000c8ed87a7067e357118e015123b1 diff --git a/backend/migrations/20260820073246_gcp_trigger_application_default_credentials.down.sql b/backend/migrations/20260820073246_gcp_trigger_application_default_credentials.down.sql new file mode 100644 index 0000000000..cde87d3d4f --- /dev/null +++ b/backend/migrations/20260820073246_gcp_trigger_application_default_credentials.down.sql @@ -0,0 +1,25 @@ +-- Add down migration script here + +DROP INDEX unique_subscription_per_gcp_resource; + +DELETE FROM gcp_trigger WHERE gcp_resource_path IS NULL; + +-- Rows that differed only by project_id, and rows whose subscription no longer fits the narrower +-- column, would both make the restored index or constraint fail after the column changes below. +DELETE FROM gcp_trigger WHERE char_length(subscription_id::text) > 255; +DELETE FROM gcp_trigger t USING gcp_trigger keep +WHERE t.ctid > keep.ctid + AND t.subscription_id = keep.subscription_id + AND t.gcp_resource_path = keep.gcp_resource_path + AND t.workspace_id = keep.workspace_id; + +ALTER TABLE gcp_trigger DROP COLUMN project_id; +ALTER TABLE gcp_trigger ALTER COLUMN gcp_resource_path SET NOT NULL; + +ALTER TABLE gcp_trigger DROP CONSTRAINT gcp_trigger_subscription_id_check; +ALTER TABLE gcp_trigger ALTER COLUMN subscription_id TYPE VARCHAR(255); +ALTER TABLE gcp_trigger ADD CONSTRAINT gcp_trigger_subscription_id_check + CHECK (char_length(subscription_id::text) >= 3 AND char_length(subscription_id::text) <= 255); + +CREATE UNIQUE INDEX unique_subscription_per_gcp_resource +ON gcp_trigger (subscription_id, gcp_resource_path, workspace_id); diff --git a/backend/migrations/20260820073246_gcp_trigger_application_default_credentials.up.sql b/backend/migrations/20260820073246_gcp_trigger_application_default_credentials.up.sql new file mode 100644 index 0000000000..3778892643 --- /dev/null +++ b/backend/migrations/20260820073246_gcp_trigger_application_default_credentials.up.sql @@ -0,0 +1,25 @@ +-- Add up migration script here + +ALTER TABLE gcp_trigger ALTER COLUMN gcp_resource_path DROP NOT NULL; +ALTER TABLE gcp_trigger ADD COLUMN project_id VARCHAR(255); + +-- Subscriptions are now stored fully qualified (projects//subscriptions/) so that one +-- Pub/Sub subscription has exactly one representation. A Pub/Sub id may itself be 255 characters, +-- so the qualified form needs more room than the bare one did. +ALTER TABLE gcp_trigger ALTER COLUMN subscription_id TYPE VARCHAR(400); +ALTER TABLE gcp_trigger DROP CONSTRAINT gcp_trigger_subscription_id_check; +ALTER TABLE gcp_trigger ADD CONSTRAINT gcp_trigger_subscription_id_check + CHECK (char_length(subscription_id::text) >= 3 AND char_length(subscription_id::text) <= 400); + +-- A NULL gcp_resource_path means application default credentials, and NULLs compare as distinct, +-- so the plain column index would stop guarding those rows. project_id is deliberately absent: +-- with the subscription stored fully qualified it is not part of a subscription's identity, and +-- including it would split rows that denote the same subscription. +-- +-- Rows written before this migration hold a bare id, which cannot be backfilled here: the project +-- they resolve against lives inside the credentials, not in this table. So a legacy `my-sub` and a +-- new `projects/p/subscriptions/my-sub` still read as different subscriptions until the older +-- trigger is saved again, which rewrites it in the qualified form. +DROP INDEX unique_subscription_per_gcp_resource; +CREATE UNIQUE INDEX unique_subscription_per_gcp_resource +ON gcp_trigger (subscription_id, COALESCE(gcp_resource_path, ''), workspace_id); diff --git a/backend/windmill-api-integration-tests/tests/triggers.rs b/backend/windmill-api-integration-tests/tests/triggers.rs index 980bdc571f..8b6da6efe9 100644 --- a/backend/windmill-api-integration-tests/tests/triggers.rs +++ b/backend/windmill-api-integration-tests/tests/triggers.rs @@ -1497,7 +1497,10 @@ async fn test_gcp_trigger_insert_pull(db: Pool) -> anyhow::Result<()> .fetch_one(&db) .await?; - assert_eq!(trigger.gcp_resource_path, "u/admin/gcp_resource"); + assert_eq!( + trigger.gcp_resource_path.as_deref(), + Some("u/admin/gcp_resource") + ); assert_eq!(trigger.topic_id, "my-topic"); assert_eq!(trigger.subscription_id, "my-subscription"); assert_eq!(trigger.delivery_type, "pull"); @@ -1607,6 +1610,70 @@ async fn test_gcp_trigger_unique_constraint(db: Pool) -> anyhow::Resul Ok(()) } +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_gcp_trigger_unique_constraint_with_default_credentials( + db: Pool, +) -> anyhow::Result<()> { + // A trigger on application default credentials has a NULL gcp_resource_path, and NULLs compare + // as distinct, so the unique index only keeps guarding these rows because it coalesces. + // Subscriptions are stored fully qualified, which is what makes the subscription alone + // sufficient to identify one: project_id is deliberately not part of the key, so a differing + // project must not let a second trigger onto the same subscription. + let insert_query = r#" + INSERT INTO gcp_trigger ( + path, gcp_resource_path, project_id, topic_id, subscription_id, + delivery_type, subscription_mode, script_path, is_flow, + workspace_id, edited_by, permissioned_as + ) + VALUES ($1, NULL, $2, $3, $4, $5::delivery_mode, $6::gcp_subscription_mode, $7, false, $8, $9, $10) + "#; + + let insert = + |path: &'static str, project_id: Option<&'static str>, subscription: &'static str| { + sqlx::query(insert_query) + .bind(path) + .bind(project_id) + .bind("projects/my-project/topics/my-topic") + .bind(subscription) + .bind("pull") + .bind("create_update") + .bind("f/test/gcp_handler") + .bind("test-workspace") + .bind("test-user") + .bind("u/test-user") + .execute(&db) + }; + + let shared = "projects/my-project/subscriptions/shared-subscription"; + insert("f/test/gcp_adc_1", Some("my-project"), shared).await?; + + assert!( + insert("f/test/gcp_adc_2", Some("my-project"), shared) + .await + .is_err(), + "a second trigger on the same subscription must collide" + ); + assert!( + insert("f/test/gcp_adc_3", Some("other-project"), shared) + .await + .is_err(), + "project_id must not let a second trigger onto the same subscription" + ); + assert!( + insert("f/test/gcp_adc_4", None, shared).await.is_err(), + "an absent project_id must not let a second trigger onto the same subscription" + ); + + insert( + "f/test/gcp_adc_5", + Some("my-project"), + "projects/other-project/subscriptions/shared-subscription", + ) + .await?; + + Ok(()) +} + // ============================================================================ // Email Trigger Tests (DB-level) // ============================================================================ diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index e4453a3a21..d21591cb42 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5695,14 +5695,14 @@ async fn clone_triggers_and_schedules( sqlx::query!( r#"INSERT INTO gcp_trigger ( - gcp_resource_path, topic_id, subscription_id, delivery_type, + gcp_resource_path, project_id, topic_id, subscription_id, delivery_type, delivery_config, path, script_path, is_flow, workspace_id, edited_by, edited_at, extra_perms, server_id, last_server_ping, error, subscription_mode, error_handler_path, error_handler_args, retry, auto_acknowledge_msg, ack_deadline, mode, permissioned_as, labels ) SELECT - gcp_resource_path, topic_id, subscription_id, delivery_type, + gcp_resource_path, project_id, topic_id, subscription_id, delivery_type, delivery_config, path, script_path, is_flow, $1, edited_by, edited_at, extra_perms, NULL, NULL, NULL, subscription_mode, error_handler_path, error_handler_args, retry, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5ed18d1a18..2656e336b1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -19041,6 +19041,29 @@ paths: schema: type: string + /w/{workspace}/gcp_triggers/subscriptions/delete: + delete: + summary: delete a subscription reachable with the instance's application default credentials + operationId: deleteGcpSubscriptionWithDefaultCredentials + tags: + - gcp_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: args to delete subscription from google cloud + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteGcpSubscription" + responses: + "200": + description: gcp trigger deleted + content: + text/plain: + schema: + type: string + /w/{workspace}/gcp_triggers/topics/list/{path}: get: summary: list all topics of google cloud service @@ -19050,6 +19073,26 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GcpProjectId" + responses: + "200": + description: get all google topics + content: + application/json: + schema: + type: array + items: + type: string + + /w/{workspace}/gcp_triggers/topics/list: + get: + summary: list all topics reachable with the instance's application default credentials + operationId: listGoogleTopicsWithDefaultCredentials + tags: + - gcp_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/GcpProjectId" responses: "200": description: get all google topics @@ -19086,6 +19129,31 @@ paths: items: type: string + /w/{workspace}/gcp_triggers/subscriptions/list: + post: + summary: list all subscriptions of a topic reachable with the instance's application default credentials + operationId: listAllTGoogleTopicSubscriptionsWithDefaultCredentials + tags: + - gcp_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: args to get subscription's topic from google cloud + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GetAllTopicSubscription" + responses: + "200": + description: get all google topic subscriptions name + content: + application/json: + schema: + type: array + items: + type: string + /w/{workspace}/azure_triggers/create: post: summary: create an Azure Event Grid trigger @@ -24834,6 +24902,12 @@ components: required: true schema: type: string + GcpProjectId: + name: project_id + in: query + description: GCP project to list resources from, when it is not the project of the credentials + schema: + type: string S3Sig: name: sig in: query @@ -30165,13 +30239,16 @@ components: properties: gcp_resource_path: type: string - description: "Path to the GCP resource containing service account credentials for authentication." + description: "Path to the GCP resource containing service account credentials for authentication. Omit to authenticate with the instance's application default credentials." + project_id: + type: string + description: "GCP project the client operates in. Defaults to the project of the credentials. Topics and subscriptions given as fully qualified names are reached whatever it is." topic_id: type: string - description: "Google Cloud Pub/Sub topic ID to subscribe to." + description: "Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID or a fully qualified name (projects//topics/)." subscription_id: type: string - description: "Google Cloud Pub/Sub subscription ID." + description: "Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully qualified name (projects//subscriptions/)." server_id: type: string description: "ID of the server currently handling this trigger (internal use)." @@ -30199,7 +30276,6 @@ components: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: "Retry configuration for failed executions." required: - - gcp_resource_path - topic_id - subscription_id - delivery_type @@ -30218,15 +30294,18 @@ components: properties: gcp_resource_path: type: string - description: "Path to the GCP resource containing service account credentials for authentication." + description: "Path to the GCP resource containing service account credentials for authentication. Omit to authenticate with the instance's application default credentials, which only workspace admins may select." + project_id: + type: string + description: "GCP project the client operates in. Defaults to the project of the credentials. Topics and subscriptions given as fully qualified names are reached whatever it is." subscription_mode: $ref: "#/components/schemas/SubscriptionMode" topic_id: type: string - description: "Google Cloud Pub/Sub topic ID to subscribe to." + description: "Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID or a fully qualified name (projects//topics/)." subscription_id: type: string - description: "Google Cloud Pub/Sub subscription ID." + description: "Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully qualified name (projects//subscriptions/)." base_endpoint: type: string description: "Base URL for push delivery endpoint." @@ -30278,7 +30357,6 @@ components: - path - script_path - is_flow - - gcp_resource_path - topic_id - subscription_mode @@ -30287,6 +30365,8 @@ components: properties: topic_id: type: string + project_id: + type: string required: - topic_id @@ -30295,6 +30375,8 @@ components: properties: subscription_id: type: string + project_id: + type: string required: - subscription_id diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 1eed038c25..16363228eb 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -200,7 +200,14 @@ pub struct AzureTriggerConfig { #[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))] #[derive(Debug, Serialize, Deserialize)] pub struct GcpTriggerConfig { - pub gcp_resource_path: String, + /// `None` selects application default credentials. Deliberately not `empty_as_none`: unlike + /// every other field here, absent does not mean "unset" but "use the instance's own identity", + /// so a blank string left behind by a half-filled form must stay distinguishable and be + /// rejected rather than silently selecting the privileged mode. + #[serde(default)] + pub gcp_resource_path: Option, + #[serde(default, deserialize_with = "empty_as_none")] + pub project_id: Option, pub subscription_mode: GcpSubscriptionMode, #[serde(default, deserialize_with = "empty_as_none")] pub subscription_id: Option, @@ -424,11 +431,24 @@ async fn set_gcp_trigger_config( return Err(Error::BadRequest("Invalid GCP Pub/Sub config".to_string())); }; + if gcp_config + .gcp_resource_path + .as_deref() + .is_some_and(|path| path.trim().is_empty()) + { + return Err(Error::BadRequest( + "GCP resource path cannot be empty. Remove the field entirely to use application \ + default credentials." + .to_string(), + )); + } + let config = manage_google_subscription( authed, db, w_id, - &gcp_config.gcp_resource_path, + gcp_config.gcp_resource_path.as_deref(), + gcp_config.project_id.as_deref(), &capture_config.path, &gcp_config.topic_id, &mut gcp_config.subscription_id, @@ -1047,7 +1067,7 @@ async fn gcp_payload( user_db.clone(), authed.clone(), &headers, - &gcp_trigger_config.gcp_resource_path, + gcp_trigger_config.gcp_resource_path.as_deref(), &w_id, config.delivery_config.as_ref().unwrap(), ) diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index c68fa7fce6..28898489f4 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -267,6 +267,22 @@ pub trait TriggerCrud: Send + Sync + 'static { Ok(()) } + /// Authorize a mode transition, for kinds where attaching a listener is itself privileged + /// rather than merely a write on the row — a trigger authenticating as the instance instead of + /// through a workspace resource, say. Create and update authorize their own config, but a row + /// can also arrive by being cloned into a workspace fork, so the transition needs its own + /// check. Runs before the mode is written; the default adds no authorization. + async fn authorize_set_trigger_mode( + &self, + _authed: &ApiAuthed, + _tx: &mut PgConnection, + _workspace_id: &str, + _path: &str, + _mode: &TriggerMode, + ) -> Result<()> { + Ok(()) + } + async fn set_trigger_mode( &self, authed: &ApiAuthed, @@ -575,10 +591,7 @@ async fn create_trigger( // Reject a forged superadmin run identity in a preserved permissioned_as // (the sentinel guard; a trigger's email is derived from it at execution). let resolved_permissioned_as = new_trigger.base.resolve_permissioned_as(&authed); - windmill_common::auth::validate_on_behalf_of( - Some(&resolved_permissioned_as), - None, - )?; + windmill_common::auth::validate_on_behalf_of(Some(&resolved_permissioned_as), None)?; let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( new_trigger.base.permissioned_as.as_deref(), @@ -837,10 +850,7 @@ async fn update_trigger( // Reject a forged superadmin run identity in a preserved permissioned_as // (the sentinel guard; a trigger's email is derived from it at execution). let resolved_permissioned_as = edit_trigger.base.resolve_permissioned_as(&authed); - windmill_common::auth::validate_on_behalf_of( - Some(&resolved_permissioned_as), - None, - )?; + windmill_common::auth::validate_on_behalf_of(Some(&resolved_permissioned_as), None)?; let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( edit_trigger.base.permissioned_as.as_deref(), @@ -1150,6 +1160,10 @@ async fn set_trigger_mode( } } + handler + .authorize_set_trigger_mode(&authed, &mut *tx, &workspace_id, path, &payload.mode) + .await?; + let before = trigger_history::snapshot_row(&mut *tx, T::TABLE_NAME, &workspace_id, path).await?; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index be38232110..0f57c21463 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -8177,13 +8177,21 @@ properties: gcp_resource_path: type: string description: Path to the GCP resource containing service account credentials for - authentication. + authentication. Omit to authenticate with the instance's application default + credentials. + project_id: + type: string + description: GCP project the client operates in. Defaults to the project of the + credentials. Topics and subscriptions given as fully qualified names are reached + whatever it is. topic_id: type: string - description: Google Cloud Pub/Sub topic ID to subscribe to. + description: Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID + or a fully qualified name (projects//topics/). subscription_id: type: string - description: Google Cloud Pub/Sub subscription ID. + description: Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully + qualified name (projects//subscriptions/). delivery_type: type: string enum: @@ -8253,7 +8261,6 @@ properties: required: - script_path - is_flow -- gcp_resource_path - topic_id - subscription_id - delivery_type diff --git a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts index d22dceffa6..47237a0ae1 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts @@ -380,10 +380,11 @@ export const sqsTriggerRequestSchema = z.object({ }) export const gcpTriggerRequestSchema = z.object({ - "gcp_resource_path": z.string().describe("Path to the GCP resource containing service account credentials for authentication."), + "gcp_resource_path": z.string().describe("Path to the GCP resource containing service account credentials for authentication. Omit to authenticate with the instance's application default credentials, which only workspace admins may select.").optional(), + "project_id": z.string().describe("GCP project the client operates in. Defaults to the project of the credentials. Topics and subscriptions given as fully qualified names are reached whatever it is.").optional(), "subscription_mode": z.enum(["existing", "create_update"]).describe("The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new subscription."), - "topic_id": z.string().describe("Google Cloud Pub/Sub topic ID to subscribe to."), - "subscription_id": z.string().describe("Google Cloud Pub/Sub subscription ID.").optional(), + "topic_id": z.string().describe("Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID or a fully qualified name (projects//topics/)."), + "subscription_id": z.string().describe("Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully qualified name (projects//subscriptions/).").optional(), "base_endpoint": z.string().describe("Base URL for push delivery endpoint.").optional(), "delivery_type": z.enum(["push", "pull"]).describe("Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger actively fetches messages.").optional(), "delivery_config": z.object({ diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte index f7bc924d8f..591de38b3b 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorConfigSection.svelte @@ -15,10 +15,11 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import { base } from '$lib/base' import Toggle from '$lib/components/Toggle.svelte' - import { workspaceStore } from '$lib/stores' + import { userStore, workspaceStore } from '$lib/stores' import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' import { Button, Url } from '$lib/components/common' + import TextInput from '$lib/components/text_input/TextInput.svelte' import { RefreshCw } from 'lucide-svelte' import Alert from '$lib/components/common/alert/Alert.svelte' import TestingBadge from '../testingBadge.svelte' @@ -41,43 +42,72 @@ } async function loadAllPubSubTopicsFromProject() { - if (!emptyStringTrimmed(gcp_resource_path)) { - try { - loadingTopic = true - topic_items = await GcpTriggerService.listGoogleTopics({ - workspace: wsId!, - path: gcp_resource_path - }) - } catch (error) { - sendUserToast(error.body, true) - } - loadingTopic = false + // Listing is admin-only under application default credentials, and a non-admin viewing an + // inherited trigger cannot change the topic anyway, so asking would only raise a 403 toast. + if (!hasCredentials || blockedByAdminGate) { + return } + try { + loadingTopic = true + topic_items = usesDefaultCredentials + ? await GcpTriggerService.listGoogleTopicsWithDefaultCredentials({ + workspace: wsId!, + projectId: project_id + }) + : await GcpTriggerService.listGoogleTopics({ + workspace: wsId!, + path: gcp_resource_path!, + projectId: project_id + }) + } catch (error) { + sendUserToast(error.body, true) + } + loadingTopic = false } async function loadAllSubscriptionFromGooglePubSubTopic() { - if (!emptyStringTrimmed(gcp_resource_path) && !emptyStringTrimmed(topic_id)) { - try { - loadingSubscription = true - subscription_items = await GcpTriggerService.listAllTgoogleTopicSubscriptions({ - workspace: wsId!, - path: gcp_resource_path, - requestBody: { - topic_id - } - }) - } catch (error) { - sendUserToast(error.body, true) - } - loadingSubscription = false + if (!hasCredentials || blockedByAdminGate || emptyStringTrimmed(topic_id)) { + return } + try { + loadingSubscription = true + const requestBody = { topic_id, project_id } + subscription_items = usesDefaultCredentials + ? await GcpTriggerService.listAllTgoogleTopicSubscriptionsWithDefaultCredentials({ + workspace: wsId!, + requestBody + }) + : await GcpTriggerService.listAllTgoogleTopicSubscriptions({ + workspace: wsId!, + path: gcp_resource_path!, + requestBody + }) + } catch (error) { + sendUserToast(error.body, true) + } + loadingSubscription = false + } + + /** Subscriptions come back fully qualified so cross-project ones survive the round trip. The + * project is what tells two same-named subscriptions apart, which is exactly the case a + * cross-project topic creates, so it stays in the label rather than being trimmed away. */ + function subscriptionLabel(name: string): string { + const parts = name.split('/') + const id = parts.pop() ?? name + const project = parts.length >= 2 ? parts[1] : undefined + return project ? `${id} (${project})` : id } interface Props { can_write?: boolean headless?: boolean isValid?: boolean - gcp_resource_path?: string + gcp_resource_path?: string | undefined + /** Authenticate as the server itself instead of with a `gcloud` resource. */ + use_default_credentials?: boolean + /** Whether the config was *loaded* in that mode, as opposed to switched into it here. */ + loaded_uses_default_credentials?: boolean + project_id?: string subscription_id?: string topic_id?: string delivery_type?: DeliveryType | undefined @@ -96,7 +126,10 @@ can_write = false, headless = false, isValid = $bindable(false), - gcp_resource_path = $bindable(''), + gcp_resource_path = $bindable(), + use_default_credentials = $bindable(), + loaded_uses_default_credentials = false, + project_id = $bindable(), subscription_id = $bindable(''), topic_id = $bindable(''), delivery_type = $bindable('pull'), @@ -111,13 +144,50 @@ create_update_subscription_id = $bindable('') }: Props = $props() - if (gcp_resource_path) { + /** Only workspace admins may point a trigger at the server's own GCP identity, which no + * resource ACL covers. The backend enforces this too; hiding it keeps a non-admin from + * building a config that cannot be saved. Someone who inherits such a trigger still sees the + * mode it is in. */ + const usesDefaultCredentials = $derived(use_default_credentials ?? false) + // Keyed on the loaded mode, not the live one: reading the live mode would make the toggle a + // one-way door, disabling itself the moment a non-admin switched an inherited ADC trigger away. + const canUseDefaultCredentials = $derived( + $userStore?.is_admin === true || loaded_uses_default_credentials + ) + const hasCredentials = $derived(usesDefaultCredentials || !emptyStringTrimmed(gcp_resource_path)) + /** Saving re-provisions the subscription with the instance's credentials, so the backend runs + * the admin check on every write, not only when the mode is switched. A non-admin who inherits + * such a trigger can open it, so say why saving is unavailable instead of letting them hit a + * bare 403. */ + const blockedByAdminGate = $derived(usesDefaultCredentials && $userStore?.is_admin !== true) + + // One-shot on mount, so read the props rather than the derived: referencing `$derived` state + // here captures its initial value anyway, and Svelte warns about it. + if (gcp_resource_path || use_default_credentials) { loadAllPubSubTopicsFromProject() } + function onCredentialsModeChange(useDefault: boolean) { + use_default_credentials = useDefault + gcp_resource_path = useDefault ? undefined : '' + // The topic and subscription belong to the credentials that listed them. Keeping them + // across a switch leaves the form valid and saveable against names the new credentials may + // not have, or worse may have in a different project. + topic_items = [] + subscription_items = [] + topic_id = '' + subscription_id = '' + cloud_subscription_id = '' + create_update_subscription_id = '' + if (useDefault) { + loadAllPubSubTopicsFromProject() + } + } + $effect(() => { isValid = - !emptyStringTrimmed(gcp_resource_path) && + hasCredentials && + !blockedByAdminGate && !emptyStringTrimmed(topic_id) && !emptyStringTrimmed(subscription_id) }) @@ -152,25 +222,82 @@ {/snippet}
-
- gcp_resource_path, - (v) => { - gcp_resource_path = v - loadAllPubSubTopicsFromProject() +
+ onCredentialsModeChange(e.detail === 'default')} + > + {#snippet children({ item })} + + + {/snippet} + + + {#if !usesDefaultCredentials} + gcp_resource_path, + (v) => { + gcp_resource_path = v + loadAllPubSubTopicsFromProject() + } } - } - /> - {#if !emptyStringTrimmed(gcp_resource_path)} - + /> + {/if} + + +
+ + + project_id ?? '', (v) => (project_id = emptyStringTrimmed(v) ? undefined : v)} + inputProps={{ + placeholder: 'my-gcp-project', + disabled: !can_write, + autocomplete: 'off' + }} + /> +
+
+ + {#if blockedByAdminGate} + + This trigger authenticates as the Windmill server. Saving changes to it needs + workspace admin, because saving re-provisions the subscription with those credentials. + + {/if} + + {#if hasCredentials} + {/if}
- {#if gcp_resource_path} + {#if hasCredentials}
{/if} - {#if !emptyStringTrimmed(gcp_resource_path) && !emptyStringTrimmed(topic_id)} + {#if hasCredentials && !emptyStringTrimmed(topic_id)}
((subscription_id = t), (cloud_subscription_id = t)) } onClear={() => (subscription_id = '')} - items={safeSelectItems(subscription_items)} + items={subscription_items.map((s) => ({ + value: s, + label: subscriptionLabel(s) + }))} placeholder="Choose a subscription" />