feat: per-role fork capability and fork role mapping on rulesets (#12054)

* feat: fork capability per role and fork role mapping on rulesets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep unset fork for unrestricted roles and apply fork role mapping to fork-owner adds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: fork_roles feature usage telemetry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: derive an unset fork in the role editor as the server does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to d535972f0b35c6a7f257720816bdaec18465adef

This commit updates the EE repository reference after PR #929 was merged in windmill-ee-private.

Previous ee-repo-ref: 4a8d59cd1563382fcec85b1367f7e15e09bc13c2

New ee-repo-ref: d535972f0b35c6a7f257720816bdaec18465adef

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-28 00:43:33 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent e3b4dcffbc
commit ca8f0a16c1
43 changed files with 877 additions and 232 deletions
+17 -1
View File
@@ -15,6 +15,16 @@ function sortedUnique(arr: readonly string[]): string[] {
return [...new Set(arr)].sort();
}
// Absent when empty, so a file written before the field existed stays in sync.
function sortedMapping(
m: Record<string, string> | undefined,
): Record<string, string> | undefined {
const keys = Object.keys(m ?? {}).sort();
return keys.length > 0
? Object.fromEntries(keys.map((k) => [k, m![k]]))
: undefined;
}
export class ProtectionRulesConverter {
// Canonicalize a single rule so comparisons are insensitive to array order
// and duplicates.
@@ -24,6 +34,9 @@ export class ProtectionRulesConverter {
rules: sortedUnique(entry.rules ?? []) as ProtectionRuleEntry["rules"],
bypass_groups: sortedUnique(entry.bypass_groups ?? []),
bypass_users: sortedUnique(entry.bypass_users ?? []),
...(sortedMapping(entry.fork_role_mapping)
? { fork_role_mapping: sortedMapping(entry.fork_role_mapping) }
: {}),
};
}
@@ -45,6 +58,7 @@ export class ProtectionRulesConverter {
rules: [...(r.rules ?? [])],
bypass_groups: [...(r.bypass_groups ?? [])],
bypass_users: [...(r.bypass_users ?? [])],
fork_role_mapping: { ...(r.fork_role_mapping ?? {}) },
})),
);
}
@@ -62,7 +76,9 @@ export class ProtectionRulesConverter {
na.bypass_groups.length === nb.bypass_groups.length &&
na.bypass_groups.every((v, i) => v === nb.bypass_groups[i]) &&
na.bypass_users.length === nb.bypass_users.length &&
na.bypass_users.every((v, i) => v === nb.bypass_users[i])
na.bypass_users.every((v, i) => v === nb.bypass_users[i]) &&
JSON.stringify(na.fork_role_mapping ?? {}) ===
JSON.stringify(nb.fork_role_mapping ?? {})
);
}
@@ -208,6 +208,7 @@ export async function pushProtectionRules(
rules: n.rules,
bypass_groups: n.bypass_groups,
bypass_users: n.bypass_users,
fork_role_mapping: n.fork_role_mapping ?? {},
},
});
applied.created++;
@@ -221,6 +222,7 @@ export async function pushProtectionRules(
rules: n.rules,
bypass_groups: n.bypass_groups,
bypass_users: n.bypass_users,
fork_role_mapping: n.fork_role_mapping ?? {},
},
});
applied.updated++;
@@ -10,6 +10,8 @@ export interface ProtectionRuleEntry {
rules: ProtectionRuleKind[];
bypass_groups: string[];
bypass_users: string[];
// Role a member gets in a fork of the workspace, keyed by the role it holds there.
fork_role_mapping?: Record<string, string>;
}
// protection-rules.yaml is a flat map: workspace name -> its protection rules.
@@ -43,6 +43,10 @@ function describeEntry(entry: ProtectionRuleEntry): string {
if (n.bypass_users.length > 0) {
parts.push(`bypass_users=[${n.bypass_users.join(", ")}]`);
}
if (n.fork_role_mapping) {
const pairs = Object.entries(n.fork_role_mapping).map(([f, t]) => `${f}->${t}`);
parts.push(`fork_role_mapping=[${pairs.join(", ")}]`);
}
return parts.join(" ");
}
+1 -1
View File
@@ -8497,7 +8497,7 @@ write a secret you were not given: keep the placeholder.
| \`git_sync\` | \`{ repositories: [...] }\`, each with \`git_repo_resource_path\`, \`script_path\` and \`settings\` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |
| \`datatable\` | \`{ datatables: { <name>: { database: { resource_type, resource_path } } } }\`, both required: \`resource_type: postgresql\` with \`resource_path\` a Postgres resource's path, or \`instance\`, or \`external_instance\` (Enterprise only), with \`resource_path\` the name of a database a superadmin set up. A fork's entry can instead be \`{ reference: { workspace_id, datatable } }\`, pointing at its parent's data table; keep it as is. \`main\` is the one scripts use by default. Role permissions are \`datatable_permissions\`. |
| \`datatable_permissions\` | \`{ <data table>: { default_role, roles: { <role>: [tenants] } } }\`: who may connect to each data table as which role. Tenants are \`u/<user>\`, \`g/<group>\`, \`f/<folder>\` or \`*\`; \`admin\` is always a role, and \`default_role: null\` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |
| \`protection_rules\` | \`{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }\`, rules among \`DisableDirectDeployment\`, \`DisableWorkspaceForking\`, \`RestrictDeployToDeployers\`, \`RestrictAnonymousAppDeployment\`, \`RestrictPublicRunSharing\`, \`RestrictGuestAppDeployment\`, \`RequireIsolatedForks\`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| \`protection_rules\` | \`{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...], fork_role_mapping?: { <role here>: <role in its forks> } } }\`, rules among \`DisableDirectDeployment\`, \`DisableWorkspaceForking\`, \`RestrictDeployToDeployers\`, \`RestrictAnonymousAppDeployment\`, \`RestrictPublicRunSharing\`, \`RestrictGuestAppDeployment\`, \`RequireIsolatedForks\`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |
| \`ducklake\` | \`{ ducklakes: { <name>: { catalog, storage, ... } } }\`. |
| \`dbt_warehouses\` | dbt warehouse connections. |
| \`default_app\` | Path of the app shown as the workspace home. |
@@ -238,3 +238,14 @@ describe("protection-rules.yaml read/write", () => {
}
});
});
describe("fork_role_mapping", () => {
test("a changed mapping is an update, an empty one is no change", () => {
const local = { ...rule("forks", []), fork_role_mapping: { operator: "developer" } };
const plan = ProtectionRulesConverter.computePlan([local], [rule("forks", [])]);
expect(plan.toUpdate.map((e) => e.name)).toEqual(["forks"]);
const empty = { ...rule("forks", []), fork_role_mapping: {} };
expect(ProtectionRulesConverter.entriesEqual(empty, rule("forks", []))).toBe(true);
});
});