mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
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
74 lines
2.4 KiB
Rust
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(())
|
|
}
|