mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
feat: make guest access unavailable on the shared cloud (#11040)
* feat: make guest access unavailable on the shared cloud Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiPw5gUgNJPxtGG1meS6RY * test: pin that an issued guest session stops on the shared cloud Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiPw5gUgNJPxtGG1meS6RY * fix: refuse only widening an app into guests where they are unavailable Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NiPw5gUgNJPxtGG1meS6RY --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fd35b47658
commit
0b63e0a692
@@ -0,0 +1,168 @@
|
||||
//! Guests are unavailable on the shared cloud (`CLOUD_HOSTED`).
|
||||
//!
|
||||
//! One test in its own binary on purpose: `CLOUD_HOSTED` is read once into a
|
||||
//! `lazy_static`, so it must be set before anything reads it and cannot be unset for a
|
||||
//! sibling test in the same process.
|
||||
//!
|
||||
//! 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))
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn the_cloud_admits_no_guest(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Before the server starts, so the flag is what the whole process sees.
|
||||
unsafe { std::env::set_var("CLOUD_HOSTED", "true") };
|
||||
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");
|
||||
|
||||
// The workspace switch cannot be turned on, so no policy can lean on it.
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(
|
||||
resp.text().await?.contains("self-hosted"),
|
||||
"the refusal must name what guests need"
|
||||
);
|
||||
|
||||
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(), 400, "an app cannot be deployed to guests");
|
||||
|
||||
// Nor can a key be configured for the JWT way in — the refusal lands before the
|
||||
// outbound JWKS fetch it would otherwise make.
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_jwt_key")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "jwks_url": "https://issuer.example.com/.well-known/jwks.json" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "a guest JWT key cannot be configured");
|
||||
// Clearing one stays allowed: a key nobody can use is still worth removing.
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_jwt_key")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
|
||||
// An app already stored in guest mode — pushed by git-sync, or deployed before the
|
||||
// instance became a cloud one — advertises no entry either.
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": APP_PATH,
|
||||
"summary": "Guest app",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "publisher", "triggerables": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
sqlx::query(
|
||||
"UPDATE app SET policy = jsonb_set(policy, '{execution_mode}', '\"guest\"')
|
||||
WHERE path = $1 AND workspace_id = 'test-workspace'",
|
||||
)
|
||||
.bind(APP_PATH)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query("UPDATE workspace_settings SET guest_access_enabled = true WHERE workspace_id = 'test-workspace'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Deploying it again is not refused: only widening an app into guests is, so a
|
||||
// git-sync push of one already stored that way keeps working (and keeps being inert).
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/apps/update/{APP_PATH}")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({
|
||||
"policy": { "execution_mode": "guest", "triggerables": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"an app already stored in guest mode must stay deployable: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client().get(format!("{ws}/apps/secret_of/{APP_PATH}")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "reading the share secret must succeed");
|
||||
let secret: String = resp.text().await?;
|
||||
let resp = client()
|
||||
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
404,
|
||||
"a guest app must not advertise entry where guests are unavailable"
|
||||
);
|
||||
|
||||
// And a session issued before the instance became a cloud one stops on its next
|
||||
// request: the door re-reads the switch, so the credential itself is not enough.
|
||||
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, 'test-workspace', now() + interval '8 hours')",
|
||||
)
|
||||
.bind(GUEST_TOKEN.as_bytes())
|
||||
.bind(GUEST_TOKEN)
|
||||
.bind(vec![
|
||||
"guest".to_string(),
|
||||
"users:read".to_string(),
|
||||
format!("apps:read:{APP_PATH}"),
|
||||
format!("apps:run:{APP_PATH}"),
|
||||
])
|
||||
.execute(&db)
|
||||
.await?;
|
||||
// `whoami` is where an admitted guest resolves as `role: guest`, so a 401 here is
|
||||
// the door refusing the credential rather than a route saying no.
|
||||
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"a guest session must not authenticate where guests are unavailable"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4691,6 +4691,9 @@ async fn edit_guest_access(
|
||||
Json(EditGuestAccess { guest_access_enabled }): Json<EditGuestAccess>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if guest_access_enabled {
|
||||
windmill_common::workspaces::require_guest_support()?;
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
@@ -4747,6 +4750,11 @@ async fn edit_guest_jwt_key(
|
||||
"Set a PEM public key or a JWKS URL, not both".to_string(),
|
||||
));
|
||||
}
|
||||
// Clearing stays allowed wherever guests are: a key nobody can use is still worth
|
||||
// removing.
|
||||
if public_key.is_some() || jwks_url.is_some() {
|
||||
windmill_common::workspaces::require_guest_support()?;
|
||||
}
|
||||
if let Some(pem) = public_key.as_deref() {
|
||||
windmill_common::guest_jwt::decoding_key_from_pem(pem)?;
|
||||
}
|
||||
|
||||
@@ -5913,7 +5913,8 @@ paths:
|
||||
account; the `guest` app execution mode admits them. Off by default. Re-read
|
||||
where a guest session is minted and at the auth door on every guest request, so
|
||||
turning it off takes effect immediately, for sessions already issued and for
|
||||
apps whose policy already says `guest`.
|
||||
apps whose policy already says `guest`. Turning it *on* is refused with a 400
|
||||
where guests are unavailable (the shared cloud); turning it off always works.
|
||||
operationId: editGuestAccess
|
||||
tags:
|
||||
- workspace
|
||||
@@ -5948,7 +5949,8 @@ paths:
|
||||
URL, at most one. Both empty clears the workspace key; off cloud, verification then
|
||||
falls back to the instance issuer (`JWT_EXT_JWKS_URL`) if one is set, else no guest
|
||||
JWT is accepted (`guest_access_enabled` is the on/off switch). Workspace-admin gated.
|
||||
The key is validated before it is stored.
|
||||
The key is validated before it is stored. Setting a key is refused with a 400 where
|
||||
guests are unavailable (the shared cloud); clearing one always works.
|
||||
operationId: editGuestJwtKey
|
||||
tags:
|
||||
- workspace
|
||||
@@ -9150,7 +9152,8 @@ paths:
|
||||
description: >-
|
||||
The custom-path counterpart of `getGuestEntry`. Unauthenticated; 404 unless
|
||||
the app's execution mode is `guest` AND its workspace has
|
||||
`guest_access_enabled` AND the instance has not set `guest_access_disabled`.
|
||||
`guest_access_enabled` AND the instance has not set `guest_access_disabled`,
|
||||
and never on a deployment where guests are unavailable (the shared cloud).
|
||||
Returns the workspace too, since a custom URL may not carry it.
|
||||
operationId: getGuestEntryByCustomPath
|
||||
tags:
|
||||
@@ -13332,8 +13335,9 @@ paths:
|
||||
Unauthenticated: what a signed-out visitor reads to learn that signing in
|
||||
would let them in. 404 unless the app's execution mode is `guest` AND the
|
||||
workspace has `guest_access_enabled` AND the instance has not set the
|
||||
`guest_access_disabled` global setting, so it says nothing about apps that
|
||||
are not open to guests. Discloses only the app path, to a caller already
|
||||
`guest_access_disabled` global setting, and never on a deployment where guests
|
||||
are unavailable (the shared cloud), so it says nothing about apps that are not
|
||||
open to guests. Discloses only the app path, to a caller already
|
||||
holding the share secret.
|
||||
operationId: getGuestEntry
|
||||
tags:
|
||||
@@ -29348,7 +29352,12 @@ components:
|
||||
to one seat: `billable_guests`, `guest_seats`); every other plan and build
|
||||
admits no new email until the count drops. `instance_enabled` is the superadmin
|
||||
switch (`guest_access_disabled` global setting) every workspace switch sits under.
|
||||
`available` is whether this deployment can have guests at all: false on the shared
|
||||
cloud, where guest access requires a self-hosted or dedicated deployment, and every
|
||||
other field and switch is then moot.
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
instance_enabled:
|
||||
type: boolean
|
||||
guest_count:
|
||||
@@ -29368,6 +29377,7 @@ components:
|
||||
type: integer
|
||||
format: int64
|
||||
required:
|
||||
- available
|
||||
- instance_enabled
|
||||
- guest_count
|
||||
- window_days
|
||||
|
||||
@@ -343,6 +343,29 @@ fn refuse_unscopable_guest_app(path: &str, mode: ExecutionMode) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse *widening* an app into guests where the deployment has none
|
||||
/// (`instance_supports_guests`). Only the transition is refused, like the protection
|
||||
/// rule below it: an app already stored in the mode — deployed before the instance
|
||||
/// became a cloud one, or pushed by git-sync — keeps deploying, and keeps being inert,
|
||||
/// since every guest gate refuses it anyway. `deployed_mode` is what the app is stored
|
||||
/// as, `None` when it is being created.
|
||||
fn refuse_guest_mode_where_unavailable(
|
||||
path: &str,
|
||||
mode: ExecutionMode,
|
||||
deployed_mode: Option<ExecutionMode>,
|
||||
) -> Result<()> {
|
||||
if !matches!(mode, ExecutionMode::Guest)
|
||||
|| deployed_mode == Some(ExecutionMode::Guest)
|
||||
|| windmill_common::workspaces::instance_supports_guests()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::BadRequest(format!(
|
||||
"app {path} cannot be set to Guests: {}",
|
||||
windmill_common::workspaces::GUESTS_UNAVAILABLE_MESSAGE
|
||||
)))
|
||||
}
|
||||
|
||||
/// Gate a viewer on the app's `execution_mode`, as far as can be decided without an
|
||||
/// ACL probe. `Ok(true)` means already authorized — anonymous admits anyone, guest
|
||||
/// admits anyone signed in; `Ok(false)` means the caller is a member and still owes
|
||||
@@ -2518,6 +2541,7 @@ async fn create_app_internal<'a>(
|
||||
// even when the caller did not.
|
||||
app.policy.set_execution_mode(app.policy.execution_mode());
|
||||
refuse_unscopable_guest_app(&app.path, app.policy.execution_mode())?;
|
||||
refuse_guest_mode_where_unavailable(&app.path, app.policy.execution_mode(), None)?;
|
||||
if let Some(rule) = deployment_rule_for_mode(app.policy.execution_mode()) {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
w_id,
|
||||
@@ -3565,6 +3589,13 @@ async fn update_app_internal<'a>(
|
||||
ns.path.as_deref().unwrap_or(path),
|
||||
npolicy.execution_mode(),
|
||||
)?;
|
||||
// An unreadable deployed policy reads as not already-in-mode, the strict
|
||||
// direction, as for the protection rule below.
|
||||
refuse_guest_mode_where_unavailable(
|
||||
ns.path.as_deref().unwrap_or(path),
|
||||
npolicy.execution_mode(),
|
||||
deployed_policy.as_ref().map(|d| d.execution_mode()),
|
||||
)?;
|
||||
if let Some(rule) =
|
||||
deployment_rule_for_mode(npolicy.execution_mode()).filter(|_| !authed.is_admin)
|
||||
{
|
||||
|
||||
@@ -579,6 +579,15 @@ pub async fn jwks_key_for(url: &str, token: &str) -> Result<(DecodingKey, Vec<Al
|
||||
/// Verify `token` for `w_id` against whatever key the workspace configured. A PEM key
|
||||
/// ignores `kid`; a JWKS selects by it.
|
||||
pub async fn verify_for_workspace(db: &DB, w_id: &str, token: &str) -> Result<GuestJwtClaims> {
|
||||
// The admit check downstream refuses these anyway; refusing here keeps a deployment
|
||||
// with no guests from parsing attacker-supplied JWTs at all, and names the reason in
|
||||
// the log the caller writes.
|
||||
if !crate::workspaces::instance_supports_guests() {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"guest JWT refused: {}",
|
||||
crate::workspaces::GUESTS_UNAVAILABLE_MESSAGE
|
||||
)));
|
||||
}
|
||||
if token.len() > MAX_GUEST_JWT_LEN {
|
||||
return Err(Error::NotAuthorized(format!(
|
||||
"guest JWT refused: token is longer than {MAX_GUEST_JWT_LEN} bytes"
|
||||
|
||||
@@ -863,8 +863,12 @@ pub async fn guest_count_in_window<'c, E: sqlx::Executor<'c, Database = sqlx::Po
|
||||
/// The instance's standing against the guest allowance, as every surface reports it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct GuestUsage {
|
||||
/// Whether this deployment can admit guests at all ([`instance_supports_guests`]).
|
||||
/// Off, every other field is moot and no switch below can turn guests on.
|
||||
pub available: bool,
|
||||
/// The superadmin switch (`GUEST_ACCESS_DISABLED_SETTING`), which every workspace
|
||||
/// switch sits under.
|
||||
/// switch sits under. Reported as stored, so a superadmin sees what they set even
|
||||
/// where `available` overrules it.
|
||||
pub instance_enabled: bool,
|
||||
/// Distinct guest emails over the trailing `window_days`.
|
||||
pub guest_count: i64,
|
||||
@@ -877,9 +881,33 @@ pub struct GuestUsage {
|
||||
pub guest_seats: i64,
|
||||
}
|
||||
|
||||
/// SQL for "the instance admits guests": the superadmin switch, absent meaning on. The
|
||||
/// setting is read as text before the cast so `true` and `"true"` both count.
|
||||
fn instance_admits_guests_sql() -> String {
|
||||
/// What a caller is told when it asks for guests on a deployment that cannot have them.
|
||||
pub const GUESTS_UNAVAILABLE_MESSAGE: &str =
|
||||
"Guest access is not available on Windmill Cloud. It requires a self-hosted instance \
|
||||
or a dedicated Windmill Cloud deployment.";
|
||||
|
||||
/// Whether guests can exist on this deployment at all. They cannot on the shared cloud:
|
||||
/// a guest is an identity Windmill itself never vouched for, admitted on the say-so of
|
||||
/// whoever runs the instance, which is not a call a multi-tenant deployment can make for
|
||||
/// its tenants. Folded into every guest gate below, so a workspace switch or an app
|
||||
/// policy left saying `guest` is inert rather than honored.
|
||||
pub fn instance_supports_guests() -> bool {
|
||||
!*crate::worker::CLOUD_HOSTED
|
||||
}
|
||||
|
||||
/// [`instance_supports_guests`] as an error, for the writes that would otherwise store a
|
||||
/// setting that can never take effect.
|
||||
pub fn require_guest_support() -> Result<()> {
|
||||
if instance_supports_guests() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::BadRequest(GUESTS_UNAVAILABLE_MESSAGE.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// SQL for the superadmin switch alone, absent meaning on. The setting is read as text
|
||||
/// before the cast so `true` and `"true"` both count.
|
||||
fn instance_switch_sql() -> String {
|
||||
format!(
|
||||
"NOT COALESCE((SELECT (value #>> '{{}}')::boolean FROM global_settings \
|
||||
WHERE name = '{}'), false)",
|
||||
@@ -887,9 +915,18 @@ fn instance_admits_guests_sql() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL for "the instance admits guests": the superadmin switch, under
|
||||
/// [`instance_supports_guests`].
|
||||
fn instance_admits_guests_sql() -> String {
|
||||
if !instance_supports_guests() {
|
||||
return "false".to_string();
|
||||
}
|
||||
instance_switch_sql()
|
||||
}
|
||||
|
||||
pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> {
|
||||
let instance_admits = instance_admits_guests_sql();
|
||||
let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_admits}"))
|
||||
let instance_switch = instance_switch_sql();
|
||||
let instance_enabled: bool = sqlx::query_scalar(&format!("SELECT {instance_switch}"))
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("reading the instance guest switch: {e:#}")))?;
|
||||
@@ -901,6 +938,7 @@ pub async fn guest_usage(db: &crate::DB) -> Result<GuestUsage> {
|
||||
0
|
||||
};
|
||||
Ok(GuestUsage {
|
||||
available: instance_supports_guests(),
|
||||
instance_enabled,
|
||||
guest_count,
|
||||
window_days: GUEST_WINDOW_DAYS,
|
||||
|
||||
@@ -115,6 +115,10 @@
|
||||
// letting the publisher believe the app is open.
|
||||
let guestAccessEnabled: boolean | undefined = $state(undefined)
|
||||
let guestUsage: GuestUsage | undefined = $state(undefined)
|
||||
// Whether the deployment can have guests at all; off, the mode is not on offer. The
|
||||
// backend decides; the hostname stands in until it has answered, the shared cloud
|
||||
// being the only deployment where guests are unavailable.
|
||||
let guestsAvailable = $derived.by(() => guestUsage?.available ?? !isCloudHosted())
|
||||
|
||||
$effect(() => {
|
||||
const ws = opWs
|
||||
@@ -458,7 +462,7 @@
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest'}
|
||||
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable}
|
||||
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
|
||||
Opening this app to guests is restricted to workspace admins and bypass users by a workspace
|
||||
protection rule
|
||||
@@ -481,8 +485,10 @@
|
||||
<ToggleButton
|
||||
label="Guests"
|
||||
value="guest"
|
||||
disabled={!canSetGuest && policy.execution_mode != 'guest'}
|
||||
tooltip="Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."
|
||||
disabled={policy.execution_mode != 'guest' && (!guestsAvailable || !canSetGuest)}
|
||||
tooltip={!guestsAvailable
|
||||
? 'Not available on Windmill Cloud. Guests require a self-hosted instance or a dedicated Windmill Cloud deployment.'
|
||||
: "Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
@@ -499,7 +505,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 guestUsage && !guestUsage.instance_enabled}
|
||||
{#if !guestsAvailable}
|
||||
Guests are not available on Windmill Cloud, so this app still admits members only. They
|
||||
require a self-hosted instance or a dedicated Windmill Cloud deployment.
|
||||
{:else if guestUsage && !guestUsage.instance_enabled}
|
||||
A superadmin has turned guests off for this instance, so this app still admits members
|
||||
only.
|
||||
{:else if guestAccessEnabled === undefined}
|
||||
@@ -554,7 +563,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase}
|
||||
{#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable}
|
||||
<div class="mt-4 border-t pt-3 flex flex-col gap-2">
|
||||
<div class="text-xs font-semibold text-emphasis">
|
||||
Embed for your own authenticated users (guest JWT)
|
||||
|
||||
@@ -65,33 +65,46 @@
|
||||
description="People your identity provider authenticated who opened an app set to Guests without a Windmill account. One email is one guest, however many workspaces it opened."
|
||||
/>
|
||||
|
||||
<div class="flex flex-row gap-2 items-center mb-4">
|
||||
{#key usage}
|
||||
<Toggle
|
||||
bind:checked={switchOn}
|
||||
disabled={switchPending}
|
||||
on:change={(e) => setInstanceSwitch(e.detail)}
|
||||
options={{
|
||||
right: 'Allow guests on this instance',
|
||||
rightTooltip:
|
||||
'Off, no guest can sign in anywhere, whatever a workspace or an app says, and sessions already issued stop on their next request.'
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{#if !usage.available}
|
||||
<div class="mb-4">
|
||||
<Alert type="info" size="xs" title="Guests are not available on Windmill Cloud">
|
||||
No guest can sign in here, whatever a workspace or an app says. Guests require a self-hosted
|
||||
instance or a dedicated Windmill Cloud deployment.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-2 items-center mb-4">
|
||||
{#key usage}
|
||||
<Toggle
|
||||
bind:checked={switchOn}
|
||||
disabled={switchPending}
|
||||
on:change={(e) => setInstanceSwitch(e.detail)}
|
||||
options={{
|
||||
right: 'Allow guests on this instance',
|
||||
rightTooltip:
|
||||
'Off, no guest can sign in anywhere, whatever a workspace or an app says, and sessions already issued stop on their next request.'
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<Alert type={pastAllowance ? 'warning' : 'info'} size="xs" title="{usage.guest_count} of {usage.free_allowance} free guests used in the last {usage.window_days} days">
|
||||
{#if usage.metered}
|
||||
Beyond the allowance, every four guests count as one seat{usage.guest_seats > 0
|
||||
? `: ${usage.billable_guests} guests past it take ${usage.guest_seats} ${usage.guest_seats === 1 ? 'seat' : 'seats'} now`
|
||||
: ''}.
|
||||
{:else}
|
||||
Beyond the allowance, new guests are refused until the count drops below it; an
|
||||
Enterprise license meters them instead.
|
||||
{/if}
|
||||
</Alert>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<Alert
|
||||
type={pastAllowance ? 'warning' : 'info'}
|
||||
size="xs"
|
||||
title="{usage.guest_count} of {usage.free_allowance} free guests used in the last {usage.window_days} days"
|
||||
>
|
||||
{#if usage.metered}
|
||||
Beyond the allowance, every four guests count as one seat{usage.guest_seats > 0
|
||||
? `: ${usage.billable_guests} guests past it take ${usage.guest_seats} ${usage.guest_seats === 1 ? 'seat' : 'seats'} now`
|
||||
: ''}.
|
||||
{:else}
|
||||
Beyond the allowance, new guests are refused until the count drops below it; an Enterprise
|
||||
license meters them instead.
|
||||
{/if}
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DataTable
|
||||
shouldLoadMore={hasMore}
|
||||
|
||||
@@ -204,6 +204,9 @@
|
||||
pem: guestJwtKeyType === 'pem' ? guestJwtPublicKey.trim() : '',
|
||||
jwks: guestJwtKeyType === 'jwks' ? guestJwtJwksUrl.trim() : ''
|
||||
})
|
||||
// Whether the deployment can have guests at all; off, the card offers no switch to
|
||||
// turn on. The backend decides; the hostname stands in until it has answered.
|
||||
let guestsAvailable = $derived.by(() => guestUsage?.available ?? !isCloudHosted())
|
||||
let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined)
|
||||
|
||||
let hasInstanceAiConfig = $state(false)
|
||||
@@ -571,6 +574,20 @@
|
||||
sendUserToast('Guest JWT key updated')
|
||||
}
|
||||
|
||||
// Removing a key stays allowed where guests are unavailable, and the rest of the card
|
||||
// is hidden there, so this is the only way left to drop one stored earlier.
|
||||
async function clearGuestJwtKey(): Promise<void> {
|
||||
await WorkspaceService.editGuestJwtKey({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { public_key: undefined, jwks_url: undefined }
|
||||
})
|
||||
guestJwtPublicKey = ''
|
||||
guestJwtJwksUrl = ''
|
||||
initialGuestJwtPublicKey = ''
|
||||
initialGuestJwtJwksUrl = ''
|
||||
sendUserToast('Guest JWT key cleared')
|
||||
}
|
||||
|
||||
async function editGuestAccess(): Promise<void> {
|
||||
await WorkspaceService.editGuestAccess({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -2231,77 +2248,92 @@ export async function main(
|
||||
description="Let anyone your identity provider authenticates, or a JWT your own backend signs (configured below), 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
|
||||
bind:checked={guestAccessEnabled}
|
||||
options={{ right: 'Allow guests to open apps set to Guests' }}
|
||||
/>
|
||||
{#if guestUsage && !guestUsage.instance_enabled}
|
||||
<span class="text-hint text-2xs">
|
||||
A superadmin has turned guests off for this instance, so this switch has no
|
||||
effect until they are allowed again.
|
||||
</span>
|
||||
{:else if guestUsage}
|
||||
<span class="text-hint text-2xs">
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this
|
||||
instance in the last {guestUsage.window_days} days.
|
||||
{#if guestUsage.metered}
|
||||
Beyond that, every four guests count as one seat{guestUsage.guest_seats > 0
|
||||
? ` (${guestUsage.guest_seats} now)`
|
||||
: ''}.
|
||||
{:else}
|
||||
Beyond that, new guests are refused until the count drops; an Enterprise
|
||||
license meters them instead.
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
|
||||
<div class="text-xs font-semibold text-emphasis">
|
||||
Guest JWT verification key
|
||||
</div>
|
||||
<div class="text-2xs text-hint">
|
||||
A guest can also enter through a JWT your own backend mints and signs, with no
|
||||
identity-provider round-trip, for iframe embedding. The token must carry
|
||||
<code>email</code>, <code>workspace_id</code>, <code>app_path</code> and
|
||||
<code>exp</code> (lifetime capped at 24h); it opens only the app named by
|
||||
<code>app_path</code>. Accepted algorithms: RS256/384/512, PS256/384/512,
|
||||
ES256/384. Symmetric algorithms (HS*) are refused. Configure one key, a PEM
|
||||
public key or a JWKS URL (which must be https). Point it at an issuer you
|
||||
control: any token that key signs carrying these claims is accepted, so a shared
|
||||
multi-tenant issuer is not a good fit.
|
||||
</div>
|
||||
<ToggleButtonGroup bind:selected={guestJwtKeyType}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton {item} value="pem" label="PEM public key" />
|
||||
<ToggleButton {item} value="jwks" label="JWKS URL" />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{#if guestJwtKeyType === 'pem'}
|
||||
<TextInput
|
||||
underlyingInputEl="textarea"
|
||||
class="font-mono text-xs"
|
||||
autosizeParams={{ minHeight: 128 }}
|
||||
inputProps={{
|
||||
placeholder: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'
|
||||
}}
|
||||
bind:value={guestJwtPublicKey}
|
||||
/>
|
||||
{:else}
|
||||
<TextInput
|
||||
inputProps={{
|
||||
placeholder: 'https://issuer.example.com/.well-known/jwks.json'
|
||||
}}
|
||||
bind:value={guestJwtJwksUrl}
|
||||
/>
|
||||
{#if !guestsAvailable}
|
||||
<Alert type="info" title="Not available on Windmill Cloud" size="xs">
|
||||
Guests require a self-hosted instance or a dedicated Windmill Cloud deployment.
|
||||
</Alert>
|
||||
{#if initialGuestJwtPublicKey || initialGuestJwtJwksUrl}
|
||||
<div class="mt-3 flex flex-row items-center gap-3">
|
||||
<span class="text-hint text-2xs">
|
||||
A guest JWT verification key is stored for this workspace and cannot be
|
||||
used.
|
||||
</span>
|
||||
<Button unifiedSize="xs" variant="default" onclick={clearGuestJwtKey}>
|
||||
Clear stored key
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isCloudHosted()}
|
||||
{:else}
|
||||
<Toggle
|
||||
bind:checked={guestAccessEnabled}
|
||||
options={{ right: 'Allow guests to open apps set to Guests' }}
|
||||
/>
|
||||
{#if guestUsage && !guestUsage.instance_enabled}
|
||||
<span class="text-hint text-2xs">
|
||||
A superadmin has turned guests off for this instance, so this switch has no
|
||||
effect until they are allowed again.
|
||||
</span>
|
||||
{:else if guestUsage}
|
||||
<span class="text-hint text-2xs">
|
||||
{guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across
|
||||
this instance in the last {guestUsage.window_days} days.
|
||||
{#if guestUsage.metered}
|
||||
Beyond that, every four guests count as one seat{guestUsage.guest_seats > 0
|
||||
? ` (${guestUsage.guest_seats} now)`
|
||||
: ''}.
|
||||
{:else}
|
||||
Beyond that, new guests are refused until the count drops; an Enterprise
|
||||
license meters them instead.
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
|
||||
<div class="text-xs font-semibold text-emphasis">
|
||||
Guest JWT verification key
|
||||
</div>
|
||||
<div class="text-2xs text-hint">
|
||||
A guest can also enter through a JWT your own backend mints and signs, with no
|
||||
identity-provider round-trip, for iframe embedding. The token must carry
|
||||
<code>email</code>, <code>workspace_id</code>, <code>app_path</code> and
|
||||
<code>exp</code> (lifetime capped at 24h); it opens only the app named by
|
||||
<code>app_path</code>. Accepted algorithms: RS256/384/512, PS256/384/512,
|
||||
ES256/384. Symmetric algorithms (HS*) are refused. Configure one key, a PEM
|
||||
public key or a JWKS URL (which must be https). Point it at an issuer you
|
||||
control: any token that key signs carrying these claims is accepted, so a
|
||||
shared multi-tenant issuer is not a good fit.
|
||||
</div>
|
||||
<ToggleButtonGroup bind:selected={guestJwtKeyType}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton {item} value="pem" label="PEM public key" />
|
||||
<ToggleButton {item} value="jwks" label="JWKS URL" />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{#if guestJwtKeyType === 'pem'}
|
||||
<TextInput
|
||||
underlyingInputEl="textarea"
|
||||
class="font-mono text-xs"
|
||||
autosizeParams={{ minHeight: 128 }}
|
||||
inputProps={{
|
||||
placeholder: '-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----'
|
||||
}}
|
||||
bind:value={guestJwtPublicKey}
|
||||
/>
|
||||
{:else}
|
||||
<TextInput
|
||||
inputProps={{
|
||||
placeholder: 'https://issuer.example.com/.well-known/jwks.json'
|
||||
}}
|
||||
bind:value={guestJwtJwksUrl}
|
||||
/>
|
||||
{/if}
|
||||
<div class="text-2xs text-hint">
|
||||
Leave empty to fall back to the instance's configured JWT issuer (<code
|
||||
>JWT_EXT_JWKS_URL</code
|
||||
>), if one is set. Set a key here to trust a different issuer for this
|
||||
workspace.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</SettingCard>
|
||||
|
||||
<SettingsFooter
|
||||
|
||||
Reference in New Issue
Block a user