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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NiPw5gUgNJPxtGG1meS6RY

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-09 11:19:49 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent fd35b47658
commit 0b63e0a692
9 changed files with 424 additions and 106 deletions
+168
View File
@@ -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<Postgres>) -> 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(())
}
@@ -4691,6 +4691,9 @@ async fn edit_guest_access(
Json(EditGuestAccess { guest_access_enabled }): Json<EditGuestAccess>,
) -> Result<String> {
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)?;
}
+15 -5
View File
@@ -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
+31
View File
@@ -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<ExecutionMode>,
) -> 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)
{
+9
View File
@@ -579,6 +579,15 @@ pub async fn jwks_key_for(url: &str, token: &str) -> Result<(DecodingKey, Vec<Al
/// Verify `token` for `w_id` against whatever key the workspace configured. A PEM key
/// ignores `kid`; a JWKS selects by it.
pub async fn verify_for_workspace(db: &DB, w_id: &str, token: &str) -> Result<GuestJwtClaims> {
// 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"
+44 -6
View File
@@ -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<GuestUsage> {
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<GuestUsage> {
0
};
Ok(GuestUsage {
available: instance_supports_guests(),
instance_enabled,
guest_count,
window_days: GUEST_WINDOW_DAYS,
@@ -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 @@
</Alert>
<div class="mb-2"></div>
{/if}
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest'}
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable}
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
Opening this app to guests is restricted to workspace admins and bypass users by a workspace
protection rule
@@ -481,8 +485,10 @@
<ToggleButton
label="Guests"
value="guest"
disabled={!canSetGuest && policy.execution_mode != 'guest'}
tooltip="Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."
disabled={policy.execution_mode != 'guest' && (!guestsAvailable || !canSetGuest)}
tooltip={!guestsAvailable
? 'Not available on Windmill Cloud. Guests require a self-hosted instance or a dedicated Windmill Cloud deployment.'
: "Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."}
{item}
/>
<ToggleButton
@@ -499,7 +505,10 @@
{#if policy.execution_mode == 'anonymous'}
Anyone holding the secret URL below can open this app without signing in.
{:else if policy.execution_mode == 'guest'}
{#if guestUsage && !guestUsage.instance_enabled}
{#if !guestsAvailable}
Guests are not available on Windmill Cloud, so this app still admits members only. They
require a self-hosted instance or a dedicated Windmill Cloud deployment.
{:else if guestUsage && !guestUsage.instance_enabled}
A superadmin has turned guests off for this instance, so this app still admits members
only.
{:else if guestAccessEnabled === undefined}
@@ -554,7 +563,7 @@
{/if}
</div>
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase}
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable}
<div class="mt-4 border-t pt-3 flex flex-col gap-2">
<div class="text-xs font-semibold text-emphasis">
Embed for your own authenticated users (guest JWT)
@@ -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."
/>
<div class="flex flex-row gap-2 items-center mb-4">
{#key usage}
<Toggle
bind:checked={switchOn}
disabled={switchPending}
on:change={(e) => 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}
</div>
{#if !usage.available}
<div class="mb-4">
<Alert type="info" size="xs" title="Guests are not available on Windmill Cloud">
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.
</Alert>
</div>
{:else}
<div class="flex flex-row gap-2 items-center mb-4">
{#key usage}
<Toggle
bind:checked={switchOn}
disabled={switchPending}
on:change={(e) => 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}
</div>
<div class="mb-4">
<Alert type={pastAllowance ? 'warning' : 'info'} size="xs" title="{usage.guest_count} of {usage.free_allowance} free guests used in the last {usage.window_days} days">
{#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}
</Alert>
</div>
<div class="mb-4">
<Alert
type={pastAllowance ? 'warning' : 'info'}
size="xs"
title="{usage.guest_count} of {usage.free_allowance} free guests used in the last {usage.window_days} days"
>
{#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}
</Alert>
</div>
{/if}
<DataTable
shouldLoadMore={hasMore}
@@ -204,6 +204,9 @@
pem: guestJwtKeyType === 'pem' ? guestJwtPublicKey.trim() : '',
jwks: guestJwtKeyType === 'jwks' ? guestJwtJwksUrl.trim() : ''
})
// Whether the deployment can have guests at all; off, the card offers no switch to
// turn on. The backend decides; the hostname stands in until it has answered.
let guestsAvailable = $derived.by(() => 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<void> {
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<void> {
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"
>
<Toggle
bind:checked={guestAccessEnabled}
options={{ right: 'Allow guests to open apps set to Guests' }}
/>
{#if guestUsage && !guestUsage.instance_enabled}
<span class="text-hint text-2xs">
A superadmin has turned guests off for this instance, so this switch has no
effect until they are allowed again.
</span>
{:else if guestUsage}
<span class="text-hint text-2xs">
{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}
</span>
{/if}
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
<div class="text-xs font-semibold text-emphasis">
Guest JWT verification key
</div>
<div class="text-2xs text-hint">
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
<code>email</code>, <code>workspace_id</code>, <code>app_path</code> and
<code>exp</code> (lifetime capped at 24h); it opens only the app named by
<code>app_path</code>. 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.
</div>
<ToggleButtonGroup bind:selected={guestJwtKeyType}>
{#snippet children({ item })}
<ToggleButton {item} value="pem" label="PEM public key" />
<ToggleButton {item} value="jwks" label="JWKS URL" />
{/snippet}
</ToggleButtonGroup>
{#if guestJwtKeyType === 'pem'}
<TextInput
underlyingInputEl="textarea"
class="font-mono text-xs"
autosizeParams={{ minHeight: 128 }}
inputProps={{
placeholder: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'
}}
bind:value={guestJwtPublicKey}
/>
{:else}
<TextInput
inputProps={{
placeholder: 'https://issuer.example.com/.well-known/jwks.json'
}}
bind:value={guestJwtJwksUrl}
/>
{#if !guestsAvailable}
<Alert type="info" title="Not available on Windmill Cloud" size="xs">
Guests require a self-hosted instance or a dedicated Windmill Cloud deployment.
</Alert>
{#if initialGuestJwtPublicKey || initialGuestJwtJwksUrl}
<div class="mt-3 flex flex-row items-center gap-3">
<span class="text-hint text-2xs">
A guest JWT verification key is stored for this workspace and cannot be
used.
</span>
<Button unifiedSize="xs" variant="default" onclick={clearGuestJwtKey}>
Clear stored key
</Button>
</div>
{/if}
{#if !isCloudHosted()}
{:else}
<Toggle
bind:checked={guestAccessEnabled}
options={{ right: 'Allow guests to open apps set to Guests' }}
/>
{#if guestUsage && !guestUsage.instance_enabled}
<span class="text-hint text-2xs">
A superadmin has turned guests off for this instance, so this switch has no
effect until they are allowed again.
</span>
{:else if guestUsage}
<span class="text-hint text-2xs">
{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}
</span>
{/if}
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
<div class="text-xs font-semibold text-emphasis">
Guest JWT verification key
</div>
<div class="text-2xs text-hint">
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
<code>email</code>, <code>workspace_id</code>, <code>app_path</code> and
<code>exp</code> (lifetime capped at 24h); it opens only the app named by
<code>app_path</code>. 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.
</div>
<ToggleButtonGroup bind:selected={guestJwtKeyType}>
{#snippet children({ item })}
<ToggleButton {item} value="pem" label="PEM public key" />
<ToggleButton {item} value="jwks" label="JWKS URL" />
{/snippet}
</ToggleButtonGroup>
{#if guestJwtKeyType === 'pem'}
<TextInput
underlyingInputEl="textarea"
class="font-mono text-xs"
autosizeParams={{ minHeight: 128 }}
inputProps={{
placeholder: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'
}}
bind:value={guestJwtPublicKey}
/>
{:else}
<TextInput
inputProps={{
placeholder: 'https://issuer.example.com/.well-known/jwks.json'
}}
bind:value={guestJwtJwksUrl}
/>
{/if}
<div class="text-2xs text-hint">
Leave empty to fall back to the instance's configured JWT issuer (<code
>JWT_EXT_JWKS_URL</code
>), if one is set. Set a key here to trust a different issuer for this
workspace.
</div>
{/if}
</div>
</div>
{/if}
</SettingCard>
<SettingsFooter