feat: make an operator who forks a developer in the fork

AllowOperatorForking now grants a fork to build in: the creator row copies
only is_admin, so the operator is a developer there. Deploying back still
runs with their parent role, where operator write checks refuse it. On
self-hosted EE this makes them a full seat, which the ruleset toggle says.

"Edit in fork" is shown to an operator when they may fork and the workspace
has no canonical dev workspace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-13 08:12:18 -07:00
co-authored by Claude Opus 5.5
parent a2643f36b0
commit cd3efb86ed
14 changed files with 60 additions and 73 deletions
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin)\n SELECT $1, email, username, is_admin FROM usr\n WHERE workspace_id = $3 AND email = $2\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "42a0ba479ff164cc190c350927e13902ed94816142faf15446b4b9f19c3097d7"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, role)\n SELECT $1, email, username, is_admin, operator, role FROM usr\n WHERE workspace_id = $3 AND email = $2\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "be88c05af4d8b86325cebad62370b95beed35ba95dbab948bb9bd1287aae28cd"
}
@@ -5,10 +5,10 @@ use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
/// Operators cannot fork until a ruleset carries `AllowOperatorForking`, and the fork keeps them an
/// operator rather than handing them the developer role the bare creator row would default to.
/// Operators cannot fork until a ruleset carries `AllowOperatorForking`, 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_ruleset_and_stays_operator(
async fn test_operator_fork_needs_ruleset_and_develops_in_fork(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
@@ -57,7 +57,7 @@ async fn test_operator_fork_needs_ruleset_and_stays_operator(
)
.fetch_one(&db)
.await?;
assert!(operator && !is_admin);
assert!(!operator && !is_admin);
Ok(())
}
@@ -9798,11 +9798,13 @@ 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. `operator` is left at its
// false default on purpose: an operator allowed by `AllowOperatorForking` develops in the fork,
// while deploying back still runs with their parent role, where operator write checks refuse it.
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin, operator, role)
SELECT $1, email, username, is_admin, operator, role FROM usr
(workspace_id, email, username, is_admin)
SELECT $1, email, username, is_admin FROM usr
WHERE workspace_id = $3 AND email = $2
ON CONFLICT DO NOTHING
",
+6 -3
View File
@@ -103,6 +103,9 @@ Operators cannot fork unless some protection ruleset of the workspace carries
rather than restricts, so it never goes through `check_user_against_rule` and its ruleset's bypass
lists play no part. `canCreateFork` in `frontend/src/lib/utils/editInFork.ts` mirrors it.
The fork copies the creator's `operator` flag and `role`, so an operator who forks is an operator in
the fork. Without that, the bare creator row defaults to developer and forking becomes a way to gain
write access.
An operator who forks is a developer in the fork: the creator row copies only `is_admin`, and
`operator` keeps its false default. That is the point of the rule, a copy to build in. It gives no
write access to the parent: deploying a fork's changes back runs with the user's role in the
parent, where the operator write checks refuse it, so an admin or a `wm_deployers` member deploys
them instead. The developer row does cost a seat: the self-hosted seat count takes a user who is a
developer in any workspace, forks included, as a full seat.
@@ -174,7 +174,7 @@
</Button>
</div>
{/if}
{#if !$userStore?.operator && !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>
@@ -207,8 +207,7 @@
</Button>
</div>
{/if}
<!-- Hidden from every operator, builder rights included. -->
{#if !$userStore?.operator && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
<EditInForkButton itemType="flow" path={flow.path} />
{/if}
</span>
@@ -278,10 +277,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:
$userStore?.operator ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
hide: isCloudHosted() || !editInForkAllowed($workspaceStore, $userWorkspaces)
},
{
displayName: 'Audit logs',
@@ -267,9 +267,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
@@ -360,10 +360,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:
$userStore?.operator ||
isCloudHosted() ||
!editInForkAllowed($workspaceStore, $userWorkspaces)
hide: isCloudHosted() || !editInForkAllowed($workspaceStore, $userWorkspaces)
},
{
displayName: 'Move/Rename',
@@ -381,12 +381,7 @@
})
}
if (
flow &&
!actingUser?.operator &&
!isCloudHosted() &&
editInForkAllowed(workspace, $userWorkspaces)
) {
if (flow && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) {
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('flow', workspace, $userWorkspaces),
@@ -544,12 +544,7 @@
})
}
if (
script &&
!actingUser?.operator &&
!isCloudHosted() &&
editInForkAllowed(workspace, $userWorkspaces)
) {
if (script && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) {
buttons.push({
label: editInForkLabel(workspace, $userWorkspaces),
description: editInForkDescription('script', workspace, $userWorkspaces),
@@ -1148,21 +1148,17 @@
/>
{/if}
{/if}
{#if !showEditButton && !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces)}
<Button
href={buildForkEditUrl(
isScript ? 'script' : 'flow',
job?.script_path ?? '',
workspaceId
)}
onClick={(e) => editInFork(e, isScript ? 'script' : 'flow', job?.script_path ?? '')}
unifiedSize="sm"
variant="default"
iconOnly={compactBar}
title={editInForkLabel(workspaceId, $userWorkspaces)}
startIcon={{ icon: Pen }}>{editInForkLabel(workspaceId, $userWorkspaces)}</Button
>
{/if}
{/if}
{#if !showEditButton && !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces)}
<Button
href={buildForkEditUrl(isScript ? 'script' : 'flow', job?.script_path ?? '', workspaceId)}
onClick={(e) => editInFork(e, isScript ? 'script' : 'flow', job?.script_path ?? '')}
unifiedSize="sm"
variant="default"
iconOnly={compactBar}
title={editInForkLabel(workspaceId, $userWorkspaces)}
startIcon={{ icon: Pen }}>{editInForkLabel(workspaceId, $userWorkspaces)}</Button
>
{/if}
{/if}
{#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'}
@@ -128,7 +128,7 @@
// Base candidates are the current workspace's family: its root first, then every fork/dev under it.
// Bases the user is an operator in stay listed: the server refuses one whose rulesets lack
// `AllowOperatorForking`, and the fork keeps the operator role.
// `AllowOperatorForking`, and deploying back from the fork runs with their role in the parent.
let familyRoot = $derived(findWorkspaceRoot($workspaceStore, forkableWorkspaces))
let baseCandidates = $derived(
familyRoot ? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, forkableWorkspaces)] : []
@@ -378,7 +378,8 @@
/>
<div class="text-xs text-secondary ml-6">
Operators cannot fork a workspace unless a ruleset turns this on. It applies to every
operator, whatever the bypass list says, and the operator stays an operator in the fork.
operator, whatever the bypass list says. An operator who forks is a developer in the fork,
and counts as a developer seat, but still cannot deploy changes back to this workspace.
</div>
</div>
+6 -4
View File
@@ -19,16 +19,18 @@ 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 an operator, who 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.
* An operator gets it only when they may fork and there is no canonical dev: a new fork makes them a
* developer, but in an existing dev workspace they keep whatever role they have there. 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 user = get(userStore)
if (user?.operator) return false
return canCreateFork(user) || !!findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces)
const dev = findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces)
if (user?.operator) return canCreateFork(user) && !dev
return canCreateFork(user) || !!dev
}
/** Label for the affordance: "Edit in <dev name>" when routed to a canonical dev, else "Edit in fork". */