From 4766f979dc7d3236aee4596485960c507fb7b796 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 1 Sep 2026 21:59:50 +0000 Subject: [PATCH] fix: close the relabel hole, guest embed tokens, read-path switch, custom-path entry Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 --- backend/ee-repo-ref.txt | 2 +- backend/src/monitor.rs | 20 +-- backend/tests/app_guest_execution_mode.rs | 149 ++++++++++++++++++ backend/windmill-api-users/src/users.rs | 20 +-- backend/windmill-api/openapi.yaml | 39 ++++- backend/windmill-api/src/apps.rs | 57 +++++-- backend/windmill-common/src/auth.rs | 8 +- backend/windmill-common/src/workspaces.rs | 22 +++ frontend/src/lib/components/Login.svelte | 9 +- .../apps/editor/AppEditorHeaderDeploy.svelte | 6 +- .../components/settings/TokensTable.svelte | 1 + .../(logged)/workspace_settings/+page.svelte | 2 +- frontend/src/routes/a/[...path]/+page.svelte | 24 +++ 13 files changed, 309 insertions(+), 50 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 14375ebc7c..9d1b850042 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -4b9c5f296c12dc26076131be62f6c0003d83917e +60ea364b42bd6e099d46522f3399274d25925ba7 diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 4627bd4c38..fb5aeb68ca 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -353,9 +353,7 @@ pub async fn initial_load( ) } }); - pass.action(windmill_common::min_version::store_min_keep_alive_version( - db, - )); + pass.action(windmill_common::min_version::store_min_keep_alive_version(db)); pass.setting( windmill_common::global_settings::INSTANCE_EVENTS_WEBHOOK_SETTING, false, @@ -701,6 +699,7 @@ pub async fn initial_load( pass.run(conn).await; } + pub fn apply_metrics_enabled(value: Option) { if let Some(serde_json::Value::Bool(t)) = value { METRICS_ENABLED.store(t, Ordering::Relaxed) @@ -1057,8 +1056,8 @@ pub fn apply_fork_workspace_tag_append_fork_suffix(value: Option error::Result<()> { - let v = load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true) - .await?; + let v = + load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true).await?; apply_critical_alert_mute_ui_setting(v); Ok(()) } @@ -2666,6 +2665,7 @@ pub async fn reload_timeout_wait_result_setting(conn: &Connection) { .await; } + pub async fn reload_extra_pip_index_url_setting(conn: &Connection) { reload_option_setting_with_tracing( conn, @@ -2756,6 +2756,7 @@ pub async fn reload_bunfig_install_scopes_setting(conn: &Connection) { .await; } + pub async fn reload_nuget_config_setting(conn: &Connection) { reload_option_setting_with_tracing( conn, @@ -2863,6 +2864,7 @@ pub async fn reload_ruby_repos_setting(conn: &Connection) { .await; } + pub async fn reload_workspace_registries_setting(conn: &Connection) { match load_value_from_global_settings_with_conn( conn, @@ -3101,6 +3103,7 @@ pub async fn apply_job_isolation_setting(value: Option) { } } + async fn resolve_license_key_value(conn: &Connection, quiet: bool) -> anyhow::Result { let q = load_value_from_global_settings_with_conn(conn, LICENSE_KEY_SETTING, true) .await @@ -3395,10 +3398,7 @@ impl<'a> SettingsPass<'a> { // on compile-time defaults until the next full reload. Only the single-query transport // can fail this way; over HTTP the batch already is the per-setting read. if matches!(conn, Connection::Sql(_)) && values.is_empty() && !names.is_empty() { - tracing::warn!( - "Falling back to per-setting reads for {} settings", - names.len() - ); + tracing::warn!("Falling back to per-setting reads for {} settings", names.len()); values = fetch_settings_individually(conn, &names).await; } for (name, http) in &declared { @@ -3790,6 +3790,7 @@ pub fn parse_setting_value( value } + #[cfg(feature = "prometheus")] pub async fn monitor_pool(db: &DB) { if METRICS_ENABLED.load(Ordering::Relaxed) { @@ -6505,6 +6506,7 @@ pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<( Ok(()) } + pub async fn reload_jwt_secret_setting(db: &DB) -> error::Result<()> { let v = load_value_from_global_settings(db, JWT_SECRET_SETTING).await?; apply_jwt_secret_setting(db, v).await diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs index cee3bb49f0..fd5bf3b901 100644 --- a/backend/tests/app_guest_execution_mode.rs +++ b/backend/tests/app_guest_execution_mode.rs @@ -255,6 +255,37 @@ async fn a_self_declared_guest_scope_grants_nothing(db: Pool) -> anyho "the guest session label must be server-minted only" ); + // ...and so must relabelling an ordinary token into it, or the pin-less user + // token would become a guest session that authenticates in every workspace. + let resp = authed( + client().post(format!("http://localhost:{port}/api/users/tokens/create")), + ADMIN_TOKEN, + ) + .json(&json!({ "label": "mine", "scopes": guest_scopes() })) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + let prefix: String = sqlx::query_scalar( + "SELECT token_prefix FROM token WHERE email = 'test@windmill.dev' AND label = 'mine'", + ) + .fetch_one(&db) + .await?; + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/users/tokens/update_label/{prefix}" + )), + ADMIN_TOKEN, + ) + .json(&json!({ "label": "guest_session" })) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "relabelling into the guest namespace must be refused: {}", + resp.text().await? + ); + // ...and a token that carries the scopes under any other label authenticates as // nothing in a workspace its owner is not a member of. // An email with no `usr` row anywhere: exactly the identity the guest arm exists @@ -281,3 +312,121 @@ async fn a_self_declared_guest_scope_grants_nothing(db: Pool) -> anyho Ok(()) } + +/// A guest-mode policy that names one runnable, so an `execute_component` request +/// gets past the triggerables lookup and reaches the guest gate. +fn guest_app_with_runnable(path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "Guest app", + "value": {}, + "policy": { + "execution_mode": "guest", + "triggerables_v2": { + "script/u/test-user/noop": { "static_inputs": {}, "one_of_inputs": {} } + } + } + }) +} + +fn execute(port: u16, ws: &str, app: &str, token: &str) -> reqwest::RequestBuilder { + authed( + client().post(format!( + "http://localhost:{port}/api/w/{ws}/apps_u/execute_component/{app}" + )), + token, + ) + .json(&json!({ + "component": "a", + "path": "script/u/test-user/noop", + "args": {} + })) +} + +/// The run path re-reads the workspace switch instead of trusting mint time. This is +/// the only thing standing between a `guest` policy pushed by git-sync and execution +/// once an admin has turned guests off. +#[sqlx::test(fixtures("base"))] +async fn execute_component_re_checks_the_workspace_switch( + db: Pool, +) -> anyhow::Result<()> { + 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"); + + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable(APP_PATH)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + insert_guest_token(&db, "test-workspace").await?; + + // Switch off: refused at the gate, even though the app's policy says guest and + // the session was (in this fixture) issued regardless. + let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a guest must not run components while the workspace has guests off" + ); + + // Switch on: past the gate. What follows is the runnable lookup, which fails on + // the nonexistent script — the point is that it is no longer a 403. + authed( + client().post(format!("{ws}/workspaces/edit_guest_access")), + ADMIN_TOKEN, + ) + .json(&json!({ "guest_access_enabled": true })) + .send() + .await?; + let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN) + .send() + .await?; + assert_ne!( + resp.status(), + 403, + "with guests on, the guest gate must let the run through: {}", + resp.text().await? + ); + + Ok(()) +} + +/// The path scope is what keeps a guest to the one app it was let in for: the route +/// layer is resource-blind for `apps:run`, so this line is drawn in the handler. +#[sqlx::test(fixtures("base"))] +async fn guest_cannot_run_another_guest_app(db: Pool) -> anyhow::Result<()> { + 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"); + + authed( + client().post(format!("{ws}/workspaces/edit_guest_access")), + ADMIN_TOKEN, + ) + .json(&json!({ "guest_access_enabled": true })) + .send() + .await?; + let other = "u/test-user/other_guest_app"; + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable(other)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + insert_guest_token(&db, "test-workspace").await?; // scoped to APP_PATH, not `other` + + let resp = execute(port, "test-workspace", other, GUEST_TOKEN) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a guest session scoped to one app must not run another, even one open to guests" + ); + + Ok(()) +} diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 9446fa8320..18bd58a72f 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -2970,11 +2970,11 @@ fn guest_session_scopes(app_path: &str) -> Vec { /// chrome-less public app page calls none of them; a page that needs one for a guest /// has to become workspace-scoped rather than the pin being loosened. /// -/// Refuses unless `app_path` is currently in `guest` execution mode, so no caller can -/// mint a guest session for an app that does not admit one. The caller still owes the -/// workspace switch ([`windmill_common::workspaces::is_guest_access_enabled`]) and the -/// authentication of `email` — this function trusts neither the path nor the workspace -/// on its own, only that the identity provider vouched for who is asking. +/// Refuses unless both gates say yes — the workspace admits guests and `app_path` is +/// currently in `guest` execution mode — so no caller can mint a guest session where +/// one is not wanted, whatever it believed when it decided to call. The one thing left +/// to the caller is the authentication of `email`: this function trusts only that the +/// identity provider vouched for who is asking. pub async fn create_guest_session_token<'c>( email: &str, w_id: &str, @@ -2994,14 +2994,7 @@ pub async fn create_guest_session_token<'c>( }; let scopes = guest_session_scopes(app_path); - let mode: Option> = sqlx::query_scalar( - "SELECT policy->>'execution_mode' FROM app WHERE workspace_id = $1 AND path = $2", - ) - .bind(w_id) - .bind(app_path) - .fetch_optional(&mut **tx) - .await?; - if mode.flatten().as_deref() != Some("guest") { + if !windmill_common::workspaces::guest_app_admits(&mut **tx, w_id, app_path).await? { return Err(Error::NotAuthorized(format!( "app {app_path} is not open to guests" ))); @@ -3401,6 +3394,7 @@ async fn update_token_label( WHERE email = $2 AND token_prefix = $3 AND (label IS NULL OR ( label <> 'session' + AND label <> 'guest_session' AND lower(label) NOT LIKE 'ephemeral%' AND label <> 'debugger-token' AND label NOT LIKE 'mcp-oauth-%' diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index cf4f5d1d19..43ba93cb01 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -8846,6 +8846,27 @@ paths: required: - app + /apps_u/guest_entry_by_custom_path/{custom_path}: + get: + summary: whether the app behind a custom path admits guests + description: >- + The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless + the app's execution mode is `guest` AND its workspace has + `guest_access_enabled`. Returns the workspace too, since a custom URL may not + carry it. + operationId: getGuestEntryByCustomPath + tags: + - app + parameters: + - $ref: "#/components/parameters/CustomPath" + responses: + "200": + description: the app is open to guests + content: + application/json: + schema: + $ref: "#/components/schemas/GuestEntry" + /apps_u/public_app_by_custom_path/{custom_path}: get: summary: get public app by custom path @@ -13027,12 +13048,7 @@ paths: content: application/json: schema: - type: object - properties: - app_path: - type: string - required: - - app_path + $ref: "#/components/schemas/GuestEntry" /w/{workspace}/apps_u/public_app/{path}: get: @@ -35071,6 +35087,17 @@ components: description: Configuration of protection restrictions items: $ref: "#/components/schemas/ProtectionRuleKind" + GuestEntry: + type: object + description: What a signed-out visitor needs to start a guest sign-in. + properties: + workspace_id: + type: string + app_path: + type: string + required: + - workspace_id + - app_path ProtectionRuleKind: type: string enum: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index bf52e28887..a491ea0f6d 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -335,8 +335,12 @@ fn deployment_rule_for_mode(mode: ExecutionMode) -> Option { /// A guest is authorized by its token's scope and never by an ACL probe: it holds no /// `usr` row, so RLS finds nothing for it and every guest would read as having no /// access. That scope is also what keeps a guest session to the one app it was minted -/// for, even though the mode itself admits anyone signed in. -pub fn authorize_non_member_viewer( +/// for, even though the mode itself admits anyone signed in. The workspace switch is +/// re-read for a guest here as it is on the run path, so turning guests off closes +/// the app to sessions already issued rather than waiting out their expiry. +pub async fn authorize_non_member_viewer( + db: &DB, + w_id: &str, mode: ExecutionMode, app_path: &str, opt_authed: &Option, @@ -352,6 +356,11 @@ pub fn authorize_non_member_viewer( let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()); if matches!(mode, ExecutionMode::Guest) { if is_guest { + if !windmill_common::workspaces::is_guest_access_enabled(db, w_id).await? { + return Err(Error::PermissionDenied(format!( + "app {app_path} is not open to guests" + ))); + } check_scopes(authed, || format!("apps:read:{}", app_path))?; } return Ok(true); @@ -367,6 +376,7 @@ pub fn authorize_non_member_viewer( /// [`authorize_non_member_viewer`] plus the member read-access probe, for the /// entry points that address an app by id. async fn authorize_app_viewer( + db: &DB, mode: ExecutionMode, app_path: &str, app_id: i64, @@ -374,7 +384,7 @@ async fn authorize_app_viewer( user_db: &UserDB, opt_authed: &Option, ) -> Result<()> { - if authorize_non_member_viewer(mode, app_path, opt_authed)? { + if authorize_non_member_viewer(db, w_id, mode, app_path, opt_authed).await? { return Ok(()); } let authed = opt_authed @@ -1320,6 +1330,7 @@ async fn get_public_app_by_secret( let policy = serde_json::from_str::(app.policy.0.get()).map_err(to_anyhow)?; authorize_app_viewer( + &db, policy.execution_mode(), &app.path, id, @@ -1666,8 +1677,18 @@ pub async fn mint_app_embed_token( // guest session is. `mint_raw_app_sdk_token` has the same shape. ensure_scopes_within_caller(authed, Some(&scopes))?; scopes.push(windmill_api_auth::scopes::APP_EMBED_SENTINEL.to_string()); + // An embed token minted by a guest has to resolve the same way the guest's + // own session does — through the label, since there is no `usr` row behind + // the email. With the ordinary label the iframe's every request would 401. + // The `app_embed` sentinel pushed above still confines it more tightly than + // the session that minted it. + let label = if windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) { + windmill_common::auth::GUEST_SESSION_LABEL.to_string() + } else { + format!("embed_app:{app_path}") + }; let token_config = NewToken::new( - Some(format!("embed_app:{app_path}")), + Some(label), Some(expiration), None, Some(scopes), @@ -1698,8 +1719,11 @@ pub async fn mint_app_embed_token( #[derive(Serialize)] pub struct GuestEntry { - /// The app path to name when starting a guest sign-in. - app_path: String, + /// The workspace and app path to name when starting a guest sign-in. The + /// workspace is redundant on the secret route and load-bearing on the custom-path + /// one, which may not carry it in its URL. + pub workspace_id: String, + pub app_path: String, } /// Whether the app behind this share secret admits guests, and under what path. @@ -1727,7 +1751,7 @@ async fn get_guest_entry( { return Err(Error::NotFound("App is not open to guests".to_string())); } - Ok(Json(GuestEntry { app_path: app.path })) + Ok(Json(GuestEntry { workspace_id: w_id, app_path: app.path })) } /// Issue an embed token for a public app addressed by its (secret) share id. @@ -1783,7 +1807,7 @@ async fn get_app_embed_token( } else { ExecutionMode::Publisher }; - authorize_app_viewer(mode, &app.path, id, &w_id, &user_db, &opt_authed).await?; + authorize_app_viewer(&db, mode, &app.path, id, &w_id, &user_db, &opt_authed).await?; opt_authed }; @@ -3996,8 +4020,16 @@ async fn execute_component( } }; - // Check rate limit for anonymous (public) executions - if matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none() { + // Rate limit for executions by callers the workspace does not know: anonymous + // viewers, and guests — on an instance whose provider accepts any consumer + // account, "anyone the IdP authenticates" is close to the anonymous population, + // and each run costs a job as the publisher. + let is_guest_caller = opt_authed + .as_ref() + .is_some_and(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref())); + if (matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none()) + || is_guest_caller + { if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? { if limit > 0 { crate::public_app_rate_limit::check_and_increment(&w_id, limit)?; @@ -4012,10 +4044,7 @@ async fn execute_component( // The workspace switch is re-read here rather than trusted from mint time, so // turning guests off stops them running code within the request, not within the // session's remaining lifetime. One indexed lookup, and only on the guest path. - if let Some(authed) = opt_authed - .as_ref() - .filter(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref())) - { + if let Some(authed) = opt_authed.as_ref().filter(|_| is_guest_caller) { if !matches!(policy.execution_mode(), ExecutionMode::Guest) || !windmill_common::workspaces::is_guest_access_enabled(&db, &w_id).await? { diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index ad0c7b4761..ca8b23e6f3 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -19,7 +19,7 @@ use crate::{ }; /// Whether `label` denotes a user-created token rather than a system token -/// (`session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token +/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token /// labels are load-bearing — session cleanup, super_admin propagation, expiry /// notifications and username overrides all key off them — so they must not be /// user-editable. `None` (no label) is treated as a user token. @@ -36,6 +36,7 @@ pub fn is_user_token(label: Option<&str>) -> bool { // frontend mirror (`label.toLowerCase().startsWith('ephemeral')`) and // the SQL `lower(label) NOT LIKE 'ephemeral%'` guard. l != "session" + && l != GUEST_SESSION_LABEL && !l.to_lowercase().starts_with("ephemeral") && l != "debugger-token" && !l.starts_with("mcp-oauth-") @@ -72,6 +73,11 @@ pub fn is_server_minted_label(label: &str) -> bool { pub const GUEST_SESSION_LABEL: &str = "guest_session"; /// Whether `label` marks a guest session. See [`GUEST_SESSION_LABEL`]. +/// +/// Reserved in [`is_user_token`] as well as [`is_server_minted_label`]: the former +/// gates relabelling, and a user token that could be relabelled *into* this +/// namespace would become a guest session with no workspace pin — one that +/// authenticates everywhere. pub fn is_guest_session_label(label: Option<&str>) -> bool { label == Some(GUEST_SESSION_LABEL) } diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 4025220a67..03215e2e12 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -794,6 +794,28 @@ pub async fn is_guest_access_enabled(db: &crate::DB, w_id: &str) -> Result .unwrap_or(false)) } +/// Both gates at once: the workspace admits guests and `app_path` is in `guest` +/// execution mode. The single answer to "may a guest session be minted for this app", +/// used by the mint itself and by the sign-in branch that decides whether to call it. +/// A missing app or a policy with no stated mode reads as "no". +pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>( + executor: E, + w_id: &str, + app_path: &str, +) -> Result { + let admits: Option = sqlx::query_scalar( + "SELECT COALESCE(ws.guest_access_enabled AND app.policy->>'execution_mode' = 'guest', false) + FROM app JOIN workspace_settings ws ON ws.workspace_id = app.workspace_id + WHERE app.workspace_id = $1 AND app.path = $2", + ) + .bind(w_id) + .bind(app_path) + .fetch_optional(executor) + .await + .map_err(|e| Error::internal_err(format!("checking guest access to {w_id}/{app_path}: {e:#}")))?; + Ok(admits.unwrap_or(false)) +} + /// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service /// accounts cannot log in and do not take a seat; a disabled member is not billed either. /// diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 2a5eb23c7b..8bec0dd309 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -607,10 +607,15 @@ } /** Mirrors the server-side write in the OAuth `login` handler, including clearing - * it when this sign-in is not a guest entry. `login_externally` consumes it. */ + * it when this sign-in is not a guest entry. `login_externally` consumes it. + * `Secure` only over https, as the backend does with its own cookies: a browser + * drops a `Secure` cookie set from an http origin, and a SAML guest sign-in there + * would then silently provision a real account instead. Lax suffices, since the + * ACS redirect that consumes it is a same-site top-level navigation. */ function setGuestAppCookie(value: string | undefined) { try { - document.cookie = `guest_app=${encodeURIComponent(value ?? '')}; path=/; SameSite=None; Secure` + const secure = window.location.protocol === 'https:' ? '; Secure' : '' + document.cookie = `guest_app=${encodeURIComponent(value ?? '')}; path=/; SameSite=Lax${secure}` } catch (e) { console.error('Could not set the guest app cookie', e) } diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 770b2130fa..10a8d2d440 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -467,7 +467,7 @@ value="guest" disabled={(!canSetGuest || !$enterpriseLicense) && policy.execution_mode != 'guest'} tooltip={$enterpriseLicense - ? 'Anyone who signs in through your identity provider. No workspace membership, no seat.' + ? 'Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat.' : 'Guest sign-in is a Windmill Enterprise Edition feature.'} {item} /> @@ -492,8 +492,8 @@ Guests are turned off for this workspace, so this app still admits members only. A workspace admin can turn them on in the workspace settings. {:else} - Anyone your identity provider authenticates can open this app. They join no workspace and - take no seat. + Anyone your identity provider authenticates can open this app without a Windmill account. + They join no workspace and take no seat. Members of this workspace can open it too. {/if} {:else} Only workspace members with read access on this app can open it. diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index f2e2ca235d..fe85d85f03 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -56,6 +56,7 @@ if (!label) return true return ( label !== 'session' && + label !== 'guest_session' && !label.toLowerCase().startsWith('ephemeral') && label !== 'debugger-token' && !label.startsWith('mcp-oauth-') diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 5a47d5b746..00a632f327 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -2177,7 +2177,7 @@ export async function main( void) | undefined + /** `/` when this app is open to guests. Resolved eagerly: + * PublicAppFrame renders its sign-in gate before `onViewerReady` fires. */ + let guestAppPath: string | undefined = $state(undefined) + + async function loadGuestEntry() { + try { + const entry = await AppService.getGuestEntryByCustomPath({ + customPath: parsedCustomPath.path + }) + guestAppPath = `${entry.workspace_id}/${entry.app_path}` + } catch { + guestAppPath = undefined + } + } // Embedder side: validate access (main session cookie or shared JWT) and mint // a scoped embed token for the opaque iframe (WIN-2006). @@ -113,17 +127,26 @@ } else { notExists = true } + // The app exists and admits guests; the load failed only for want of a + // session, so offer one instead of the not-found page. + await loadGuestEntry() + if (guestAppPath) { + notExists = false + noPermission = true + } } } if (BROWSER) { setLicense() + loadGuestEntry() } { refresh = requestTokenRefresh loadApp() @@ -135,6 +158,7 @@ {notExists} {noPermission} {jwtError} + {guestAppPath} {app} onLoginSuccess={() => loadApp()} >