From 21d2a49971b858ff8aef2776a36989b57ba49415 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 21 Oct 2026 15:51:54 +0200 Subject: [PATCH] feat: configurable workspace roles with per-workspace overrides (#11821) * feat: configurable workspace roles with per-workspace overrides Co-Authored-By: Claude Opus 5.5 * fix: close role review gaps in pickers, declarative sync and removal Co-Authored-By: Claude Opus 5.5 * docs: keep resolve_role_name doc on its function Co-Authored-By: Claude Opus 5.5 * fix: validate overrides on instance role changes and bill admins fully Co-Authored-By: Claude Opus 5.5 * fix: allow pipeline drafts and keep role checks on one connection Co-Authored-By: Claude Opus 5.5 * docs: list data pipelines in operator_can_draft Co-Authored-By: Claude Opus 5.5 * test: expect a 403 role refusal when an operator resolves jobs Co-Authored-By: Claude Opus 5.5 * feat: require a valid license for custom workspace roles Co-Authored-By: Claude Opus 5.5 * fix: freeze workspace roles when the license lapses Co-Authored-By: Claude Opus 5.5 * chore: pin the ee companion for workspace roles Co-Authored-By: Claude Opus 5.5 * refactor: broadcast role override changes from the write path, not a trigger Co-Authored-By: Claude Opus 5.5 * 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 * fix: treat superadmin members as admins and return role names from get user Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- AGENTS.md | 3 + ...e8848ac8184d61d3f7cde126a56080aef643c.json | 23 + ...2a383e841fa7c3f1c5e2849481c6057ab1e84.json | 23 + ...8d16414daad90ad8b0ea35a769d78626039aa.json | 15 + ...473ac77fad069e7c805cf3c2352bd72f8ffb6.json | 22 + ...0e086996d5fcc3c270e5c1cc9824c30d82726.json | 17 + ...a05768ed9a679ba908ab16497a9bd55578ba1.json | 8 +- ...85f96bd91de548185238f9fd66ac2b4a1e0a0.json | 52 + ...2f722f631882929086c453b5825ab4b9c1cb8.json | 23 + ...22b8cc0b2fece43f03c0f025e7acefa0d4f32.json | 3 +- ...e48d266b2d5dc11236c2bb41b1a927ede63d8.json | 15 + ...f168c2f79258fb900199e8575973d5b3d32bb.json | 94 ++ ...227a40a49d9435107e71033740f11cc8d2417.json | 15 + ...16b526db8e60887d6279b0e66197da686fbf8.json | 19 + ...64c4b6a41d96df223b6d3d1d1bc972ab10d42.json | 16 + ...3db8bf47f11969a0c026a35a6673574e074d8.json | 19 + ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...953632040122a9af98332d3fd1d12244b1b80.json | 3 +- ...35168e2cd0e627e3bbff413e38f8a3c616aa0.json | 23 + ...065a203ca86b40e8388db963c183ac8fb4fd6.json | 18 + ...400a6fa59e8a88bebd6e63437e9cb4c491333.json | 35 + ...5844a0fbb10fb79e25a604eeb0d83d2122d96.json | 41 + ...069204bfc974a55da927658947f3e335da3f5.json | 23 + ...e7c732775949f92e1f099ff997d385b025f55.json | 41 + ...0d78c8e577dbce51f8571a9ffe3e8e1e652a1.json | 15 + ...669551ed6860b0839dd019967569e2325b0bc.json | 18 + ...54cff4538e230b3cd72dd2e840a08b2a178d9.json | 41 + ...67000a7bc8e56adddcd9304b05daf814e5246.json | 22 + ...a3514e0756722762fabe49e1d291ce5c09a19.json | 16 + ...28fab0f26ee5a0b9550ea4ab04e2857bebeda.json | 15 + ...887e613debd842e575cc740502144bb279bb3.json | 76 ++ ...e597611be96a8f2c6a8ea884c39b11620be57.json | 41 + ...d5f82cc2121ea8519311add88f23f0ea0dae9.json | 17 + ...dc509bbd1e322788789a913d5148c1113643c.json | 52 + ...bf54a3f182c03cef0aef06a1c75b45992cd39.json | 2 +- ...61459604a093a74e22770ccdc14207cacbfb0.json | 52 + ...a7152c190bab532406fd26d17dfbd2355c764.json | 35 + ...4b3bc473599c4cb1b2181144988eb88e9fa17.json | 29 + ...ec87a0016b1949cfe2d901aacc281d9442738.json | 15 + ...69b58a0016bfb4f0dad133ef2c25b8cd8b4b6.json | 17 + ...46d9b9ecb504cbea4976c8f45fc089cb81977.json | 15 + ...6f0322a83c01cc465742f54a21f8fe5f4f037.json | 3 +- ...a3f1578a6be8686e02c3faa55100eb8fa3700.json | 17 + ...986ea2bfd561943ff512ac20f93203e02c3d8.json | 77 ++ ...88f5bc2c40c098558d92ab768281f4e9664c5.json | 23 + ...c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b.json | 2 +- ...0c7ed15b7bf962ba19fcd09b23f8e982fa1c9.json | 14 + ...114718468fbf75ef13c6087c2f57fbbe0b82f.json | 3 +- ...513d23e8ef5412d030a172f81dd9a9be0bf2f.json | 23 + backend/ee-repo-ref.txt | 2 +- .../20261016072241_workspace_roles.down.sql | 4 + .../20261016072241_workspace_roles.up.sql | 68 ++ backend/src/main.rs | 12 +- backend/tests/app_preview_auth.rs | 6 +- backend/tests/inline_preview_auth.rs | 2 +- backend/windmill-api-auth/src/auth.rs | 50 +- backend/windmill-api-auth/src/lib.rs | 2 +- backend/windmill-api-flows/src/flows.rs | 48 +- .../windmill-api-groups/src/granular_acls.rs | 4 +- .../tests/datatable_roles.rs | 2 +- .../tests/drafts.rs | 16 + .../tests/jobs_authed.rs | 3 +- .../tests/native_triggers.rs | 59 +- .../tests/operator_builder_apps.rs | 9 +- .../tests/operator_builder_flows.rs | 20 +- .../tests/operator_manage_rights.rs | 15 +- .../tests/workspaces.rs | 30 + backend/windmill-api-schedule/src/lib.rs | 14 +- backend/windmill-api-scripts/src/scripts.rs | 30 +- backend/windmill-api-settings/src/lib.rs | 31 +- backend/windmill-api-users/src/users.rs | 205 ++-- .../src/datatable_migrations.rs | 32 +- backend/windmill-api-workspaces/src/lib.rs | 1 + .../src/workspace_roles.rs | 290 +++++ .../windmill-api-workspaces/src/workspaces.rs | 290 ++--- .../src/workspaces_extra.rs | 16 +- backend/windmill-api/openapi.yaml | 184 +++- backend/windmill-api/src/ai_evals/datasets.rs | 24 +- backend/windmill-api/src/ai_evals/mod.rs | 8 +- backend/windmill-api/src/ai_evals/run.rs | 2 +- backend/windmill-api/src/apps.rs | 121 +-- backend/windmill-api/src/drafts.rs | 12 +- backend/windmill-api/src/jobs.rs | 85 +- backend/windmill-api/src/runnables.rs | 85 +- backend/windmill-common/src/auth.rs | 11 +- backend/windmill-common/src/ee_oss.rs | 14 +- .../windmill-common/src/global_settings.rs | 3 + .../windmill-common/src/instance_config.rs | 13 + backend/windmill-common/src/lib.rs | 3 +- backend/windmill-common/src/roles.rs | 998 ++++++++++++++++++ backend/windmill-common/src/users.rs | 138 ++- backend/windmill-common/src/workspaces.rs | 272 ++--- .../tests/billing_workspace.rs | 30 +- .../tests/instance_group_auto_add.rs | 18 +- .../tests/workspace_roles_declarative.rs | 80 ++ .../tests/workspace_roles_license.rs | 70 ++ .../src/github/routes.rs | 2 +- .../src/google/routes.rs | 6 +- backend/windmill-native-triggers/src/lib.rs | 21 +- .../src/nextcloud/routes.rs | 2 +- backend/windmill-store/src/resources.rs | 3 +- backend/windmill-store/src/variables.rs | 3 +- backend/windmill-trigger/src/handler.rs | 14 +- docs/operator-builder-rights.md | 28 +- docs/operator-write-rights.md | 18 +- docs/workspace-roles.md | 66 ++ 106 files changed, 3966 insertions(+), 822 deletions(-) create mode 100644 backend/.sqlx/query-0d419f09cce9f33fecfabc62b17e8848ac8184d61d3f7cde126a56080aef643c.json create mode 100644 backend/.sqlx/query-0d69f5f6d2e3a4b7b801c6feea92a383e841fa7c3f1c5e2849481c6057ab1e84.json create mode 100644 backend/.sqlx/query-0d73210f3ce58d0dd8fd850c1df8d16414daad90ad8b0ea35a769d78626039aa.json create mode 100644 backend/.sqlx/query-104b8d267f8f904122973d20629473ac77fad069e7c805cf3c2352bd72f8ffb6.json create mode 100644 backend/.sqlx/query-1c97bf1af258b43fb8dedd3417d0e086996d5fcc3c270e5c1cc9824c30d82726.json create mode 100644 backend/.sqlx/query-1fbb8493d20eacea2ac78c4473a85f96bd91de548185238f9fd66ac2b4a1e0a0.json create mode 100644 backend/.sqlx/query-205f2fb7629382294e53869aa5d2f722f631882929086c453b5825ab4b9c1cb8.json create mode 100644 backend/.sqlx/query-25b09b592b6917214fd7cfabfece48d266b2d5dc11236c2bb41b1a927ede63d8.json create mode 100644 backend/.sqlx/query-2e8a53e73c3b5c2662efd0e2876f168c2f79258fb900199e8575973d5b3d32bb.json create mode 100644 backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json create mode 100644 backend/.sqlx/query-3b01c43a828ec5d61ed92a27bdb16b526db8e60887d6279b0e66197da686fbf8.json create mode 100644 backend/.sqlx/query-464286216add454cb64dba810d964c4b6a41d96df223b6d3d1d1bc972ab10d42.json create mode 100644 backend/.sqlx/query-4c74a75d99f8d6689470da1d57d3db8bf47f11969a0c026a35a6673574e074d8.json create mode 100644 backend/.sqlx/query-65acc5b9322f03266079ea6051535168e2cd0e627e3bbff413e38f8a3c616aa0.json create mode 100644 backend/.sqlx/query-729852e65e4cf22c474850d46a2065a203ca86b40e8388db963c183ac8fb4fd6.json create mode 100644 backend/.sqlx/query-72bc3795182333d203b4731defb400a6fa59e8a88bebd6e63437e9cb4c491333.json create mode 100644 backend/.sqlx/query-7341d832643627cdc33a6eefbca5844a0fbb10fb79e25a604eeb0d83d2122d96.json create mode 100644 backend/.sqlx/query-74190dc8f1049b61adfcefe19df069204bfc974a55da927658947f3e335da3f5.json create mode 100644 backend/.sqlx/query-7e71cb88613a78b1b6bd0327970e7c732775949f92e1f099ff997d385b025f55.json create mode 100644 backend/.sqlx/query-80977e90833b63da4cdde111db80d78c8e577dbce51f8571a9ffe3e8e1e652a1.json create mode 100644 backend/.sqlx/query-80d62bd0f372a322d9c2a548a66669551ed6860b0839dd019967569e2325b0bc.json create mode 100644 backend/.sqlx/query-884dd7d41713a964a0fbb854d6e54cff4538e230b3cd72dd2e840a08b2a178d9.json create mode 100644 backend/.sqlx/query-9784d19363fa14b9d7cc49e208967000a7bc8e56adddcd9304b05daf814e5246.json create mode 100644 backend/.sqlx/query-988c720384e4e2d70bfa11196f3a3514e0756722762fabe49e1d291ce5c09a19.json create mode 100644 backend/.sqlx/query-a32aea24f8cc6e0aed80dfa1f0928fab0f26ee5a0b9550ea4ab04e2857bebeda.json create mode 100644 backend/.sqlx/query-a37bd72bb1a151e859b4a37a447887e613debd842e575cc740502144bb279bb3.json create mode 100644 backend/.sqlx/query-acd87efeb1e92f6c5f04f8482fde597611be96a8f2c6a8ea884c39b11620be57.json create mode 100644 backend/.sqlx/query-ad218040fc53b063508774c6f04d5f82cc2121ea8519311add88f23f0ea0dae9.json create mode 100644 backend/.sqlx/query-b0e56637096b48867401967ad5ddc509bbd1e322788789a913d5148c1113643c.json create mode 100644 backend/.sqlx/query-b57b038384e48f2e6f288056e7261459604a093a74e22770ccdc14207cacbfb0.json create mode 100644 backend/.sqlx/query-b8b89624212f32fb6e994b7488fa7152c190bab532406fd26d17dfbd2355c764.json create mode 100644 backend/.sqlx/query-c1be495552d1bd4c38e378016324b3bc473599c4cb1b2181144988eb88e9fa17.json create mode 100644 backend/.sqlx/query-c7a1a350f95523df4e2012d2d21ec87a0016b1949cfe2d901aacc281d9442738.json create mode 100644 backend/.sqlx/query-ca4c2768d111fbd653f1a73afc669b58a0016bfb4f0dad133ef2c25b8cd8b4b6.json create mode 100644 backend/.sqlx/query-cbc0edfcdda98420e728e35508e46d9b9ecb504cbea4976c8f45fc089cb81977.json create mode 100644 backend/.sqlx/query-ce033ee4db97c0f9ae643d287f1a3f1578a6be8686e02c3faa55100eb8fa3700.json create mode 100644 backend/.sqlx/query-d46f75bb9d7144d571a31ddbb3c986ea2bfd561943ff512ac20f93203e02c3d8.json create mode 100644 backend/.sqlx/query-dcb5053bdae0a2d141f7e7cb16988f5bc2c40c098558d92ab768281f4e9664c5.json create mode 100644 backend/.sqlx/query-e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9.json create mode 100644 backend/.sqlx/query-f54795cfa04ef4d0f6ed05aee09513d23e8ef5412d030a172f81dd9a9be0bf2f.json create mode 100644 backend/migrations/20261016072241_workspace_roles.down.sql create mode 100644 backend/migrations/20261016072241_workspace_roles.up.sql create mode 100644 backend/windmill-api-workspaces/src/workspace_roles.rs create mode 100644 backend/windmill-common/src/roles.rs create mode 100644 backend/windmill-common/tests/workspace_roles_declarative.rs create mode 100644 backend/windmill-common/tests/workspace_roles_license.rs create mode 100644 docs/workspace-roles.md diff --git a/AGENTS.md b/AGENTS.md index 6516e0e095..6a3bcf0463 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/backend/.sqlx/query-0d419f09cce9f33fecfabc62b17e8848ac8184d61d3f7cde126a56080aef643c.json b/backend/.sqlx/query-0d419f09cce9f33fecfabc62b17e8848ac8184d61d3f7cde126a56080aef643c.json new file mode 100644 index 0000000000..1dd5c1042c --- /dev/null +++ b/backend/.sqlx/query-0d419f09cce9f33fecfabc62b17e8848ac8184d61d3f7cde126a56080aef643c.json @@ -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" +} diff --git a/backend/.sqlx/query-0d69f5f6d2e3a4b7b801c6feea92a383e841fa7c3f1c5e2849481c6057ab1e84.json b/backend/.sqlx/query-0d69f5f6d2e3a4b7b801c6feea92a383e841fa7c3f1c5e2849481c6057ab1e84.json new file mode 100644 index 0000000000..56caf2108d --- /dev/null +++ b/backend/.sqlx/query-0d69f5f6d2e3a4b7b801c6feea92a383e841fa7c3f1c5e2849481c6057ab1e84.json @@ -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" +} diff --git a/backend/.sqlx/query-0d73210f3ce58d0dd8fd850c1df8d16414daad90ad8b0ea35a769d78626039aa.json b/backend/.sqlx/query-0d73210f3ce58d0dd8fd850c1df8d16414daad90ad8b0ea35a769d78626039aa.json new file mode 100644 index 0000000000..07a85d9004 --- /dev/null +++ b/backend/.sqlx/query-0d73210f3ce58d0dd8fd850c1df8d16414daad90ad8b0ea35a769d78626039aa.json @@ -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" +} diff --git a/backend/.sqlx/query-104b8d267f8f904122973d20629473ac77fad069e7c805cf3c2352bd72f8ffb6.json b/backend/.sqlx/query-104b8d267f8f904122973d20629473ac77fad069e7c805cf3c2352bd72f8ffb6.json new file mode 100644 index 0000000000..e9fb7a6b3b --- /dev/null +++ b/backend/.sqlx/query-104b8d267f8f904122973d20629473ac77fad069e7c805cf3c2352bd72f8ffb6.json @@ -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" +} diff --git a/backend/.sqlx/query-1c97bf1af258b43fb8dedd3417d0e086996d5fcc3c270e5c1cc9824c30d82726.json b/backend/.sqlx/query-1c97bf1af258b43fb8dedd3417d0e086996d5fcc3c270e5c1cc9824c30d82726.json new file mode 100644 index 0000000000..bc0ce67103 --- /dev/null +++ b/backend/.sqlx/query-1c97bf1af258b43fb8dedd3417d0e086996d5fcc3c270e5c1cc9824c30d82726.json @@ -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" +} diff --git a/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json b/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json index b4a9f19f45..e6233b6d9f 100644 --- a/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json +++ b/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json @@ -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 ] diff --git a/backend/.sqlx/query-1fbb8493d20eacea2ac78c4473a85f96bd91de548185238f9fd66ac2b4a1e0a0.json b/backend/.sqlx/query-1fbb8493d20eacea2ac78c4473a85f96bd91de548185238f9fd66ac2b4a1e0a0.json new file mode 100644 index 0000000000..084ae3562e --- /dev/null +++ b/backend/.sqlx/query-1fbb8493d20eacea2ac78c4473a85f96bd91de548185238f9fd66ac2b4a1e0a0.json @@ -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" +} diff --git a/backend/.sqlx/query-205f2fb7629382294e53869aa5d2f722f631882929086c453b5825ab4b9c1cb8.json b/backend/.sqlx/query-205f2fb7629382294e53869aa5d2f722f631882929086c453b5825ab4b9c1cb8.json new file mode 100644 index 0000000000..e7b50bb89a --- /dev/null +++ b/backend/.sqlx/query-205f2fb7629382294e53869aa5d2f722f631882929086c453b5825ab4b9c1cb8.json @@ -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" +} diff --git a/backend/.sqlx/query-22e14fc3bb5d8cf3006f0002e8522b8cc0b2fece43f03c0f025e7acefa0d4f32.json b/backend/.sqlx/query-22e14fc3bb5d8cf3006f0002e8522b8cc0b2fece43f03c0f025e7acefa0d4f32.json index 3df72f8955..6e8e17ddf2 100644 --- a/backend/.sqlx/query-22e14fc3bb5d8cf3006f0002e8522b8cc0b2fece43f03c0f025e7acefa0d4f32.json +++ b/backend/.sqlx/query-22e14fc3bb5d8cf3006f0002e8522b8cc0b2fece43f03c0f025e7acefa0d4f32.json @@ -17,7 +17,8 @@ "script", "flow", "raw_app", - "asset" + "asset", + "agent" ] } } diff --git a/backend/.sqlx/query-25b09b592b6917214fd7cfabfece48d266b2d5dc11236c2bb41b1a927ede63d8.json b/backend/.sqlx/query-25b09b592b6917214fd7cfabfece48d266b2d5dc11236c2bb41b1a927ede63d8.json new file mode 100644 index 0000000000..ed1e19e7e0 --- /dev/null +++ b/backend/.sqlx/query-25b09b592b6917214fd7cfabfece48d266b2d5dc11236c2bb41b1a927ede63d8.json @@ -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" +} diff --git a/backend/.sqlx/query-2e8a53e73c3b5c2662efd0e2876f168c2f79258fb900199e8575973d5b3d32bb.json b/backend/.sqlx/query-2e8a53e73c3b5c2662efd0e2876f168c2f79258fb900199e8575973d5b3d32bb.json new file mode 100644 index 0000000000..d55c6ebb51 --- /dev/null +++ b/backend/.sqlx/query-2e8a53e73c3b5c2662efd0e2876f168c2f79258fb900199e8575973d5b3d32bb.json @@ -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" +} diff --git a/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json b/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json new file mode 100644 index 0000000000..7560df9dd2 --- /dev/null +++ b/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json @@ -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" +} diff --git a/backend/.sqlx/query-3b01c43a828ec5d61ed92a27bdb16b526db8e60887d6279b0e66197da686fbf8.json b/backend/.sqlx/query-3b01c43a828ec5d61ed92a27bdb16b526db8e60887d6279b0e66197da686fbf8.json new file mode 100644 index 0000000000..02871b22f1 --- /dev/null +++ b/backend/.sqlx/query-3b01c43a828ec5d61ed92a27bdb16b526db8e60887d6279b0e66197da686fbf8.json @@ -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" +} diff --git a/backend/.sqlx/query-464286216add454cb64dba810d964c4b6a41d96df223b6d3d1d1bc972ab10d42.json b/backend/.sqlx/query-464286216add454cb64dba810d964c4b6a41d96df223b6d3d1d1bc972ab10d42.json new file mode 100644 index 0000000000..43bb3da025 --- /dev/null +++ b/backend/.sqlx/query-464286216add454cb64dba810d964c4b6a41d96df223b6d3d1d1bc972ab10d42.json @@ -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" +} diff --git a/backend/.sqlx/query-4c74a75d99f8d6689470da1d57d3db8bf47f11969a0c026a35a6673574e074d8.json b/backend/.sqlx/query-4c74a75d99f8d6689470da1d57d3db8bf47f11969a0c026a35a6673574e074d8.json new file mode 100644 index 0000000000..fd576892db --- /dev/null +++ b/backend/.sqlx/query-4c74a75d99f8d6689470da1d57d3db8bf47f11969a0c026a35a6673574e074d8.json @@ -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" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-653685b39d93008762818d0518b953632040122a9af98332d3fd1d12244b1b80.json b/backend/.sqlx/query-653685b39d93008762818d0518b953632040122a9af98332d3fd1d12244b1b80.json index e3ef8a7f0a..82604817a0 100644 --- a/backend/.sqlx/query-653685b39d93008762818d0518b953632040122a9af98332d3fd1d12244b1b80.json +++ b/backend/.sqlx/query-653685b39d93008762818d0518b953632040122a9af98332d3fd1d12244b1b80.json @@ -17,7 +17,8 @@ "script", "flow", "raw_app", - "asset" + "asset", + "agent" ] } } diff --git a/backend/.sqlx/query-65acc5b9322f03266079ea6051535168e2cd0e627e3bbff413e38f8a3c616aa0.json b/backend/.sqlx/query-65acc5b9322f03266079ea6051535168e2cd0e627e3bbff413e38f8a3c616aa0.json new file mode 100644 index 0000000000..213fb4a5d6 --- /dev/null +++ b/backend/.sqlx/query-65acc5b9322f03266079ea6051535168e2cd0e627e3bbff413e38f8a3c616aa0.json @@ -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" +} diff --git a/backend/.sqlx/query-729852e65e4cf22c474850d46a2065a203ca86b40e8388db963c183ac8fb4fd6.json b/backend/.sqlx/query-729852e65e4cf22c474850d46a2065a203ca86b40e8388db963c183ac8fb4fd6.json new file mode 100644 index 0000000000..6d4c707f08 --- /dev/null +++ b/backend/.sqlx/query-729852e65e4cf22c474850d46a2065a203ca86b40e8388db963c183ac8fb4fd6.json @@ -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" +} diff --git a/backend/.sqlx/query-72bc3795182333d203b4731defb400a6fa59e8a88bebd6e63437e9cb4c491333.json b/backend/.sqlx/query-72bc3795182333d203b4731defb400a6fa59e8a88bebd6e63437e9cb4c491333.json new file mode 100644 index 0000000000..9450642e5f --- /dev/null +++ b/backend/.sqlx/query-72bc3795182333d203b4731defb400a6fa59e8a88bebd6e63437e9cb4c491333.json @@ -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" +} diff --git a/backend/.sqlx/query-7341d832643627cdc33a6eefbca5844a0fbb10fb79e25a604eeb0d83d2122d96.json b/backend/.sqlx/query-7341d832643627cdc33a6eefbca5844a0fbb10fb79e25a604eeb0d83d2122d96.json new file mode 100644 index 0000000000..a5181dd0a2 --- /dev/null +++ b/backend/.sqlx/query-7341d832643627cdc33a6eefbca5844a0fbb10fb79e25a604eeb0d83d2122d96.json @@ -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" +} diff --git a/backend/.sqlx/query-74190dc8f1049b61adfcefe19df069204bfc974a55da927658947f3e335da3f5.json b/backend/.sqlx/query-74190dc8f1049b61adfcefe19df069204bfc974a55da927658947f3e335da3f5.json new file mode 100644 index 0000000000..8a68c6364e --- /dev/null +++ b/backend/.sqlx/query-74190dc8f1049b61adfcefe19df069204bfc974a55da927658947f3e335da3f5.json @@ -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" +} diff --git a/backend/.sqlx/query-7e71cb88613a78b1b6bd0327970e7c732775949f92e1f099ff997d385b025f55.json b/backend/.sqlx/query-7e71cb88613a78b1b6bd0327970e7c732775949f92e1f099ff997d385b025f55.json new file mode 100644 index 0000000000..787342ca47 --- /dev/null +++ b/backend/.sqlx/query-7e71cb88613a78b1b6bd0327970e7c732775949f92e1f099ff997d385b025f55.json @@ -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" +} diff --git a/backend/.sqlx/query-80977e90833b63da4cdde111db80d78c8e577dbce51f8571a9ffe3e8e1e652a1.json b/backend/.sqlx/query-80977e90833b63da4cdde111db80d78c8e577dbce51f8571a9ffe3e8e1e652a1.json new file mode 100644 index 0000000000..24144fdbdb --- /dev/null +++ b/backend/.sqlx/query-80977e90833b63da4cdde111db80d78c8e577dbce51f8571a9ffe3e8e1e652a1.json @@ -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" +} diff --git a/backend/.sqlx/query-80d62bd0f372a322d9c2a548a66669551ed6860b0839dd019967569e2325b0bc.json b/backend/.sqlx/query-80d62bd0f372a322d9c2a548a66669551ed6860b0839dd019967569e2325b0bc.json new file mode 100644 index 0000000000..5b59198fd9 --- /dev/null +++ b/backend/.sqlx/query-80d62bd0f372a322d9c2a548a66669551ed6860b0839dd019967569e2325b0bc.json @@ -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" +} diff --git a/backend/.sqlx/query-884dd7d41713a964a0fbb854d6e54cff4538e230b3cd72dd2e840a08b2a178d9.json b/backend/.sqlx/query-884dd7d41713a964a0fbb854d6e54cff4538e230b3cd72dd2e840a08b2a178d9.json new file mode 100644 index 0000000000..1823f66c53 --- /dev/null +++ b/backend/.sqlx/query-884dd7d41713a964a0fbb854d6e54cff4538e230b3cd72dd2e840a08b2a178d9.json @@ -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" +} diff --git a/backend/.sqlx/query-9784d19363fa14b9d7cc49e208967000a7bc8e56adddcd9304b05daf814e5246.json b/backend/.sqlx/query-9784d19363fa14b9d7cc49e208967000a7bc8e56adddcd9304b05daf814e5246.json new file mode 100644 index 0000000000..9ad7df84ba --- /dev/null +++ b/backend/.sqlx/query-9784d19363fa14b9d7cc49e208967000a7bc8e56adddcd9304b05daf814e5246.json @@ -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" +} diff --git a/backend/.sqlx/query-988c720384e4e2d70bfa11196f3a3514e0756722762fabe49e1d291ce5c09a19.json b/backend/.sqlx/query-988c720384e4e2d70bfa11196f3a3514e0756722762fabe49e1d291ce5c09a19.json new file mode 100644 index 0000000000..0ffc66a8b7 --- /dev/null +++ b/backend/.sqlx/query-988c720384e4e2d70bfa11196f3a3514e0756722762fabe49e1d291ce5c09a19.json @@ -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" +} diff --git a/backend/.sqlx/query-a32aea24f8cc6e0aed80dfa1f0928fab0f26ee5a0b9550ea4ab04e2857bebeda.json b/backend/.sqlx/query-a32aea24f8cc6e0aed80dfa1f0928fab0f26ee5a0b9550ea4ab04e2857bebeda.json new file mode 100644 index 0000000000..1e8970290f --- /dev/null +++ b/backend/.sqlx/query-a32aea24f8cc6e0aed80dfa1f0928fab0f26ee5a0b9550ea4ab04e2857bebeda.json @@ -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" +} diff --git a/backend/.sqlx/query-a37bd72bb1a151e859b4a37a447887e613debd842e575cc740502144bb279bb3.json b/backend/.sqlx/query-a37bd72bb1a151e859b4a37a447887e613debd842e575cc740502144bb279bb3.json new file mode 100644 index 0000000000..5eb7d45dd4 --- /dev/null +++ b/backend/.sqlx/query-a37bd72bb1a151e859b4a37a447887e613debd842e575cc740502144bb279bb3.json @@ -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" +} diff --git a/backend/.sqlx/query-acd87efeb1e92f6c5f04f8482fde597611be96a8f2c6a8ea884c39b11620be57.json b/backend/.sqlx/query-acd87efeb1e92f6c5f04f8482fde597611be96a8f2c6a8ea884c39b11620be57.json new file mode 100644 index 0000000000..f9ef3977e7 --- /dev/null +++ b/backend/.sqlx/query-acd87efeb1e92f6c5f04f8482fde597611be96a8f2c6a8ea884c39b11620be57.json @@ -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" +} diff --git a/backend/.sqlx/query-ad218040fc53b063508774c6f04d5f82cc2121ea8519311add88f23f0ea0dae9.json b/backend/.sqlx/query-ad218040fc53b063508774c6f04d5f82cc2121ea8519311add88f23f0ea0dae9.json new file mode 100644 index 0000000000..87a4b3e764 --- /dev/null +++ b/backend/.sqlx/query-ad218040fc53b063508774c6f04d5f82cc2121ea8519311add88f23f0ea0dae9.json @@ -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" +} diff --git a/backend/.sqlx/query-b0e56637096b48867401967ad5ddc509bbd1e322788789a913d5148c1113643c.json b/backend/.sqlx/query-b0e56637096b48867401967ad5ddc509bbd1e322788789a913d5148c1113643c.json new file mode 100644 index 0000000000..2478a6c7eb --- /dev/null +++ b/backend/.sqlx/query-b0e56637096b48867401967ad5ddc509bbd1e322788789a913d5148c1113643c.json @@ -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" +} diff --git a/backend/.sqlx/query-b19f5156b3e36b5e831ce792637bf54a3f182c03cef0aef06a1c75b45992cd39.json b/backend/.sqlx/query-b19f5156b3e36b5e831ce792637bf54a3f182c03cef0aef06a1c75b45992cd39.json index 10adc6e68d..4db5bc6e3c 100644 --- a/backend/.sqlx/query-b19f5156b3e36b5e831ce792637bf54a3f182c03cef0aef06a1c75b45992cd39.json +++ b/backend/.sqlx/query-b19f5156b3e36b5e831ce792637bf54a3f182c03cef0aef06a1c75b45992cd39.json @@ -11,7 +11,7 @@ ], "parameters": { "Left": [ - "Varchar", + "Text", "Int8" ] }, diff --git a/backend/.sqlx/query-b57b038384e48f2e6f288056e7261459604a093a74e22770ccdc14207cacbfb0.json b/backend/.sqlx/query-b57b038384e48f2e6f288056e7261459604a093a74e22770ccdc14207cacbfb0.json new file mode 100644 index 0000000000..b344aa8d0e --- /dev/null +++ b/backend/.sqlx/query-b57b038384e48f2e6f288056e7261459604a093a74e22770ccdc14207cacbfb0.json @@ -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" +} diff --git a/backend/.sqlx/query-b8b89624212f32fb6e994b7488fa7152c190bab532406fd26d17dfbd2355c764.json b/backend/.sqlx/query-b8b89624212f32fb6e994b7488fa7152c190bab532406fd26d17dfbd2355c764.json new file mode 100644 index 0000000000..cdd594c8df --- /dev/null +++ b/backend/.sqlx/query-b8b89624212f32fb6e994b7488fa7152c190bab532406fd26d17dfbd2355c764.json @@ -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" +} diff --git a/backend/.sqlx/query-c1be495552d1bd4c38e378016324b3bc473599c4cb1b2181144988eb88e9fa17.json b/backend/.sqlx/query-c1be495552d1bd4c38e378016324b3bc473599c4cb1b2181144988eb88e9fa17.json new file mode 100644 index 0000000000..72f509fc57 --- /dev/null +++ b/backend/.sqlx/query-c1be495552d1bd4c38e378016324b3bc473599c4cb1b2181144988eb88e9fa17.json @@ -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" +} diff --git a/backend/.sqlx/query-c7a1a350f95523df4e2012d2d21ec87a0016b1949cfe2d901aacc281d9442738.json b/backend/.sqlx/query-c7a1a350f95523df4e2012d2d21ec87a0016b1949cfe2d901aacc281d9442738.json new file mode 100644 index 0000000000..01baaa7c57 --- /dev/null +++ b/backend/.sqlx/query-c7a1a350f95523df4e2012d2d21ec87a0016b1949cfe2d901aacc281d9442738.json @@ -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" +} diff --git a/backend/.sqlx/query-ca4c2768d111fbd653f1a73afc669b58a0016bfb4f0dad133ef2c25b8cd8b4b6.json b/backend/.sqlx/query-ca4c2768d111fbd653f1a73afc669b58a0016bfb4f0dad133ef2c25b8cd8b4b6.json new file mode 100644 index 0000000000..c84a500f29 --- /dev/null +++ b/backend/.sqlx/query-ca4c2768d111fbd653f1a73afc669b58a0016bfb4f0dad133ef2c25b8cd8b4b6.json @@ -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" +} diff --git a/backend/.sqlx/query-cbc0edfcdda98420e728e35508e46d9b9ecb504cbea4976c8f45fc089cb81977.json b/backend/.sqlx/query-cbc0edfcdda98420e728e35508e46d9b9ecb504cbea4976c8f45fc089cb81977.json new file mode 100644 index 0000000000..d109e62811 --- /dev/null +++ b/backend/.sqlx/query-cbc0edfcdda98420e728e35508e46d9b9ecb504cbea4976c8f45fc089cb81977.json @@ -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" +} diff --git a/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json b/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json index 29f31c62eb..59ee111e89 100644 --- a/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json +++ b/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json @@ -39,7 +39,8 @@ "java", "duckdb", "ruby", - "rlang" + "rlang", + "dbt" ] } } diff --git a/backend/.sqlx/query-ce033ee4db97c0f9ae643d287f1a3f1578a6be8686e02c3faa55100eb8fa3700.json b/backend/.sqlx/query-ce033ee4db97c0f9ae643d287f1a3f1578a6be8686e02c3faa55100eb8fa3700.json new file mode 100644 index 0000000000..3e44f3ab42 --- /dev/null +++ b/backend/.sqlx/query-ce033ee4db97c0f9ae643d287f1a3f1578a6be8686e02c3faa55100eb8fa3700.json @@ -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" +} diff --git a/backend/.sqlx/query-d46f75bb9d7144d571a31ddbb3c986ea2bfd561943ff512ac20f93203e02c3d8.json b/backend/.sqlx/query-d46f75bb9d7144d571a31ddbb3c986ea2bfd561943ff512ac20f93203e02c3d8.json new file mode 100644 index 0000000000..f70dff1f07 --- /dev/null +++ b/backend/.sqlx/query-d46f75bb9d7144d571a31ddbb3c986ea2bfd561943ff512ac20f93203e02c3d8.json @@ -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" +} diff --git a/backend/.sqlx/query-dcb5053bdae0a2d141f7e7cb16988f5bc2c40c098558d92ab768281f4e9664c5.json b/backend/.sqlx/query-dcb5053bdae0a2d141f7e7cb16988f5bc2c40c098558d92ab768281f4e9664c5.json new file mode 100644 index 0000000000..12e32dfe45 --- /dev/null +++ b/backend/.sqlx/query-dcb5053bdae0a2d141f7e7cb16988f5bc2c40c098558d92ab768281f4e9664c5.json @@ -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" +} diff --git a/backend/.sqlx/query-ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b.json b/backend/.sqlx/query-ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b.json index 9622e63a56..02c50b1e45 100644 --- a/backend/.sqlx/query-ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b.json +++ b/backend/.sqlx/query-ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b.json @@ -23,4 +23,4 @@ ] }, "hash": "ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b" -} \ No newline at end of file +} diff --git a/backend/.sqlx/query-e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9.json b/backend/.sqlx/query-e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9.json new file mode 100644 index 0000000000..032d104253 --- /dev/null +++ b/backend/.sqlx/query-e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM role_seat WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9" +} diff --git a/backend/.sqlx/query-f4af0affaed3b1d30f5c6f4ddf4114718468fbf75ef13c6087c2f57fbbe0b82f.json b/backend/.sqlx/query-f4af0affaed3b1d30f5c6f4ddf4114718468fbf75ef13c6087c2f57fbbe0b82f.json index e52ddcf21f..dfcaa29208 100644 --- a/backend/.sqlx/query-f4af0affaed3b1d30f5c6f4ddf4114718468fbf75ef13c6087c2f57fbbe0b82f.json +++ b/backend/.sqlx/query-f4af0affaed3b1d30f5c6f4ddf4114718468fbf75ef13c6087c2f57fbbe0b82f.json @@ -34,7 +34,8 @@ "java", "duckdb", "ruby", - "rlang" + "rlang", + "dbt" ] } } diff --git a/backend/.sqlx/query-f54795cfa04ef4d0f6ed05aee09513d23e8ef5412d030a172f81dd9a9be0bf2f.json b/backend/.sqlx/query-f54795cfa04ef4d0f6ed05aee09513d23e8ef5412d030a172f81dd9a9be0bf2f.json new file mode 100644 index 0000000000..bb2808083c --- /dev/null +++ b/backend/.sqlx/query-f54795cfa04ef4d0f6ed05aee09513d23e8ef5412d030a172f81dd9a9be0bf2f.json @@ -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" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 9cccde0b70..e5146df23b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -32784f3907f0a25fee4a9f96fa172420f17b3fea +b5294d38db082bdd104d5358a47695390694c05d diff --git a/backend/migrations/20261016072241_workspace_roles.down.sql b/backend/migrations/20261016072241_workspace_roles.down.sql new file mode 100644 index 0000000000..6cc9a3cbef --- /dev/null +++ b/backend/migrations/20261016072241_workspace_roles.down.sql @@ -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; diff --git a/backend/migrations/20261016072241_workspace_roles.up.sql b/backend/migrations/20261016072241_workspace_roles.up.sql new file mode 100644 index 0000000000..8cd96628a0 --- /dev/null +++ b/backend/migrations/20261016072241_workspace_roles.up.sql @@ -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: `{"": {"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; diff --git a/backend/src/main.rs b/backend/src/main.rs index ef9961ba57..1035a0549f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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; } diff --git a/backend/tests/app_preview_auth.rs b/backend/tests/app_preview_auth.rs index f8e90ce144..9f6e77f61c 100644 --- a/backend/tests/app_preview_auth.rs +++ b/backend/tests/app_preview_auth.rs @@ -75,12 +75,12 @@ async fn test_app_preview_authorization(db: Pool) -> 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 diff --git a/backend/tests/inline_preview_auth.rs b/backend/tests/inline_preview_auth.rs index acf511c26c..231e71e92a 100644 --- a/backend/tests/inline_preview_auth.rs +++ b/backend/tests/inline_preview_auth.rs @@ -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"; diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 6bc9a930a4..b37a3616f1 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -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)) => { diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 30c3bb65af..bfbd4470c8 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -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) } diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index fe4d8c37d4..3ef3de7a33 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -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, Json(mut nf): Json, ) -> 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, ) -> Result { - 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, ) -> Result { - 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, ) -> Result { - 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( diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index f7c3398adc..c867e89a60 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -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 { diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 764cc3ac04..e1623ef901 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -1737,7 +1737,7 @@ async fn an_operator_neither_writes_nor_runs_a_migration(db: Pool) -> 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}" ); } diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index f45ffb3f8e..0d1e3c9185 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -143,6 +143,12 @@ async fn test_update_draft_write_authorization(db: Pool) -> 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) -> 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(()) } diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs index 2c5d2ee78e..2592ecdf7a 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_authed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -626,10 +626,9 @@ async fn test_resolve_completed_jobs_scoping(db: Pool) -> 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? ); diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index e7f0147323..26c59cf205 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -342,24 +342,57 @@ async fn test_token_update_persists(db: Pool) -> 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, +) -> 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] diff --git a/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs b/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs index 7033d093f2..71a7a2b8fa 100644 --- a/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs +++ b/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs @@ -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, 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(()) } diff --git a/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs index 7be3920124..b9c4e60800 100644 --- a/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs +++ b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs @@ -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, 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) -> anyhow::Res .send() .await?; assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let notified: Vec = + 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")) diff --git a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs index 22901d1c87..cfbeae7a69 100644 --- a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs +++ b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs @@ -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 .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 #[serial] async fn test_operator_manage_rights(db: Pool) -> 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, ) -> 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) -> 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) -> 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) -> anyhow: #[serial] async fn test_operators_draft_resources_and_variables(db: Pool) -> 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}"); diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index a159bf6090..de5ed4baa0 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -1262,5 +1262,35 @@ async fn test_admin_and_operator_are_exclusive(db: Pool) -> 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(()) } diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index fb935e0b5c..787742d18d 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -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, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 4bbe372254..6e5b43b68b 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -185,6 +185,10 @@ async fn list_scripts( Query(pagination): Query, Query(lq): Query, ) -> JsonResult> { + // 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, Vec, )> { - 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, 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, Path((w_id, hash)): Path<(String, ScriptHash)>, ) -> JsonResult> { - 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), diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 96c1be32c9..2dbb7d427c 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -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() { diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 9c853d8ef8..c0e48083dc 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -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, } @@ -301,7 +302,10 @@ pub struct EditUser { #[derive(Deserialize)] pub struct EditWorkspaceUser { pub is_admin: Option, + /// Legacy role selector: `true` is the `operator` role, `false` `developer`. `role` wins. pub operator: Option, + /// The member's role, one of the workspace's roles. + pub role: Option, pub disabled: Option, } @@ -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 { 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, pub is_service_account: bool, + pub workspace_role: Option, } async fn get_user(w_id: &str, username: &str, db: &DB) -> Result> { @@ -972,7 +986,10 @@ async fn get_user(w_id: &str, username: &str, db: &DB) -> Result, Json(eu): Json, ) -> Result { - 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 = - 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 = eligible_groups[0] + .instance_groups_roles + .clone() + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + let group_names: Vec = eligible_groups + .iter() + .map(|g| g.group_name.clone()) + .collect(); + let role_order: Vec = 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, operator: Option, + role: Option, disabled: Option, 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, diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index 839b449f37..35ea6210dd 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -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, ) -> JsonResult { - 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, ) -> JsonResult { - 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, ) -> JsonResult { - 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, Path((w_id, datatable_name, timestamp)): Path<(String, String, i64)>, ) -> Result { - 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, ) -> Result { - 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, Path((w_id, datatable_name)): Path<(String, String)>, ) -> JsonResult { - 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( diff --git a/backend/windmill-api-workspaces/src/lib.rs b/backend/windmill-api-workspaces/src/lib.rs index 8d37fdf194..cc5a7b9ab0 100644 --- a/backend/windmill-api-workspaces/src/lib.rs +++ b/backend/windmill-api-workspaces/src/lib.rs @@ -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; diff --git a/backend/windmill-api-workspaces/src/workspace_roles.rs b/backend/windmill-api-workspaces/src/workspace_roles.rs new file mode 100644 index 0000000000..58bf4e1904 --- /dev/null +++ b/backend/windmill-api-workspaces/src/workspace_roles.rs @@ -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, + 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, + my_capabilities: RoleCapabilities, +} + +async fn list_roles( + authed: ApiAuthed, + Path(w_id): Path, + Extension(db): Extension, +) -> JsonResult { + 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, + Extension(db): Extension, + Json(overrides): Json, +) -> Result { + 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, +) -> 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 = 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + builder_apps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + manage_schedules: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + manage_triggers: Option, +} + +/// 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, + Extension(db): Extension, + Json(settings): Json, +) -> Result { + 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| 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()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 0c70946c58..984341c068 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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, + /// The role auto-invited members get; wins over `operator`. + pub role: Option, pub invite_all: Option, pub auto_add: Option, } @@ -579,6 +585,9 @@ struct UserWorkspace { pub created_by: Option, 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, } #[derive(Deserialize)] @@ -604,7 +617,34 @@ pub struct NewWorkspaceUser { pub email: String, pub username: Option, pub is_admin: bool, + #[serde(default)] pub operator: bool, + #[serde(default)] + pub role: Option, +} + +/// 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, ApiAuthed { email, .. }: ApiAuthed, ) -> JsonResult { - 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, ) -> 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, Json(mut nu): Json, ) -> 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - builder_apps: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - manage_schedules: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - manage_triggers: Option, -} - -async fn update_operator_settings( - authed: ApiAuthed, - Path(w_id): Path, - Extension(db): Extension, - Json(settings): Json, -) -> Result { - 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) = diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index e02d7c8c3e..f3618c81eb 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -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?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e2bed33483..5009402bbf 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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: diff --git a/backend/windmill-api/src/ai_evals/datasets.rs b/backend/windmill-api/src/ai_evals/datasets.rs index 8e1acb3d2a..107a316676 100644 --- a/backend/windmill-api/src/ai_evals/datasets.rs +++ b/backend/windmill-api/src/ai_evals/datasets.rs @@ -123,17 +123,15 @@ pub async fn list_datasets( pub async fn create_dataset( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path(w_id): Path, Json(payload): Json, ) -> Result { 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, Extension(user_db): Extension, Path((w_id, path)): Path<(String, String)>, Json(payload): Json, ) -> Result { - 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, Extension(user_db): Extension, Path((w_id, path)): Path<(String, String)>, ) -> Result { 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", diff --git a/backend/windmill-api/src/ai_evals/mod.rs b/backend/windmill-api/src/ai_evals/mod.rs index b4d6009857..867f638aa7 100644 --- a/backend/windmill-api/src/ai_evals/mod.rs +++ b/backend/windmill-api/src/ai_evals/mod.rs @@ -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", diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index 57e75b3f9f..410e92f515 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -561,7 +561,7 @@ pub async fn run_experiment( ) -> Result { 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)?; diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index d28005c2a0..9f58638046 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -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, Json(req): Json, ) -> Result { - 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, 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, Json(app): Json, ) -> 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, Path((w_id, path)): Path<(String, StripPath)>, ) -> Result { - 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, ) -> 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 { - 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::>(&force_viewer_allowed_s3_keys).unwrap_or_default()) } else { diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index b582e84e36..018fc43d54 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -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() ))); } diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index a7e2915571..c5dd374c7a 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -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 { + 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) -> 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, 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 { 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 { 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, Json(raw_flow): Json, ) -> 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, Extension(user_db): Extension, Path(w_id): Path, Json(req): Json, ) -> error::JsonResult> { - 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, Extension(user_db): Extension, Path(w_id): Path, Json(req): Json, ) -> error::JsonResult> { - 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!( diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 754221dac0..0037a37042 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -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, Query(q): Query, ) -> JsonResult { + 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 = 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 { @@ -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| -> Option { - 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, Query(q): Query, ) -> JsonResult { + 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 = vec![]; let branches: Vec = kinds diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 24d9c99661..78d3b98c48 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -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}" diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 82f99c54fb..24607574f1 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -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 { + 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> { Ok(None) } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 07a05f5ab4..ddbfb03dbf 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -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 diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 2b476b338a..bcee690373 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -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(()) } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index fb37721114..1fdfe72de7 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -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; diff --git a/backend/windmill-common/src/roles.rs b/backend/windmill-common/src/roles.rs new file mode 100644 index 0000000000..b3fd9937f4 --- /dev/null +++ b/backend/windmill-common/src/roles.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flows: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub apps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manage_schedules: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manage_triggers: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visibility: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +pub struct RoleVisibilityPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schedules: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variables: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assets: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub triggers: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audit_logs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub groups: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folders: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workers: Option, +} + +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(b: T, t: T) -> Option { + (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, + #[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, + #[serde(default)] + pub capabilities: RoleCapabilitiesPatch, +} + +pub type RoleOverrides = HashMap; + +#[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 { + 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> { + let roles: Vec = 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> { + 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) -> 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, 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>, i64)> = Cache::new(1); + static ref WORKSPACE_ROLES: Cache>, i64)> = Cache::new(1000); + static ref MEMBER_ROLE: Cache<(String, String), (Option, 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>> { + 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>> { + 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>> { + 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 { + let v: Option> = 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>> { + 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>> { + 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>> { + 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> { + 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 { + 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, + operator: bool, +) -> Result { + 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, + operator: bool, +) -> Result { + 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> { + 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( + db: &DB, + workspace_id: &str, + authed: &A, +) -> Result { + 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)> = 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 = 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 = 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()); + } +} diff --git a/backend/windmill-common/src/users.rs b/backend/windmill-common/src/users.rs index 1b696746b0..277a2632f8 100644 --- a/backend/windmill-common/src/users.rs +++ b/backend/windmill-common/src/users.rs @@ -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, 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 { + 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()) + ); } } diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 1b9412eb5a..3b5d78c5b7 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -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 { - 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 = 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 { - 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 { - 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( db: &DB, workspace_id: &str, + authed: &A, +) -> Result { + 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( + db: &DB, + workspace_id: &str, + authed: &A, kind: crate::user_drafts::UserDraftItemKind, ) -> Result { 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( 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 { + 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( + 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 { - 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( 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() ))); } diff --git a/backend/windmill-common/tests/billing_workspace.rs b/backend/windmill-common/tests/billing_workspace.rs index ccc013d47a..3e962f5840 100644 --- a/backend/windmill-common/tests/billing_workspace.rs +++ b/backend/windmill-common/tests/billing_workspace.rs @@ -125,13 +125,12 @@ async fn paid_seats_and_fork_count(db: Pool) { ); 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) { 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; diff --git a/backend/windmill-common/tests/instance_group_auto_add.rs b/backend/windmill-common/tests/instance_group_auto_add.rs index 95b20cc842..963b6e7a79 100644 --- a/backend/windmill-common/tests/instance_group_auto_add.rs +++ b/backend/windmill-common/tests/instance_group_auto_add.rs @@ -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 = 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!( diff --git a/backend/windmill-common/tests/workspace_roles_declarative.rs b/backend/windmill-common/tests/workspace_roles_declarative.rs new file mode 100644 index 0000000000..b3ab777310 --- /dev/null +++ b/backend/windmill-common/tests/workspace_roles_declarative.rs @@ -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) { + 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"); +} diff --git a/backend/windmill-common/tests/workspace_roles_license.rs b/backend/windmill-common/tests/workspace_roles_license.rs new file mode 100644 index 0000000000..88aa3593cb --- /dev/null +++ b/backend/windmill-common/tests/workspace_roles_license.rs @@ -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) { + 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"); +} diff --git a/backend/windmill-native-triggers/src/github/routes.rs b/backend/windmill-native-triggers/src/github/routes.rs index 68de93b6a9..0b56a132a2 100644 --- a/backend/windmill-native-triggers/src/github/routes.rs +++ b/backend/windmill-native-triggers/src/github/routes.rs @@ -21,7 +21,7 @@ async fn list_repos( Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { - 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(); diff --git a/backend/windmill-native-triggers/src/google/routes.rs b/backend/windmill-native-triggers/src/google/routes.rs index 306a7e54e2..40098142bf 100644 --- a/backend/windmill-native-triggers/src/google/routes.rs +++ b/backend/windmill-native-triggers/src/google/routes.rs @@ -93,7 +93,7 @@ async fn list_calendars( Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { - 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, Query(query): Query, ) -> JsonResult { - 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, Path(workspace_id): Path, ) -> JsonResult> { - 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!( diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 6398e8a6d9..ece41a17bf 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -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>>( diff --git a/backend/windmill-native-triggers/src/nextcloud/routes.rs b/backend/windmill-native-triggers/src/nextcloud/routes.rs index a1c679602c..aefed1c11f 100644 --- a/backend/windmill-native-triggers/src/nextcloud/routes.rs +++ b/backend/windmill-native-triggers/src/nextcloud/routes.rs @@ -21,7 +21,7 @@ async fn list_available_events( Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { - 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 diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 010eb0a90b..1ee96881c2 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -488,8 +488,7 @@ async fn list_resources( && lq.broad_filter.is_none() && lq.label.is_none() && !lq.starred_only.unwrap_or(false) - && (!authed.is_operator - || operator_can_draft(&db, &w_id, UserDraftItemKind::Resource).await?) + && operator_can_draft(&db, &w_id, &authed, UserDraftItemKind::Resource).await? { let rt_filter: Option> = lq .resource_type diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 42f8b4cc01..0b0660f967 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -236,8 +236,7 @@ async fn list_variables( && lq.value.is_none() && lq.broad_filter.is_none() && lq.label.is_none() - && (!authed.is_operator - || operator_can_draft(&db, &w_id, UserDraftItemKind::Variable).await?) + && operator_can_draft(&db, &w_id, &authed, UserDraftItemKind::Variable).await? { let draft_only_rows = fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Variable) diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 7395a5e8a3..fec3180801 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -700,13 +700,13 @@ async fn list_triggers( && query.is_flow.is_none() && query.path_start.is_none() && query.label.is_none() - && (!authed.is_operator - || windmill_common::workspaces::operator_can_draft( - &db, - &workspace_id, - T::user_draft_item_kind(), - ) - .await?) + && windmill_common::workspaces::operator_can_draft( + &db, + &workspace_id, + &authed, + T::user_draft_item_kind(), + ) + .await? { let draft_only_rows = fetch_draft_only_list_rows( &db, diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md index 8ace42a1e5..44a012131f 100644 --- a/docs/operator-builder-rights.md +++ b/docs/operator-builder-rights.md @@ -14,12 +14,12 @@ without the second, so **never gate on "either"**: a gate that does authorizes t workspace never granted, which is the whole point of the split. `BuilderKind` exists to make each call site say which one it means. -These are write rights, unlike the visibility flags beside them, and unlike the withdrawable -rights in `docs/operator-write-rights.md` they are granted on request and cost a seat. Read them -with `windmill_common::workspaces::operator_builder_rights` (60s cache, shared with the -withdrawable rights), which returns an `OperatorBuilderRights { flows, apps }`, and gate a write -with `check_operator_can_build`, naming the `BuilderKind`. `.any()` is only for surfaces that are -genuinely not per-kind: the seat a membership costs, and listing drafts. +These rights are now the `compose` level of a role's `flows` and `apps` capabilities +(`docs/workspace-roles.md`): `builder_flows` / `builder_apps` in the legacy operator settings set +the `operator` role to it. Gate a write with `check_operator_can_build`, naming the `BuilderKind`: +it refuses `none` and returns the level held, and the caller MUST run the composition-only checks +below at `compose` and may skip them only at `full`. `OperatorBuilderRights::any()` is only for +surfaces that are genuinely not per-kind, such as listing drafts. ## What the check has to cover @@ -146,16 +146,13 @@ artifacts, and there is no half-author, so granting both costs no more than gran introduce per-right pricing — it turns one boolean into a capability matrix that billing, the cap check and the displayed count would each have to agree on. -`consumes_operator_seat` is the seat-role helper; the EE counting queries share -`OPERATOR_SEAT_SQL` so the displayed, enforced and reported numbers agree. The one exception is -`get_user_usage` in `stats_ee.rs`: it is a compile-checked `query!`, which cannot interpolate the -constant, so it spells the predicate out. Change both together. On cloud, `billable_seats` applies -the same rule; see its doc for the out-of-repo invoice it must match. +Every count reads the role's author flag from `role_seat` through `AUTHOR_SEAT_SQL`; see +`docs/workspace-roles.md`. On cloud, `billable_seats` applies the same rule; see its doc for the +out-of-repo invoice it must match. -Granting the first right runs `check_seat_cap_for_operator_builder`, which prices the change by -counting seats twice rather than by counting the workspace's operators: an operator who already -authors elsewhere must not be charged again. Adding the second right later, or re-saving settings -that already have one on, is a zero delta and never blocks. +A role change runs `check_seat_cap_for_role_change` inside the transaction that writes it, which +prices the change by counting seats twice rather than by counting the role's members: a member who +already authors elsewhere must not be charged again. A change that adds no seat never blocks. ## Accepted risks @@ -186,7 +183,6 @@ that already have one on, is a zero delta and never blocks. carries none makes the deployer its run identity, as any deploy does. An admin deploying such a draft, from the editor or Compare & Deploy, therefore publishes the builder's code running as that admin; the deploy is the admin's explicit act, as it is for a developer's draft. -- All-or-nothing per workspace: there is no per-user builder role. - `operator_settings` is git-synced, so a pull can flip every operator's class in a workspace and the billed seat count with it. - A builder writes JavaScript expressions: step inputs, branch predicates, `stop_after_if` and diff --git a/docs/operator-write-rights.md b/docs/operator-write-rights.md index a1421400f0..5fda1547f0 100644 --- a/docs/operator-write-rights.md +++ b/docs/operator-write-rights.md @@ -95,20 +95,22 @@ toolbar, which takes its permissions from `can_write`, needs no lock of its own. `ShareModal`, which locks itself off the kind it was opened on rather than relying on each of the dozen menu entries that open it. -The cache is per process, so withdrawing a right has to reach every replica: an `AFTER UPDATE OF -operator_settings` trigger writes a `notify_operator_settings_change` row and `process_notify_event` -drops the entry. Keep both ends if you touch either, or a workspace that withdrew a right keeps -authorizing writes on every other replica until its own entry expires. +These rights are now capabilities of a member's role (`docs/workspace-roles.md`), and the legacy +operator settings write them as an override of the `operator` role. The roles cache is per process, +so withdrawing a right has to reach every replica: `write_role_overrides` calls +`roles::notify_role_overrides_change` in its transaction and `process_notify_event` drops the +entry. A new writer of `role_overrides` has to call it too, or a workspace that withdrew a right +keeps authorizing writes on every other replica until its own entry expires. ## Granted unless withdrawn These name capabilities operators already hold, so absence has to mean "never configured", not a value. That is easy to get wrong in two places, and the obvious implementation gets both wrong: -- The read coalesces to **true** (`operator_rights`), including for a workspace with no - `workspace_settings` row, which is what `OperatorManageRights::default` is for. Coalescing to - false instead revokes the right on upgrade for every workspace that ever saved operator settings, - since those rows carry explicit keys and none of them is this one. +- An unset key falls through to **true** (`RoleCapabilities::OPERATOR`), including for a + workspace with no override. A default of false instead revokes the right on upgrade for every + workspace that ever saved operator settings, since those rows carry explicit keys and none of + them is this one. - The update endpoint **merges** into the stored jsonb and takes these two as `Option`, so an omitted key keeps its stored value. It has to: `operator_settings` is git-synced as a whole object (`cli/src/core/settings.ts` posts the file's contents verbatim), so a settings file diff --git a/docs/workspace-roles.md b/docs/workspace-roles.md new file mode 100644 index 0000000000..953b4c023c --- /dev/null +++ b/docs/workspace-roles.md @@ -0,0 +1,66 @@ +# Workspace roles + +What a non-admin member may author, manage and see is its **role**. `developer` and `operator` are +the default roles; a licensed EE instance defines its own list in the `workspace_roles` global +setting (at least one role), and any workspace may override a role's capabilities in +`workspace_settings.role_overrides`, or, when licensed, add roles of its own there. A lapsed +license freezes them rather than dropping them (`custom_roles_enabled`): the stored list and the +workspace-only roles keep applying, writes that change them are refused, and resetting the list to +the defaults or removing a workspace-only role stays open. Falling back to the defaults instead +would hand a restricted `developer` everything. Workspace admin is not a +role: it is the `is_admin` flag on top of whichever role a member holds, and holds everything. The +code is `windmill_common::roles`. + +## Capabilities + +| Capability | Values | Enforced | +|---|---|---| +| `code` | bool | yes: scripts, previews, dependency jobs, dynamic selects, resolving jobs, data table migrations, eval datasets | +| `flows` | `none` / `compose` / `full` | yes | +| `apps` | `none` / `compose` / `full` | yes; low-code apps and worker-built apps need `full` | +| `manage_schedules`, `manage_triggers` | bool | yes, see `docs/operator-write-rights.md` | +| `visibility.*` | bool per page | no, UI only | + +`compose` is the former operator builder right: the composition-only checks of +`docs/operator-builder-rights.md` run for it and are skipped only at `full`. `full` flows or apps +without `code` is refused at write, since both carry inline code. + +Stored values are **patches**: an unset key falls through to the layer below, so a key added later +reads as "never configured". The layers, top first: the workspace override, the instance +definition, then `RoleCapabilities::base_for` (`FULL` for `developer`, the legacy operator for any +other name). A role name no longer defined resolves to `NONE`, so deleting a role fails closed; +deleting one that a member, invite, auto-invite or instance group mapping still names is refused, +and so is deleting an instance role that a workspace still overrides (the override would fall onto +`base_for` instead of the definition it was written against). + +## Who is restricted + +`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 (`caller_capabilities` +returns `FULL` for an unrestricted or admin caller without reading the role), so it must stay +derived from the role: a stale flag lets a restricted member through. The authentication cache +holds it for up to 120s. + +`usr.operator` is kept as "this role cannot write code", which is what clients that predate roles +read it as. A member's role is `usr.workspace_role`, or, while that is NULL, `operator` / +`developer` from `usr.operator`: rows written by paths that know nothing of roles stay meaningful. +The old `usr.role` column is unrelated and holds legacy display strings. + +## Seats + +A role that writes or composes anything costs a full seat; anything else half. Billing SQL cannot +merge capability patches, so `role_seat` materializes the author flag per role +(`workspace_id = ''` for the instance, a row per workspace override), rewritten by +`sync_role_seats` after every write to either setting. Every count reads it through +`AUTHOR_SEAT_SQL`, and usage telemetry reports builders (authors anywhere) against non-builders the +same way. A change that adds seats under an offline license is priced inside the transaction that +writes it, by `check_seat_cap_for_role_change`. + +## Legacy `operator_settings` + +`/operator_settings` still works: it is applied as an override of the `operator` role, dropping any +key equal to what the instance gives that role, and the payload is mirrored into the +`operator_settings` column for git sync and older servers. The migration moved every key a +workspace had changed from its default into `role_overrides.operator` the same way. The workspace +list still returns an `operator_settings` object for a restricted member, synthesized from its +role, alongside `role` and `capabilities`.