mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-16 08:03:02 +00:00
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:
co-authored by
Claude Opus 5.5
parent
a2643f36b0
commit
cd3efb86ed
+16
@@ -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"
|
||||
}
|
||||
-16
@@ -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
|
||||
",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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". */
|
||||
|
||||
Reference in New Issue
Block a user