mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
* feat: guest JWT entry for embedded apps (jwt_guest_) A second way in for a guest, alongside the signed-in guest session: a JWT the embedding customer's backend mints and signs, verified per request against a per-workspace key (a PEM public key or a JWKS URL), resolving to the same seatless guest identity confined to the one app its app_path claim names. Bearer prefix jwt_guest_, stateless (no token row). See PR #10954. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: surface guest JWT as the embed method in the app deploy drawer The deploy drawer explained the secret-URL embed but not the guest JWT path, so the primary way to embed an app for a customer's own authenticated users was undiscoverable. For a guest-mode app with guests enabled, show how to mint a `jwt_guest_` token and append `guest.<jwt>` to the app URL, with a copyable iframe template pre-filled with this app's workspace_id and app_path, and a note that new guest emails are refused past the instance's free allowance (the live count is shown just above). Also log a guest JWT allowance refusal at warn, not info: the caller gets a bare 401 (the reason must not leak to an unauthenticated caller), so the log is the admin's signal that the instance hit its guest cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: correct the guest JWT minting instructions in the embed block The block said "sign it with the workspace's guest JWT key", but that setting holds the public verification key. Clarify the keypair relationship (configure the public key or a JWKS URL in the workspace; sign with the matching private key), name the accepted algorithms (RS/PS/ES; HS* refused), and keep the required claims, so an embedder knows how to actually mint the token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: fall back to the instance JWT issuer for guest verification (off on cloud) A workspace with no guest key of its own now verifies guest JWTs against the instance issuer (JWT_EXT_JWKS_URL, already used by jwt_ext_), so an operator running one issuer configures it once. Verification and the guest grant are CE; granting a full login from that issuer stays EE (jwt_ext_, unchanged). Disabled under CLOUD_HOSTED, where one instance issuer must not be trusted to mint guests in every tenant's workspace — there the per-workspace key is the only source, which also stays the override everywhere. The workspace settings note (hidden on cloud) explains the fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: embed instructions cover both the workspace key and instance issuer The embed block said to set the workspace's guest JWT key; now it says Windmill verifies against the workspace key or, off cloud, the instance issuer (JWT_EXT_JWKS_URL) when no workspace key is set. The instance clause is hidden under isCloudHosted(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: show the guest JWT embed block only when Embed is toggled It belongs with the iframe snippet, not the plain-URL view, so gate it on embedMode alongside the guest-mode / guests-enabled checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: trust the instance issuer in the guest fallback; refresh stale docs P1 (CI review): the fallback wrapped JWT_EXT_JWKS_URL as a workspace JwksUrl, so it hit validate_guest_jwks_url and was refused for http/private issuers unless ALLOW_PRIVATE_GUEST_JWKS_URLS was also set — a self-hosted internal issuer that works for jwt_ext_ failed for guests, though the UI says setting the env var is enough. fetch_jwks now fetches the instance issuer without the https/private restriction (matching the jwt_ext_ loader; it stays operator-trusted), while a workspace-admin URL is validated and pinned as before. All the size/key/URL bounds still apply to both. P2 (CI review): refresh the stale docs that said a missing workspace key always refuses a guest JWT — the module, bearer, key-source, and EditGuestJwtKey field docs now describe the workspace key with the off-cloud instance-issuer fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: fetch the trusted instance issuer like the jwt_ext_ loader P1 (CI review): the instance-issuer fetch skipped SSRF validation but still disabled redirects and default cert validation, so an instance issuer that works for jwt_ext_ through a redirect or an operator-approved self-signed cert failed the guest fallback. Fetch it with HTTP_CLIENT_PERMISSIVE (follows redirects, honors ACCEPT_INVALID_CERTS) — the same behavior jwt_ext_ has — while a workspace-admin URL stays validated, DNS-pinned and redirect-free. The body size cap still bounds both. P2 (CI review): the WorkspaceSettings field doc still said None/None means no JWT guests; it now names the off-cloud instance-issuer fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: schema summary + OpenAPI cover the guest JWT columns and fallback P2 (CI review): summarized_schema.txt was missing guest_activity.jwt_entry and the two workspace_settings guest-JWT key columns (required by docs/validation.md after a schema change). The edit_guest_jwt_key OpenAPI description now notes that clearing the workspace key falls back to the instance issuer (JWT_EXT_JWKS_URL) off cloud rather than necessarily stopping guest JWTs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: keep JWKS single-flight locks in a self-cleaning map, not a bounded cache P1 (CI review): JWKS_FETCH_LOCKS was a 200-entry quick_cache. Past 200 cold URLs it can evict a lock whose fetch is still in flight; the next request for that URL then mints a fresh lock and starts a second fetch, so cycling configured workspaces defeats single-flight and can storm the issuers. Replace it with a plain map guarded by a JwksFetchLock RAII handle that removes each entry once its last holder drops, so the map only ever holds the fetches in flight and never evicts an in-flight lock. Add a unit test pinning the shared-lock and self-cleaning invariants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: update ee-repo-ref to c2270eb5fe2d9f0968253e6b460c33186363f4e7 This commit updates the EE repository reference after PR #773 was merged in windmill-ee-private. Previous ee-repo-ref: 5a1d9dee34159512c0823fddcd3d096490edbcce New ee-repo-ref: c2270eb5fe2d9f0968253e6b460c33186363f4e7 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1093 lines
38 KiB
Rust
1093 lines
38 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 and the guest JWT key must
|
|
/// travel with them, or the rename silently shuts every guest app or drops the key.
|
|
#[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?;
|
|
sqlx::query(
|
|
"UPDATE workspace_settings SET guest_jwt_public_key = 'test-pem-key' WHERE workspace_id = 'test-workspace'",
|
|
)
|
|
.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 jwt_key: Option<String> = sqlx::query_scalar(
|
|
"SELECT guest_jwt_public_key FROM workspace_settings WHERE workspace_id = 'test-workspace-2'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(
|
|
jwt_key.as_deref(),
|
|
Some("test-pem-key"),
|
|
"the guest JWT key 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(())
|
|
}
|