mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
feat: guest app execution mode, a fourth role that takes no seat
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
94af8d0fb5
commit
0edfee970b
+214
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slack_team_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "teams_team_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "teams_team_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "slack_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "slack_command_script",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "teams_command_script",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "slack_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "slack_oauth_client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "slack_oauth_client_secret",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "customer_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "plan",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "webhook",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "ai_config",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "dbt_warehouses",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "ducklake",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "git_sync",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "default_app",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "default_scripts",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "mute_critical_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "color",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "operator_settings",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"name": "git_app_installations",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "auto_invite",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "error_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "success_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "public_app_execution_limit_per_minute",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"name": "error_handler_fallback_to_instance_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "00a61afc5faa3826c283660417ff1f8a93060fe062a0b727f164329ab56387a2"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "02bf6098743be1cc61b72747951fded5dad1fb16b10f1ba27a580261bb9d050d"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT guest_access_enabled FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "03b0658a1bc3e2d8831055d336799eca4766ec4f61ae1528e3481fa57c0b3464"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path, policy->>'execution_mode' as execution_mode\n FROM app WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "execution_mode",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "126f72d28d1bfcea764311f3aedb2d1bbe51c9d5ba2cd12890de867ac51573ea"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO guest_activity (email, workspace_id, day)\n VALUES ($1, $2, CURRENT_DATE)\n ON CONFLICT (email, workspace_id, day)\n DO UPDATE SET last_seen_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477"
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n (SELECT MIN(day) FROM guest_activity) AS since,\n (SELECT COUNT(DISTINCT email) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_count!\",\n (SELECT COUNT(DISTINCT workspace_id) FROM guest_activity\n WHERE day > CURRENT_DATE - 30)::INT AS \"guest_workspace_count!\",\n (SELECT COUNT(*) FROM workspace_settings ws JOIN workspace w ON w.id = ws.workspace_id\n WHERE ws.guest_access_enabled AND NOT w.deleted)::INT AS \"guest_enabled_workspace_count!\",\n (SELECT COUNT(*) FROM workspace WHERE NOT deleted)::INT AS \"workspace_count!\"\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "since",
|
||||
"type_info": "Date"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "guest_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "guest_workspace_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "guest_enabled_workspace_count!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "workspace_count!",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8b28332dd5b3932dfdaa9fcb2e3eb6b9c48ec164b05b149b3477351df7a1bd60"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2"
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slack_team_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "slack_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "teams_team_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "teams_team_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "mute_critical_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "guest_access_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS guest_activity;
|
||||
ALTER TABLE workspace_settings DROP COLUMN guest_access_enabled;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Guest app access: a workspace-level switch, off by default. An app whose policy says
|
||||
-- `execution_mode: guest` only admits guests where this is on, and the check runs where
|
||||
-- the guest session is minted -- an app definition carries its policy, so git-sync and
|
||||
-- the CLI push `guest` past every UI gate.
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN guest_access_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- A guest leaves no `usr` or `password` row, which is what keeps them off every seat
|
||||
-- counter, so this is the only durable record that one was here: a row per guest,
|
||||
-- workspace and day, written when the session is minted.
|
||||
--
|
||||
-- Deliberately not the audit log. The seat scan is served by a partial index whose
|
||||
-- predicate names the login operations literally, and `audit_partitioned` is a
|
||||
-- partitioned table, where `CREATE INDEX CONCURRENTLY` is unsupported -- adding a
|
||||
-- guest operation to that predicate means a locking rebuild on the largest table an
|
||||
-- instance has. Guest logins still write `users.login_guest` for the audit trail;
|
||||
-- nothing counts them from there.
|
||||
CREATE TABLE guest_activity (
|
||||
email VARCHAR(255) NOT NULL,
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
day DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (email, workspace_id, day)
|
||||
);
|
||||
|
||||
-- The retention delete filters on day alone; the PK only reaches it through two
|
||||
-- other columns.
|
||||
CREATE INDEX idx_guest_activity_day ON guest_activity (day);
|
||||
+18
-11
@@ -353,7 +353,9 @@ 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,
|
||||
@@ -699,7 +701,6 @@ 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)
|
||||
@@ -1056,8 +1057,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(())
|
||||
}
|
||||
@@ -1870,6 +1871,15 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
tracing::error!("Error deleting old feature_usage rows: {e}");
|
||||
}
|
||||
|
||||
// Guest sign-ins, kept a month longer than the seat window they feed so a late
|
||||
// telemetry send still sees a whole month.
|
||||
if let Err(e) = sqlx::query!("DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60")
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting old guest_activity rows: {e}");
|
||||
}
|
||||
|
||||
match sqlx::query_scalar!(
|
||||
"DELETE FROM agent_token_blacklist WHERE expires_at <= now() RETURNING token",
|
||||
)
|
||||
@@ -2656,7 +2666,6 @@ 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,
|
||||
@@ -2747,7 +2756,6 @@ 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,
|
||||
@@ -2855,7 +2863,6 @@ 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,
|
||||
@@ -3094,7 +3101,6 @@ 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
|
||||
@@ -3389,7 +3395,10 @@ 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 {
|
||||
@@ -3781,7 +3790,6 @@ 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) {
|
||||
@@ -6497,7 +6505,6 @@ 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
|
||||
|
||||
@@ -110,6 +110,7 @@ folder_permission_history: id(bigint), workspace_id(char), folder_name(char), ch
|
||||
FK: (workspace_id, folder_name) -> folder(workspace_id, name)
|
||||
gcp_trigger: gcp_resource_path(char), topic_id(char), subscription_id(char), delivery_type(delivery_mode), delivery_config(jsonb), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), subscription_mode(gcp_subscription_mode), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), auto_acknowledge_msg(bool), ack_deadline(int), mode(trigger_mode), labels(text[])
|
||||
global_settings: name(char), value(jsonb), updated_at(ts)
|
||||
guest_activity: email(char), workspace_id(char), day(date), last_seen_at(timestamptz)
|
||||
group_: workspace_id(char), name(char), summary(text), extra_perms(jsonb)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
group_permission_history: id(bigint), workspace_id(char), group_name(char), changed_by(char), changed_at(ts), change_type(char), member_affected(char)
|
||||
@@ -222,7 +223,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char)
|
||||
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
zombie_job_counter: job_id(uuid), counter(int)
|
||||
FK: (job_id) -> v2_job(id)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Tests for the `guest` app execution mode.
|
||||
//!
|
||||
//! 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
|
||||
//! would silently undo it:
|
||||
//!
|
||||
//! * 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;
|
||||
//! * 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).
|
||||
//!
|
||||
//! The token is inserted directly: how a guest session is minted is the identity
|
||||
//! provider's business (EE), what one can do is this file's.
|
||||
//!
|
||||
//! 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))
|
||||
}
|
||||
|
||||
/// 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![
|
||||
"guest".to_string(),
|
||||
"jobs:read".to_string(),
|
||||
"resources:run".to_string(),
|
||||
"users:read".to_string(),
|
||||
"folders:read".to_string(),
|
||||
format!("apps:read:{APP_PATH}"),
|
||||
format!("apps:run:{APP_PATH}"),
|
||||
])
|
||||
.bind(workspace)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn guest_session_is_confined_to_its_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");
|
||||
|
||||
insert_guest_token(&db, "test-workspace").await?;
|
||||
|
||||
// Its own identity resolves, and reports the role rather than falling through to
|
||||
// the non-member branch that hands out a `superadmin` shape.
|
||||
let resp = authed(client().get(format!("{ws}/users/whoami")), GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "guest whoami must resolve");
|
||||
let me: serde_json::Value = resp.json().await?;
|
||||
assert_eq!(
|
||||
me["role"],
|
||||
json!("guest"),
|
||||
"guest must not read as superadmin"
|
||||
);
|
||||
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 [
|
||||
"jobs/list",
|
||||
"scripts/list",
|
||||
"flows/list",
|
||||
"variables/list",
|
||||
"resources/get_value/u/test-user/secret",
|
||||
"apps/list",
|
||||
] {
|
||||
let resp = authed(client().get(format!("{ws}/{route}")), GUEST_TOKEN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
403,
|
||||
"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(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn guest_token_does_not_cross_workspaces(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// A second workspace with an app at the SAME path: without the token's workspace
|
||||
// pin, `apps:run:<path>` would unlock it too, since a path is not unique across
|
||||
// workspaces.
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace (id, name, owner) VALUES ('other-ws', 'other-ws', 'test-user')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ('other-ws')")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
insert_guest_token(&db, "test-workspace").await?;
|
||||
|
||||
let resp = authed(
|
||||
client().get(format!(
|
||||
"http://localhost:{port}/api/w/other-ws/apps/get/p/{APP_PATH}"
|
||||
)),
|
||||
GUEST_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
401,
|
||||
"a guest token pinned to one workspace must not authenticate against another"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn guest_entry_needs_both_the_app_mode_and_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(&json!({
|
||||
"path": APP_PATH,
|
||||
"summary": "Guest app",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "guest", "triggerables": {} }
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
|
||||
let secret: String = authed(
|
||||
client().get(format!("{ws}/apps/secret_of/{APP_PATH}")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
// The app says guest, the workspace has not opted in: inert.
|
||||
let resp = client()
|
||||
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
404,
|
||||
"a guest app in a workspace that has not enabled guests must not advertise entry"
|
||||
);
|
||||
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": true }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Unauthenticated on purpose: this is what a signed-out visitor reads.
|
||||
let resp = client()
|
||||
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
let entry: serde_json::Value = resp.json().await?;
|
||||
assert_eq!(entry["app_path"], json!(APP_PATH));
|
||||
|
||||
// Turning the switch back off closes the door again even though the app's own
|
||||
// policy is unchanged.
|
||||
authed(
|
||||
client().post(format!("{ws}/workspaces/edit_guest_access")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "guest_access_enabled": false }))
|
||||
.send()
|
||||
.await?;
|
||||
let resp = client()
|
||||
.get(format!("{ws}/apps_u/guest_entry/{secret}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
404,
|
||||
"turning guests off must stop advertising entry for an app already set to guest"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -509,6 +509,34 @@ impl AuthCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A guest session: authenticated by the IdP,
|
||||
// member of nothing. Deliberately no `usr`
|
||||
// lookup and no groups or folders, so every
|
||||
// ACL denies it on its own and the token's
|
||||
// scopes are its entire grant
|
||||
// (`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(),
|
||||
) =>
|
||||
{
|
||||
Some(ApiAuthed {
|
||||
username: email.clone(),
|
||||
email,
|
||||
is_admin: false,
|
||||
is_operator: true,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes,
|
||||
username_override,
|
||||
username_override_is_token_label,
|
||||
is_session_token,
|
||||
token_prefix: Some(safe_token_prefix(token)),
|
||||
read_only,
|
||||
job_id: None,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -500,6 +500,27 @@ 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 {
|
||||
if let Some(suffix) = route_suffix.as_deref() {
|
||||
if guest_route_denied(required_domain, suffix) {
|
||||
return Err(Error::GuestPromotionRequired(format!(
|
||||
"a guest session cannot access {route_path}"
|
||||
)));
|
||||
}
|
||||
// Same rationale as the embed branch: re-running a component supersedes
|
||||
// its in-flight run, and `cancel_job_api` confines this to the caller's
|
||||
// own jobs.
|
||||
if suffix.starts_with("jobs_u/queue/cancel/") {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each declared scope must grant what its prompt said and no more:
|
||||
// `jobs:run` only deployed runnables, `users:read` only the viewer's identity.
|
||||
if has_raw_app_sdk_sentinel(Some(token_scopes)) {
|
||||
@@ -577,6 +598,12 @@ 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
|
||||
@@ -753,6 +780,19 @@ pub fn has_app_embed_sentinel(scopes: Option<&[String]>) -> bool {
|
||||
scopes.is_some_and(|s| s.iter().any(|x| x == APP_EMBED_SENTINEL))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub const GUEST_SENTINEL: &str = "guest";
|
||||
|
||||
/// True if a token is a guest session. Such a session has no `usr` row, so its
|
||||
/// scopes are its entire grant — every ACL check denies it on its own.
|
||||
pub fn has_guest_sentinel(scopes: Option<&[String]>) -> bool {
|
||||
scopes.is_some_and(|s| s.iter().any(|x| x == GUEST_SENTINEL))
|
||||
}
|
||||
|
||||
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
|
||||
/// to narrow the declared scopes to what the viewer's prompt promised.
|
||||
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
|
||||
@@ -815,6 +855,19 @@ fn app_embed_apps_route_allowed(suffix: &str) -> bool {
|
||||
suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/")
|
||||
}
|
||||
|
||||
/// Routes a guest session is denied: the app-embed allowlist, plus the embed-token
|
||||
/// mint. A guest session is the *embedder* — the viewer's own browser rendering the
|
||||
/// app page — not the app's own JS, and the page mints the iframe's token from it.
|
||||
///
|
||||
/// Everything else stays default-denied, so a guest reaches the app it was let in
|
||||
/// for and nothing around it.
|
||||
fn guest_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
|
||||
if domain == ScopeDomain::Apps && suffix.starts_with("apps_u/embed_token") {
|
||||
return false;
|
||||
}
|
||||
app_embed_route_denied(domain, suffix)
|
||||
}
|
||||
|
||||
/// Job routes a running app uses (the by-id poll/cancel surface driven by the
|
||||
/// frontend JobLoader). Everything else in the jobs domain — enumeration, counts,
|
||||
/// exports, and the `job_signature`/`resume_urls` capability-minting routes — is
|
||||
|
||||
@@ -745,7 +745,31 @@ async fn whoami(
|
||||
Path(w_id): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
) -> JsonResult<UserInfo> {
|
||||
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
|
||||
let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed;
|
||||
// A guest has no `usr` row by construction, so it would otherwise fall through to
|
||||
// the non-member branch below and be handed a `superadmin` role. Answer it here,
|
||||
// as the operator-shaped identity it actually is.
|
||||
if is_guest {
|
||||
return Ok(Json(UserInfo {
|
||||
workspace_id: w_id,
|
||||
email,
|
||||
username,
|
||||
name: None,
|
||||
is_admin: false,
|
||||
is_super_admin: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
groups: vec![],
|
||||
operator: true,
|
||||
disabled: false,
|
||||
role: Some("guest".to_string()),
|
||||
folders_read: vec![],
|
||||
folders: vec![],
|
||||
folders_owners: vec![],
|
||||
is_service_account: false,
|
||||
non_member: true,
|
||||
}));
|
||||
}
|
||||
let user = get_user(&w_id, &username, &db).await?;
|
||||
// Only treat the row as "this user is a member" when its email matches; the
|
||||
// derived username is instance-unique so a match on a different email should
|
||||
@@ -2882,7 +2906,12 @@ pub async fn create_session_token<'c>(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let mut cookie = Cookie::new(COOKIE_NAME, token.clone());
|
||||
set_session_cookie(&cookies, &token, *MAX_SESSION_VALIDITY_SECONDS);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn set_session_cookie(cookies: &Cookies, token: &str, validity_seconds: i64) {
|
||||
let mut cookie = Cookie::new(COOKIE_NAME, token.to_string());
|
||||
cookie.set_secure(IS_SECURE.load(std::sync::atomic::Ordering::Relaxed));
|
||||
cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax));
|
||||
cookie.set_http_only(true);
|
||||
@@ -2892,9 +2921,118 @@ pub async fn create_session_token<'c>(
|
||||
}
|
||||
|
||||
let mut expire: OffsetDateTime = time::OffsetDateTime::now_utc();
|
||||
expire += time::Duration::seconds(*MAX_SESSION_VALIDITY_SECONDS);
|
||||
expire += time::Duration::seconds(validity_seconds);
|
||||
cookie.set_expires(expire);
|
||||
cookies.add(cookie);
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// A guest session is the only credential held by someone with no account, so
|
||||
/// there is nothing to disable when the workspace revokes guest access or the
|
||||
/// identity provider removes them — the expiry is the revocation. Much shorter
|
||||
/// than a member session for that reason.
|
||||
static ref GUEST_SESSION_VALIDITY_SECONDS: i64 = std::env::var("GUEST_SESSION_VALIDITY_SECONDS")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i64>().ok())
|
||||
.unwrap_or(8 * 60 * 60);
|
||||
}
|
||||
|
||||
/// Scopes a guest session carries. Mirrors `APP_EMBED_SCOPES` — the same broad-looking
|
||||
/// 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.
|
||||
fn guest_session_scopes(app_path: &str) -> Vec<String> {
|
||||
vec![
|
||||
windmill_api_auth::scopes::GUEST_SENTINEL.to_string(),
|
||||
"jobs:read".to_string(),
|
||||
"resources:run".to_string(),
|
||||
"users:read".to_string(),
|
||||
"folders:read".to_string(),
|
||||
format!("apps:read:{app_path}"),
|
||||
format!("apps:run:{app_path}"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Mint a browser session for someone the identity provider authenticated who is a
|
||||
/// member of no workspace, so they can open one guest-mode app.
|
||||
///
|
||||
/// Writes no `password` and no `usr` row: that absence is what keeps a guest off every
|
||||
/// seat counter, so nothing here may be "helpfully" upgraded into provisioning.
|
||||
///
|
||||
/// The token is pinned to `w_id`: `AuthCache` matches on `token.workspace_id`, and
|
||||
/// without the pin an `apps:run:<path>` scope would also unlock a same-path app in
|
||||
/// another workspace. The pin also means a guest cannot authenticate on any
|
||||
/// workspace-less route at all — `/api/users/*`, `/api/settings/*` — since those
|
||||
/// resolve with a NULL workspace and the match fails. That is the intent, and the
|
||||
/// 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`].
|
||||
pub async fn create_guest_session_token<'c>(
|
||||
email: &str,
|
||||
w_id: &str,
|
||||
app_path: &str,
|
||||
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
|
||||
cookies: Cookies,
|
||||
) -> Result<String> {
|
||||
use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH;
|
||||
|
||||
let token = rd_string(32);
|
||||
let t_hash = windmill_common::auth::hash_token(&token);
|
||||
let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token);
|
||||
let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await {
|
||||
None
|
||||
} else {
|
||||
Some(&token)
|
||||
};
|
||||
let scopes = guest_session_scopes(app_path);
|
||||
|
||||
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)",
|
||||
t_hash,
|
||||
t_prefix,
|
||||
plaintext as Option<&str>,
|
||||
email,
|
||||
&GUEST_SESSION_VALIDITY_SECONDS.to_string(),
|
||||
&scopes,
|
||||
w_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
// The only durable record that a guest was here: no `usr` row means no membership
|
||||
// to read them off, and the seat scan reads this rather than the audit log (see the
|
||||
// migration). Idempotent per email, workspace and day.
|
||||
sqlx::query!(
|
||||
"INSERT INTO guest_activity (email, workspace_id, day)
|
||||
VALUES ($1, $2, CURRENT_DATE)
|
||||
ON CONFLICT (email, workspace_id, day)
|
||||
DO UPDATE SET last_seen_at = now()",
|
||||
email,
|
||||
w_id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut **tx,
|
||||
&AuditAuthor {
|
||||
email: email.to_string(),
|
||||
username: email.to_string(),
|
||||
username_override: None,
|
||||
token_prefix: Some(t_prefix.to_string()),
|
||||
},
|
||||
"users.login_guest",
|
||||
ActionKind::Create,
|
||||
w_id,
|
||||
Some(app_path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
set_session_cookie(&cookies, &token, *GUEST_SESSION_VALIDITY_SECONDS);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ pub fn workspaced_service() -> Router {
|
||||
)
|
||||
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
|
||||
.route("/edit_default_app", post(edit_default_app))
|
||||
.route("/edit_guest_access", post(edit_guest_access))
|
||||
.route("/default_app", get(get_default_app))
|
||||
.route(
|
||||
"/default_scripts",
|
||||
@@ -316,6 +317,10 @@ pub struct WorkspaceSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
pub error_handler_fallback_to_instance_alerts: bool,
|
||||
/// Whether this workspace admits guest sessions -- someone the identity provider
|
||||
/// authenticated who is a member of nothing, and who therefore takes no seat. An
|
||||
/// app's own `execution_mode: guest` is inert while this is off.
|
||||
pub guest_access_enabled: bool,
|
||||
}
|
||||
|
||||
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
|
||||
@@ -338,6 +343,9 @@ pub struct WorkspacePublicSettings {
|
||||
pub teams_team_guid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mute_critical_alerts: Option<bool>,
|
||||
/// Not sensitive, and the app editor needs it to say whether the guest rung is
|
||||
/// live -- an app can be set to `guest` while the workspace has guests off.
|
||||
pub guest_access_enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deploy_ui: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1072,7 +1080,8 @@ async fn get_settings(
|
||||
error_handler,
|
||||
success_handler,
|
||||
public_app_execution_limit_per_minute,
|
||||
error_handler_fallback_to_instance_alerts
|
||||
error_handler_fallback_to_instance_alerts,
|
||||
guest_access_enabled
|
||||
FROM
|
||||
workspace_settings
|
||||
WHERE
|
||||
@@ -1111,6 +1120,7 @@ async fn get_public_settings(
|
||||
teams_team_name,
|
||||
teams_team_guid,
|
||||
mute_critical_alerts,
|
||||
guest_access_enabled,
|
||||
deploy_ui,
|
||||
large_file_storage,
|
||||
datatable
|
||||
@@ -4594,6 +4604,51 @@ async fn edit_default_app(
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditGuestAccess {
|
||||
guest_access_enabled: bool,
|
||||
}
|
||||
|
||||
/// Turn guest sessions on or off for this workspace. Off by default, and off is
|
||||
/// authoritative: an app whose policy already says `guest` stops admitting them at
|
||||
/// the next sign-in, because the switch is checked where the session is minted.
|
||||
///
|
||||
/// Sessions already handed out are not revoked — a guest has no account to disable —
|
||||
/// and run out on their own (`GUEST_SESSION_VALIDITY_SECONDS`).
|
||||
async fn edit_guest_access(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(EditGuestAccess { guest_access_enabled }): Json<EditGuestAccess>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET guest_access_enabled = $1 WHERE workspace_id = $2",
|
||||
guest_access_enabled,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.edit_guest_access",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&guest_access_enabled.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!(
|
||||
"Guest access set to {guest_access_enabled} for workspace {w_id}"
|
||||
))
|
||||
}
|
||||
|
||||
async fn edit_default_scripts(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
|
||||
@@ -3667,8 +3667,12 @@ paths:
|
||||
$ref: "#/components/schemas/WorkspaceDeployUISettings"
|
||||
mute_critical_alerts:
|
||||
type: boolean
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
required:
|
||||
- workspace_id
|
||||
- guest_access_enabled
|
||||
|
||||
/w/{workspace}/workspaces/get_settings:
|
||||
get:
|
||||
@@ -3750,6 +3754,9 @@ paths:
|
||||
error_handler_fallback_to_instance_alerts:
|
||||
type: boolean
|
||||
description: Report failed jobs to the instance critical alert channels when no workspace error handler is set.
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false.
|
||||
|
||||
/w/{workspace}/workspaces/get_deploy_to:
|
||||
get:
|
||||
@@ -5739,6 +5746,39 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_guest_access:
|
||||
post:
|
||||
summary: enable or disable guest sessions for this workspace
|
||||
description: >-
|
||||
Guests are people the identity provider authenticates who are members of no
|
||||
workspace, so they take no seat. Off by default. Checked where a guest session
|
||||
is minted, so turning it off stops new guests immediately even for apps whose
|
||||
policy already says `guest`; sessions already handed out run out on their own.
|
||||
operationId: editGuestAccess
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Whether guest sessions are admitted
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
guest_access_enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- guest_access_enabled
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/default_scripts:
|
||||
post:
|
||||
summary: edit default scripts for workspace
|
||||
@@ -12966,6 +13006,34 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps_u/guest_entry/{path}:
|
||||
get:
|
||||
summary: whether the app behind a share secret admits guests
|
||||
description: >-
|
||||
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`, 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:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: the app is open to guests
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
app_path:
|
||||
type: string
|
||||
required:
|
||||
- app_path
|
||||
|
||||
/w/{workspace}/apps_u/public_app/{path}:
|
||||
get:
|
||||
summary: get public app by secret
|
||||
@@ -33128,14 +33196,16 @@ components:
|
||||
type: string
|
||||
execution_mode:
|
||||
type: string
|
||||
enum: [viewer, publisher, anonymous]
|
||||
enum: [viewer, publisher, guest, anonymous]
|
||||
description: >-
|
||||
Who the app's runnables execute as. Optional, and what omitting it
|
||||
means depends on the operation: creating an app defaults it to
|
||||
`publisher` (runs on behalf of the app's publisher and requires an
|
||||
authenticated viewer), while updating one keeps the mode the app is
|
||||
already deployed under. Either way `anonymous`, which makes the app
|
||||
publicly executable, is never assumed
|
||||
Who may open the app, and who its runnables execute as. Optional, and
|
||||
what omitting it means depends on the operation: creating an app
|
||||
defaults it to `publisher` (runs on behalf of the app's publisher and
|
||||
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`
|
||||
on_behalf_of:
|
||||
type: string
|
||||
on_behalf_of_email:
|
||||
@@ -33186,7 +33256,7 @@ components:
|
||||
format: date-time
|
||||
execution_mode:
|
||||
type: string
|
||||
enum: [viewer, publisher, anonymous]
|
||||
enum: [viewer, publisher, guest, anonymous]
|
||||
raw_app:
|
||||
type: boolean
|
||||
labels:
|
||||
@@ -35008,6 +35078,7 @@ components:
|
||||
- RestrictDeployToDeployers
|
||||
- RestrictAnonymousAppDeployment
|
||||
- RestrictPublicRunSharing
|
||||
- RestrictGuestAppDeployment
|
||||
RuleBypasserGroups:
|
||||
type: array
|
||||
description: Groups that can bypass this ruleset
|
||||
|
||||
@@ -170,6 +170,7 @@ pub fn unauthed_service() -> Router {
|
||||
)
|
||||
.route("/load_csv_preview/{*path}", get(app_load_csv_preview))
|
||||
.route("/public_app/{secret}", get(get_public_app_by_secret))
|
||||
.route("/guest_entry/{secret}", get(get_guest_entry))
|
||||
.route("/embed_token/{secret}", get(get_app_embed_token))
|
||||
.route("/public_resource/{*path}", get(get_public_resource))
|
||||
.route("/get_data/v/{*id}", get(get_raw_app_data))
|
||||
@@ -288,6 +289,13 @@ pub type AllowUserResources = Vec<String>;
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExecutionMode {
|
||||
Anonymous,
|
||||
/// Login required, workspace membership not: anyone the instance's identity
|
||||
/// provider authenticates may open the app, and the runnables execute as the
|
||||
/// publisher exactly as in [`ExecutionMode::Publisher`]. Such a viewer holds a
|
||||
/// guest session — no `usr` row, no `password` row, no seat — so it is only
|
||||
/// honored where `workspace_settings.guest_access_enabled` is on, checked
|
||||
/// where the session is minted rather than here.
|
||||
Guest,
|
||||
/// Default for a policy that omits `execution_mode`. It MUST stay a mode
|
||||
/// that requires an authenticated viewer: an omitted field must never be
|
||||
/// able to publish an app anonymously (publicly executable).
|
||||
@@ -296,6 +304,99 @@ pub enum ExecutionMode {
|
||||
Viewer,
|
||||
}
|
||||
|
||||
impl ExecutionMode {
|
||||
/// The serialized form, matching this enum's `rename_all = "lowercase"`.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ExecutionMode::Anonymous => "anonymous",
|
||||
ExecutionMode::Guest => "guest",
|
||||
ExecutionMode::Publisher => "publisher",
|
||||
ExecutionMode::Viewer => "viewer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The protection rule gating a *transition into* `mode`, if any. Anonymous and
|
||||
/// guest each widen who may open an app past the workspace's own members, so each
|
||||
/// carries its own rule; the two member-only modes are ungated.
|
||||
fn deployment_rule_for_mode(mode: ExecutionMode) -> Option<ProtectionRuleKind> {
|
||||
match mode {
|
||||
ExecutionMode::Anonymous => Some(ProtectionRuleKind::RestrictAnonymousAppDeployment),
|
||||
ExecutionMode::Guest => Some(ProtectionRuleKind::RestrictGuestAppDeployment),
|
||||
ExecutionMode::Publisher | ExecutionMode::Viewer => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the read-access check its caller performs.
|
||||
///
|
||||
/// 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(
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<bool> {
|
||||
if matches!(mode, ExecutionMode::Anonymous) {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(authed) = opt_authed.as_ref() else {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
));
|
||||
};
|
||||
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
|
||||
if matches!(mode, ExecutionMode::Guest) {
|
||||
if is_guest {
|
||||
check_scopes(authed, || format!("apps:read:{}", app_path))?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if is_guest {
|
||||
return Err(Error::GuestPromotionRequired(format!(
|
||||
"app {app_path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// [`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(
|
||||
mode: ExecutionMode,
|
||||
app_path: &str,
|
||||
app_id: i64,
|
||||
w_id: &str,
|
||||
user_db: &UserDB,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<()> {
|
||||
if authorize_non_member_viewer(mode, app_path, opt_authed)? {
|
||||
return Ok(());
|
||||
}
|
||||
let authed = opt_authed
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::internal_err("authorize_app_viewer: unauthenticated".to_string()))?;
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
app_id,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct PolicyTriggerableInputs {
|
||||
static_inputs: StaticFields,
|
||||
@@ -1218,29 +1319,15 @@ async fn get_public_app_by_secret(
|
||||
|
||||
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
|
||||
|
||||
if !matches!(policy.execution_mode(), ExecutionMode::Anonymous) {
|
||||
if opt_authed.is_none() {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
));
|
||||
} else {
|
||||
let authed = opt_authed.unwrap();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
authorize_app_viewer(
|
||||
policy.execution_mode(),
|
||||
&app.path,
|
||||
id,
|
||||
&w_id,
|
||||
&user_db,
|
||||
&opt_authed,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Compute bundle_secret for raw apps
|
||||
if app.raw_app {
|
||||
@@ -1601,6 +1688,40 @@ 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,
|
||||
}
|
||||
|
||||
/// Whether the app behind this share secret admits guests, and under what path.
|
||||
///
|
||||
/// Unauthenticated on purpose: it is what a signed-out visitor reads to learn that
|
||||
/// signing in would get them in. It discloses only the app's path, to a caller who
|
||||
/// already holds the share secret — the secret is the capability here. A 404 when the
|
||||
/// app is not open to guests, so it says nothing about apps that are not.
|
||||
async fn get_guest_entry(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, secret)): Path<(String, String)>,
|
||||
) -> JsonResult<GuestEntry> {
|
||||
let id = get_id_from_secret(&db, &w_id, secret, None).await?;
|
||||
let app = sqlx::query!(
|
||||
"SELECT path, policy->>'execution_mode' as execution_mode
|
||||
FROM app WHERE id = $1 AND workspace_id = $2",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
let app = not_found_if_none(app, "App", id.to_string())?;
|
||||
if app.execution_mode.as_deref() != Some(ExecutionMode::Guest.as_str())
|
||||
|| !windmill_common::workspaces::is_guest_access_enabled(&db, &w_id).await?
|
||||
{
|
||||
return Err(Error::NotFound("App is not open to guests".to_string()));
|
||||
}
|
||||
Ok(Json(GuestEntry { app_path: app.path }))
|
||||
}
|
||||
|
||||
/// Issue an embed token for a public app addressed by its (secret) share id.
|
||||
/// Mirrors the access check in [`get_public_app_by_secret`]: anonymous apps are
|
||||
/// reachable without auth, otherwise the caller must be logged in and have read
|
||||
@@ -1649,26 +1770,13 @@ async fn get_app_embed_token(
|
||||
// logged in (so the app sees their identity), otherwise stay anonymous.
|
||||
opt_authed
|
||||
} else {
|
||||
let authed = opt_authed.ok_or_else(|| {
|
||||
Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
Some(authed)
|
||||
let mode = if policy.guest_execution {
|
||||
ExecutionMode::Guest
|
||||
} else {
|
||||
ExecutionMode::Publisher
|
||||
};
|
||||
authorize_app_viewer(mode, &app.path, id, &w_id, &user_db, &opt_authed).await?;
|
||||
opt_authed
|
||||
};
|
||||
|
||||
let resp = build_embed_token_response(
|
||||
@@ -1694,6 +1802,9 @@ async fn get_app_embed_token(
|
||||
/// strictest access interpretation.
|
||||
pub struct EmbedPolicyView {
|
||||
pub anonymous_execution: bool,
|
||||
/// Open to anyone the identity provider authenticates. Like
|
||||
/// `anonymous_execution`, an unknown mode reads as `false` — the strict side.
|
||||
pub guest_execution: bool,
|
||||
pub sandbox: bool,
|
||||
/// Raw apps: author-declared scopes for the frontend SDK token; empty when
|
||||
/// the app doesn't use the frontend SDK (non-string entries are ignored).
|
||||
@@ -1704,6 +1815,7 @@ pub fn parse_embed_policy(policy_str: &str) -> Result<EmbedPolicyView> {
|
||||
let v: serde_json::Value = serde_json::from_str(policy_str).map_err(to_anyhow)?;
|
||||
Ok(EmbedPolicyView {
|
||||
anonymous_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("anonymous"),
|
||||
guest_execution: v.get("execution_mode").and_then(|m| m.as_str()) == Some("guest"),
|
||||
sandbox: v.get("sandbox").and_then(|b| b.as_bool()).unwrap_or(false),
|
||||
frontend_sdk_scopes: v
|
||||
.get("frontend_sdk_scopes")
|
||||
@@ -2287,10 +2399,10 @@ async fn create_app_internal<'a>(
|
||||
// Pin the mode the app is created under, so the stored policy states one
|
||||
// even when the caller did not.
|
||||
app.policy.set_execution_mode(app.policy.execution_mode());
|
||||
if matches!(app.policy.execution_mode(), ExecutionMode::Anonymous) {
|
||||
if let Some(rule) = deployment_rule_for_mode(app.policy.execution_mode()) {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
w_id,
|
||||
&ProtectionRuleKind::RestrictAnonymousAppDeployment,
|
||||
&rule,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -3308,21 +3420,22 @@ async fn update_app_internal<'a>(
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
if matches!(npolicy.execution_mode(), ExecutionMode::Anonymous) && !authed.is_admin {
|
||||
// Restricted users may keep deploying an app that is already
|
||||
// public, but flipping an app to anonymous (public) access is
|
||||
// gated by the RestrictAnonymousAppDeployment protection rule.
|
||||
// An unreadable deployed policy reads as not-anonymous, the
|
||||
// strict direction.
|
||||
let already_anonymous = deployed
|
||||
if let Some(rule) =
|
||||
deployment_rule_for_mode(npolicy.execution_mode()).filter(|_| !authed.is_admin)
|
||||
{
|
||||
// Restricted users may keep deploying an app that is already open
|
||||
// to this audience, but widening one is gated by the matching
|
||||
// protection rule. An unreadable deployed policy reads as not
|
||||
// already-widened, the strict direction.
|
||||
let already_in_mode = deployed
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("execution_mode"))
|
||||
.and_then(|m| m.as_str())
|
||||
== Some("anonymous");
|
||||
if !already_anonymous {
|
||||
== Some(npolicy.execution_mode().as_str());
|
||||
if !already_in_mode {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
w_id,
|
||||
&ProtectionRuleKind::RestrictAnonymousAppDeployment,
|
||||
&rule,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
@@ -3570,7 +3683,9 @@ async fn get_on_behalf_details_from_policy_and_authed(
|
||||
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
|
||||
(username, permissioned_as, email)
|
||||
}
|
||||
ExecutionMode::Publisher => {
|
||||
// Guest runs as the publisher exactly as Publisher does; the two differ only
|
||||
// in who is let through the door, which is settled before we get here.
|
||||
ExecutionMode::Publisher | ExecutionMode::Guest => {
|
||||
let username = opt_authed
|
||||
.as_ref()
|
||||
.map(|a| a.username.clone())
|
||||
@@ -3882,6 +3997,21 @@ 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.
|
||||
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!(
|
||||
"app {path} is not open to guests"
|
||||
)));
|
||||
}
|
||||
check_scopes(authed, || format!("apps:run:{}", path))?;
|
||||
}
|
||||
|
||||
// Execution is publisher and an user is authenticated: check if the user is authorized to
|
||||
// execute the app.
|
||||
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode(), opt_authed.as_ref())
|
||||
|
||||
@@ -36,6 +36,14 @@ 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}")]
|
||||
@@ -130,6 +138,7 @@ impl Error {
|
||||
Self::NotAuthorized(_) => "NotAuthorized",
|
||||
Self::MetricNotFound(_) => "MetricNotFound",
|
||||
Self::PermissionDenied(_) => "PermissionDenied",
|
||||
Self::GuestPromotionRequired(_) => "GuestPromotionRequired",
|
||||
_ => "InternalErr",
|
||||
}
|
||||
}
|
||||
@@ -287,7 +296,9 @@ 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(_) => axum::http::StatusCode::FORBIDDEN,
|
||||
Self::RequireAdmin(_) | Self::PermissionDenied(_) | Self::GuestPromotionRequired(_) => {
|
||||
axum::http::StatusCode::FORBIDDEN
|
||||
}
|
||||
Self::SqlErr { .. }
|
||||
| Self::BadRequest(_)
|
||||
| Self::AIError(_)
|
||||
@@ -308,14 +319,21 @@ impl IntoResponse for Error {
|
||||
|
||||
let body = Body::from(e.to_string());
|
||||
|
||||
axum::response::Response::builder()
|
||||
let mut builder = axum::response::Response::builder()
|
||||
.header("Content-Type", "text/plain")
|
||||
.status(status)
|
||||
.body(body)
|
||||
.unwrap()
|
||||
.status(status);
|
||||
if matches!(e, Self::GuestPromotionRequired(_)) {
|
||||
builder = builder.header(GUEST_PROMOTE_HEADER, "1");
|
||||
}
|
||||
builder.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
|
||||
|
||||
@@ -71,6 +71,7 @@ bitflags::bitflags! {
|
||||
const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2;
|
||||
const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3;
|
||||
const RESTRICT_PUBLIC_RUN_SHARING = 1 << 4;
|
||||
const RESTRICT_GUEST_APP_DEPLOYMENT = 1 << 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +84,7 @@ pub enum ProtectionRuleKind {
|
||||
RestrictDeployToDeployers,
|
||||
RestrictAnonymousAppDeployment,
|
||||
RestrictPublicRunSharing,
|
||||
RestrictGuestAppDeployment,
|
||||
}
|
||||
|
||||
impl ProtectionRuleKind {
|
||||
@@ -103,6 +105,9 @@ impl ProtectionRuleKind {
|
||||
ProtectionRuleKind::RestrictPublicRunSharing => {
|
||||
ProtectionRules::RESTRICT_PUBLIC_RUN_SHARING
|
||||
}
|
||||
ProtectionRuleKind::RestrictGuestAppDeployment => {
|
||||
ProtectionRules::RESTRICT_GUEST_APP_DEPLOYMENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +126,9 @@ impl ProtectionRuleKind {
|
||||
ProtectionRuleKind::RestrictPublicRunSharing => {
|
||||
"Sharing a run publicly (readable without login) is restricted in this workspace"
|
||||
}
|
||||
ProtectionRuleKind::RestrictGuestAppDeployment => {
|
||||
"Opening an app to guests (anyone who can sign in) is restricted in this workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,6 +775,25 @@ pub struct BillableSeats {
|
||||
pub seats: i64,
|
||||
}
|
||||
|
||||
/// Whether `w_id` admits guest sessions — someone the identity provider authenticated
|
||||
/// who is a member of no workspace, and who therefore takes no seat.
|
||||
///
|
||||
/// Read uncached, and only where a guest session is minted. An app carries its own
|
||||
/// `execution_mode` in its definition, so git-sync and the CLI can push `guest` past
|
||||
/// every deploy-time gate; the switch is only meaningful if the door itself checks it.
|
||||
/// Turning it off does not invalidate sessions already handed out — they expire on
|
||||
/// their own, which is why a guest session's validity is short.
|
||||
pub async fn is_guest_access_enabled(db: &crate::DB, w_id: &str) -> Result<bool> {
|
||||
Ok(sqlx::query_scalar!(
|
||||
"SELECT guest_access_enabled FROM workspace_settings WHERE workspace_id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("reading guest access of {w_id}: {e:#}")))?
|
||||
.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.
|
||||
///
|
||||
|
||||
@@ -1059,7 +1059,10 @@
|
||||
<li>instance base URL</li>
|
||||
<li>login type usage (login type, count)</li>
|
||||
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
||||
<li>user usage (author count, operator count)</li>
|
||||
<li
|
||||
>user usage (author count, operator count, guest sign-in count and the workspaces
|
||||
that allow guests)</li
|
||||
>
|
||||
<li>superadmin email addresses</li>
|
||||
<li>development instance status</li>
|
||||
</ul>
|
||||
@@ -1119,7 +1122,10 @@
|
||||
<li>job usage (language, total duration, count)</li>
|
||||
<li>login type usage (login type, count)</li>
|
||||
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
|
||||
<li>user usage (author count, operator count)</li>
|
||||
<li
|
||||
>user usage (author count, operator count, guest sign-in count and the workspaces
|
||||
that allow guests)</li
|
||||
>
|
||||
<li>development instance status</li>
|
||||
<li
|
||||
>feature usage (counts of which product features are used, including AI provider and
|
||||
|
||||
@@ -67,6 +67,12 @@
|
||||
/** Reports the instance's login options once loaded, so the page around the card can
|
||||
* adapt its heading: a third-party login also creates the account on first use. */
|
||||
onOptionsLoaded?: (options: { hasThirdParty: boolean }) => void
|
||||
/** `<workspace>/<app_path>` when this sign-in is someone opening an app that is
|
||||
* open to guests. A third-party login then mints a guest session -- no account,
|
||||
* no seat -- instead of creating a user. Omitting it is what promotion is: the
|
||||
* same sign-in without this, which provisions them for real. Password login
|
||||
* ignores it: a guest has no stored credential to check. */
|
||||
guestApp?: string | undefined
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -79,7 +85,8 @@
|
||||
autoRedirect = true,
|
||||
onLoginSuccess = undefined,
|
||||
preview = undefined,
|
||||
onOptionsLoaded = undefined
|
||||
onOptionsLoaded = undefined,
|
||||
guestApp = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// The harness never takes effect in a production bundle, whatever a caller passes.
|
||||
@@ -539,7 +546,11 @@
|
||||
if (previewConfig) return true
|
||||
markLoginMethodPending({ kind: 'oauth', provider })
|
||||
persistRd()
|
||||
let url = base + '/api/oauth/login/' + provider + (popup ? '?close=true' : '')
|
||||
const params = new URLSearchParams()
|
||||
if (popup) params.set('close', 'true')
|
||||
if (guestApp) params.set('guest_app', guestApp)
|
||||
const query = params.size > 0 ? '?' + params.toString() : ''
|
||||
let url = base + '/api/oauth/login/' + provider + query
|
||||
console.log('storeRedirect', popup, url)
|
||||
|
||||
if (popup) {
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { AppService, SettingService } from '$lib/gen'
|
||||
import { AppService, SettingService, WorkspaceService } from '$lib/gen'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import { computeSecretUrl } from './appDeploy.svelte'
|
||||
import { base } from '$lib/base'
|
||||
@@ -90,6 +92,47 @@
|
||||
(rulesetsLoaded &&
|
||||
canUserBypassRuleKind('RestrictAnonymousAppDeployment', $userStore ?? undefined))
|
||||
)
|
||||
let canSetGuest = $derived(
|
||||
!!$userStore?.is_admin ||
|
||||
!!$userStore?.is_super_admin ||
|
||||
(rulesetsLoaded &&
|
||||
canUserBypassRuleKind('RestrictGuestAppDeployment', $userStore ?? undefined))
|
||||
)
|
||||
// The three rungs of the access control, widest last. `viewer` is a fourth
|
||||
// execution mode that this control never sets (it runs components as the viewer,
|
||||
// which a guest cannot be), so an app in it shows as members-only here.
|
||||
let accessMode = $derived(
|
||||
policy.execution_mode == 'anonymous'
|
||||
? 'anonymous'
|
||||
: policy.execution_mode == 'guest'
|
||||
? 'guest'
|
||||
: 'publisher'
|
||||
)
|
||||
// Undefined until loaded. An app can be set to `guest` while the workspace has
|
||||
// guests off, in which case the mode is stored but inert -- say so rather than
|
||||
// letting the publisher believe the app is open.
|
||||
let guestAccessEnabled: boolean | undefined = $state(undefined)
|
||||
|
||||
$effect(() => {
|
||||
const ws = opWs
|
||||
if (ws === undefined) return
|
||||
untrack(() => {
|
||||
WorkspaceService.getPublicSettings({ workspace: ws })
|
||||
.then((s) => (guestAccessEnabled = s.guest_access_enabled))
|
||||
.catch(() => (guestAccessEnabled = undefined))
|
||||
})
|
||||
})
|
||||
|
||||
function onAccessModeChange(mode: string | undefined) {
|
||||
if (mode === undefined || mode === accessMode) return
|
||||
policy.execution_mode = mode
|
||||
// Same as sandbox: a not-yet-deployed app has no row to PATCH, so
|
||||
// `setPublishState` would 404. The mode is carried by the first deploy's
|
||||
// policy; persist incrementally only once the app exists.
|
||||
if (savedApp && !newApp) {
|
||||
setPublishState()
|
||||
}
|
||||
}
|
||||
let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer)
|
||||
let savedOnBehalfOfEmail = $derived(savedApp?.policy?.on_behalf_of_email)
|
||||
let savedOnBehalfOf = $derived(savedApp?.policy?.on_behalf_of)
|
||||
@@ -389,34 +432,67 @@
|
||||
{/if}
|
||||
|
||||
{#if !hideSecretUrl}
|
||||
<h2>Public URL</h2>
|
||||
<h2>Access</h2>
|
||||
|
||||
<div class="my-6">
|
||||
{#if rulesetsLoaded && !canSetAnonymous}
|
||||
{#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'}
|
||||
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
|
||||
Making this app publicly accessible without login is restricted to workspace admins and
|
||||
bypass users by a workspace protection rule
|
||||
Opening this app to anyone with the link is restricted to workspace admins and bypass users
|
||||
by a workspace protection rule
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
{#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest'}
|
||||
<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
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
<Toggle
|
||||
options={{
|
||||
left: `Require login and read-access`,
|
||||
right: `No login required`
|
||||
}}
|
||||
checked={policy.execution_mode == 'anonymous'}
|
||||
on:change={(e) => {
|
||||
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
|
||||
// Same as sandbox: a not-yet-deployed app has no row to PATCH, so
|
||||
// `setPublishState` would 404. The mode is carried by the first
|
||||
// deploy's policy; persist incrementally only once the app exists.
|
||||
if (savedApp && !newApp) {
|
||||
setPublishState()
|
||||
}
|
||||
}}
|
||||
disabled={!savedApp || (!canSetAnonymous && policy.execution_mode != 'anonymous')}
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
selected={accessMode}
|
||||
on:selected={(e) => onAccessModeChange(e.detail)}
|
||||
disabled={!savedApp}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
label="Members"
|
||||
value="publisher"
|
||||
tooltip="Workspace members with read access on this app."
|
||||
{item}
|
||||
/>
|
||||
<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."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
label="Public"
|
||||
value="anonymous"
|
||||
disabled={!canSetAnonymous && policy.execution_mode != 'anonymous'}
|
||||
tooltip="Anyone with the secret URL. No login."
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<div class="text-xs text-secondary mb-3">
|
||||
{#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}
|
||||
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.
|
||||
{/if}
|
||||
{:else}
|
||||
Only workspace members with read access on this app can open it.
|
||||
{/if}
|
||||
</div>
|
||||
{#if !savedApp || newApp}
|
||||
<ClipboardPanel content={`Deploy this app once to get the public secret URL`} size="md" />
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
notExists,
|
||||
noPermission,
|
||||
jwtError,
|
||||
guestAppPath = undefined,
|
||||
onLoginSuccess,
|
||||
app,
|
||||
workspace,
|
||||
@@ -37,6 +38,9 @@
|
||||
notExists: boolean
|
||||
noPermission: boolean
|
||||
jwtError: boolean
|
||||
/** Set when this app is open to guests: signing in gets the visitor in without
|
||||
* an account. Undefined means the ordinary "you need read access" dead end. */
|
||||
guestAppPath?: string | undefined
|
||||
onLoginSuccess: () => void
|
||||
app: (AppWithLastVersion & { value: any; workspace_id?: string }) | undefined
|
||||
workspace: string | undefined
|
||||
@@ -128,17 +132,31 @@
|
||||
</Alert></div
|
||||
>
|
||||
{:else if noPermission}
|
||||
<div class="px-4 mt-20 w-full text-center font-bold text-xl"> This app requires read access </div>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
{#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace}
|
||||
You are logged in but are not a member of the workspace <span class="text-xl font-bold"
|
||||
>{effectiveWorkspace}</span
|
||||
> this app is part of
|
||||
{:else}You must be logged in and have read access to this app{/if}</div
|
||||
>
|
||||
{#if guestAppPath && !$userStore}
|
||||
<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>
|
||||
<div class="text-center mt-8 text-sm text-primary">
|
||||
{#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace}
|
||||
You are logged in but are not a member of the workspace <span class="text-xl font-bold"
|
||||
>{effectiveWorkspace}</span
|
||||
> this app is part of
|
||||
{:else}You must be logged in and have read access to this app{/if}</div
|
||||
>
|
||||
{/if}
|
||||
<div class="px-2 mx-auto mt-20 max-w-xl w-full">
|
||||
{#if !jwtError}
|
||||
<Login {onLoginSuccess} popup rd={page.url.pathname + page.url.search + page.url.hash} />
|
||||
<Login
|
||||
{onLoginSuccess}
|
||||
popup
|
||||
guestApp={guestAppPath}
|
||||
rd={page.url.pathname + page.url.search + page.url.hash}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if app}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
let restrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers'))
|
||||
let restrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment'))
|
||||
let restrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing'))
|
||||
let restrictGuestAppDeployment = $state(hasRule('RestrictGuestAppDeployment'))
|
||||
let selectedGroups = $state<string[]>(
|
||||
untrack(() => rule)?.bypass_groups?.map((g) => g.replace('g/', '')) ?? []
|
||||
)
|
||||
@@ -57,6 +58,7 @@
|
||||
let initialRestrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers'))
|
||||
let initialRestrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment'))
|
||||
let initialRestrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing'))
|
||||
let initialRestrictGuestAppDeployment = $state(hasRule('RestrictGuestAppDeployment'))
|
||||
let initialSelectedGroups = $state<string[]>(
|
||||
untrack(() => rule)?.bypass_groups
|
||||
? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', ''))
|
||||
@@ -125,6 +127,7 @@
|
||||
restrictDeployToDeployers ||
|
||||
restrictAnonymousAppDeployment ||
|
||||
restrictPublicRunSharing ||
|
||||
restrictGuestAppDeployment ||
|
||||
selectedGroups.length > 0 ||
|
||||
selectedUsers.length > 0
|
||||
: name !== initialName ||
|
||||
@@ -133,6 +136,7 @@
|
||||
restrictDeployToDeployers !== initialRestrictDeployToDeployers ||
|
||||
restrictAnonymousAppDeployment !== initialRestrictAnonymousAppDeployment ||
|
||||
restrictPublicRunSharing !== initialRestrictPublicRunSharing ||
|
||||
restrictGuestAppDeployment !== initialRestrictGuestAppDeployment ||
|
||||
JSON.stringify([...selectedGroups].sort()) !==
|
||||
JSON.stringify([...initialSelectedGroups].sort()) ||
|
||||
JSON.stringify([...selectedUsers].sort()) !==
|
||||
@@ -176,7 +180,10 @@
|
||||
...(restrictAnonymousAppDeployment
|
||||
? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind]
|
||||
: []),
|
||||
...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : [])
|
||||
...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []),
|
||||
...(restrictGuestAppDeployment
|
||||
? ['RestrictGuestAppDeployment' as ProtectionRuleKind]
|
||||
: [])
|
||||
],
|
||||
bypass_groups: selectedGroups,
|
||||
bypass_users: selectedUsers
|
||||
@@ -209,7 +216,10 @@
|
||||
...(restrictAnonymousAppDeployment
|
||||
? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind]
|
||||
: []),
|
||||
...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : [])
|
||||
...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []),
|
||||
...(restrictGuestAppDeployment
|
||||
? ['RestrictGuestAppDeployment' as ProtectionRuleKind]
|
||||
: [])
|
||||
],
|
||||
bypass_groups: selectedGroups,
|
||||
bypass_users: selectedUsers
|
||||
@@ -225,6 +235,7 @@
|
||||
initialRestrictDeployToDeployers = restrictDeployToDeployers
|
||||
initialRestrictAnonymousAppDeployment = restrictAnonymousAppDeployment
|
||||
initialRestrictPublicRunSharing = restrictPublicRunSharing
|
||||
initialRestrictGuestAppDeployment = restrictGuestAppDeployment
|
||||
initialSelectedGroups = clone(selectedGroups)
|
||||
initialSelectedUsers = clone(selectedUsers)
|
||||
|
||||
@@ -379,6 +390,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restrict guest app deployment -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
bind:checked={restrictGuestAppDeployment}
|
||||
options={{
|
||||
right: 'Restrict guest app access'
|
||||
}}
|
||||
/>
|
||||
<div class="text-xs text-secondary ml-6">
|
||||
Only workspace admins and bypass users can open an app to guests (anyone who signs in,
|
||||
with no workspace membership and no seat). Apps that already admit guests can still be
|
||||
redeployed.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restrict public run sharing -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
|
||||
@@ -187,6 +187,8 @@
|
||||
let criticalAlertUIMuted: boolean | undefined = $state(undefined)
|
||||
let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined)
|
||||
let publicAppRateLimitPerMinute: number | undefined = $state(undefined)
|
||||
let guestAccessEnabled: boolean = $state(false)
|
||||
let initialGuestAccessEnabled: boolean = $state(false)
|
||||
let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined)
|
||||
|
||||
let hasInstanceAiConfig = $state(false)
|
||||
@@ -528,6 +530,22 @@
|
||||
if (publicAppRateLimitPerMinute !== initialPublicAppRateLimitPerMinute) {
|
||||
await editPublicAppRateLimit()
|
||||
}
|
||||
if (guestAccessEnabled !== initialGuestAccessEnabled) {
|
||||
await editGuestAccess()
|
||||
}
|
||||
}
|
||||
|
||||
async function editGuestAccess(): Promise<void> {
|
||||
await WorkspaceService.editGuestAccess({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { guest_access_enabled: guestAccessEnabled }
|
||||
})
|
||||
initialGuestAccessEnabled = guestAccessEnabled
|
||||
sendUserToast(
|
||||
guestAccessEnabled
|
||||
? 'Guests can now open apps set to Guests in this workspace'
|
||||
: 'Guests can no longer sign in to this workspace'
|
||||
)
|
||||
}
|
||||
|
||||
async function loadWorkspaceEncryptionKey(): Promise<void> {
|
||||
@@ -623,6 +641,8 @@
|
||||
initialCriticalAlertUIMuted = settings.mute_critical_alerts
|
||||
publicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined
|
||||
initialPublicAppRateLimitPerMinute = settings.public_app_execution_limit_per_minute ?? undefined
|
||||
guestAccessEnabled = settings.guest_access_enabled ?? false
|
||||
initialGuestAccessEnabled = settings.guest_access_enabled ?? false
|
||||
if (emptyString($enterpriseLicense)) {
|
||||
errorHandlerSelected = 'custom'
|
||||
} else if (
|
||||
@@ -1024,11 +1044,13 @@
|
||||
return {
|
||||
savedValue: {
|
||||
defaultAppPath: initialWorkspaceDefaultAppPath,
|
||||
publicAppRateLimitPerMinute: initialPublicAppRateLimitPerMinute
|
||||
publicAppRateLimitPerMinute: initialPublicAppRateLimitPerMinute,
|
||||
guestAccessEnabled: initialGuestAccessEnabled
|
||||
},
|
||||
modifiedValue: {
|
||||
defaultAppPath: workspaceDefaultAppPath,
|
||||
publicAppRateLimitPerMinute: publicAppRateLimitPerMinute
|
||||
publicAppRateLimitPerMinute: publicAppRateLimitPerMinute,
|
||||
guestAccessEnabled: guestAccessEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1037,6 +1059,7 @@
|
||||
function discardDefaultAppSettingsChanges() {
|
||||
workspaceDefaultAppPath = initialWorkspaceDefaultAppPath
|
||||
publicAppRateLimitPerMinute = initialPublicAppRateLimitPerMinute
|
||||
guestAccessEnabled = initialGuestAccessEnabled
|
||||
}
|
||||
|
||||
// Strip keys from extraArgs that are auto-managed by child components:
|
||||
@@ -2152,6 +2175,17 @@ export async function main(
|
||||
<span class="text-hint text-2xs">executions per minute per server</span>
|
||||
</SettingCard>
|
||||
|
||||
<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."
|
||||
class="mt-6"
|
||||
>
|
||||
<Toggle
|
||||
bind:checked={guestAccessEnabled}
|
||||
options={{ right: 'Allow guests to open apps set to Guests' }}
|
||||
/>
|
||||
</SettingCard>
|
||||
|
||||
<SettingsFooter
|
||||
class="mt-8"
|
||||
hasUnsavedChanges={hasDefaultAppChanges}
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
let notExists = $state(false)
|
||||
let noPermission = $state(false)
|
||||
let jwtError = $state(false)
|
||||
/** `<workspace>/<app_path>` when this app is open to guests, so the sign-in card can
|
||||
* offer a guest session rather than a dead end. 404 (the common case) leaves it
|
||||
* undefined. */
|
||||
let guestAppPath: string | undefined = $state(undefined)
|
||||
|
||||
function parseSecret(secret: string): { secret: string; jwt: string | undefined } {
|
||||
const parts = secret.split('/')
|
||||
@@ -83,6 +87,20 @@
|
||||
} else {
|
||||
notExists = true
|
||||
}
|
||||
await loadGuestEntry()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGuestEntry() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +124,7 @@
|
||||
{notExists}
|
||||
{noPermission}
|
||||
{jwtError}
|
||||
{guestAppPath}
|
||||
onLoginSuccess={() => loadApp()}
|
||||
></PublicApp>
|
||||
{/snippet}
|
||||
|
||||
Reference in New Issue
Block a user