diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e144a45307..f59f61e7c2 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -25a44debea8b34018b0df3263b7d050954a6702d \ No newline at end of file +a7dbde8c0df6da63dfa389c14c08bf4fd3169d5e \ No newline at end of file diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs index 30133cafe0..db1169169d 100644 --- a/backend/tests/app_guest_execution_mode.rs +++ b/backend/tests/app_guest_execution_mode.rs @@ -454,6 +454,62 @@ async fn guest_cannot_run_another_guest_app(db: Pool) -> anyhow::Resul Ok(()) } +/// The superadmin switch sits above every workspace's: off, no guest session stands and +/// no app discovers as open, whatever the workspace and the app say. +#[sqlx::test(fixtures("base"))] +async fn the_instance_switch_closes_every_workspace(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"); + + enable_guests(port, "test-workspace").await?; + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&guest_app_with_runnable(APP_PATH, false)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + insert_guest_token(&db, "test-workspace").await?; + let set_instance_switch = |disabled: bool| { + authed( + client().post(format!( + "http://localhost:{port}/api/settings/global/guest_access_disabled" + )), + ADMIN_TOKEN, + ) + .json(&json!({ "value": disabled })) + .send() + }; + + let secret: String = authed( + client().get(format!("{ws}/apps/secret_of/{APP_PATH}")), + ADMIN_TOKEN, + ) + .send() + .await? + .text() + .await?; + + set_instance_switch(true).await?.error_for_status()?; + let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) + .send() + .await?; + assert_eq!(resp.status(), 401, "the instance switch closes an issued session"); + let resp = client() + .get(format!("{ws}/apps_u/guest_entry/{secret}")) + .send() + .await?; + assert_eq!(resp.status(), 404, "and nothing discovers as open to guests"); + + set_instance_switch(false).await?.error_for_status()?; + let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN) + .send() + .await?; + assert_eq!(resp.status(), 200, "back on, the session stands again"); + + Ok(()) +} + /// An account holder is never a guest, and that holds after the mint too: a session /// minted before the account existed ends at the door the moment one does, so an /// account provisioned in a race with the mint cannot outlive the rule. diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 82548e44f5..d49ec4cf33 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -28859,8 +28859,11 @@ components: Guests are free up to `free_allowance` distinct emails over the trailing `window_days`. Past that an Enterprise plan meters them (`metered`, four guests to one seat: `billable_guests`, `guest_seats`); every other plan and build - admits no new email until the count drops. + admits no new email until the count drops. `instance_enabled` is the superadmin + switch (`guest_access_disabled` global setting) every workspace switch sits under. properties: + instance_enabled: + type: boolean guest_count: type: integer format: int64 @@ -28878,6 +28881,7 @@ components: type: integer format: int64 required: + - instance_enabled - guest_count - window_days - free_allowance diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index b1ad2edd01..ce6bc67a54 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -65,6 +65,9 @@ pub const EXPOSE_METRICS_SETTING: &str = "expose_metrics"; pub const EXPOSE_DEBUG_METRICS_SETTING: &str = "expose_debug_metrics"; pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir"; pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth"; +/// Superadmin switch over guest sessions for the whole instance, above the per-workspace +/// one. Read from the table, uncached, by the same gates that read the workspace switch. +pub const GUEST_ACCESS_DISABLED_SETTING: &str = "guest_access_disabled"; pub const JOB_ISOLATION_SETTING: &str = "job_isolation"; pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb"; pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing"; diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 0dd1048550..7898d89a19 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -820,6 +820,9 @@ 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 { + /// The superadmin switch (`GUEST_ACCESS_DISABLED_SETTING`), which every workspace + /// switch sits under. + pub instance_enabled: bool, /// Distinct guest emails over the trailing `window_days`. pub guest_count: i64, pub window_days: i32, @@ -831,7 +834,19 @@ pub struct GuestUsage { pub guest_seats: i64, } +/// SQL for "the instance admits guests": the superadmin switch, absent meaning on. +const INSTANCE_ADMITS_GUESTS_SQL: &str = + "NOT COALESCE((SELECT value::boolean FROM global_settings \ + WHERE name = 'guest_access_disabled'), false)"; + pub async fn guest_usage(db: &crate::DB) -> Result { + let instance_enabled: bool = + sqlx::query_scalar(&format!("SELECT {INSTANCE_ADMITS_GUESTS_SQL}")) + .fetch_one(db) + .await + .map_err(|e| { + Error::internal_err(format!("reading the instance guest switch: {e:#}")) + })?; let guest_count = guest_count_in_window(db).await?; let metered = guests_are_metered().await; let billable_guests = if metered { @@ -840,6 +855,7 @@ pub async fn guest_usage(db: &crate::DB) -> Result { 0 }; Ok(GuestUsage { + instance_enabled, guest_count, window_days: GUEST_WINDOW_DAYS, free_allowance: FREE_GUESTS_PER_WINDOW, @@ -901,17 +917,18 @@ pub async fn is_guest_access_enabled(db: &crate::DB, w_id: &str) -> Result .unwrap_or(false)) } -/// Whether a guest session for `email` in `w_id` still stands: the workspace admits -/// guests, and the email still has no account. Read at the auth door on every guest -/// request, so an account provisioned after the mint (or racing it) ends the session on -/// its next request rather than outliving the rule that an account holder is no guest. +/// Whether a guest session for `email` in `w_id` still stands: the instance and the +/// workspace admit guests, and the email still has no account. Read at the auth door on +/// every guest request, so turning either switch off, or an account provisioned after +/// the mint (or racing it), ends the session on its next request. pub async fn guest_session_stands(db: &crate::DB, w_id: &str, email: &str) -> Result { - let stands: Option = sqlx::query_scalar( + let stands: Option = sqlx::query_scalar(&format!( "SELECT guest_access_enabled + AND {INSTANCE_ADMITS_GUESTS_SQL} AND NOT EXISTS(SELECT 1 FROM password WHERE email = $2) AND NOT EXISTS(SELECT 1 FROM usr WHERE email = $2) - FROM workspace_settings WHERE workspace_id = $1", - ) + FROM workspace_settings WHERE workspace_id = $1" + )) .bind(w_id) .bind(email) .fetch_optional(db) @@ -920,25 +937,29 @@ pub async fn guest_session_stands(db: &crate::DB, w_id: &str, email: &str) -> Re Ok(stands.unwrap_or(false)) } -/// Both gates at once: 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". The allowance is `guest_admission`. +/// Every switch at once: the instance's, the workspace's, 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". The allowance is +/// `guest_admission`. 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( + let admits: Option = sqlx::query_scalar(&format!( "SELECT COALESCE(ws.guest_access_enabled AND app.policy->>'execution_mode' = 'guest', false) + AND {INSTANCE_ADMITS_GUESTS_SQL} FROM app JOIN workspace_settings ws ON ws.workspace_id = app.workspace_id - WHERE app.workspace_id = $1 AND app.path = $2", - ) + 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:#}")))?; + .map_err(|e| { + Error::internal_err(format!("checking guest access to {w_id}/{app_path}: {e:#}")) + })?; Ok(admits.unwrap_or(false)) } diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 65f092ddce..fe9ec97a37 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -362,17 +362,13 @@ {/if} - {#if extJwtTokens.length > 0 || (guestList?.guests.length ?? 0) > 0} - - - {#if extJwtTokens.length > 0} - - {/if} - {#if (guestList?.guests.length ?? 0) > 0} - - {/if} - - {/if} + + + {#if extJwtTokens.length > 0} + + {/if} + + {#if usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0) || (usersSubTab === 'guests' && !guestList)} loadGuestPage(Math.floor((guestList?.guests.length ?? 0) / guestPerPage) + 1)} + onInstanceSwitch={() => loadGuestPage(1)} /> {/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index e583fa1ffb..6d9e353d46 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -488,7 +488,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 guestAccessEnabled === undefined} + {#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} Checking whether this workspace allows guests… {:else if guestAccessEnabled === false} Guests are turned off for this workspace, so this app still admits members only. A diff --git a/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte b/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte index 14f9896486..1faaf1be16 100644 --- a/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte +++ b/frontend/src/lib/components/instanceSettings/GuestActivityList.svelte @@ -3,8 +3,10 @@ import Head from '$lib/components/table/Head.svelte' import Cell from '$lib/components/table/Cell.svelte' import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' + import Toggle from '$lib/components/Toggle.svelte' import { Alert } from '$lib/components/common' - import type { GuestActivity, GuestUsage } from '$lib/gen' + import { SettingService, type GuestActivity, type GuestUsage } from '$lib/gen' + import { sendUserToast } from '$lib/toast' interface Props { usage: GuestUsage @@ -12,10 +14,29 @@ hasMore: boolean loading: boolean onLoadMore: () => void + /** The instance switch was written; the caller re-reads usage. */ + onInstanceSwitch: () => void } - let { usage, guests, hasMore, loading, onLoadMore }: Props = $props() + let { usage, guests, hasMore, loading, onLoadMore, onInstanceSwitch }: Props = $props() const loadMoreSize = 50 + + async function setInstanceSwitch(enabled: boolean) { + try { + await SettingService.setGlobal({ + key: 'guest_access_disabled', + requestBody: { value: !enabled } + }) + sendUserToast( + enabled + ? 'Guests can sign in again where a workspace allows them' + : 'Guests can no longer sign in anywhere on this instance' + ) + } catch (e) { + sendUserToast(`Could not change the instance guest switch: ${e}`, true) + } + onInstanceSwitch() + } // A capped instance refuses the next stranger as soon as the allowance is used up. let pastAllowance = $derived( usage.metered @@ -29,6 +50,18 @@ 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." /> +
+ 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.' + }} + /> +
+
{#if usage.metered} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index e061e2cda1..5b5f614929 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -2189,7 +2189,12 @@ export async function main( bind:checked={guestAccessEnabled} options={{ right: 'Allow guests to open apps set to Guests' }} /> - {#if guestUsage} + {#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.