feat: configurable workspace roles with per-workspace overrides (#11821)

* feat: configurable workspace roles with per-workspace overrides

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

* fix: close role review gaps in pickers, declarative sync and removal

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

* docs: keep resolve_role_name doc on its function

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

* fix: validate overrides on instance role changes and bill admins fully

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

* fix: allow pipeline drafts and keep role checks on one connection

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

* docs: list data pipelines in operator_can_draft

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

* test: expect a 403 role refusal when an operator resolves jobs

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

* feat: require a valid license for custom workspace roles

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

* fix: freeze workspace roles when the license lapses

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

* chore: pin the ee companion for workspace roles

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

* refactor: broadcast role override changes from the write path, not a trigger

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

* chore: update ee-repo-ref to b5294d38db082bdd104d5358a47695390694c05d

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

Previous ee-repo-ref: 688e00c5982865b14098f530d54280e2311994c0

New ee-repo-ref: b5294d38db082bdd104d5358a47695390694c05d

Automated by sync-ee-ref workflow.

* fix: let an admin row that kept the operator flag hold full rights everywhere

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

* fix: treat superadmin members as admins and return role names from get user

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

---------

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-21 15:51:54 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent c740a8f369
commit 21d2a49971
106 changed files with 3966 additions and 822 deletions
+3
View File
@@ -41,6 +41,9 @@ Open-source platform for internal tools, workflows, API integrations, background
for an inline app runnable that opts in), why an admin's roles are not added, the per-role-set
logins and the order their rows and logins are written in
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Workspace roles**: `docs/workspace-roles.md` — instance-defined roles with per-workspace
overrides, the capabilities a role holds, why `ApiAuthed::is_operator` must be derived from the
role, and the `role_seat` table billing reads
- **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are
enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option`
fields and a jsonb merge rather than a serde default
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM workspace_settings WHERE ($1::text IS NULL OR workspace_id = $1) AND (\n jsonb_typeof(auto_invite) = 'object' AND COALESCE(auto_invite->>'role',\n CASE WHEN (auto_invite->>'operator')::boolean THEN 'operator' ELSE 'developer' END) = $2\n OR EXISTS (SELECT 1 FROM jsonb_each_text(\n CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'\n THEN auto_invite->'instance_groups_roles' ELSE '{}'::jsonb END) g\n WHERE g.value = $2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "0d419f09cce9f33fecfabc62b17e8848ac8184d61d3f7cde126a56080aef643c"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM workspace_settings WHERE ($1::text IS NOT NULL AND workspace_id = $1\n OR $1::text IS NULL AND NOT COALESCE(role_overrides ? $2, false)) AND (\n auto_invite->>'role' = $2\n OR EXISTS (SELECT 1 FROM jsonb_each_text(\n CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'\n THEN auto_invite->'instance_groups_roles' ELSE '{}'::jsonb END) g\n WHERE g.value = $2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "0d69f5f6d2e3a4b7b801c6feea92a383e841fa7c3f1c5e2849481c6057ab1e84"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET role_overrides =\n (SELECT role_overrides FROM workspace_settings WHERE workspace_id = $2)\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "0d73210f3ce58d0dd8fd850c1df8d16414daad90ad8b0ea35a769d78626039aa"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT role_overrides FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "role_overrides",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "104b8d267f8f904122973d20629473ac77fad069e7c805cf3c2352bd72f8ffb6"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET workspace_role = $1, operator = $2 WHERE username = $3 AND workspace_id = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "1c97bf1af258b43fb8dedd3417d0e086996d5fcc3c270e5c1cc9824c30d82726"
}
@@ -55,11 +55,16 @@
},
{
"ordinal": 10,
"name": "workspace_role",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "super_admin!",
"type_info": "Bool"
},
{
"ordinal": 11,
"ordinal": 12,
"name": "name",
"type_info": "Varchar"
}
@@ -81,6 +86,7 @@
true,
true,
false,
true,
null,
true
]
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n workspace_invite.workspace_id,\n workspace_invite.email,\n workspace_invite.is_admin,\n workspace_invite.operator,\n COALESCE(workspace_invite.workspace_role, CASE WHEN workspace_invite.operator THEN 'operator' ELSE 'developer' END) AS \"role!\",\n workspace.parent_workspace_id\n FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id\n WHERE workspace_id = $1\n ORDER BY workspace_invite.email",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "role!",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "parent_workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
null,
true
]
},
"hash": "1fbb8493d20eacea2ac78c4473a85f96bd91de548185238f9fd66ac2b4a1e0a0"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM usr t WHERE ($1::text IS NULL OR t.workspace_id = $1) AND\n COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "205f2fb7629382294e53869aa5d2f722f631882929086c453b5825ab4b9c1cb8"
}
@@ -17,7 +17,8 @@
"script",
"flow",
"raw_app",
"asset"
"asset",
"agent"
]
}
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, workspace_role) SELECT $1, username, email, is_admin, created_at, operator, disabled, role, workspace_role FROM usr WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "25b09b592b6917214fd7cfabfece48d266b2d5dc11236c2bb41b1a927ede63d8"
}
@@ -0,0 +1,94 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n usr.is_admin, usr.operator, usr.workspace_role,\n usr.disabled, usr.is_service_account\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "parent_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "is_dev_workspace",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "dev_workspace_label",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "created_by?",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "workspace_role",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "is_service_account",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
false,
false,
false,
true,
false,
false
]
},
"hash": "2e8a53e73c3b5c2662efd0e2876f168c2f79258fb900199e8575973d5b3d32bb"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, operator, added_via, workspace_role) VALUES ($1, $2, $3, false, $4, $5, $6) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Jsonb",
"Varchar"
]
},
"nullable": []
},
"hash": "3b01c43a828ec5d61ed92a27bdb16b526db8e60887d6279b0e66197da686fbf8"
}
@@ -0,0 +1,16 @@
{
"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,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, workspace_role)\n VALUES ($1, $2, $3, $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "4c74a75d99f8d6689470da1d57d3db8bf47f11969a0c026a35a6673574e074d8"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -17,7 +17,8 @@
"script",
"flow",
"raw_app",
"asset"
"asset",
"agent"
]
}
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM workspace_settings WHERE ($1::text IS NULL OR workspace_id = $1) AND (\n auto_invite->>'role' = $2\n OR EXISTS (SELECT 1 FROM jsonb_each_text(\n CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'\n THEN auto_invite->'instance_groups_roles' ELSE '{}'::jsonb END) g\n WHERE g.value = $2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "65acc5b9322f03266079ea6051535168e2cd0e627e3bbff413e38f8a3c616aa0"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_invite WHERE\n workspace_id = $1 AND email = $2 AND is_admin = $3\n AND ($5::text IS NOT NULL OR operator = $4)\n AND ($5::text IS NULL OR $5 = COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END))",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Bool",
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "729852e65e4cf22c474850d46a2065a203ca86b40e8388db963c183ac8fb4fd6"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin, operator, workspace_role",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "workspace_role",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "72bc3795182333d203b4731defb400a6fa59e8a88bebd6e63437e9cb4c491333"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, is_admin, operator, workspace_role FROM usr WHERE\n email = $1 AND workspace_id = $2 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "workspace_role",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "7341d832643627cdc33a6eefbca5844a0fbb10fb79e25a604eeb0d83d2122d96"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM workspace_invite t WHERE ($1::text IS NOT NULL AND t.workspace_id = $1\n OR $1::text IS NULL AND NOT EXISTS (SELECT 1 FROM workspace_settings ws\n WHERE ws.workspace_id = t.workspace_id AND ws.role_overrides ? $2)) AND\n COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "74190dc8f1049b61adfcefe19df069204bfc974a55da927658947f3e335da3f5"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, is_admin, operator, workspace_role FROM usr\n WHERE workspace_id = $1 AND email = $2 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "workspace_role",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "7e71cb88613a78b1b6bd0327970e7c732775949f92e1f099ff997d385b025f55"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1\n WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "80977e90833b63da4cdde111db80d78c8e577dbce51f8571a9ffe3e8e1e652a1"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator, workspace_role)\n VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, email)\n DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator,\n workspace_role = EXCLUDED.workspace_role",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "80d62bd0f372a322d9c2a548a66669551ed6860b0839dd019967569e2325b0bc"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT is_admin, operator, workspace_role, email FROM usr where username = $1 AND workspace_id = $2 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "workspace_role",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true,
false
]
},
"hash": "884dd7d41713a964a0fbb854d6e54cff4538e230b3cd72dd2e840a08b2a178d9"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM workspace_settings WHERE COALESCE(role_overrides ? $1, false)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9784d19363fa14b9d7cc49e208967000a7bc8e56adddcd9304b05daf814e5246"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET workspace_role = $1 WHERE workspace_id = $2 AND username = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "988c720384e4e2d70bfa11196f3a3514e0756722762fabe49e1d291ce5c09a19"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET role_overrides = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "a32aea24f8cc6e0aed80dfa1f0928fab0f26ee5a0b9550ea4ab04e2857bebeda"
}
@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT workspace_id, username, email, is_admin, created_at, operator, disabled,\n COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END) AS role,\n added_via, is_service_account\n FROM usr\n WHERE workspace_id = $1\n ORDER BY email\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "role",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "is_service_account",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
null,
true,
false
]
},
"hash": "a37bd72bb1a151e859b4a37a447887e613debd842e575cc740502144bb279bb3"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id,\n (auto_invite->>'operator')::boolean as \"is_operator: bool\",\n auto_invite->>'role' as \"role: String\",\n (auto_invite->>'mode') = 'add' as \"is_auto_add: bool\"\n FROM workspace_settings ws\n WHERE (auto_invite->>'domain' = $1 OR auto_invite->>'domain' = '*')\n AND (auto_invite->>'enabled')::boolean = true\n AND NOT EXISTS (SELECT 1 FROM usr WHERE workspace_id = ws.workspace_id AND email = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_operator: bool",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "role: String",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "is_auto_add: bool",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
null,
null,
null
]
},
"hash": "acd87efeb1e92f6c5f04f8482fde597611be96a8f2c6a8ea884c39b11620be57"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET added_via = $1, is_admin = $2 WHERE username = $3 AND workspace_id = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "ad218040fc53b063508774c6f04d5f82cc2121ea8519311add88f23f0ea0dae9"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, email, is_admin, operator, workspace_role,\n added_via->>'group' as granting_group\n FROM usr\n WHERE workspace_id = $1 AND added_via->>'source' = 'instance_group'\n ORDER BY email",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "workspace_role",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "granting_group",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
null
]
},
"hash": "b0e56637096b48867401967ad5ddc509bbd1e322788789a913d5148c1113643c"
}
@@ -11,7 +11,7 @@
],
"parameters": {
"Left": [
"Varchar",
"Text",
"Int8"
]
},
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n workspace_invite.workspace_id,\n workspace_invite.email,\n workspace_invite.is_admin,\n workspace_invite.operator,\n COALESCE(workspace_invite.workspace_role, CASE WHEN workspace_invite.operator THEN 'operator' ELSE 'developer' END) AS \"role!\",\n workspace.parent_workspace_id\n FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "role!",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "parent_workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
null,
true
]
},
"hash": "b57b038384e48f2e6f288056e7261459604a093a74e22770ccdc14207cacbfb0"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT is_admin, operator, workspace_role FROM usr where username = $1 AND workspace_id = $2 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "workspace_role",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "b8b89624212f32fb6e994b7488fa7152c190bab532406fd26d17dfbd2355c764"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND username = $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": "c1be495552d1bd4c38e378016324b3bc473599c4cb1b2181144988eb88e9fa17"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO role_seat (workspace_id, role, author)\n SELECT $1, role, author FROM role_seat WHERE workspace_id = $2\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "c7a1a350f95523df4e2012d2d21ec87a0016b1949cfe2d901aacc281d9442738"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator, workspace_role)\n VALUES ($1, $2, false, $3, $4)\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "ca4c2768d111fbd653f1a73afc669b58a0016bfb4f0dad133ef2c25b8cd8b4b6"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE role_seat SET workspace_id = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "cbc0edfcdda98420e728e35508e46d9b9ecb504cbea4976c8f45fc089cb81977"
}
@@ -39,7 +39,8 @@
"java",
"duckdb",
"ruby",
"rlang"
"rlang",
"dbt"
]
}
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator, workspace_role)\n SELECT $1::text, email, false, $3, $4 FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%@', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "ce033ee4db97c0f9ae643d287f1a3f1578a6be8686e02c3faa55100eb8fa3700"
}
@@ -0,0 +1,77 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, username, email, is_admin, created_at, operator, disabled,\n COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END) AS role,\n added_via, is_service_account\n FROM usr WHERE username = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "operator",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "role",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "is_service_account",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
null,
true,
false
]
},
"hash": "d46f75bb9d7144d571a31ddbb3c986ea2bfd561943ff512ac20f93203e02c3d8"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM usr t WHERE ($1::text IS NOT NULL AND t.workspace_id = $1\n OR $1::text IS NULL AND NOT EXISTS (SELECT 1 FROM workspace_settings ws\n WHERE ws.workspace_id = t.workspace_id AND ws.role_overrides ? $2)) AND\n COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "dcb5053bdae0a2d141f7e7cb16988f5bc2c40c098558d92ab768281f4e9664c5"
}
@@ -23,4 +23,4 @@
]
},
"hash": "ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b"
}
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM role_seat WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9"
}
@@ -34,7 +34,8 @@
"java",
"duckdb",
"ruby",
"rlang"
"rlang",
"dbt"
]
}
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM workspace_invite t WHERE ($1::text IS NULL OR t.workspace_id = $1) AND\n COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "f54795cfa04ef4d0f6ed05aee09513d23e8ef5412d030a172f81dd9a9be0bf2f"
}
+1 -1
View File
@@ -1 +1 @@
32784f3907f0a25fee4a9f96fa172420f17b3fea
b5294d38db082bdd104d5358a47695390694c05d
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS role_seat;
ALTER TABLE workspace_settings DROP COLUMN IF EXISTS role_overrides;
ALTER TABLE workspace_invite DROP COLUMN IF EXISTS workspace_role;
ALTER TABLE usr DROP COLUMN IF EXISTS workspace_role;
@@ -0,0 +1,68 @@
-- A member's role by name. NULL keeps the legacy reading of `operator` (`operator` / `developer`),
-- so rows written by code that predates roles stay meaningful.
ALTER TABLE usr ADD COLUMN IF NOT EXISTS workspace_role VARCHAR(50);
ALTER TABLE workspace_invite ADD COLUMN IF NOT EXISTS workspace_role VARCHAR(50);
-- Per-workspace role overrides, keyed by role name: `{"<role>": {"label"?, "capabilities": {..}}}`.
ALTER TABLE workspace_settings ADD COLUMN IF NOT EXISTS role_overrides JSONB;
-- The operator settings a workspace changed from their default become its override of the
-- `operator` role. Keys left at their default stay unset, so they follow the instance definition.
-- A value that is not a boolean reads as unset: one hand-edited row must not abort the upgrade.
CREATE OR REPLACE FUNCTION wm_try_bool(v TEXT) RETURNS BOOLEAN AS $$
BEGIN
RETURN v::boolean;
EXCEPTION WHEN invalid_text_representation THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
WITH caps AS (
SELECT workspace_id, jsonb_strip_nulls(jsonb_build_object(
'flows', CASE WHEN wm_try_bool(operator_settings->>'builder_flows') THEN 'compose' END,
'apps', CASE WHEN wm_try_bool(operator_settings->>'builder_apps') THEN 'compose' END,
'manage_schedules', CASE WHEN wm_try_bool(operator_settings->>'manage_schedules') IS false THEN false END,
'manage_triggers', CASE WHEN wm_try_bool(operator_settings->>'manage_triggers') IS false THEN false END,
'visibility', NULLIF(jsonb_strip_nulls(jsonb_build_object(
'runs', CASE WHEN wm_try_bool(operator_settings->>'runs') IS false THEN false END,
'schedules', CASE WHEN wm_try_bool(operator_settings->>'schedules') IS false THEN false END,
'resources', CASE WHEN wm_try_bool(operator_settings->>'resources') IS false THEN false END,
'variables', CASE WHEN wm_try_bool(operator_settings->>'variables') IS false THEN false END,
'assets', CASE WHEN wm_try_bool(operator_settings->>'assets') IS false THEN false END,
'triggers', CASE WHEN wm_try_bool(operator_settings->>'triggers') IS false THEN false END,
'audit_logs', CASE WHEN wm_try_bool(operator_settings->>'audit_logs') IS false THEN false END,
'groups', CASE WHEN wm_try_bool(operator_settings->>'groups') IS false THEN false END,
'folders', CASE WHEN wm_try_bool(operator_settings->>'folders') IS false THEN false END,
'workers', CASE WHEN wm_try_bool(operator_settings->>'workers') IS false THEN false END
)), '{}'::jsonb)
)) AS caps
FROM workspace_settings
WHERE operator_settings IS NOT NULL AND jsonb_typeof(operator_settings) = 'object'
)
UPDATE workspace_settings ws
SET role_overrides = jsonb_build_object('operator', jsonb_build_object('capabilities', caps.caps))
FROM caps
WHERE ws.workspace_id = caps.workspace_id AND caps.caps <> '{}'::jsonb;
DROP FUNCTION wm_try_bool(TEXT);
-- Whether a role costs a full (author) seat, materialized from the role configuration so billing
-- SQL needs no copy of the capability merge. `workspace_id = ''` holds the instance roles; a
-- workspace row exists only for a role that workspace overrides or adds.
CREATE TABLE IF NOT EXISTS role_seat (
workspace_id VARCHAR(50) NOT NULL,
role VARCHAR(50) NOT NULL,
author BOOLEAN NOT NULL,
PRIMARY KEY (workspace_id, role)
);
INSERT INTO role_seat (workspace_id, role, author)
VALUES ('', 'developer', true), ('', 'operator', false)
ON CONFLICT DO NOTHING;
INSERT INTO role_seat (workspace_id, role, author)
SELECT workspace_id,
'operator',
(role_overrides->'operator'->'capabilities'->>'flows') IS NOT NULL
OR (role_overrides->'operator'->'capabilities'->>'apps') IS NOT NULL
FROM workspace_settings
WHERE role_overrides ? 'operator'
ON CONFLICT DO NOTHING;
+9 -3
View File
@@ -1680,12 +1680,15 @@ async fn process_notify_event(
);
windmill_common::variables::CUSTOM_ENVS_CACHE.remove(payload);
}
"notify_operator_settings_change" => {
// Written by the legacy operator settings mirror, always alongside a role override.
c if c == "notify_operator_settings_change"
|| c == windmill_common::roles::ROLE_OVERRIDES_CHANGE_CHANNEL =>
{
tracing::info!(
"Operator settings change detected, invalidating operator rights cache: {}",
"Role overrides change detected, invalidating workspace roles cache: {}",
payload
);
windmill_common::workspaces::invalidate_operator_rights_cache(payload);
windmill_common::roles::invalidate_workspace_roles_cache(payload);
}
"notify_asset_producer_change" => {
tracing::debug!(
@@ -2042,6 +2045,9 @@ async fn process_notify_event(
DISABLE_PASSWORD_LOGIN_SETTING => {
load_disable_password_login(db).await;
}
windmill_common::global_settings::WORKSPACE_ROLES_SETTING => {
windmill_common::roles::invalidate_all_roles_cache();
}
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => {
load_mcp_disable_token_query_param(db).await;
}
+3 -3
View File
@@ -75,12 +75,12 @@ async fn test_app_preview_authorization(db: Pool<Postgres>) -> anyhow::Result<()
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 401,
status, 403,
"Operator must be rejected from app preview (got {status}): {body}"
);
assert!(
body.contains("Operators cannot run preview jobs"),
"rejection must be the operator guard, got: {body}"
body.contains("Your role cannot run preview jobs"),
"rejection must be the role guard, got: {body}"
);
// 2. The fix must NOT over-block the legitimate editor flow: a regular
+1 -1
View File
@@ -91,7 +91,7 @@ async fn operator_job_token(job_id: uuid::Uuid) -> String {
.expect("mint operator job token")
}
const OPERATOR_GUARD_MSG: &str = "Operators cannot run preview jobs";
const OPERATOR_GUARD_MSG: &str = "Your role cannot run preview jobs";
/// The fixture's deployed-script jobs of the operator: one running, one queued.
const RUNNING_JOB_ID: &str = "2aa0c0de-0000-4000-8000-000000000001";
+43 -7
View File
@@ -466,8 +466,8 @@ impl AuthCache {
let lookup = if super_admin {
Some((true, false))
} else {
sqlx::query!(
"SELECT is_admin, operator FROM usr where username = $1 AND \
let row = sqlx::query!(
"SELECT is_admin, operator, workspace_role FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id.as_ref().unwrap()
@@ -475,8 +475,24 @@ impl AuthCache {
.fetch_optional(&self.db)
.await
.ok()
.flatten()
.map(|r| (r.is_admin, r.operator))
.flatten();
match row {
Some(r) => {
// Fails closed: an unreadable role config
// gates the member rather than freeing it.
let restricted =
windmill_common::roles::member_is_restricted_db(
&self.db,
w_id.as_ref().unwrap(),
r.workspace_role,
r.operator,
)
.await
.unwrap_or(true);
Some((r.is_admin, restricted && !r.is_admin))
}
None => None,
}
};
if let Some((is_admin, is_operator)) = lookup {
@@ -596,15 +612,35 @@ impl AuthCache {
username_override_from_label(label);
if w_id.is_some() {
let row_o = sqlx::query!(
"SELECT username, is_admin, operator FROM usr WHERE
"SELECT username, is_admin, operator, workspace_role FROM usr WHERE
email = $1 AND workspace_id = $2 AND disabled = false",
&email,
w_id.as_ref().unwrap()
)
.map(|x| (x.username, x.is_admin, x.operator))
.map(|x| (x.username, x.is_admin, x.operator, x.workspace_role))
.fetch_optional(&self.db)
.await
.unwrap_or(Some(("error".to_string(), false, false)));
.unwrap_or(Some(("error".to_string(), false, false, None)));
let row_o = match row_o {
Some((username, is_admin, operator, workspace_role)) => {
// Fails closed, as above.
let restricted =
windmill_common::roles::member_is_restricted_db(
&self.db,
w_id.as_ref().unwrap(),
workspace_role,
operator,
)
.await
.unwrap_or(true);
Some((
username,
is_admin,
restricted && !is_admin && !super_admin,
))
}
None => None,
};
match row_o {
Some((username, is_admin, is_operator)) => {
+1 -1
View File
@@ -943,7 +943,7 @@ pub async fn gate_operator_writes(
let w_id = params.get("workspace_id").ok_or_else(|| {
Error::internal_err("operator write gate layered outside a workspaced router".to_string())
})?;
check_operator_can_manage(&db, w_id, authed.is_operator, kind).await?;
check_operator_can_manage(&db, w_id, &authed, kind).await?;
Ok(next.run(Request::from_parts(parts, body)).await)
}
+12 -36
View File
@@ -19,6 +19,7 @@ use windmill_api_auth::{
build_scope_path_predicate, check_scopes, get_scope_tags, maybe_refresh_folders,
require_owner_of_path, ApiAuthed,
};
use windmill_common::roles::AuthorLevel;
use windmill_common::workspaces::{
check_deploy_rules, check_operator_can_build, operator_builder_rights, BuilderKind,
RuleCheckResult,
@@ -244,7 +245,7 @@ async fn list_flows(
// Append the authed user's drafts at paths with no deployed flow; see scripts.rs.
if lq.include_draft_only.unwrap_or(false)
&& (!authed.is_operator || operator_builder_rights(&db, &w_id).await?.flows)
&& operator_builder_rights(&db, &w_id, &authed).await?.flows
&& offset == 0
&& lq.path_start.is_none()
&& lq.path_exact.is_none()
@@ -576,6 +577,7 @@ async fn list_paths_linking_agent(
async fn validate_flow(
new_flow: &NewFlow,
flows_level: AuthorLevel,
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
@@ -591,7 +593,7 @@ async fn validate_flow(
guard_flow_from_debounce_data(new_flow).await?;
if authed.is_operator {
if flows_level == AuthorLevel::Compose {
validate_operator_flow(
&new_flow.parse_flow_value()?,
&new_flow.tag,
@@ -732,14 +734,8 @@ async fn create_flow(
Path(w_id): Path<String>,
Json(mut nf): Json<NewFlow>,
) -> Result<(StatusCode, String)> {
check_operator_can_build(
&db,
&w_id,
authed.is_operator,
BuilderKind::Flows,
"create flows",
)
.await?;
let flows_level =
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "create flows").await?;
check_scopes(&authed, || format!("flows:write:{}", nf.path))?;
// A `<= 0` flow timeout is "unset", not a 0-second limit that kills every run instantly.
@@ -759,7 +755,7 @@ async fn create_flow(
return Err(Error::PermissionDenied(msg));
}
validate_flow(&nf, &authed, &db, &user_db, &w_id).await?;
validate_flow(&nf, flows_level, &authed, &db, &user_db, &w_id).await?;
if *CLOUD_HOSTED {
let nb_flows =
sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id)
@@ -1354,14 +1350,8 @@ async fn update_flow(
Path((w_id, flow_path)): Path<(String, StripPath)>,
Json(ef): Json<EditFlow>,
) -> Result<String> {
check_operator_can_build(
&db,
&w_id,
authed.is_operator,
BuilderKind::Flows,
"update flows",
)
.await?;
let flows_level =
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "update flows").await?;
let flow_path = flow_path.to_path();
// The URL identifies the flow being updated; the body path is only needed to rename.
let mut nf = ef.into_new_flow(flow_path);
@@ -1388,7 +1378,7 @@ async fn update_flow(
return Err(Error::PermissionDenied(msg));
}
validate_flow(&nf, &authed, &db, &user_db, &w_id).await?;
validate_flow(&nf, flows_level, &authed, &db, &user_db, &w_id).await?;
let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await;
// Before `tx` opens, for the reason given in `create_flow`.
@@ -2056,14 +2046,7 @@ async fn archive_flow_by_path(
Path((w_id, path)): Path<(String, StripPath)>,
Json(archived): Json<Archived>,
) -> Result<String> {
check_operator_can_build(
&db,
&w_id,
authed.is_operator,
BuilderKind::Flows,
"archive flows",
)
.await?;
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "archive flows").await?;
let path = path.to_path();
check_scopes(&authed, || format!("flows:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
@@ -2204,14 +2187,7 @@ async fn delete_flow_by_path(
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<DeleteFlowQuery>,
) -> Result<String> {
check_operator_can_build(
&db,
&w_id,
authed.is_operator,
BuilderKind::Flows,
"delete flows",
)
.await?;
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "delete flows").await?;
let path = path.to_path();
check_scopes(&authed, || format!("flows:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
@@ -117,7 +117,7 @@ async fn add_granular_acl(
}
if let Some(manage_kind) = manage_kind_for_acl_kind(kind) {
check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?;
check_operator_can_manage(&db, &w_id, &authed, manage_kind).await?;
}
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
@@ -368,7 +368,7 @@ async fn remove_granular_acl(
}
if let Some(manage_kind) = manage_kind_for_acl_kind(kind) {
check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?;
check_operator_can_manage(&db, &w_id, &authed, manage_kind).await?;
}
if !authed.is_admin {
@@ -1737,7 +1737,7 @@ async fn an_operator_neither_writes_nor_runs_a_migration(db: Pool<Postgres>) ->
let status = resp.status();
let text = resp.text().await?;
assert!(
status == 401 && text.contains("Operators cannot"),
status == 403 && text.contains("Your role cannot"),
"{status}: {text}"
);
}
@@ -143,6 +143,12 @@ async fn test_update_draft_write_authorization(db: Pool<Postgres>) -> anyhow::Re
.send()
.await?;
assert_eq!(r.status(), 200, "own namespace allowed");
let r = user2
.post(save_url(port, "data_pipeline", "u/test-user-2/pipeline"))
.json(&json!({ "value": { "a": 1 } }))
.send()
.await?;
assert_eq!(r.status(), 200, "a developer saves data pipeline drafts");
// Another user's namespace, no grant → rejected.
let r = user2
@@ -166,6 +172,16 @@ async fn test_update_draft_write_authorization(db: Pool<Postgres>) -> anyhow::Re
.send()
.await?;
assert_eq!(r.status(), 403, "operator rejected");
let r = op
.post(save_url(port, "data_pipeline", "u/test-user-3/pipeline"))
.json(&json!({ "value": { "a": 1 } }))
.send()
.await?;
assert_eq!(
r.status(),
403,
"a role that cannot code has no pipeline drafts"
);
Ok(())
}
@@ -626,10 +626,9 @@ async fn test_resolve_completed_jobs_scoping(db: Pool<Postgres>) -> anyhow::Resu
.json(&json!({ "job_ids": [mine] }))
.send()
.await?;
// Error::NotAuthorized maps to 401 (403 is RequireAdmin/PermissionDenied).
assert_eq!(
resp.status().as_u16(),
401,
403,
"operators must be refused: {}",
resp.text().await?
);
@@ -342,24 +342,57 @@ async fn test_token_update_persists(db: Pool<Postgres>) -> anyhow::Result<()> {
// 3. Channel Expiration Renewal — should_renew_channel
// ============================================================================
#[test]
fn test_require_native_integration_use_blocks_operators() {
// Regression: the integration *use* routes (calendar/drive/repo/event pickers)
// must reject read-only operators, who cannot create native triggers and so
// must not be able to drive the admin-configured integration's upstream API.
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
async fn test_require_native_integration_use_needs_code_and_manage_triggers(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
// The pickers drive the admin-connected account's upstream API: the default operator, which
// may manage triggers but not write code, must stay locked out of them.
windmill_common::roles::invalidate_all_roles_cache();
let mut operator = test_authed();
operator.username = "operator".to_string();
operator.is_admin = false;
operator.is_operator = true;
assert!(require_native_integration_use(&operator).is_err());
assert!(
require_native_integration_use(&db, "test-workspace", &operator)
.await
.is_err()
);
// A regular non-admin author (the population that configures triggers) is allowed.
let mut author = test_authed();
author.is_admin = false;
author.is_operator = false;
assert!(require_native_integration_use(&author).is_ok());
let set_override = |caps: &'static str| {
let db = db.clone();
async move {
sqlx::query(
"UPDATE workspace_settings SET role_overrides = jsonb_build_object('operator',
jsonb_build_object('capabilities', $1::jsonb))
WHERE workspace_id = 'test-workspace'",
)
.bind(caps)
.execute(&db)
.await
.unwrap();
windmill_common::roles::invalidate_all_roles_cache();
}
};
set_override(r#"{"code": true}"#).await;
assert!(
require_native_integration_use(&db, "test-workspace", &operator)
.await
.is_ok()
);
set_override(r#"{"code": true, "manage_triggers": false}"#).await;
assert!(
require_native_integration_use(&db, "test-workspace", &operator)
.await
.is_err()
);
// Admins are allowed.
assert!(require_native_integration_use(&test_authed()).is_ok());
assert!(
require_native_integration_use(&db, "test-workspace", &test_authed())
.await
.is_ok()
);
Ok(())
}
#[test]
@@ -1,7 +1,6 @@
use serde_json::json;
use serial_test::serial;
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::invalidate_operator_rights_cache;
use windmill_test_utils::*;
/// Every test here must be `#[serial]`: the rights cache is process-global and keyed by workspace id
@@ -22,16 +21,18 @@ fn client(token: &str) -> reqwest::Client {
async fn set_builder(db: &Pool<Postgres>, flows: bool, apps: bool) -> anyhow::Result<()> {
sqlx::query(
"UPDATE workspace_settings SET operator_settings = $1::text::jsonb WHERE workspace_id = $2",
"UPDATE workspace_settings SET role_overrides = $1::text::jsonb WHERE workspace_id = $2",
)
.bind(format!(
r#"{{"builder_flows": {flows}, "builder_apps": {apps}}}"#
r#"{{"operator": {{"capabilities": {{"flows": "{}", "apps": "{}"}}}}}}"#,
if flows { "compose" } else { "none" },
if apps { "compose" } else { "none" },
))
.bind(WS)
.execute(db)
.await?;
// The rights are read through a process-global 60s cache keyed by workspace id.
invalidate_operator_rights_cache(WS);
windmill_common::roles::invalidate_all_roles_cache();
Ok(())
}
@@ -1,6 +1,5 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::invalidate_operator_rights_cache;
use windmill_test_utils::*;
const WS: &str = "test-workspace";
@@ -19,14 +18,17 @@ fn operator_client() -> reqwest::Client {
async fn set_builder(db: &Pool<Postgres>, flows: bool) -> anyhow::Result<()> {
sqlx::query(
"UPDATE workspace_settings SET operator_settings = $1::text::jsonb WHERE workspace_id = $2",
"UPDATE workspace_settings SET role_overrides = $1::text::jsonb WHERE workspace_id = $2",
)
.bind(format!(r#"{{"builder_flows": {flows}}}"#))
.bind(format!(
r#"{{"operator": {{"capabilities": {{"flows": "{}"}}}}}}"#,
if flows { "compose" } else { "none" }
))
.bind(WS)
.execute(db)
.await?;
// The right is read through a process-global 60s cache keyed by workspace id.
invalidate_operator_rights_cache(WS);
windmill_common::roles::invalidate_all_roles_cache();
Ok(())
}
@@ -115,6 +117,16 @@ async fn test_operator_builder_flows_boundary(db: Pool<Postgres>) -> anyhow::Res
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let notified: Vec<String> =
sqlx::query_scalar("SELECT payload FROM notify_event WHERE channel = $1")
.bind(windmill_common::roles::ROLE_OVERRIDES_CHANGE_CHANNEL)
.fetch_all(&db)
.await?;
assert_eq!(
notified,
vec![WS.to_string()],
"other replicas drop their cached roles only through this event"
);
let resp = c
.post(format!("{api}/flows/create"))
@@ -1,7 +1,6 @@
use serde_json::json;
use serial_test::serial;
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::invalidate_operator_rights_cache;
use windmill_test_utils::*;
/// Every test here must be `#[serial]`, and start by dropping the cache entry. The rights cache is
@@ -44,7 +43,7 @@ async fn set_settings(api: &str, body: serde_json::Value) -> anyhow::Result<u16>
.send()
.await?;
let status = resp.status().as_u16();
invalidate_operator_rights_cache(WS);
windmill_common::roles::invalidate_all_roles_cache();
Ok(status)
}
@@ -53,7 +52,7 @@ async fn set_settings(api: &str, body: serde_json::Value) -> anyhow::Result<u16>
#[serial]
async fn test_operator_manage_rights(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_operator_rights_cache(WS);
windmill_common::roles::invalidate_all_roles_cache();
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
@@ -146,7 +145,7 @@ async fn test_manage_triggers_covers_a_route_outside_the_shared_handler(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_operator_rights_cache(WS);
windmill_common::roles::invalidate_all_roles_cache();
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
@@ -223,7 +222,7 @@ async fn test_manage_triggers_covers_a_route_outside_the_shared_handler(
#[serial]
async fn test_manage_rights_admit_matching_drafts(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_operator_rights_cache(WS);
windmill_common::roles::invalidate_all_roles_cache();
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
@@ -253,7 +252,9 @@ async fn test_manage_rights_admit_matching_drafts(db: Pool<Postgres>) -> anyhow:
};
let resp = c
.post(format!("{api}/drafts/update/trigger_schedule/u/operator/sched_draft"))
.post(format!(
"{api}/drafts/update/trigger_schedule/u/operator/sched_draft"
))
.json(&json!({ "value": {
"path": "u/operator/sched_draft",
"schedule": "0 0 12 * * *",
@@ -315,7 +316,7 @@ async fn test_manage_rights_admit_matching_drafts(db: Pool<Postgres>) -> anyhow:
#[serial]
async fn test_operators_draft_resources_and_variables(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_operator_rights_cache(WS);
windmill_common::roles::invalidate_all_roles_cache();
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
@@ -1262,5 +1262,35 @@ async fn test_admin_and_operator_are_exclusive(db: Pool<Postgres>) -> anyhow::Re
.await?;
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
// Rows written before the rule can still carry both flags: the admin wins, and `whoami`
// says so, since the frontend hides every gated page from an operator.
sqlx::query(
"UPDATE usr SET is_admin = true, operator = true
WHERE workspace_id = 'test-workspace' AND username = 'operator'",
)
.execute(&db)
.await?;
let resp = client()
.get(format!("{api}/users/whoami"))
.header("Authorization", "Bearer OPERATOR_TOKEN_1")
.send()
.await?;
assert_eq!(resp.status(), 200);
let me: serde_json::Value = resp.json().await?;
assert_eq!(
(me["is_admin"].as_bool(), me["operator"].as_bool()),
(Some(true), Some(false))
);
let resp = client()
.post(format!("{api}/scripts/create"))
.header("Authorization", "Bearer OPERATOR_TOKEN_1")
.json(&json!({
"path": "u/operator/legacy_admin", "summary": "", "description": "",
"content": "export function main() {}", "language": "deno", "schema": {}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
Ok(())
}
+7 -7
View File
@@ -926,13 +926,13 @@ async fn list_schedule(
&& lsq.summary.is_none()
&& lsq.broad_filter.is_none()
&& lsq.label.is_none()
&& (!authed.is_operator
|| windmill_common::workspaces::operator_can_draft(
&db,
&w_id,
UserDraftItemKind::TriggerSchedule,
)
.await?)
&& windmill_common::workspaces::operator_can_draft(
&db,
&w_id,
&authed,
UserDraftItemKind::TriggerSchedule,
)
.await?
{
let draft_only_rows = fetch_draft_only_list_rows(
&db,
+12 -18
View File
@@ -185,6 +185,10 @@ async fn list_scripts(
Query(pagination): Query<Pagination>,
Query(lq): Query<ListScriptQuery>,
) -> JsonResult<Vec<ListableScript>> {
// A role that cannot write code lists only what it can run: no libraries, no drafts.
let cannot_code = !windmill_common::roles::caller_capabilities(&db, &w_id, &authed)
.await?
.code;
let (per_page, offset) = paginate_without_limits(pagination);
let mut sqlb = SqlBuilder::select_from("script as o")
.fields(&[
@@ -255,7 +259,7 @@ async fn list_scripts(
.as_ref()
.map(|x| !x.contains(&"preprocessor".to_string()))
.unwrap_or(true))
|| authed.is_operator
|| cannot_code
{
// only include scripts that have a runnable entrypoint. Use a
// deny-list: anything that isn't a 'lib' (library script without
@@ -312,7 +316,7 @@ async fn list_scripts(
);
}
}
if authed.is_operator {
if cannot_code {
sqlb.and_where_eq("kind", quote("script"));
} else if let Some(lowercased_kinds) = lowercased_kinds {
let safe_kinds = lowercased_kinds
@@ -360,7 +364,7 @@ async fn list_scripts(
// `include_draft_only` so picker callers stay deployed-only (home page opts in);
// skipped past page 0 or under any narrowing filter to keep pagination clean.
if lq.include_draft_only.unwrap_or(false)
&& !authed.is_operator
&& !cannot_code
&& offset == 0
&& lq.path_start.is_none()
&& lq.path_exact.is_none()
@@ -1352,11 +1356,7 @@ async fn create_script_internal<'c>(
Option<PostCommitDeploy>,
Vec<MovedNativeTrigger>,
)> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot create scripts for security reasons".to_string(),
));
}
windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "create scripts").await?;
check_scopes(&authed, || format!("scripts:write:{}", ns.path))?;
// Normalize positive-only settings so a `<= 0` value (e.g. a CLI-pushed `0`) persists as
@@ -4086,11 +4086,8 @@ async fn archive_script_by_path(
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<()> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot archive scripts for security reasons".to_string(),
));
}
windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "archive scripts")
.await?;
let path = path.to_path();
check_scopes(&authed, || format!("scripts:write:{}", path))?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
@@ -4182,11 +4179,8 @@ async fn archive_script_by_hash(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, hash)): Path<(String, ScriptHash)>,
) -> JsonResult<Script<ScriptRunnableSettingsInline>> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot archive scripts for security reasons".to_string(),
));
}
windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "archive scripts")
.await?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
AuditAuthorable::username(&authed),
+30 -1
View File
@@ -74,7 +74,7 @@ use windmill_common::{
RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING,
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
WS_BASE_URL_SETTING,
WORKSPACE_ROLES_SETTING, WS_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -969,6 +969,8 @@ pub async fn set_global_setting_internal(
bump_instance_ai_config_revision();
}
run_setting_post_write_hook(db, &key).await?;
// Tag reads are served from an in-memory cache that this process otherwise only
// refreshes on the next global-settings poll, so without this a refetch right after
// the write still returns the pre-write list. The setting is already persisted at
@@ -983,6 +985,14 @@ pub async fn set_global_setting_internal(
Ok(())
}
/// Side effects a setting needs once it is stored, for both the per-key and the bulk endpoint.
async fn run_setting_post_write_hook(db: &DB, key: &str) -> error::Result<()> {
if key == WORKSPACE_ROLES_SETTING {
windmill_common::roles::after_instance_roles_write(db).await?;
}
Ok(())
}
/// Run side-effect hooks for specific settings before writing to DB.
/// Extracted from `set_global_setting_internal` for reuse by the bulk endpoint.
async fn run_setting_pre_write_hook(
@@ -999,6 +1009,9 @@ async fn run_setting_pre_write_hook(
error::Error::BadRequest(format!("The dev workspace tag suffix {e}"))
})?,
},
WORKSPACE_ROLES_SETTING => {
windmill_common::roles::validate_instance_roles_write(db, Some(value)).await?;
}
// The instance AI config is written as an untyped blob through this generic
// endpoint, so it never passes the typed check the workspace handler applies.
// Rates that reach a cost total unbounded would make it negative or infinite.
@@ -1369,6 +1382,14 @@ async fn set_instance_config(
run_setting_pre_write_hook(&db, key, value).await?;
}
}
if settings_diff
.deletes
.iter()
.any(|k| k == WORKSPACE_ROLES_SETTING)
{
run_setting_pre_write_hook(&db, WORKSPACE_ROLES_SETTING, &serde_json::Value::Null)
.await?;
}
let auditor = InstanceConfigAuditor { db: &db, author: &authed, source: None };
// The external-instance write removes its own key from the diff.
windmill_common::external_instance_pg::write_external_instance_pg_from_diff(
@@ -1384,6 +1405,14 @@ async fn set_instance_config(
if ai_config_changed {
bump_instance_ai_config_revision();
}
for key in settings_diff
.upserts
.iter()
.map(|(k, _)| k)
.chain(settings_diff.deletes.iter())
{
run_setting_post_write_hook(&db, key).await?;
}
}
if !desired.worker_configs.is_empty() {
+132 -73
View File
@@ -262,6 +262,7 @@ pub struct WorkspaceInvite {
pub email: String,
pub is_admin: bool,
pub operator: bool,
pub role: String,
pub parent_workspace_id: Option<String>,
}
@@ -301,7 +302,10 @@ pub struct EditUser {
#[derive(Deserialize)]
pub struct EditWorkspaceUser {
pub is_admin: Option<bool>,
/// Legacy role selector: `true` is the `operator` role, `false` `developer`. `role` wins.
pub operator: Option<bool>,
/// The member's role, one of the workspace's roles.
pub role: Option<String>,
pub disabled: Option<bool>,
}
@@ -414,7 +418,9 @@ async fn list_users(
let rows = sqlx::query_as!(
User,
"
SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account
SELECT workspace_id, username, email, is_admin, created_at, operator, disabled,
COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END) AS role,
added_via, is_service_account
FROM usr
WHERE workspace_id = $1
ORDER BY email
@@ -636,6 +642,7 @@ async fn list_invites(
workspace_invite.email,
workspace_invite.is_admin,
workspace_invite.operator,
COALESCE(workspace_invite.workspace_role, CASE WHEN workspace_invite.operator THEN 'operator' ELSE 'developer' END) AS \"role!\",
workspace.parent_workspace_id
FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE email = $1",
authed.email
@@ -765,7 +772,7 @@ async fn whoami(
authed: ApiAuthed,
) -> JsonResult<UserInfo> {
let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref());
let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed;
let ApiAuthed { username, email, is_admin, is_operator, groups, folders, .. } = authed;
// A guest would otherwise fall through to the non-member branch below and be
// handed a `superadmin` role. Answer it here, as the operator-shaped identity it is.
if is_guest {
@@ -794,7 +801,13 @@ async fn whoami(
// never happen, but guard against it so a non-member superadmin is never
// shown another member's identity/role.
if let Some(user) = user.filter(|u| u.email == email) {
Ok(Json(user))
// The frontend gates pages on `operator`, so it reports what `caller_capabilities`
// decides rather than the raw column: an admin (or superadmin) whose row still carries
// `operator` holds everything.
Ok(Json(UserInfo {
operator: is_operator && !is_admin,
..user
}))
} else {
Ok(Json(UserInfo {
workspace_id: w_id,
@@ -937,6 +950,7 @@ pub struct User2 {
#[serde(skip_serializing_if = "Option::is_none")]
pub added_via: Option<serde_json::Value>,
pub is_service_account: bool,
pub workspace_role: Option<String>,
}
async fn get_user(w_id: &str, username: &str, db: &DB) -> Result<Option<UserInfo>> {
@@ -972,7 +986,10 @@ async fn get_user(w_id: &str, username: &str, db: &DB) -> Result<Option<UserInfo
created_at: usr.created_at,
operator: usr.operator,
disabled: usr.disabled,
role: usr.role,
role: Some(windmill_common::roles::resolve_role_name(
usr.workspace_role,
usr.operator,
)),
folders_read: folders.clone().into_iter().map(|x| x.0).collect(),
folders: folders
.clone()
@@ -1093,7 +1110,7 @@ async fn accept_invite(
let mut tx = db.begin().await?;
let r = sqlx::query!(
"DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin, operator",
"DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin, operator, workspace_role",
nu.workspace_id,
authed.email,
)
@@ -1130,6 +1147,16 @@ async fn accept_invite(
tx,
)
.await?;
if let Some(role) = &r.workspace_role {
sqlx::query!(
"UPDATE usr SET workspace_role = $1 WHERE workspace_id = $2 AND username = $3",
role,
&nu.workspace_id,
&username
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
@@ -1307,7 +1334,10 @@ async fn get_workspace_user(
let user = sqlx::query_as!(
User,
"SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account FROM usr WHERE username = $1 AND workspace_id = $2",
"SELECT workspace_id, username, email, is_admin, created_at, operator, disabled,
COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END) AS role,
added_via, is_service_account
FROM usr WHERE username = $1 AND workspace_id = $2",
&username_to_update,
&w_id
)
@@ -1325,21 +1355,31 @@ async fn update_workspace_user(
Path((w_id, username_to_update)): Path<(String, String)>,
Json(eu): Json<EditWorkspaceUser>,
) -> Result<String> {
let mut tx = db.begin().await?;
require_admin(authed.is_admin, &authed.username)?;
#[cfg(feature = "enterprise")]
let seat_baseline = windmill_common::ee_oss::seat_cap_baseline(&db).await?;
let mut tx = db.begin().await?;
update_workspace_user_internal(
&w_id,
&username_to_update,
eu.is_admin,
eu.operator,
eu.role,
eu.disabled,
&mut tx,
Some(&authed),
)
.await?;
#[cfg(feature = "enterprise")]
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_role_change(&seat_baseline, &mut tx).await?
{
return Err(Error::BadRequest(msg));
}
let user_email = sqlx::query_scalar!(
"SELECT email FROM usr WHERE username = $1 AND workspace_id = $2",
&username_to_update,
@@ -1417,65 +1457,44 @@ async fn convert_user_to_group(
));
}
// Determine the group with highest precedence (same logic as reconcile_workspace_instance_groups)
let roles: std::collections::HashMap<String, String> =
if let Some(roles_json) = &eligible_groups[0].instance_groups_roles {
serde_json::from_value(roles_json.clone()).unwrap_or_default()
} else {
std::collections::HashMap::new()
};
let roles: std::collections::HashMap<String, String> = eligible_groups[0]
.instance_groups_roles
.clone()
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
let group_names: Vec<String> = eligible_groups
.iter()
.map(|g| g.group_name.clone())
.collect();
let role_order: Vec<String> = windmill_common::roles::workspace_roles_on(&mut *tx, &w_id)
.await?
.iter()
.map(|r| r.name.clone())
.collect();
let (primary_group_name, is_admin, role) =
windmill_common::users::compute_highest_workspace_role(
&group_names,
&group_names,
&roles,
&role_order,
);
let mut best_group = &eligible_groups[0].group_name;
let mut best_precedence = 0u8;
for group in &eligible_groups {
let default_role = "developer".to_string();
let role = roles.get(&group.group_name).unwrap_or(&default_role);
let precedence = match role.as_str() {
"admin" => 3,
"developer" => 2,
"operator" => 1,
_ => 2,
};
if precedence > best_precedence {
best_precedence = precedence;
best_group = &group.group_name;
}
}
let primary_group_name = best_group;
// Determine role from group configuration using the selected primary group
let default_role = "developer".to_string();
let role = roles
.get(primary_group_name)
.unwrap_or(&default_role)
.as_str();
let (is_admin, is_operator) = match role {
"admin" => (true, false),
"operator" => (false, true),
_ => (false, false),
};
// Update user with instance group information
let instance_group_source = serde_json::json!({
"source": "instance_group",
"group": primary_group_name
});
sqlx::query!(
"UPDATE usr SET added_via = $1, is_admin = $2, operator = $3 WHERE username = $4 AND workspace_id = $5",
"UPDATE usr SET added_via = $1, is_admin = $2 WHERE username = $3 AND workspace_id = $4",
instance_group_source,
is_admin,
is_operator,
username_to_convert,
&w_id
)
.execute(&mut *tx)
.await?;
set_workspace_role(&mut tx, &w_id, &username_to_convert, &role).await?;
let role = if is_admin { "admin" } else { role.as_str() };
audit_log(
&mut *tx,
@@ -2414,26 +2433,28 @@ lazy_static::lazy_static! {
}
/// Internal helper for updating workspace user permissions - used by both API and system operations
///
/// `role` names one of the workspace's roles; without it, the legacy `operator` flag selects the
/// `operator` or `developer` role. Admin is a flag on top of the role, so it is set independently.
pub async fn update_workspace_user_internal(
w_id: &str,
username_to_update: &str,
is_admin: Option<bool>,
operator: Option<bool>,
role: Option<String>,
disabled: Option<bool>,
tx: &mut Transaction<'_, Postgres>,
authed: Option<&ApiAuthed>, // None for system operations
) -> Result<()> {
// The role is one choice stored as two flags. Granting one clears the other, or a request
// setting only `is_admin` leaves a user shown as admin but refused as an operator.
let (is_admin, operator) = match (is_admin, operator) {
(Some(true), Some(true)) => {
return Err(Error::BadRequest(
"A user cannot be both admin and operator".to_string(),
))
}
(Some(true), _) => (Some(true), Some(false)),
(_, Some(true)) => (Some(false), Some(true)),
flags => flags,
if role.is_none() && is_admin == Some(true) && operator == Some(true) {
return Err(Error::BadRequest(
"A user cannot be both admin and operator".to_string(),
));
}
// The legacy selector made admin and operator one choice: granting operator demoted.
let is_admin = match (&role, is_admin, operator) {
(None, None, Some(true)) => Some(false),
_ => is_admin,
};
if let Some(a) = is_admin {
sqlx::query_scalar!(
@@ -2445,15 +2466,20 @@ pub async fn update_workspace_user_internal(
.execute(&mut **tx)
.await?;
}
if let Some(a) = operator {
sqlx::query_scalar!(
"UPDATE usr SET operator = $1 WHERE username = $2 AND workspace_id = $3",
a,
username_to_update,
w_id
)
.execute(&mut **tx)
.await?;
// A legacy payload granting admin also cleared `operator`; keep that, as the default role.
let role = match (role, is_admin, operator) {
(Some(role), _, _) => Some(role),
(None, _, Some(true)) => Some(windmill_common::roles::OPERATOR_ROLE.to_string()),
(None, Some(true), _) | (None, _, Some(false)) => {
let roles = windmill_common::roles::workspace_roles_on(&mut **tx, w_id).await?;
Some(windmill_common::roles::default_role_name(
roles.iter().map(|r| r.name.as_str()),
))
}
_ => None,
};
if let Some(role) = role {
set_workspace_role(tx, w_id, username_to_update, &role).await?;
}
if let Some(a) = disabled {
sqlx::query_scalar!(
@@ -2483,6 +2509,39 @@ pub async fn update_workspace_user_internal(
Ok(())
}
/// Gives a member role `role`, which must be one of the workspace's roles. `usr.operator` is
/// kept as "this role cannot write code", the reading clients that predate roles give it.
///
/// AUTHORIZATION: changes what the member may do and checks nothing. Callers MUST have verified
/// that the caller is an admin of `w_id`, or run as trusted server-side code (instance group sync,
/// invite acceptance).
pub async fn set_workspace_role(
tx: &mut Transaction<'_, Postgres>,
w_id: &str,
username: &str,
role: &str,
) -> Result<()> {
let caps = windmill_common::roles::workspace_roles_on(&mut **tx, w_id)
.await?
.iter()
.find(|r| r.name == role)
.map(|r| r.capabilities)
.ok_or_else(|| {
Error::BadRequest(format!("role `{role}` is not defined in workspace {w_id}"))
})?;
sqlx::query!(
"UPDATE usr SET workspace_role = $1, operator = $2 WHERE username = $3 AND workspace_id = $4",
role,
!caps.code,
username,
w_id
)
.execute(&mut **tx)
.await?;
windmill_common::roles::invalidate_member_role_cache(w_id, username);
Ok(())
}
/// Internal helper for deleting workspace users - used by both API and system operations
pub async fn delete_workspace_user_internal(
w_id: &str,
@@ -157,15 +157,17 @@ async fn ensure_may_edit_migration(
Ok(())
}
/// Operators run deployed code only, and a migration is SQL written or run by hand. Checked before
/// anything else, including the role checks: on a data table not under roles those pass everyone.
fn ensure_not_operator(authed: &ApiAuthed) -> Result<()> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot manage or run data table migrations".to_string(),
));
}
Ok(())
/// A migration is SQL written or run by hand, so it needs a role that may run code of its own.
/// Checked before anything else, including the data table role checks: on a data table not under
/// roles those pass everyone.
async fn ensure_not_operator(db: &DB, w_id: &str, authed: &ApiAuthed) -> Result<()> {
windmill_common::workspaces::check_role_can_code(
db,
w_id,
authed,
"manage or run data table migrations",
)
.await
}
/// Whether new migrations declare a scope, which the editors push for but do not require.
@@ -691,7 +693,7 @@ async fn run_datatable_migrations(
Path((w_id, datatable_name)): Path<(String, String)>,
Query(query): Query<RunDatatableMigrationsQuery>,
) -> JsonResult<RunDatatableMigrationsResult> {
ensure_not_operator(&authed)?;
ensure_not_operator(&db, &w_id, &authed).await?;
// Before the admin connection is opened at all: the bookkeeping below is created and read
// through it, so a caller no role covers must be refused here rather than after the fact.
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
@@ -827,7 +829,7 @@ async fn rollback_datatable_migrations(
Path((w_id, datatable_name)): Path<(String, String)>,
Query(query): Query<RollbackDatatableMigrationsQuery>,
) -> JsonResult<RollbackDatatableMigrationsResult> {
ensure_not_operator(&authed)?;
ensure_not_operator(&db, &w_id, &authed).await?;
// Before the admin connection is opened at all: the bookkeeping below is created and read
// through it, so a caller no role covers must be refused here rather than after the fact.
crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed)
@@ -1488,7 +1490,7 @@ async fn create_datatable_migration(
Path((w_id, datatable_name)): Path<(String, String)>,
Json(payload): Json<CreateDatatableMigration>,
) -> JsonResult<DatatableMigration> {
ensure_not_operator(&authed)?;
ensure_not_operator(&db, &w_id, &authed).await?;
validate_datatable_path_segment(&datatable_name)?;
validate_migration_name(&payload.name)?;
let scope = parse_migration_schemas(&payload.code_up)?;
@@ -1555,7 +1557,7 @@ async fn delete_datatable_migration(
Extension(db): Extension<DB>,
Path((w_id, datatable_name, timestamp)): Path<(String, String, i64)>,
) -> Result<String> {
ensure_not_operator(&authed)?;
ensure_not_operator(&db, &w_id, &authed).await?;
let deleted_message = format!("Deleted migration {} from {}", timestamp, datatable_name);
// A definition already gone is not an error: `wmill sync push` deletes whatever the repo
@@ -1692,7 +1694,7 @@ async fn upsert_datatable_migration(
Path((w_id, datatable_name)): Path<(String, String)>,
Json(payload): Json<UpsertDatatableMigration>,
) -> Result<String> {
ensure_not_operator(&authed)?;
ensure_not_operator(&db, &w_id, &authed).await?;
validate_datatable_path_segment(&datatable_name)?;
validate_migration_name(&payload.name)?;
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
@@ -1896,7 +1898,7 @@ async fn generate_initial_datatable_migration(
Extension(db): Extension<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<DatatableMigration> {
ensure_not_operator(&authed)?;
ensure_not_operator(&db, &w_id, &authed).await?;
// Returns a `pg_dump` of the whole schema and writes into the data table's own bookkeeping, so
// it answers to the workspace that governs it rather than to whoever is asking.
ensure_datatable_admin_access(
@@ -11,6 +11,7 @@ pub mod datatable_permissions_oss;
pub mod datatable_replay_oss;
pub mod deployment_requests;
pub mod remote_deploy;
pub mod workspace_roles;
pub mod workspaces;
pub mod workspaces_extra;
pub mod workspaces_oss;
@@ -0,0 +1,290 @@
//! The workspace's roles and its overrides of them. See `windmill_common::roles`.
use axum::{
extract::{Extension, Path},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use windmill_api_auth::ApiAuthed;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
error::{Error, JsonResult, Result},
roles::{
self, instance_roles, load_role_overrides, EffectiveRole, RoleCapabilities, RoleOverrides,
RoleSource, OPERATOR_ROLE,
},
utils::require_admin,
DB,
};
use windmill_git_sync::handle_deployment_metadata;
pub fn workspaced_service() -> Router {
Router::new()
.route("/roles", get(list_roles))
.route("/role_overrides", post(update_role_overrides))
}
#[derive(Serialize)]
struct WorkspaceRoles {
roles: Vec<EffectiveRole>,
overrides: RoleOverrides,
/// Whether this workspace may add roles of its own.
custom_roles_enabled: bool,
/// The caller's role, `None` for a caller with no membership (a superadmin, a guest).
my_role: Option<String>,
my_capabilities: RoleCapabilities,
}
async fn list_roles(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(db): Extension<DB>,
) -> JsonResult<WorkspaceRoles> {
let roles = roles::workspace_roles(&db, &w_id).await?;
let overrides = load_role_overrides(&db, &w_id).await?;
let my_role = sqlx::query!(
"SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND username = $2",
&w_id,
&authed.username
)
.fetch_optional(&db)
.await?
.map(|r| roles::resolve_role_name(r.workspace_role, r.operator));
// Not `caller_capabilities`: its fast path answers FULL for an unrestricted member, which
// drops the visibility a role may still withhold.
let my_capabilities = match &my_role {
_ if authed.is_admin => roles::RoleCapabilities::FULL,
Some(role) => roles::role_capabilities_by_name(&db, &w_id, role).await?,
None => roles::RoleCapabilities::NONE,
};
Ok(Json(WorkspaceRoles {
roles: roles.as_ref().clone(),
overrides,
custom_roles_enabled: roles::custom_roles_enabled(),
my_role,
my_capabilities,
}))
}
async fn update_role_overrides(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(db): Extension<DB>,
Json(overrides): Json<RoleOverrides>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
write_role_overrides(&db, &authed, &w_id, overrides, None).await?;
Ok("Role overrides updated".to_string())
}
/// Replaces the workspace's role overrides with `overrides`. Refuses removing a workspace-only role
/// anything still names, and a change that pushes an offline license over its seat cap.
/// `operator_settings` is the legacy `/operator_settings` payload, mirrored into its column for
/// git sync and for servers that predate roles.
pub(crate) async fn write_role_overrides(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
overrides: RoleOverrides,
operator_settings: Option<serde_json::Value>,
) -> Result<()> {
let instance = instance_roles(db).await?;
roles::validate_overrides(&instance, &overrides)?;
roles::check_frozen_overrides(
&instance,
&roles::load_role_overrides(db, w_id).await?,
&overrides,
)?;
let previous = roles::workspace_roles(db, w_id).await?;
let removed: Vec<String> = previous
.iter()
.filter(|r| r.source == RoleSource::Workspace && !overrides.contains_key(&r.name))
.map(|r| r.name.clone())
.collect();
for name in &removed {
roles::ensure_role_unused(db, Some(w_id), name).await?;
}
let overrides_json = serde_json::to_value(&overrides)
.map_err(|e| Error::internal_err(format!("serializing role overrides: {e:#}")))?;
#[cfg(feature = "enterprise")]
let seat_baseline = windmill_common::ee_oss::seat_cap_baseline(db).await?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET role_overrides = $1 WHERE workspace_id = $2",
if overrides.is_empty() {
None
} else {
Some(&overrides_json)
},
w_id
)
.execute(&mut *tx)
.await?;
roles::notify_role_overrides_change(&mut tx, w_id).await?;
if let Some(settings) = &operator_settings {
sqlx::query!(
"UPDATE workspace_settings
SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1
WHERE workspace_id = $2",
settings,
w_id
)
.execute(&mut *tx)
.await?;
}
roles::invalidate_workspace_roles_cache(w_id);
roles::sync_role_seats(&mut tx, Some(w_id)).await?;
#[cfg(feature = "enterprise")]
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_role_change(&seat_baseline, &mut tx).await?
{
return Err(Error::BadRequest(msg));
}
audit_log(
&mut *tx,
authed,
if operator_settings.is_some() {
"workspaces.update_operator_settings"
} else {
"workspaces.update_role_overrides"
},
ActionKind::Update,
w_id,
None,
Some([("role_overrides", overrides_json.to_string().as_str())].into()),
)
.await?;
tx.commit().await?;
roles::invalidate_workspace_roles_cache(w_id);
handle_deployment_metadata(
&authed.email,
&authed.username,
db,
w_id,
windmill_git_sync::DeployedObject::Settings {
setting_type: if operator_settings.is_some() {
"operator_settings".to_string()
} else {
"role_overrides".to_string()
},
},
Some("Workspace roles updated".to_string()),
false,
None,
)
.await?;
Ok(())
}
/// The legacy `/operator_settings` payload. Visibility flags always serialize; the rights are
/// `Option` because git-sync posts whole files that may predate them, and an absent key must
/// leave the stored value alone.
#[derive(Deserialize, Serialize)]
pub(crate) struct ChangeOperatorSettings {
#[serde(default)]
runs: bool,
#[serde(default)]
schedules: bool,
#[serde(default)]
resources: bool,
#[serde(default)]
variables: bool,
#[serde(default)]
assets: bool,
#[serde(default)]
triggers: bool,
#[serde(default)]
audit_logs: bool,
#[serde(default)]
groups: bool,
#[serde(default)]
folders: bool,
#[serde(default)]
workers: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
builder_flows: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
builder_apps: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manage_schedules: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manage_triggers: Option<bool>,
}
/// Applies a legacy operator settings payload as an override of the `operator` role. A key equal
/// to what the instance gives the role is dropped rather than pinned, so it keeps following the
/// instance definition.
pub(crate) async fn update_operator_settings(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(db): Extension<DB>,
Json(settings): Json<ChangeOperatorSettings>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let instance = instance_roles(&db).await?;
let Some(base) = roles::effective_roles(&instance, &RoleOverrides::new())?
.into_iter()
.find(|r| r.name == OPERATOR_ROLE)
else {
return Err(Error::BadRequest(
"this instance defines no `operator` role: edit the workspace roles instead"
.to_string(),
));
};
let base = base.capabilities;
let mut overrides = load_role_overrides(&db, &w_id).await?;
let current = roles::workspace_roles(&db, &w_id)
.await?
.iter()
.find(|r| r.name == OPERATOR_ROLE)
.map(|r| r.capabilities)
.unwrap_or(base);
let mut target = current;
let v = &mut target.visibility;
(v.runs, v.schedules, v.resources, v.variables, v.assets) = (
settings.runs,
settings.schedules,
settings.resources,
settings.variables,
settings.assets,
);
(v.triggers, v.audit_logs, v.groups, v.folders, v.workers) = (
settings.triggers,
settings.audit_logs,
settings.groups,
settings.folders,
settings.workers,
);
let compose_or_keep = |cur: roles::AuthorLevel, grant: Option<bool>| match grant {
Some(true) if cur == roles::AuthorLevel::None => roles::AuthorLevel::Compose,
Some(false) => roles::AuthorLevel::None,
_ => cur,
};
target.flows = compose_or_keep(current.flows, settings.builder_flows);
target.apps = compose_or_keep(current.apps, settings.builder_apps);
if let Some(b) = settings.manage_schedules {
target.manage_schedules = b;
}
if let Some(b) = settings.manage_triggers {
target.manage_triggers = b;
}
let entry = overrides.entry(OPERATOR_ROLE.to_string()).or_default();
entry.capabilities = roles::diff_capabilities(&base, &target);
if entry.label.is_none() && entry.capabilities == Default::default() {
overrides.remove(OPERATOR_ROLE);
}
let settings_json = serde_json::json!(settings);
write_role_overrides(&db, &authed, &w_id, overrides, Some(settings_json)).await?;
Ok("Operator settings updated successfully".to_string())
}
+156 -134
View File
@@ -192,7 +192,11 @@ pub fn workspaced_service() -> Router {
.route("/usage", get(get_usage))
.route("/used_triggers", get(get_used_triggers))
.route("/public_app_rate_limit", post(edit_public_app_rate_limit))
.route("/operator_settings", post(update_operator_settings))
.route(
"/operator_settings",
post(crate::workspace_roles::update_operator_settings),
)
.merge(crate::workspace_roles::workspaced_service())
.route(
"/create_workspace_fork_branch",
post(create_workspace_fork_branch),
@@ -442,6 +446,8 @@ struct RunSlackMessageTestJobResponse {
#[derive(Deserialize)]
pub struct EditAutoInvite {
pub operator: Option<bool>,
/// The role auto-invited members get; wins over `operator`.
pub role: Option<String>,
pub invite_all: Option<bool>,
pub auto_add: Option<bool>,
}
@@ -579,6 +585,9 @@ struct UserWorkspace {
pub created_by: Option<String>,
pub disabled: bool,
pub is_service_account: bool,
/// The member's role in this workspace.
pub role: String,
pub capabilities: windmill_common::roles::RoleCapabilities,
}
#[derive(Deserialize)]
@@ -596,7 +605,11 @@ struct ValidateUsername {
pub struct NewWorkspaceInvite {
pub email: String,
pub is_admin: bool,
/// Legacy role selector, `true` for the `operator` role; `role` wins.
#[serde(default)]
pub operator: bool,
#[serde(default)]
pub role: Option<String>,
}
#[derive(Deserialize)]
@@ -604,7 +617,34 @@ pub struct NewWorkspaceUser {
pub email: String,
pub username: Option<String>,
pub is_admin: bool,
#[serde(default)]
pub operator: bool,
#[serde(default)]
pub role: Option<String>,
}
/// The role a new member or invite gets, `role` if given, else the one the legacy `operator` flag
/// names, and the `usr.operator` value that goes with it: "cannot write code".
async fn resolve_new_member_role(
db: &DB,
w_id: &str,
operator: bool,
role: Option<&str>,
) -> Result<(String, bool)> {
let roles = windmill_common::roles::workspace_roles(db, w_id).await?;
let name = match role {
Some(role) => role.to_string(),
None if operator => windmill_common::roles::OPERATOR_ROLE.to_string(),
None => windmill_common::roles::default_role_name(roles.iter().map(|r| r.name.as_str())),
};
let caps = roles
.iter()
.find(|r| r.name == name)
.ok_or_else(|| {
Error::BadRequest(format!("role `{name}` is not defined in workspace {w_id}"))
})?
.capabilities;
Ok((name, !caps.code))
}
/// The role is one choice stored as two flags; both set would show as admin in the UI while the
@@ -718,6 +758,7 @@ async fn list_pending_invites(
workspace_invite.email,
workspace_invite.is_admin,
workspace_invite.operator,
COALESCE(workspace_invite.workspace_role, CASE WHEN workspace_invite.operator THEN 'operator' ELSE 'developer' END) AS \"role!\",
workspace.parent_workspace_id
FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id
WHERE workspace_id = $1
@@ -6689,13 +6730,11 @@ async fn user_workspaces(
Extension(db): Extension<DB>,
ApiAuthed { email, .. }: ApiAuthed,
) -> JsonResult<WorkspaceList> {
let mut tx = db.begin().await?;
let workspaces = sqlx::query_as!(
UserWorkspace,
let rows = sqlx::query!(
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,
workspace.is_dev_workspace, workspace.dev_workspace_label,
workspace.owner AS \"created_by?\",
CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,
usr.is_admin, usr.operator, usr.workspace_role,
usr.disabled, usr.is_service_account
FROM workspace
JOIN usr ON usr.workspace_id = workspace.id
@@ -6703,9 +6742,46 @@ async fn user_workspaces(
WHERE usr.email = $1 AND workspace.deleted = false",
email
)
.fetch_all(&mut *tx)
.fetch_all(&db)
.await?;
tx.commit().await?;
let mut workspaces = Vec::with_capacity(rows.len());
for r in rows {
let role = windmill_common::roles::resolve_role_name(r.workspace_role, r.operator);
let capabilities = if r.is_admin {
windmill_common::roles::RoleCapabilities::FULL
} else {
windmill_common::roles::role_capabilities_by_name(&db, &r.id, &role).await?
};
// The pre-roles shape the frontend gates on: present exactly for a restricted member.
let operator_settings = (capabilities.is_restricted()).then(|| {
let v = &capabilities.visibility;
Some(serde_json::json!({
"runs": v.runs, "schedules": v.schedules, "resources": v.resources,
"variables": v.variables, "assets": v.assets, "triggers": v.triggers,
"audit_logs": v.audit_logs, "groups": v.groups, "folders": v.folders,
"workers": v.workers,
"builder_flows": capabilities.flows != windmill_common::roles::AuthorLevel::None,
"builder_apps": capabilities.apps != windmill_common::roles::AuthorLevel::None,
"manage_schedules": capabilities.manage_schedules,
"manage_triggers": capabilities.manage_triggers,
}))
});
workspaces.push(UserWorkspace {
id: r.id,
name: r.name,
username: r.username,
color: r.color,
operator_settings,
parent_workspace_id: r.parent_workspace_id,
is_dev_workspace: r.is_dev_workspace,
dev_workspace_label: r.dev_workspace_label,
created_by: r.created_by,
disabled: r.disabled,
is_service_account: r.is_service_account,
role,
capabilities,
});
}
Ok(Json(WorkspaceList { email, workspaces }))
}
@@ -7672,9 +7748,9 @@ async fn copy_workspace_members(
// 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)
"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
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",
@@ -7684,6 +7760,27 @@ async fn copy_workspace_members(
)
.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)
WHERE workspace_id = $1",
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"INSERT INTO role_seat (workspace_id, role, author)
SELECT $1, role, author FROM role_seat WHERE workspace_id = $2
ON CONFLICT DO NOTHING",
target_workspace_id,
source_workspace_id,
)
.execute(&mut **tx)
.await?;
windmill_common::roles::invalidate_workspace_roles_cache(target_workspace_id);
Ok(())
}
@@ -11389,7 +11486,11 @@ async fn invite_user(
Json(mut nu): Json<NewWorkspaceInvite>,
) -> Result<(StatusCode, String)> {
require_admin(is_admin, &username)?;
reject_admin_and_operator(nu.is_admin, nu.operator)?;
if nu.role.is_none() {
reject_admin_and_operator(nu.is_admin, nu.operator)?;
}
let (role, role_operator) =
resolve_new_member_role(&db, &w_id, nu.operator, nu.role.as_deref()).await?;
#[cfg(not(feature = "enterprise"))]
if w_id == "admins" {
@@ -11404,7 +11505,7 @@ async fn invite_user(
if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user(
&db,
&nu.email,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.is_admin, &role).await?,
)
.await?
{
@@ -11431,13 +11532,15 @@ async fn invite_user(
sqlx::query!(
"INSERT INTO workspace_invite
(workspace_id, email, is_admin, operator)
VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, email)
DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator",
(workspace_id, email, is_admin, operator, workspace_role)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, email)
DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator,
workspace_role = EXCLUDED.workspace_role",
&w_id,
nu.email,
nu.is_admin,
nu.operator
role_operator,
&role
)
.execute(&mut *tx)
.await?;
@@ -11490,7 +11593,13 @@ async fn authorize_fork_owner_add_user(
.await?
.ok_or_else(|| Error::RequireAdmin(authed.username.clone()))?;
if nu.is_admin || nu.operator {
if nu.is_admin
|| nu.operator
|| nu
.role
.as_deref()
.is_some_and(|r| r != windmill_common::roles::DEVELOPER_ROLE)
{
return Err(Error::PermissionDenied(format!(
"as the creator of fork {w_id} you can only add members as developers; ask an admin of \
{w_id} for any other role"
@@ -11541,7 +11650,11 @@ async fn add_user(
Path(w_id): Path<String>,
Json(mut nu): Json<NewWorkspaceUser>,
) -> Result<(StatusCode, String)> {
reject_admin_and_operator(nu.is_admin, nu.operator)?;
if nu.role.is_none() {
reject_admin_and_operator(nu.is_admin, nu.operator)?;
}
let (role, role_operator) =
resolve_new_member_role(&db, &w_id, nu.operator, nu.role.as_deref()).await?;
#[cfg(not(feature = "enterprise"))]
if w_id == "admins" {
@@ -11562,7 +11675,7 @@ async fn add_user(
if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user(
&db,
&nu.email,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?,
windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.is_admin, &role).await?,
)
.await?
{
@@ -11622,13 +11735,14 @@ async fn add_user(
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin, operator)
VALUES ($1, $2, $3, $4, $5)",
(workspace_id, email, username, is_admin, operator, workspace_role)
VALUES ($1, $2, $3, $4, $5, $6)",
&w_id,
nu.email,
username,
nu.is_admin,
nu.operator
role_operator,
&role
)
.execute(&mut *tx)
.await?;
@@ -11737,11 +11851,14 @@ async fn delete_invite(
sqlx::query!(
"DELETE FROM workspace_invite WHERE
workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4",
workspace_id = $1 AND email = $2 AND is_admin = $3
AND ($5::text IS NOT NULL OR operator = $4)
AND ($5::text IS NULL OR $5 = COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END))",
&w_id,
nu.email,
nu.is_admin,
nu.operator
nu.operator,
nu.role
)
.execute(&mut *tx)
.await?;
@@ -12105,114 +12222,6 @@ pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result<Option<i32
Ok(rate_limit)
}
#[derive(Deserialize, Serialize)]
struct ChangeOperatorSettings {
#[serde(default)]
runs: bool,
#[serde(default)]
schedules: bool,
#[serde(default)]
resources: bool,
#[serde(default)]
variables: bool,
#[serde(default)]
assets: bool,
#[serde(default)]
triggers: bool,
#[serde(default)]
audit_logs: bool,
#[serde(default)]
groups: bool,
#[serde(default)]
folders: bool,
#[serde(default)]
workers: bool,
/// Write rights, so `None` (key absent) must mean "leave as stored" rather than a value: the
/// row is merged, not overwritten, and this endpoint takes whole-object payloads from git-sync
/// files that predate the key. Defaulting either way here would make an older file silently
/// withdraw or grant the right on every push.
///
/// `builder_flows` lets every operator of this workspace compose flows out of already-deployed
/// runnables, `builder_apps` full-code apps. Either makes each operator consume a full author
/// seat instead of half of one.
#[serde(default, skip_serializing_if = "Option::is_none")]
builder_flows: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
builder_apps: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manage_schedules: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manage_triggers: Option<bool>,
}
async fn update_operator_settings(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(db): Extension<DB>,
Json(settings): Json<ChangeOperatorSettings>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
// Every operator of the workspace turns into a full seat, which an offline license may not
// cover. Either right costs the same seat, so this prices "any", and it is a no-op delta when
// one of them is already on.
#[cfg(feature = "enterprise")]
if settings.builder_flows == Some(true) || settings.builder_apps == Some(true) {
if let Some(msg) =
windmill_common::ee_oss::check_seat_cap_for_operator_builder(&db, &w_id).await?
{
return Err(Error::BadRequest(msg));
}
}
let mut tx = db.begin().await?;
let settings_json = serde_json::json!(settings);
// Merged for the `Option` fields above; the visibility flags always serialize, so for them
// this is a plain overwrite.
sqlx::query!(
"UPDATE workspace_settings
SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1
WHERE workspace_id = $2",
settings_json,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"workspaces.update_operator_settings",
ActionKind::Update,
&w_id,
None,
Some([("operator_settings", settings_json.to_string().as_str())].into()),
)
.await?;
tx.commit().await?;
windmill_common::workspaces::invalidate_operator_rights_cache(&w_id);
// Trigger git sync for operator settings changes
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::Settings {
setting_type: "operator_settings".to_string(),
},
Some("Operator settings updated".to_string()),
false,
None,
)
.await?;
Ok("Operator settings updated successfully".to_string())
}
// Protection Rules API endpoints
#[derive(Deserialize)]
@@ -13812,7 +13821,7 @@ async fn load_workspace_authed(
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, base_authed).await?;
let user_row = sqlx::query!(
"SELECT username, is_admin, operator FROM usr
"SELECT username, is_admin, operator, workspace_role FROM usr
WHERE workspace_id = $1 AND email = $2 AND disabled = false",
workspace_id,
&base_authed.email
@@ -13858,7 +13867,14 @@ async fn load_workspace_authed(
email: base_authed.email.clone(),
username: user_row.username,
is_admin: is_super_admin || user_row.is_admin,
is_operator: user_row.operator,
is_operator: !user_row.is_admin
&& windmill_common::roles::member_is_restricted(
&mut conn,
workspace_id,
user_row.workspace_role.clone(),
user_row.operator,
)
.await?,
groups,
folders,
scopes: base_authed.scopes.clone(),
@@ -15327,7 +15343,13 @@ pub async fn apply_settings_edit(
DefaultApp => edit_default_app(a, d, p, settings_edit_body(edit, body)?).await?,
Color => change_workspace_color(a, p, d, settings_edit_body(edit, body)?).await?,
OperatorSettings => {
update_operator_settings(a, p, d, settings_edit_body(edit, body)?).await?
crate::workspace_roles::update_operator_settings(
a,
p,
d,
settings_edit_body(edit, body)?,
)
.await?
}
Datatable => {
let Json(result) =
@@ -135,7 +135,7 @@ pub(crate) async fn change_workspace_id(
// Duplicate workspace settings (keep copy in old workspace for reference)
info!("Duplicating workspace_settings table");
sqlx::query!(
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
@@ -225,6 +225,14 @@ pub(crate) async fn change_workspace_id(
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE role_seat SET workspace_id = $1 WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
.execute(&mut *tx)
.await?;
info!("Updating account table");
sqlx::query!(
"UPDATE account SET workspace_id = $1 WHERE workspace_id = $2",
@@ -856,7 +864,7 @@ pub(crate) async fn change_workspace_id(
info!("Duplicating usr table");
sqlx::query!(
"INSERT INTO usr SELECT $1, username, email, is_admin, created_at, operator, disabled, role FROM usr WHERE workspace_id = $2",
"INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, workspace_role) SELECT $1, username, email, is_admin, created_at, operator, disabled, role, workspace_role FROM usr WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
@@ -1258,6 +1266,10 @@ pub(crate) async fn delete_workspace(
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM role_seat WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM resource_type WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
+182 -2
View File
@@ -3696,13 +3696,16 @@ paths:
type: boolean
operator:
type: boolean
description: Legacy role selector, true for the `operator` role.
role:
type: string
description: The workspace role to give, one of the workspace's roles. Wins over `operator`.
parent_workspace_id:
type: string
nullable: true
required:
- email
- is_admin
- operator
responses:
"200":
description: status
@@ -3735,10 +3738,13 @@ paths:
type: string
operator:
type: boolean
description: Legacy role selector, true for the `operator` role.
role:
type: string
description: The workspace role to give, one of the workspace's roles. Wins over `operator`.
required:
- email
- is_admin
- operator
responses:
"200":
description: status
@@ -4078,6 +4084,46 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/roles:
get:
operationId: listWorkspaceRoles
summary: List the workspace's roles
description: The roles members of this workspace can hold, as they apply here (instance definition with the workspace's override applied, then workspace-only roles), the stored overrides, and the caller's own role and capabilities.
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: workspace roles
content:
application/json:
schema:
$ref: "#/components/schemas/WorkspaceRoles"
/w/{workspace}/workspaces/role_overrides:
post:
operationId: updateWorkspaceRoleOverrides
summary: Replace the workspace's role overrides
description: Replaces the workspace's overrides of instance roles, and its workspace-only roles (EE). Refuses removing a workspace-only role still held or referenced, and a change exceeding an offline license's seat cap. Requires workspace admin privileges.
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RoleOverrides"
responses:
"200":
description: role overrides updated
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/compare/{target_workspace_id}:
get:
operationId: compareWorkspaces
@@ -5075,6 +5121,9 @@ paths:
properties:
operator:
type: boolean
role:
type: string
description: The workspace role to give, one of the workspace's roles. Wins over `operator`.
invite_all:
type: boolean
auto_add:
@@ -31973,6 +32022,10 @@ components:
type: boolean
operator:
type: boolean
description: Legacy role selector, true for the `operator` role and false for `developer`.
role:
type: string
description: The member's workspace role, one of the workspace's roles. Wins over `operator`.
disabled:
type: boolean
@@ -36815,6 +36868,11 @@ components:
is_service_account:
type: boolean
description: Whether this membership is a service account.
role:
type: string
description: The member's workspace role.
capabilities:
$ref: "#/components/schemas/RoleCapabilities"
required:
- id
- name
@@ -36822,6 +36880,8 @@ components:
- color
- is_dev_workspace
- disabled
- role
- capabilities
required:
- email
- workspaces
@@ -37000,6 +37060,9 @@ components:
type: boolean
operator:
type: boolean
role:
type: string
description: The workspace role the invite grants.
parent_workspace_id:
type: string
nullable: true
@@ -37008,6 +37071,7 @@ components:
- email
- is_admin
- operator
- role
GlobalUserInfo:
type: object
@@ -38764,6 +38828,122 @@ components:
type: boolean
description: Whether operators can create, edit and delete triggers. Granted unless withdrawn; omitting the field leaves the stored value unchanged.
AuthorLevel:
type: string
enum: [none, compose, full]
description: How much of a kind of runnable a role may author. `compose` assembles already-deployed runnables with no code of one's own.
RoleVisibility:
type: object
description: Pages a role sees. Not enforced.
properties:
runs: { type: boolean }
schedules: { type: boolean }
resources: { type: boolean }
variables: { type: boolean }
assets: { type: boolean }
triggers: { type: boolean }
audit_logs: { type: boolean }
groups: { type: boolean }
folders: { type: boolean }
workers: { type: boolean }
required: [runs, schedules, resources, variables, assets, triggers, audit_logs, groups, folders, workers]
RoleCapabilities:
type: object
properties:
code:
type: boolean
description: Author scripts and run code of one's own (previews, dependency jobs, dynamic selects, resolving jobs, data table migrations). Required by `full` flows or apps.
flows:
$ref: "#/components/schemas/AuthorLevel"
apps:
$ref: "#/components/schemas/AuthorLevel"
manage_schedules:
type: boolean
manage_triggers:
type: boolean
visibility:
$ref: "#/components/schemas/RoleVisibility"
required: [code, flows, apps, manage_schedules, manage_triggers, visibility]
RoleCapabilitiesPatch:
type: object
description: A partial RoleCapabilities; an unset key falls through to the layer below.
properties:
code: { type: boolean }
flows:
$ref: "#/components/schemas/AuthorLevel"
apps:
$ref: "#/components/schemas/AuthorLevel"
manage_schedules: { type: boolean }
manage_triggers: { type: boolean }
visibility:
type: object
additionalProperties:
type: boolean
RoleDefinition:
type: object
description: One entry of the `workspace_roles` instance setting.
properties:
name:
type: string
label:
type: string
capabilities:
$ref: "#/components/schemas/RoleCapabilitiesPatch"
required: [name]
RoleOverride:
type: object
properties:
label:
type: string
capabilities:
$ref: "#/components/schemas/RoleCapabilitiesPatch"
RoleOverrides:
type: object
description: A workspace's overrides keyed by role name; a name the instance does not define adds a workspace-only role (EE).
additionalProperties:
$ref: "#/components/schemas/RoleOverride"
EffectiveRole:
type: object
properties:
name:
type: string
label:
type: string
capabilities:
$ref: "#/components/schemas/RoleCapabilities"
source:
type: string
enum: [instance, workspace]
overridden:
type: boolean
required: [name, label, capabilities, source, overridden]
WorkspaceRoles:
type: object
properties:
roles:
type: array
items:
$ref: "#/components/schemas/EffectiveRole"
overrides:
$ref: "#/components/schemas/RoleOverrides"
custom_roles_enabled:
type: boolean
description: Whether this workspace may add roles of its own.
my_role:
type: string
nullable: true
my_capabilities:
$ref: "#/components/schemas/RoleCapabilities"
required: [roles, overrides, custom_roles_enabled, my_capabilities]
WorkspaceComparison:
type: object
required:
+9 -15
View File
@@ -123,17 +123,15 @@ pub async fn list_datasets(
pub async fn create_dataset(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(payload): Json<CreateDataset>,
) -> Result<String> {
check_proper_path(&payload.path)?;
check_summary(payload.summary.as_deref())?;
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot create eval datasets".to_string(),
));
}
windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "create eval datasets")
.await?;
check_case_set(
payload
.cases
@@ -207,15 +205,13 @@ pub async fn get_dataset(
/// or not at all.
pub async fn update_dataset(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, String)>,
Json(payload): Json<EditDataset>,
) -> Result<String> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot modify eval datasets".to_string(),
));
}
windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "modify eval datasets")
.await?;
check_summary(payload.summary.as_deref())?;
let new_path = match payload.path.filter(|p| *p != path) {
Some(new_path) => {
@@ -301,15 +297,13 @@ pub async fn update_dataset(
/// own retention.
pub async fn delete_dataset(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, String)>,
) -> Result<String> {
check_proper_path(&path)?;
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot delete eval datasets".to_string(),
));
}
windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "delete eval datasets")
.await?;
let mut tx = user_db.clone().begin(&authed).await?;
let deleted = sqlx::query_scalar!(
"DELETE FROM eval_dataset WHERE workspace_id = $1 AND path = $2 RETURNING path",
+3 -5
View File
@@ -267,16 +267,14 @@ pub(crate) async fn read_dataset_and_cases(
/// being mirrored here.
async fn require_dataset_writable(
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &str,
path: &str,
) -> Result<()> {
check_proper_path(path)?;
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot modify eval datasets".to_string(),
));
}
windmill_common::workspaces::check_role_can_code(db, w_id, authed, "modify eval datasets")
.await?;
let mut tx = user_db.clone().begin(authed).await?;
let writable = sqlx::query_scalar!(
"SELECT path FROM eval_dataset WHERE workspace_id = $1 AND path = $2 FOR UPDATE",
+1 -1
View File
@@ -561,7 +561,7 @@ pub async fn run_experiment(
) -> Result<String> {
check_scopes(&authed, || "jobs:run".to_string())?;
// A write, not a read: it persists an experiment into the dataset.
require_dataset_writable(&authed, &user_db, &w_id, &payload.dataset).await?;
require_dataset_writable(&authed, &db, &user_db, &w_id, &payload.dataset).await?;
let mut subject = payload.subject;
let draft = validate_subject(&subject)?;
+44 -77
View File
@@ -55,6 +55,7 @@ use sqlx::{types::Uuid, FromRow};
use std::str;
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::roles::{caller_capabilities, AuthorLevel};
use windmill_common::{
apps::{
app_value_has_inline_script, app_value_runnable_paths, AppScriptId, ListAppQuery,
@@ -760,7 +761,7 @@ async fn list_apps(
// Append the authed user's `app`/`raw_app` drafts at paths with no deployed app;
// see scripts.rs.
if lq.include_draft_only.unwrap_or(false)
&& (!authed.is_operator || operator_builder_rights(&db, &w_id).await?.apps)
&& operator_builder_rights(&db, &w_id, &authed).await?.apps
&& offset == 0
&& lq.path_start.is_none()
&& lq.path_exact.is_none()
@@ -1755,6 +1756,17 @@ pub struct PreviewSdkTokenRequest {
/// conveys nothing `users/tokens/create` wouldn't. Scopes come from the request
/// so the preview matches an app whose scopes aren't deployed yet, as
/// `execute_component` already does for editor previews.
/// Gate for an app write only full app authoring allows: low-code apps, whose inline scripts are
/// code, and anything built on a worker.
async fn require_full_apps(db: &DB, w_id: &str, authed: &ApiAuthed, action: &str) -> Result<()> {
if caller_capabilities(db, w_id, authed).await?.apps != AuthorLevel::Full {
return Err(Error::PermissionDenied(format!(
"Your role cannot {action} in this workspace"
)));
}
Ok(())
}
async fn mint_preview_sdk_token(
authed: ApiAuthed,
OptJobAuthed { job_id, .. }: OptJobAuthed,
@@ -1762,14 +1774,7 @@ async fn mint_preview_sdk_token(
Path(w_id): Path<String>,
Json(req): Json<PreviewSdkTokenRequest>,
) -> Result<String> {
check_operator_can_build(
&db,
&w_id,
authed.is_operator,
BuilderKind::Apps,
"preview raw apps",
)
.await?;
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "preview raw apps").await?;
check_scopes(&authed, || format!("apps:write:{}", req.path))?;
let (token, _expiration) =
mint_raw_app_sdk_token(&db, &w_id, &req.path, &authed, &req.scopes, job_id).await?;
@@ -2622,14 +2627,7 @@ async fn create_app_raw<'a>(
Path(w_id): Path<String>,
multipart: Multipart,
) -> Result<(StatusCode, String)> {
check_operator_can_build(
&db,
&w_id,
authed.is_operator,
BuilderKind::Apps,
"create apps",
)
.await?;
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "create apps").await?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
@@ -2693,11 +2691,7 @@ async fn create_app(
Path(w_id): Path<String>,
Json(app): Json<CreateApp>,
) -> Result<(StatusCode, String)> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot create apps for security reasons".to_string(),
));
}
require_full_apps(&db, &w_id, &authed, "create low-code apps").await?;
let path = app.path.clone();
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
@@ -2806,7 +2800,7 @@ async fn create_app_internal<'a>(
if raw_app {
validate_raw_app_path_keys(&app.value.0)?;
}
if authed.is_operator {
if caller_capabilities(&db, w_id, &authed).await?.apps == AuthorLevel::Compose {
validate_operator_composed_app(
&authed,
&db,
@@ -3125,21 +3119,16 @@ async fn delete_app(
Extension(webhook): Extension<WebhookShared>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> Result<String> {
check_operator_can_build(
&db,
&w_id,
authed.is_operator,
BuilderKind::Apps,
"delete apps",
)
.await?;
let apps_level =
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "delete apps").await?;
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
// One endpoint deletes both kinds, and builder rights only cover full-code apps.
if authed.is_operator && deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true)
if apps_level == AuthorLevel::Compose
&& deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true)
{
return Err(Error::PermissionDenied(
"Operators with builder rights can only delete full-code apps".to_string(),
"Your role can only delete full-code apps".to_string(),
));
}
@@ -3332,14 +3321,14 @@ async fn update_app(
let path = path.to_path();
// The deploy panel saves a deployed full-code app's access and frontend scopes here. A
// builder may change those settings; the app itself only through `update_app_raw`.
if authed.is_operator {
check_operator_can_build(&db, &w_id, true, BuilderKind::Apps, "update apps").await?;
let apps_level =
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "update apps").await?;
if apps_level == AuthorLevel::Compose {
if ns.value.is_some()
|| deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true)
{
return Err(Error::PermissionDenied(
"Operators with builder rights can only change the settings of full-code apps"
.to_string(),
"Your role can only change the settings of full-code apps".to_string(),
));
}
}
@@ -3360,7 +3349,7 @@ async fn update_app(
let opath = path.to_string();
let db2 = db.clone();
// Settings-only for an operator, of an app checked above to be full-code.
let raw_app = authed.is_operator;
let raw_app = apps_level == AuthorLevel::Compose;
let (new_tx, npath, v_id) =
update_app_internal(authed, db, user_db, &w_id, path, raw_app, ns, None).await?;
new_tx.commit().await?;
@@ -3395,11 +3384,7 @@ async fn update_app_raw_source(
// next to it: this path compiles caller-supplied sources with a bundler job on a worker, which
// is arbitrary code execution. Builders lose nothing: the browser and the CLI both bundle
// locally and deploy through the multipart endpoints.
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot update apps for security reasons".to_string(),
));
}
require_full_apps(&db, &w_id, &authed, "build apps on a worker").await?;
let path = path.to_path();
check_scopes(&authed, || format!("apps:write:{}", path))?;
@@ -3556,11 +3541,7 @@ async fn create_app_raw_source(
Json(mut app): Json<CreateApp>,
) -> Result<(StatusCode, String)> {
// Closed to builders too, for the reason on `update_app_raw_source`.
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot create apps for security reasons".to_string(),
));
}
require_full_apps(&db, &w_id, &authed, "build apps on a worker").await?;
let path = app.path.clone();
check_scopes(&authed, || format!("apps:write:{}", path))?;
@@ -3721,14 +3702,8 @@ async fn update_app_raw<'a>(
Path((w_id, path)): Path<(String, StripPath)>,
multipart: Multipart,
) -> JsonResult<AppDeployed> {
check_operator_can_build(
&db,
&w_id,
authed.is_operator,
BuilderKind::Apps,
"update apps",
)
.await?;
let apps_level =
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "update apps").await?;
if let RuleCheckResult::Blocked(msg) = check_deploy_rules(
&w_id,
@@ -3746,10 +3721,11 @@ async fn update_app_raw<'a>(
check_scopes(&authed, || format!("apps:write:{}", path))?;
// `update_app_internal` only compares kinds when a new value is deployed, so a
// policy-only update would otherwise let a builder rewrite a low-code app's policy.
if authed.is_operator && deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true)
if apps_level == AuthorLevel::Compose
&& deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true)
{
return Err(Error::PermissionDenied(
"Operators with builder rights can only update full-code apps".to_string(),
"Your role can only update full-code apps".to_string(),
));
}
let opath = path.to_string();
@@ -3819,7 +3795,7 @@ async fn update_app_internal<'a>(
}
}
if authed.is_operator {
if caller_capabilities(&db, w_id, &authed).await?.apps == AuthorLevel::Compose {
// Restoring a full-code version always sends allow_kind_change. A builder converts
// nothing, so drop it and let the locked kind check below refuse an actual conversion.
ns.allow_kind_change = None;
@@ -4556,14 +4532,13 @@ async fn execute_component(
.unwrap_or(p)
.starts_with("hub/")
});
if authed.is_operator {
let msg = "Operators cannot run preview jobs for security reasons".to_string();
if !operator_builder_rights(&db, &w_id).await?.apps {
return Err(Error::NotAuthorized(msg));
}
if payload.raw_code.is_some() || previews_hub {
return Err(Error::PermissionDenied(msg));
}
let caps = caller_capabilities(&db, &w_id, authed).await?;
if !caps.code
&& (caps.apps == AuthorLevel::None || payload.raw_code.is_some() || previews_hub)
{
return Err(Error::PermissionDenied(
"Your role cannot run preview jobs in this workspace".to_string(),
));
}
check_scopes(authed, || format!("jobs:run"))?;
if let Some(p) = payload.path.as_deref() {
@@ -5266,11 +5241,7 @@ async fn upload_s3_file_from_app(
let authed = opt_authed.as_ref().ok_or_else(|| {
Error::NotAuthorized("App S3 preview upload requires authentication".to_string())
})?;
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot run app S3 previews for security reasons".to_string(),
));
}
require_full_apps(&db, &w_id, authed, "run app S3 previews").await?;
check_scopes(authed, || format!("apps:write:{}", path.to_path()))?;
Some(Policy {
stated_execution_mode: Some(ExecutionMode::Viewer),
@@ -5965,11 +5936,7 @@ async fn download_s3_file_from_app(
let authed = opt_authed.as_ref().ok_or_else(|| {
Error::NotAuthorized("App S3 preview download requires authentication".to_string())
})?;
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot run app S3 previews for security reasons".to_string(),
));
}
require_full_apps(&db, &w_id, authed, "run app S3 previews").await?;
check_scopes(authed, || format!("apps:write:{}", path))?;
Some(serde_json::from_str::<Vec<S3Key>>(&force_viewer_allowed_s3_keys).unwrap_or_default())
} else {
+8 -4
View File
@@ -16,6 +16,7 @@ use axum::{
use serde::{Deserialize, Serialize};
use windmill_api_flows::flows::validate_operator_flow;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::roles::AuthorLevel;
use windmill_common::{
db::UserDB,
error::{Error, Result},
@@ -469,7 +470,10 @@ async fn update_draft(
}
}
if authed.is_operator && kind == UserDraftItemKind::Flow {
// A role that composes but does not author in full is held to the composition-only rules on
// its drafts as on its deploys.
let caps = windmill_common::roles::caller_capabilities(&db, &w_id, &authed).await?;
if caps.flows == AuthorLevel::Compose && kind == UserDraftItemKind::Flow {
if let Some(value) = &req.value {
#[derive(Deserialize)]
struct FlowDraft {
@@ -496,7 +500,7 @@ async fn update_draft(
}
// A raw-app draft carries the app value and its policy side by side.
if authed.is_operator && kind == UserDraftItemKind::RawApp {
if caps.apps == AuthorLevel::Compose && kind == UserDraftItemKind::RawApp {
if let Some(value) = &req.value {
let draft: serde_json::Value = serde_json::from_str(&strip_json_nul(value.0.get()))
.map_err(|e| Error::BadRequest(format!("Invalid app draft: {e}")))?;
@@ -1269,9 +1273,9 @@ pub(crate) async fn require_can_write_path(
// about. Read access is deliberately asymmetric: `require_can_read_path` has no operator block,
// so an operator can still READ a draft they can read via `/drafts/get`, mirroring their read
// access to deployed content. Intended.
if authed.is_operator && !operator_can_draft(db, w_id, kind).await? {
if !operator_can_draft(db, w_id, authed, kind).await? {
return Err(Error::PermissionDenied(format!(
"operators cannot save {} drafts in this workspace",
"your role cannot save {} drafts in this workspace",
kind.as_str()
)));
}
+48 -37
View File
@@ -42,6 +42,7 @@ use windmill_common::jobs::{
use windmill_common::jobs::{
InlineScriptTarget, RunInlinePreviewScriptFnParams, RunInlineScriptFnParams,
};
use windmill_common::roles::AuthorLevel;
use windmill_common::runnable_settings::{
ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings,
};
@@ -7058,7 +7059,7 @@ async fn batch_rerun_handle_job(
)
.await;
if let Ok((uuid, _, _, _)) = result {
resolve_batch_rerun_source(authed, user_db, w_id, job.id, uuid).await;
resolve_batch_rerun_source(authed, db, user_db, w_id, job.id, uuid).await;
return Ok(uuid.to_string());
}
}
@@ -7092,7 +7093,7 @@ async fn batch_rerun_handle_job(
.map(|r| r.0)
};
if let Ok(uuid) = result {
resolve_batch_rerun_source(authed, user_db, w_id, job.id, uuid).await;
resolve_batch_rerun_source(authed, db, user_db, w_id, job.id, uuid).await;
return Ok(uuid.to_string());
}
}
@@ -7109,12 +7110,13 @@ async fn batch_rerun_handle_job(
/// re-run, which is already queued: a source that cannot be resolved is logged and left as is.
async fn resolve_batch_rerun_source(
authed: &ApiAuthed,
db: &DB,
user_db: &UserDB,
w_id: &str,
source: Uuid,
rerun: Uuid,
) {
if authed.is_operator {
if !role_can_code(db, w_id, authed).await.unwrap_or(false) {
return;
}
let result: error::Result<()> = async {
@@ -8989,14 +8991,24 @@ async fn operator_may_run_datatable_query(
.unwrap_or(false))
}
/// The refusal an operator gets from a preview route. Inside a job the caller never ran a
/// preview themselves, so name the one thing the job's token may do.
/// Whether the caller's role may run code of its own: previews, dependency jobs, inline dynamic
/// selects, resolving jobs.
async fn role_can_code(db: &DB, w_id: &str, authed: &ApiAuthed) -> error::Result<bool> {
Ok(
windmill_common::roles::caller_capabilities(db, w_id, authed)
.await?
.code,
)
}
/// The refusal a member whose role cannot code gets from a preview route. Inside a job the caller
/// never ran a preview themselves, so name the one thing the job's token may do.
fn operator_preview_refusal(job_id: Option<Uuid>) -> error::Error {
let reason = if job_id.is_some() {
"Operators cannot run preview jobs for security reasons: from inside a job, an \
operator may only run a wmill.datatable() query while that job is running"
"Your role cannot run preview jobs in this workspace: from inside a job, a member \
whose role cannot code may only run a wmill.datatable() query while that job is running"
} else {
"Operators cannot run preview jobs for security reasons"
"Your role cannot run preview jobs in this workspace"
};
// 403, not 401: the frontend reads a 401 as a dead session and logs the user out, and the
// flow editor builders open reaches this route.
@@ -9023,7 +9035,9 @@ pub(crate) async fn preview_on_behalf_of(
if requested == own || requested == authed.email {
return Ok(None);
}
if authed.is_operator || !windmill_common::can_preserve_on_behalf_of(authed) {
if !role_can_code(db, w_id, authed).await?
|| !windmill_common::can_preserve_on_behalf_of(authed)
{
return Err(Error::PermissionDenied(
"Only admins and members of wm_deployers can run a preview on behalf of another user"
.to_string(),
@@ -9119,7 +9133,7 @@ async fn run_preview_script(
) -> error::Result<(StatusCode, String)> {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
if authed.is_operator {
if !role_can_code(&db, &w_id, &authed).await? {
// A deferred run would outlive the running job the exemption keys off.
if run_query.get_scheduled_for(&db).await?.is_some()
|| !operator_may_run_datatable_query(
@@ -9250,7 +9264,7 @@ async fn run_inline_preview_script(
// Same arbitrary-code class as run_preview_script, and every worker and standalone
// server exposes this route, so an operator is refused on the same terms. A
// narrowly-scoped token must not escape its scope through inline preview either.
if authed.is_operator
if !role_can_code(&db, &w_id, &authed).await?
&& !operator_may_run_datatable_query(
&db,
&w_id,
@@ -9556,10 +9570,8 @@ async fn run_bundle_preview_script(
Query(run_query): Query<RunJobQuery>,
mut multipart: axum::extract::Multipart,
) -> error::Result<(StatusCode, String)> {
if authed.is_operator {
return Err(error::Error::NotAuthorized(
"Operators cannot run preview jobs for security reasons".to_string(),
));
if !role_can_code(&db, &w_id, &authed).await? {
return Err(operator_preview_refusal(None));
}
// Bundle preview runs arbitrary, request-supplied code; require the broad jobs:run
// scope so a narrowly-scoped token cannot escape its scope. See run_preview_script.
@@ -9766,9 +9778,9 @@ async fn push_dependencies_job(
req: RunDependenciesRequest,
) -> error::Result<Uuid> {
check_dependencies_scope(authed, &req.entrypoint)?;
if authed.is_operator {
return Err(error::Error::NotAuthorized(
"Operators cannot run dependencies jobs for security reasons".to_string(),
if !role_can_code(db, w_id, authed).await? {
return Err(error::Error::PermissionDenied(
"Your role cannot run dependencies jobs in this workspace".to_string(),
));
}
@@ -9889,10 +9901,10 @@ async fn push_flow_dependencies_job(
req: RunFlowDependenciesRequest,
) -> error::Result<Uuid> {
check_dependencies_scope(authed, &req.path)?;
check_operator_can_build(
let flows_level = check_operator_can_build(
db,
w_id,
authed.is_operator,
authed,
BuilderKind::Flows,
"run dependencies jobs",
)
@@ -9900,7 +9912,7 @@ async fn push_flow_dependencies_job(
// The dependency job locks whatever inline code this request carries, on a worker. A
// composition-only flow has none, so validating here costs a builder nothing and keeps the
// lock step from becoming the way to run code the write path refuses.
if authed.is_operator {
if flows_level == AuthorLevel::Compose {
validate_operator_composed_flow(&req.flow_value, &None, authed, db, user_db, w_id).await?;
// The job rewrites bookkeeping stored under `req.path` (dependency map, asset usages,
// lock error) even with `skip_flow_update`, so a builder must be able to write there.
@@ -10316,14 +10328,9 @@ async fn run_preview_flow_job(
Query(run_query): Query<RunJobQuery>,
Json(raw_flow): Json<PreviewFlow>,
) -> error::Result<(StatusCode, String)> {
check_operator_can_build(
&db,
&w_id,
authed.is_operator,
BuilderKind::Flows,
"run preview jobs",
)
.await?;
let flows_level =
check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "run preview jobs")
.await?;
// Flow preview runs an arbitrary, request-supplied flow definition; require the broad
// jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script.
check_scopes(&authed, || format!("jobs:run"))?;
@@ -10337,7 +10344,7 @@ async fn run_preview_flow_job(
}
// A builder must be able to test what it composes, but the submitted value is not the stored
// one: without this the preview is a way to run inline code the write path refuses.
if authed.is_operator {
if flows_level == AuthorLevel::Compose {
validate_operator_composed_flow(
&raw_flow.value,
&raw_flow.tag,
@@ -10489,7 +10496,7 @@ async fn run_dynamic_select(
if matches!(
request.runnable_ref,
DynamicSelectRunnableRef::Inline { .. }
) && authed.is_operator
) && !role_can_code(&db, &w_id, &authed).await?
{
return Err(operator_preview_refusal(None));
}
@@ -12572,14 +12579,16 @@ fn resolution_attribution<'a>(
(None, None)
}
fn check_resolution_request(
async fn check_resolution_request(
authed: &ApiAuthed,
db: &DB,
w_id: &str,
job_ids: &[Uuid],
note: Option<&str>,
) -> error::Result<()> {
if authed.is_operator {
return Err(error::Error::NotAuthorized(
"Operators cannot resolve jobs".to_string(),
if !role_can_code(db, w_id, authed).await? {
return Err(error::Error::PermissionDenied(
"Your role cannot resolve jobs in this workspace".to_string(),
));
}
if job_ids.len() > MAX_RESOLUTION_BATCH {
@@ -12604,11 +12613,12 @@ fn check_resolution_request(
/// absent rather than an error, so a bulk selection never fails as a whole.
async fn resolve_completed_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<ResolveJobsRequest>,
) -> error::JsonResult<Vec<Uuid>> {
check_resolution_request(&authed, &req.job_ids, req.note.as_deref())?;
check_resolution_request(&authed, &db, &w_id, &req.job_ids, req.note.as_deref()).await?;
let mut tx = user_db.begin(&authed).await?;
let tags = get_scope_tags(&authed);
let (resolved_by, typed_note) = resolution_attribution(&authed, req.note.as_deref());
@@ -12690,11 +12700,12 @@ async fn resolve_completed_jobs(
async fn unresolve_completed_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<ResolveJobsRequest>,
) -> error::JsonResult<Vec<Uuid>> {
check_resolution_request(&authed, &req.job_ids, None)?;
check_resolution_request(&authed, &db, &w_id, &req.job_ids, None).await?;
let mut tx = user_db.begin(&authed).await?;
let tags = get_scope_tags(&authed);
let unresolved = sqlx::query_scalar!(
+47 -38
View File
@@ -36,7 +36,7 @@ use std::collections::HashMap;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult},
workspaces::{operator_builder_rights, OperatorBuilderRights},
roles::{caller_capabilities, AuthorLevel, RoleCapabilities},
};
use windmill_types::scripts::ScriptHash;
use windmill_types::user_drafts::DraftUserRef;
@@ -471,6 +471,7 @@ async fn list_runnables(
Path(w_id): Path<String>,
Query(q): Query<ListRunnablesQuery>,
) -> JsonResult<ListRunnablesResponse> {
let caps = caller_capabilities(&db, &w_id, &authed).await?;
let order_by_name = q.order_by.as_deref() == Some("name");
let desc = q.order_desc.unwrap_or(true);
let per_page = q.per_page.unwrap_or(50).clamp(1, 1000);
@@ -614,7 +615,7 @@ async fn list_runnables(
"o.archived = false"
};
let mut script_extras: Vec<String> = vec![];
if !q.include_without_main.unwrap_or(false) || authed.is_operator {
if !q.include_without_main.unwrap_or(false) || !caps.code {
script_extras.push("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')".to_string());
}
script_extras.push(archived_pred.to_string());
@@ -651,13 +652,8 @@ async fn list_runnables(
// have no place in the archived view, and carrying no labels of their own they are
// out of scope of a label filter (as in the per-kind endpoints). Operators don't
// see other people's drafts and have none of their own, except of what they may build.
let builder = if authed.is_operator {
operator_builder_rights(&db, &w_id).await?
} else {
OperatorBuilderRights::default()
};
let include_drafts = q.include_draft_only.unwrap_or(false)
&& (!authed.is_operator || builder.any())
&& ALL_KINDS.iter().any(|k| role_drafts_kind(&caps, k))
&& !show_archived
&& q.label.as_ref().filter(|s| !s.is_empty()).is_none();
let draft_extras_for = |kind: &str| -> Vec<String> {
@@ -670,7 +666,7 @@ async fn list_runnables(
};
// The lib filter reads the same projected `auto_kind`, so a draft-only library
// script hides with the deployed ones.
if kind == "script" && (!q.include_without_main.unwrap_or(false) || authed.is_operator) {
if kind == "script" && (!q.include_without_main.unwrap_or(false) || !caps.code) {
extras.push("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')".to_string());
}
if let Some(s) = scope {
@@ -745,10 +741,7 @@ async fn list_runnables(
keyset: Option<&str>,
limit: Option<usize>|
-> Option<String> {
if !include_drafts
|| !kinds.contains(&kind)
|| (authed.is_operator && !operator_drafts_kind(builder, kind))
{
if !include_drafts || !kinds.contains(&kind) || !role_drafts_kind(&caps, kind) {
return None;
}
// `fav` is ignored: with no favorite join there is nothing to filter on, and the
@@ -895,6 +888,7 @@ async fn count_runnables_by_owner(
Path(w_id): Path<String>,
Query(q): Query<CountRunnablesQuery>,
) -> JsonResult<RunnableCountsResponse> {
let caps = caller_capabilities(&db, &w_id, &authed).await?;
let kinds: Vec<&str> = match q.kinds.as_deref() {
None | Some("") => ALL_KINDS.to_vec(),
// Deduplicated: every kind becomes its own count subquery, so a repeated
@@ -912,7 +906,7 @@ async fn count_runnables_by_owner(
if kinds.is_empty() {
return Ok(Json(RunnableCountsResponse { counts: HashMap::new() }));
}
let with_libs = q.include_without_main.unwrap_or(false) && !authed.is_operator;
let with_libs = q.include_without_main.unwrap_or(false) && caps.code;
// Per-kind predicates shared by both passes. `alias` is the branch's table
// alias; `binds` collects the scope values, numbered from `base + 1`.
@@ -1006,7 +1000,17 @@ async fn count_runnables_by_owner(
for r in query.fetch_all(&db).await? {
counts.insert(r.owner, r.count);
}
add_draft_counts(&authed, &db, &w_id, &kinds, with_libs, &q, &mut counts).await?;
add_draft_counts(
&caps,
&db,
&w_id,
&authed,
&kinds,
with_libs,
&q,
&mut counts,
)
.await?;
counts.retain(|_, c| *c > 0);
return Ok(Json(RunnableCountsResponse { counts }));
}
@@ -1079,17 +1083,27 @@ async fn count_runnables_by_owner(
counts.entry(r.owner).or_insert(r.count);
}
add_draft_counts(&authed, &db, &w_id, &kinds, with_libs, &q, &mut counts).await?;
add_draft_counts(
&caps,
&db,
&w_id,
&authed,
&kinds,
with_libs,
&q,
&mut counts,
)
.await?;
counts.retain(|_, c| *c > 0);
Ok(Json(RunnableCountsResponse { counts }))
}
/// Whether an operator with `builder` rights can have drafts of a `/list` kind.
fn operator_drafts_kind(builder: OperatorBuilderRights, kind: &str) -> bool {
/// Whether a role can have drafts of a `/list` kind: one it may write.
fn role_drafts_kind(caps: &RoleCapabilities, kind: &str) -> bool {
match kind {
"flow" => builder.flows,
"app" => builder.apps,
_ => false,
"flow" => caps.flows != AuthorLevel::None,
"app" => caps.apps != AuthorLevel::None,
_ => caps.code,
}
}
@@ -1102,9 +1116,10 @@ fn operator_drafts_kind(builder: OperatorBuilderRights, kind: &str) -> bool {
/// Deployed and draft rows can't overlap (the anti-join is what makes a draft
/// "draft-only"), so the two counts add.
async fn add_draft_counts(
authed: &ApiAuthed,
caps: &RoleCapabilities,
db: &DB,
w_id: &str,
authed: &ApiAuthed,
kinds: &[&str],
with_libs: bool,
q: &CountRunnablesQuery,
@@ -1113,22 +1128,16 @@ async fn add_draft_counts(
if !q.include_draft_only.unwrap_or(false) {
return Ok(());
}
// An operator has no drafts of their own, except of what they may build.
let builder_kinds: Vec<&str>;
let kinds: &[&str] = if !authed.is_operator {
kinds
} else {
let builder = operator_builder_rights(db, w_id).await?;
builder_kinds = kinds
.iter()
.copied()
.filter(|k| operator_drafts_kind(builder, k))
.collect();
if builder_kinds.is_empty() {
return Ok(());
}
&builder_kinds
};
// A role has no drafts of its own except of what it may write.
let kinds: Vec<&str> = kinds
.iter()
.copied()
.filter(|k| role_drafts_kind(caps, k))
.collect();
if kinds.is_empty() {
return Ok(());
}
let kinds = kinds.as_slice();
// $1 = workspace, $2 = the caller's email.
let mut binds: Vec<String> = vec![];
let branches: Vec<String> = kinds
+9 -2
View File
@@ -509,7 +509,7 @@ async fn fetch_authed_from_permissioned_as_inner(
let member = match permissioned_as.split_once('/') {
Some(("u", name)) => {
sqlx::query!(
"SELECT is_admin, operator, email FROM usr where username = $1 AND \
"SELECT is_admin, operator, workspace_role, email FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id
@@ -552,7 +552,14 @@ async fn fetch_authed_from_permissioned_as_inner(
let (is_admin, is_operator) = if is_super_admin {
(true, false)
} else if let Some(m) = member.as_ref() {
(m.is_admin, m.operator)
let restricted = crate::roles::member_is_restricted(
&mut *conn,
w_id,
m.workspace_role.clone(),
m.operator,
)
.await?;
(m.is_admin, restricted && !m.is_admin)
} else {
return Err(Error::NotFound(format!(
"user {name} not found in workspace {w_id}"
+11 -3
View File
@@ -72,9 +72,17 @@ pub async fn check_seat_cap_for_reactivation(
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn check_seat_cap_for_operator_builder(
_db: &DB,
_w_id: &str,
pub struct SeatCapBaseline;
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn seat_cap_baseline(_db: &DB) -> anyhow::Result<SeatCapBaseline> {
Ok(SeatCapBaseline)
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn check_seat_cap_for_role_change(
_baseline: &SeatCapBaseline,
_tx: &mut sqlx::PgConnection,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
@@ -432,6 +432,9 @@ pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries";
pub const RESTART_COORDINATION_SETTING: &str = "_restart_coordination";
pub const ALERT_CONFIG_SETTING: &str = "alert_job_queue_waiting";
/// The instance's workspace roles (EE), a list of `crate::roles::RoleDefinition`.
pub const WORKSPACE_ROLES_SETTING: &str = "workspace_roles";
// Workspace fairness: cloud-only mechanism that caps any single workspace at
// `workspace_fairness_max_percent`% of the shared worker pool once it has been
// occupying it for more than `workspace_fairness_duration_secs` seconds. See
@@ -1573,9 +1573,22 @@ pub async fn sync_global_settings_declarative(
.map_err(|e| anyhow::anyhow!("{max_expiration_key}: {e}"))?;
let mut diff = diff_global_settings(current, desired, ApplyMode::Replace);
let roles_key = crate::global_settings::WORKSPACE_ROLES_SETTING;
let roles_written = diff.upserts.iter().any(|(k, _)| k == roles_key)
|| diff.deletes.iter().any(|k| k == roles_key);
if roles_written {
crate::roles::validate_instance_roles_write(db, desired.get(roles_key))
.await
.map_err(|e| anyhow::anyhow!("{roles_key}: {e}"))?;
}
crate::external_instance_pg::write_external_instance_pg_from_diff(db, &mut diff, observer)
.await?;
apply_settings_diff(db, &diff, observer).await?;
if roles_written {
crate::roles::after_instance_roles_write(db)
.await
.map_err(|e| anyhow::anyhow!("{roles_key}: {e}"))?;
}
Ok(())
}
+2 -1
View File
@@ -77,8 +77,8 @@ pub mod flows;
pub mod fork_storage;
pub mod folders;
pub mod global_settings;
pub mod hub_resource_types;
pub mod guest_jwt;
pub mod hub_resource_types;
pub mod indexer;
pub mod instance_config;
pub mod job_metrics;
@@ -128,6 +128,7 @@ pub mod query_builders;
pub mod queue;
pub mod queue_metrics;
pub mod result_stream;
pub mod roles;
pub mod runnable_job_stats;
pub mod runnable_settings;
pub mod runnables;
+998
View File
@@ -0,0 +1,998 @@
//! Workspace roles: what a non-admin member may author, manage and see.
//!
//! `developer` and `operator` are the default roles. An EE instance defines its own list in the
//! [`WORKSPACE_ROLES_SETTING`] global setting (at least one role), and a workspace may override any
//! role's capabilities, or on EE define roles of its own, in `workspace_settings.role_overrides`.
//! Workspace admin is not a role: it is the `is_admin` flag on top of whichever role a member has,
//! and holds every capability. See `docs/workspace-roles.md`.
//!
//! A member's role is `usr.workspace_role`, or, while that is NULL, `operator` / `developer` from the
//! legacy `usr.operator` flag. `ApiAuthed::is_operator` means "this member's role is restricted",
//! computed at authentication from the role, never from `usr.operator` alone: every gate fast-paths
//! on it, so a stale flag would let a restricted member through.
use std::{collections::HashMap, sync::Arc};
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use crate::{
db::Authable,
error::{Error, Result},
global_settings::WORKSPACE_ROLES_SETTING,
DB,
};
pub const DEVELOPER_ROLE: &str = "developer";
pub const OPERATOR_ROLE: &str = "operator";
/// How much of a kind of runnable a role may author.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum AuthorLevel {
#[default]
None,
/// Assemble already-deployed runnables, with no code of one's own. A composed flow is checked
/// in full server-side; a composed full-code app is forced into sandbox isolation. See
/// `docs/operator-builder-rights.md`.
Compose,
Full,
}
/// Pages a role sees. Not enforced: hiding a page never stopped an API call, and the enforced
/// rights are the other fields of [`RoleCapabilities`].
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub struct RoleVisibility {
pub runs: bool,
pub schedules: bool,
pub resources: bool,
pub variables: bool,
pub assets: bool,
pub triggers: bool,
pub audit_logs: bool,
pub groups: bool,
pub folders: bool,
pub workers: bool,
}
impl RoleVisibility {
pub const ALL: Self = Self {
runs: true,
schedules: true,
resources: true,
variables: true,
assets: true,
triggers: true,
audit_logs: true,
groups: true,
folders: true,
workers: true,
};
pub const NONE: Self = Self {
runs: false,
schedules: false,
resources: false,
variables: false,
assets: false,
triggers: false,
audit_logs: false,
groups: false,
folders: false,
workers: false,
};
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub struct RoleCapabilities {
/// Author scripts and run code of one's own on a worker: script previews, dependency jobs,
/// dynamic selects, resolving a job's code, data table migrations. `Full` flows or apps
/// require it, since both carry inline code.
pub code: bool,
pub flows: AuthorLevel,
pub apps: AuthorLevel,
pub manage_schedules: bool,
pub manage_triggers: bool,
pub visibility: RoleVisibility,
}
impl RoleCapabilities {
pub const FULL: Self = Self {
code: true,
flows: AuthorLevel::Full,
apps: AuthorLevel::Full,
manage_schedules: true,
manage_triggers: true,
visibility: RoleVisibility::ALL,
};
/// The `operator` role as it stood before roles were configurable, and the base any other
/// role's unset keys fall back to.
pub const OPERATOR: Self = Self {
code: false,
flows: AuthorLevel::None,
apps: AuthorLevel::None,
manage_schedules: true,
manage_triggers: true,
visibility: RoleVisibility::ALL,
};
/// For a restricted caller whose role cannot be resolved: a guest, a removed member's job, a
/// role name no longer defined.
pub const NONE: Self = Self {
code: false,
flows: AuthorLevel::None,
apps: AuthorLevel::None,
manage_schedules: false,
manage_triggers: false,
visibility: RoleVisibility::NONE,
};
/// What a role named `name` holds before any configuration applies.
pub fn base_for(name: &str) -> Self {
if name == DEVELOPER_ROLE {
Self::FULL
} else {
Self::OPERATOR
}
}
/// Whether a member of this role is gated at all. Every gate skips an unrestricted caller
/// without reading its role, which is what `ApiAuthed::is_operator` carries. Visibility is
/// 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
}
/// Whether the role authors deployable artifacts and so costs a full seat rather than half.
/// Composing counts: there is no half-author.
pub fn is_author(&self) -> bool {
self.code || self.flows != AuthorLevel::None || self.apps != AuthorLevel::None
}
pub fn has_manage(&self, kind: crate::workspaces::ManageKind) -> bool {
match kind {
crate::workspaces::ManageKind::Schedules => self.manage_schedules,
crate::workspaces::ManageKind::Triggers => self.manage_triggers,
}
}
pub fn level(&self, kind: crate::workspaces::BuilderKind) -> AuthorLevel {
match kind {
crate::workspaces::BuilderKind::Flows => self.flows,
crate::workspaces::BuilderKind::Apps => self.apps,
}
}
fn apply(&mut self, patch: &RoleCapabilitiesPatch) {
if let Some(v) = patch.code {
self.code = v;
}
if let Some(v) = patch.flows {
self.flows = v;
}
if let Some(v) = patch.apps {
self.apps = v;
}
if let Some(v) = patch.manage_schedules {
self.manage_schedules = v;
}
if let Some(v) = patch.manage_triggers {
self.manage_triggers = v;
}
if let Some(v) = &patch.visibility {
v.apply(&mut self.visibility);
}
}
/// A `Full` flow or app carries inline code, so granting one without `code` would be a way
/// around refusing `code`.
pub fn validate(&self, role: &str) -> Result<()> {
if !self.code && (self.flows == AuthorLevel::Full || self.apps == AuthorLevel::Full) {
return Err(Error::BadRequest(format!(
"role `{role}`: full flow or app authoring requires the code capability"
)));
}
Ok(())
}
}
/// A partial [`RoleCapabilities`]: an unset key falls through to the layer below (workspace
/// override, then instance definition, then [`RoleCapabilities::base_for`]). Stored settings are
/// patches rather than full values so that a key added later reads as "never configured".
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
pub struct RoleCapabilitiesPatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flows: Option<AuthorLevel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps: Option<AuthorLevel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manage_schedules: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manage_triggers: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<RoleVisibilityPatch>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
pub struct RoleVisibilityPatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runs: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedules: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resources: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variables: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub triggers: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit_logs: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub groups: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub folders: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workers: Option<bool>,
}
impl RoleVisibilityPatch {
fn apply(&self, v: &mut RoleVisibility) {
macro_rules! set {
($($f:ident),*) => { $( if let Some(x) = self.$f { v.$f = x; } )* };
}
set!(
runs, schedules, resources, variables, assets, triggers, audit_logs, groups, folders,
workers
);
}
}
/// The smallest patch that turns `base` into `target`: only the keys that differ.
pub fn diff_capabilities(
base: &RoleCapabilities,
target: &RoleCapabilities,
) -> RoleCapabilitiesPatch {
fn d<T: PartialEq + Copy>(b: T, t: T) -> Option<T> {
(b != t).then_some(t)
}
let (b, t) = (&base.visibility, &target.visibility);
let visibility = RoleVisibilityPatch {
runs: d(b.runs, t.runs),
schedules: d(b.schedules, t.schedules),
resources: d(b.resources, t.resources),
variables: d(b.variables, t.variables),
assets: d(b.assets, t.assets),
triggers: d(b.triggers, t.triggers),
audit_logs: d(b.audit_logs, t.audit_logs),
groups: d(b.groups, t.groups),
folders: d(b.folders, t.folders),
workers: d(b.workers, t.workers),
};
RoleCapabilitiesPatch {
code: d(base.code, target.code),
flows: d(base.flows, target.flows),
apps: d(base.apps, target.apps),
manage_schedules: d(base.manage_schedules, target.manage_schedules),
manage_triggers: d(base.manage_triggers, target.manage_triggers),
visibility: (visibility != RoleVisibilityPatch::default()).then_some(visibility),
}
}
/// One entry of the instance's [`WORKSPACE_ROLES_SETTING`].
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct RoleDefinition {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default)]
pub capabilities: RoleCapabilitiesPatch,
}
/// One entry of `workspace_settings.role_overrides`, keyed by role name. Naming a role the
/// instance does not define adds a workspace-only role.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
pub struct RoleOverride {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default)]
pub capabilities: RoleCapabilitiesPatch,
}
pub type RoleOverrides = HashMap<String, RoleOverride>;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RoleSource {
Instance,
Workspace,
}
/// A role as it applies in one workspace.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct EffectiveRole {
pub name: String,
pub label: String,
pub capabilities: RoleCapabilities,
pub source: RoleSource,
/// Whether the workspace overrides this role.
pub overridden: bool,
}
/// Whether stored instance roles and workspace-only roles apply: on any EE build. A CE build has
/// `developer` and `operator`, which a workspace may still override.
fn custom_roles_built() -> bool {
cfg!(feature = "enterprise")
}
/// Whether instance roles and workspace-only roles may be changed: an EE build whose license
/// verified. A lapsed license freezes them instead of dropping them, since falling back to the
/// defaults would hand a restricted `developer` everything. The `cfg` is load-bearing: CE's
/// `LICENSE_KEY_VALID` is always `true`.
pub fn custom_roles_enabled() -> bool {
#[cfg(feature = "enterprise")]
{
crate::ee_oss::LICENSE_KEY_VALID.load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(not(feature = "enterprise"))]
{
false
}
}
pub fn default_instance_roles() -> Vec<RoleDefinition> {
vec![
RoleDefinition {
name: DEVELOPER_ROLE.to_string(),
label: Some("Developer".to_string()),
capabilities: RoleCapabilitiesPatch::default(),
},
RoleDefinition {
name: OPERATOR_ROLE.to_string(),
label: Some("Operator".to_string()),
capabilities: RoleCapabilitiesPatch::default(),
},
]
}
/// Role names appear in `u/`-free contexts only (settings, group mappings, the `usr` row), but are
/// also shown as-is and compared verbatim, so they are kept to a slug.
pub fn validate_role_name(name: &str) -> Result<()> {
let ok = !name.is_empty()
&& name.len() <= 50
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
&& name != "admin";
if ok {
Ok(())
} else {
Err(Error::BadRequest(format!(
"invalid role name `{name}`: use 1-50 lowercase letters, digits, `_` or `-`, and not `admin`"
)))
}
}
/// Parses and validates a [`WORKSPACE_ROLES_SETTING`] value: at least one role, unique valid
/// names, capabilities that pass [`RoleCapabilities::validate`].
pub fn parse_instance_roles(value: &serde_json::Value) -> Result<Vec<RoleDefinition>> {
let roles: Vec<RoleDefinition> = serde_json::from_value(value.clone())
.map_err(|e| Error::BadRequest(format!("invalid workspace roles: {e}")))?;
if roles.is_empty() {
return Err(Error::BadRequest(
"at least one workspace role must be defined".to_string(),
));
}
let mut seen = std::collections::HashSet::new();
for r in &roles {
validate_role_name(&r.name)?;
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)?;
}
Ok(roles)
}
/// The workspace's roles: the instance's, in their order and with any override applied, then the
/// workspace-only ones by name. Order is precedence where one is needed (instance group mapping).
pub fn effective_roles(
instance: &[RoleDefinition],
overrides: &RoleOverrides,
) -> 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);
}
out.push(EffectiveRole {
label: ovr
.and_then(|o| o.label.clone())
.or_else(|| def.label.clone())
.unwrap_or_else(|| def.name.clone()),
name: def.name.clone(),
capabilities: caps,
source: RoleSource::Instance,
overridden: ovr.is_some(),
});
}
if custom_roles_built() {
let mut extra: Vec<_> = overrides
.iter()
.filter(|(n, _)| !instance.iter().any(|d| &d.name == *n))
.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);
out.push(EffectiveRole {
name: name.clone(),
label: o.label.clone().unwrap_or_else(|| name.clone()),
capabilities: caps,
source: RoleSource::Workspace,
overridden: true,
});
}
}
Ok(out)
}
/// Validates a workspace's `role_overrides` against the instance roles.
pub fn validate_overrides(instance: &[RoleDefinition], overrides: &RoleOverrides) -> Result<()> {
for name in overrides.keys() {
validate_role_name(name)?;
if !custom_roles_built() && !instance.iter().any(|d| &d.name == name) {
return Err(Error::BadRequest(format!(
"role `{name}` is not defined: adding workspace roles requires Windmill Enterprise Edition"
)));
}
}
for r in effective_roles(instance, overrides)? {
r.capabilities.validate(&r.name)?;
}
Ok(())
}
/// Refuses, while [`custom_roles_enabled`] is false, a `role_overrides` write that adds, edits or
/// relabels a workspace-only role. Removing one, and overriding instance roles, stay allowed.
pub fn check_frozen_overrides(
instance: &[RoleDefinition],
previous: &RoleOverrides,
next: &RoleOverrides,
) -> Result<()> {
if custom_roles_enabled() {
return Ok(());
}
for (name, o) in next {
if !instance.iter().any(|d| &d.name == name) && previous.get(name) != Some(o) {
return Err(Error::BadRequest(format!(
"changing workspace role `{name}` requires a valid Windmill Enterprise Edition license"
)));
}
}
Ok(())
}
/// The role a member gets when nothing names one (a legacy non-operator payload, an unmapped
/// instance group, an admin's underlying role): `developer` while it is defined, else the first
/// role, since an instance may drop `developer` and `set_workspace_role` refuses unknown names.
pub fn default_role_name<'a>(names: impl IntoIterator<Item = &'a str>) -> String {
let mut first = None;
for name in names {
if name == DEVELOPER_ROLE {
return DEVELOPER_ROLE.to_string();
}
first.get_or_insert(name);
}
first.unwrap_or(DEVELOPER_ROLE).to_string()
}
/// The role a `usr` row holds: its `workspace_role`, or the legacy one its `operator` flag names.
pub fn resolve_role_name(workspace_role: Option<String>, operator: bool) -> String {
workspace_role.unwrap_or_else(|| {
if operator {
OPERATOR_ROLE.to_string()
} else {
DEVELOPER_ROLE.to_string()
}
})
}
/// SQL for the role a `usr` / `workspace_invite` row aliased `t` holds; mirrors
/// [`resolve_role_name`].
pub const ROLE_NAME_SQL: &str =
"COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END)";
lazy_static::lazy_static! {
static ref INSTANCE_ROLES: Cache<(), (Arc<Vec<RoleDefinition>>, i64)> = Cache::new(1);
static ref WORKSPACE_ROLES: Cache<String, (Arc<Vec<EffectiveRole>>, i64)> = Cache::new(1000);
static ref MEMBER_ROLE: Cache<(String, String), (Option<String>, i64)> = Cache::new(10000);
}
const ROLE_CACHE_TTL_SECS: i64 = 60;
/// The instance's role list. A CE build, or an instance that never configured one, has the
/// default roles. Cached 60s, dropped on every replica when the setting changes.
pub async fn instance_roles(db: &DB) -> Result<Arc<Vec<RoleDefinition>>> {
if let Some(roles) = cached_instance_roles() {
return Ok(roles);
}
let mut conn = db.acquire().await?;
instance_roles_on(&mut conn).await
}
fn cached_instance_roles() -> Option<Arc<Vec<RoleDefinition>>> {
let now = chrono::Utc::now().timestamp();
INSTANCE_ROLES
.get(&())
.filter(|(_, expiry)| *expiry > now)
.map(|(r, _)| r)
}
async fn instance_roles_on(conn: &mut sqlx::PgConnection) -> Result<Arc<Vec<RoleDefinition>>> {
if let Some(roles) = cached_instance_roles() {
return Ok(roles);
}
let roles = if custom_roles_built() {
let stored = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = $1",
WORKSPACE_ROLES_SETTING
)
.fetch_optional(&mut *conn)
.await?;
match stored {
Some(v) => parse_instance_roles(&v).unwrap_or_else(|e| {
tracing::error!("ignoring invalid {WORKSPACE_ROLES_SETTING} setting: {e:#}");
default_instance_roles()
}),
None => default_instance_roles(),
}
} else {
default_instance_roles()
};
let roles = Arc::new(roles);
INSTANCE_ROLES.insert(
(),
(
roles.clone(),
chrono::Utc::now().timestamp() + ROLE_CACHE_TTL_SECS,
),
);
Ok(roles)
}
pub async fn load_role_overrides<'c, E: sqlx::PgExecutor<'c>>(
db: E,
workspace_id: &str,
) -> Result<RoleOverrides> {
let v: Option<Option<serde_json::Value>> = sqlx::query_scalar!(
"SELECT role_overrides FROM workspace_settings WHERE workspace_id = $1",
workspace_id
)
.fetch_optional(db)
.await?;
match v.flatten() {
Some(v) => serde_json::from_value(v).map_err(|e| {
Error::internal_err(format!("invalid role overrides of {workspace_id}: {e:#}"))
}),
None => Ok(RoleOverrides::new()),
}
}
fn cached_workspace_roles(workspace_id: &str) -> Option<Arc<Vec<EffectiveRole>>> {
let now = chrono::Utc::now().timestamp();
WORKSPACE_ROLES
.get(workspace_id)
.filter(|(_, expiry)| *expiry > now)
.map(|(r, _)| r)
}
/// The roles of `workspace_id`. Cached 60s; `role_overrides` writes and the instance setting drop
/// the entry on every replica.
///
/// Call it before opening an RLS transaction: on a cache miss it takes a connection from the root
/// pool, and a second pooled connection held alongside a transaction self-deadlocks on a
/// one-connection pool. A caller that already holds a connection uses [`workspace_roles_on`].
pub async fn workspace_roles(db: &DB, workspace_id: &str) -> Result<Arc<Vec<EffectiveRole>>> {
if let Some(roles) = cached_workspace_roles(workspace_id) {
return Ok(roles);
}
let mut conn = db.acquire().await?;
workspace_roles_on(&mut conn, workspace_id).await
}
pub async fn workspace_roles_on(
conn: &mut sqlx::PgConnection,
workspace_id: &str,
) -> Result<Arc<Vec<EffectiveRole>>> {
if let Some(roles) = cached_workspace_roles(workspace_id) {
return Ok(roles);
}
let instance = instance_roles_on(&mut *conn).await?;
let overrides = load_role_overrides(&mut *conn, workspace_id).await?;
let roles = Arc::new(effective_roles(&instance, &overrides)?);
WORKSPACE_ROLES.insert(
workspace_id.to_string(),
(
roles.clone(),
chrono::Utc::now().timestamp() + ROLE_CACHE_TTL_SECS,
),
);
Ok(roles)
}
pub async fn find_role(db: &DB, workspace_id: &str, name: &str) -> Result<Option<EffectiveRole>> {
Ok(workspace_roles(db, workspace_id)
.await?
.iter()
.find(|r| r.name == name)
.cloned())
}
/// Capabilities of role `name` in `workspace_id`; [`RoleCapabilities::NONE`] for a name no longer
/// defined, so a deleted role fails closed.
pub async fn role_capabilities_by_name(
db: &DB,
workspace_id: &str,
name: &str,
) -> Result<RoleCapabilities> {
Ok(find_role(db, workspace_id, name)
.await?
.map(|r| r.capabilities)
.unwrap_or(RoleCapabilities::NONE))
}
/// Whether a member holding the role of this `usr` row is restricted: what authentication puts in
/// `ApiAuthed::is_operator`. Runs on the caller's connection, which authentication already holds.
pub async fn member_is_restricted(
conn: &mut sqlx::PgConnection,
workspace_id: &str,
workspace_role: Option<String>,
operator: bool,
) -> Result<bool> {
let name = resolve_role_name(workspace_role, operator);
Ok(workspace_roles_on(conn, workspace_id)
.await?
.iter()
.find(|r| r.name == name)
.map(|r| r.capabilities)
.unwrap_or(RoleCapabilities::NONE)
.is_restricted())
}
/// [`member_is_restricted`] for a caller holding no connection.
pub async fn member_is_restricted_db(
db: &DB,
workspace_id: &str,
workspace_role: Option<String>,
operator: bool,
) -> Result<bool> {
let name = resolve_role_name(workspace_role, operator);
Ok(role_capabilities_by_name(db, workspace_id, &name)
.await?
.is_restricted())
}
async fn member_role(db: &DB, workspace_id: &str, username: &str) -> Result<Option<String>> {
let now = chrono::Utc::now().timestamp();
let key = (workspace_id.to_string(), username.to_string());
if let Some((role, expiry)) = MEMBER_ROLE.get(&key) {
if expiry > now {
return Ok(role);
}
}
let row = sqlx::query!(
"SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND username = $2",
workspace_id,
username
)
.fetch_optional(db)
.await?;
let role = row.map(|r| resolve_role_name(r.workspace_role, r.operator));
MEMBER_ROLE.insert(key, (role.clone(), now + ROLE_CACHE_TTL_SECS));
Ok(role)
}
/// What the caller may do in `workspace_id`. An admin, and any caller authentication did not mark
/// restricted, holds everything; a restricted caller holds its role's capabilities, or none if it
/// has no membership (a guest, a removed member's job). `workspace_id` MUST be the route's.
///
/// Call it before opening an RLS transaction; see [`workspace_roles`].
pub async fn caller_capabilities<A: Authable + ?Sized>(
db: &DB,
workspace_id: &str,
authed: &A,
) -> Result<RoleCapabilities> {
if authed.is_admin() || !authed.is_operator() {
return Ok(RoleCapabilities::FULL);
}
match member_role(db, workspace_id, authed.username()).await? {
Some(role) => role_capabilities_by_name(db, workspace_id, &role).await,
None => Ok(RoleCapabilities::NONE),
}
}
pub fn invalidate_workspace_roles_cache(workspace_id: &str) {
WORKSPACE_ROLES.remove(workspace_id);
}
pub const ROLE_OVERRIDES_CHANGE_CHANNEL: &str = "notify_role_overrides_change";
/// Tells every replica to drop its cached roles for the workspace. Call it in the transaction
/// that writes `role_overrides`: a replica that missed it keeps authorizing whatever the workspace
/// just withdrew until its entry expires.
pub async fn notify_role_overrides_change(
conn: &mut sqlx::PgConnection,
workspace_id: &str,
) -> Result<()> {
sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)")
.bind(ROLE_OVERRIDES_CHANGE_CHANNEL)
.bind(workspace_id)
.execute(conn)
.await?;
Ok(())
}
pub fn invalidate_all_roles_cache() {
INSTANCE_ROLES.clear();
WORKSPACE_ROLES.clear();
MEMBER_ROLE.clear();
}
pub fn invalidate_member_role_cache(workspace_id: &str, username: &str) {
MEMBER_ROLE.remove(&(workspace_id.to_string(), username.to_string()));
}
/// Refuses removing role `name` while a member, a pending invite, the domain auto-invite or an
/// instance group mapping still names it: in `w_id`, or, for an instance role (`w_id = None`), in
/// any workspace, and while any workspace still overrides it.
pub async fn ensure_role_unused(db: &DB, w_id: Option<&str>, name: &str) -> Result<()> {
let users = sqlx::query_scalar!(
"SELECT COUNT(*) FROM usr t WHERE ($1::text IS NULL OR t.workspace_id = $1) AND
COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2",
w_id,
name
)
.fetch_one(db)
.await?
.unwrap_or(0);
let invites = sqlx::query_scalar!(
"SELECT COUNT(*) FROM workspace_invite t WHERE ($1::text IS NULL OR t.workspace_id = $1) AND
COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2",
w_id,
name
)
.fetch_one(db)
.await?
.unwrap_or(0);
let settings = sqlx::query_scalar!(
"SELECT COUNT(*) FROM workspace_settings WHERE ($1::text IS NULL OR workspace_id = $1) AND (
jsonb_typeof(auto_invite) = 'object' AND COALESCE(auto_invite->>'role',
CASE WHEN (auto_invite->>'operator')::boolean THEN 'operator' ELSE 'developer' END) = $2
OR EXISTS (SELECT 1 FROM jsonb_each_text(
CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'
THEN auto_invite->'instance_groups_roles' ELSE '{}'::jsonb END) g
WHERE g.value = $2))",
w_id,
name
)
.fetch_one(db)
.await?
.unwrap_or(0);
if users + invites + settings > 0 {
return Err(Error::BadRequest(format!(
"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"
)));
}
// 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() {
let overriding = sqlx::query_scalar!(
"SELECT COUNT(*) FROM workspace_settings WHERE COALESCE(role_overrides ? $1, false)",
name
)
.fetch_one(db)
.await?
.unwrap_or(0);
if overriding > 0 {
return Err(Error::BadRequest(format!(
"role `{name}` is overridden in {overriding} workspace(s): remove those overrides first"
)));
}
}
Ok(())
}
/// Checks a write of the `workspace_roles` setting (`None` or null resets to the defaults) before
/// it is stored. Every writer of the setting must call it, the declarative config sync included.
pub async fn validate_instance_roles_write(
db: &DB,
value: Option<&serde_json::Value>,
) -> Result<()> {
let current = instance_roles(db).await?;
let next = match value {
None | Some(serde_json::Value::Null) => default_instance_roles(),
Some(v) => parse_instance_roles(v)?,
};
// Resetting stays open so a downgraded instance can always drop the setting.
if !custom_roles_enabled() && *current != next && next != default_instance_roles() {
return Err(Error::BadRequest(
"changing workspace roles requires a valid Windmill Enterprise Edition license"
.to_string(),
));
}
for removed in current
.iter()
.filter(|r| !next.iter().any(|n| n.name == r.name))
{
ensure_role_unused(db, None, &removed.name).await?;
}
// An override valid on the old definitions can merge into an invalid role on the new ones
// (`flows: full` kept while the instance withdraws `code`), which the gates would honour.
let stored: Vec<(String, serde_json::Value)> = sqlx::query_as(
"SELECT workspace_id, role_overrides FROM workspace_settings WHERE role_overrides IS NOT NULL",
)
.fetch_all(db)
.await?;
for (w, overrides) in stored {
let overrides: RoleOverrides = serde_json::from_value(overrides)
.map_err(|e| Error::internal_err(format!("invalid role overrides of {w}: {e:#}")))?;
validate_overrides(&next, &overrides).map_err(|e| {
Error::BadRequest(format!(
"workspace {w}'s role overrides would become invalid: {e}"
))
})?;
}
Ok(())
}
/// Side effects of a stored `workspace_roles` write, owed by every writer. The seat table is read
/// by billing on every replica, so it is rebuilt here rather than on the notify that drops the
/// role caches.
pub async fn after_instance_roles_write(db: &DB) -> Result<()> {
invalidate_all_roles_cache();
let mut tx = db.begin().await?;
sync_role_seats(&mut tx, None).await?;
tx.commit().await?;
Ok(())
}
/// Rewrites `role_seat`, the per-role author flag billing SQL joins on, from the current role
/// configuration: the instance rows (`workspace_id = ''`) and those of every workspace with an
/// override, or only `workspace_id`'s when given. Run after any write to either setting; billing
/// reads nothing else about roles. Reads on `tx` only: a second pooled connection held alongside
/// it self-deadlocks on a one-connection pool.
pub async fn sync_role_seats<'c>(
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
workspace_id: Option<&str>,
) -> Result<()> {
let instance = instance_roles_on(&mut **tx).await?;
let workspaces: Vec<(String, Option<serde_json::Value>)> = match workspace_id {
Some(w) => sqlx::query_as(
"SELECT $1::text, (SELECT role_overrides FROM workspace_settings WHERE workspace_id = $1)",
)
.bind(w)
.fetch_all(&mut **tx)
.await?,
None => {
sqlx::query("DELETE FROM role_seat").execute(&mut **tx).await?;
let rows = effective_roles(&instance, &RoleOverrides::new())?;
write_role_seats(tx, "", &rows).await?;
sqlx::query_as(
"SELECT workspace_id, role_overrides FROM workspace_settings WHERE role_overrides IS NOT NULL",
)
.fetch_all(&mut **tx)
.await?
}
};
for (w, overrides) in workspaces {
sqlx::query("DELETE FROM role_seat WHERE workspace_id = $1")
.bind(&w)
.execute(&mut **tx)
.await?;
let overrides: RoleOverrides = match overrides {
Some(v) => serde_json::from_value(v).map_err(|e| {
Error::internal_err(format!("invalid role overrides of {w}: {e:#}"))
})?,
None => continue,
};
let rows: Vec<_> = effective_roles(&instance, &overrides)?
.into_iter()
.filter(|r| r.overridden)
.collect();
write_role_seats(tx, &w, &rows).await?;
}
Ok(())
}
async fn write_role_seats<'c>(
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
workspace_id: &str,
roles: &[EffectiveRole],
) -> Result<()> {
let names: Vec<&str> = roles.iter().map(|r| r.name.as_str()).collect();
let authors: Vec<bool> = roles.iter().map(|r| r.capabilities.is_author()).collect();
sqlx::query(
"INSERT INTO role_seat (workspace_id, role, author)
SELECT $1, n, a FROM unnest($2::text[], $3::bool[]) AS x(n, a)",
)
.bind(workspace_id)
.bind(&names)
.bind(&authors)
.execute(&mut **tx)
.await?;
Ok(())
}
/// SQL for "the membership row aliased `t` consumes a full (author) seat": an admin always does,
/// whatever role it holds underneath; otherwise from `role_seat`, the workspace's row for the role,
/// else the instance's, else the legacy rule that only a developer is an author. `t` carries
/// `workspace_id`, `is_admin`, `operator` and `workspace_role`. Shared by every seat count so the
/// displayed, enforced and invoiced numbers agree.
pub const AUTHOR_SEAT_SQL: &str = "t.is_admin OR COALESCE(
(SELECT rs.author FROM role_seat rs WHERE rs.workspace_id = t.workspace_id
AND rs.role = COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END)),
(SELECT rs.author FROM role_seat rs WHERE rs.workspace_id = ''
AND rs.role = COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END)),
NOT t.operator)";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_legacy_roles() {
let roles = effective_roles(&default_instance_roles(), &RoleOverrides::new()).unwrap();
assert_eq!(roles[0].capabilities, RoleCapabilities::FULL);
assert!(!roles[0].capabilities.is_restricted());
assert_eq!(roles[1].capabilities, RoleCapabilities::OPERATOR);
assert!(roles[1].capabilities.is_restricted());
assert!(!roles[1].capabilities.is_author());
let hidden_page = RoleCapabilities {
visibility: RoleVisibility { workers: false, ..RoleVisibility::ALL },
..RoleCapabilities::FULL
};
assert!(!hidden_page.is_restricted());
}
#[test]
fn override_layers_over_instance_definition() {
let instance: Vec<RoleDefinition> = serde_json::from_value(serde_json::json!([
{"name": "developer"},
{"name": "operator", "capabilities": {"manage_triggers": false, "visibility": {"runs": false}}}
]))
.unwrap();
let overrides: RoleOverrides = serde_json::from_value(serde_json::json!({
"operator": {"capabilities": {"flows": "compose", "visibility": {"workers": false}}}
}))
.unwrap();
let op = effective_roles(&instance, &overrides).unwrap().remove(1);
assert!(op.overridden);
assert_eq!(op.capabilities.flows, AuthorLevel::Compose);
assert!(!op.capabilities.manage_triggers);
assert!(!op.capabilities.visibility.runs);
assert!(!op.capabilities.visibility.workers);
assert!(op.capabilities.visibility.folders);
assert!(op.capabilities.is_author());
}
#[test]
fn full_authoring_requires_code() {
let err = parse_instance_roles(&serde_json::json!([
{"name": "builder", "capabilities": {"flows": "full"}}
]));
assert!(err.is_err());
assert!(parse_instance_roles(&serde_json::json!([])).is_err());
assert!(parse_instance_roles(&serde_json::json!([{"name": "admin"}])).is_err());
}
}
+66 -72
View File
@@ -379,44 +379,54 @@ async fn get_email_from_permissioned_as_inner<'c>(
}
}
/// Compute the highest-precedence workspace role for a user across all their instance groups.
/// The highest-precedence workspace role a user holds across their instance groups.
///
/// Precedence: admin (3) > developer (2) > operator (1).
/// Returns `(best_group_name, is_admin, is_operator)`.
/// `admin` ranks first, then the workspace's roles in `role_order` (earlier is higher). A group
/// with no mapped role grants `roles::default_role_name`; a role name not in `role_order` ranks
/// last. Returns `(best_group_name, is_admin, role)`, where `role` is the role the membership
/// holds and, for an admin, the one it keeps under the admin flag.
pub fn compute_highest_workspace_role(
user_igroups: &[String],
ws_configured_groups: &[String],
ws_roles: &std::collections::HashMap<String, String>,
) -> (String, bool, bool) {
let mut best_group = String::new();
let mut best_precedence = 0u8;
role_order: &[String],
) -> (String, bool, String) {
let rank = |role: &str| -> usize {
if role == "admin" {
0
} else {
1 + role_order
.iter()
.position(|r| r == role)
.unwrap_or(role_order.len())
}
};
let default_role = crate::roles::default_role_name(role_order.iter().map(String::as_str));
let role_of = |group: &str| -> String {
ws_roles
.get(group)
.cloned()
.unwrap_or_else(|| default_role.clone())
};
let mut best: Option<(usize, String)> = None;
for group in user_igroups {
if !ws_configured_groups.contains(group) {
continue;
}
let default_role = "developer".to_string();
let role = ws_roles.get(group).unwrap_or(&default_role);
let precedence = match role.as_str() {
"admin" => 3u8,
"operator" => 1,
_ => 2,
};
if precedence > best_precedence {
best_precedence = precedence;
best_group = group.clone();
let r = rank(&role_of(group));
if best.as_ref().map_or(true, |(b, _)| r < *b) {
best = Some((r, group.clone()));
}
}
let default_role = "developer".to_string();
let best_role_str = ws_roles.get(&best_group).unwrap_or(&default_role);
let (is_admin, is_operator) = match best_role_str.as_str() {
"admin" => (true, false),
"operator" => (false, true),
_ => (false, false),
};
(best_group, is_admin, is_operator)
let best_group = best.map(|(_, g)| g).unwrap_or_default();
let role = role_of(&best_group);
if role == "admin" {
(best_group, true, default_role)
} else {
(best_group, false, role)
}
}
pub fn truncate_token(token: &str) -> String {
@@ -478,62 +488,46 @@ mod tests {
}
}
#[test]
fn test_compute_highest_workspace_role_admin_wins() {
let user_groups = vec!["ops".to_string(), "admins".to_string()];
let ws_groups = vec!["ops".to_string(), "admins".to_string()];
let mut roles = std::collections::HashMap::new();
roles.insert("ops".to_string(), "operator".to_string());
roles.insert("admins".to_string(), "admin".to_string());
let (group, is_admin, is_operator) =
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
assert_eq!(group, "admins");
assert!(is_admin);
assert!(!is_operator);
fn order() -> Vec<String> {
vec![
"developer".to_string(),
"builder".to_string(),
"operator".to_string(),
]
}
#[test]
fn test_compute_highest_workspace_role_developer_over_operator() {
let user_groups = vec!["devs".to_string(), "ops".to_string()];
let ws_groups = vec!["devs".to_string(), "ops".to_string()];
fn test_compute_highest_workspace_role_follows_role_order() {
let user_groups = vec!["ops".to_string(), "builders".to_string()];
let ws_groups = user_groups.clone();
let mut roles = std::collections::HashMap::new();
roles.insert("devs".to_string(), "developer".to_string());
roles.insert("ops".to_string(), "operator".to_string());
roles.insert("builders".to_string(), "builder".to_string());
assert_eq!(
compute_highest_workspace_role(&user_groups, &ws_groups, &roles, &order()),
("builders".to_string(), false, "builder".to_string())
);
let (group, is_admin, is_operator) =
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
assert_eq!(group, "devs");
assert!(!is_admin);
assert!(!is_operator);
roles.insert("ops".to_string(), "admin".to_string());
assert_eq!(
compute_highest_workspace_role(&user_groups, &ws_groups, &roles, &order()),
("ops".to_string(), true, "developer".to_string())
);
}
#[test]
fn test_compute_highest_workspace_role_skips_unconfigured_groups() {
let user_groups = vec!["admins".to_string(), "other".to_string()];
let ws_groups = vec!["ops".to_string()]; // admins not configured for this workspace
let mut roles = std::collections::HashMap::new();
roles.insert("admins".to_string(), "admin".to_string());
roles.insert("ops".to_string(), "operator".to_string());
let (group, is_admin, is_operator) =
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
// No user groups match ws_configured_groups, so best_group stays empty
assert_eq!(group, "");
assert!(!is_admin);
assert!(!is_operator);
}
#[test]
fn test_compute_highest_workspace_role_defaults_to_developer() {
let user_groups = vec!["team".to_string()];
fn test_compute_highest_workspace_role_unmapped_group_is_developer() {
let user_groups = vec!["team".to_string(), "other".to_string()];
let ws_groups = vec!["team".to_string()];
let roles = std::collections::HashMap::new(); // no role configured → developer
let (group, is_admin, is_operator) =
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
assert_eq!(group, "team");
assert!(!is_admin);
assert!(!is_operator);
assert_eq!(
compute_highest_workspace_role(&user_groups, &ws_groups, &Default::default(), &order()),
("team".to_string(), false, "developer".to_string())
);
// An instance that dropped `developer` falls back to its first role.
let no_dev = vec!["builder".to_string(), "operator".to_string()];
assert_eq!(
compute_highest_workspace_role(&user_groups, &ws_groups, &Default::default(), &no_dev),
("team".to_string(), false, "builder".to_string())
);
}
}
+116 -156
View File
@@ -7,8 +7,10 @@ use strum::AsRefStr;
use crate::{
datatable_roles::{ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER},
db::Authable,
error::{self, to_anyhow, Error, Result},
get_database_url,
roles::{caller_capabilities, AuthorLevel, RoleCapabilities},
secret_backend::{get_secret_value, is_external_stored_value},
utils::{get_custom_pg_instance_password, get_custom_pg_instance_replication_password},
variables::{build_crypt, decrypt},
@@ -1060,9 +1062,9 @@ pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgre
}
/// Billable members of `w_id` and the seats they cost, as
/// `ceil(developers + (operators + service_accounts)/2)`. A service account is half a seat whatever
/// its role; a disabled member is not billed. In a workspace that granted operators builder rights,
/// every operator counts as a developer.
/// `ceil(authors + (non_authors + service_accounts)/2)`. A service account is half a seat whatever
/// its role; a disabled member is not billed. A member is an author when its role writes or
/// composes scripts, flows or apps (`crate::roles::AUTHOR_SEAT_SQL`).
///
/// The workspace is invoiced by a job outside this codebase that counts the same rows with its own
/// SQL. The two must be changed together: this rule disagreeing with that one is what bills a
@@ -1071,28 +1073,23 @@ pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgre
/// Unauthenticated metering helper: reads member counts for any `w_id`, so callers must already be
/// authorized for that workspace (or run in trusted server-side code).
pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result<BillableSeats> {
let row = sqlx::query!(
r#"SELECT
COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS "developers!",
COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS "operators!",
COUNT(*) FILTER (WHERE is_service_account AND NOT disabled) AS "service_accounts!"
FROM usr WHERE workspace_id = $1"#,
w_id
)
let (developers, operators, service_accounts): (i64, i64, i64) = sqlx::query_as(&format!(
"SELECT
COUNT(*) FILTER (WHERE ({author}) AND NOT disabled AND NOT is_service_account),
COUNT(*) FILTER (WHERE NOT ({author}) AND NOT disabled AND NOT is_service_account),
COUNT(*) FILTER (WHERE is_service_account AND NOT disabled)
FROM usr t WHERE workspace_id = $1",
author = crate::roles::AUTHOR_SEAT_SQL
))
.bind(w_id)
.fetch_one(db)
.await
.map_err(|e| Error::internal_err(format!("counting billable seats of {w_id}: {e:#}")))?;
let (developers, operators) = if operator_builder_rights(db, w_id).await?.any() {
(row.developers + row.operators, 0)
} else {
(row.developers, row.operators)
};
Ok(BillableSeats {
developers,
operators,
service_accounts: row.service_accounts,
seats: ((developers as f64) + 0.5 * ((operators + row.service_accounts) as f64)).ceil()
as i64,
service_accounts,
seats: ((developers as f64) + 0.5 * ((operators + service_accounts) as f64)).ceil() as i64,
})
}
@@ -1212,25 +1209,10 @@ pub fn invalidate_protection_rules_cache(workspace_id: &str) {
PROTECTION_RULES_CACHE.remove(workspace_id);
}
// Operator rights cache
// Role rights. What a restricted caller may author or manage comes from its role; see
// `crate::roles`. The gates below keep the operator vocabulary of the call sites that predate roles.
lazy_static::lazy_static! {
static ref OPERATOR_RIGHTS_CACHE: Cache<String, (OperatorRights, i64)> = Cache::new(1000);
}
/// Every operator right of a workspace, read and cached together because they share one
/// `operator_settings` column and one invalidation. The two groups have opposite polarity:
/// the builder rights are granted on request and cost a seat, the `manage` rights are held by
/// default and cost nothing.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct OperatorRights {
pub builder: OperatorBuilderRights,
pub manage: OperatorManageRights,
}
/// The two things a workspace can let its operators compose. Granted independently: composing a
/// flow is fully checked server-side, while a full-code app is a browser-built bundle no check can
/// read, so an admin may reasonably want the first without the second.
/// What a caller may compose or author, per kind, at least at the `Compose` level.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct OperatorBuilderRights {
pub flows: bool,
@@ -1238,8 +1220,8 @@ pub struct OperatorBuilderRights {
}
/// Which capability a gate needs. Naming a kind at each call site is what keeps the two rights
/// from collapsing back into one: a gate that takes "either" authorizes an app write on a
/// flows-only workspace.
/// from collapsing back into one: a gate that takes "either" authorizes an app write for a role
/// that may only compose flows.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum BuilderKind {
Flows,
@@ -1247,8 +1229,8 @@ pub enum BuilderKind {
}
impl OperatorBuilderRights {
/// Whether either right is granted. Only for surfaces that are genuinely not per-kind: the
/// seat a membership costs, and listing drafts.
/// Whether either right is granted. Only for surfaces that are genuinely not per-kind, such
/// as listing drafts.
pub fn any(&self) -> bool {
self.flows || self.apps
}
@@ -1259,23 +1241,9 @@ impl OperatorBuilderRights {
BuilderKind::Apps => self.apps,
}
}
}
/// Writes an operator may perform unless the workspace withdraws them. Unlike the visibility
/// flags beside them in `operator_settings`, these are enforced; unlike a right a workspace
/// grants, they are held by default and cost no seat.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct OperatorManageRights {
pub schedules: bool,
pub triggers: bool,
}
/// Granted unless explicitly withdrawn. Every path that materializes these rights without a
/// stored setting - a workspace with no `workspace_settings` row included - has to land here, or
/// an upgrade silently revokes what operators could do the day before.
impl Default for OperatorManageRights {
fn default() -> Self {
Self { schedules: true, triggers: true }
pub fn from_capabilities(caps: &RoleCapabilities) -> Self {
Self { flows: caps.flows != AuthorLevel::None, apps: caps.apps != AuthorLevel::None }
}
}
@@ -1306,77 +1274,25 @@ impl ManageKind {
}
}
impl OperatorManageRights {
pub fn has(&self, kind: ManageKind) -> bool {
match kind {
ManageKind::Schedules => self.schedules,
ManageKind::Triggers => self.triggers,
}
}
}
/// What operators of this workspace may still write. Per workspace, not per user.
///
/// Read on every gated write, so it is cached with a 60s TTL. This gates writes, so a withdrawal
/// cannot wait out that TTL on the rest of the fleet: an `AFTER UPDATE OF operator_settings`
/// trigger publishes `notify_operator_settings_change` and every server drops its entry through
/// `process_notify_event`. [`invalidate_operator_rights_cache`] is the local half of that, and
/// what a test flipping the setting directly has to call itself.
///
/// Call it before opening an RLS transaction: it takes a connection from the root pool, and a
/// second pooled connection held alongside a transaction self-deadlocks on a one-connection pool.
async fn operator_rights(db: &DB, workspace_id: &str) -> Result<OperatorRights> {
let now = chrono::Utc::now().timestamp();
if let Some((rights, expiry)) = OPERATOR_RIGHTS_CACHE.get(workspace_id) {
if expiry > now {
return Ok(rights);
}
}
// The builder key defaults to false, a right the workspace has to grant; the manage keys to
// true, rights it has to withdraw. An absent key means "never configured" for both, so the
// defaults have to differ here rather than at the call sites.
let row = sqlx::query!(
"SELECT COALESCE((operator_settings->>'builder_flows')::boolean, false) AS \"flows!\",
COALESCE((operator_settings->>'builder_apps')::boolean, false) AS \"apps!\",
COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\",
COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\"
FROM workspace_settings WHERE workspace_id = $1",
workspace_id
)
.fetch_optional(db)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to fetch operator settings for {workspace_id}: {e:#}"
))
})?;
let rights = row
.map(|r| OperatorRights {
builder: OperatorBuilderRights { flows: r.flows, apps: r.apps },
manage: OperatorManageRights { schedules: r.schedules, triggers: r.triggers },
})
.unwrap_or_default();
OPERATOR_RIGHTS_CACHE.insert(workspace_id.to_string(), (rights, now + 60));
Ok(rights)
}
/// What operators of this workspace may compose out of already-deployed runnables. Per workspace,
/// not per user: every operator gets whatever is granted, and consumes a full seat for it.
pub async fn operator_builder_rights(db: &DB, workspace_id: &str) -> Result<OperatorBuilderRights> {
Ok(operator_rights(db, workspace_id).await?.builder)
}
/// Whether operators of this workspace may save a draft of `kind`: one they could deploy. A
/// resource or variable always, since their routes do not gate operators; a flow or full-code app
/// under its builder right, a schedule or trigger under the matching manage right; nothing else.
pub async fn operator_can_draft(
/// What the caller may compose or author, per kind. Everything for an unrestricted caller.
pub async fn operator_builder_rights<A: Authable + ?Sized>(
db: &DB,
workspace_id: &str,
authed: &A,
) -> Result<OperatorBuilderRights> {
Ok(OperatorBuilderRights::from_capabilities(
&caller_capabilities(db, workspace_id, authed).await?,
))
}
/// Whether the caller may save a draft of `kind`: one they could deploy. A resource or variable
/// always, since their routes do not gate roles; a flow or full-code app at the `Compose` level or
/// above, a low-code app at `Full`, a schedule or trigger under the matching manage right, a
/// script or data pipeline with `code`.
pub async fn operator_can_draft<A: Authable + ?Sized>(
db: &DB,
workspace_id: &str,
authed: &A,
kind: crate::user_drafts::UserDraftItemKind,
) -> Result<bool> {
use crate::user_drafts::UserDraftItemKind;
@@ -1386,64 +1302,108 @@ pub async fn operator_can_draft(
) {
return Ok(true);
}
let rights = operator_rights(db, workspace_id).await?;
let caps = caller_capabilities(db, workspace_id, authed).await?;
// Exhaustive on purpose: a new kind must pick the capability it needs, or every non-admin
// (unrestricted developers included) is refused its drafts.
Ok(match kind {
UserDraftItemKind::Flow => rights.builder.has(BuilderKind::Flows),
UserDraftItemKind::RawApp => rights.builder.has(BuilderKind::Apps),
kind => kind.manage_kind().is_some_and(|m| rights.manage.has(m)),
UserDraftItemKind::Flow => caps.flows != AuthorLevel::None,
UserDraftItemKind::RawApp => caps.apps != AuthorLevel::None,
UserDraftItemKind::Script | UserDraftItemKind::DataPipeline => caps.code,
UserDraftItemKind::App => caps.apps == AuthorLevel::Full,
UserDraftItemKind::Resource | UserDraftItemKind::Variable => true,
UserDraftItemKind::TriggerSchedule
| UserDraftItemKind::TriggerWebhook
| UserDraftItemKind::TriggerDefaultEmail
| UserDraftItemKind::TriggerEmail
| UserDraftItemKind::TriggerHttp
| UserDraftItemKind::TriggerWebsocket
| UserDraftItemKind::TriggerPostgres
| UserDraftItemKind::TriggerKafka
| UserDraftItemKind::TriggerNats
| UserDraftItemKind::TriggerMqtt
| UserDraftItemKind::TriggerAmqp
| UserDraftItemKind::TriggerSqs
| UserDraftItemKind::TriggerGcp
| UserDraftItemKind::TriggerAzure
| UserDraftItemKind::TriggerPoll
| UserDraftItemKind::TriggerCli
| UserDraftItemKind::TriggerNextcloud
| UserDraftItemKind::TriggerGoogle
| UserDraftItemKind::TriggerGithub => {
kind.manage_kind().is_some_and(|m| caps.has_manage(m))
}
})
}
/// Gate for a write only a workspace that granted the builder right covering `kind` lets operators
/// perform. `action` completes "Operators cannot {action} for security reasons".
pub async fn check_operator_can_build(
/// Gate for a flow or app write. Refuses a caller whose role holds no right on `kind`, and returns
/// the level it does hold: at `Compose` the caller MUST then run the composition-only checks, and
/// only at `Full` may skip them. `action` completes "Your role cannot {action}".
pub async fn check_operator_can_build<A: Authable + ?Sized>(
db: &DB,
workspace_id: &str,
is_operator: bool,
authed: &A,
kind: BuilderKind,
action: &str,
) -> Result<()> {
if is_operator && !operator_builder_rights(db, workspace_id).await?.has(kind) {
) -> Result<AuthorLevel> {
let level = caller_capabilities(db, workspace_id, authed)
.await?
.level(kind);
if level == AuthorLevel::None {
return Err(Error::PermissionDenied(format!(
"Operators cannot {action} for security reasons"
"Your role cannot {action} in this workspace"
)));
}
Ok(level)
}
/// Gate for a write that runs or stores code of the caller's own on a worker: scripts, previews,
/// dependency jobs, dynamic selects. `action` completes "Your role cannot {action}".
pub async fn check_role_can_code<A: Authable + ?Sized>(
db: &DB,
workspace_id: &str,
authed: &A,
action: &str,
) -> Result<()> {
if !caller_capabilities(db, workspace_id, authed).await?.code {
return Err(Error::PermissionDenied(format!(
"Your role cannot {action} in this workspace"
)));
}
Ok(())
}
/// Whether a membership consumes an operator (half) seat rather than an author seat. Either
/// builder right makes an operator an author of deployable artifacts, so both weigh the same full
/// seat: there is no half-author.
/// Whether a member holding role `role` in `workspace_id` consumes an operator (half) seat rather
/// than an author seat: a role that composes counts as an author, there is no half-author.
pub async fn consumes_operator_seat(
db: &DB,
workspace_id: &str,
is_operator: bool,
is_admin: bool,
role: &str,
) -> Result<bool> {
Ok(is_operator && !operator_builder_rights(db, workspace_id).await?.any())
Ok(!is_admin
&& !crate::roles::role_capabilities_by_name(db, workspace_id, role)
.await?
.is_author())
}
/// Invalidate the operator rights cache for a workspace
pub fn invalidate_operator_rights_cache(workspace_id: &str) {
OPERATOR_RIGHTS_CACHE.remove(workspace_id);
}
/// Gate for a write operators may perform unless the workspace withdrew it. Prefer layering
/// Gate for a write a role may perform unless its configuration withdraws it. Prefer layering
/// `gate_operator_writes` on a whole router over calling this per handler; see
/// `docs/operator-write-rights.md`. Both `workspace_id` and `is_operator` MUST come from the
/// authenticated request (the route's workspace, `ApiAuthed::is_operator`): passing `false` skips
/// the check, and another workspace's id checks the wrong settings.
pub async fn check_operator_can_manage(
/// `docs/operator-write-rights.md`. `workspace_id` MUST be the route's workspace.
pub async fn check_operator_can_manage<A: Authable + ?Sized>(
db: &DB,
workspace_id: &str,
is_operator: bool,
authed: &A,
kind: ManageKind,
) -> Result<()> {
if is_operator && !operator_rights(db, workspace_id).await?.manage.has(kind) {
if !caller_capabilities(db, workspace_id, authed)
.await?
.has_manage(kind)
{
// 403, not 401: the caller is authenticated and simply lacks the right. The frontend reads
// an uncaught 401 as a dead session and logs the user out, so `NotAuthorized` here would
// eject an operator from the app instead of telling them why.
// eject the user from the app instead of telling them why.
return Err(Error::PermissionDenied(format!(
"Operators cannot manage {} in this workspace",
"Your role cannot manage {} in this workspace",
kind.noun()
)));
}
@@ -125,13 +125,12 @@ async fn paid_seats_and_fork_count(db: Pool<Postgres>) {
);
sqlx::query(
"INSERT INTO workspace_settings (workspace_id, operator_settings)
VALUES ('seat-root', '{\"builder_flows\": true}')",
"INSERT INTO role_seat (workspace_id, role, author) VALUES ('seat-root', 'operator', true)",
)
.execute(&db)
.await
.unwrap();
windmill_common::workspaces::invalidate_operator_rights_cache("seat-root");
windmill_common::roles::invalidate_all_roles_cache();
let breakdown = billable_seats(&db, "seat-root").await.unwrap();
assert_eq!(
(
@@ -141,22 +140,29 @@ async fn paid_seats_and_fork_count(db: Pool<Postgres>) {
breakdown.seats
),
(4, 0, 1, 5),
"builder rights bill every operator as a developer, service accounts stay half a seat"
"a role that composes bills its members as developers, service accounts stay half a seat"
);
sqlx::query("UPDATE role_seat SET author = false WHERE workspace_id = 'seat-root'")
.execute(&db)
.await
.unwrap();
windmill_common::roles::invalidate_all_roles_cache();
let breakdown = billable_seats(&db, "seat-root").await.unwrap();
assert_eq!(
(breakdown.developers, breakdown.operators, breakdown.seats),
(2, 2, 4),
"a workspace override that authors nothing bills its members at half a seat"
);
// An admin holds everything, so it is a full seat whatever role it keeps underneath.
sqlx::query(
"UPDATE workspace_settings SET operator_settings = '{\"builder_apps\": true}'
WHERE workspace_id = 'seat-root'",
"UPDATE usr SET is_admin = true WHERE workspace_id = 'seat-root' AND email = 'op1@w.dev'",
)
.execute(&db)
.await
.unwrap();
windmill_common::workspaces::invalidate_operator_rights_cache("seat-root");
let breakdown = billable_seats(&db, "seat-root").await.unwrap();
assert_eq!(
(breakdown.developers, breakdown.operators, breakdown.seats),
(4, 0, 5),
"the app right alone also bills every operator as a developer"
);
assert_eq!((breakdown.developers, breakdown.operators), (3, 1));
insert_ws(&db, "seat-fork1", Some("seat-root"), false).await;
insert_ws(&db, "seat-fork2", Some("seat-root"), false).await;
@@ -440,8 +440,13 @@ mod tests {
.and_then(|ig| serde_json::from_value(ig).ok())
.unwrap_or_default();
let (best_group, is_admin, is_operator) =
compute_highest_workspace_role(&user_igroups, &ws_configured_groups, &ws_roles);
let (best_group, is_admin, role) = compute_highest_workspace_role(
&user_igroups,
&ws_configured_groups,
&ws_roles,
&["developer".to_string(), "operator".to_string()],
);
let is_operator = role == "operator";
let instance_group_source = json!({
"source": "instance_group",
@@ -528,8 +533,13 @@ mod tests {
let ws_roles: std::collections::HashMap<String, String> =
serde_json::from_value(roles).unwrap();
let (best_group, is_admin, is_operator) =
compute_highest_workspace_role(&user_igroups, &ws_configured_groups, &ws_roles);
let (best_group, is_admin, role) = compute_highest_workspace_role(
&user_igroups,
&ws_configured_groups,
&ws_roles,
&["developer".to_string(), "operator".to_string()],
);
let is_operator = role == "operator";
let instance_group_source = json!({"source": "instance_group", "group": &best_group});
sqlx::query!(
@@ -0,0 +1,80 @@
#![cfg(feature = "enterprise")]
use std::collections::BTreeMap;
use sqlx::{Pool, Postgres};
use windmill_common::global_settings::WORKSPACE_ROLES_SETTING;
use windmill_common::instance_config::sync_global_settings_declarative;
/// The declarative config sync writes `workspace_roles` without the HTTP settings handler, so it
/// must validate the value and rebuild `role_seat` itself, or billing misprices the new roles.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn declarative_workspace_roles_validates_and_rebuilds_seats(db: Pool<Postgres>) {
windmill_common::roles::invalidate_all_roles_cache();
let desired =
|roles: serde_json::Value| BTreeMap::from([(WORKSPACE_ROLES_SETTING.to_string(), roles)]);
sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired(serde_json::json!([{"name": "x", "capabilities": {"flows": "full"}}])),
&(),
)
.await
.expect_err("full flows without code must be refused");
sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired(serde_json::json!([
{"name": "developer"},
{"name": "builder", "capabilities": {"flows": "compose"}},
{"name": "operator"}
])),
&(),
)
.await
.unwrap();
let author: bool = sqlx::query_scalar(
"SELECT author FROM role_seat WHERE workspace_id = '' AND role = 'builder'",
)
.fetch_one(&db)
.await
.unwrap();
assert!(author, "a role that composes flows costs a full seat");
// A workspace grants `builder` full flows on top of an instance definition that codes; the
// instance then withdrawing `code` would leave that workspace with inline code and no code.
sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired(serde_json::json!([
{"name": "developer"},
{"name": "builder", "capabilities": {"code": true, "flows": "compose"}},
{"name": "operator"}
])),
&(),
)
.await
.unwrap();
sqlx::query(
"UPDATE workspace_settings SET role_overrides =
'{\"builder\": {\"capabilities\": {\"flows\": \"full\"}}}'
WHERE workspace_id = 'test-workspace'",
)
.execute(&db)
.await
.unwrap();
sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired(serde_json::json!([
{"name": "developer"},
{"name": "builder", "capabilities": {"code": false, "flows": "compose"}},
{"name": "operator"}
])),
&(),
)
.await
.expect_err("an instance change that invalidates a workspace override must be refused");
}
@@ -0,0 +1,70 @@
#![cfg(feature = "enterprise")]
//! Flips the process-wide `LICENSE_KEY_VALID`, so it keeps a test binary of its own.
use std::sync::atomic::Ordering;
use sqlx::{Pool, Postgres};
use windmill_common::ee_oss::LICENSE_KEY_VALID;
use windmill_common::roles::{
self, check_frozen_overrides, instance_roles, load_role_overrides,
validate_instance_roles_write, RoleOverrides,
};
/// A lapsed license freezes the configured roles: falling back to the defaults would give a
/// restricted `developer` everything and strand the members of workspace-only roles.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn lapsed_license_freezes_roles(db: Pool<Postgres>) {
let restricted = serde_json::json!([
{"name": "developer", "capabilities": {"code": false, "flows": "none", "apps": "none"}}
]);
sqlx::query("INSERT INTO global_settings (name, value) VALUES ('workspace_roles', $1)")
.bind(&restricted)
.execute(&db)
.await
.unwrap();
sqlx::query(
"UPDATE workspace_settings SET role_overrides = '{\"auditor\": {\"label\": \"Auditor\"}}'
WHERE workspace_id = 'test-workspace'",
)
.execute(&db)
.await
.unwrap();
LICENSE_KEY_VALID.store(false, Ordering::Relaxed);
roles::invalidate_all_roles_cache();
let ws = roles::workspace_roles(&db, "test-workspace").await.unwrap();
let dev = ws.iter().find(|r| r.name == "developer").unwrap();
assert!(
!dev.capabilities.code,
"the restricted developer must not widen"
);
assert!(
ws.iter().any(|r| r.name == "auditor"),
"workspace-only roles keep applying"
);
let changed = serde_json::json!([{"name": "developer"}, {"name": "scripter"}]);
validate_instance_roles_write(&db, Some(&changed))
.await
.expect_err("changing instance roles needs a license");
validate_instance_roles_write(&db, Some(&restricted))
.await
.expect("resaving the stored roles is a no-op");
validate_instance_roles_write(&db, None)
.await
.expect("resetting to the defaults stays open");
let instance = instance_roles(&db).await.unwrap();
let previous = load_role_overrides(&db, "test-workspace").await.unwrap();
let mut relabelled = previous.clone();
relabelled.get_mut("auditor").unwrap().label = Some("Reader".to_string());
check_frozen_overrides(&instance, &previous, &relabelled)
.expect_err("editing a workspace-only role needs a license");
check_frozen_overrides(&instance, &previous, &previous).expect("unchanged entries pass");
check_frozen_overrides(&instance, &previous, &RoleOverrides::new())
.expect("removing a workspace-only role stays open");
LICENSE_KEY_VALID.store(true, Ordering::Relaxed);
check_frozen_overrides(&instance, &previous, &relabelled).expect("licensed again");
}
@@ -21,7 +21,7 @@ async fn list_repos(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<GithubRepoEntry>> {
require_native_integration_use(&authed)?;
require_native_integration_use(&db, &workspace_id, &authed).await?;
get_workspace_integration(&db, &workspace_id, ServiceName::Github).await?;
let mut all_entries = Vec::new();
@@ -93,7 +93,7 @@ async fn list_calendars(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<GoogleCalendarEntry>> {
require_native_integration_use(&authed)?;
require_native_integration_use(&db, &workspace_id, &authed).await?;
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let url = format!(
@@ -126,7 +126,7 @@ async fn list_drive_files(
Path(workspace_id): Path<String>,
Query(query): Query<DriveFilesQuery>,
) -> JsonResult<GoogleDriveFilesResponse> {
require_native_integration_use(&authed)?;
require_native_integration_use(&db, &workspace_id, &authed).await?;
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let drive_query = if query.shared_with_me {
@@ -201,7 +201,7 @@ async fn list_shared_drives(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<SharedDriveEntry>> {
require_native_integration_use(&authed)?;
require_native_integration_use(&db, &workspace_id, &authed).await?;
get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?;
let url = format!(
+11 -10
View File
@@ -1733,17 +1733,18 @@ pub async fn store_workspace_integration(
}
/// Authorization gate for the integration *use* routes (calendar/drive/repo/event
/// pickers). A workspace admin configures the integration, but any member who can
/// create a native trigger needs the pickers to configure one. Operators are
/// read-only and cannot create triggers, so they must not be able to drive the
/// admin-configured integration's upstream API and enumerate its data.
pub fn require_native_integration_use(authed: &ApiAuthed) -> Result<()> {
if authed.is_operator {
return Err(Error::NotAuthorized(
"Operators cannot use workspace integrations".to_string(),
));
/// pickers). They drive the admin-connected account's upstream API and enumerate its data, so
/// they need both the right to manage triggers and the right to write code: a role that only
/// manages triggers (the default `operator`) must not browse the admin's Drive or repos.
pub async fn require_native_integration_use(db: &DB, w_id: &str, authed: &ApiAuthed) -> Result<()> {
let caps = windmill_common::roles::caller_capabilities(db, w_id, authed).await?;
if caps.code && caps.manage_triggers {
Ok(())
} else {
Err(Error::PermissionDenied(
"Your role cannot use this workspace's native trigger integrations".to_string(),
))
}
Ok(())
}
pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Postgres>>(
@@ -21,7 +21,7 @@ async fn list_available_events<T: External>(
Extension(db): Extension<DB>,
Path(workspace_id): Path<String>,
) -> JsonResult<Vec<NextCloudEventType>> {
require_native_integration_use(&authed)?;
require_native_integration_use(&db, &workspace_id, &authed).await?;
let integration = get_workspace_integration(&db, &workspace_id, ServiceName::Nextcloud).await?;
let base_url = integration

Some files were not shown because too many files have changed in this diff Show More