diff --git a/backend/.sqlx/query-3e82929b365a6aa7ccc39fc5615c4110e1d740e0cb0509b1c58bc6636d83cafd.json b/backend/.sqlx/query-3e82929b365a6aa7ccc39fc5615c4110e1d740e0cb0509b1c58bc6636d83cafd.json new file mode 100644 index 0000000000..c4d6928bc5 --- /dev/null +++ b/backend/.sqlx/query-3e82929b365a6aa7ccc39fc5615c4110e1d740e0cb0509b1c58bc6636d83cafd.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT policy->>'execution_mode' = 'anonymous' FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3e82929b365a6aa7ccc39fc5615c4110e1d740e0cb0509b1c58bc6636d83cafd" +} diff --git a/backend/tests/app_anonymous_execution_mode.rs b/backend/tests/app_anonymous_execution_mode.rs new file mode 100644 index 0000000000..fd84a0eb42 --- /dev/null +++ b/backend/tests/app_anonymous_execution_mode.rs @@ -0,0 +1,279 @@ +//! Test for the `RestrictAnonymousAppDeployment` workspace protection rule. +//! +//! By default (no rule), any user with write access can deploy an app with +//! `execution_mode: anonymous` (no login required) — the historical +//! behavior. When a workspace protection ruleset enables +//! `RestrictAnonymousAppDeployment`, only workspace admins and the +//! ruleset's bypass users/groups can create an anonymous app or flip an +//! existing app to anonymous. To avoid breaking existing workflows, +//! restricted users can still redeploy an app that is already anonymous +//! (no exposure change) and can downgrade it back to `publisher` +//! (exposure reduction). +//! +//! Users from the `base` fixture: +//! test-user (admin, token SECRET_TOKEN) +//! test-user-2 (non-admin, token SECRET_TOKEN_2) + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::invalidate_protection_rules_cache; +use windmill_test_utils::*; + +const ADMIN_TOKEN: &str = "SECRET_TOKEN"; +const USER_TOKEN: &str = "SECRET_TOKEN_2"; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +fn app_payload(path: &str, execution_mode: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "Test app", + "value": {}, + "policy": { "execution_mode": execution_mode, "triggerables": {} } + }) +} + +fn policy_update(execution_mode: &str) -> serde_json::Value { + json!({ + "policy": { "execution_mode": execution_mode, "triggerables": {} } + }) +} + +/// Single test to avoid interference through the process-global protection +/// rules cache (keyed by workspace id, shared across parallel tests). +#[sqlx::test(fixtures("base"))] +async fn test_restrict_anonymous_app_deployment_rule(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + invalidate_protection_rules_cache("test-workspace"); + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + // ======================================== + // 1. Default behavior (no rule): a non-admin can create an anonymous + // app and flip an app to anonymous, as before. + // ======================================== + + let resp = authed(client().post(format!("{ws}/apps/create")), USER_TOKEN) + .json(&app_payload("u/test-user-2/anon_default", "anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "without the rule, non-admin creating an anonymous app must succeed: {}", + resp.text().await? + ); + + let resp = authed(client().post(format!("{ws}/apps/create")), USER_TOKEN) + .json(&app_payload("u/test-user-2/test_app", "publisher")) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "without the rule, non-admin flipping an app to anonymous must succeed: {}", + resp.text().await? + ); + + // back to publisher for the gated scenarios below + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("publisher")) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + // ======================================== + // 2. Admin enables the RestrictAnonymousAppDeployment rule. + // ======================================== + + let resp = authed( + client().post(format!("{ws}/workspaces/protection_rules")), + ADMIN_TOKEN, + ) + .json(&json!({ + "name": "no-public-apps", + "rules": ["RestrictAnonymousAppDeployment"], + "bypass_users": [], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "admin should create the protection rule: {}", + resp.text().await? + ); + + // ======================================== + // 3. With the rule, a non-admin cannot create an anonymous app... + // ======================================== + + let resp = authed(client().post(format!("{ws}/apps/create")), USER_TOKEN) + .json(&app_payload("u/test-user-2/anon_blocked", "anonymous")) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 403, + "with the rule, non-admin creating an anonymous app must be rejected: {body}" + ); + assert!( + body.contains("no-public-apps"), + "error should name the blocking ruleset, got: {body}" + ); + + // ... nor flip an existing app to anonymous ... + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "with the rule, non-admin flipping an app to anonymous must be rejected" + ); + + // ... while a publisher create still works. + let resp = authed(client().post(format!("{ws}/apps/create")), USER_TOKEN) + .json(&app_payload("u/test-user-2/pub_ok", "publisher")) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + + // ======================================== + // 4. An admin is never blocked by the rule. + // ======================================== + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + ADMIN_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "admin flipping an app to anonymous must succeed: {}", + resp.text().await? + ); + + // ======================================== + // 5. A restricted user can still redeploy an app that is already + // anonymous — keeping it anonymous does not change its exposure, and + // blocking it would prevent non-admin editors from deploying public + // apps at all (the frontend always sends the full policy on deploy). + // ======================================== + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&json!({ + "value": { "edited": true }, + "policy": { "execution_mode": "anonymous", "triggerables": {} } + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "restricted user redeploying an already-anonymous app must succeed: {}", + resp.text().await? + ); + + // ======================================== + // 6. A restricted user can downgrade the app back to publisher + // (reduces exposure), but cannot re-flip it to anonymous. + // ======================================== + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("publisher")) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "restricted user downgrading anonymous to publisher must succeed: {}", + resp.text().await? + ); + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "restricted user re-flipping to anonymous must be rejected" + ); + + // ======================================== + // 7. Bypass users are exempt from the rule. + // ======================================== + + let resp = authed( + client().post(format!("{ws}/workspaces/protection_rules/no-public-apps")), + ADMIN_TOKEN, + ) + .json(&json!({ + "rules": ["RestrictAnonymousAppDeployment"], + "bypass_users": ["test-user-2"], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "admin should update the protection rule: {}", + resp.text().await? + ); + + let resp = authed( + client().post(format!("{ws}/apps/update/u/test-user-2/test_app")), + USER_TOKEN, + ) + .json(&policy_update("anonymous")) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "bypass user flipping an app to anonymous must succeed: {}", + resp.text().await? + ); + + Ok(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index a286cce5e4..cce0a3e8c6 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -28245,6 +28245,7 @@ components: - DisableDirectDeployment - DisableWorkspaceForking - RestrictDeployToDeployers + - RestrictAnonymousAppDeployment 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 de55cb87ff..a8c2d3805b 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -65,7 +65,9 @@ use windmill_common::{ }, variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, worker::{to_raw_value, CLOUD_HOSTED}, - workspaces::{check_deploy_rules, RuleCheckResult}, + workspaces::{ + check_deploy_rules, check_user_against_rule, ProtectionRuleKind, RuleCheckResult, + }, HUB_BASE_URL, }; #[cfg(feature = "parquet")] @@ -1348,6 +1350,20 @@ async fn create_app_internal<'a>( )); } } + if matches!(app.policy.execution_mode, ExecutionMode::Anonymous) { + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + w_id, + &ProtectionRuleKind::RestrictAnonymousAppDeployment, + &authed.username, + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + } // CLI / git-sync deploys ask us to preserve any existing user draft at this // path instead of wiping it as part of the deploy. if !app.skip_draft_deletion.unwrap_or(false) { @@ -1866,6 +1882,37 @@ async fn update_app_internal<'a>( } if let Some(mut npolicy) = ns.policy { + 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. + // FOR UPDATE locks the row until this transaction's policy + // UPDATE commits, so a concurrent admin downgrade cannot be + // silently overwritten by a stale redeploy keeping anonymous. + let already_anonymous = sqlx::query_scalar!( + "SELECT policy->>'execution_mode' = 'anonymous' FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE", + path, + w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten() + .unwrap_or(false); + if !already_anonymous { + if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + w_id, + &ProtectionRuleKind::RestrictAnonymousAppDeployment, + &authed.username, + &authed.groups, + authed.is_admin, + &db, + ) + .await? + { + return Err(Error::PermissionDenied(msg)); + } + } + } let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false) && windmill_common::can_preserve_on_behalf_of(&authed) && npolicy.on_behalf_of.is_some(); diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index e20a896103..e719891a69 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -68,6 +68,7 @@ bitflags::bitflags! { const DISABLE_DIRECT_DEPLOYMENT = 1 << 0; const DISABLE_WORKSPACE_FORKING = 1 << 1; const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2; + const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3; } } @@ -78,6 +79,7 @@ pub enum ProtectionRuleKind { DisableDirectDeployment, DisableWorkspaceForking, RestrictDeployToDeployers, + RestrictAnonymousAppDeployment, } impl ProtectionRuleKind { @@ -92,6 +94,9 @@ impl ProtectionRuleKind { ProtectionRuleKind::RestrictDeployToDeployers => { ProtectionRules::RESTRICT_DEPLOY_TO_DEPLOYERS } + ProtectionRuleKind::RestrictAnonymousAppDeployment => { + ProtectionRules::RESTRICT_ANONYMOUS_APP_DEPLOYMENT + } } } @@ -104,6 +109,9 @@ impl ProtectionRuleKind { ProtectionRuleKind::RestrictDeployToDeployers => { "Only workspace admins and members of wm_deployers can deploy to this workspace" } + ProtectionRuleKind::RestrictAnonymousAppDeployment => { + "Making an app publicly accessible without login (anonymous execution mode) is restricted in this workspace" + } } } } diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 2294554f3e..a71dda4a90 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -18,6 +18,7 @@ import OnBehalfOfSelector, { type OnBehalfOfChoice } from '$lib/components/OnBehalfOfSelector.svelte' + import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte' const WM_DEPLOYERS_GROUP = 'wm_deployers' @@ -60,6 +61,16 @@ } = $props() let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) + // Admins always pass the backend check. For everyone else, fail closed + // while the workspace protection rules are still loading so the toggle + // is never briefly enabled for a user the rules will end up restricting. + let rulesetsLoaded = $derived(protectionRulesState.rulesets !== undefined) + let canSetAnonymous = $derived( + !!$userStore?.is_admin || + !!$userStore?.is_super_admin || + (rulesetsLoaded && + canUserBypassRuleKind('RestrictAnonymousAppDeployment', $userStore ?? undefined)) + ) 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) @@ -253,6 +264,13 @@

Public URL

+ {#if rulesetsLoaded && !canSetAnonymous} + + Making this app publicly accessible without login is restricted to workspace admins and + bypass users by a workspace protection rule + +
+ {/if}
{#if !savedApp} diff --git a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte index ab1e080264..6a71f63c44 100644 --- a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte +++ b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte @@ -37,6 +37,7 @@ let disableDirectDeployment = $state(hasRule('DisableDirectDeployment')) let disableFork = $state(hasRule('DisableWorkspaceForking')) let restrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers')) + let restrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment')) let selectedGroups = $state( untrack(() => rule)?.bypass_groups?.map((g) => g.replace('g/', '')) ?? [] ) @@ -49,6 +50,7 @@ let initialDisableDirectDeployment = $state(hasRule('DisableDirectDeployment')) let initialDisableFork = $state(hasRule('DisableWorkspaceForking')) let initialRestrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers')) + let initialRestrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment')) let initialSelectedGroups = $state( untrack(() => rule)?.bypass_groups ? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', '')) @@ -115,12 +117,14 @@ disableDirectDeployment || disableFork || restrictDeployToDeployers || + restrictAnonymousAppDeployment || selectedGroups.length > 0 || selectedUsers.length > 0 : name !== initialName || disableDirectDeployment !== initialDisableDirectDeployment || disableFork !== initialDisableFork || restrictDeployToDeployers !== initialRestrictDeployToDeployers || + restrictAnonymousAppDeployment !== initialRestrictAnonymousAppDeployment || JSON.stringify([...selectedGroups].sort()) !== JSON.stringify([...initialSelectedGroups].sort()) || JSON.stringify([...selectedUsers].sort()) !== @@ -160,6 +164,9 @@ ...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []), ...(restrictDeployToDeployers ? ['RestrictDeployToDeployers' as ProtectionRuleKind] + : []), + ...(restrictAnonymousAppDeployment + ? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind] : []) ], bypass_groups: selectedGroups, @@ -188,6 +195,9 @@ ...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []), ...(restrictDeployToDeployers ? ['RestrictDeployToDeployers' as ProtectionRuleKind] + : []), + ...(restrictAnonymousAppDeployment + ? ['RestrictAnonymousAppDeployment' as ProtectionRuleKind] : []) ], bypass_groups: selectedGroups, @@ -202,6 +212,7 @@ initialDisableDirectDeployment = disableDirectDeployment initialDisableFork = disableFork initialRestrictDeployToDeployers = restrictDeployToDeployers + initialRestrictAnonymousAppDeployment = restrictAnonymousAppDeployment initialSelectedGroups = clone(selectedGroups) initialSelectedUsers = clone(selectedUsers) @@ -335,6 +346,20 @@ Non-deployers can still fork, browse, and request a review.
+ + +
+ +
+ Only workspace admins and bypass users can make an app publicly accessible without login + (anonymous execution mode). Apps that are already public can still be redeployed. +
+