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
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND email = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_role",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "operator",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
false
]
},
"hash": "0be5632227efca9d58cfa67bf3ce5326c5d344a0106e219867acc2447f417160"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE workspace_protection_rule\n SET name = $1, rules = $2, bypass_groups = $3, bypass_users = $4\n WHERE workspace_id = $5 AND name = $6\n ",
"query": "\n UPDATE workspace_protection_rule\n SET name = $1, rules = $2, bypass_groups = $3, bypass_users = $4,\n fork_role_mapping = COALESCE($7, fork_role_mapping)\n WHERE workspace_id = $5 AND name = $6\n ",
"describe": {
"columns": [],
"parameters": {
@@ -10,10 +10,11 @@
"TextArray",
"TextArray",
"Text",
"Text"
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "adbe6ce27d46ba0d2d3ce87d875f60c179e5bccc54a36c8d5dcf04fd84445309"
"hash": "11a315b1b019c4bbd6b6676029943ea7c4da6b1368408c541cc2f9c390a5e840"
}
@@ -1,16 +0,0 @@
{
"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 (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via, workspace_role)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,\n CASE WHEN $3 THEN NULL ELSE added_via END, workspace_role\n FROM usr WHERE workspace_id = $2\n AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Bool"
]
},
"nullable": []
},
"hash": "464286216add454cb64dba810d964c4b6a41d96df223b6d3d1d1bc972ab10d42"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, workspace_role, operator FROM usr\n WHERE workspace_id = $1 AND email = $2 AND NOT disabled",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "workspace_role",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "operator",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "4e8c9610c697c2fb1b08ce78773ef2875ef34dd304ff21979095bb7b9304bccd"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username FROM usr\n WHERE workspace_id = $1 AND email = $2 AND NOT operator AND NOT disabled",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "67d86d334029276d78bf8fa14b25702eba0b0d674b99f952af94487b3e54211c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)\n VALUES ($1, $2, $3, $4, $5)\n ",
"query": "\n INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users, fork_role_mapping)\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
"describe": {
"columns": [],
"parameters": {
@@ -9,10 +9,11 @@
"Varchar",
"Int4",
"TextArray",
"TextArray"
"TextArray",
"Jsonb"
]
},
"nullable": []
},
"hash": "dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1"
"hash": "8c3b028e01aaf693a69ef0f9644a7182490293e210602f5cc7b42978e3b89109"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "WITH m AS (SELECT * FROM unnest($4::text[], $5::text[], $6::bool[]) AS m(from_role, to_role, to_operator))\n INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via, workspace_role)\n SELECT $1, u.username, u.email, u.is_admin, u.created_at, COALESCE(m.to_operator, u.operator), u.disabled, u.role,\n u.is_service_account, CASE WHEN $3 OR $7::text IS NOT NULL THEN NULL ELSE u.added_via END,\n COALESCE(m.to_role, u.workspace_role)\n FROM usr u\n LEFT JOIN m ON m.from_role = COALESCE(u.workspace_role, CASE WHEN u.operator THEN 'operator' ELSE 'developer' END)\n WHERE u.workspace_id = $2\n AND ($7::text IS NULL OR u.email = $7)\n AND (NOT $3 OR (NOT u.operator AND NOT u.disabled AND NOT u.is_service_account))\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Bool",
"TextArray",
"TextArray",
"BoolArray",
"Text"
]
},
"nullable": []
},
"hash": "b80d2fd5368eb0bad94bf529b61df755ed21a35339cae18728d9cb0b6b7ea530"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n name,\n rules as \"rules: ProtectionRules\",\n bypass_groups,\n bypass_users\n FROM workspace_protection_rule\n WHERE workspace_id = $1\n ORDER BY name\n ",
"query": "\n SELECT\n workspace_id,\n name,\n rules as \"rules: ProtectionRules\",\n bypass_groups,\n bypass_users,\n fork_role_mapping as \"fork_role_mapping: ForkRoleMapping\"\n FROM workspace_protection_rule\n WHERE workspace_id = $1\n ORDER BY name\n ",
"describe": {
"columns": [
{
@@ -27,6 +27,11 @@
"ordinal": 4,
"name": "bypass_users",
"type_info": "TextArray"
},
{
"ordinal": 5,
"name": "fork_role_mapping: ForkRoleMapping",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -39,8 +44,9 @@
false,
false,
false,
false,
false
]
},
"hash": "4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef"
"hash": "c0b4a4914f738a6a9975bb75d8ffeda071ba59b874fd56ec3eeb9b054df7eb64"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM workspace_protection_rule WHERE ($1::text IS NULL OR workspace_id = $1)\n AND (fork_role_mapping ? $2\n OR EXISTS (SELECT 1 FROM jsonb_each_text(fork_role_mapping) m WHERE m.value = $2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ee2a8ae43f2d0a748f1e55185df87f1d9ff80b7298846d45adcf854c9052424d"
}
+1 -1
View File
@@ -1 +1 @@
5e7b1de9a07114e277a22e9e3c68b0809c06f18d
d535972f0b35c6a7f257720816bdaec18465adef
@@ -0,0 +1 @@
ALTER TABLE workspace_protection_rule DROP COLUMN IF EXISTS fork_role_mapping;
@@ -0,0 +1,2 @@
ALTER TABLE workspace_protection_rule
ADD COLUMN IF NOT EXISTS fork_role_mapping JSONB NOT NULL DEFAULT '{}'::jsonb;
+1 -1
View File
@@ -241,7 +241,7 @@ workspace_invite: workspace_id(char), email(char), is_admin(bool), operator(bool
FK: (workspace_id) -> workspace(id)
workspace_key: workspace_id(char), kind(workspace_key_kind), key(char)
FK: (workspace_id) -> workspace(id)
workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_groups(text[]), bypass_users(text[]), created_at(ts)
workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_groups(text[]), bypass_users(text[]), created_at(ts), fork_role_mapping(jsonb)
FK: (workspace_id) -> workspace(id)
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
@@ -73,3 +73,108 @@ async fn test_fork_adds_parent_admins_and_developers(db: Pool<Postgres>) -> anyh
Ok(())
}
/// A forker joins the fork with the role they hold in the parent, unless a ruleset of the parent
/// maps it; a role without `fork` cannot fork at all, and the fork's owner cannot seat a member
/// as a developer the mapping makes something else. EE: CE caps the workspace count below the
/// forks this creates.
#[cfg(feature = "enterprise")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_fork_carries_role_and_mapping(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// Not `test-workspace`: the role and ruleset caches are process-wide and keyed by workspace
// id, so the other test here, running concurrently on its own database, would fill them.
sqlx::raw_sql(
"INSERT INTO workspace (id, name, owner) VALUES ('rf-ws', 'rf-ws', 'test-user');
INSERT INTO workspace_settings (workspace_id) VALUES ('rf-ws');
INSERT INTO workspace_key (workspace_id, kind, key) VALUES ('rf-ws', 'cloud', 'rf-key');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('rf-ws', 'all', 'All users', '{}');
INSERT INTO usr (workspace_id, email, username, is_admin, operator, role) VALUES
('rf-ws', 'test@windmill.dev', 'test-user', true, false, 'Admin'),
('rf-ws', 'test2@windmill.dev', 'test-user-2', false, true, 'User'),
('rf-ws', 'test3@windmill.dev', 'test-user-3', false, false, 'User')",
)
.execute(&db)
.await?;
windmill_common::roles::invalidate_all_roles_cache();
windmill_common::workspaces::invalidate_protection_rules_cache("rf-ws");
let server = ApiServer::start(db.clone()).await?;
let base_url = format!(
"http://localhost:{}/api/w/rf-ws/workspaces",
server.addr.port()
);
let client = reqwest::Client::new();
let fork = |id: &str| {
client
.post(format!("{base_url}/create_fork"))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&json!({ "id": id, "name": id }))
.send()
};
let fork_role = |id: &'static str| {
sqlx::query_as::<_, (Option<String>, bool)>(
"SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND username = 'test-user-2'",
)
.bind(id)
.fetch_one(&db)
};
let resp = fork("wm-fork-refused").await?;
assert_eq!(resp.status(), 403, "operator lacks fork: {}", resp.text().await?);
// `code` makes the copy write `operator = false`: the legacy row's NULL role must not then
// read as developer.
sqlx::query(
r#"UPDATE workspace_settings SET role_overrides = '{"operator": {"capabilities": {"fork": true, "code": true}}}'
WHERE workspace_id = 'rf-ws'"#,
)
.execute(&db)
.await?;
windmill_common::roles::invalidate_all_roles_cache();
let resp = fork("wm-fork-kept").await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
assert_eq!(
fork_role("wm-fork-kept").await?,
(Some("operator".to_string()), false),
"stays an operator"
);
let resp = client
.post(format!("{base_url}/protection_rules"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({
"name": "forks", "rules": [], "bypass_groups": [], "bypass_users": [],
"fork_role_mapping": { "operator": "developer", "developer": "operator" }
}))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
let resp = fork("wm-fork-mapped").await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
assert_eq!(
fork_role("wm-fork-mapped").await?,
(Some("developer".to_string()), false),
"mapped to developer"
);
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{}/api/w/wm-fork-mapped/workspaces/add_user",
server.addr.port()
))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&json!({ "email": "test3@windmill.dev", "is_admin": false, "operator": false }))
.send()
.await?;
let (status, text) = (resp.status(), resp.text().await?);
assert!(
status == 403 && text.contains("role in its forks"),
"a developer mapped to operator: {status} {text}"
);
Ok(())
}
+232 -84
View File
@@ -29,7 +29,7 @@ use regex::Regex;
use hex;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use strum::IntoEnumIterator;
use uuid::Uuid;
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
@@ -8084,34 +8084,48 @@ async fn clone_groups(
Ok(())
}
/// Copy the source workspace's members (the `usr` rows, carrying each member's role) into the
/// target so a fork/dev can be a shared environment. Idempotent — skips members the target already
/// has. Group memberships are not handled here: the sole caller is the create-fork path, where
/// `clone_groups` already copies the source's full group structure (including `all` membership).
async fn copy_workspace_members(
/// The role each of the parent's roles becomes in a fork of it, after the parent's ruleset
/// mappings, with the `usr.operator` value that goes with it.
struct ForkRoles {
from: Vec<String>,
to: Vec<String>,
to_operator: Vec<bool>,
}
impl ForkRoles {
fn maps(&self, role: &str) -> bool {
self.from
.iter()
.zip(&self.to)
.any(|(from, to)| from == role && from != to)
}
}
async fn fork_roles(db: &DB, parent_workspace_id: &str) -> Result<ForkRoles> {
let mapping = windmill_common::workspaces::fork_role_mapping(parent_workspace_id, db).await?;
let roles = windmill_common::roles::workspace_roles(db, parent_workspace_id).await?;
let mut out = ForkRoles { from: vec![], to: vec![], to_operator: vec![] };
for role in roles.iter() {
let to = mapping.get(&role.name).unwrap_or(&role.name);
let caps = roles
.iter()
.find(|r| &r.name == to)
.map(|r| r.capabilities)
.unwrap_or(windmill_common::roles::RoleCapabilities::NONE);
out.from.push(role.name.clone());
out.to.push(to.clone());
out.to_operator.push(!caps.code);
}
Ok(out)
}
/// Give the fork the parent's workspace-only roles and overrides, so a role carried into the fork
/// means there what it means in the parent.
async fn copy_workspace_roles(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
admins_and_developers_only: bool,
) -> Result<()> {
// Admins and developers join as manual members: the fork does not inherit the source's
// instance-group config, so a copied `instance_group` provenance would let the fork's
// reconciliation delete them and their data.
sqlx::query!(
"INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via, workspace_role)
SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,
CASE WHEN $3 THEN NULL ELSE added_via END, workspace_role
FROM usr WHERE workspace_id = $2
AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))
ON CONFLICT DO NOTHING",
target_workspace_id,
source_workspace_id,
admins_and_developers_only,
)
.execute(&mut **tx)
.await?;
// The members keep their roles, so the fork needs the source's workspace-only roles and
// overrides to give them the same meaning.
sqlx::query!(
"UPDATE workspace_settings SET role_overrides =
(SELECT role_overrides FROM workspace_settings WHERE workspace_id = $2)
@@ -8134,6 +8148,50 @@ async fn copy_workspace_members(
Ok(())
}
/// Copy the source workspace's members (the `usr` rows) into the target so a fork/dev can be a
/// shared environment, each with its role after `roles`. Idempotent — skips members the target
/// already has. `only_email` restricts the copy to one member. Group memberships are not handled
/// here: the sole caller is the create-fork path, where `clone_groups` already copies the source's
/// full group structure (including `all` membership).
async fn copy_workspace_members(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
roles: &ForkRoles,
admins_and_developers_only: bool,
only_email: Option<&str>,
) -> Result<()> {
// Admins and developers, and a lone member, join as manual members: the fork does not inherit
// the source's instance-group config, so a copied `instance_group` provenance would let the fork's
// reconciliation delete them and their data. The role is written by name even when unmapped:
// `operator` is recomputed from it, and a NULL role would then read as whatever that flag
// names. A role the parent no longer defines is carried as is, and resolves to no
// capabilities in the fork as it does in the parent.
sqlx::query!(
"WITH m AS (SELECT * FROM unnest($4::text[], $5::text[], $6::bool[]) AS m(from_role, to_role, to_operator))
INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via, workspace_role)
SELECT $1, u.username, u.email, u.is_admin, u.created_at, COALESCE(m.to_operator, u.operator), u.disabled, u.role,
u.is_service_account, CASE WHEN $3 OR $7::text IS NOT NULL THEN NULL ELSE u.added_via END,
COALESCE(m.to_role, u.workspace_role)
FROM usr u
LEFT JOIN m ON m.from_role = COALESCE(u.workspace_role, CASE WHEN u.operator THEN 'operator' ELSE 'developer' END)
WHERE u.workspace_id = $2
AND ($7::text IS NULL OR u.email = $7)
AND (NOT $3 OR (NOT u.operator AND NOT u.disabled AND NOT u.is_service_account))
ON CONFLICT DO NOTHING",
target_workspace_id,
source_workspace_id,
admins_and_developers_only,
&roles.from,
&roles.to,
&roles.to_operator,
only_email,
)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn clone_resource_types(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
@@ -9303,6 +9361,32 @@ async fn clone_workspace_dependencies(
Ok(())
}
/// Who may fork `w_id` at all: the instance switch, the caller's role, then the parent's rulesets.
async fn enforce_fork_allowed(db: &DB, authed: &ApiAuthed, w_id: &str) -> Result<()> {
if *DISABLE_WORKSPACE_FORK {
require_super_admin(db, authed).await?;
}
if !windmill_common::roles::caller_can_fork(db, w_id, authed).await? {
windmill_common::feature_usage::log_feature_usage("fork_roles", "fork_refused", "");
return Err(Error::PermissionDenied(format!(
"Your role in {w_id} cannot fork it"
)));
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
w_id,
&ProtectionRuleKind::DisableWorkspaceForking,
AuditAuthorable::username(authed),
&authed.groups,
authed.is_admin,
db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
Ok(())
}
async fn deprecated_create_workspace_fork(_authed: ApiAuthed) -> Result<String> {
return Err(Error::BadRequest("This API endpoint has been relocated. Your Windmill CLI version is outdated and needs to be updated.".to_string()));
}
@@ -9322,21 +9406,7 @@ async fn create_workspace_fork_branch(
enforce_cloud_fork_cap(&db, &w_id).await?;
}
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed).await?;
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&w_id,
&ProtectionRuleKind::DisableWorkspaceForking,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
enforce_fork_allowed(&db, &authed, &w_id).await?;
enforce_isolated_forks(&db, &authed, &w_id, &nw).await?;
// Two-phase create for git-synced workspaces: this endpoint only creates the git branch(es) and
@@ -10073,21 +10143,7 @@ async fn create_workspace_fork(
#[cfg(not(feature = "enterprise"))]
_check_nb_of_workspaces(&db).await?;
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed).await?;
}
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
&parent_workspace_id,
&ProtectionRuleKind::DisableWorkspaceForking,
AuditAuthorable::username(&authed),
&authed.groups,
authed.is_admin,
&db,
)
.await?
{
return Err(Error::PermissionDenied(msg));
}
enforce_fork_allowed(&db, &authed, &parent_workspace_id).await?;
enforce_isolated_forks(&db, &authed, &parent_workspace_id, &nw).await?;
if nw.is_dev_workspace {
@@ -10652,6 +10708,8 @@ async fn write_workspace_fork(
copies: &[crate::datatable_clone::MadeCopy],
progress: &ForkProgress,
) -> Result<String> {
// Read on the pool before the transaction holds a connection of its own.
let roles = fork_roles(&db, &parent_workspace_id).await?;
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
// Held until this transaction ends: after an error, the copies' cleanup waits on it, so it reads
// whether the fork exists only once a commit still being resolved has settled.
@@ -10721,13 +10779,20 @@ async fn write_workspace_fork(
// Optionally bring the parent's members into the fork (a shared dev env). Dev-only: it's part of the
// dev-workspace feature (and the frontend only offers it there), so the backend enforces it rather
// than trusting the client — copying the parent's whole team into an ordinary throwaway fork isn't
// intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before
// the explicit creator insert below so the creator (a parent member) is copied with full metadata
// (operator/role/is_service_account/added_via), not the bare row the insert alone would leave.
// intended. Dev creation is already admin-gated, so this is transitively admin-only too.
// Independently, the parent's admins can have every fork of it start with its admins and
// developers; the forker cannot opt out, since the point is that those admins can review it.
copy_workspace_roles(&mut tx, &parent_workspace_id, &forked_id).await?;
if nw.copy_members && nw.is_dev_workspace {
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, false).await?;
copy_workspace_members(
&mut tx,
&parent_workspace_id,
&forked_id,
&roles,
false,
None,
)
.await?;
} else if sqlx::query_scalar!(
"SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1",
parent_workspace_id
@@ -10736,24 +10801,44 @@ async fn write_workspace_fork(
.await?
.unwrap_or(false)
{
copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, true).await?;
copy_workspace_members(
&mut tx,
&parent_workspace_id,
&forked_id,
&roles,
true,
None,
)
.await?;
}
// 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.
sqlx::query!(
"INSERT INTO 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
",
forked_id,
authed.email,
parent_workspace_id,
// The creator joins with the role they hold in the parent, after its ruleset mappings, even
// without copy_members. No-op when copy_members already brought their row.
copy_workspace_members(
&mut tx,
&parent_workspace_id,
&forked_id,
&roles,
false,
Some(&authed.email),
)
.execute(&mut *tx)
.await?;
if let Some(forker) = sqlx::query!(
"SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND email = $2",
parent_workspace_id,
authed.email,
)
.fetch_optional(&mut *tx)
.await?
{
let role =
windmill_common::roles::resolve_role_name(forker.workspace_role, forker.operator);
windmill_common::feature_usage::log_feature_usage(
"fork_roles",
"fork_created",
if roles.maps(&role) { "mapped" } else { "kept" },
);
}
// The pointers this fork writes to the parent's data tables stay invisible until it commits, so
// a rename of one of them cannot carry them. Holding the parent's settings row makes such a
@@ -11929,8 +12014,10 @@ If you do not have an account on {}, login with SSO or ask an admin to create an
/// - only on a fork they created, never on a root workspace;
/// - only as a developer, so it can never mint an admin (nor an operator, which would need the
/// workspace's operator settings to be meaningful);
/// - only for someone who is already a developer or admin of the parent, so pulling them into the
/// fork cannot widen who can read the parent's data.
/// - only for a member of the parent whose role in its forks, after the parent's ruleset
/// mappings, is `developer`, so pulling them into the fork cannot widen who can read the
/// parent's data nor undo a mapping. A custom role is refused even when unrestricted: the grant
/// seats a developer, not that role.
///
/// Anything outside those bounds stays an admin's call. Returns the username the new member must be
/// given in the fork.
@@ -11957,19 +12044,29 @@ async fn authorize_fork_owner_add_user(
)));
}
let parent_username = sqlx::query_scalar!(
"SELECT username FROM usr
WHERE workspace_id = $1 AND email = $2 AND NOT operator AND NOT disabled",
let member = sqlx::query!(
"SELECT username, workspace_role, operator FROM usr
WHERE workspace_id = $1 AND email = $2 AND NOT disabled",
parent,
nu.email,
)
.fetch_optional(db)
.await?;
let mut fork_role = None;
if let Some(m) = &member {
let role = windmill_common::roles::resolve_role_name(m.workspace_role.clone(), m.operator);
let mapping = windmill_common::workspaces::fork_role_mapping(&parent, db).await?;
fork_role = Some(mapping.get(&role).cloned().unwrap_or(role));
}
let parent_username = member
.filter(|_| fork_role.as_deref() == Some(windmill_common::roles::DEVELOPER_ROLE))
.map(|m| m.username);
let Some(parent_username) = parent_username else {
return Err(Error::PermissionDenied(format!(
"as the creator of fork {w_id} you can only add developers or admins of its parent \
workspace {parent}; {} is not one, so only an admin of {w_id} can add them",
"as the creator of fork {w_id} you can only add members of its parent workspace \
{parent} whose role in its forks is developer; {} is not one, so only an admin of \
{w_id} can add them",
nu.email
)));
};
@@ -12581,6 +12678,8 @@ struct CreateProtectionRuleRequest {
rules: Vec<ProtectionRuleKind>,
bypass_groups: Vec<String>,
bypass_users: Vec<String>,
#[serde(default)]
fork_role_mapping: BTreeMap<String, String>,
}
#[derive(Deserialize)]
@@ -12591,6 +12690,9 @@ struct UpdateProtectionRuleRequest {
rules: Vec<ProtectionRuleKind>,
bypass_groups: Vec<String>,
bypass_users: Vec<String>,
/// Absent leaves the stored mapping alone, which is what a client that predates it gets.
#[serde(default)]
fork_role_mapping: Option<BTreeMap<String, String>>,
}
#[derive(Serialize)]
@@ -12600,6 +12702,7 @@ struct ProtectionRulesetResponse {
pub rules: Vec<ProtectionRuleKind>,
pub bypass_groups: Vec<String>,
pub bypass_users: Vec<String>,
pub fork_role_mapping: BTreeMap<String, String>,
}
impl From<ProtectionRuleset> for ProtectionRulesetResponse {
@@ -12618,10 +12721,38 @@ impl From<ProtectionRuleset> for ProtectionRulesetResponse {
name: value.name,
bypass_groups: value.bypass_groups,
bypass_users: value.bypass_users,
fork_role_mapping: value.fork_role_mapping.0,
}
}
}
/// A fork role mapping may only name roles `w_id` defines, and maps each to a different one.
async fn validate_fork_role_mapping(
db: &DB,
w_id: &str,
mapping: &BTreeMap<String, String>,
) -> Result<()> {
if mapping.is_empty() {
return Ok(());
}
let roles = windmill_common::roles::workspace_roles(db, w_id).await?;
for (from, to) in mapping {
for name in [from, to] {
if !roles.iter().any(|r| &r.name == name) {
return Err(Error::BadRequest(format!(
"fork role mapping: role `{name}` is not defined in workspace {w_id}"
)));
}
}
if from == to {
return Err(Error::BadRequest(format!(
"fork role mapping: `{from}` is mapped to itself"
)));
}
}
Ok(())
}
/// List all protection rules for a workspace
async fn list_protection_rules(
Extension(db): Extension<DB>,
@@ -13003,6 +13134,10 @@ async fn create_protection_rule(
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
reject_reserved_rule_name(&req.name)?;
validate_fork_role_mapping(&db, &w_id, &req.fork_role_mapping).await?;
if !req.fork_role_mapping.is_empty() {
windmill_common::feature_usage::log_feature_usage("fork_roles", "mapping_saved", "create");
}
let mut tx = db.begin().await?;
@@ -13026,14 +13161,15 @@ async fn create_protection_rule(
// Insert the new rule
sqlx::query!(
r#"
INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users, fork_role_mapping)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
&w_id,
&req.name,
ProtectionRules::from(&req.rules).bits(),
&req.bypass_groups,
&req.bypass_users,
sqlx::types::Json(&req.fork_role_mapping) as _,
)
.execute(&mut *tx)
.await?;
@@ -13099,6 +13235,16 @@ async fn update_protection_rule(
}
reject_reserved_rule_name(new_name)?;
}
if let Some(mapping) = &req.fork_role_mapping {
validate_fork_role_mapping(&db, &w_id, mapping).await?;
if !mapping.is_empty() {
windmill_common::feature_usage::log_feature_usage(
"fork_roles",
"mapping_saved",
"update",
);
}
}
let mut tx = db.begin().await?;
@@ -13142,7 +13288,8 @@ async fn update_protection_rule(
sqlx::query!(
r#"
UPDATE workspace_protection_rule
SET name = $1, rules = $2, bypass_groups = $3, bypass_users = $4
SET name = $1, rules = $2, bypass_groups = $3, bypass_users = $4,
fork_role_mapping = COALESCE($7, fork_role_mapping)
WHERE workspace_id = $5 AND name = $6
"#,
final_name,
@@ -13150,7 +13297,8 @@ async fn update_protection_rule(
&req.bypass_groups,
&req.bypass_users,
&w_id,
&rule_name
&rule_name,
req.fork_role_mapping.as_ref().map(sqlx::types::Json) as _,
)
.execute(&mut *tx)
.await?;
+19 -1
View File
@@ -8239,6 +8239,8 @@ paths:
$ref: "#/components/schemas/RuleBypasserGroups"
bypass_users:
$ref: "#/components/schemas/RuleBypasserUsers"
fork_role_mapping:
$ref: "#/components/schemas/ForkRoleMapping"
responses:
"200":
description: protection rule created successfully
@@ -8287,6 +8289,8 @@ paths:
$ref: "#/components/schemas/RuleBypasserGroups"
bypass_users:
$ref: "#/components/schemas/RuleBypasserUsers"
fork_role_mapping:
$ref: "#/components/schemas/ForkRoleMapping"
responses:
"200":
description: protection rule updated successfully
@@ -39157,9 +39161,12 @@ components:
type: boolean
manage_triggers:
type: boolean
fork:
type: boolean
description: Fork the workspace.
visibility:
$ref: "#/components/schemas/RoleVisibility"
required: [code, flows, apps, manage_schedules, manage_triggers, visibility]
required: [code, flows, apps, manage_schedules, manage_triggers, fork, visibility]
RoleCapabilitiesPatch:
type: object
@@ -39172,6 +39179,7 @@ components:
$ref: "#/components/schemas/AuthorLevel"
manage_schedules: { type: boolean }
manage_triggers: { type: boolean }
fork: { type: boolean }
visibility:
type: object
additionalProperties:
@@ -39836,6 +39844,16 @@ components:
$ref: "#/components/schemas/RuleBypasserGroups"
bypass_users:
$ref: "#/components/schemas/RuleBypasserUsers"
fork_role_mapping:
$ref: "#/components/schemas/ForkRoleMapping"
ForkRoleMapping:
type: object
description: >-
Role a member of this workspace gets in a fork of it, keyed by the role it holds here.
Unmapped roles carry over unchanged; where two rulesets map the same role, the first by
name wins. On update, omitting it leaves the stored mapping unchanged.
additionalProperties:
type: string
ProtectionRules:
type: array
description: Configuration of protection restrictions
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,
+92 -11
View File
@@ -92,6 +92,10 @@ pub struct RoleCapabilities {
pub apps: AuthorLevel,
pub manage_schedules: bool,
pub manage_triggers: bool,
/// Fork the workspace. Like visibility, it does not make a role restricted: it is read from
/// the role at the fork routes, never through [`caller_capabilities`]'s fast path. Left unset
/// by every layer, it is granted exactly to an unrestricted role (see [`Self::resolve`]).
pub fork: bool,
pub visibility: RoleVisibility,
}
@@ -102,6 +106,7 @@ impl RoleCapabilities {
apps: AuthorLevel::Full,
manage_schedules: true,
manage_triggers: true,
fork: true,
visibility: RoleVisibility::ALL,
};
@@ -113,6 +118,7 @@ impl RoleCapabilities {
apps: AuthorLevel::None,
manage_schedules: true,
manage_triggers: true,
fork: false,
visibility: RoleVisibility::ALL,
};
@@ -124,6 +130,7 @@ impl RoleCapabilities {
apps: AuthorLevel::None,
manage_schedules: false,
manage_triggers: false,
fork: false,
visibility: RoleVisibility::NONE,
};
@@ -141,7 +148,7 @@ impl RoleCapabilities {
/// left out: it only hides pages, and must not turn a full author into an operator for the
/// raw `is_operator` checks, job permissions and credential caches keyed on it.
pub fn is_restricted(&self) -> bool {
Self { visibility: RoleVisibility::ALL, ..*self } != Self::FULL
Self { visibility: RoleVisibility::ALL, fork: true, ..*self } != Self::FULL
}
/// Whether the role authors deployable artifacts and so costs a full seat rather than half.
@@ -164,6 +171,20 @@ impl RoleCapabilities {
}
}
/// Role `name` with `patches` applied over [`Self::base_for`], bottom layer first. A `fork` no
/// layer sets follows [`Self::is_restricted`]: roles that predate the capability keep the
/// forking they had, which every unrestricted role held and no restricted one.
pub fn resolve(name: &str, patches: &[&RoleCapabilitiesPatch]) -> Self {
let mut caps = Self::base_for(name);
for p in patches {
caps.apply(p);
}
if patches.iter().all(|p| p.fork.is_none()) {
caps.fork = !caps.is_restricted();
}
caps
}
fn apply(&mut self, patch: &RoleCapabilitiesPatch) {
if let Some(v) = patch.code {
self.code = v;
@@ -180,6 +201,9 @@ impl RoleCapabilities {
if let Some(v) = patch.manage_triggers {
self.manage_triggers = v;
}
if let Some(v) = patch.fork {
self.fork = v;
}
if let Some(v) = &patch.visibility {
v.apply(&mut self.visibility);
}
@@ -213,6 +237,8 @@ pub struct RoleCapabilitiesPatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manage_triggers: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<RoleVisibilityPatch>,
}
@@ -279,6 +305,7 @@ pub fn diff_capabilities(
apps: d(base.apps, target.apps),
manage_schedules: d(base.manage_schedules, target.manage_schedules),
manage_triggers: d(base.manage_triggers, target.manage_triggers),
fork: d(base.fork, target.fork),
visibility: (visibility != RoleVisibilityPatch::default()).then_some(visibility),
}
}
@@ -393,9 +420,7 @@ pub fn parse_instance_roles(value: &serde_json::Value) -> Result<Vec<RoleDefinit
if !seen.insert(r.name.as_str()) {
return Err(Error::BadRequest(format!("duplicate role `{}`", r.name)));
}
let mut caps = RoleCapabilities::base_for(&r.name);
caps.apply(&r.capabilities);
caps.validate(&r.name)?;
RoleCapabilities::resolve(&r.name, &[&r.capabilities]).validate(&r.name)?;
}
Ok(roles)
}
@@ -408,12 +433,10 @@ pub fn effective_roles(
) -> Result<Vec<EffectiveRole>> {
let mut out = Vec::with_capacity(instance.len() + overrides.len());
for def in instance {
let mut caps = RoleCapabilities::base_for(&def.name);
caps.apply(&def.capabilities);
let ovr = overrides.get(&def.name);
if let Some(o) = ovr {
caps.apply(&o.capabilities);
}
let mut patches = vec![&def.capabilities];
patches.extend(ovr.map(|o| &o.capabilities));
let caps = RoleCapabilities::resolve(&def.name, &patches);
out.push(EffectiveRole {
label: ovr
.and_then(|o| o.label.clone())
@@ -432,8 +455,7 @@ pub fn effective_roles(
.collect();
extra.sort_by(|a, b| a.0.cmp(b.0));
for (name, o) in extra {
let mut caps = RoleCapabilities::base_for(name);
caps.apply(&o.capabilities);
let caps = RoleCapabilities::resolve(name, &[&o.capabilities]);
out.push(EffectiveRole {
name: name.clone(),
label: o.label.clone().unwrap_or_else(|| name.clone()),
@@ -721,6 +743,36 @@ pub async fn caller_capabilities<A: Authable + ?Sized>(
}
}
/// Whether the caller's role in `workspace_id` holds [`RoleCapabilities::fork`]. Read from the role
/// even for an unrestricted caller, since withdrawing `fork` alone leaves a role unrestricted. A
/// caller with no membership is left to the fork routes' other gates.
pub async fn caller_can_fork<A: Authable + ?Sized>(
db: &DB,
workspace_id: &str,
authed: &A,
) -> Result<bool> {
if authed.is_admin() {
return Ok(true);
}
// Not through `member_role`: its cache would then hold every forker, restricted or not.
let row = sqlx::query!(
"SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND username = $2",
workspace_id,
authed.username()
)
.fetch_optional(db)
.await?;
match row {
Some(r) => {
let role = resolve_role_name(r.workspace_role, r.operator);
Ok(role_capabilities_by_name(db, workspace_id, &role)
.await?
.fork)
}
None => Ok(true),
}
}
pub fn invalidate_workspace_roles_cache(workspace_id: &str) {
WORKSPACE_ROLES.remove(workspace_id);
}
@@ -793,6 +845,21 @@ pub async fn ensure_role_unused(db: &DB, w_id: Option<&str>, name: &str) -> Resu
"role `{name}` is still held by {users} member(s) and {invites} pending invite(s), or named by {settings} workspace auto-invite or instance group mapping(s): reassign them first"
)));
}
let mapping_rulesets = sqlx::query_scalar!(
"SELECT COUNT(*) FROM workspace_protection_rule WHERE ($1::text IS NULL OR workspace_id = $1)
AND (fork_role_mapping ? $2
OR EXISTS (SELECT 1 FROM jsonb_each_text(fork_role_mapping) m WHERE m.value = $2))",
w_id,
name
)
.fetch_one(db)
.await?
.unwrap_or(0);
if mapping_rulesets > 0 {
return Err(Error::BadRequest(format!(
"role `{name}` is named by the fork role mapping of {mapping_rulesets} protection ruleset(s): edit them first"
)));
}
// An override left behind would silently re-base onto `base_for` instead of the instance
// definition it was written against.
if w_id.is_none() {
@@ -986,6 +1053,20 @@ mod tests {
assert!(op.capabilities.is_author());
}
#[test]
fn unset_fork_follows_restriction() {
let instance: Vec<RoleDefinition> = serde_json::from_value(serde_json::json!([
{"name": "developer"},
{"name": "operator"},
{"name": "maintainer", "capabilities": {"code": true, "flows": "full", "apps": "full"}},
{"name": "auditor", "capabilities": {"fork": true}}
]))
.unwrap();
let roles = effective_roles(&instance, &RoleOverrides::new()).unwrap();
let fork: Vec<bool> = roles.iter().map(|r| r.capabilities.fork).collect();
assert_eq!(fork, [true, false, true, true]);
}
#[test]
fn full_authoring_requires_code() {
let err = parse_instance_roles(&serde_json::json!([
+22 -1
View File
@@ -63,8 +63,13 @@ pub struct ProtectionRuleset {
pub rules: ProtectionRules,
pub bypass_groups: Vec<String>,
pub bypass_users: Vec<String>,
/// Role a member of the workspace gets in a fork of it, keyed by the role it holds here.
/// Unmapped roles carry over unchanged.
pub fork_role_mapping: ForkRoleMapping,
}
pub type ForkRoleMapping = sqlx::types::Json<std::collections::BTreeMap<String, String>>;
bitflags::bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
// #[sqlx(transparent)]
@@ -1185,7 +1190,8 @@ pub async fn get_protection_rules(
name,
rules as "rules: ProtectionRules",
bypass_groups,
bypass_users
bypass_users,
fork_role_mapping as "fork_role_mapping: ForkRoleMapping"
FROM workspace_protection_rule
WHERE workspace_id = $1
ORDER BY name
@@ -1475,6 +1481,21 @@ pub async fn check_user_against_rule(
Ok(RuleCheckResult::Allowed)
}
/// The roles members of `workspace_id` get in a fork of it, merged across its rulesets: where two
/// rulesets map the same role, the first by name wins. Unmapped roles carry over unchanged.
pub async fn fork_role_mapping(
workspace_id: &str,
db: &DB,
) -> Result<std::collections::BTreeMap<String, String>> {
let mut mapping = std::collections::BTreeMap::new();
for ruleset in get_protection_rules(workspace_id, db).await?.iter() {
for (from, to) in ruleset.fork_role_mapping.iter() {
mapping.entry(from.clone()).or_insert_with(|| to.clone());
}
}
Ok(mapping)
}
/// Check all deploy-gating protection rules at once.
///
/// Evaluates `DisableDirectDeployment` first (so its message wins when both
+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);
});
});
+2 -2
View File
@@ -4,13 +4,13 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
It currently carries 86 registered actions across thirty-three features (`ai_session`, `ai_chat`,
It currently carries 89 registered actions across thirty-four features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_decision`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`,
`flow_run`, `flow_step`, `home`, `header_nav`, `run_form`, `debugger`, `trigger`, `command_script`,
`mcp`, `mcp_workspace`, `hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`,
`job_token_scopes`, `worker_draining`, `worker_group`, `worker_resource_stats`,
`dev_workspace_isolation`, `slack_agent`, `variable_expiration`, `anthropic_federation`,
`deployment_request`). Nearly all of the
`deployment_request`, `fork_roles`). Nearly all of the
product is uninstrumented, so new user-facing work is the opportunity to change that.
## When to instrument
+15
View File
@@ -19,6 +19,7 @@ code is `windmill_common::roles`.
| `flows` | `none` / `compose` / `full` | yes; also AI agents, whose tools are checked as composed steps |
| `apps` | `none` / `compose` / `full` | yes; low-code apps and worker-built apps need `full` |
| `manage_schedules`, `manage_triggers` | bool | yes, see `docs/operator-write-rights.md` |
| `fork` | bool | yes, at both fork routes; unset by every layer, it is granted exactly to an unrestricted role |
| `visibility.*` | bool per page | no, UI only |
`compose` is the former operator builder right: the composition-only checks of
@@ -33,6 +34,20 @@ deleting one that a member, invite, auto-invite or instance group mapping still
and so is deleting an instance role that a workspace still overrides (the override would fall onto
`base_for` instead of the definition it was written against).
## Forks
A member's role carries into a fork: the forker, and every member `copy_members` or
`add_admins_and_developers_to_forks` brings, joins the fork with the role they hold in the parent,
and the fork gets the parent's `role_overrides` so the name means the same there. A protection
ruleset's `fork_role_mapping` (`{ role here: role in the fork }`) replaces that role; where several
rulesets map one role, the first by name wins. A role named by a mapping cannot be deleted. A
fork's non-admin creator may add a parent member only when the mapping makes them a developer, so
re-adding cannot undo a mapping that restricts.
`fork`, like `visibility`, does not make a role restricted: withdrawing it from `developer` must not
turn every developer into an operator for the raw `is_operator` checks. So the fork routes read it
from the member's role (`caller_can_fork`), never through `caller_capabilities`' fast path.
## Who is restricted
`ApiAuthed::is_operator` means "this member's role is restricted", computed at authentication from
@@ -6,7 +6,7 @@
import RoleCapabilitiesEditor from '../settings/RoleCapabilitiesEditor.svelte'
import type { RoleCapabilities, RoleCapabilitiesPatch, RoleDefinition } from '$lib/gen'
import {
applyCapabilitiesPatch,
resolveCapabilities,
authorLevelLabel,
baseCapabilitiesFor,
DEFAULT_INSTANCE_ROLES,
@@ -154,7 +154,7 @@
>
{#each rows as row, i (row.id)}
{@const base = baseCapabilitiesFor(row.name)}
{@const caps = applyCapabilitiesPatch(base, row.capabilities)}
{@const caps = resolveCapabilities(base, row.capabilities, false)}
<div class="flex flex-col rounded-md border bg-surface-tertiary">
<div class="flex items-center gap-2 p-2">
<div
@@ -114,9 +114,9 @@
/>
<Alert type="info" title="You created this fork">
You are not an admin of this workspace, so you can only add developers or admins of
<b>{parentWorkspaceId}</b> as developers of this fork, and remove the ones who are not admins here.
Ask an admin of this workspace for any other membership change.
You are not an admin of this workspace, so you can only add members of
<b>{parentWorkspaceId}</b> whose role in its forks is developer, as developers of this fork, and remove
the ones who are not admins here. Ask an admin of this workspace for any other membership change.
</Alert>
<div class="pt-6"></div>
@@ -10,9 +10,9 @@
RoleVisibility
} from '$lib/gen'
import {
applyCapabilitiesPatch,
capabilitiesError,
isEmptyPatch,
resolveCapabilities,
VISIBILITY_KEYS
} from '$lib/workspaceRoles'
import { RotateCcw } from 'lucide-svelte'
@@ -22,6 +22,8 @@
base: RoleCapabilities
/** Fields whose `base` value is not known here, as `code` or `visibility.runs`. */
unknownBase?: string[]
/** Whether a layer `base` includes sets `fork`; otherwise an unset one is derived. */
baseSetsFork?: boolean
patch: RoleCapabilitiesPatch
/** What the layer below is called: "default" on the instance, "instance" in a workspace. */
baseName: string
@@ -35,6 +37,7 @@
let {
base,
unknownBase = [],
baseSetsFork = false,
patch,
baseName,
setText,
@@ -43,10 +46,10 @@
onchange
}: Props = $props()
const effective = $derived(applyCapabilitiesPatch(base, patch))
const effective = $derived(resolveCapabilities(base, patch, baseSetsFork))
const error = $derived(capabilitiesError(effective))
type BoolKey = 'code' | 'manage_schedules' | 'manage_triggers'
type BoolKey = 'code' | 'manage_schedules' | 'manage_triggers' | 'fork'
type LevelKey = 'flows' | 'apps'
function boolValue(path: string): boolean {
@@ -115,6 +118,12 @@
key: 'manage_triggers',
label: 'Manage triggers',
description: 'Create, edit and delete triggers where folder permissions allow.'
},
{
key: 'fork',
label: 'Fork the workspace',
description:
'Create forks of this workspace. Their role in the fork is this one, unless a ruleset maps it.'
}
]
</script>
@@ -13,10 +13,11 @@
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import {
applyCapabilitiesPatch,
authorLevelLabel,
baseCapabilitiesFor,
capabilitiesError,
forkLooksSet,
resolveCapabilities,
isEmptyPatch,
roleNameError,
VISIBILITY_KEYS
@@ -58,7 +59,7 @@
function overriddenKeys(o: RoleOverride | undefined): string[] {
const c = o?.capabilities ?? {}
const keys = ['code', 'flows', 'apps', 'manage_schedules', 'manage_triggers'].filter(
const keys = ['code', 'flows', 'apps', 'manage_schedules', 'manage_triggers', 'fork'].filter(
(k) => c[k] != null
)
for (const { key } of VISIBILITY_KEYS) {
@@ -78,8 +79,20 @@
return { base: role.capabilities, unknown: overriddenKeys(stored) }
}
/** Whether the layers under the draft set `fork`, so an unset one is not derived again. */
function forkSetBelow(name: string): boolean {
const { base, unknown } = baseFor(name)
return (
instanceRoles.some((r) => r.name === name) && (unknown.includes('fork') || forkLooksSet(base))
)
}
function effectiveFor(name: string): RoleCapabilities {
return applyCapabilitiesPatch(baseFor(name).base, draft.get(name)?.capabilities)
return resolveCapabilities(
baseFor(name).base,
draft.get(name)?.capabilities,
forkSetBelow(name)
)
}
const AUTHORING_KEYS = ['code', 'flows', 'apps']
@@ -204,7 +217,7 @@
{#snippet roleCard(name: string, role: EffectiveRole | undefined)}
{@const { base, unknown } = baseFor(name)}
{@const caps = applyCapabilitiesPatch(base, draft.get(name)?.capabilities)}
{@const caps = effectiveFor(name)}
{@const overridden = Object.hasOwn(cleanedDraft, name)}
{@const frozen = !role && !data?.custom_roles_enabled}
<div class="flex flex-col rounded-md border bg-surface-tertiary">
@@ -283,6 +296,7 @@
<RoleCapabilitiesEditor
{base}
unknownBase={unknown}
baseSetsFork={forkSetBelow(name)}
patch={draft.get(name)?.capabilities ?? {}}
baseName={role ? 'instance' : 'default'}
setText={role ? 'Overridden' : 'Set'}
@@ -34,7 +34,7 @@
devWorkspacesInChainAbove
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import { capabilitiesIn, isRestricted } from '$lib/roleCapabilities'
import { canFork, capabilitiesIn } from '$lib/roleCapabilities'
import {
canUserBypassRuleKindInRulesets,
fetchProtectionRulesForWorkspace,
@@ -137,15 +137,14 @@
})
let forkableWorkspaces = $derived(forkable.current)
// Base candidates are the current workspace's family: its root first, then every fork/dev under it.
// Never one the user holds a restricted role in: a fork does not carry the role, so forking such a base
// would hand them a full-rights copy of it.
// Base candidates are the current workspace's family: its root first, then every fork/dev under it,
// except those the user's role there may not fork.
let familyRoot = $derived(findWorkspaceRoot($workspaceStore, forkableWorkspaces))
let baseCandidates = $derived(
(familyRoot
? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, forkableWorkspaces)]
: []
).filter((w) => !isRestricted(capabilitiesIn(undefined, w)))
).filter((w) => canFork(capabilitiesIn(undefined, w)))
)
let baseItems = $derived(
baseCandidates.map((w) => ({
@@ -16,8 +16,9 @@
import { clone } from '$lib/utils'
import { DEV_WORKSPACE_LOCK_RULE_NAME } from '$lib/workspaceProtectionRules.svelte'
import { untrack } from 'svelte'
import { Save } from 'lucide-svelte'
import { ArrowRight, Plus, Save, Trash2 } from 'lucide-svelte'
import { safeSelectItems } from '$lib/components/select/utils.svelte'
import Select from '$lib/components/select/Select.svelte'
interface Props {
rule?: ProtectionRuleset
@@ -52,6 +53,10 @@
let selectedUsers = $state<string[]>(
untrack(() => rule)?.bypass_users?.map((u) => u.replace('u/', '')) ?? []
)
type RoleMapping = { from: string | undefined; to: string | undefined }[]
const mappingRows = (m: Record<string, string> | undefined): RoleMapping =>
Object.entries(m ?? {}).map(([from, to]) => ({ from, to }))
let forkRoleMapping = $state<RoleMapping>(mappingRows(untrack(() => rule)?.fork_role_mapping))
// Initial state for unsaved changes tracking
let initialName = $state(untrack(() => rule)?.name ?? '')
@@ -67,6 +72,9 @@
? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', ''))
: []
)
let initialForkRoleMapping = $state<RoleMapping>(
mappingRows(untrack(() => rule)?.fork_role_mapping)
)
let initialSelectedUsers = $state<string[]>(
untrack(() => rule)?.bypass_users
? untrack(() => rule)!.bypass_users.map((u) => u.replace('u/', ''))
@@ -76,6 +84,7 @@
// Available options
let availableGroups = $state<string[]>([])
let availableUsers = $state<string[]>([])
let availableRoles = $state<{ value: string; label: string }[]>([])
let saving = $state(false)
@@ -90,11 +99,17 @@
availableUsers = users
}
async function loadAvailableRoles() {
const { roles } = await WorkspaceService.listWorkspaceRoles({ workspace: $workspaceStore! })
availableRoles = roles.map((r) => ({ value: r.name, label: r.label }))
}
$effect(() => {
if ($workspaceStore) {
untrack(() => {
loadAvailableGroups()
loadAvailableUsers()
loadAvailableRoles()
})
}
})
@@ -111,7 +126,8 @@
restrictGuestAppDeployment ||
requireIsolatedForks ||
selectedGroups.length > 0 ||
selectedUsers.length > 0
selectedUsers.length > 0 ||
forkRoleMapping.length > 0
: name !== initialName ||
disableDirectDeployment !== initialDisableDirectDeployment ||
disableFork !== initialDisableFork ||
@@ -123,7 +139,23 @@
JSON.stringify([...selectedGroups].sort()) !==
JSON.stringify([...initialSelectedGroups].sort()) ||
JSON.stringify([...selectedUsers].sort()) !==
JSON.stringify([...initialSelectedUsers].sort())
JSON.stringify([...initialSelectedUsers].sort()) ||
JSON.stringify(forkRoleMapping) !== JSON.stringify(initialForkRoleMapping)
)
const mappingError = $derived.by(() => {
const seen = new Set<string>()
for (const { from, to } of forkRoleMapping) {
if (!from || !to) return 'Pick both roles of every mapping'
if (from === to) return `${from} is mapped to itself`
if (seen.has(from)) return `${from} is mapped twice`
seen.add(from)
}
return undefined
})
const forkRoleMappingBody = $derived(
Object.fromEntries(forkRoleMapping.map(({ from, to }) => [from!, to!]))
)
const nameError = $derived.by(() => {
@@ -136,7 +168,7 @@
return undefined
})
const canSave = $derived(!nameError && hasUnsavedChanges)
const canSave = $derived(!nameError && !mappingError && hasUnsavedChanges)
async function create() {
if (!canSave || !$workspaceStore) return
@@ -163,7 +195,8 @@
...(requireIsolatedForks ? ['RequireIsolatedForks' as ProtectionRuleKind] : [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
bypass_users: selectedUsers,
fork_role_mapping: forkRoleMappingBody
}
})
@@ -203,7 +236,8 @@
...(requireIsolatedForks ? ['RequireIsolatedForks' as ProtectionRuleKind] : [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
bypass_users: selectedUsers,
fork_role_mapping: forkRoleMappingBody
}
})
@@ -220,6 +254,7 @@
initialRequireIsolatedForks = requireIsolatedForks
initialSelectedGroups = clone(selectedGroups)
initialSelectedUsers = clone(selectedUsers)
initialForkRoleMapping = clone(forkRoleMapping)
onUpdate?.()
} catch (error) {
@@ -345,6 +380,51 @@
)}
</div>
</Section>
<Section
label="Roles in forks"
description="A member forking this workspace keeps their role in the fork. Map a role to give its members another one in their forks, for example operator to developer. Only roles that may fork can use it."
class="flex flex-col gap-2"
>
{#each forkRoleMapping as mapping, i (i)}
<div class="flex items-center gap-2">
<Select
bind:value={mapping.from}
items={availableRoles}
placeholder="Role here"
class="grow"
/>
<ArrowRight size={14} class="text-secondary shrink-0" />
<Select
bind:value={mapping.to}
items={availableRoles}
placeholder="Role in the fork"
class="grow"
/>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: Trash2 }}
aria-label="Remove mapping"
onclick={() => forkRoleMapping.splice(i, 1)}
/>
</div>
{/each}
{#if mappingError}
<p class="text-2xs text-red-500">{mappingError}</p>
{/if}
<div>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Plus }}
onclick={() => forkRoleMapping.push({ from: undefined, to: undefined })}
>
Add mapping
</Button>
</div>
</Section>
</div>
{#snippet actions()}
+11 -11
View File
@@ -1,6 +1,6 @@
import type { OperatorSettings, RoleCapabilities, RoleVisibility } from '$lib/gen'
import type { UserExt, UserWorkspace } from '$lib/stores'
import { FULL_CAPABILITIES, VISIBILITY_KEYS } from '$lib/workspaceRoles'
import { capabilitiesRestricted, FULL_CAPABILITIES, VISIBILITY_KEYS } from '$lib/workspaceRoles'
/**
* What a member's workspace role lets them author, manage and see: the frontend's copy of
@@ -32,6 +32,7 @@ export function legacyOperatorCapabilities(
apps: settings?.builder_apps === true ? 'compose' : 'none',
manage_schedules: settings?.manage_schedules !== false,
manage_triggers: settings?.manage_triggers !== false,
fork: false,
visibility
}
}
@@ -55,17 +56,16 @@ export function capabilitiesIn(
return operator ? legacyOperatorCapabilities(workspace?.operator_settings) : FULL_CAPABILITIES
}
/** Whether the role is gated at all, as `RoleCapabilities::is_restricted`: visibility is left out,
* since hiding a page must not cost a full author what restricted roles are refused, such as
* forking. */
/** Whether the role is gated at all, as `RoleCapabilities::is_restricted`: visibility and `fork`
* are left out, since neither may cost a full author what restricted roles are refused. */
export function isRestricted(caps: RoleCapabilities): boolean {
return (
!caps.code ||
caps.flows !== 'full' ||
caps.apps !== 'full' ||
!caps.manage_schedules ||
!caps.manage_triggers
)
return capabilitiesRestricted(caps)
}
/** Whether the role may fork the workspace. A server that predates the capability sends none, and
* then refused every restricted role in the UI. */
export function canFork(caps: RoleCapabilities): boolean {
return caps.fork ?? !isRestricted(caps)
}
/** The role with nothing left to build and the same pages in view: what a session in Use mode
+3 -8
View File
@@ -1,5 +1,5 @@
import { get } from 'svelte/store'
import { isRestricted } from '$lib/roleCapabilities'
import { canFork } from '$lib/roleCapabilities'
import type { RoleCapabilities } from '$lib/gen'
import {
roleCapabilities,
@@ -58,17 +58,12 @@ export function editInForkDescription(
}
/**
* Whether the user may CREATE a new fork of the current workspace: their role is unrestricted, and
* Whether the user may CREATE a new fork of the current workspace: their role holds `fork`, and
* either forking isn't disabled or they can bypass the rule (workspace admins). Keeps the "Fork
* workspace" entry available to admins as the last-resort escape hatch on a locked prod.
*
* Any restricted role is refused because a fork does not carry the role: the server writes the
* forker's membership row from the parent's `is_admin` alone, so the forker gets the default role
* and forking would hand a restricted member a full-rights copy of the workspace. The server does
* not refuse it: this hides the affordance, it does not enforce the rule.
*/
export function canCreateFork(user: UserExt | undefined, caps: RoleCapabilities): boolean {
if (isRestricted(caps)) return false
if (!canFork(caps)) return false
return (
!isRuleActive('DisableWorkspaceForking') ||
canUserBypassRuleKind('DisableWorkspaceForking', user)
+17
View File
@@ -4,6 +4,7 @@ import {
applyCapabilitiesPatch,
baseCapabilitiesFor,
FULL_CAPABILITIES,
resolveCapabilities,
validRoleOr,
workspaceRolesErrors
} from './workspaceRoles'
@@ -62,3 +63,19 @@ describe('validRoleOr', () => {
expect(validRoleOr([role('builder')], 'admin')).toBe('admin')
})
})
describe('resolveCapabilities', () => {
it('derives an unset fork from the restriction, as the server does', () => {
const maintainer = resolveCapabilities(
baseCapabilitiesFor('maintainer'),
{ code: true, flows: 'full', apps: 'full' },
false
)
expect(maintainer.fork).toBe(true)
const developer = resolveCapabilities(baseCapabilitiesFor('developer'), { code: false }, false)
expect(developer.fork).toBe(false)
expect(resolveCapabilities(baseCapabilitiesFor('operator'), { fork: true }, false).fork).toBe(
true
)
})
})
+35
View File
@@ -48,6 +48,7 @@ export const FULL_CAPABILITIES: RoleCapabilities = {
apps: 'full',
manage_schedules: true,
manage_triggers: true,
fork: true,
visibility: ALL_VISIBLE
}
@@ -57,6 +58,7 @@ const OPERATOR_CAPABILITIES: RoleCapabilities = {
apps: 'none',
manage_schedules: true,
manage_triggers: true,
fork: false,
visibility: ALL_VISIBLE
}
@@ -86,6 +88,7 @@ export function applyCapabilitiesPatch(
if (patch.apps != null) out.apps = patch.apps
if (patch.manage_schedules != null) out.manage_schedules = patch.manage_schedules
if (patch.manage_triggers != null) out.manage_triggers = patch.manage_triggers
if (patch.fork != null) out.fork = patch.fork
for (const { key } of VISIBILITY_KEYS) {
const v = patch.visibility?.[key]
if (v != null) out.visibility[key] = v
@@ -93,6 +96,38 @@ export function applyCapabilitiesPatch(
return out
}
/** `RoleCapabilities::is_restricted`: visibility and `fork` are left out. */
export function capabilitiesRestricted(caps: RoleCapabilities): boolean {
return (
!caps.code ||
caps.flows !== 'full' ||
caps.apps !== 'full' ||
!caps.manage_schedules ||
!caps.manage_triggers
)
}
/**
* `patch` over `base` as `RoleCapabilities::resolve` does it: a `fork` that neither `patch` nor a
* layer under it sets is granted exactly to an unrestricted role. `forkSetBelow` says whether a
* layer `base` already includes sets it.
*/
export function resolveCapabilities(
base: RoleCapabilities,
patch: RoleCapabilitiesPatch | undefined,
forkSetBelow: boolean
): RoleCapabilities {
const out = applyCapabilitiesPatch(base, patch)
if (patch?.fork == null && !forkSetBelow) out.fork = !capabilitiesRestricted(out)
return out
}
/** Whether a server-resolved role's `fork` was set by a layer rather than derived. The listing
* does not say, so a value the fallback would have given anyway reads as derived. */
export function forkLooksSet(caps: RoleCapabilities): boolean {
return caps.fork !== !capabilitiesRestricted(caps)
}
/** The error the server raises for these capabilities, if any (`RoleCapabilities::validate`). */
export function capabilitiesError(caps: RoleCapabilities): string | undefined {
if (!caps.code && (caps.flows === 'full' || caps.apps === 'full')) {
@@ -2,14 +2,11 @@
import CreateWorkspace from '$lib/components/workspaceSettings/CreateWorkspace.svelte'
import { Alert } from '$lib/components/common'
import { roleCapabilities } from '$lib/stores'
import { isRestricted } from '$lib/roleCapabilities'
import { canFork } from '$lib/roleCapabilities'
</script>
<!-- The form is reachable by URL whatever the pickers show, and forking is what turns a restricted role
into a full member of the copy (the fork's membership row carries `is_admin` but not the
role). The server still accepts the call: this refuses the page, it does not enforce
the rule; see docs/operator-write-rights.md. -->
{#if isRestricted($roleCapabilities)}
<!-- The form is reachable by URL whatever the pickers show; the server refuses the fork too. -->
{#if !canFork($roleCapabilities)}
<div class="p-4 mt-12">
<Alert type="error" title="Unauthorized">Page not available for your role</Alert>
</div>
+1 -1
View File
@@ -1605,7 +1605,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. |
@@ -46,7 +46,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. |
+1 -1
View File
@@ -41,7 +41,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. |