fix: the label alone governs a guest; refuse guests with accounts; unserialize discovery

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5
This commit is contained in:
Ruben Fiszel
2026-09-02 07:19:41 +00:00
co-authored by Claude Opus 5
parent 27cb199e0a
commit b317002dd6
12 changed files with 181 additions and 34 deletions
+2
View File
@@ -14790,6 +14790,7 @@ dependencies = [
"tikv-jemallocator",
"tokio",
"tokio-stream",
"tower-cookies",
"tracing",
"tracing-subscriber",
"url",
@@ -14801,6 +14802,7 @@ dependencies = [
"windmill-api-client",
"windmill-api-scripts",
"windmill-api-settings",
"windmill-api-users",
"windmill-autoscaling",
"windmill-common",
"windmill-dep-map",
+2
View File
@@ -351,6 +351,8 @@ windmill-trigger-sqs.workspace = true
windmill-trigger-gcp.workspace = true
windmill-trigger-azure.workspace = true
windmill-api-auth.workspace = true
tower-cookies.workspace = true
windmill-api-users.workspace = true
axum.workspace = true
serde.workspace = true
windmill-api-client.workspace = true
+1 -1
View File
@@ -1 +1 @@
60ea364b42bd6e099d46522f3399274d25925ba7
fdb2f5b5df33776bc440a12e39bcc6b65981bd37
+89
View File
@@ -554,3 +554,92 @@ async fn a_guest_minted_embed_token_stays_a_guest(db: Pool<Postgres>) -> anyhow:
Ok(())
}
/// The label is the single source of truth: a guest-labelled credential is governed
/// as a guest even if its scopes carry no sentinel. Otherwise every mint that derives
/// a token from a guest session is one forgotten `push` away from an ungoverned
/// non-member credential.
#[sqlx::test(fixtures("base"))]
async fn a_guest_label_is_governed_without_the_sentinel(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");
let scopes: Vec<String> = guest_scopes().into_iter().filter(|s| s != "guest").collect();
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, token, email, label, scopes, workspace_id, expiration)
VALUES (encode(sha256($1::bytea), 'hex'), 'NOSENTINE_', $2, 'guest@example.com',
'guest_session', $3, 'test-workspace', now() + interval '8 hours')",
)
.bind(b"NOSENTINEL".as_slice())
.bind("NOSENTINEL")
.bind(scopes)
.execute(&db)
.await?;
let resp = authed(client().get(format!("{ws}/users/whoami")), "NOSENTINEL")
.send()
.await?;
assert_eq!(resp.status(), 200);
let me: serde_json::Value = resp.json().await?;
assert_eq!(
me["role"],
json!("guest"),
"the label alone must make a credential a guest"
);
let resp = authed(client().get(format!("{ws}/jobs/list")), "NOSENTINEL")
.send()
.await?;
assert_eq!(resp.status(), 403, "and confine it like one");
Ok(())
}
/// A guest is someone with no account at all — including a deactivated one. The
/// sign-in path's own account lookup filters on `disabled = false`, so a disabled
/// account reads as absent there; the mint has to refuse on its own or deactivation
/// (manual or SCIM, whose revocation is "delete the tokens") walks straight back in.
#[sqlx::test(fixtures("base"))]
async fn a_disabled_account_cannot_become_a_guest(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");
authed(
client().post(format!("{ws}/workspaces/edit_guest_access")),
ADMIN_TOKEN,
)
.json(&json!({ "guest_access_enabled": true }))
.send()
.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?);
sqlx::query(
"INSERT INTO password (email, password_hash, login_type, super_admin, verified, disabled)
VALUES ('gone@example.com', 'x', 'password', false, true, true)",
)
.execute(&db)
.await?;
let mut tx = db.begin().await?;
let cookies = tower_cookies::Cookies::default();
let minted = windmill_api_users::users::create_guest_session_token(
"gone@example.com",
"test-workspace",
APP_PATH,
&mut tx,
cookies,
)
.await;
assert!(
matches!(minted, Err(windmill_common::error::Error::NotAuthorized(_))),
"a deactivated account must be refused a guest session, got {minted:?}"
);
Ok(())
}
+9
View File
@@ -527,6 +527,15 @@ impl AuthCache {
// scopes on a user-minted token are whatever
// the caller typed.
None if is_guest_session => {
// The label is the grant; every guest
// control downstream keys on the `guest`
// sentinel. Pin the two together here so
// a credential that carries the label is
// governed as a guest whatever its scopes
// say — nothing else may decide that.
let scopes = Some(crate::scopes::with_guest_sentinel(
scopes.unwrap_or_default(),
));
Some(ApiAuthed {
username: email.clone(),
email,
+8
View File
@@ -785,6 +785,14 @@ pub fn has_guest_sentinel(scopes: Option<&[String]>) -> bool {
scopes.is_some_and(|s| s.iter().any(|x| x == GUEST_SENTINEL))
}
/// `scopes` with the guest sentinel present exactly once.
pub fn with_guest_sentinel(mut scopes: Vec<String>) -> Vec<String> {
if !scopes.iter().any(|x| x == GUEST_SENTINEL) {
scopes.push(GUEST_SENTINEL.to_string());
}
scopes
}
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
/// to narrow the declared scopes to what the viewer's prompt promised.
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
+13
View File
@@ -2994,6 +2994,19 @@ pub async fn create_guest_session_token<'c>(
};
let scopes = guest_session_scopes(app_path);
// A guest is someone with no account at all. Checked here, not only by the caller,
// because the sign-in path's own account lookup filters on `disabled = false` —
// a deactivated account (manual or SCIM, whose revocation is "delete the tokens")
// would otherwise read as absent and walk straight back in as a guest.
let has_account: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM password WHERE email = $1)")
.bind(email)
.fetch_one(&mut **tx)
.await?;
if has_account {
return Err(Error::NotAuthorized(
"an existing account cannot hold a guest session".to_string(),
));
}
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"
+16 -1
View File
@@ -1540,7 +1540,22 @@ pub async fn build_embed_token_response(
&& opt_authed.is_some()
&& !policy.frontend_sdk_scopes.is_empty()
{
Some(policy.frontend_sdk_scopes.clone())
// An SDK token runs as the viewer, and a guest's session is the ceiling on what
// it may delegate — the mint enforces that. Advertise only what a guest can
// actually be granted, so the consent prompt never promises a scope the mint
// would then refuse.
let declared = policy.frontend_sdk_scopes.clone();
let offered = match opt_authed {
Some(a) if windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()) => {
let held = a.scopes.as_deref().unwrap_or_default();
declared
.into_iter()
.filter(|sc| held.iter().any(|h| h == sc))
.collect::<Vec<_>>()
}
_ => declared,
};
(!offered.is_empty()).then_some(offered)
} else {
None
};
+25 -23
View File
@@ -404,7 +404,9 @@
if (autoRedirect && autoLogin && !error && !shouldSkipAutoRedirect()) {
if (autoLogin === 'saml' && saml) {
autoRedirecting = true
if (!redirectSaml()) autoRedirecting = false
redirectSaml().then((ok) => {
if (!ok) autoRedirecting = false
})
} else if (logins?.some((l) => l.type === autoLogin)) {
autoRedirecting = true
if (!storeRedirect(autoLogin)) {
@@ -596,16 +598,14 @@
console.log('oauth: popup closed before login completed')
return
}
// A guest session is pinned to its workspace and cannot answer the global
// probe; an ordinary session for a non-member cannot answer the workspace
// one. Either answering means the popup signed someone in.
const guestWorkspace = guestApp?.split('/')[0]
const probes: Promise<unknown>[] = [UserService.getCurrentEmail()]
if (guestWorkspace) probes.push(UserService.whoami({ workspace: guestWorkspace }))
try {
// A guest session is pinned to its workspace and cannot authenticate on
// any workspace-less route, so the global probe would 401 forever and this
// fallback would never complete a guest sign-in.
const guestWorkspace = guestApp?.split('/')[0]
if (guestWorkspace) {
await UserService.whoami({ workspace: guestWorkspace })
} else {
await UserService.getCurrentEmail()
}
await Promise.any(probes)
} catch {
return
}
@@ -614,24 +614,26 @@
}, 1500)
}
/** The SAML counterpart of the cookie the OAuth `login` handler writes server-side
* (`set_unsensitive_cookie`), including clearing it when this sign-in is not a
* guest entry. `login_externally` consumes it. `SameSite=None` is required: the
* SAML ACS is a cross-site POST from the IdP, and a Lax cookie is not sent on
* those. `None` needs `Secure`, so this only survives the round trip over https;
* over plain http the browser drops it and a guest sign-in falls through to
* ordinary provisioning. Host-only: a `COOKIE_DOMAIN` deployment that serves the
* ACS from a different host than this page would need the domain set here too. */
function setGuestAppCookie(value: string | undefined) {
/** Have the server write the guest-entry cookie, as the OAuth `login` handler does
* on its own path. SAML goes straight to the IdP and never passes through `login`,
* and a browser-set cookie would be host-only — on a `COOKIE_DOMAIN` deployment
* whose ACS answers on a sibling host it would never arrive. Server-set, it carries
* the same attributes as every other session cookie. Cleared (empty) when this
* sign-in is not a guest entry. `login_externally` consumes it. */
async function setGuestAppCookie(value: string | undefined) {
try {
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie = `guest_app=${encodeURIComponent(value ?? '')}; path=/; SameSite=None${secure}`
await fetch(`${base}/api/oauth/guest_app`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guest_app: value ?? '' })
})
} catch (e) {
console.error('Could not set the guest app cookie', e)
}
}
function redirectSaml(): boolean {
async function redirectSaml(): Promise<boolean> {
if (!saml) {
sendUserToast('No SAML login available', true)
return false
@@ -644,7 +646,7 @@
// too. Client-set is safe: the callback still checks that the named app is in
// guest mode and that the workspace allows guests, so the worst a forged value
// can do is give its own author a narrower session than they'd otherwise get.
setGuestAppCookie(guestApp)
await setGuestAppCookie(guestApp)
let target = saml
let relayStateSet = false
// Carry the SP-initiated deep link through the IdP round-trip via SAML
@@ -50,7 +50,8 @@
onViewerReady,
viewer,
viewerUrl,
guestAppPath = undefined
guestAppPath = undefined,
guestEntryResolved = true
}: {
/** Embedder-side: validate access + mint the scoped token. Throws with a
* `.status` of 401 (login required) or 404 (not found). Pass
@@ -74,6 +75,10 @@
* up front and pass it down — otherwise a signed-out visitor is offered an
* ordinary sign-in that creates an account and still cannot open the app. */
guestAppPath?: string | undefined
/** False while the page is still finding out whether the app admits guests. The
* sign-in card waits for it (a configured auto-login would otherwise start an
* ordinary sign-in); nothing else does. */
guestEntryResolved?: boolean
} = $props()
const EMBED_PARAM = 'wm_embed'
@@ -542,6 +547,8 @@
onContinue={onSdkConsentContinue}
onDecline={onSdkConsentDecline}
/>
{:else if status === 'noPermission' && !guestEntryResolved}
<Skeleton layout={[[4], 0.5, [50]]} />
{:else if status === 'noPermission'}
<!-- Login happens here, on the embedder (main) window, so the session cookie
is set on the main origin only and never reaches the opaque iframe. -->
+4 -4
View File
@@ -61,8 +61,9 @@
/** `<workspace>/<app_path>` when this app is open to guests. Resolved eagerly:
* PublicAppFrame renders its sign-in gate before `onViewerReady` fires. */
let guestAppPath: string | undefined = $state(undefined)
/** The frame's sign-in gate must not mount before this is known: a configured
* auto-login would otherwise fire an ordinary sign-in and provision an account. */
/** The frame's sign-in card must not mount before this is known: a configured
* auto-login would otherwise fire an ordinary sign-in and provision an account.
* Only the card waits — the app load itself runs in parallel with discovery. */
let guestEntryResolved = $state(false)
async function loadGuestEntry() {
@@ -148,11 +149,11 @@
}
</script>
{#if guestEntryResolved}
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
{guestAppPath}
{guestEntryResolved}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
@@ -170,4 +171,3 @@
></PublicApp>
{/snippet}
</PublicAppFrame>
{/if}
@@ -20,8 +20,9 @@
* offer a guest session rather than a dead end. 404 (the common case) leaves it
* undefined. */
let guestAppPath: string | undefined = $state(undefined)
/** The frame's sign-in gate must not mount before this is known: a configured
* auto-login would otherwise fire an ordinary sign-in and provision an account. */
/** The frame's sign-in card must not mount before this is known: a configured
* auto-login would otherwise fire an ordinary sign-in and provision an account.
* Only the card waits — the app load itself runs in parallel with discovery. */
let guestEntryResolved = $state(false)
function parseSecret(secret: string): { secret: string; jwt: string | undefined } {
@@ -124,11 +125,11 @@
}
</script>
{#if guestEntryResolved}
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
{guestAppPath}
{guestEntryResolved}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
@@ -146,4 +147,3 @@
></PublicApp>
{/snippet}
</PublicAppFrame>
{/if}