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 <noreply@anthropic.com>

* fix: address review findings on gcp application default credentials

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address review nits on gcp application default credentials

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: key the gcp credential-mode permission off the loaded mode

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: gate enabling an ADC gcp trigger on workspace admin

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: lock the gcp trigger row while authorizing a mode change

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: skip admin-only gcp listing when the caller cannot use those credentials

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
hugocasa
2026-08-21 10:41:14 +02:00
committed by GitHub
parent 28b2ca6367
commit 8e508ea01a
25 changed files with 797 additions and 107 deletions
+1
View File
@@ -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
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -72,7 +72,7 @@
]
},
"nullable": [
false,
true,
false,
false,
false,
@@ -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"
}
@@ -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"
}
+12
View File
@@ -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"
+4 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
c6902ec2c51dc0ce30962afbfab3e456c5d9b831
54bf630681000c8ed87a7067e357118e015123b1
@@ -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);
@@ -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/<project>/subscriptions/<id>) 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);
@@ -1497,7 +1497,10 @@ async fn test_gcp_trigger_insert_pull(db: Pool<Postgres>) -> 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<Postgres>) -> anyhow::Resul
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_gcp_trigger_unique_constraint_with_default_credentials(
db: Pool<Postgres>,
) -> 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)
// ============================================================================
@@ -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,
+90 -8
View File
@@ -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/<project>/topics/<id>)."
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/<project>/subscriptions/<id>)."
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/<project>/topics/<id>)."
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/<project>/subscriptions/<id>)."
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
+23 -3
View File
@@ -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<String>,
#[serde(default, deserialize_with = "empty_as_none")]
pub project_id: Option<String>,
pub subscription_mode: GcpSubscriptionMode,
#[serde(default, deserialize_with = "empty_as_none")]
pub subscription_id: Option<String>,
@@ -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(),
)
+22 -8
View File
@@ -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<T: TriggerCrud>(
// 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<T: TriggerCrud>(
// 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<T: TriggerCrud>(
}
}
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?;
+11 -4
View File
@@ -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/<project>/topics/<id>).
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/<project>/subscriptions/<id>).
delivery_type:
type: string
enum:
@@ -8253,7 +8261,6 @@ properties:
required:
- script_path
- is_flow
- gcp_resource_path
- topic_id
- subscription_id
- delivery_type
@@ -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/<project>/topics/<id>)."),
"subscription_id": z.string().describe("Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully qualified name (projects/<project>/subscriptions/<id>).").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({
@@ -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}
<div class="flex flex-col w-full gap-4">
<Subsection label="Connection setup">
<div class="flex flex-col gap-1 mt-2">
<ResourcePicker
workspace={wsId}
resourceType="gcloud"
bind:value={
() => gcp_resource_path,
(v) => {
gcp_resource_path = v
loadAllPubSubTopicsFromProject()
<div class="flex flex-col gap-3 mt-2">
<ToggleButtonGroup
selected={usesDefaultCredentials ? 'default' : 'resource'}
on:selected={(e) => onCredentialsModeChange(e.detail === 'default')}
>
{#snippet children({ item })}
<ToggleButton
label="Service account"
value="resource"
tooltip="Authenticate with a service account key held in a GCP resource."
showTooltipIcon
{item}
/>
<ToggleButton
label="Application default credentials"
value="default"
disabled={!canUseDefaultCredentials}
tooltip={canUseDefaultCredentials
? 'Authenticate as the Windmill server itself, using the credentials of its environment (workload identity, the metadata server, or GOOGLE_APPLICATION_CREDENTIALS).'
: 'Workspace admins can authenticate as the Windmill server itself. Ask one to set this up.'}
showTooltipIcon
{item}
/>
{/snippet}
</ToggleButtonGroup>
{#if !usesDefaultCredentials}
<ResourcePicker
workspace={wsId}
resourceType="gcloud"
bind:value={
() => gcp_resource_path,
(v) => {
gcp_resource_path = v
loadAllPubSubTopicsFromProject()
}
}
}
/>
{#if !emptyStringTrimmed(gcp_resource_path)}
<TestTriggerConnection kind="gcp" args={{ gcp_resource_path }} />
/>
{/if}
<Subsection
label="Project ID"
tooltip="The project topics and subscriptions are listed and created in. Leave empty to use the project of the credentials. Names given in full (projects/<project>/topics/<id>) are reached whatever this is set to."
>
<div class="mt-2">
<!-- Typing does not refetch: every keystroke would be a Pub/Sub call for a
project id that is not finished being typed. The refresh button next to
the topic picker is what reloads the lists. -->
<!-- Cleared means "unset", not an empty or whitespace-only string: that would
travel as `?project_id=`, dirty the config, and reach the column as a
value `empty_as_none` does not trim away. -->
<TextInput
bind:value={() => project_id ?? '', (v) => (project_id = emptyStringTrimmed(v) ? undefined : v)}
inputProps={{
placeholder: 'my-gcp-project',
disabled: !can_write,
autocomplete: 'off'
}}
/>
</div>
</Subsection>
{#if blockedByAdminGate}
<Alert title="Workspace admin required" type="info" size="xs">
This trigger authenticates as the Windmill server. Saving changes to it needs
workspace admin, because saving re-provisions the subscription with those credentials.
</Alert>
{/if}
{#if hasCredentials}
<TestTriggerConnection kind="gcp" args={{ gcp_resource_path, project_id }} />
{/if}
</div>
</Subsection>
{#if gcp_resource_path}
{#if hasCredentials}
<div class="flex flex-col gap-1">
<Subsection
label="Topic"
@@ -204,7 +331,7 @@
</Subsection>
</div>
{/if}
{#if !emptyStringTrimmed(gcp_resource_path) && !emptyStringTrimmed(topic_id)}
{#if hasCredentials && !emptyStringTrimmed(topic_id)}
<Section
label="Subscription"
tooltip="Choose whether to create or update a Pub/Sub subscription, or link an existing one from your Google Cloud project."
@@ -324,7 +451,10 @@
(t) => ((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"
/>
<Button
@@ -59,7 +59,11 @@
let can_write = $state(true)
let drawerLoading = $state(true)
let topic_id: string = $state('')
let gcp_resource_path: string = $state('')
let gcp_resource_path: string | undefined = $state('')
// `undefined` rather than `''` is what marks application default credentials, and the two
// survive a round trip through the deployed config and the draft as `null` vs `''`.
let use_default_credentials = $state(false)
let project_id: string | undefined = $state(undefined)
let subscription_id: string = $state('')
let isValid = $state(false)
let delivery_config: PushConfig | undefined = $state(undefined)
@@ -79,6 +83,11 @@
let retry: Retry | undefined = $state()
let suspendedJobsModal = $state<TriggerSuspendedJobsModal | null>(null)
let originalConfig = $state<Record<string, any> | undefined>(undefined)
/** The credential mode the config was loaded in, as opposed to the one currently selected in
* the form. A non-admin may keep a trigger that already uses the instance's credentials, but
* may not move one onto them, so the permission has to key off this rather than the live mode.
* Set at load rather than derived from the deployed row, so a draft-only trigger counts too. */
let loadedUsesDefaultCredentials = $state(false)
let {
useDrawer = true,
description = undefined,
@@ -177,7 +186,20 @@
initialScriptPath = ''
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
gcp_resource_path = defaultValues?.gcp_resource_path ?? ''
// A draft or capture config reaches `openNew` too, and it stores the credentials mode as
// an explicit `gcp_resource_path: null`. Only an absent key means "fresh trigger", so
// collapsing null and missing here would silently reopen an ADC config in service
// account mode.
const reopenedAsDefaultCredentials =
!!defaultValues &&
'gcp_resource_path' in defaultValues &&
defaultValues.gcp_resource_path == null
gcp_resource_path = reopenedAsDefaultCredentials
? undefined
: (defaultValues?.gcp_resource_path ?? '')
use_default_credentials = reopenedAsDefaultCredentials
loadedUsesDefaultCredentials = reopenedAsDefaultCredentials
project_id = defaultValues?.project_id ?? undefined
delivery_type = defaultValues?.delivery_type ?? 'pull'
delivery_config = defaultValues?.delivery_config ?? undefined
subscription_id = ''
@@ -234,7 +256,10 @@
async function loadTriggerConfig(cfg?: Record<string, any>): Promise<void> {
script_path = cfg?.script_path
initialScriptPath = cfg?.script_path
gcp_resource_path = cfg?.gcp_resource_path
gcp_resource_path = cfg?.gcp_resource_path ?? undefined
use_default_credentials = cfg?.gcp_resource_path == null
loadedUsesDefaultCredentials = use_default_credentials
project_id = cfg?.project_id ?? undefined
delivery_type = cfg?.delivery_type
subscription_id = cfg?.subscription_id
delivery_config = cfg?.delivery_config
@@ -277,7 +302,8 @@
function getGcpConfig() {
return {
gcp_resource_path,
gcp_resource_path: gcp_resource_path ?? null,
project_id,
subscription_mode,
subscription_id,
delivery_type,
@@ -300,7 +326,8 @@
function getGcpCaptureConfig() {
return {
gcp_resource_path,
gcp_resource_path: gcp_resource_path ?? null,
project_id,
subscription_mode,
subscription_id,
delivery_type,
@@ -317,15 +344,26 @@
const previousMode = mode
mode = newMode
if (!trigger?.draftConfig) {
const ok = await withForkConflictRetry(
(force) =>
GcpTriggerService.setGcpTriggerMode({
path: initialPath,
workspace: wsId ?? '',
requestBody: { mode: newMode, force }
}),
'GCP Pub/Sub trigger'
)
let ok: boolean
try {
ok = await withForkConflictRetry(
(force) =>
GcpTriggerService.setGcpTriggerMode({
path: initialPath,
workspace: wsId ?? '',
requestBody: { mode: newMode, force }
}),
'GCP Pub/Sub trigger'
)
} catch (err) {
// `withForkConflictRetry` re-throws anything that is not a fork conflict, and
// enabling a trigger on application default credentials is rejected for non-admins,
// so a refusal here is expected rather than exceptional: put the toggle back and say
// why, instead of leaving it showing a mode the server did not accept.
mode = previousMode
sendUserToast(err?.body ?? err?.message ?? 'Could not change trigger mode', true)
return
}
if (!ok) {
mode = previousMode
return
@@ -516,6 +554,9 @@
<GcpTriggerEditorConfigSection
bind:isValid
bind:gcp_resource_path
bind:use_default_credentials
loaded_uses_default_credentials={loadedUsesDefaultCredentials}
bind:project_id
bind:subscription_id
bind:delivery_type
bind:delivery_config
@@ -19,7 +19,8 @@ export async function saveGcpTriggerFromCfg(
: {}
const requestBody: GcpTriggerData = {
gcp_resource_path: cfg.gcp_resource_path,
gcp_resource_path: cfg.gcp_resource_path ?? undefined,
project_id: cfg.project_id,
subscription_mode: cfg.subscription_mode,
subscription_id: cfg.subscription_id,
delivery_type: cfg.delivery_type,
@@ -241,7 +241,13 @@ export const TRIGGER_KINDS: Record<
AmqpTriggerService.createAmqpTrigger({ workspace, requestBody })
},
gcp: {
configFields: ['gcp_resource_path', 'topic_id', 'delivery_type', 'subscription_mode'],
configFields: [
'gcp_resource_path',
'project_id',
'topic_id',
'delivery_type',
'subscription_mode'
],
provisionsOnCreate: true,
badge: 'GCP Pub/Sub',
route: 'gcp_triggers',
@@ -541,7 +547,8 @@ export function triggerDetails(t: WorkspaceTrigger): Array<{ label: string; valu
push('Queue', c.queue_name)
break
case 'gcp':
push('Resource', c.gcp_resource_path)
push('Resource', c.gcp_resource_path ?? 'Application default credentials')
push('Project', c.project_id)
push('Topic', c.topic_id)
push('Subscription', c.subscription_id)
break
@@ -397,7 +397,7 @@
/>
{:else if items?.length}
<div class="border rounded-md divide-y">
{#each items.slice(0, nbDisplayed) as { gcp_resource_path, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{#each items.slice(0, nbDisplayed) as { gcp_resource_path, project_id, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)}
{@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_gcp', path) ?? is_draft}
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
{@const ping = new Date()}
@@ -596,13 +596,17 @@
subscriptionToDelete = subscription_id
currentTopic = topic_id
deleteSubscriptionCallback = async () => {
const message = await GcpTriggerService.deleteGcpSubscription({
workspace: $workspaceStore ?? '',
path: gcp_resource_path,
requestBody: {
subscription_id
}
})
const requestBody = { subscription_id, project_id }
const message = gcp_resource_path
? await GcpTriggerService.deleteGcpSubscription({
workspace: $workspaceStore ?? '',
path: gcp_resource_path,
requestBody
})
: await GcpTriggerService.deleteGcpSubscriptionWithDefaultCredentials({
workspace: $workspaceStore ?? '',
requestBody
})
sendUserToast(message)
}
deleteGcpTriggerCallback = async () => {
@@ -33,13 +33,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/<project>/topics/<id>).
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/<project>/subscriptions/<id>).
delivery_type:
type: string
enum:
@@ -109,7 +117,6 @@ properties:
required:
- script_path
- is_flow
- gcp_resource_path
- topic_id
- subscription_id
- delivery_type