From 0b63e0a6929088ff25def4fd6547cf61668251a5 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 9 Sep 2026 11:19:49 +0200 Subject: [PATCH] feat: make guest access unavailable on the shared cloud (#11040) * feat: make guest access unavailable on the shared cloud Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NiPw5gUgNJPxtGG1meS6RY * test: pin that an issued guest session stops on the shared cloud Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NiPw5gUgNJPxtGG1meS6RY * fix: refuse only widening an app into guests where they are unavailable Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NiPw5gUgNJPxtGG1meS6RY --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/tests/app_guest_cloud_hosted.rs | 168 ++++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 8 + backend/windmill-api/openapi.yaml | 20 ++- backend/windmill-api/src/apps.rs | 31 ++++ backend/windmill-common/src/guest_jwt.rs | 9 + backend/windmill-common/src/workspaces.rs | 50 +++++- .../apps/editor/AppEditorHeaderDeploy.svelte | 19 +- .../instanceSettings/GuestActivityList.svelte | 65 ++++--- .../(logged)/workspace_settings/+page.svelte | 160 ++++++++++------- 9 files changed, 424 insertions(+), 106 deletions(-) create mode 100644 backend/tests/app_guest_cloud_hosted.rs diff --git a/backend/tests/app_guest_cloud_hosted.rs b/backend/tests/app_guest_cloud_hosted.rs new file mode 100644 index 0000000000..7ab4145a65 --- /dev/null +++ b/backend/tests/app_guest_cloud_hosted.rs @@ -0,0 +1,168 @@ +//! Guests are unavailable on the shared cloud (`CLOUD_HOSTED`). +//! +//! One test in its own binary on purpose: `CLOUD_HOSTED` is read once into a +//! `lazy_static`, so it must be set before anything reads it and cannot be unset for a +//! sibling test in the same process. +//! +//! Users from the `base` fixture: +//! test-user (admin, token SECRET_TOKEN) + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const ADMIN_TOKEN: &str = "SECRET_TOKEN"; +const GUEST_TOKEN: &str = "GUEST_SECRET_TOKEN"; +const APP_PATH: &str = "u/test-user/guest_app"; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +#[sqlx::test(fixtures("base"))] +async fn the_cloud_admits_no_guest(db: Pool) -> anyhow::Result<()> { + // Before the server starts, so the flag is what the whole process sees. + unsafe { std::env::set_var("CLOUD_HOSTED", "true") }; + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + // The workspace switch cannot be turned on, so no policy can lean on it. + let resp = authed( + client().post(format!("{ws}/workspaces/edit_guest_access")), + ADMIN_TOKEN, + ) + .json(&json!({ "guest_access_enabled": true })) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!( + resp.text().await?.contains("self-hosted"), + "the refusal must name what guests need" + ); + + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": APP_PATH, + "summary": "Guest app", + "value": {}, + "policy": { "execution_mode": "guest", "triggerables": {} } + })) + .send() + .await?; + assert_eq!(resp.status(), 400, "an app cannot be deployed to guests"); + + // Nor can a key be configured for the JWT way in — the refusal lands before the + // outbound JWKS fetch it would otherwise make. + let resp = authed( + client().post(format!("{ws}/workspaces/edit_guest_jwt_key")), + ADMIN_TOKEN, + ) + .json(&json!({ "jwks_url": "https://issuer.example.com/.well-known/jwks.json" })) + .send() + .await?; + assert_eq!(resp.status(), 400, "a guest JWT key cannot be configured"); + // Clearing one stays allowed: a key nobody can use is still worth removing. + let resp = authed( + client().post(format!("{ws}/workspaces/edit_guest_jwt_key")), + ADMIN_TOKEN, + ) + .json(&json!({})) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + // An app already stored in guest mode — pushed by git-sync, or deployed before the + // instance became a cloud one — advertises no entry either. + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": APP_PATH, + "summary": "Guest app", + "value": {}, + "policy": { "execution_mode": "publisher", "triggerables": {} } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + sqlx::query( + "UPDATE app SET policy = jsonb_set(policy, '{execution_mode}', '\"guest\"') + WHERE path = $1 AND workspace_id = 'test-workspace'", + ) + .bind(APP_PATH) + .execute(&db) + .await?; + sqlx::query("UPDATE workspace_settings SET guest_access_enabled = true WHERE workspace_id = 'test-workspace'") + .execute(&db) + .await?; + + // Deploying it again is not refused: only widening an app into guests is, so a + // git-sync push of one already stored that way keeps working (and keeps being inert). + let resp = authed( + client().post(format!("{ws}/apps/update/{APP_PATH}")), + ADMIN_TOKEN, + ) + .json(&json!({ + "policy": { "execution_mode": "guest", "triggerables": {} } + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "an app already stored in guest mode must stay deployable: {}", + resp.text().await? + ); + + let resp = authed( + client().get(format!("{ws}/apps/secret_of/{APP_PATH}")), + ADMIN_TOKEN, + ) + .send() + .await?; + assert_eq!(resp.status(), 200, "reading the share secret must succeed"); + let secret: String = resp.text().await?; + let resp = client() + .get(format!("{ws}/apps_u/guest_entry/{secret}")) + .send() + .await?; + assert_eq!( + resp.status(), + 404, + "a guest app must not advertise entry where guests are unavailable" + ); + + // And a session issued before the instance became a cloud one stops on its next + // request: the door re-reads the switch, so the credential itself is not enough. + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, scopes, workspace_id, expiration) + VALUES (encode(sha256($1::bytea), 'hex'), 'GUEST_SECR', $2, 'guest@example.com', + 'guest_session', $3, 'test-workspace', now() + interval '8 hours')", + ) + .bind(GUEST_TOKEN.as_bytes()) + .bind(GUEST_TOKEN) + .bind(vec![ + "guest".to_string(), + "users:read".to_string(), + format!("apps:read:{APP_PATH}"), + format!("apps:run:{APP_PATH}"), + ]) + .execute(&db) + .await?; + // `whoami` is where an admitted guest resolves as `role: guest`, so a 401 here is + // the door refusing the credential rather than a route saying no. + let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "a guest session must not authenticate where guests are unavailable" + ); + + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 85226ddf2c..879e739dc0 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -4691,6 +4691,9 @@ async fn edit_guest_access( Json(EditGuestAccess { guest_access_enabled }): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + if guest_access_enabled { + windmill_common::workspaces::require_guest_support()?; + } let mut tx = db.begin().await?; sqlx::query!( @@ -4747,6 +4750,11 @@ async fn edit_guest_jwt_key( "Set a PEM public key or a JWKS URL, not both".to_string(), )); } + // Clearing stays allowed wherever guests are: a key nobody can use is still worth + // removing. + if public_key.is_some() || jwks_url.is_some() { + windmill_common::workspaces::require_guest_support()?; + } if let Some(pem) = public_key.as_deref() { windmill_common::guest_jwt::decoding_key_from_pem(pem)?; } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index a0e05aa54a..d6616987cb 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -5913,7 +5913,8 @@ paths: account; the `guest` app execution mode admits them. Off by default. Re-read where a guest session is minted and at the auth door on every guest request, so turning it off takes effect immediately, for sessions already issued and for - apps whose policy already says `guest`. + apps whose policy already says `guest`. Turning it *on* is refused with a 400 + where guests are unavailable (the shared cloud); turning it off always works. operationId: editGuestAccess tags: - workspace @@ -5948,7 +5949,8 @@ paths: URL, at most one. Both empty clears the workspace key; off cloud, verification then falls back to the instance issuer (`JWT_EXT_JWKS_URL`) if one is set, else no guest JWT is accepted (`guest_access_enabled` is the on/off switch). Workspace-admin gated. - The key is validated before it is stored. + The key is validated before it is stored. Setting a key is refused with a 400 where + guests are unavailable (the shared cloud); clearing one always works. operationId: editGuestJwtKey tags: - workspace @@ -9150,7 +9152,8 @@ paths: description: >- The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless the app's execution mode is `guest` AND its workspace has - `guest_access_enabled` AND the instance has not set `guest_access_disabled`. + `guest_access_enabled` AND the instance has not set `guest_access_disabled`, + and never on a deployment where guests are unavailable (the shared cloud). Returns the workspace too, since a custom URL may not carry it. operationId: getGuestEntryByCustomPath tags: @@ -13332,8 +13335,9 @@ paths: Unauthenticated: what a signed-out visitor reads to learn that signing in would let them in. 404 unless the app's execution mode is `guest` AND the workspace has `guest_access_enabled` AND the instance has not set the - `guest_access_disabled` global setting, so it says nothing about apps that - are not open to guests. Discloses only the app path, to a caller already + `guest_access_disabled` global setting, and never on a deployment where guests + are unavailable (the shared cloud), so it says nothing about apps that are not + open to guests. Discloses only the app path, to a caller already holding the share secret. operationId: getGuestEntry tags: @@ -29348,7 +29352,12 @@ components: to one seat: `billable_guests`, `guest_seats`); every other plan and build admits no new email until the count drops. `instance_enabled` is the superadmin switch (`guest_access_disabled` global setting) every workspace switch sits under. + `available` is whether this deployment can have guests at all: false on the shared + cloud, where guest access requires a self-hosted or dedicated deployment, and every + other field and switch is then moot. properties: + available: + type: boolean instance_enabled: type: boolean guest_count: @@ -29368,6 +29377,7 @@ components: type: integer format: int64 required: + - available - instance_enabled - guest_count - window_days diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 36b6eab0bb..25178ebd80 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -343,6 +343,29 @@ fn refuse_unscopable_guest_app(path: &str, mode: ExecutionMode) -> Result<()> { Ok(()) } +/// Refuse *widening* an app into guests where the deployment has none +/// (`instance_supports_guests`). Only the transition is refused, like the protection +/// rule below it: an app already stored in the mode — deployed before the instance +/// became a cloud one, or pushed by git-sync — keeps deploying, and keeps being inert, +/// since every guest gate refuses it anyway. `deployed_mode` is what the app is stored +/// as, `None` when it is being created. +fn refuse_guest_mode_where_unavailable( + path: &str, + mode: ExecutionMode, + deployed_mode: Option, +) -> Result<()> { + if !matches!(mode, ExecutionMode::Guest) + || deployed_mode == Some(ExecutionMode::Guest) + || windmill_common::workspaces::instance_supports_guests() + { + return Ok(()); + } + Err(Error::BadRequest(format!( + "app {path} cannot be set to Guests: {}", + windmill_common::workspaces::GUESTS_UNAVAILABLE_MESSAGE + ))) +} + /// Gate a viewer on the app's `execution_mode`, as far as can be decided without an /// ACL probe. `Ok(true)` means already authorized — anonymous admits anyone, guest /// admits anyone signed in; `Ok(false)` means the caller is a member and still owes @@ -2518,6 +2541,7 @@ async fn create_app_internal<'a>( // even when the caller did not. app.policy.set_execution_mode(app.policy.execution_mode()); refuse_unscopable_guest_app(&app.path, app.policy.execution_mode())?; + refuse_guest_mode_where_unavailable(&app.path, app.policy.execution_mode(), None)?; if let Some(rule) = deployment_rule_for_mode(app.policy.execution_mode()) { if let RuleCheckResult::Blocked(msg) = check_user_against_rule( w_id, @@ -3565,6 +3589,13 @@ async fn update_app_internal<'a>( ns.path.as_deref().unwrap_or(path), npolicy.execution_mode(), )?; + // An unreadable deployed policy reads as not already-in-mode, the strict + // direction, as for the protection rule below. + refuse_guest_mode_where_unavailable( + ns.path.as_deref().unwrap_or(path), + npolicy.execution_mode(), + deployed_policy.as_ref().map(|d| d.execution_mode()), + )?; if let Some(rule) = deployment_rule_for_mode(npolicy.execution_mode()).filter(|_| !authed.is_admin) { diff --git a/backend/windmill-common/src/guest_jwt.rs b/backend/windmill-common/src/guest_jwt.rs index fe05bec6b6..9f3e47d708 100644 --- a/backend/windmill-common/src/guest_jwt.rs +++ b/backend/windmill-common/src/guest_jwt.rs @@ -579,6 +579,15 @@ pub async fn jwks_key_for(url: &str, token: &str) -> Result<(DecodingKey, Vec Result { + // The admit check downstream refuses these anyway; refusing here keeps a deployment + // with no guests from parsing attacker-supplied JWTs at all, and names the reason in + // the log the caller writes. + if !crate::workspaces::instance_supports_guests() { + return Err(Error::NotAuthorized(format!( + "guest JWT refused: {}", + crate::workspaces::GUESTS_UNAVAILABLE_MESSAGE + ))); + } if token.len() > MAX_GUEST_JWT_LEN { return Err(Error::NotAuthorized(format!( "guest JWT refused: token is longer than {MAX_GUEST_JWT_LEN} bytes" diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 443c3b6b63..ef013fc2a8 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -863,8 +863,12 @@ pub async fn guest_count_in_window<'c, E: sqlx::Executor<'c, Database = sqlx::Po /// The instance's standing against the guest allowance, as every surface reports it. #[derive(Clone, Debug, Serialize)] pub struct GuestUsage { + /// Whether this deployment can admit guests at all ([`instance_supports_guests`]). + /// Off, every other field is moot and no switch below can turn guests on. + pub available: bool, /// The superadmin switch (`GUEST_ACCESS_DISABLED_SETTING`), which every workspace - /// switch sits under. + /// switch sits under. Reported as stored, so a superadmin sees what they set even + /// where `available` overrules it. pub instance_enabled: bool, /// Distinct guest emails over the trailing `window_days`. pub guest_count: i64, @@ -877,9 +881,33 @@ pub struct GuestUsage { pub guest_seats: i64, } -/// SQL for "the instance admits guests": the superadmin switch, absent meaning on. The -/// setting is read as text before the cast so `true` and `"true"` both count. -fn instance_admits_guests_sql() -> String { +/// What a caller is told when it asks for guests on a deployment that cannot have them. +pub const GUESTS_UNAVAILABLE_MESSAGE: &str = + "Guest access is not available on Windmill Cloud. It requires a self-hosted instance \ + or a dedicated Windmill Cloud deployment."; + +/// Whether guests can exist on this deployment at all. They cannot on the shared cloud: +/// a guest is an identity Windmill itself never vouched for, admitted on the say-so of +/// whoever runs the instance, which is not a call a multi-tenant deployment can make for +/// its tenants. Folded into every guest gate below, so a workspace switch or an app +/// policy left saying `guest` is inert rather than honored. +pub fn instance_supports_guests() -> bool { + !*crate::worker::CLOUD_HOSTED +} + +/// [`instance_supports_guests`] as an error, for the writes that would otherwise store a +/// setting that can never take effect. +pub fn require_guest_support() -> Result<()> { + if instance_supports_guests() { + Ok(()) + } else { + Err(Error::BadRequest(GUESTS_UNAVAILABLE_MESSAGE.to_string())) + } +} + +/// SQL for the superadmin switch alone, absent meaning on. The setting is read as text +/// before the cast so `true` and `"true"` both count. +fn instance_switch_sql() -> String { format!( "NOT COALESCE((SELECT (value #>> '{{}}')::boolean FROM global_settings \ WHERE name = '{}'), false)", @@ -887,9 +915,18 @@ fn instance_admits_guests_sql() -> String { ) } +/// SQL for "the instance admits guests": the superadmin switch, under +/// [`instance_supports_guests`]. +fn instance_admits_guests_sql() -> String { + if !instance_supports_guests() { + return "false".to_string(); + } + instance_switch_sql() +} + pub async fn guest_usage(db: &crate::DB) -> Result { - let instance_admits = instance_admits_guests_sql(); - let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_admits}")) + let instance_switch = instance_switch_sql(); + let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_switch}")) .fetch_one(db) .await .map_err(|e| Error::internal_err(format!("reading the instance guest switch: {e:#}")))?; @@ -901,6 +938,7 @@ pub async fn guest_usage(db: &crate::DB) -> Result { 0 }; Ok(GuestUsage { + available: instance_supports_guests(), instance_enabled, guest_count, window_days: GUEST_WINDOW_DAYS, diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 7b68389d82..836af68db6 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -115,6 +115,10 @@ // letting the publisher believe the app is open. let guestAccessEnabled: boolean | undefined = $state(undefined) let guestUsage: GuestUsage | undefined = $state(undefined) + // Whether the deployment can have guests at all; off, the mode is not on offer. The + // backend decides; the hostname stands in until it has answered, the shared cloud + // being the only deployment where guests are unavailable. + let guestsAvailable = $derived.by(() => guestUsage?.available ?? !isCloudHosted()) $effect(() => { const ws = opWs @@ -458,7 +462,7 @@
{/if} - {#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest'} + {#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable} Opening this app to guests is restricted to workspace admins and bypass users by a workspace protection rule @@ -481,8 +485,10 @@ - {#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase} + {#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable}
Embed for your own authenticated users (guest JWT) diff --git a/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte b/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte index 98fb15c4bd..d55c2f9ce6 100644 --- a/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte +++ b/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte @@ -65,33 +65,46 @@ description="People your identity provider authenticated who opened an app set to Guests without a Windmill account. One email is one guest, however many workspaces it opened." /> -
- {#key usage} - setInstanceSwitch(e.detail)} - options={{ - right: 'Allow guests on this instance', - rightTooltip: - 'Off, no guest can sign in anywhere, whatever a workspace or an app says, and sessions already issued stop on their next request.' - }} - /> - {/key} -
+{#if !usage.available} +
+ + No guest can sign in here, whatever a workspace or an app says. Guests require a self-hosted + instance or a dedicated Windmill Cloud deployment. + +
+{:else} +
+ {#key usage} + setInstanceSwitch(e.detail)} + options={{ + right: 'Allow guests on this instance', + rightTooltip: + 'Off, no guest can sign in anywhere, whatever a workspace or an app says, and sessions already issued stop on their next request.' + }} + /> + {/key} +
-
- - {#if usage.metered} - Beyond the allowance, every four guests count as one seat{usage.guest_seats > 0 - ? `: ${usage.billable_guests} guests past it take ${usage.guest_seats} ${usage.guest_seats === 1 ? 'seat' : 'seats'} now` - : ''}. - {:else} - Beyond the allowance, new guests are refused until the count drops below it; an - Enterprise license meters them instead. - {/if} - -
+
+ + {#if usage.metered} + Beyond the allowance, every four guests count as one seat{usage.guest_seats > 0 + ? `: ${usage.billable_guests} guests past it take ${usage.guest_seats} ${usage.guest_seats === 1 ? 'seat' : 'seats'} now` + : ''}. + {:else} + Beyond the allowance, new guests are refused until the count drops below it; an Enterprise + license meters them instead. + {/if} + +
+{/if} guestUsage?.available ?? !isCloudHosted()) let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined) let hasInstanceAiConfig = $state(false) @@ -571,6 +574,20 @@ sendUserToast('Guest JWT key updated') } + // Removing a key stays allowed where guests are unavailable, and the rest of the card + // is hidden there, so this is the only way left to drop one stored earlier. + async function clearGuestJwtKey(): Promise { + await WorkspaceService.editGuestJwtKey({ + workspace: $workspaceStore!, + requestBody: { public_key: undefined, jwks_url: undefined } + }) + guestJwtPublicKey = '' + guestJwtJwksUrl = '' + initialGuestJwtPublicKey = '' + initialGuestJwtJwksUrl = '' + sendUserToast('Guest JWT key cleared') + } + async function editGuestAccess(): Promise { await WorkspaceService.editGuestAccess({ workspace: $workspaceStore!, @@ -2231,77 +2248,92 @@ export async function main( description="Let anyone your identity provider authenticates, or a JWT your own backend signs (configured below), open the apps set to Guests without a Windmill account. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops guests immediately, even for apps already set to Guests." class="mt-6" > - - {#if guestUsage && !guestUsage.instance_enabled} - - A superadmin has turned guests off for this instance, so this switch has no - effect until they are allowed again. - - {:else if guestUsage} - - {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this - instance in the last {guestUsage.window_days} days. - {#if guestUsage.metered} - Beyond that, every four guests count as one seat{guestUsage.guest_seats > 0 - ? ` (${guestUsage.guest_seats} now)` - : ''}. - {:else} - Beyond that, new guests are refused until the count drops; an Enterprise - license meters them instead. - {/if} - - {/if} -
-
- Guest JWT verification key -
-
- A guest can also enter through a JWT your own backend mints and signs, with no - identity-provider round-trip, for iframe embedding. The token must carry - email, workspace_id, app_path and - exp (lifetime capped at 24h); it opens only the app named by - app_path. Accepted algorithms: RS256/384/512, PS256/384/512, - ES256/384. Symmetric algorithms (HS*) are refused. Configure one key, a PEM - public key or a JWKS URL (which must be https). Point it at an issuer you - control: any token that key signs carrying these claims is accepted, so a shared - multi-tenant issuer is not a good fit. -
- - {#snippet children({ item })} - - - {/snippet} - - {#if guestJwtKeyType === 'pem'} - - {:else} - + {#if !guestsAvailable} + + Guests require a self-hosted instance or a dedicated Windmill Cloud deployment. + + {#if initialGuestJwtPublicKey || initialGuestJwtJwksUrl} +
+ + A guest JWT verification key is stored for this workspace and cannot be + used. + + +
{/if} - {#if !isCloudHosted()} + {:else} + + {#if guestUsage && !guestUsage.instance_enabled} + + A superadmin has turned guests off for this instance, so this switch has no + effect until they are allowed again. + + {:else if guestUsage} + + {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across + this instance in the last {guestUsage.window_days} days. + {#if guestUsage.metered} + Beyond that, every four guests count as one seat{guestUsage.guest_seats > 0 + ? ` (${guestUsage.guest_seats} now)` + : ''}. + {:else} + Beyond that, new guests are refused until the count drops; an Enterprise + license meters them instead. + {/if} + + {/if} +
+
+ Guest JWT verification key +
+
+ A guest can also enter through a JWT your own backend mints and signs, with no + identity-provider round-trip, for iframe embedding. The token must carry + email, workspace_id, app_path and + exp (lifetime capped at 24h); it opens only the app named by + app_path. Accepted algorithms: RS256/384/512, PS256/384/512, + ES256/384. Symmetric algorithms (HS*) are refused. Configure one key, a PEM + public key or a JWKS URL (which must be https). Point it at an issuer you + control: any token that key signs carrying these claims is accepted, so a + shared multi-tenant issuer is not a good fit. +
+ + {#snippet children({ item })} + + + {/snippet} + + {#if guestJwtKeyType === 'pem'} + + {:else} + + {/if}
Leave empty to fall back to the instance's configured JWT issuer (JWT_EXT_JWKS_URL), if one is set. Set a key here to trust a different issuer for this workspace.
- {/if} -
+
+ {/if}