mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 16:17:25 +00:00
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:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
e3b4dcffbc
commit
ca8f0a16c1
@@ -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(" ");
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user