Files
windmill/backend/tests/postgres_trigger_scope.rs
T
Ruben FiszelandClaude Opus 4.8 9cb8e991eb feat: guest JWT entry for embedded apps
A second way in for a guest (companion to windmill#10929): a JWT the embedding
customer's own backend mints and signs, carried on the app's share link and
verified against a key the workspace admin configured. It needs no
identity-provider round-trip, so it works inside an iframe where popups and
third-party cookies do not. Bearer prefix jwt_guest_, stateless: verified per
request, cached until exp, no token row.

A JWT guest is the same identity as a signed-in guest: no usr row, no password
row, no seat, confined to the one app app_path names. Every guest gate applies:
the plan, the workspace switch (enforced once at the auth door via the sentinel),
the app mode (guest_app_admits), and "no account at all" (has_any_account). The
claim's workspace_id must equal the route's workspace, and a workspace-less route
never accepts it.

Claims honoured: email, workspace_id, app_path, exp (mandatory); nbf/iat
validated when present; the accepted lifetime is capped at 24h. Algorithms:
RS256/384/512, PS256/384/512, ES256/384; HS* is refused. The key is a
per-workspace setting, a PEM public key or a JWKS URL (at most one, a DB CHECK
enforces it), Enterprise-plan gated like the guest switch. The JWKS URL is
validated against private ranges and the fetch is pinned to the validated
address.

Counting: a JWT guest is recorded in guest_activity (once per email, workspace
and day, cached), marked jwt_entry, and not in unique_ext_jwt_token. A first-seen
users.login_guest audit carries the entry kind.

Narrower than jwt_ext_ by design: that key is instance-level and can assert
admin, groups and folders; a guest key is scoped to one workspace and only ever
mints guests. An app-only user a customer routes through jwt_ext_ today is
counted; through a guest JWT they become a free guest, the intended pricing
change, split out as guest_jwt_count in the telemetry so it can be measured.

Changes on the parent branch, additive: ApiAuthed.credential_expiry (a
credential's own expiry when it has no token row); guest_derived_token_constraints
caps on it; guest_session_scopes moved to windmill-api-auth::scopes and
has_any_account to windmill-common::users so the mint and the JWT arm share one
copy; the signed-in mint's login_guest audit now carries entry=idp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VF3v6LA9399gNphmZaHYG3
2026-09-03 08:00:25 +00:00

74 lines
2.4 KiB
Rust

//! Postgres-trigger ancillary handlers (slot / publication / version management)
//! must reject a path-mismatched scoped token before opening any connection —
//! the route-level middleware only checks the scope domain, so per-path
//! enforcement lives in the handlers. Rejecting pre-connection is why these tests
//! need no real Postgres resource.
use axum::{extract::Path, Extension, Json};
use sqlx::{Pool, Postgres};
use windmill_api_auth::ApiAuthed;
use windmill_common::{db::UserDB, error::Error};
use windmill_trigger_postgres::{handler, Slot};
fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed {
ApiAuthed {
email: "alice@windmill.dev".to_string(),
username: "alice".to_string(),
is_admin: false,
is_operator: false,
groups: vec![],
folders: vec![],
scopes: Some(scopes.into_iter().map(str::to_string).collect()),
username_override: None,
username_override_is_token_label: false,
is_session_token: false,
token_prefix: None,
read_only: false,
job_id: None,
credential_expiry: None,
}
}
// A token scoped to `u/alice/db` must not reach a read handler for `u/bob/db`.
#[sqlx::test]
async fn read_handler_rejects_path_mismatched_scope(db: Pool<Postgres>) -> anyhow::Result<()> {
let authed = scoped_authed(vec!["postgres_triggers:read:u/alice/db"]);
let user_db = UserDB::new(db.clone());
let res = handler::get_postgres_version(
authed,
Extension(db),
Extension(user_db),
Path(("test-workspace".to_string(), "u/bob/db".to_string())),
)
.await;
assert!(
matches!(res, Err(Error::PermissionDenied(_))),
"expected PermissionDenied, got {res:?}"
);
Ok(())
}
// The destructive slot-drop handler must reject a write token scoped to another path.
#[sqlx::test]
async fn drop_slot_rejects_path_mismatched_scope(db: Pool<Postgres>) -> anyhow::Result<()> {
let authed = scoped_authed(vec!["postgres_triggers:write:u/alice/db"]);
let user_db = UserDB::new(db.clone());
let res = handler::drop_slot_name(
authed,
Extension(user_db),
Extension(db),
Path(("test-workspace".to_string(), "u/bob/db".to_string())),
Json(Slot { name: "some_slot".to_string() }),
)
.await;
assert!(
matches!(res, Err(Error::PermissionDenied(_))),
"expected PermissionDenied, got {res:?}"
);
Ok(())
}