Files
coder/coderd/database/migrations/000595_remove_task_permissions.up.sql
T
Michael Suchacz f46ff158c5 feat!: remove the Coder Tasks RBAC resource, scopes and permissions (#28750)
Removes the `task` RBAC resource, its five `task:*` API key scopes, and
every persisted reference to them. Custom role permission arrays drop
their `task` entries; API keys and OAuth2 codes and tokens lose task
scopes, and task-only ones are revoked; OAuth2 apps whose scope list
named only task scopes get a whitespace-only scope so they stay
restricted instead of reading as unconfigured; then `api_key_scope` is
recreated without the task values. Generated RBAC, SDK, site and API doc
artifacts are regenerated. The `resource_type` enum keeps `task` so
historical audit log rows remain queryable.

**Breaking change:** migration 000595 revokes task-only keys and grants.
The down migration restores the enum values only.

**Review changes:** the api_keys scope cleanup deletes any key left
without a non-task scope (redundant EXISTS dropped), the allow-list
comment states the real reason (`allow_list` cannot be empty), and the
migration test keeps each fixture next to its expected post-migration
value. Renumbered from 000594 to 000595.

**Stack context:** Coder Tasks removal stack (CODAGT-642): #28743
frontend → #28788 CLI → #28789 API → #28790 provisioner → #29084
notifications → #29085 build reasons → #29086 tables → **#28750
permissions**. The last four PRs replace the single data-drop migration
that used to live in #28750; each carries one concern and its own
migration number.

> Created by Xum (AI agent) on behalf of @ibetitsmike.


## Delivery record

**Status (2026-09-11 16:30Z): not merged; awaiting re-review.** Head
`80ee30141ee81287a03de3f46407f6a421387778` on `main`.

**Review history relevant to this diff:** 10 completed Codex code
reviews on the monolithic diff (2026-08-28 to 2026-09-01) plus
spikecurtis CHANGES_REQUESTED (2026-09-04, asked for the split),
spikecurtis COMMENTED 2026-09-08 (three findings on the migration and
test, all applied and resolved) and spikecurtis COMMENTED 2026-09-11
([r3990045458](https://github.com/coder/coder/pull/28750#discussion_r3990045458),
[r3990067224](https://github.com/coder/coder/pull/28750#discussion_r3990067224),
both applied in `80ee30141ee` and resolved). The review budget is
exhausted, so no further discretionary review or final advisory was
commissioned; a fresh independent final advisory for the final diff does
not exist and remains a documented gap. The reviewer's own re-review is
the remaining gate.

**Findings and dispositions on the current head:**
- OAuth-linked API key fixtures were confusing (deletion came from the
token trigger, not the migration): removed from `keys`; the grants loop
creates its backing api_keys row inline and asserts only code/token
rows.
- Missing cases: added `multi-task-scopes-key`, `multi-task-allows-key`,
and `task-only-key` (only task scopes and only task allows, deleted).
- Earlier round: redundant EXISTS on the api_keys scope DELETE dropped;
allow-list comment corrected (`allow_list` cannot be empty); test
restructured as before/after tables; renumbered to 000595.

**Test evidence for `80ee30141ee`:**
`TestMigration000595RemoveTaskPermissions` passes (up, down, re-up);
red-green: removing the token DELETE and UPDATE from the migration fails
the test at the grants/tokens assertion, file restored byte-identically.
Full repository pre-push hook passed before the push (`test` 97s,
`test-js` 45s, site build; `pre-push passed (143s)`). Note: this
workspace runs a service on loopback:3000, jsdom's default origin, which
made `test-js` fail on unmocked inbox requests for any commit; the hook
run used an `LD_PRELOAD` shim that only makes `connect()` to
loopback:3000 return `ECONNREFUSED`, matching CI, with no change to
sources, tests, config, or the host. Exact-head CI: run
[34622204759](https://github.com/coder/coder/actions/runs/34622204759)
in progress at the time of writing.

**Deferred:** `AI Documentation Check` failures on this stack come from
the dormant `doc-check-pr-*` workspace (CODAGT-1025), not code.
2026-09-14 20:15:13 +02:00

338 lines
11 KiB
SQL

-- Remove task permissions from custom roles.
UPDATE custom_roles
SET site_permissions = COALESCE((
SELECT jsonb_agg(elem)
FROM jsonb_array_elements(site_permissions) elem
WHERE elem->>'resource_type' != 'task'
), '[]'::jsonb)
WHERE site_permissions @> '[{"resource_type": "task"}]';
UPDATE custom_roles
SET org_permissions = COALESCE((
SELECT jsonb_agg(elem)
FROM jsonb_array_elements(org_permissions) elem
WHERE elem->>'resource_type' != 'task'
), '[]'::jsonb)
WHERE org_permissions @> '[{"resource_type": "task"}]';
UPDATE custom_roles
SET user_permissions = COALESCE((
SELECT jsonb_agg(elem)
FROM jsonb_array_elements(user_permissions) elem
WHERE elem->>'resource_type' != 'task'
), '[]'::jsonb)
WHERE user_permissions @> '[{"resource_type": "task"}]';
UPDATE custom_roles
SET member_permissions = COALESCE((
SELECT jsonb_agg(elem)
FROM jsonb_array_elements(member_permissions) elem
WHERE elem->>'resource_type' != 'task'
), '[]'::jsonb)
WHERE member_permissions @> '[{"resource_type": "task"}]';
-- Remove task scopes from persisted API keys and OAuth2 grants so scope
-- expansion cannot hit the deleted task scope definitions. Keys and grants
-- left with no other scope are revoked outright.
DELETE FROM api_keys
WHERE NOT EXISTS (SELECT 1 FROM unnest(scopes) s WHERE s::text NOT LIKE 'task:%');
UPDATE api_keys
SET scopes = ARRAY(SELECT s FROM unnest(scopes) s WHERE s::text NOT LIKE 'task:%')
WHERE EXISTS (SELECT 1 FROM unnest(scopes) s WHERE s::text LIKE 'task:%');
-- Remove task entries from API key allow lists; loading a key reparses every
-- entry against the RBAC resource catalog, which no longer knows task. The
-- allow list cannot be empty, so keys that only referenced tasks are revoked.
DELETE FROM api_keys
WHERE NOT EXISTS (SELECT 1 FROM unnest(allow_list) e WHERE e NOT LIKE 'task:%');
UPDATE api_keys
SET allow_list = ARRAY(SELECT e FROM unnest(allow_list) e WHERE e NOT LIKE 'task:%')
WHERE EXISTS (SELECT 1 FROM unnest(allow_list) e WHERE e LIKE 'task:%');
DELETE FROM oauth2_provider_app_codes
WHERE EXISTS (SELECT 1 FROM unnest(string_to_array(scope, ' ')) w WHERE w LIKE 'task:%')
AND NOT EXISTS (SELECT 1 FROM unnest(string_to_array(scope, ' ')) w WHERE w NOT LIKE 'task:%' AND w <> '');
UPDATE oauth2_provider_app_codes
SET scope = array_to_string(ARRAY(SELECT w FROM unnest(string_to_array(scope, ' ')) w WHERE w NOT LIKE 'task:%' AND w <> ''), ' ')
WHERE EXISTS (SELECT 1 FROM unnest(string_to_array(scope, ' ')) w WHERE w LIKE 'task:%');
DELETE FROM oauth2_provider_app_tokens
WHERE EXISTS (SELECT 1 FROM unnest(string_to_array(scope, ' ')) w WHERE w LIKE 'task:%')
AND NOT EXISTS (SELECT 1 FROM unnest(string_to_array(scope, ' ')) w WHERE w NOT LIKE 'task:%' AND w <> '');
UPDATE oauth2_provider_app_tokens
SET scope = array_to_string(ARRAY(SELECT w FROM unnest(string_to_array(scope, ' ')) w WHERE w NOT LIKE 'task:%' AND w <> ''), ' ')
WHERE EXISTS (SELECT 1 FROM unnest(string_to_array(scope, ' ')) w WHERE w LIKE 'task:%');
-- Neutralize apps whose allow list named only task scopes. Stripping to an
-- empty string would read as "no allowlist configured" (unrestricted), so
-- store a whitespace-only allowlist, a configured value that grants nothing.
UPDATE oauth2_provider_apps
SET scope = ' '
WHERE EXISTS (SELECT 1 FROM unnest(string_to_array(scope, ' ')) w WHERE w LIKE 'task:%')
AND NOT EXISTS (SELECT 1 FROM unnest(string_to_array(scope, ' ')) w WHERE w NOT LIKE 'task:%' AND w <> '');
UPDATE oauth2_provider_apps
SET scope = array_to_string(ARRAY(SELECT w FROM unnest(string_to_array(scope, ' ')) w WHERE w NOT LIKE 'task:%' AND w <> ''), ' ')
WHERE EXISTS (SELECT 1 FROM unnest(string_to_array(scope, ' ')) w WHERE w LIKE 'task:%');
-- Remove task scopes from the api_key_scope enum.
ALTER TYPE api_key_scope RENAME TO api_key_scope_old;
CREATE TYPE api_key_scope AS ENUM (
'coder:all',
'coder:application_connect',
'aibridge_interception:create',
'aibridge_interception:read',
'aibridge_interception:update',
'api_key:create',
'api_key:delete',
'api_key:read',
'api_key:update',
'assign_org_role:assign',
'assign_org_role:create',
'assign_org_role:delete',
'assign_org_role:read',
'assign_org_role:unassign',
'assign_org_role:update',
'assign_role:assign',
'assign_role:read',
'assign_role:unassign',
'audit_log:create',
'audit_log:read',
'connection_log:read',
'connection_log:update',
'crypto_key:create',
'crypto_key:delete',
'crypto_key:read',
'crypto_key:update',
'debug_info:read',
'deployment_config:read',
'deployment_config:update',
'deployment_stats:read',
'file:create',
'file:read',
'group:create',
'group:delete',
'group:read',
'group:update',
'group_member:read',
'idpsync_settings:read',
'idpsync_settings:update',
'inbox_notification:create',
'inbox_notification:read',
'inbox_notification:update',
'license:create',
'license:delete',
'license:read',
'notification_message:create',
'notification_message:delete',
'notification_message:read',
'notification_message:update',
'notification_preference:read',
'notification_preference:update',
'notification_template:read',
'notification_template:update',
'oauth2_app:create',
'oauth2_app:delete',
'oauth2_app:read',
'oauth2_app:update',
'oauth2_app_code_token:create',
'oauth2_app_code_token:delete',
'oauth2_app_code_token:read',
'oauth2_app_secret:create',
'oauth2_app_secret:delete',
'oauth2_app_secret:read',
'oauth2_app_secret:update',
'organization:create',
'organization:delete',
'organization:read',
'organization:update',
'organization_member:create',
'organization_member:delete',
'organization_member:read',
'organization_member:update',
'prebuilt_workspace:delete',
'prebuilt_workspace:update',
'provisioner_daemon:create',
'provisioner_daemon:delete',
'provisioner_daemon:read',
'provisioner_daemon:update',
'provisioner_jobs:create',
'provisioner_jobs:read',
'provisioner_jobs:update',
'replicas:read',
'system:create',
'system:delete',
'system:read',
'system:update',
'tailnet_coordinator:create',
'tailnet_coordinator:delete',
'tailnet_coordinator:read',
'tailnet_coordinator:update',
'template:create',
'template:delete',
'template:read',
'template:update',
'template:use',
'template:view_insights',
'usage_event:create',
'usage_event:read',
'usage_event:update',
'user:create',
'user:delete',
'user:read',
'user:read_personal',
'user:update',
'user:update_personal',
'user_secret:create',
'user_secret:delete',
'user_secret:read',
'user_secret:update',
'webpush_subscription:create',
'webpush_subscription:delete',
'webpush_subscription:read',
'workspace:application_connect',
'workspace:create',
'workspace:create_agent',
'workspace:delete',
'workspace:delete_agent',
'workspace:read',
'workspace:ssh',
'workspace:start',
'workspace:stop',
'workspace:update',
'workspace_agent_devcontainers:create',
'workspace_agent_resource_monitor:create',
'workspace_agent_resource_monitor:read',
'workspace_agent_resource_monitor:update',
'workspace_dormant:application_connect',
'workspace_dormant:create',
'workspace_dormant:create_agent',
'workspace_dormant:delete',
'workspace_dormant:delete_agent',
'workspace_dormant:read',
'workspace_dormant:ssh',
'workspace_dormant:start',
'workspace_dormant:stop',
'workspace_dormant:update',
'workspace_proxy:create',
'workspace_proxy:delete',
'workspace_proxy:read',
'workspace_proxy:update',
'coder:workspaces.create',
'coder:workspaces.operate',
'coder:workspaces.delete',
'coder:workspaces.access',
'coder:templates.build',
'coder:templates.author',
'coder:apikeys.manage_self',
'aibridge_interception:*',
'api_key:*',
'assign_org_role:*',
'assign_role:*',
'audit_log:*',
'connection_log:*',
'crypto_key:*',
'debug_info:*',
'deployment_config:*',
'deployment_stats:*',
'file:*',
'group:*',
'group_member:*',
'idpsync_settings:*',
'inbox_notification:*',
'license:*',
'notification_message:*',
'notification_preference:*',
'notification_template:*',
'oauth2_app:*',
'oauth2_app_code_token:*',
'oauth2_app_secret:*',
'organization:*',
'organization_member:*',
'prebuilt_workspace:*',
'provisioner_daemon:*',
'provisioner_jobs:*',
'replicas:*',
'system:*',
'tailnet_coordinator:*',
'template:*',
'usage_event:*',
'user:*',
'user_secret:*',
'webpush_subscription:*',
'workspace:*',
'workspace_agent_devcontainers:*',
'workspace_agent_resource_monitor:*',
'workspace_dormant:*',
'workspace_proxy:*',
'workspace:share',
'workspace_dormant:share',
'boundary_usage:*',
'boundary_usage:delete',
'boundary_usage:read',
'boundary_usage:update',
'workspace:update_agent',
'workspace_dormant:update_agent',
'chat:create',
'chat:read',
'chat:update',
'chat:delete',
'chat:*',
'ai_seat:*',
'ai_seat:create',
'ai_seat:read',
'ai_model_price:*',
'ai_model_price:read',
'ai_model_price:update',
'ai_provider:*',
'ai_provider:create',
'ai_provider:delete',
'ai_provider:read',
'ai_provider:update',
'chat:share',
'user_skill:create',
'user_skill:read',
'user_skill:update',
'user_skill:delete',
'user_skill:*',
'boundary_log:*',
'boundary_log:create',
'boundary_log:delete',
'boundary_log:read',
'ai_gateway_key:*',
'ai_gateway_key:create',
'ai_gateway_key:delete',
'ai_gateway_key:read',
'ai_gateway_key:update',
'workspace_build_orchestration:*',
'workspace_build_orchestration:create',
'workspace_build_orchestration:delete',
'workspace_build_orchestration:read',
'workspace_build_orchestration:update',
'mcp_server_config:*',
'mcp_server_config:create',
'mcp_server_config:read',
'mcp_server_config:update',
'mcp_server_config:delete',
'mcp_server_config:share',
'chat_model_config:*',
'chat_model_config:create',
'chat_model_config:read',
'chat_model_config:update',
'chat_model_config:delete',
'chat_model_config:share'
);
ALTER TABLE api_keys ALTER COLUMN scopes TYPE api_key_scope[] USING (scopes::text[]::api_key_scope[]);
DROP TYPE api_key_scope_old;
-- Task audit history is retained: audit logs are subject to retention
-- policies, so the task value stays in the resource_type enum as a
-- historical audit value.