mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 00:16:53 +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
+29
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+35
@@ -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"
|
||||
}
|
||||
-23
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+23
@@ -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 @@
|
||||
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;
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,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!([
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user