feat: gate forking behind a role capability

Forking becomes the `fork` capability of a workspace role: `developer` holds it, `operator` and
other roles do not until an admin grants it per role, in the instance roles or a workspace
override. Both fork endpoints refuse a role without it; until now only the frontend hid the
entry. A member who forks is a developer in the fork. A role without it keeps a disabled
"Workspace fork" entry in the workspace picker explaining how to get it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-22 06:57:11 -07:00
co-authored by Claude Opus 5.5
parent 34de8bc06d
commit 8bba9e92f9
21 changed files with 222 additions and 86 deletions
+1 -1
View File
@@ -1 +1 @@
c8d562aa9d75a7e99bf0ecd1ac224f532452a07e
f469d1cde99e59120c85f9ebd791d2aa044a4259
@@ -0,0 +1,58 @@
//! Its own test binary: the roles cache is process-wide and keyed by workspace id, so a sibling
//! test overriding roles in its own `test-workspace` could leave this one reading stale ones.
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// An operator cannot fork until their role is granted `fork`, and the fork they then create makes
/// them a developer, not an operator.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_operator_fork_needs_capability_and_develops_in_fork(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base_url = format!(
"http://localhost:{}/api/w/test-workspace/workspaces",
server.addr.port()
);
let client = reqwest::Client::new();
sqlx::query(
"UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'",
)
.execute(&db)
.await?;
let fork = || {
client
.post(format!("{base_url}/create_fork"))
.header("Authorization", "Bearer SECRET_TOKEN_3")
.json(&json!({ "id": "wm-fork-op", "name": "Operator fork" }))
.send()
};
let resp = fork().await?;
assert_eq!(resp.status(), 403, "{}", resp.text().await?);
let resp = client
.post(format!("{base_url}/role_overrides"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({ "operator": { "capabilities": { "fork": true } } }))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
let resp = fork().await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
let (operator, is_admin, role): (bool, bool, Option<String>) = sqlx::query_as(
"SELECT operator, is_admin, workspace_role FROM usr WHERE workspace_id = 'wm-fork-op' AND username = 'test-user-3'",
)
.fetch_one(&db)
.await?;
assert!(!operator && !is_admin && role.is_none());
Ok(())
}
@@ -9009,6 +9009,33 @@ async fn deprecated_create_workspace_fork(_authed: ApiAuthed) -> Result<String>
return Err(Error::BadRequest("This API endpoint has been relocated. Your Windmill CLI version is outdated and needs to be updated.".to_string()));
}
async fn check_can_fork(db: &DB, authed: &ApiAuthed, w_id: &str) -> Result<()> {
if *DISABLE_WORKSPACE_FORK {
require_super_admin(db, authed).await?;
}
if !windmill_common::roles::caller_capabilities(db, w_id, authed)
.await?
.fork
{
return Err(Error::PermissionDenied(
"Your role cannot fork this workspace".to_string(),
));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
w_id,
&ProtectionRuleKind::DisableWorkspaceForking,
AuditAuthorable::username(authed),
&authed.groups,
authed.is_admin,
db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
Ok(())
}
/// Return the uuids of the git sync jobs to create the branch before creating the fork
async fn create_workspace_fork_branch(
authed: ApiAuthed,
@@ -9024,21 +9051,7 @@ async fn create_workspace_fork_branch(
enforce_cloud_fork_cap(&db, &w_id).await?;
}
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed).await?;
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableWorkspaceForking,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
check_can_fork(&db, &authed, &w_id).await?;
enforce_isolated_forks(&db, &authed, &w_id, &nw).await?;
// Two-phase create for git-synced workspaces: this endpoint only creates the git branch(es) and
@@ -9775,21 +9788,7 @@ async fn create_workspace_fork(
#[cfg(not(feature = "enterprise"))]
_check_nb_of_workspaces(&db).await?;
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed).await?;
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&parent_workspace_id,
&ProtectionRuleKind::DisableWorkspaceForking,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
check_can_fork(&db, &authed, &parent_workspace_id).await?;
enforce_isolated_forks(&db, &authed, &parent_workspace_id, &nw).await?;
if nw.is_dev_workspace {
@@ -10040,6 +10039,7 @@ async fn make_workspace_fork(
.filter(|c| c.replayed)
.map(|c| c.behavior)
.collect();
let restricted_fork = authed.is_operator && !authed.is_admin;
match write_workspace_fork(
db.clone(),
authed,
@@ -10062,6 +10062,9 @@ async fn make_workspace_fork(
},
);
}
if restricted_fork {
windmill_common::feature_usage::log_feature_usage("operator_fork", "forked", "");
}
Ok(message)
}
// A commit whose acknowledgement was lost can still have committed, and a concurrent
@@ -10442,7 +10445,11 @@ async fn write_workspace_fork(
}
// Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent
// member). No-op when copy_members already brought their full row.
// member). No-op when copy_members already brought their full row. `workspace_role` and
// `operator` are left at their defaults on purpose: a restricted role granted `fork` is a
// developer in the fork, including over the parent's kept data tables and shared object storage
// and ducklakes, which the fork reaches directly; only item deploys back run with their parent
// role and are refused.
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin)
+5 -1
View File
@@ -38894,9 +38894,12 @@ components:
type: boolean
manage_triggers:
type: boolean
fork:
type: boolean
description: Create a fork of the workspace. The forker is a developer in the fork whatever the role holds.
visibility:
$ref: "#/components/schemas/RoleVisibility"
required: [code, flows, apps, manage_schedules, manage_triggers, visibility]
required: [code, flows, apps, manage_schedules, manage_triggers, fork, visibility]
RoleCapabilitiesPatch:
type: object
@@ -38909,6 +38912,7 @@ components:
$ref: "#/components/schemas/AuthorLevel"
manage_schedules: { type: boolean }
manage_triggers: { type: boolean }
fork: { type: boolean }
visibility:
type: object
additionalProperties:
+12
View File
@@ -92,6 +92,9 @@ pub struct RoleCapabilities {
pub apps: AuthorLevel,
pub manage_schedules: bool,
pub manage_triggers: bool,
/// Create a fork of the workspace. The forker is a developer in the fork whatever this role
/// holds, so granting it to a restricted role hands that member a full-rights copy.
pub fork: bool,
pub visibility: RoleVisibility,
}
@@ -102,6 +105,7 @@ impl RoleCapabilities {
apps: AuthorLevel::Full,
manage_schedules: true,
manage_triggers: true,
fork: true,
visibility: RoleVisibility::ALL,
};
@@ -113,6 +117,7 @@ impl RoleCapabilities {
apps: AuthorLevel::None,
manage_schedules: true,
manage_triggers: true,
fork: false,
visibility: RoleVisibility::ALL,
};
@@ -124,6 +129,7 @@ impl RoleCapabilities {
apps: AuthorLevel::None,
manage_schedules: false,
manage_triggers: false,
fork: false,
visibility: RoleVisibility::NONE,
};
@@ -180,6 +186,9 @@ impl RoleCapabilities {
if let Some(v) = patch.manage_triggers {
self.manage_triggers = v;
}
if let Some(v) = patch.fork {
self.fork = v;
}
if let Some(v) = &patch.visibility {
v.apply(&mut self.visibility);
}
@@ -213,6 +222,8 @@ pub struct RoleCapabilitiesPatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manage_triggers: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<RoleVisibilityPatch>,
}
@@ -279,6 +290,7 @@ pub fn diff_capabilities(
apps: d(base.apps, target.apps),
manage_schedules: d(base.manage_schedules, target.manage_schedules),
manage_triggers: d(base.manage_triggers, target.manage_triggers),
fork: d(base.fork, target.fork),
visibility: (visibility != RoleVisibilityPatch::default()).then_some(visibility),
}
}
+3 -2
View File
@@ -4,10 +4,11 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
It currently carries 62 registered actions across twenty-one features (`ai_session`, `ai_chat`,
It currently carries 64 registered actions across twenty-three features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_decision`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`,
`flow_run`, `flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`,
`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the
`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`, `job_token_scopes`,
`operator_fork`). Nearly all of the
product is uninstrumented, so new user-facing work is the opportunity to change that.
## When to instrument
+22
View File
@@ -122,3 +122,25 @@ their behaviour is unchanged.
Do not model a right that an admin *grants* on these. Granted-by-default and granted-on-request
are opposite polarities, and a gate written for one is wrong for the other.
## Forking is a role capability
Forking is the `fork` capability of a role (`docs/workspace-roles.md`): `developer` holds it,
`operator` and any other role does not until an admin grants it, per role, in the instance roles
or a workspace override. `check_can_fork` refuses a caller whose role lacks it on both
`create_fork` and `create_workspace_fork_branch`, before `DisableWorkspaceForking`, which still
restricts everyone it does not exempt. `canCreateFork` in `frontend/src/lib/utils/editInFork.ts`
mirrors it.
A member who forks is a developer in the fork whatever their role: the creator row copies only
`is_admin`, so `workspace_role` stays NULL and `operator` keeps its false default. That is the
point of granting `fork`, a copy to build in, and it means granting it to a restricted role grants
that role arbitrary code execution. Deploying scripts, flows and apps back runs with the user's
role in the parent, where its write checks refuse what the role cannot author, so an admin or a
`wm_deployers` member deploys them instead. Data is not behind that check: a data table kept on
fork (`KeepOriginal`, the default) is a pointer at the parent's database, and a shared object
storage or ducklake writes the parent's, so code in the fork reads and writes the parent's data
directly. The capability's help text says so; an admin who grants it accepts it, or also turns on
the `RequireIsolatedForks` ruleset rule (`docs/fork-object-storage.md`), which binds restricted
roles too. The developer row also costs a seat: the self-hosted seat count takes a user who is a
developer in any workspace, forks included, as a full seat.
+1
View File
@@ -19,6 +19,7 @@ code is `windmill_common::roles`.
| `flows` | `none` / `compose` / `full` | yes |
| `apps` | `none` / `compose` / `full` | yes; low-code apps and worker-built apps need `full` |
| `manage_schedules`, `manage_triggers` | bool | yes, see `docs/operator-write-rights.md` |
| `fork` | bool | yes: both fork endpoints; the forker is a developer in the fork, see `docs/operator-write-rights.md` |
| `visibility.*` | bool per page | no, UI only |
`compose` is the former operator builder right: the composition-only checks of
@@ -159,7 +159,7 @@
</Button>
</div>
{/if}
{#if $roleCapabilities.code && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)}
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)}
<EditInForkButton itemType={app.raw_app ? 'raw_app' : 'app'} path={app.path} />
{/if}
</span>
@@ -239,10 +239,7 @@
// No `href`: the handler resolves the destination asynchronously, and a melt
// menu item's anchor navigates before a delegated onclick can preventDefault it.
action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path),
hide:
!$roleCapabilities.code ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
hide: isCloudHosted() || !editInForkAllowed($workspaceStore, $userWorkspaces)
},
{
displayName: 'Move/Rename',
@@ -189,8 +189,7 @@
</Button>
</div>
{/if}
<!-- Hidden from every role without code, flow composers included. -->
{#if $roleCapabilities.code && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
<EditInForkButton itemType="flow" path={flow.path} />
{/if}
</span>
@@ -261,10 +260,7 @@
// No `href`: the handler resolves the destination asynchronously, and a melt
// menu item's anchor navigates before a delegated onclick can preventDefault it.
action: (e) => onEditInForkClick(e, 'flow', path),
hide:
!$roleCapabilities.code ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
hide: isCloudHosted() || !editInForkAllowed($workspaceStore, $userWorkspaces)
},
{
displayName: 'Audit logs',
@@ -253,9 +253,9 @@
</div>
{/if}
{/if}
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !script.canWrite)}
<EditInForkButton itemType="script" path={script.path} />
{/if}
{/if}
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !script.canWrite)}
<EditInForkButton itemType="script" path={script.path} />
{/if}
</span>
<Dropdown
@@ -347,10 +347,7 @@
// No `href`: the handler resolves the destination asynchronously, and a melt
// menu item's anchor navigates before a delegated onclick can preventDefault it.
action: (e) => onEditInForkClick(e, 'script', script.path),
hide:
!$roleCapabilities.code ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
hide: isCloudHosted() || !editInForkAllowed($workspaceStore, $userWorkspaces)
},
{
displayName: 'Move/Rename',
@@ -421,7 +421,7 @@
})
}
if (flow && caps.code && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) {
if (flow && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) {
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('flow', workspace, $userWorkspaces),
@@ -573,7 +573,7 @@
})
}
if (script && caps.code && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) {
if (script && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) {
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('script', workspace, $userWorkspaces),
@@ -771,8 +771,7 @@
)
const canEditInFork = $derived(
(job?.job_kind === 'script' || job?.job_kind === 'flow') &&
editRights.capabilities.code &&
!showEditButton &&
(!showEditButton || !editRights.capabilities.code) &&
!isCloudHosted() &&
editInForkAllowed(workspaceId, $userWorkspaces)
)
@@ -46,7 +46,7 @@
const effective = $derived(applyCapabilitiesPatch(base, patch))
const error = $derived(capabilitiesError(effective))
type BoolKey = 'code' | 'manage_schedules' | 'manage_triggers'
type BoolKey = 'code' | 'manage_schedules' | 'manage_triggers' | 'fork'
type LevelKey = 'flows' | 'apps'
function boolValue(path: string): boolean {
@@ -114,6 +114,12 @@
key: 'manage_triggers',
label: 'Manage triggers',
description: 'Create, edit and delete triggers where folder permissions allow.'
},
{
key: 'fork',
label: 'Fork the workspace',
description:
"The forker is a developer in the fork and costs a full seat. There they can run any code, including against this workspace's data tables and any object storage or ducklakes shared with the fork, unless a ruleset requires isolated forks. Deploying back still needs this role's rights."
}
]
</script>
@@ -41,7 +41,11 @@
findWorkspaceAncestors,
isForkOwner
} from '$lib/utils/workspaceHierarchy'
import { canCreateFork } from '$lib/utils/editInFork'
import {
canCreateFork,
OPERATOR_FORK_BLOCKED_REASON,
operatorForkBlocked
} from '$lib/utils/editInFork'
import { workspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
import DevWorkspaceBadge from '$lib/components/DevWorkspaceBadge.svelte'
@@ -161,7 +165,14 @@
// modal carries its own base-workspace picker). Hidden on non-premium cloud,
// in the admins workspace, or when forking is disabled.
const canForkHere = $derived(
(!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore, $roleCapabilities)
(!isCloudHosted() || $maybePremium) &&
$workspaceStore !== 'admins' &&
canCreateFork($userStore, $roleCapabilities)
)
const forkBlockedHere = $derived(
(!isCloudHosted() || $maybePremium) &&
$workspaceStore !== 'admins' &&
operatorForkBlocked($roleCapabilities)
)
// The row mechanics below — which family to expand, which collapsed row carries the tick — key on
// a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the
@@ -572,7 +583,7 @@
</div>
{/each}
</div>
{#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect}
{#if (isCloudHosted() || $superadmin || canForkHere || forkBlockedHere) && !strictWorkspaceSelect}
<div class="py-1" role="none">
{#if isCloudHosted() || $superadmin}
<div role="none" data-nav-id={actionNavId('create')}>
@@ -593,6 +604,19 @@
Workspace fork
</MenuItem>
</div>
{:else if forkBlockedHere}
<Tooltip class="block w-full" placement="right" openDelay={200}>
<div
class="{itemClass} opacity-60 cursor-not-allowed hover:bg-transparent"
aria-disabled="true"
>
<Plus size={16} />
Workspace fork
</div>
{#snippet text()}
<span class="block max-w-64 text-xs">{OPERATOR_FORK_BLOCKED_REASON}</span>
{/snippet}
</Tooltip>
{/if}
</div>
{/if}
@@ -34,7 +34,7 @@
devWorkspacesInChainAbove
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import { capabilitiesIn, isRestricted } from '$lib/roleCapabilities'
import { capabilitiesIn } from '$lib/roleCapabilities'
import {
canUserBypassRuleKindInRulesets,
fetchProtectionRulesForWorkspace,
@@ -138,14 +138,13 @@
let forkableWorkspaces = $derived(forkable.current)
// Base candidates are the current workspace's family: its root first, then every fork/dev under it.
// Never one the user holds a restricted role in: a fork does not carry the role, so forking such a base
// would hand them a full-rights copy of it.
// Only bases whose role for the user holds `fork`: the server refuses the others.
let familyRoot = $derived(findWorkspaceRoot($workspaceStore, forkableWorkspaces))
let baseCandidates = $derived(
(familyRoot
? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, forkableWorkspaces)]
: []
).filter((w) => !isRestricted(capabilitiesIn(undefined, w)))
).filter((w) => capabilitiesIn(undefined, w).fork)
)
let baseItems = $derived(
baseCandidates.map((w) => ({
+3 -1
View File
@@ -32,6 +32,7 @@ export function legacyOperatorCapabilities(
apps: settings?.builder_apps === true ? 'compose' : 'none',
manage_schedules: settings?.manage_schedules !== false,
manage_triggers: settings?.manage_triggers !== false,
fork: false,
visibility
}
}
@@ -64,7 +65,8 @@ export function isRestricted(caps: RoleCapabilities): boolean {
caps.flows !== 'full' ||
caps.apps !== 'full' ||
!caps.manage_schedules ||
!caps.manage_triggers
!caps.manage_triggers ||
!caps.fork
)
}
+24 -14
View File
@@ -1,5 +1,4 @@
import { get } from 'svelte/store'
import { isRestricted } from '$lib/roleCapabilities'
import type { RoleCapabilities } from '$lib/gen'
import {
roleCapabilities,
@@ -22,17 +21,20 @@ export type ItemType = 'script' | 'flow' | 'app' | 'raw_app'
* Whether to show the "edit in fork / dev workspace" affordance. Allowed when the user may create a
* fork, OR when the current workspace has a canonical dev to route to — routing into an existing dev
* workspace creates no fork, so it survives a locked prod that has `DisableWorkspaceForking` set.
* Never for a role without `code`, which has nothing to edit at the other end either way. User
* identity is read non-reactively (it's stable within a session); reactivity comes from the
* workspace args.
* A role without `code` gets it only when it may fork and there is no canonical dev: a new fork
* makes the member a developer, but in an existing dev workspace they keep their role and have
* nothing to edit. User identity is read non-reactively (it's stable within a session); reactivity
* comes from the workspace args.
*/
export function editInForkAllowed(
currentWorkspaceId: string | undefined,
allWorkspaces: UserWorkspace[]
): boolean {
const caps = get(roleCapabilities)
if (!caps.code) return false
return canCreateFork(get(userStore), caps) || !!findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces)
const user = get(userStore)
const dev = findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces)
if (!caps.code) return canCreateFork(user, caps) && !dev
return canCreateFork(user, caps) || !!dev
}
/** Label for the affordance: "Edit in <dev name>" when routed to a canonical dev, else "Edit in fork". */
@@ -58,23 +60,31 @@ export function editInForkDescription(
}
/**
* Whether the user may CREATE a new fork of the current workspace: their role is unrestricted, and
* Whether the user may CREATE a new fork of the current workspace: their role holds `fork`, and
* either forking isn't disabled or they can bypass the rule (workspace admins). Keeps the "Fork
* workspace" entry available to admins as the last-resort escape hatch on a locked prod.
*
* Any restricted role is refused because a fork does not carry the role: the server writes the
* forker's membership row from the parent's `is_admin` alone, so the forker gets the default role
* and forking would hand a restricted member a full-rights copy of the workspace. The server does
* not refuse it: this hides the affordance, it does not enforce the rule.
* workspace" entry available to admins as the last-resort escape hatch on a locked prod. The server
* enforces both.
*/
export function canCreateFork(user: UserExt | undefined, caps: RoleCapabilities): boolean {
if (isRestricted(caps)) return false
if (!caps.fork) return false
return (
!isRuleActive('DisableWorkspaceForking') ||
canUserBypassRuleKind('DisableWorkspaceForking', user)
)
}
export const OPERATOR_FORK_BLOCKED_REASON =
'Your role cannot fork this workspace. A workspace admin can grant "Fork the workspace" to your role, under Workspace settings > Users > Workspace roles.'
/**
* Whether the only thing refusing this user a fork is their role lacking `fork`. The fork entries
* then stay visible, disabled, so the member learns an admin can grant it; when forking is disabled
* for everyone they stay hidden, as they are for developers.
*/
export function operatorForkBlocked(caps: RoleCapabilities): boolean {
return !caps.fork && !isRuleActive('DisableWorkspaceForking')
}
function editPathFor(itemType: ItemType, itemPath: string): string {
switch (itemType) {
case 'script':
+3
View File
@@ -48,6 +48,7 @@ export const FULL_CAPABILITIES: RoleCapabilities = {
apps: 'full',
manage_schedules: true,
manage_triggers: true,
fork: true,
visibility: ALL_VISIBLE
}
@@ -57,6 +58,7 @@ const OPERATOR_CAPABILITIES: RoleCapabilities = {
apps: 'none',
manage_schedules: true,
manage_triggers: true,
fork: false,
visibility: ALL_VISIBLE
}
@@ -86,6 +88,7 @@ export function applyCapabilitiesPatch(
if (patch.apps != null) out.apps = patch.apps
if (patch.manage_schedules != null) out.manage_schedules = patch.manage_schedules
if (patch.manage_triggers != null) out.manage_triggers = patch.manage_triggers
if (patch.fork != null) out.fork = patch.fork
for (const { key } of VISIBILITY_KEYS) {
const v = patch.visibility?.[key]
if (v != null) out.visibility[key] = v
@@ -2,14 +2,12 @@
import CreateWorkspace from '$lib/components/workspaceSettings/CreateWorkspace.svelte'
import { Alert } from '$lib/components/common'
import { roleCapabilities } from '$lib/stores'
import { isRestricted } from '$lib/roleCapabilities'
</script>
<!-- The form is reachable by URL whatever the pickers show, and forking is what turns a restricted role
into a full member of the copy (the fork's membership row carries `is_admin` but not the
role). The server still accepts the call: this refuses the page, it does not enforce
the rule; see docs/operator-write-rights.md. -->
{#if isRestricted($roleCapabilities)}
<!-- The form is reachable by URL whatever the pickers show. The server refuses a fork to a role
without `fork` either way; a developer blocked by `DisableWorkspaceForking` gets the server's
ruleset message instead. -->
{#if !$roleCapabilities.fork}
<div class="p-4 mt-12">
<Alert type="error" title="Unauthorized">Page not available for your role</Alert>
</div>