feat: a superadmin switch over guests for the whole instance; the pre-existing-user flag keeps its meaning

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5
This commit is contained in:
Ruben Fiszel
2026-09-03 19:37:48 +02:00
co-authored by Claude Fable 5.1
parent 2ce9118f60
commit f626dc312f
9 changed files with 154 additions and 32 deletions
+1 -1
View File
@@ -1 +1 @@
25a44debea8b34018b0df3263b7d050954a6702d
a7dbde8c0df6da63dfa389c14c08bf4fd3169d5e
+56
View File
@@ -454,6 +454,62 @@ async fn guest_cannot_run_another_guest_app(db: Pool<Postgres>) -> 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<Postgres>) -> 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.
+5 -1
View File
@@ -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
@@ -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";
+36 -15
View File
@@ -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<GuestUsage> {
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<GuestUsage> {
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<bool>
.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<bool> {
let stands: Option<bool> = sqlx::query_scalar(
let stands: Option<bool> = 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<bool> {
let admits: Option<bool> = sqlx::query_scalar(
let admits: Option<bool> = 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))
}
@@ -362,17 +362,13 @@
</div>
{/if}
{#if extJwtTokens.length > 0 || (guestList?.guests.length ?? 0) > 0}
<Tabs bind:selected={usersSubTab} class="mb-4">
<Tab value="users" label="Users" />
{#if extJwtTokens.length > 0}
<Tab value="ext_jwt" label="External JWTs" />
{/if}
{#if (guestList?.guests.length ?? 0) > 0}
<Tab value="guests" label="Guests" />
{/if}
</Tabs>
{/if}
<Tabs bind:selected={usersSubTab} class="mb-4">
<Tab value="users" label="Users" />
{#if extJwtTokens.length > 0}
<Tab value="ext_jwt" label="External JWTs" />
{/if}
<Tab value="guests" label="Guests" />
</Tabs>
{#if usersSubTab === 'users' || (usersSubTab === 'ext_jwt' && extJwtTokens.length === 0) || (usersSubTab === 'guests' && !guestList)}
<SettingsPageHeader
@@ -748,6 +744,7 @@
loading={guestLoading}
onLoadMore={() =>
loadGuestPage(Math.floor((guestList?.guests.length ?? 0) / guestPerPage) + 1)}
onInstanceSwitch={() => loadGuestPage(1)}
/>
{/if}
</div>
@@ -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
@@ -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."
/>
<div class="flex flex-row gap-2 items-center mb-4">
<Toggle
checked={usage.instance_enabled}
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.'
}}
/>
</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}
@@ -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}
<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.