mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-23 00:03:46 +00:00
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:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
c740a8f369
commit
21d2a49971
@@ -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
|
||||
|
||||
+23
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+7
-1
@@ -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
|
||||
]
|
||||
|
||||
+52
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -17,7 +17,8 @@
|
||||
"script",
|
||||
"flow",
|
||||
"raw_app",
|
||||
"asset"
|
||||
"asset",
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
+94
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@
|
||||
"script",
|
||||
"flow",
|
||||
"raw_app",
|
||||
"asset"
|
||||
"asset",
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+35
@@ -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"
|
||||
}
|
||||
+41
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+41
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+18
@@ -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"
|
||||
}
|
||||
+41
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+76
@@ -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"
|
||||
}
|
||||
+41
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+52
@@ -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"
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
|
||||
+52
@@ -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"
|
||||
}
|
||||
+35
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -39,7 +39,8 @@
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby",
|
||||
"rlang"
|
||||
"rlang",
|
||||
"dbt"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -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"
|
||||
}
|
||||
+77
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+1
-1
@@ -23,4 +23,4 @@
|
||||
]
|
||||
},
|
||||
"hash": "ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b"
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM role_seat WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9"
|
||||
}
|
||||
+2
-1
@@ -34,7 +34,8 @@
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby",
|
||||
"rlang"
|
||||
"rlang",
|
||||
"dbt"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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 @@
|
||||
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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user