mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
feat: workspace protection rule to restrict anonymous app deployment (#9509)
* feat: restrict anonymous app execution mode to admins Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: add regression test for anonymous app admin gate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: gate anonymous app mode behind workspace protection rule Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: lock app row on anonymous-mode check, fail closed while rules load Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7fc5340da3
commit
cf9ad54181
+23
@@ -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"
|
||||
}
|
||||
@@ -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<Postgres>) -> 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(())
|
||||
}
|
||||
@@ -28245,6 +28245,7 @@ components:
|
||||
- DisableDirectDeployment
|
||||
- DisableWorkspaceForking
|
||||
- RestrictDeployToDeployers
|
||||
- RestrictAnonymousAppDeployment
|
||||
RuleBypasserGroups:
|
||||
type: array
|
||||
description: Groups that can bypass this ruleset
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
<h2>Public URL</h2>
|
||||
|
||||
<div class="my-6">
|
||||
{#if rulesetsLoaded && !canSetAnonymous}
|
||||
<Alert type="warning" title="Restricted by a workspace protection rule" size="xs">
|
||||
Making this app publicly accessible without login is restricted to workspace admins and
|
||||
bypass users by a workspace protection rule
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
<Toggle
|
||||
options={{
|
||||
@@ -264,7 +282,7 @@
|
||||
policy.execution_mode = e.detail ? 'anonymous' : 'publisher'
|
||||
setPublishState()
|
||||
}}
|
||||
disabled={!savedApp}
|
||||
disabled={!savedApp || (!canSetAnonymous && policy.execution_mode != 'anonymous')}
|
||||
/>
|
||||
</div>
|
||||
{#if !savedApp}
|
||||
|
||||
@@ -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<string[]>(
|
||||
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<string[]>(
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restrict anonymous app deployment -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
bind:checked={restrictAnonymousAppDeployment}
|
||||
options={{
|
||||
right: 'Restrict public app access'
|
||||
}}
|
||||
/>
|
||||
<div class="text-xs text-secondary ml-6">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user