diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs index 247fbd26ac..47e5c5b31b 100644 --- a/backend/tests/app_guest_execution_mode.rs +++ b/backend/tests/app_guest_execution_mode.rs @@ -15,7 +15,9 @@ //! is written (git-sync and the CLI push policies past every UI). //! //! The token is inserted directly: how a guest session is minted is the identity -//! provider's business (EE), what one can do is this file's. +//! provider's business (EE), what one can do is this file's. Build with +//! `enterprise,private`, as CI does: the plan gate refuses every guest on a build +//! without them, so on a plain CE build every test here fails at the door. //! //! Users from the `base` fixture: //! test-user (admin, token SECRET_TOKEN) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index d5d14f69ea..d621b231c6 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -1099,6 +1099,10 @@ async fn get_settings( if let Some(git_sync) = settings.git_sync.as_mut() { redact_git_sync_webhook_secrets(git_sync); } + // The effective value, not the column: a switch left on by a plan that no longer + // admits guests must not read as open when every gate says shut. + settings.guest_access_enabled = + settings.guest_access_enabled && windmill_common::workspaces::guest_access_licensed().await; Ok(Json(settings)) } @@ -1135,8 +1139,10 @@ async fn get_public_settings( .await .map_err(|e| Error::internal_err(format!("getting public settings: {e:#}")))?; - let settings = not_found_if_none(settings, "workspace settings", &w_id)?; + let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?; tx.commit().await?; + settings.guest_access_enabled = + settings.guest_access_enabled && windmill_common::workspaces::guest_access_licensed().await; Ok(Json(settings)) } diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 160a3aeb7a..e6b217cdc3 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -775,10 +775,10 @@ pub struct BillableSeats { pub seats: i64, } -/// Guests are an Enterprise-plan feature, so a Pro key refuses them at every gate — -/// the workspace switch, the mint, the door — not only in the UI that hides them. A -/// build without `enterprise` has no plan to consult and cannot mint a guest session -/// at all (the mint lives in EE code), so nothing is gated there. +/// Guests are an Enterprise-plan feature, refused on any other plan and on any build +/// without `enterprise` at every gate: the switch write, discovery, the mint, the +/// door. The CE image compiles the OAuth callback that mints, so the build check is +/// load-bearing, not a formality. pub async fn guest_access_licensed() -> bool { #[cfg(feature = "enterprise")] { @@ -789,12 +789,13 @@ pub async fn guest_access_licensed() -> bool { } #[cfg(not(feature = "enterprise"))] { - true + false } } /// Whether `w_id` admits guest sessions — someone the identity provider authenticated -/// who is a member of no workspace, and who therefore takes no seat. +/// who is a member of no workspace, and who therefore takes no seat. The plan is +/// checked first: a switch left on by a plan that no longer admits guests is shut. /// /// Read uncached where a session is minted ([`guest_app_admits`]) and then once per /// request at the auth door (`AuthCache::get_opt_job_authed`) for every guest. An app @@ -815,7 +816,7 @@ 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` +/// Every gate at once: the plan, the workspace switch, and `app_path` being 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". diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index 66120610ee..706e3a1986 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -215,10 +215,9 @@ let accountSession = $state<'unknown' | 'none' | 'held'>('unknown') let deniedStatus: number | undefined = $state(undefined) /** The sign-in card belongs on a 401, and on a 403 unless discovery has settled - * that the app is not open to guests — a 403 on a guest app is a session for a - * different app of the same workspace, which a fresh sign-in replaces. While - * discovery is still pending this stays true so the skeleton shows rather than - * a flash of "Not found". */ + * that the app is not open to guests: a 403 on a guest app is a session for another + * app of the workspace, which a fresh sign-in replaces. True while discovery is + * pending, so the skeleton shows rather than a flash of "Not found". */ let offerSignIn = $derived( status === 'noPermission' || (status === 'notExists' && deniedStatus === 403 && guestEntry !== 'none') @@ -235,10 +234,9 @@ }) // The stale guest session must be gone before the card mounts: it still - // authenticates in this workspace, so the popup's success poll would see it and - // complete the sign-in before the new session ever lands. The card waits on - // `staleGuestCleared`, and a failed logout fails closed rather than offering a - // sign-in that could not complete. + // authenticates here, so the popup's success poll would see it and complete the + // sign-in before the new session lands. The card waits on `staleGuestCleared`; a + // failed logout fails closed rather than offering a sign-in that cannot complete. let staleGuestCleared = $state(false) let staleGuestLogoutFailed = $state(false) $effect(() => {