mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-23 00:03:46 +00:00
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:
co-authored by
Claude Opus 5.5
parent
34de8bc06d
commit
8bba9e92f9
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user