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
File diff suppressed because one or more lines are too long
@@ -262,8 +262,8 @@ fn datatable_permissions_document(datatable: Option<&Value>, roles: &DatatableNa
/// Protection rules by name, their rules in a fixed order so a reordered file is no change. The
/// dev workspace lock is left out: the dev workspace setting creates and removes it.
async fn protection_rules_document(conn: &mut PgConnection, w_id: &str) -> Result<Value> {
let rows = sqlx::query_as::<_, (String, i32, Vec<String>, Vec<String>)>(
"SELECT name, rules, bypass_groups, bypass_users FROM workspace_protection_rule
let rows = sqlx::query_as::<_, (String, i32, Vec<String>, Vec<String>, Value)>(
"SELECT name, rules, bypass_groups, bypass_users, fork_role_mapping FROM workspace_protection_rule
WHERE workspace_id = $1 AND name <> $2 ORDER BY name",
)
.bind(w_id)
@@ -272,19 +272,27 @@ async fn protection_rules_document(conn: &mut PgConnection, w_id: &str) -> Resul
.await?;
Ok(Value::Object(
rows.into_iter()
.map(|(name, bits, bypass_groups, bypass_users)| {
.map(|(name, bits, bypass_groups, bypass_users, fork_role_mapping)| {
let rules = ProtectionRules::from_bits_truncate(bits);
let rule = json!({
let mut rule = json!({
"rules": rule_kinds(rules),
"bypass_groups": bypass_groups,
"bypass_users": bypass_users,
});
with_fork_role_mapping(&mut rule, fork_role_mapping);
(name, rule)
})
.collect(),
))
}
/// Written only when set, so documents of rulesets that map no role read as they always did.
fn with_fork_role_mapping(rule: &mut Value, mapping: Value) {
if mapping.as_object().is_some_and(|m| !m.is_empty()) {
rule["fork_role_mapping"] = mapping;
}
}
fn rule_kinds(rules: ProtectionRules) -> Value {
json!(rules.kinds())
}
@@ -860,14 +868,16 @@ fn normalize_protection_rules(v: &Value, warnings: &mut Vec<String>) -> Result<V
let kinds: Vec<ProtectionRuleKind> =
serde_json::from_value(rule.get("rules").cloned().unwrap_or(json!([])))
.map_err(|e| Error::BadRequest(format!("Invalid {what}.rules: {e}")))?;
out.insert(
name.clone(),
json!({
"rules": rule_kinds(ProtectionRules::from(&kinds)),
"bypass_groups": string_list(rule.get("bypass_groups"), &format!("{what}.bypass_groups"))?,
"bypass_users": string_list(rule.get("bypass_users"), &format!("{what}.bypass_users"))?,
}),
);
let fork_role_mapping: std::collections::BTreeMap<String, String> =
serde_json::from_value(rule.get("fork_role_mapping").cloned().unwrap_or(json!({})))
.map_err(|e| Error::BadRequest(format!("Invalid {what}.fork_role_mapping: {e}")))?;
let mut normalized = json!({
"rules": rule_kinds(ProtectionRules::from(&kinds)),
"bypass_groups": string_list(rule.get("bypass_groups"), &format!("{what}.bypass_groups"))?,
"bypass_users": string_list(rule.get("bypass_users"), &format!("{what}.bypass_users"))?,
});
with_fork_role_mapping(&mut normalized, json!(fork_role_mapping));
out.insert(name.clone(), normalized);
}
Ok(Value::Object(out))
}
@@ -1761,6 +1771,7 @@ fn plan_protection_rules(p: &mut Plan, desired: Value) {
"rules": r.get("rules"),
"bypass_groups": r.get("bypass_groups"),
"bypass_users": r.get("bypass_users"),
"fork_role_mapping": r.get("fork_role_mapping").cloned().unwrap_or(json!({})),
})
};
match (before, after) {
@@ -2178,7 +2189,8 @@ mod tests {
json!({ "old": { "rules": ["DisableWorkspaceForking"], "bypass_groups": [], "bypass_users": [] } }),
);
let d = doc(json!({ "protection_rules": {
"new": { "rules": ["RestrictPublicRunSharing", "DisableDirectDeployment"] },
"new": { "rules": ["RestrictPublicRunSharing", "DisableDirectDeployment"],
"fork_role_mapping": { "operator": "developer" } },
"dev_workspace_lock": { "rules": [] },
} }));
let p = plan_document(cur, d, false, &mc, &DatatableNames::default()).unwrap();
@@ -2186,7 +2198,8 @@ mod tests {
assert_eq!(
p.writes[0].body,
json!({ "name": "new", "rules": ["DisableDirectDeployment", "RestrictPublicRunSharing"],
"bypass_groups": [], "bypass_users": [] })
"bypass_groups": [], "bypass_users": [],
"fork_role_mapping": { "operator": "developer" } })
);
assert!(matches!(
p.writes[1].target,