mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
d5fd03d581
commit
4766f979dc
@@ -1 +1 @@
|
||||
4b9c5f296c12dc26076131be62f6c0003d83917e
|
||||
60ea364b42bd6e099d46522f3399274d25925ba7
|
||||
|
||||
+11
-9
@@ -353,9 +353,7 @@ pub async fn initial_load(
|
||||
)
|
||||
}
|
||||
});
|
||||
pass.action(windmill_common::min_version::store_min_keep_alive_version(
|
||||
db,
|
||||
));
|
||||
pass.action(windmill_common::min_version::store_min_keep_alive_version(db));
|
||||
pass.setting(
|
||||
windmill_common::global_settings::INSTANCE_EVENTS_WEBHOOK_SETTING,
|
||||
false,
|
||||
@@ -701,6 +699,7 @@ pub async fn initial_load(
|
||||
pass.run(conn).await;
|
||||
}
|
||||
|
||||
|
||||
pub fn apply_metrics_enabled(value: Option<serde_json::Value>) {
|
||||
if let Some(serde_json::Value::Bool(t)) = value {
|
||||
METRICS_ENABLED.store(t, Ordering::Relaxed)
|
||||
@@ -1057,8 +1056,8 @@ pub fn apply_fork_workspace_tag_append_fork_suffix(value: Option<serde_json::Val
|
||||
}
|
||||
|
||||
pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::Result<()> {
|
||||
let v = load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true)
|
||||
.await?;
|
||||
let v =
|
||||
load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true).await?;
|
||||
apply_critical_alert_mute_ui_setting(v);
|
||||
Ok(())
|
||||
}
|
||||
@@ -2666,6 +2665,7 @@ pub async fn reload_timeout_wait_result_setting(conn: &Connection) {
|
||||
.await;
|
||||
}
|
||||
|
||||
|
||||
pub async fn reload_extra_pip_index_url_setting(conn: &Connection) {
|
||||
reload_option_setting_with_tracing(
|
||||
conn,
|
||||
@@ -2756,6 +2756,7 @@ pub async fn reload_bunfig_install_scopes_setting(conn: &Connection) {
|
||||
.await;
|
||||
}
|
||||
|
||||
|
||||
pub async fn reload_nuget_config_setting(conn: &Connection) {
|
||||
reload_option_setting_with_tracing(
|
||||
conn,
|
||||
@@ -2863,6 +2864,7 @@ pub async fn reload_ruby_repos_setting(conn: &Connection) {
|
||||
.await;
|
||||
}
|
||||
|
||||
|
||||
pub async fn reload_workspace_registries_setting(conn: &Connection) {
|
||||
match load_value_from_global_settings_with_conn(
|
||||
conn,
|
||||
@@ -3101,6 +3103,7 @@ pub async fn apply_job_isolation_setting(value: Option<serde_json::Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async fn resolve_license_key_value(conn: &Connection, quiet: bool) -> anyhow::Result<String> {
|
||||
let q = load_value_from_global_settings_with_conn(conn, LICENSE_KEY_SETTING, true)
|
||||
.await
|
||||
@@ -3395,10 +3398,7 @@ impl<'a> SettingsPass<'a> {
|
||||
// on compile-time defaults until the next full reload. Only the single-query transport
|
||||
// can fail this way; over HTTP the batch already is the per-setting read.
|
||||
if matches!(conn, Connection::Sql(_)) && values.is_empty() && !names.is_empty() {
|
||||
tracing::warn!(
|
||||
"Falling back to per-setting reads for {} settings",
|
||||
names.len()
|
||||
);
|
||||
tracing::warn!("Falling back to per-setting reads for {} settings", names.len());
|
||||
values = fetch_settings_individually(conn, &names).await;
|
||||
}
|
||||
for (name, http) in &declared {
|
||||
@@ -3790,6 +3790,7 @@ pub fn parse_setting_value<T: FromStr + DeserializeOwned + Display>(
|
||||
value
|
||||
}
|
||||
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
pub async fn monitor_pool(db: &DB) {
|
||||
if METRICS_ENABLED.load(Ordering::Relaxed) {
|
||||
@@ -6505,6 +6506,7 @@ pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub async fn reload_jwt_secret_setting(db: &DB) -> error::Result<()> {
|
||||
let v = load_value_from_global_settings(db, JWT_SECRET_SETTING).await?;
|
||||
apply_jwt_secret_setting(db, v).await
|
||||
|
||||
@@ -255,6 +255,37 @@ async fn a_self_declared_guest_scope_grants_nothing(db: Pool<Postgres>) -> anyho
|
||||
"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
|
||||
@@ -281,3 +312,121 @@ async fn a_self_declared_guest_scope_grants_nothing(db: Pool<Postgres>) -> anyho
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A guest-mode policy that names one runnable, so an `execute_component` request
|
||||
/// gets past the triggerables lookup and reaches the guest gate.
|
||||
fn guest_app_with_runnable(path: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"path": path,
|
||||
"summary": "Guest app",
|
||||
"value": {},
|
||||
"policy": {
|
||||
"execution_mode": "guest",
|
||||
"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 run path re-reads the workspace switch instead of trusting mint time. This is
|
||||
/// the only thing standing between a `guest` policy pushed by git-sync and execution
|
||||
/// once an admin has turned guests off.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn execute_component_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))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
insert_guest_token(&db, "test-workspace").await?;
|
||||
|
||||
// Switch off: refused at the gate, even though the app's policy says guest and
|
||||
// the session was (in this fixture) issued regardless.
|
||||
let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
403,
|
||||
"a guest must not run components while the workspace has guests off"
|
||||
);
|
||||
|
||||
// Switch on: past the gate. What follows is the runnable lookup, which fails on
|
||||
// the nonexistent script — the point is that it is no longer a 403.
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
let resp = execute(port, "test-workspace", APP_PATH, GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
403,
|
||||
"with guests on, the guest gate must let the run through: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
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))
|
||||
.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(())
|
||||
}
|
||||
|
||||
@@ -2970,11 +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.
|
||||
///
|
||||
/// 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.
|
||||
/// Refuses unless both gates say yes — the workspace admits guests and `app_path` is
|
||||
/// currently in `guest` execution mode — so no caller can mint a guest session where
|
||||
/// one is not wanted, whatever it believed when it decided to call. The one thing left
|
||||
/// to the caller is the authentication of `email`: this function trusts only that the
|
||||
/// identity provider vouched for who is asking.
|
||||
pub async fn create_guest_session_token<'c>(
|
||||
email: &str,
|
||||
w_id: &str,
|
||||
@@ -2994,14 +2994,7 @@ 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") {
|
||||
if !windmill_common::workspaces::guest_app_admits(&mut **tx, w_id, app_path).await? {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"app {app_path} is not open to guests"
|
||||
)));
|
||||
@@ -3401,6 +3394,7 @@ async fn update_token_label(
|
||||
WHERE email = $2 AND token_prefix = $3
|
||||
AND (label IS NULL OR (
|
||||
label <> 'session'
|
||||
AND label <> 'guest_session'
|
||||
AND lower(label) NOT LIKE 'ephemeral%'
|
||||
AND label <> 'debugger-token'
|
||||
AND label NOT LIKE 'mcp-oauth-%'
|
||||
|
||||
@@ -8846,6 +8846,27 @@ paths:
|
||||
required:
|
||||
- app
|
||||
|
||||
/apps_u/guest_entry_by_custom_path/{custom_path}:
|
||||
get:
|
||||
summary: whether the app behind a custom path admits guests
|
||||
description: >-
|
||||
The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless
|
||||
the app's execution mode is `guest` AND its workspace has
|
||||
`guest_access_enabled`. Returns the workspace too, since a custom URL may not
|
||||
carry it.
|
||||
operationId: getGuestEntryByCustomPath
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/CustomPath"
|
||||
responses:
|
||||
"200":
|
||||
description: the app is open to guests
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GuestEntry"
|
||||
|
||||
/apps_u/public_app_by_custom_path/{custom_path}:
|
||||
get:
|
||||
summary: get public app by custom path
|
||||
@@ -13027,12 +13048,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
app_path:
|
||||
type: string
|
||||
required:
|
||||
- app_path
|
||||
$ref: "#/components/schemas/GuestEntry"
|
||||
|
||||
/w/{workspace}/apps_u/public_app/{path}:
|
||||
get:
|
||||
@@ -35071,6 +35087,17 @@ components:
|
||||
description: Configuration of protection restrictions
|
||||
items:
|
||||
$ref: "#/components/schemas/ProtectionRuleKind"
|
||||
GuestEntry:
|
||||
type: object
|
||||
description: What a signed-out visitor needs to start a guest sign-in.
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
app_path:
|
||||
type: string
|
||||
required:
|
||||
- workspace_id
|
||||
- app_path
|
||||
ProtectionRuleKind:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
@@ -335,8 +335,12 @@ fn deployment_rule_for_mode(mode: ExecutionMode) -> Option<ProtectionRuleKind> {
|
||||
/// A guest is authorized by its token's scope and never by an ACL probe: it holds no
|
||||
/// `usr` row, so RLS finds nothing for it and every guest would read as having no
|
||||
/// access. That scope is also what keeps a guest session to the one app it was minted
|
||||
/// for, even though the mode itself admits anyone signed in.
|
||||
pub fn authorize_non_member_viewer(
|
||||
/// for, even though the mode itself admits anyone signed in. The workspace switch is
|
||||
/// re-read for a guest here as it is on the run path, so turning guests off closes
|
||||
/// the app to sessions already issued rather than waiting out their expiry.
|
||||
pub async fn authorize_non_member_viewer(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
@@ -352,6 +356,11 @@ pub fn authorize_non_member_viewer(
|
||||
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
|
||||
if matches!(mode, ExecutionMode::Guest) {
|
||||
if is_guest {
|
||||
if !windmill_common::workspaces::is_guest_access_enabled(db, w_id).await? {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"app {app_path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
check_scopes(authed, || format!("apps:read:{}", app_path))?;
|
||||
}
|
||||
return Ok(true);
|
||||
@@ -367,6 +376,7 @@ pub fn authorize_non_member_viewer(
|
||||
/// [`authorize_non_member_viewer`] plus the member read-access probe, for the
|
||||
/// entry points that address an app by id.
|
||||
async fn authorize_app_viewer(
|
||||
db: &DB,
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
app_id: i64,
|
||||
@@ -374,7 +384,7 @@ async fn authorize_app_viewer(
|
||||
user_db: &UserDB,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<()> {
|
||||
if authorize_non_member_viewer(mode, app_path, opt_authed)? {
|
||||
if authorize_non_member_viewer(db, w_id, mode, app_path, opt_authed).await? {
|
||||
return Ok(());
|
||||
}
|
||||
let authed = opt_authed
|
||||
@@ -1320,6 +1330,7 @@ async fn get_public_app_by_secret(
|
||||
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
|
||||
|
||||
authorize_app_viewer(
|
||||
&db,
|
||||
policy.execution_mode(),
|
||||
&app.path,
|
||||
id,
|
||||
@@ -1666,8 +1677,18 @@ pub async fn mint_app_embed_token(
|
||||
// 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());
|
||||
// An embed token minted by a guest has to resolve the same way the guest's
|
||||
// own session does — through the label, since there is no `usr` row behind
|
||||
// the email. With the ordinary label the iframe's every request would 401.
|
||||
// The `app_embed` sentinel pushed above still confines it more tightly than
|
||||
// the session that minted it.
|
||||
let label = if windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()) {
|
||||
windmill_common::auth::GUEST_SESSION_LABEL.to_string()
|
||||
} else {
|
||||
format!("embed_app:{app_path}")
|
||||
};
|
||||
let token_config = NewToken::new(
|
||||
Some(format!("embed_app:{app_path}")),
|
||||
Some(label),
|
||||
Some(expiration),
|
||||
None,
|
||||
Some(scopes),
|
||||
@@ -1698,8 +1719,11 @@ pub async fn mint_app_embed_token(
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GuestEntry {
|
||||
/// The app path to name when starting a guest sign-in.
|
||||
app_path: String,
|
||||
/// The workspace and app path to name when starting a guest sign-in. The
|
||||
/// workspace is redundant on the secret route and load-bearing on the custom-path
|
||||
/// one, which may not carry it in its URL.
|
||||
pub workspace_id: String,
|
||||
pub app_path: String,
|
||||
}
|
||||
|
||||
/// Whether the app behind this share secret admits guests, and under what path.
|
||||
@@ -1727,7 +1751,7 @@ async fn get_guest_entry(
|
||||
{
|
||||
return Err(Error::NotFound("App is not open to guests".to_string()));
|
||||
}
|
||||
Ok(Json(GuestEntry { app_path: app.path }))
|
||||
Ok(Json(GuestEntry { workspace_id: w_id, app_path: app.path }))
|
||||
}
|
||||
|
||||
/// Issue an embed token for a public app addressed by its (secret) share id.
|
||||
@@ -1783,7 +1807,7 @@ async fn get_app_embed_token(
|
||||
} else {
|
||||
ExecutionMode::Publisher
|
||||
};
|
||||
authorize_app_viewer(mode, &app.path, id, &w_id, &user_db, &opt_authed).await?;
|
||||
authorize_app_viewer(&db, mode, &app.path, id, &w_id, &user_db, &opt_authed).await?;
|
||||
opt_authed
|
||||
};
|
||||
|
||||
@@ -3996,8 +4020,16 @@ async fn execute_component(
|
||||
}
|
||||
};
|
||||
|
||||
// Check rate limit for anonymous (public) executions
|
||||
if matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none() {
|
||||
// Rate limit for executions by callers the workspace does not know: anonymous
|
||||
// viewers, and guests — on an instance whose provider accepts any consumer
|
||||
// account, "anyone the IdP authenticates" is close to the anonymous population,
|
||||
// and each run costs a job as the publisher.
|
||||
let is_guest_caller = opt_authed
|
||||
.as_ref()
|
||||
.is_some_and(|a| windmill_api_auth::scopes::has_guest_sentinel(a.scopes.as_deref()));
|
||||
if (matches!(policy.execution_mode(), ExecutionMode::Anonymous) && opt_authed.is_none())
|
||||
|| is_guest_caller
|
||||
{
|
||||
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
|
||||
if limit > 0 {
|
||||
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
|
||||
@@ -4012,10 +4044,7 @@ async fn execute_component(
|
||||
// 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 let Some(authed) = opt_authed.as_ref().filter(|_| is_guest_caller) {
|
||||
if !matches!(policy.execution_mode(), ExecutionMode::Guest)
|
||||
|| !windmill_common::workspaces::is_guest_access_enabled(&db, &w_id).await?
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::{
|
||||
};
|
||||
|
||||
/// Whether `label` denotes a user-created token rather than a system token
|
||||
/// (`session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
|
||||
/// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`). System-token
|
||||
/// labels are load-bearing — session cleanup, super_admin propagation, expiry
|
||||
/// notifications and username overrides all key off them — so they must not be
|
||||
/// user-editable. `None` (no label) is treated as a user token.
|
||||
@@ -36,6 +36,7 @@ pub fn is_user_token(label: Option<&str>) -> bool {
|
||||
// frontend mirror (`label.toLowerCase().startsWith('ephemeral')`) and
|
||||
// the SQL `lower(label) NOT LIKE 'ephemeral%'` guard.
|
||||
l != "session"
|
||||
&& l != GUEST_SESSION_LABEL
|
||||
&& !l.to_lowercase().starts_with("ephemeral")
|
||||
&& l != "debugger-token"
|
||||
&& !l.starts_with("mcp-oauth-")
|
||||
@@ -72,6 +73,11 @@ pub fn is_server_minted_label(label: &str) -> bool {
|
||||
pub const GUEST_SESSION_LABEL: &str = "guest_session";
|
||||
|
||||
/// Whether `label` marks a guest session. See [`GUEST_SESSION_LABEL`].
|
||||
///
|
||||
/// Reserved in [`is_user_token`] as well as [`is_server_minted_label`]: the former
|
||||
/// gates relabelling, and a user token that could be relabelled *into* this
|
||||
/// namespace would become a guest session with no workspace pin — one that
|
||||
/// authenticates everywhere.
|
||||
pub fn is_guest_session_label(label: Option<&str>) -> bool {
|
||||
label == Some(GUEST_SESSION_LABEL)
|
||||
}
|
||||
|
||||
@@ -794,6 +794,28 @@ pub async fn is_guest_access_enabled(db: &crate::DB, w_id: &str) -> Result<bool>
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Both gates at once: the workspace admits guests and `app_path` is in `guest`
|
||||
/// execution mode. The single answer to "may a guest session be minted for this app",
|
||||
/// used by the mint itself and by the sign-in branch that decides whether to call it.
|
||||
/// A missing app or a policy with no stated mode reads as "no".
|
||||
pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(
|
||||
executor: E,
|
||||
w_id: &str,
|
||||
app_path: &str,
|
||||
) -> Result<bool> {
|
||||
let admits: Option<bool> = sqlx::query_scalar(
|
||||
"SELECT COALESCE(ws.guest_access_enabled AND app.policy->>'execution_mode' = 'guest', false)
|
||||
FROM app JOIN workspace_settings ws ON ws.workspace_id = app.workspace_id
|
||||
WHERE app.workspace_id = $1 AND app.path = $2",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(app_path)
|
||||
.fetch_optional(executor)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("checking guest access to {w_id}/{app_path}: {e:#}")))?;
|
||||
Ok(admits.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service
|
||||
/// accounts cannot log in and do not take a seat; a disabled member is not billed either.
|
||||
///
|
||||
|
||||
@@ -607,10 +607,15 @@
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
* it when this sign-in is not a guest entry. `login_externally` consumes it.
|
||||
* `Secure` only over https, as the backend does with its own cookies: a browser
|
||||
* drops a `Secure` cookie set from an http origin, and a SAML guest sign-in there
|
||||
* would then silently provision a real account instead. Lax suffices, since the
|
||||
* ACS redirect that consumes it is a same-site top-level navigation. */
|
||||
function setGuestAppCookie(value: string | undefined) {
|
||||
try {
|
||||
document.cookie = `guest_app=${encodeURIComponent(value ?? '')}; path=/; SameSite=None; Secure`
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||
document.cookie = `guest_app=${encodeURIComponent(value ?? '')}; path=/; SameSite=Lax${secure}`
|
||||
} catch (e) {
|
||||
console.error('Could not set the guest app cookie', e)
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
value="guest"
|
||||
disabled={(!canSetGuest || !$enterpriseLicense) && policy.execution_mode != 'guest'}
|
||||
tooltip={$enterpriseLicense
|
||||
? 'Anyone who signs in through your identity provider. No workspace membership, no seat.'
|
||||
? 'Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat.'
|
||||
: 'Guest sign-in is a Windmill Enterprise Edition feature.'}
|
||||
{item}
|
||||
/>
|
||||
@@ -492,8 +492,8 @@
|
||||
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}
|
||||
Anyone your identity provider authenticates can open this app. They join no workspace and
|
||||
take no seat.
|
||||
Anyone your identity provider authenticates can open this app without a Windmill account.
|
||||
They join no workspace and take no seat. Members of this workspace can open it too.
|
||||
{/if}
|
||||
{:else}
|
||||
Only workspace members with read access on this app can open it.
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
if (!label) return true
|
||||
return (
|
||||
label !== 'session' &&
|
||||
label !== 'guest_session' &&
|
||||
!label.toLowerCase().startsWith('ephemeral') &&
|
||||
label !== 'debugger-token' &&
|
||||
!label.startsWith('mcp-oauth-')
|
||||
|
||||
@@ -2177,7 +2177,7 @@ export async function main(
|
||||
|
||||
<SettingCard
|
||||
label="Guests"
|
||||
description="Let anyone your identity provider authenticates open the apps set to Guests. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops new guests from signing in even for apps already set to Guests; sessions already open run out on their own."
|
||||
description="Let anyone your identity provider authenticates open the apps set to Guests without a Windmill account. They join no workspace, see nothing else, and take no seat. Off by default. Turning it off stops guests immediately, even for apps already set to Guests."
|
||||
class="mt-6"
|
||||
>
|
||||
<Toggle
|
||||
|
||||
@@ -58,6 +58,20 @@
|
||||
|
||||
let workspace: string | undefined = $state(undefined)
|
||||
let refresh: (() => void) | undefined
|
||||
/** `<workspace>/<app_path>` when this app is open to guests. Resolved eagerly:
|
||||
* PublicAppFrame renders its sign-in gate before `onViewerReady` fires. */
|
||||
let guestAppPath: string | undefined = $state(undefined)
|
||||
|
||||
async function loadGuestEntry() {
|
||||
try {
|
||||
const entry = await AppService.getGuestEntryByCustomPath({
|
||||
customPath: parsedCustomPath.path
|
||||
})
|
||||
guestAppPath = `${entry.workspace_id}/${entry.app_path}`
|
||||
} catch {
|
||||
guestAppPath = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Embedder side: validate access (main session cookie or shared JWT) and mint
|
||||
// a scoped embed token for the opaque iframe (WIN-2006).
|
||||
@@ -113,17 +127,26 @@
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (BROWSER) {
|
||||
setLicense()
|
||||
loadGuestEntry()
|
||||
}
|
||||
</script>
|
||||
|
||||
<PublicAppFrame
|
||||
{fetchEmbedToken}
|
||||
{viewerUrl}
|
||||
{guestAppPath}
|
||||
onViewerReady={(_token, requestTokenRefresh) => {
|
||||
refresh = requestTokenRefresh
|
||||
loadApp()
|
||||
@@ -135,6 +158,7 @@
|
||||
{notExists}
|
||||
{noPermission}
|
||||
{jwtError}
|
||||
{guestAppPath}
|
||||
{app}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
|
||||
Reference in New Issue
Block a user