From 8bba9e92f9f682e7cceebd275395de6849c45a1a Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 22 Oct 2026 04:59:17 -0700 Subject: [PATCH] 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) --- backend/ee-repo-ref.txt | 2 +- .../tests/operator_fork.rs | 58 ++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 69 ++++++++++--------- backend/windmill-api/openapi.yaml | 6 +- backend/windmill-common/src/roles.rs | 12 ++++ docs/feature-telemetry.md | 5 +- docs/operator-write-rights.md | 22 ++++++ docs/workspace-roles.md | 1 + .../lib/components/common/table/AppRow.svelte | 7 +- .../components/common/table/FlowRow.svelte | 8 +-- .../components/common/table/ScriptRow.svelte | 11 ++- .../lib/components/details/FlowDetail.svelte | 2 +- .../components/details/ScriptDetail.svelte | 2 +- .../src/lib/components/runs/RunDetail.svelte | 3 +- .../settings/RoleCapabilitiesEditor.svelte | 8 ++- .../sidebar/WorkspacePickerBody.svelte | 30 +++++++- .../CreateWorkspaceInner.svelte | 7 +- frontend/src/lib/roleCapabilities.ts | 4 +- frontend/src/lib/utils/editInFork.ts | 38 ++++++---- frontend/src/lib/workspaceRoles.ts | 3 + .../user/(user)/fork_workspace/+page.svelte | 10 ++- 21 files changed, 222 insertions(+), 86 deletions(-) create mode 100644 backend/windmill-api-integration-tests/tests/operator_fork.rs diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 66b973f850..afcbb49466 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -c8d562aa9d75a7e99bf0ecd1ac224f532452a07e +f469d1cde99e59120c85f9ebd791d2aa044a4259 diff --git a/backend/windmill-api-integration-tests/tests/operator_fork.rs b/backend/windmill-api-integration-tests/tests/operator_fork.rs new file mode 100644 index 0000000000..8c04eb6648 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/operator_fork.rs @@ -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, +) -> 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) = 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(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 3d3a52739e..5d26a91352 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -9009,6 +9009,33 @@ async fn deprecated_create_workspace_fork(_authed: ApiAuthed) -> Result 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) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index edbd4165d9..e2b1fff91e 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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: diff --git a/backend/windmill-common/src/roles.rs b/backend/windmill-common/src/roles.rs index b3fd9937f4..c2caaf97ea 100644 --- a/backend/windmill-common/src/roles.rs +++ b/backend/windmill-common/src/roles.rs @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub visibility: Option, } @@ -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), } } diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 8e658eda3f..f31744adde 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -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 diff --git a/docs/operator-write-rights.md b/docs/operator-write-rights.md index 5fda1547f0..c6e8bf78b5 100644 --- a/docs/operator-write-rights.md +++ b/docs/operator-write-rights.md @@ -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. diff --git a/docs/workspace-roles.md b/docs/workspace-roles.md index 953b4c023c..7ebb8b2f7e 100644 --- a/docs/workspace-roles.md +++ b/docs/workspace-roles.md @@ -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 diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 812c06284b..c71ff1dee8 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -159,7 +159,7 @@ {/if} - {#if $roleCapabilities.code && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)} + {#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)} {/if} @@ -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', diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index ccbdb9de3e..870b59c80f 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -189,8 +189,7 @@ {/if} - - {#if $roleCapabilities.code && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)} + {#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)} {/if} @@ -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', diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 9c300b9477..1ee5b6dec2 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -253,9 +253,9 @@ {/if} {/if} - {#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !script.canWrite)} - - {/if} + {/if} + {#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !script.canWrite)} + {/if} onEditInForkClick(e, 'script', script.path), - hide: - !$roleCapabilities.code || - isCloudHosted() || - !editInForkAllowed($workspaceStore, $userWorkspaces) + hide: isCloudHosted() || !editInForkAllowed($workspaceStore, $userWorkspaces) }, { displayName: 'Move/Rename', diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index b700269e93..30f0d05d3b 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -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), diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 618bafe3ab..2912f158cf 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -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), diff --git a/frontend/src/lib/components/runs/RunDetail.svelte b/frontend/src/lib/components/runs/RunDetail.svelte index 7ab0c13d06..d4eb476189 100644 --- a/frontend/src/lib/components/runs/RunDetail.svelte +++ b/frontend/src/lib/components/runs/RunDetail.svelte @@ -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) ) diff --git a/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte b/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte index e9823aecde..b6134c9a5c 100644 --- a/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte +++ b/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte @@ -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." } ] diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index 97385c7e9d..141e2b3b5e 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -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 @@ {/each} - {#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect} + {#if (isCloudHosted() || $superadmin || canForkHere || forkBlockedHere) && !strictWorkspaceSelect}
{#if isCloudHosted() || $superadmin}
@@ -593,6 +604,19 @@ Workspace fork
+ {:else if forkBlockedHere} + +
+ + Workspace fork +
+ {#snippet text()} + {OPERATOR_FORK_BLOCKED_REASON} + {/snippet} +
{/if}
{/if} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte index d6a43364f0..a45453700f 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte @@ -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) => ({ diff --git a/frontend/src/lib/roleCapabilities.ts b/frontend/src/lib/roleCapabilities.ts index 55beec9ee0..6790cd380e 100644 --- a/frontend/src/lib/roleCapabilities.ts +++ b/frontend/src/lib/roleCapabilities.ts @@ -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 ) } diff --git a/frontend/src/lib/utils/editInFork.ts b/frontend/src/lib/utils/editInFork.ts index 9e74878f39..30e59d57e8 100644 --- a/frontend/src/lib/utils/editInFork.ts +++ b/frontend/src/lib/utils/editInFork.ts @@ -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 " 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': diff --git a/frontend/src/lib/workspaceRoles.ts b/frontend/src/lib/workspaceRoles.ts index 4cfb3a6089..ae6a0a4f7a 100644 --- a/frontend/src/lib/workspaceRoles.ts +++ b/frontend/src/lib/workspaceRoles.ts @@ -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 diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte index bb72bc844e..2b81e7a6ea 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte @@ -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' - -{#if isRestricted($roleCapabilities)} + +{#if !$roleCapabilities.fork}
Page not available for your role