diff --git a/backend/.sqlx/query-00a61afc5faa3826c283660417ff1f8a93060fe062a0b727f164329ab56387a2.json b/backend/.sqlx/query-00a61afc5faa3826c283660417ff1f8a93060fe062a0b727f164329ab56387a2.json new file mode 100644 index 0000000000..a6a2b0c00a --- /dev/null +++ b/backend/.sqlx/query-00a61afc5faa3826c283660417ff1f8a93060fe062a0b727f164329ab56387a2.json @@ -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" +} diff --git a/backend/.sqlx/query-02bf6098743be1cc61b72747951fded5dad1fb16b10f1ba27a580261bb9d050d.json b/backend/.sqlx/query-02bf6098743be1cc61b72747951fded5dad1fb16b10f1ba27a580261bb9d050d.json new file mode 100644 index 0000000000..9dad2ec26c --- /dev/null +++ b/backend/.sqlx/query-02bf6098743be1cc61b72747951fded5dad1fb16b10f1ba27a580261bb9d050d.json @@ -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" +} diff --git a/backend/.sqlx/query-03b0658a1bc3e2d8831055d336799eca4766ec4f61ae1528e3481fa57c0b3464.json b/backend/.sqlx/query-03b0658a1bc3e2d8831055d336799eca4766ec4f61ae1528e3481fa57c0b3464.json new file mode 100644 index 0000000000..6a616c93b5 --- /dev/null +++ b/backend/.sqlx/query-03b0658a1bc3e2d8831055d336799eca4766ec4f61ae1528e3481fa57c0b3464.json @@ -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" +} diff --git a/backend/.sqlx/query-126f72d28d1bfcea764311f3aedb2d1bbe51c9d5ba2cd12890de867ac51573ea.json b/backend/.sqlx/query-126f72d28d1bfcea764311f3aedb2d1bbe51c9d5ba2cd12890de867ac51573ea.json new file mode 100644 index 0000000000..1e397d4f9e --- /dev/null +++ b/backend/.sqlx/query-126f72d28d1bfcea764311f3aedb2d1bbe51c9d5ba2cd12890de867ac51573ea.json @@ -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" +} diff --git a/backend/.sqlx/query-21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe.json b/backend/.sqlx/query-21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe.json new file mode 100644 index 0000000000..ca9f2cbc29 --- /dev/null +++ b/backend/.sqlx/query-21e9629bdf5824b676bf88709f4fe0d9644b8d6a08d0c73daac73c48b5933afe.json @@ -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" +} diff --git a/backend/.sqlx/query-76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477.json b/backend/.sqlx/query-76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477.json new file mode 100644 index 0000000000..ebd492abf3 --- /dev/null +++ b/backend/.sqlx/query-76c0331b18eed478a50572e35642909be7dc7eb9b6deac7ea439eb637d728477.json @@ -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" +} diff --git a/backend/.sqlx/query-8b28332dd5b3932dfdaa9fcb2e3eb6b9c48ec164b05b149b3477351df7a1bd60.json b/backend/.sqlx/query-8b28332dd5b3932dfdaa9fcb2e3eb6b9c48ec164b05b149b3477351df7a1bd60.json new file mode 100644 index 0000000000..8533137a2c --- /dev/null +++ b/backend/.sqlx/query-8b28332dd5b3932dfdaa9fcb2e3eb6b9c48ec164b05b149b3477351df7a1bd60.json @@ -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" +} diff --git a/backend/.sqlx/query-cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2.json b/backend/.sqlx/query-cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2.json new file mode 100644 index 0000000000..9ff8b3ace5 --- /dev/null +++ b/backend/.sqlx/query-cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM guest_activity WHERE day < CURRENT_DATE - 60", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "cf0c44d83ec921d104bee9cbdb7acd7ec38166533d217b718b294826889145f2" +} diff --git a/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json b/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json new file mode 100644 index 0000000000..1c247ad5b5 --- /dev/null +++ b/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json @@ -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" +} diff --git a/backend/migrations/20260901192755_guest_app_access.down.sql b/backend/migrations/20260901192755_guest_app_access.down.sql new file mode 100644 index 0000000000..065e60c0c8 --- /dev/null +++ b/backend/migrations/20260901192755_guest_app_access.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS guest_activity; +ALTER TABLE workspace_settings DROP COLUMN guest_access_enabled; diff --git a/backend/migrations/20260901192755_guest_app_access.up.sql b/backend/migrations/20260901192755_guest_app_access.up.sql new file mode 100644 index 0000000000..a29582ba99 --- /dev/null +++ b/backend/migrations/20260901192755_guest_app_access.up.sql @@ -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); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index ce09cebe71..4627bd4c38 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -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) { 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 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) { } } - async fn resolve_license_key_value(conn: &Connection, quiet: bool) -> anyhow::Result { 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( 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 diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index dfd0305e57..fe889705e4 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -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) diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs new file mode 100644 index 0000000000..ba356ea0f0 --- /dev/null +++ b/backend/tests/app_guest_execution_mode.rs @@ -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, 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) -> 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) -> 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:` 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, +) -> 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(()) +} diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index bef59aea6c..c6a824c07d 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -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 { diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 05de1183cd..a74f1653f5 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -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 diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 47b520736a..4c8bf1098d 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -745,7 +745,31 @@ async fn whoami( Path(w_id): Path, authed: ApiAuthed, ) -> JsonResult { + 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::().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 { + 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:` 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 { + 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) } diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 2aa59b20ff..b0282cc451 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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, 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, #[serde(skip_serializing_if = "Option::is_none")] pub mute_critical_alerts: Option, + /// 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(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, + Path(w_id): Path, + Json(EditGuestAccess { guest_access_enabled }): Json, +) -> Result { + 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, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3daa0b6133..fea84d9322 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 0d29c1c369..178dad95f3 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -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; #[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 { + 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, +) -> Result { + 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, +) -> 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::(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, + Path((w_id, secret)): Path<(String, String)>, +) -> JsonResult { + 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 { 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()) diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index 0d5ebdc2c3..a887db80ba 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -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 diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index c1d8fc00bc..4025220a67 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -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 { + 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. /// diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index ce5323fe2b..04738453d0 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -1059,7 +1059,10 @@
  • instance base URL
  • login type usage (login type, count)
  • worker usage (worker, worker instance, vCPUs, memory)
  • -
  • user usage (author count, operator count)
  • +
  • user usage (author count, operator count, guest sign-in count and the workspaces + that allow guests)
  • superadmin email addresses
  • development instance status
  • @@ -1119,7 +1122,10 @@
  • job usage (language, total duration, count)
  • login type usage (login type, count)
  • worker usage (worker, worker instance, vCPUs, memory)
  • -
  • user usage (author count, operator count)
  • +
  • user usage (author count, operator count, guest sign-in count and the workspaces + that allow guests)
  • development instance status
  • feature usage (counts of which product features are used, including AI provider and diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 10f996a33f..1534fbeab0 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -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 + /** `/` 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) { diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index f673711159..27e8b8a1bf 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -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} -

    Public URL

    +

    Access

    - {#if rulesetsLoaded && !canSetAnonymous} + {#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'} - 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 + +
    + {/if} + {#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest'} + + Opening this app to guests is restricted to workspace admins and bypass users by a workspace + protection rule
    {/if}
    - { - 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')} - /> + onAccessModeChange(e.detail)} + disabled={!savedApp} + > + {#snippet children({ item })} + + + + {/snippet} + +
    +
    + {#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}
    {#if !savedApp || newApp} diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index c0cf693e0f..6523f13ffa 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -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 @@
    {:else if noPermission} -
    This app requires read access
    -
    - {#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 {effectiveWorkspace} this app is part of - {:else}You must be logged in and have read access to this app{/if}
    + {#if guestAppPath && !$userStore} +
    Sign in to open this app
    +
    + You do not need a Windmill account. Signing in lets you open this app and nothing else. +
    + {:else} +
    + This app requires read access +
    +
    + {#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 {effectiveWorkspace} this app is part of + {:else}You must be logged in and have read access to this app{/if}
    + {/if}
    {#if !jwtError} - + {/if}
    {:else if app} diff --git a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte index be6a240c19..eefc1d5647 100644 --- a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte +++ b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte @@ -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( 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( 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 @@ + +
    + +
    + 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. +
    +
    +
    { + 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 { @@ -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( executions per minute per server + + + + /` 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()} > {/snippet}