mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
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:
co-authored by
Claude Fable 5.1
parent
2ce9118f60
commit
f626dc312f
@@ -1 +1 @@
|
||||
25a44debea8b34018b0df3263b7d050954a6702d
|
||||
a7dbde8c0df6da63dfa389c14c08bf4fd3169d5e
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user