mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 08:07:03 +00:00
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
0edfee970b
commit
75bde58bb8
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n VALUES ($1, $2, $3, $4, 'session', now() + ($5 || ' seconds')::interval, false, $6, $7)",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, false, $7, $8)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -9,6 +9,7 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar"
|
||||
@@ -16,5 +17,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "02bf6098743be1cc61b72747951fded5dad1fb16b10f1ba27a580261bb9d050d"
|
||||
"hash": "1553608e0d5a9a9b22c1a2c200bf02200df0007291133033f2b9013c2e508fe1"
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
//! A guest is someone the identity provider authenticated who is a member of no
|
||||
//! workspace: no `usr` row, no `password` row, and so no seat on any counter. That
|
||||
//! absence is the whole point, and it means a guest session has no ACL of its own —
|
||||
//! its token's scopes are its entire grant. These tests pin the two things that
|
||||
//! 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, and is told its denial is fixable by signing up properly;
|
||||
//! else;
|
||||
//! * the two gates — an app's own `execution_mode: guest` is inert unless the
|
||||
//! workspace switch is on, checked at the door rather than only where a policy
|
||||
//! is written (git-sync and the CLI push policies past every UI).
|
||||
@@ -34,18 +36,8 @@ fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuil
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
/// Insert a guest session for `test-workspace`, scoped to `APP_PATH`. Mirrors
|
||||
/// `create_guest_session_token`: the sentinel, the narrow reads, the two path-scoped
|
||||
/// app grants, and the workspace pin.
|
||||
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)
|
||||
VALUES (encode(sha256($1::bytea), 'hex'), 'GUEST_SECR', $2, 'guest@example.com',
|
||||
'session', $3, $4)",
|
||||
)
|
||||
.bind(GUEST_TOKEN.as_bytes())
|
||||
.bind(GUEST_TOKEN)
|
||||
.bind(vec![
|
||||
fn guest_scopes() -> Vec<String> {
|
||||
vec![
|
||||
"guest".to_string(),
|
||||
"jobs:read".to_string(),
|
||||
"resources:run".to_string(),
|
||||
@@ -53,7 +45,21 @@ async fn insert_guest_token(db: &Pool<Postgres>, workspace: &str) -> anyhow::Res
|
||||
"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, and the workspace pin.
|
||||
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)
|
||||
VALUES (encode(sha256($1::bytea), 'hex'), 'GUEST_SECR', $2, 'guest@example.com',
|
||||
'guest_session', $3, $4)",
|
||||
)
|
||||
.bind(GUEST_TOKEN.as_bytes())
|
||||
.bind(GUEST_TOKEN)
|
||||
.bind(guest_scopes())
|
||||
.bind(workspace)
|
||||
.execute(db)
|
||||
.await?;
|
||||
@@ -84,8 +90,6 @@ async fn guest_session_is_confined_to_its_app(db: Pool<Postgres>) -> anyhow::Res
|
||||
assert_eq!(me["operator"], json!(true));
|
||||
assert_eq!(me["is_admin"], json!(false));
|
||||
|
||||
// Everything outside the app surface is denied, and denied in a way the frontend
|
||||
// can act on: `x-windmill-promote` is what turns a dead end into a sign-up.
|
||||
// `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 [
|
||||
@@ -105,13 +109,6 @@ async fn guest_session_is_confined_to_its_app(db: Pool<Postgres>) -> anyhow::Res
|
||||
"guest must be denied {route}, got {}",
|
||||
resp.status()
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("x-windmill-promote")
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
Some("1"),
|
||||
"denial of {route} must be marked promotable"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -232,3 +229,55 @@ async fn guest_entry_needs_both_the_app_mode_and_the_workspace_switch(
|
||||
|
||||
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 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(())
|
||||
}
|
||||
|
||||
@@ -427,6 +427,8 @@ impl AuthCache {
|
||||
}
|
||||
(_, Some(email), super_admin, scopes, label, read_only) => {
|
||||
let is_session_token = is_session_label(label.as_deref());
|
||||
let is_guest_session =
|
||||
windmill_common::auth::is_guest_session_label(label.as_deref());
|
||||
let (username_override, username_override_is_token_label) =
|
||||
username_override_from_label(label);
|
||||
if w_id.is_some() {
|
||||
@@ -517,10 +519,14 @@ impl AuthCache {
|
||||
// (`guest_route_denied`). Placed after the
|
||||
// superadmin arm so a superadmin token can
|
||||
// never be demoted into this one.
|
||||
None if crate::scopes::has_guest_sentinel(
|
||||
scopes.as_deref(),
|
||||
) =>
|
||||
{
|
||||
//
|
||||
// Keyed on the server-minted label, never on
|
||||
// the `guest` scope: this arm is the only
|
||||
// thing in the codebase that turns "no `usr`
|
||||
// row" from a rejection into an identity, and
|
||||
// scopes on a user-minted token are whatever
|
||||
// the caller typed.
|
||||
None if is_guest_session => {
|
||||
Some(ApiAuthed {
|
||||
username: email.clone(),
|
||||
email,
|
||||
|
||||
@@ -501,14 +501,11 @@ pub fn check_route_access(
|
||||
}
|
||||
|
||||
// A guest session carries the same broad read scopes as an embed token and for
|
||||
// the same handful of routes, so it gets the same default-deny. The denial is
|
||||
// `GuestPromotionRequired` rather than `PermissionDenied`: a guest is not short
|
||||
// one grant, they are short an account, and that is fixable from the browser.
|
||||
let is_guest = has_guest_sentinel(Some(token_scopes));
|
||||
if is_guest {
|
||||
// the same handful of routes, so it gets the same default-deny.
|
||||
if has_guest_sentinel(Some(token_scopes)) {
|
||||
if let Some(suffix) = route_suffix.as_deref() {
|
||||
if guest_route_denied(required_domain, suffix) {
|
||||
return Err(Error::GuestPromotionRequired(format!(
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"a guest session cannot access {route_path}"
|
||||
)));
|
||||
}
|
||||
@@ -598,12 +595,6 @@ pub fn check_route_access(
|
||||
format!("{}:{}", required_domain.as_str(), required_action.as_str())
|
||||
};
|
||||
|
||||
if is_guest {
|
||||
return Err(Error::GuestPromotionRequired(format!(
|
||||
"a guest session cannot access {route_path} (would need {scope_display})"
|
||||
)));
|
||||
}
|
||||
|
||||
Err(Error::PermissionDenied(format!(
|
||||
"Access denied. Required scope: {}",
|
||||
scope_display
|
||||
@@ -781,10 +772,11 @@ pub fn has_app_embed_sentinel(scopes: Option<&[String]>) -> bool {
|
||||
}
|
||||
|
||||
/// Sentinel in a guest session token: someone the identity provider authenticated
|
||||
/// who is a member of no workspace. Grants nothing itself. It confines the session
|
||||
/// to the app surface the same way `app_embed` does, and it turns a denial into
|
||||
/// [`Error::GuestPromotionRequired`] so the frontend offers a real account instead
|
||||
/// of a dead end.
|
||||
/// who is a member of no workspace. Grants nothing itself — it only confines the
|
||||
/// session to the app surface, the same way `app_embed` does. What makes a session a
|
||||
/// guest at all is the server-minted label
|
||||
/// [`windmill_common::auth::GUEST_SESSION_LABEL`]; a forged sentinel here can only
|
||||
/// narrow its own token.
|
||||
pub const GUEST_SENTINEL: &str = "guest";
|
||||
|
||||
/// True if a token is a guest session. Such a session has no `usr` row, so its
|
||||
|
||||
@@ -2941,6 +2941,9 @@ lazy_static::lazy_static! {
|
||||
/// reads narrowed to a route allowlist by the sentinel (`guest_route_denied`) — plus the
|
||||
/// two path-scoped app grants minted per app. A guest has no `usr` row, so this list is
|
||||
/// the whole of what it can do.
|
||||
///
|
||||
/// The `guest` sentinel here only narrows. What makes the session a guest at all is the
|
||||
/// server-minted label ([`windmill_common::auth::GUEST_SESSION_LABEL`]).
|
||||
fn guest_session_scopes(app_path: &str) -> Vec<String> {
|
||||
vec![
|
||||
windmill_api_auth::scopes::GUEST_SENTINEL.to_string(),
|
||||
@@ -2967,7 +2970,11 @@ fn guest_session_scopes(app_path: &str) -> Vec<String> {
|
||||
/// chrome-less public app page calls none of them; a page that needs one for a guest
|
||||
/// has to become workspace-scoped rather than the pin being loosened.
|
||||
///
|
||||
/// The caller is responsible for having checked [`is_guest_access_enabled`].
|
||||
/// Refuses unless `app_path` is currently in `guest` execution mode, so no caller can
|
||||
/// mint a guest session for an app that does not admit one. The caller still owes the
|
||||
/// workspace switch ([`windmill_common::workspaces::is_guest_access_enabled`]) and the
|
||||
/// authentication of `email` — this function trusts neither the path nor the workspace
|
||||
/// on its own, only that the identity provider vouched for who is asking.
|
||||
pub async fn create_guest_session_token<'c>(
|
||||
email: &str,
|
||||
w_id: &str,
|
||||
@@ -2987,14 +2994,28 @@ pub async fn create_guest_session_token<'c>(
|
||||
};
|
||||
let scopes = guest_session_scopes(app_path);
|
||||
|
||||
let mode: Option<Option<String>> = sqlx::query_scalar(
|
||||
"SELECT policy->>'execution_mode' FROM app WHERE workspace_id = $1 AND path = $2",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(app_path)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
if mode.flatten().as_deref() != Some("guest") {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"app {app_path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO token
|
||||
(token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)
|
||||
VALUES ($1, $2, $3, $4, 'session', now() + ($5 || ' seconds')::interval, false, $6, $7)",
|
||||
VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, false, $7, $8)",
|
||||
t_hash,
|
||||
t_prefix,
|
||||
plaintext as Option<&str>,
|
||||
email,
|
||||
windmill_common::auth::GUEST_SESSION_LABEL,
|
||||
&GUEST_SESSION_VALIDITY_SECONDS.to_string(),
|
||||
&scopes,
|
||||
w_id,
|
||||
|
||||
@@ -33204,8 +33204,9 @@ components:
|
||||
requires an authenticated viewer), while updating one keeps the mode
|
||||
the app is already deployed under. Neither `anonymous`, which makes
|
||||
the app publicly executable, nor `guest`, which opens it to anyone the
|
||||
identity provider authenticates, is ever assumed. `guest` is
|
||||
additionally inert unless the workspace has `guest_access_enabled`
|
||||
identity provider authenticates, is ever assumed. A guest is only
|
||||
admitted where the workspace also has `guest_access_enabled`, which
|
||||
gates both minting a guest session and running an app's components
|
||||
on_behalf_of:
|
||||
type: string
|
||||
on_behalf_of_email:
|
||||
|
||||
@@ -357,7 +357,7 @@ pub fn authorize_non_member_viewer(
|
||||
return Ok(true);
|
||||
}
|
||||
if is_guest {
|
||||
return Err(Error::GuestPromotionRequired(format!(
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"app {app_path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
@@ -1645,7 +1645,11 @@ pub async fn mint_app_embed_token(
|
||||
}
|
||||
let expiration =
|
||||
chrono::Utc::now() + chrono::Duration::hours(APP_EMBED_TOKEN_VALIDITY_HOURS);
|
||||
let mut scopes: Vec<String> = APP_EMBED_SCOPES.iter().map(|s| s.to_string()).collect();
|
||||
let mut scopes: Vec<String> = APP_EMBED_SCOPES
|
||||
.iter()
|
||||
.filter(|s| **s != windmill_api_auth::scopes::APP_EMBED_SENTINEL)
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
// Path-scoped read so the app can fetch its OWN definition (apps/get/p,
|
||||
// which the in-workspace sandboxed viewer uses) — but no other app's. The
|
||||
// public viewer fetches via apps_u/public_app and doesn't rely on this.
|
||||
@@ -1656,8 +1660,12 @@ pub async fn mint_app_embed_token(
|
||||
scopes.push(format!("apps:run:{app_path}"));
|
||||
// A scope-restricted caller token must not bootstrap a broader-scoped
|
||||
// embed token (`create_token_internal` deliberately does not check this
|
||||
// itself). No-op for unscoped sessions — the normal embed flow.
|
||||
// itself). Checked on the real scopes only: a sentinel is a one-part string
|
||||
// that `ScopeDefinition::from_scope_string` rejects, so leaving it in the
|
||||
// requested set makes this fail outright for any scoped caller — which a
|
||||
// guest session is. `mint_raw_app_sdk_token` has the same shape.
|
||||
ensure_scopes_within_caller(authed, Some(&scopes))?;
|
||||
scopes.push(windmill_api_auth::scopes::APP_EMBED_SENTINEL.to_string());
|
||||
let token_config = NewToken::new(
|
||||
Some(format!("embed_app:{app_path}")),
|
||||
Some(expiration),
|
||||
@@ -4000,12 +4008,18 @@ async fn execute_component(
|
||||
// A guest session holds no ACL of its own, so the read-permit probe below would
|
||||
// deny every guest. What confines it is the scope the session was minted with,
|
||||
// naming the one app it may run — and the app has to be open to guests at all.
|
||||
//
|
||||
// The workspace switch is re-read here rather than trusted from mint time, so
|
||||
// turning guests off stops them running code within the request, not within the
|
||||
// session's remaining lifetime. One indexed lookup, and only on the guest path.
|
||||
if let Some(authed) = opt_authed
|
||||
.as_ref()
|
||||
.filter(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()))
|
||||
{
|
||||
if !matches!(policy.execution_mode(), ExecutionMode::Guest) {
|
||||
return Err(Error::GuestPromotionRequired(format!(
|
||||
if !matches!(policy.execution_mode(), ExecutionMode::Guest)
|
||||
|| !windmill_common::workspaces::is_guest_access_enabled(&db, &w_id).await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"app {path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -56,9 +56,26 @@ pub fn is_server_minted_label(label: &str) -> bool {
|
||||
|| label.starts_with("ephemeral-script-end-user-")
|
||||
|| label == "ephemeral-script"
|
||||
|| label == "session"
|
||||
|| label == GUEST_SESSION_LABEL
|
||||
|| label.starts_with("mcp-oauth-")
|
||||
}
|
||||
|
||||
/// Label on a guest session — someone the identity provider authenticated who is a
|
||||
/// member of no workspace. This is the *grant*: `AuthCache` will resolve a token
|
||||
/// carrying it into an identity with no `usr` row behind it, which nothing else can
|
||||
/// do. It must therefore stay unforgeable, which is what listing it in
|
||||
/// [`is_server_minted_label`] buys — `/users/tokens/create` refuses it.
|
||||
///
|
||||
/// Do not move this test onto the token's scopes. Scopes on a user-minted token are
|
||||
/// caller-supplied and only ever *narrow* (`app_embed`, `raw_app_sdk`), so a scope
|
||||
/// that granted non-member access would be free for anyone to declare.
|
||||
pub const GUEST_SESSION_LABEL: &str = "guest_session";
|
||||
|
||||
/// Whether `label` marks a guest session. See [`GUEST_SESSION_LABEL`].
|
||||
pub fn is_guest_session_label(label: Option<&str>) -> bool {
|
||||
label == Some(GUEST_SESSION_LABEL)
|
||||
}
|
||||
|
||||
/// Whether `label` is the one minted for a browser session at login. [`is_server_minted_label`]
|
||||
/// stops a member minting it directly, but `/users/refresh_token` hands one to any authenticated
|
||||
/// caller, so this attributes a request to the UI without proving it: never gate authority on it.
|
||||
|
||||
@@ -36,14 +36,6 @@ pub enum Error {
|
||||
MetricNotFound(String),
|
||||
#[error("Permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
/// A guest session asked for something outside the app it was let in for. The
|
||||
/// response carries `x-windmill-promote`, which the frontend answers by trading
|
||||
/// the guest session for a real account (`users/promote_guest`). Return it only
|
||||
/// where promotion could actually resolve the denial, never as a synonym for
|
||||
/// [`Self::PermissionDenied`]: a member who is merely lacking a grant would be
|
||||
/// sent through a signup that cannot help them.
|
||||
#[error("Guest session cannot access this: {0}")]
|
||||
GuestPromotionRequired(String),
|
||||
#[error("Require Admin privileges for {0}")]
|
||||
RequireAdmin(String),
|
||||
#[error("{0}")]
|
||||
@@ -138,7 +130,6 @@ impl Error {
|
||||
Self::NotAuthorized(_) => "NotAuthorized",
|
||||
Self::MetricNotFound(_) => "MetricNotFound",
|
||||
Self::PermissionDenied(_) => "PermissionDenied",
|
||||
Self::GuestPromotionRequired(_) => "GuestPromotionRequired",
|
||||
_ => "InternalErr",
|
||||
}
|
||||
}
|
||||
@@ -296,9 +287,7 @@ impl IntoResponse for Error {
|
||||
let status = match self {
|
||||
Self::NotFound(_) => axum::http::StatusCode::NOT_FOUND,
|
||||
Self::NotAuthorized(_) => axum::http::StatusCode::UNAUTHORIZED,
|
||||
Self::RequireAdmin(_) | Self::PermissionDenied(_) | Self::GuestPromotionRequired(_) => {
|
||||
axum::http::StatusCode::FORBIDDEN
|
||||
}
|
||||
Self::RequireAdmin(_) | Self::PermissionDenied(_) => axum::http::StatusCode::FORBIDDEN,
|
||||
Self::SqlErr { .. }
|
||||
| Self::BadRequest(_)
|
||||
| Self::AIError(_)
|
||||
@@ -319,21 +308,14 @@ impl IntoResponse for Error {
|
||||
|
||||
let body = Body::from(e.to_string());
|
||||
|
||||
let mut builder = axum::response::Response::builder()
|
||||
axum::response::Response::builder()
|
||||
.header("Content-Type", "text/plain")
|
||||
.status(status);
|
||||
if matches!(e, Self::GuestPromotionRequired(_)) {
|
||||
builder = builder.header(GUEST_PROMOTE_HEADER, "1");
|
||||
}
|
||||
builder.body(body).unwrap()
|
||||
.status(status)
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a denial a guest can resolve by trading their session for a real account.
|
||||
/// The frontend keys its promotion prompt off this rather than off the message,
|
||||
/// which is `text/plain` prose.
|
||||
pub const GUEST_PROMOTE_HEADER: &str = "x-windmill-promote";
|
||||
|
||||
/// Render a `JsonErr` payload as a readable message suitable for direct
|
||||
/// display in a toast: surface the `error` field as the headline, append a
|
||||
/// short summary of `details` (e.g. duplicate paths) when present, and fall
|
||||
|
||||
@@ -606,6 +606,16 @@
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
/** Mirrors the server-side write in the OAuth `login` handler, including clearing
|
||||
* it when this sign-in is not a guest entry. `login_externally` consumes it. */
|
||||
function setGuestAppCookie(value: string | undefined) {
|
||||
try {
|
||||
document.cookie = `guest_app=${encodeURIComponent(value ?? '')}; path=/; SameSite=None; Secure`
|
||||
} catch (e) {
|
||||
console.error('Could not set the guest app cookie', e)
|
||||
}
|
||||
}
|
||||
|
||||
function redirectSaml(): boolean {
|
||||
if (!saml) {
|
||||
sendUserToast('No SAML login available', true)
|
||||
@@ -613,6 +623,13 @@
|
||||
}
|
||||
if (previewConfig) return true
|
||||
markLoginMethodPending({ kind: 'saml' })
|
||||
// SAML goes straight to the IdP and never passes through
|
||||
// `/api/oauth/login/<client>`, which is where the OAuth path has the server
|
||||
// write this cookie. Write it here so a SAML-only instance can admit guests
|
||||
// too. Client-set is safe: the callback still checks that the named app is in
|
||||
// guest mode and that the workspace allows guests, so the worst a forged value
|
||||
// can do is give its own author a narrower session than they'd otherwise get.
|
||||
setGuestAppCookie(guestApp)
|
||||
let target = saml
|
||||
let relayStateSet = false
|
||||
// Carry the SP-initiated deep link through the IdP round-trip via SAML
|
||||
|
||||
@@ -465,8 +465,10 @@
|
||||
<ToggleButton
|
||||
label="Guests"
|
||||
value="guest"
|
||||
disabled={!canSetGuest && policy.execution_mode != 'guest'}
|
||||
tooltip="Anyone who signs in through your identity provider. No workspace membership, no seat."
|
||||
disabled={(!canSetGuest || !$enterpriseLicense) && policy.execution_mode != 'guest'}
|
||||
tooltip={$enterpriseLicense
|
||||
? 'Anyone who signs in through your identity provider. No workspace membership, no seat.'
|
||||
: 'Guest sign-in is a Windmill Enterprise Edition feature.'}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
@@ -483,7 +485,10 @@
|
||||
{#if policy.execution_mode == 'anonymous'}
|
||||
Anyone holding the secret URL below can open this app without signing in.
|
||||
{:else if policy.execution_mode == 'guest'}
|
||||
{#if guestAccessEnabled === false}
|
||||
{#if !$enterpriseLicense}
|
||||
Guest sign-in is a Windmill Enterprise Edition feature, so this app still admits members
|
||||
only.
|
||||
{:else if guestAccessEnabled === false}
|
||||
Guests are turned off for this workspace, so this app still admits members only. A
|
||||
workspace admin can turn them on in the workspace settings.
|
||||
{:else}
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
fetchEmbedToken,
|
||||
onViewerReady,
|
||||
viewer,
|
||||
viewerUrl
|
||||
viewerUrl,
|
||||
guestAppPath = undefined
|
||||
}: {
|
||||
/** Embedder-side: validate access + mint the scoped token. Throws with a
|
||||
* `.status` of 401 (login required) or 404 (not found). Pass
|
||||
@@ -68,6 +69,11 @@
|
||||
* (`/apps/get`, auth-gated, with chrome) differs from the cookieless,
|
||||
* chrome-less viewer route (`/app_embed`). */
|
||||
viewerUrl?: string
|
||||
/** `<workspace>/<app_path>` when this app is open to guests. The embedder's
|
||||
* login gate fires before the page's own load, so the page must resolve this
|
||||
* up front and pass it down — otherwise a signed-out visitor is offered an
|
||||
* ordinary sign-in that creates an account and still cannot open the app. */
|
||||
guestAppPath?: string | undefined
|
||||
} = $props()
|
||||
|
||||
const EMBED_PARAM = 'wm_embed'
|
||||
@@ -194,7 +200,14 @@
|
||||
}
|
||||
|
||||
// ---------------------------- embedder mode ----------------------------
|
||||
let status: 'loading' | 'ready' | 'noPermission' | 'notExists' | 'sdkPrompt' = $state('loading')
|
||||
type FrameStatus = 'loading' | 'ready' | 'noPermission' | 'notExists' | 'sdkPrompt'
|
||||
let status = $state<FrameStatus>('loading')
|
||||
/** Set once a sign-in completed on this page. A second `noPermission` after that
|
||||
* is not something signing in again can fix — an identity that already has an
|
||||
* account is never given a guest session, so an account holder who is not a
|
||||
* member of this workspace lands here. */
|
||||
let signedInHere = $state(false)
|
||||
let signInDidNotHelp = $derived(status === 'noPermission' && signedInHere)
|
||||
let embedToken: string | null = $state(null)
|
||||
let iframeEl: HTMLIFrameElement | undefined = $state(undefined)
|
||||
|
||||
@@ -532,14 +545,39 @@
|
||||
{:else if status === 'noPermission'}
|
||||
<!-- Login happens here, on the embedder (main) window, so the session cookie
|
||||
is set on the main origin only and never reaches the opaque iframe. -->
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">This app requires read access</div>
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
<Login
|
||||
onLoginSuccess={() => initEmbedder()}
|
||||
popup
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
</div>
|
||||
{#if signInDidNotHelp}
|
||||
<!-- Offering the same sign-in again would loop: they are signed in, and this
|
||||
app still will not open for them. Say why and stop. -->
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
You are signed in, but this app is not open to you
|
||||
</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
It is open to members of its workspace, and to guests who have no Windmill account. Ask the
|
||||
person who shared it to give your account access.
|
||||
</div>
|
||||
{:else}
|
||||
{#if guestAppPath}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">Sign in to open this app</div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
You do not need a Windmill account. Signing in lets you open this app and nothing else.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl">
|
||||
This app requires read access
|
||||
</div>
|
||||
{/if}
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
<Login
|
||||
onLoginSuccess={() => {
|
||||
signedInHere = true
|
||||
initEmbedder()
|
||||
}}
|
||||
popup
|
||||
guestApp={guestAppPath}
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if unsandboxed}
|
||||
<!-- Same-origin (full session): the app was not opted into sandbox isolation
|
||||
(the default). Rendered directly here; RawAppPreview reads
|
||||
|
||||
@@ -2182,8 +2182,14 @@ export async function main(
|
||||
>
|
||||
<Toggle
|
||||
bind:checked={guestAccessEnabled}
|
||||
disabled={!$enterpriseLicense}
|
||||
options={{ right: 'Allow guests to open apps set to Guests' }}
|
||||
/>
|
||||
{#if !$enterpriseLicense}
|
||||
<span class="text-hint text-2xs">
|
||||
Guest sign-in is a Windmill Enterprise Edition feature.
|
||||
</span>
|
||||
{/if}
|
||||
</SettingCard>
|
||||
|
||||
<SettingsFooter
|
||||
|
||||
@@ -87,7 +87,13 @@
|
||||
} else {
|
||||
notExists = true
|
||||
}
|
||||
// The app exists and admits guests; the load failed only for want of a
|
||||
// session, so offer one instead of the not-found page.
|
||||
await loadGuestEntry()
|
||||
if (guestAppPath) {
|
||||
notExists = false
|
||||
noPermission = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,15 +101,19 @@
|
||||
try {
|
||||
const entry = await AppService.getGuestEntry({ workspace, path: parsedSecret.secret })
|
||||
guestAppPath = `${workspace}/${entry.app_path}`
|
||||
// The app exists and admits guests; the load failed only for want of a session,
|
||||
// so offer one instead of the not-found page.
|
||||
notExists = false
|
||||
noPermission = true
|
||||
} catch {
|
||||
guestAppPath = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Eager, not on the failure path: PublicAppFrame asks for the embed token and
|
||||
// renders its own sign-in gate before `onViewerReady` ever fires, so resolving
|
||||
// this only after a failed `loadApp` would be too late for the case that matters
|
||||
// most — a signed-out visitor.
|
||||
if (BROWSER) {
|
||||
loadGuestEntry()
|
||||
}
|
||||
|
||||
if (BROWSER) {
|
||||
setLicense()
|
||||
}
|
||||
@@ -112,6 +122,7 @@
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
{viewerUrl}
|
||||
{guestAppPath}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
|
||||
Reference in New Issue
Block a user