mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 08:01:35 +00:00
* feat: guest app execution mode, a fourth role that takes no seat Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: make the guest grant a server-minted label, not a declarable scope Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * chore: pin ee-repo-ref to the guest session companion branch Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: close the relabel hole, guest embed tokens, read-path switch, custom-path entry Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest tokens are not rescopable and guest embed tokens keep the sentinel Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest-derived tokens share one constraint set; gate sign-in on guest discovery Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * 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 * fix: guest discovery fails closed; SAML aborts if the guest cookie write fails Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * refactor: enforce the guest switch once at the auth door; sign-in for a guest of another app Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest app-mode decided once at the on-behalf resolver; clear a stale guest session before offering another app's sign-in Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: a guest may use anonymous apps; await the stale-session logout; trim comments Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: a guest's path confinement waits for the app's mode, so anonymous apps stay open to it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest target survives http (Lax cookie), rides SAML RelayState; tell account holders on arrival * fix: a guest uses an anonymous app as itself; S3 uploads confined by app mode * fix: a guest upload needs an app policy; a missing app does not skip the confinement * fix: guests are gated on the Enterprise plan server-side; pin ee-repo-ref * fix: the guest plan gate fails closed on non-enterprise builds; settings report the effective switch * fix: guest controls read the plan, not the key; gate the guest tests on the features they need * docs: tighten the guest session invariant comments * feat: 100 free guests per 30 days, then a quarter seat each on Enterprise and a hard cap elsewhere; superadmin guest list; refusals reach the page Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: the cap is exact, an account ends a guest session at the door, popups close, and guest mode survives the CLI round trip Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * 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 * fix: drop the dead guest-access helper, name the instance setting once, guests tab states, CE save order Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: a guest app path is refused at the mint if it could widen the scope; the instance toggle waits for its reload Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guests stop at the launched-by-me job grant; canonical app paths at the mint and discovery; the toggle ends on the stored value Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: only the scope grammar's own characters bar an app path from guests, refused at deploy as well as at the mint Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: the deploy-time guest path guard checks the destination of a rename and refuses a leading slash Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: a workspace rename keeps the guest switch; the rename guard reads the deployed mode under the row lock Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * fix: guest_activity follows a workspace rename and goes with a workspace delete Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * chore: pin ee-repo-ref to the state-bound guest target Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * chore: pin ee-repo-ref; the guest cookie is never cleared by a callback Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * docs: the workspace-scoped guest_activity delete moves an instance-wide count; assert the mint records the guest Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * test: the seeded allowance is a day old, so only the mint can write today's guest_activity row Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5 * chore: update ee-repo-ref to 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07 This commit updates the EE repository reference after PR #769 was merged in windmill-ee-private. Previous ee-repo-ref: 32841072aa396bff91d30bd91854fa348cb3c439 New ee-repo-ref: 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1078 lines
37 KiB
Rust
1078 lines
37 KiB
Rust
//! Tests for the `guest` app execution mode.
|
|
//!
|
|
//! A guest (`ExecutionMode::Guest`) has no account and so no ACL of its own: its
|
|
//! token's scopes are its entire grant. These tests pin the three things that would
|
|
//! silently undo it:
|
|
//!
|
|
//! * what makes a token a guest — the server-minted label, never a scope anyone
|
|
//! could type into `users/tokens/create`;
|
|
//! * the confinement — a guest reaches the one app it was let in for and nothing
|
|
//! else;
|
|
//! * the switches — an app's own `execution_mode: guest` is inert unless the
|
|
//! workspace and the instance allow guests, checked at the door rather than only
|
|
//! where a policy is written (git-sync and the CLI push policies past every UI);
|
|
//! the allowance on top of them has a binary of its own.
|
|
//!
|
|
//! 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.
|
|
//!
|
|
//! Users from the `base` fixture:
|
|
//! test-user (admin, token SECRET_TOKEN)
|
|
|
|
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_test_utils::*;
|
|
|
|
const ADMIN_TOKEN: &str = "SECRET_TOKEN";
|
|
const GUEST_TOKEN: &str = "GUEST_SECRET_TOKEN";
|
|
const APP_PATH: &str = "u/test-user/guest_app";
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
|
builder.header("Authorization", format!("Bearer {}", token))
|
|
}
|
|
|
|
async fn enable_guests(port: u16, ws: &str) -> anyhow::Result<()> {
|
|
authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/{ws}/workspaces/edit_guest_access"
|
|
)),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "guest_access_enabled": true }))
|
|
.send()
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn guest_scopes() -> Vec<String> {
|
|
vec![
|
|
"guest".to_string(),
|
|
"jobs:read".to_string(),
|
|
"resources:run".to_string(),
|
|
"users:read".to_string(),
|
|
"folders:read".to_string(),
|
|
format!("apps:read:{APP_PATH}"),
|
|
format!("apps:run:{APP_PATH}"),
|
|
]
|
|
}
|
|
|
|
/// Insert a guest session for `test-workspace`, scoped to `APP_PATH`. Mirrors
|
|
/// `create_guest_session_token`: the server-minted label, the narrow reads, the two
|
|
/// path-scoped app grants, the workspace pin, and an expiry — a derived token's
|
|
/// lifetime is capped at it, so a guest session without one cannot mint.
|
|
async fn insert_guest_token(db: &Pool<Postgres>, workspace: &str) -> anyhow::Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO token (token_hash, token_prefix, token, email, label, scopes, workspace_id, expiration)
|
|
VALUES (encode(sha256($1::bytea), 'hex'), 'GUEST_SECR', $2, 'guest@example.com',
|
|
'guest_session', $3, $4, now() + interval '8 hours')",
|
|
)
|
|
.bind(GUEST_TOKEN.as_bytes())
|
|
.bind(GUEST_TOKEN)
|
|
.bind(guest_scopes())
|
|
.bind(workspace)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn guest_session_is_confined_to_its_app(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?;
|
|
insert_guest_token(&db, "test-workspace").await?;
|
|
|
|
// Its own identity resolves, and reports the role rather than falling through to
|
|
// the non-member branch that hands out a `superadmin` shape.
|
|
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "guest whoami must resolve");
|
|
let me: serde_json::Value = resp.json().await?;
|
|
assert_eq!(
|
|
me["role"],
|
|
json!("guest"),
|
|
"guest must not read as superadmin"
|
|
);
|
|
assert_eq!(me["operator"], json!(true));
|
|
assert_eq!(me["is_admin"], json!(false));
|
|
|
|
// `resources/list_names` and the type schemas stay open — a guest drives an app,
|
|
// and app pickers need them — so the line to pin is the value-returning route.
|
|
for route in [
|
|
"jobs/list",
|
|
"scripts/list",
|
|
"flows/list",
|
|
"variables/list",
|
|
"resources/get_value/u/test-user/secret",
|
|
"apps/list",
|
|
] {
|
|
let resp = authed(client().get(format!("{ws}/{route}")), GUEST_TOKEN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
403,
|
|
"guest must be denied {route}, got {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn guest_token_does_not_cross_workspaces(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// A second workspace with an app at the SAME path: without the token's workspace
|
|
// pin, `apps:run:<path>` would unlock it too, since a path is not unique across
|
|
// workspaces.
|
|
sqlx::query(
|
|
"INSERT INTO workspace (id, name, owner) VALUES ('other-ws', 'other-ws', 'test-user')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ('other-ws')")
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
insert_guest_token(&db, "test-workspace").await?;
|
|
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"http://localhost:{port}/api/w/other-ws/apps/get/p/{APP_PATH}"
|
|
)),
|
|
GUEST_TOKEN,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"a guest token pinned to one workspace must not authenticate against another"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch(
|
|
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 resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
|
.json(&json!({
|
|
"path": APP_PATH,
|
|
"summary": "Guest app",
|
|
"value": {},
|
|
"policy": { "execution_mode": "guest", "triggerables": {} }
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
|
|
let secret: String = authed(
|
|
client().get(format!("{ws}/apps/secret_of/{APP_PATH}")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.send()
|
|
.await?
|
|
.text()
|
|
.await?;
|
|
|
|
// The app says guest, the workspace has not opted in: inert.
|
|
let resp = client()
|
|
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
404,
|
|
"a guest app in a workspace that has not enabled guests must not advertise entry"
|
|
);
|
|
|
|
authed(
|
|
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "guest_access_enabled": true }))
|
|
.send()
|
|
.await?;
|
|
|
|
// Unauthenticated on purpose: this is what a signed-out visitor reads.
|
|
let resp = client()
|
|
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
let entry: serde_json::Value = resp.json().await?;
|
|
assert_eq!(entry["app_path"], json!(APP_PATH));
|
|
|
|
// Turning the switch back off closes the door again even though the app's own
|
|
// policy is unchanged.
|
|
authed(
|
|
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "guest_access_enabled": false }))
|
|
.send()
|
|
.await?;
|
|
let resp = client()
|
|
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
404,
|
|
"turning guests off must stop advertising entry for an app already set to guest"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The guest grant is the server-minted label, never the `guest` scope. Scopes on a
|
|
/// user-created token are whatever the caller typed, so if the scope granted anything
|
|
/// then any member of any workspace could mint themselves non-member access to every
|
|
/// guest-mode app on the instance.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn a_self_declared_guest_scope_grants_nothing(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");
|
|
|
|
// `users/tokens/create` must refuse the label outright...
|
|
let resp = authed(
|
|
client().post(format!("http://localhost:{port}/api/users/tokens/create")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "label": "guest_session", "scopes": guest_scopes() }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
400,
|
|
"the guest session label must be server-minted only"
|
|
);
|
|
|
|
// ...and so must relabelling an ordinary token into it, or the pin-less user
|
|
// token would become a guest session that authenticates in every workspace.
|
|
let resp = authed(
|
|
client().post(format!("http://localhost:{port}/api/users/tokens/create")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "label": "mine", "scopes": guest_scopes() }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
let prefix: String = sqlx::query_scalar(
|
|
"SELECT token_prefix FROM token WHERE email = 'test@windmill.dev' AND label = 'mine'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/users/tokens/update_label/{prefix}"
|
|
)),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "label": "guest_session" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
400,
|
|
"relabelling into the guest namespace must be refused: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// ...and a token that carries the scopes under any other label authenticates as
|
|
// nothing in a workspace its owner is not a member of.
|
|
// An email with no `usr` row anywhere: exactly the identity the guest arm exists
|
|
// to admit, and the one a forged scope must not admit.
|
|
sqlx::query(
|
|
"INSERT INTO token (token_hash, token_prefix, token, email, label, scopes)
|
|
VALUES (encode(sha256($1::bytea), 'hex'), 'FORGED_SCO', $2, 'outsider@example.com',
|
|
'forged', $3)",
|
|
)
|
|
.bind(b"FORGED_SCOPES".as_slice())
|
|
.bind("FORGED_SCOPES")
|
|
.bind(guest_scopes())
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let resp = authed(client().get(format!("{ws}/users/whoami")), "FORGED_SCOPES")
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"declaring the guest scope must not turn a non-member into an identity"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A guest-mode policy that names one runnable, so an `execute_component` request
|
|
/// gets past the triggerables lookup and reaches the guest gate. `sandbox` is what
|
|
/// makes the embed-token endpoint actually mint a token.
|
|
fn guest_app_with_runnable(path: &str, sandbox: bool) -> serde_json::Value {
|
|
app_with_runnable(path, "guest", sandbox)
|
|
}
|
|
|
|
fn app_with_runnable(path: &str, execution_mode: &str, sandbox: bool) -> serde_json::Value {
|
|
json!({
|
|
"path": path,
|
|
"summary": "App",
|
|
"value": {},
|
|
"policy": {
|
|
"execution_mode": execution_mode,
|
|
"sandbox": sandbox,
|
|
"triggerables_v2": {
|
|
"script/u/test-user/noop": { "static_inputs": {}, "one_of_inputs": {} }
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
fn execute(port: u16, ws: &str, app: &str, token: &str) -> reqwest::RequestBuilder {
|
|
authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/{ws}/apps_u/execute_component/{app}"
|
|
)),
|
|
token,
|
|
)
|
|
.json(&json!({
|
|
"component": "a",
|
|
"path": "script/u/test-user/noop",
|
|
"args": {}
|
|
}))
|
|
}
|
|
|
|
/// The workspace switch is enforced at the auth door for every guest request, not
|
|
/// remembered per handler. This is what stands between a `guest` policy pushed by
|
|
/// git-sync and execution once an admin has turned guests off — and it closes the
|
|
/// app to sessions already issued.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn the_door_re_checks_the_workspace_switch(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 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?;
|
|
|
|
// Switch off: the session does not authenticate at all, even though the app's
|
|
// policy says guest and the session was (in this fixture) issued regardless. On
|
|
// the authed route that is a 401; on the optional-auth run route the rejected
|
|
// token reads as no token, and a guest-mode app then refuses the anonymous
|
|
// caller — a denial either way.
|
|
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"a guest must not authenticate while guests are off"
|
|
);
|
|
let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN)
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
resp.status().is_client_error() && resp.status() != 404,
|
|
"a guest must not run while guests are off, got {}",
|
|
resp.status()
|
|
);
|
|
|
|
// Switch on: through the door. What follows the run is the runnable lookup,
|
|
// which fails on the nonexistent script — the point is that it is no longer a
|
|
// denial.
|
|
enable_guests(port, "test-workspace").await?;
|
|
let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN)
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
resp.status() != 401 && resp.status() != 403,
|
|
"with guests on, the door must let the run through: {}",
|
|
resp.status()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The path scope is what keeps a guest to the one app it was let in for: the route
|
|
/// layer is resource-blind for `apps:run`, so this line is drawn in the handler.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn guest_cannot_run_another_guest_app(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 other = "u/test-user/other_guest_app";
|
|
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
|
.json(&guest_app_with_runnable(other, false))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
insert_guest_token(&db, "test-workspace").await?; // scoped to APP_PATH, not `other`
|
|
|
|
let resp = execute(port, "test-workspace", other, GUEST_TOKEN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
403,
|
|
"a guest session scoped to one app must not run another, even one open to guests"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The app path is spliced into the session's scopes, whose grammar reserves `:`, `,`
|
|
/// and `*`: a path carrying one would scope the guest to more than the one app it was
|
|
/// let in for, so the mint refuses it before anything else. Anything else in a path
|
|
/// (spaces, `@`) is literal to that grammar and stays admissible.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn a_scope_metacharacter_in_the_app_path_is_refused(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let mint = |path: &'static str| {
|
|
let db = db.clone();
|
|
async move {
|
|
let mut tx = db.begin().await?;
|
|
let minted = windmill_api_users::users::create_guest_session_token(
|
|
"guest@example.com",
|
|
"test-workspace",
|
|
path,
|
|
&mut tx,
|
|
tower_cookies::Cookies::default(),
|
|
)
|
|
.await;
|
|
anyhow::Ok(minted)
|
|
}
|
|
};
|
|
for path in [
|
|
"u/test-user/entry,u/test-user/hidden",
|
|
"u/test-user/*",
|
|
"u/test-user/entry:run",
|
|
] {
|
|
let minted = mint(path).await?;
|
|
assert!(
|
|
matches!(minted, Err(windmill_common::error::Error::BadRequest(ref m)) if m.contains("cannot be scoped")),
|
|
"{path}: {minted:?}"
|
|
);
|
|
}
|
|
for path in ["u/test-user/My App", "u/admin@windmill.dev/x"] {
|
|
let minted = mint(path).await?;
|
|
assert!(
|
|
!matches!(minted, Err(windmill_common::error::Error::BadRequest(ref m)) if m.contains("cannot be scoped")),
|
|
"{path} is literal to the scope grammar and must get past the guard: {minted:?}"
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A guest reads the jobs it launched and nothing else: with no membership behind it,
|
|
/// it must stop where an app embed token stops, before the share-token and ACL grants
|
|
/// a member would get, and with the same "not found" so it cannot probe for jobs.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn a_guest_cannot_read_a_job_it_did_not_launch(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?;
|
|
insert_guest_token(&db, "test-workspace").await?;
|
|
let resp = authed(client().post(format!("{ws}/scripts/create")), ADMIN_TOKEN)
|
|
.json(&json!({
|
|
"path": "u/test-user/noop",
|
|
"summary": "",
|
|
"description": "",
|
|
"content": "echo 42",
|
|
"language": "bash",
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
let resp = authed(
|
|
client().post(format!("{ws}/jobs/run/p/u/test-user/noop")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
let job_id = resp.text().await?;
|
|
|
|
let resp = authed(
|
|
client().get(format!("{ws}/jobs_u/getupdate/{job_id}")),
|
|
GUEST_TOKEN,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
404,
|
|
"another caller's job is not found for a guest: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Guests mode cannot land on a path the scope grammar cannot hold, however it gets
|
|
/// there: set at creation, set on update, or a rename of an app already in that mode.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn guests_mode_needs_a_scopable_path(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 resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
|
.json(&guest_app_with_runnable("u/test-user/a:b", false))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "created into Guests on a `:` path");
|
|
|
|
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?);
|
|
let resp = authed(
|
|
client().post(format!("{ws}/apps/update/{APP_PATH}")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "path": "u/test-user/a,b" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "renamed to a `,` path while in Guests");
|
|
let resp = authed(
|
|
client().post(format!("{ws}/apps/update/{APP_PATH}")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "path": "u/test-user/My App" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"a space is literal: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// Set on update: an app that already sits on such a path cannot be switched.
|
|
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
|
.json(&json!({
|
|
"path": "u/test-user/x:y",
|
|
"summary": "App",
|
|
"value": {},
|
|
"policy": { "execution_mode": "publisher", "triggerables_v2": {} }
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
let resp = authed(
|
|
client().post(format!("{ws}/apps/update/u/test-user/x:y")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "policy": { "execution_mode": "guest", "triggerables_v2": {} } }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 400, "switched to Guests on a `:` path");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Renaming a workspace copies its settings; the guest switch must travel with them,
|
|
/// or the rename silently shuts every guest app of the workspace.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn a_workspace_rename_keeps_the_guest_switch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
enable_guests(port, "test-workspace").await?;
|
|
sqlx::query(
|
|
"INSERT INTO guest_activity (email, workspace_id, day)
|
|
VALUES ('guest@example.com', 'test-workspace', CURRENT_DATE)",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/w/test-workspace/workspaces/change_workspace_id"
|
|
)),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "new_id": "test-workspace-2", "new_name": "Test workspace 2" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
let enabled: bool = sqlx::query_scalar(
|
|
"SELECT guest_access_enabled FROM workspace_settings WHERE workspace_id = 'test-workspace-2'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(enabled, "the guest switch travels with the workspace");
|
|
let moved: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace-2')
|
|
AND NOT EXISTS(SELECT 1 FROM guest_activity WHERE workspace_id = 'test-workspace')",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(moved, "the guests seen in the workspace follow its new id");
|
|
|
|
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.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn an_account_ends_the_guest_session(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?;
|
|
insert_guest_token(&db, "test-workspace").await?;
|
|
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "guest whoami must resolve");
|
|
|
|
sqlx::query(
|
|
"INSERT INTO password (email, password_hash, login_type, super_admin, verified, name)
|
|
VALUES ('guest@example.com', 'not-a-real-hash', 'password', false, true, 'Guest')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"an account created after the mint ends the guest session at the door"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// An upload goes through an app's `s3_inputs` policy or not at all for a guest: the
|
|
/// legacy branch for an app without one uploads with the caller's own standing, which a
|
|
/// guest has none of, and an app path with no row must not slip past the confinement.
|
|
#[cfg(feature = "parquet")]
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn a_guest_cannot_upload_outside_a_policy(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)) // no `s3_inputs`
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
insert_guest_token(&db, "test-workspace").await?;
|
|
|
|
let upload = |app: &str| {
|
|
authed(
|
|
client().post(format!(
|
|
"{ws}/apps_u/upload_s3_file/{app}?file_key=anything"
|
|
)),
|
|
GUEST_TOKEN,
|
|
)
|
|
.body("x")
|
|
.send()
|
|
};
|
|
let resp = upload("u/test-user/no_such_app").await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
403,
|
|
"a path with no app must not escape the guest's confinement: {}",
|
|
resp.text().await?
|
|
);
|
|
let resp = upload(APP_PATH).await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
400,
|
|
"without an upload policy a guest is refused like an anonymous caller: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// An anonymous app is open to anyone, a guest included, and the guest uses it as
|
|
/// itself: the component run and the result read that follows are one identity, so
|
|
/// the read's launched-by-me grant matches. Acting as nobody for the run and as the
|
|
/// guest for the read would start a job whose result the page can never fetch.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn a_guest_uses_an_anonymous_app_as_itself(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}/scripts/create")), ADMIN_TOKEN)
|
|
.json(&json!({
|
|
"path": "u/test-user/noop",
|
|
"summary": "",
|
|
"description": "",
|
|
"content": "echo 42",
|
|
"language": "bash",
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
let anon = "u/test-user/anon_app";
|
|
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
|
.json(&app_with_runnable(anon, "anonymous", false))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
insert_guest_token(&db, "test-workspace").await?; // scoped to APP_PATH, not `anon`
|
|
|
|
let resp = execute(port, "test-workspace", anon, GUEST_TOKEN)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
|
let job_id = resp.text().await?;
|
|
|
|
let resp = authed(
|
|
client().get(format!("{ws}/jobs_u/getupdate/{job_id}")),
|
|
GUEST_TOKEN,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"the guest that started the run must be able to read it back: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The embed token a guest mints for a sandboxed app is the one credential handed to
|
|
/// untrusted app JS. It must be a guest twice over — resolve like its minter (the
|
|
/// label) and be governed like its minter (the sentinel) — or every guest control
|
|
/// silently skips the most exposed credential there is.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn a_guest_minted_embed_token_stays_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");
|
|
|
|
enable_guests(port, "test-workspace").await?;
|
|
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
|
.json(&guest_app_with_runnable(APP_PATH, true))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
|
let secret: String = authed(
|
|
client().get(format!("{ws}/apps/secret_of/{APP_PATH}")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.send()
|
|
.await?
|
|
.text()
|
|
.await?;
|
|
insert_guest_token(&db, "test-workspace").await?;
|
|
|
|
// The guest page mints the iframe's token from the guest session.
|
|
let resp = authed(
|
|
client().get(format!("{ws}/apps_u/embed_token/{secret}")),
|
|
GUEST_TOKEN,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"a guest must be able to mint: {}",
|
|
resp.text().await?
|
|
);
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let embed = body["token"]
|
|
.as_str()
|
|
.expect("mint must return a token for an authenticated guest")
|
|
.to_string();
|
|
|
|
// Its lifetime is capped at the session that minted it: the requested embed
|
|
// validity (12h) is longer than the guest session's (8h in this fixture), and the
|
|
// session's expiry is a guest's only revocation.
|
|
let parent_exp: chrono::DateTime<chrono::Utc> =
|
|
sqlx::query_scalar("SELECT expiration FROM token WHERE token_prefix = 'GUEST_SECR'")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
let child_exp: chrono::DateTime<chrono::Utc> = body["expiration"]
|
|
.as_str()
|
|
.and_then(|e| e.parse().ok())
|
|
.expect("mint must return the token's expiration");
|
|
assert!(
|
|
child_exp <= parent_exp,
|
|
"a guest's embed token must not outlive the session that minted it ({child_exp} > {parent_exp})"
|
|
);
|
|
|
|
// Resolves — and as a guest, not as the non-member superadmin shape.
|
|
let resp = authed(client().get(format!("{ws}/users/whoami")), &embed)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200, "the minted token must authenticate");
|
|
let me: serde_json::Value = resp.json().await?;
|
|
assert_eq!(me["role"], json!("guest"));
|
|
|
|
// Governed: the workspace switch closes it at the door, iframe or not.
|
|
authed(
|
|
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
|
ADMIN_TOKEN,
|
|
)
|
|
.json(&json!({ "guest_access_enabled": false }))
|
|
.send()
|
|
.await?;
|
|
let resp = authed(client().get(format!("{ws}/users/whoami")), &embed)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"turning guests off must stop a guest's embed token authenticating"
|
|
);
|
|
let resp = execute(port, "test-workspace", APP_PATH, &embed)
|
|
.send()
|
|
.await?;
|
|
assert!(
|
|
resp.status().is_client_error() && resp.status() != 404,
|
|
"and running components, got {}",
|
|
resp.status()
|
|
);
|
|
enable_guests(port, "test-workspace").await?;
|
|
|
|
// And its scopes are not something the guest's email can later rewrite. The
|
|
// guest session itself cannot reach `/users/*` (workspace pin), so model the real
|
|
// threat: the same email after promotion, holding an ordinary unpinned session.
|
|
sqlx::query(
|
|
"INSERT INTO token (token_hash, token_prefix, token, email, label)
|
|
VALUES (encode(sha256($1::bytea), 'hex'), 'PROMOTED_S', $2, 'guest@example.com',
|
|
'session')",
|
|
)
|
|
.bind(b"PROMOTED_SESSION".as_slice())
|
|
.bind("PROMOTED_SESSION")
|
|
.execute(&db)
|
|
.await?;
|
|
for prefix in [&embed[..10], &GUEST_TOKEN[..10]] {
|
|
let resp = authed(
|
|
client().post(format!(
|
|
"http://localhost:{port}/api/users/tokens/update_scopes/{prefix}"
|
|
)),
|
|
"PROMOTED_SESSION",
|
|
)
|
|
.json(&json!({ "scopes": null }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
404,
|
|
"a promoted account must not be able to rescope its old guest credentials"
|
|
);
|
|
}
|
|
|
|
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");
|
|
|
|
enable_guests(port, "test-workspace").await?;
|
|
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(())
|
|
}
|