From fb0ce389a6ceb8b9022e608ac23ab4f791b871c8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 22 Sep 2025 19:26:51 +0200 Subject: [PATCH] feat: implement API key scopes database migration (#19861) Added database migration for API key scopes. Fixes #19845 --- coderd/apikey/apikey.go | 3 +- coderd/apikey/apikey_test.go | 4 +- coderd/coderdtest/authorize.go | 2 +- coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbgen/dbgen.go | 3 +- coderd/database/dump.sql | 145 +++++- ...1_api_key_scopes_array_allow_list.down.sql | 18 + ...371_api_key_scopes_array_allow_list.up.sql | 157 +++++++ coderd/database/modelmethods.go | 88 +++- coderd/database/modelmethods_internal_test.go | 106 +++++ coderd/database/models.go | 443 +++++++++++++++++- coderd/database/queries.sql.go | 63 +-- coderd/database/queries/apikeys.sql | 7 +- coderd/database/sqlc.yaml | 6 + coderd/database/types.go | 85 ++++ coderd/httpmw/apikey.go | 2 +- coderd/httpmw/apikey_test.go | 4 +- coderd/httpmw/authorize_test.go | 3 +- coderd/httpmw/workspaceparam_test.go | 3 +- coderd/rbac/scopes.go | 64 ++- coderd/rbac/scopes_test.go | 63 +++ coderd/userauth_test.go | 8 +- coderd/users.go | 8 +- docs/admin/security/audit-logs.md | 2 +- enterprise/audit/table.go | 3 +- scripts/generate_api_key_scope_enum/main.go | 29 ++ 26 files changed, 1252 insertions(+), 71 deletions(-) create mode 100644 coderd/database/migrations/000371_api_key_scopes_array_allow_list.down.sql create mode 100644 coderd/database/migrations/000371_api_key_scopes_array_allow_list.up.sql create mode 100644 coderd/database/modelmethods_internal_test.go create mode 100644 coderd/rbac/scopes_test.go create mode 100644 scripts/generate_api_key_scope_enum/main.go diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index ce6960dd53..10586178e1 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -92,7 +92,8 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) UpdatedAt: dbtime.Now(), HashedSecret: hashed[:], LoginType: params.LoginType, - Scope: scope, + Scopes: database.APIKeyScopes{scope}, + AllowList: database.AllowList{database.AllowListWildcard()}, TokenName: params.TokenName, }, token, nil } diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index 198ef11511..3bb71538ee 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -159,9 +159,9 @@ func TestGenerate(t *testing.T) { } if tc.params.Scope != "" { - assert.Equal(t, tc.params.Scope, key.Scope) + assert.True(t, key.Scopes.Has(tc.params.Scope)) } else { - assert.Equal(t, database.APIKeyScopeAll, key.Scope) + assert.True(t, key.Scopes.Has(database.APIKeyScopeAll)) } if tc.params.TokenName != "" { diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 68ab5a27e5..f0d35cd635 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -68,7 +68,7 @@ func AssertRBAC(t *testing.T, api *coderd.API, client *codersdk.Client) RBACAsse ID: key.UserID.String(), Roles: rbac.RoleIdentifiers(roleNames), Groups: roles.Groups, - Scope: rbac.ScopeName(key.Scope), + Scope: key.Scopes, }, Recorder: recorder, } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 174cc88002..1eb92e3680 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -251,7 +251,7 @@ func (s *MethodTestSuite) TestAPIKey() { })) s.Run("InsertAPIKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) - arg := database.InsertAPIKeyParams{UserID: u.ID, LoginType: database.LoginTypePassword, Scope: database.APIKeyScopeAll, IPAddress: defaultIPAddress()} + arg := database.InsertAPIKeyParams{UserID: u.ID, LoginType: database.LoginTypePassword, Scopes: database.APIKeyScopes{database.APIKeyScopeAll}, IPAddress: defaultIPAddress()} ret := testutil.Fake(s.T(), faker, database.APIKey{UserID: u.ID, LoginType: database.LoginTypePassword}) dbm.EXPECT().InsertAPIKey(gomock.Any(), arg).Return(ret, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceApiKey.WithOwner(u.ID.String()), policy.ActionCreate) @@ -265,7 +265,7 @@ func (s *MethodTestSuite) TestAPIKey() { check.Args(arg).Asserts(a, policy.ActionUpdate).Returns() })) s.Run("DeleteApplicationConnectAPIKeysByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - a := testutil.Fake(s.T(), faker, database.APIKey{Scope: database.APIKeyScopeApplicationConnect}) + a := testutil.Fake(s.T(), faker, database.APIKey{Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect}}) dbm.EXPECT().DeleteApplicationConnectAPIKeysByUserID(gomock.Any(), a.UserID).Return(nil).AnyTimes() check.Args(a.UserID).Asserts(rbac.ResourceApiKey.WithOwner(a.UserID.String()), policy.ActionDelete).Returns() })) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 0d27637aa4..8101846cf8 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -185,7 +185,8 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey, munge ...func CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), LoginType: takeFirst(seed.LoginType, database.LoginTypePassword), - Scope: takeFirst(seed.Scope, database.APIKeyScopeAll), + Scopes: takeFirstSlice([]database.APIKeyScope(seed.Scopes), []database.APIKeyScope{database.APIKeyScopeAll}), + AllowList: takeFirstSlice(seed.AllowList, database.AllowList{database.AllowListWildcard()}), TokenName: takeFirst(seed.TokenName), } for _, fn := range munge { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index e68c2008e9..df1f581950 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -12,7 +12,145 @@ CREATE TYPE agent_key_scope_enum AS ENUM ( CREATE TYPE api_key_scope AS ENUM ( 'all', - 'application_connect' + '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' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -920,8 +1058,9 @@ CREATE TABLE api_keys ( login_type login_type NOT NULL, lifetime_seconds bigint DEFAULT 86400 NOT NULL, ip_address inet DEFAULT '0.0.0.0'::inet NOT NULL, - scope api_key_scope DEFAULT 'all'::api_key_scope NOT NULL, - token_name text DEFAULT ''::text NOT NULL + token_name text DEFAULT ''::text NOT NULL, + scopes api_key_scope[] NOT NULL, + allow_list text[] NOT NULL ); COMMENT ON COLUMN api_keys.hashed_secret IS 'hashed_secret contains a SHA256 hash of the key secret. This is considered a secret and MUST NOT be returned from the API as it is used for API key encryption in app proxying code.'; diff --git a/coderd/database/migrations/000371_api_key_scopes_array_allow_list.down.sql b/coderd/database/migrations/000371_api_key_scopes_array_allow_list.down.sql new file mode 100644 index 0000000000..50d02e46e0 --- /dev/null +++ b/coderd/database/migrations/000371_api_key_scopes_array_allow_list.down.sql @@ -0,0 +1,18 @@ +-- Recreate single-scope column and collapse arrays +ALTER TABLE api_keys ADD COLUMN scope api_key_scope DEFAULT 'all'::api_key_scope NOT NULL; + +-- Collapse logic: prefer 'all', else 'application_connect', else 'all' +UPDATE api_keys SET scope = + CASE + WHEN 'all'::api_key_scope = ANY(scopes) THEN 'all'::api_key_scope + WHEN 'application_connect'::api_key_scope = ANY(scopes) THEN 'application_connect'::api_key_scope + ELSE 'all'::api_key_scope + END; + +-- Drop new columns +ALTER TABLE api_keys DROP COLUMN allow_list; +ALTER TABLE api_keys DROP COLUMN scopes; + +-- Note: We intentionally keep the expanded enum values to avoid dependency churn. +-- If strict narrowing is required, create a new type with only ('all','application_connect'), +-- cast column, drop the new type, and rename. diff --git a/coderd/database/migrations/000371_api_key_scopes_array_allow_list.up.sql b/coderd/database/migrations/000371_api_key_scopes_array_allow_list.up.sql new file mode 100644 index 0000000000..b38bf89880 --- /dev/null +++ b/coderd/database/migrations/000371_api_key_scopes_array_allow_list.up.sql @@ -0,0 +1,157 @@ +-- Extend api_key_scope enum with low-level : values derived from RBACPermissions +-- Generated via: go run ./scripts/generate_api_key_scope_enum +-- Begin enum extensions +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'aibridge_interception:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'aibridge_interception:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'aibridge_interception:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'api_key:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'api_key:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'api_key:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'api_key:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'assign_org_role:assign'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'assign_org_role:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'assign_org_role:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'assign_org_role:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'assign_org_role:unassign'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'assign_org_role:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'assign_role:assign'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'assign_role:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'assign_role:unassign'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'audit_log:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'audit_log:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'connection_log:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'connection_log:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'crypto_key:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'crypto_key:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'crypto_key:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'crypto_key:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'debug_info:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'deployment_config:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'deployment_config:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'deployment_stats:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'file:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'file:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'group:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'group:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'group:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'group:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'group_member:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'idpsync_settings:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'idpsync_settings:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'inbox_notification:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'inbox_notification:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'inbox_notification:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'license:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'license:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'license:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'notification_message:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'notification_message:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'notification_message:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'notification_message:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'notification_preference:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'notification_preference:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'notification_template:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'notification_template:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app_code_token:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app_code_token:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app_code_token:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app_secret:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app_secret:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app_secret:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'oauth2_app_secret:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_member:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_member:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_member:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'organization_member:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'prebuilt_workspace:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'prebuilt_workspace:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'provisioner_daemon:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'provisioner_daemon:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'provisioner_daemon:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'provisioner_daemon:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'provisioner_jobs:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'provisioner_jobs:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'provisioner_jobs:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'replicas:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'system:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'system:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'system:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'system:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'tailnet_coordinator:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'tailnet_coordinator:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'tailnet_coordinator:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'tailnet_coordinator:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'template:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'template:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'template:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'template:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'template:use'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'template:view_insights'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'usage_event:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'usage_event:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'usage_event:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user:read_personal'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user:update_personal'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_secret:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_secret:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_secret:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'user_secret:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'webpush_subscription:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'webpush_subscription:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'webpush_subscription:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:application_connect'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:create_agent'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:delete_agent'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:ssh'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:start'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:stop'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_agent_devcontainers:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_agent_resource_monitor:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_agent_resource_monitor:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_agent_resource_monitor:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:application_connect'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:create_agent'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:delete_agent'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:ssh'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:start'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:stop'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_dormant:update'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_proxy:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_proxy:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_proxy:read'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'workspace_proxy:update'; +-- End enum extensions + +-- Add new columns without defaults; backfill; then enforce NOT NULL +ALTER TABLE api_keys ADD COLUMN scopes api_key_scope[]; +ALTER TABLE api_keys ADD COLUMN allow_list text[]; + +-- Backfill existing rows for compatibility +UPDATE api_keys SET scopes = ARRAY[scope::api_key_scope]; +UPDATE api_keys SET allow_list = ARRAY['*:*']; + +-- Enforce NOT NULL +ALTER TABLE api_keys ALTER COLUMN scopes SET NOT NULL; +ALTER TABLE api_keys ALTER COLUMN allow_list SET NOT NULL; + +-- Drop legacy single-scope column +ALTER TABLE api_keys DROP COLUMN scope; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 3f3d7b3b85..59dd8e1f17 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -2,8 +2,10 @@ package database import ( "encoding/hex" + "slices" "sort" "strconv" + "strings" "time" "github.com/google/uuid" @@ -137,10 +139,94 @@ func (s APIKeyScope) ToRBAC() rbac.ScopeName { case APIKeyScopeApplicationConnect: return rbac.ScopeApplicationConnect default: - panic("developer error: unknown scope type " + string(s)) + // Allow low-level resource:action scopes to flow through to RBAC for + // expansion via rbac.ExpandScope. + return rbac.ScopeName(s) } } +// APIKeyScopes allows expanding multiple API key scopes into a single +// RBAC scope for authorization. This implements rbac.ExpandableScope so +// callers can pass the list directly without deriving a single scope. +type APIKeyScopes []APIKeyScope + +var _ rbac.ExpandableScope = APIKeyScopes{} + +// Has returns true if the slice contains the provided scope. +func (s APIKeyScopes) Has(target APIKeyScope) bool { + return slices.Contains(s, target) +} + +// Expand merges the permissions of all scopes in the list into a single scope. +// If the list is empty, it defaults to rbac.ScopeAll. +func (s APIKeyScopes) Expand() (rbac.Scope, error) { + // Default to ScopeAll for backward compatibility when no scopes provided. + if len(s) == 0 { + return rbac.ScopeAll.Expand() + } + + var merged rbac.Scope + merged.Role = rbac.Role{ + // Identifier is informational; not used in policy evaluation. + Identifier: rbac.RoleIdentifier{Name: "Scope_Multiple"}, + Site: nil, + Org: map[string][]rbac.Permission{}, + User: nil, + } + + // Track allow list union, collapsing to wildcard if any child is wildcard. + allowAll := false + allowSet := make(map[string]rbac.AllowListElement) + + for _, s := range s { + expanded, err := s.ToRBAC().Expand() + if err != nil { + return rbac.Scope{}, err + } + + // Merge role permissions: union by simple concatenation. + merged.Site = append(merged.Site, expanded.Site...) + for orgID, perms := range expanded.Org { + merged.Org[orgID] = append(merged.Org[orgID], perms...) + } + merged.User = append(merged.User, expanded.User...) + + // Merge allow lists. + for _, e := range expanded.AllowIDList { + if e.ID == policy.WildcardSymbol && e.Type == policy.WildcardSymbol { + allowAll = true + // No need to track other entries once wildcard is present. + continue + } + key := e.String() + allowSet[key] = e + } + } + + if allowAll || len(allowSet) == 0 { + merged.AllowIDList = []rbac.AllowListElement{rbac.AllowListAll()} + } else { + merged.AllowIDList = make([]rbac.AllowListElement, 0, len(allowSet)) + for _, v := range allowSet { + merged.AllowIDList = append(merged.AllowIDList, v) + } + } + + return merged, nil +} + +// Name returns a human-friendly identifier for tracing/logging. +func (s APIKeyScopes) Name() rbac.RoleIdentifier { + if len(s) == 0 { + return rbac.RoleIdentifier{Name: string(APIKeyScopeAll)} + } + names := make([]string, 0, len(s)) + for _, s := range s { + names = append(names, string(s)) + } + return rbac.RoleIdentifier{Name: "scopes[" + strings.Join(names, "+") + "]"} +} + func (k APIKey) RBACObject() rbac.Object { return rbac.ResourceApiKey.WithIDString(k.ID). WithOwner(k.UserID.String()) diff --git a/coderd/database/modelmethods_internal_test.go b/coderd/database/modelmethods_internal_test.go new file mode 100644 index 0000000000..65075c0d7d --- /dev/null +++ b/coderd/database/modelmethods_internal_test.go @@ -0,0 +1,106 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" +) + +func TestAPIKeyScopesExpand(t *testing.T) { + t.Parallel() + t.Run("builtins", func(t *testing.T) { + t.Parallel() + cases := []struct { + name string + scopes APIKeyScopes + want func(t *testing.T, s rbac.Scope) + }{ + { + name: "all", + scopes: APIKeyScopes{APIKeyScopeAll}, + want: func(t *testing.T, s rbac.Scope) { + requirePermission(t, s, rbac.ResourceWildcard.Type, policy.Action(policy.WildcardSymbol)) + requireAllowAll(t, s) + }, + }, + { + name: "application_connect", + scopes: APIKeyScopes{APIKeyScopeApplicationConnect}, + want: func(t *testing.T, s rbac.Scope) { + requirePermission(t, s, rbac.ResourceWorkspace.Type, policy.ActionApplicationConnect) + requireAllowAll(t, s) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s, err := tc.scopes.Expand() + require.NoError(t, err) + tc.want(t, s) + }) + } + }) + + t.Run("low_level_pairs", func(t *testing.T) { + t.Parallel() + cases := []struct { + name string + scopes APIKeyScopes + res string + act policy.Action + }{ + {name: "workspace:read", scopes: APIKeyScopes{ApiKeyScopeWorkspaceRead}, res: rbac.ResourceWorkspace.Type, act: policy.ActionRead}, + {name: "template:use", scopes: APIKeyScopes{ApiKeyScopeTemplateUse}, res: rbac.ResourceTemplate.Type, act: policy.ActionUse}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s, err := tc.scopes.Expand() + require.NoError(t, err) + requirePermission(t, s, tc.res, tc.act) + requireAllowAll(t, s) + }) + } + }) + + t.Run("merge", func(t *testing.T) { + t.Parallel() + scopes := APIKeyScopes{APIKeyScopeApplicationConnect, APIKeyScopeAll, ApiKeyScopeWorkspaceRead} + s, err := scopes.Expand() + require.NoError(t, err) + requirePermission(t, s, rbac.ResourceWildcard.Type, policy.Action(policy.WildcardSymbol)) + requirePermission(t, s, rbac.ResourceWorkspace.Type, policy.ActionApplicationConnect) + requirePermission(t, s, rbac.ResourceWorkspace.Type, policy.ActionRead) + requireAllowAll(t, s) + }) + + t.Run("empty_defaults_to_all", func(t *testing.T) { + t.Parallel() + s, err := (APIKeyScopes{}).Expand() + require.NoError(t, err) + requirePermission(t, s, rbac.ResourceWildcard.Type, policy.Action(policy.WildcardSymbol)) + requireAllowAll(t, s) + }) +} + +// Helpers +func requirePermission(t *testing.T, s rbac.Scope, resource string, action policy.Action) { + t.Helper() + for _, p := range s.Site { + if p.ResourceType == resource && p.Action == action { + return + } + } + t.Fatalf("permission not found: %s:%s", resource, action) +} + +func requireAllowAll(t *testing.T, s rbac.Scope) { + t.Helper() + require.Len(t, s.AllowIDList, 1) + require.Equal(t, policy.WildcardSymbol, s.AllowIDList[0].ID) + require.Equal(t, policy.WildcardSymbol, s.AllowIDList[0].Type) +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 622441cd5a..d096b0e66b 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -19,8 +19,146 @@ import ( type APIKeyScope string const ( - APIKeyScopeAll APIKeyScope = "all" - APIKeyScopeApplicationConnect APIKeyScope = "application_connect" + APIKeyScopeAll APIKeyScope = "all" + APIKeyScopeApplicationConnect APIKeyScope = "application_connect" + ApiKeyScopeAibridgeInterceptionCreate APIKeyScope = "aibridge_interception:create" + ApiKeyScopeAibridgeInterceptionRead APIKeyScope = "aibridge_interception:read" + ApiKeyScopeAibridgeInterceptionUpdate APIKeyScope = "aibridge_interception:update" + ApiKeyScopeApiKeyCreate APIKeyScope = "api_key:create" + ApiKeyScopeApiKeyDelete APIKeyScope = "api_key:delete" + ApiKeyScopeApiKeyRead APIKeyScope = "api_key:read" + ApiKeyScopeApiKeyUpdate APIKeyScope = "api_key:update" + ApiKeyScopeAssignOrgRoleAssign APIKeyScope = "assign_org_role:assign" + ApiKeyScopeAssignOrgRoleCreate APIKeyScope = "assign_org_role:create" + ApiKeyScopeAssignOrgRoleDelete APIKeyScope = "assign_org_role:delete" + ApiKeyScopeAssignOrgRoleRead APIKeyScope = "assign_org_role:read" + ApiKeyScopeAssignOrgRoleUnassign APIKeyScope = "assign_org_role:unassign" + ApiKeyScopeAssignOrgRoleUpdate APIKeyScope = "assign_org_role:update" + ApiKeyScopeAssignRoleAssign APIKeyScope = "assign_role:assign" + ApiKeyScopeAssignRoleRead APIKeyScope = "assign_role:read" + ApiKeyScopeAssignRoleUnassign APIKeyScope = "assign_role:unassign" + ApiKeyScopeAuditLogCreate APIKeyScope = "audit_log:create" + ApiKeyScopeAuditLogRead APIKeyScope = "audit_log:read" + ApiKeyScopeConnectionLogRead APIKeyScope = "connection_log:read" + ApiKeyScopeConnectionLogUpdate APIKeyScope = "connection_log:update" + ApiKeyScopeCryptoKeyCreate APIKeyScope = "crypto_key:create" + ApiKeyScopeCryptoKeyDelete APIKeyScope = "crypto_key:delete" + ApiKeyScopeCryptoKeyRead APIKeyScope = "crypto_key:read" + ApiKeyScopeCryptoKeyUpdate APIKeyScope = "crypto_key:update" + ApiKeyScopeDebugInfoRead APIKeyScope = "debug_info:read" + ApiKeyScopeDeploymentConfigRead APIKeyScope = "deployment_config:read" + ApiKeyScopeDeploymentConfigUpdate APIKeyScope = "deployment_config:update" + ApiKeyScopeDeploymentStatsRead APIKeyScope = "deployment_stats:read" + ApiKeyScopeFileCreate APIKeyScope = "file:create" + ApiKeyScopeFileRead APIKeyScope = "file:read" + ApiKeyScopeGroupCreate APIKeyScope = "group:create" + ApiKeyScopeGroupDelete APIKeyScope = "group:delete" + ApiKeyScopeGroupRead APIKeyScope = "group:read" + ApiKeyScopeGroupUpdate APIKeyScope = "group:update" + ApiKeyScopeGroupMemberRead APIKeyScope = "group_member:read" + ApiKeyScopeIdpsyncSettingsRead APIKeyScope = "idpsync_settings:read" + ApiKeyScopeIdpsyncSettingsUpdate APIKeyScope = "idpsync_settings:update" + ApiKeyScopeInboxNotificationCreate APIKeyScope = "inbox_notification:create" + ApiKeyScopeInboxNotificationRead APIKeyScope = "inbox_notification:read" + ApiKeyScopeInboxNotificationUpdate APIKeyScope = "inbox_notification:update" + ApiKeyScopeLicenseCreate APIKeyScope = "license:create" + ApiKeyScopeLicenseDelete APIKeyScope = "license:delete" + ApiKeyScopeLicenseRead APIKeyScope = "license:read" + ApiKeyScopeNotificationMessageCreate APIKeyScope = "notification_message:create" + ApiKeyScopeNotificationMessageDelete APIKeyScope = "notification_message:delete" + ApiKeyScopeNotificationMessageRead APIKeyScope = "notification_message:read" + ApiKeyScopeNotificationMessageUpdate APIKeyScope = "notification_message:update" + ApiKeyScopeNotificationPreferenceRead APIKeyScope = "notification_preference:read" + ApiKeyScopeNotificationPreferenceUpdate APIKeyScope = "notification_preference:update" + ApiKeyScopeNotificationTemplateRead APIKeyScope = "notification_template:read" + ApiKeyScopeNotificationTemplateUpdate APIKeyScope = "notification_template:update" + ApiKeyScopeOauth2AppCreate APIKeyScope = "oauth2_app:create" + ApiKeyScopeOauth2AppDelete APIKeyScope = "oauth2_app:delete" + ApiKeyScopeOauth2AppRead APIKeyScope = "oauth2_app:read" + ApiKeyScopeOauth2AppUpdate APIKeyScope = "oauth2_app:update" + ApiKeyScopeOauth2AppCodeTokenCreate APIKeyScope = "oauth2_app_code_token:create" + ApiKeyScopeOauth2AppCodeTokenDelete APIKeyScope = "oauth2_app_code_token:delete" + ApiKeyScopeOauth2AppCodeTokenRead APIKeyScope = "oauth2_app_code_token:read" + ApiKeyScopeOauth2AppSecretCreate APIKeyScope = "oauth2_app_secret:create" + ApiKeyScopeOauth2AppSecretDelete APIKeyScope = "oauth2_app_secret:delete" + ApiKeyScopeOauth2AppSecretRead APIKeyScope = "oauth2_app_secret:read" + ApiKeyScopeOauth2AppSecretUpdate APIKeyScope = "oauth2_app_secret:update" + ApiKeyScopeOrganizationCreate APIKeyScope = "organization:create" + ApiKeyScopeOrganizationDelete APIKeyScope = "organization:delete" + ApiKeyScopeOrganizationRead APIKeyScope = "organization:read" + ApiKeyScopeOrganizationUpdate APIKeyScope = "organization:update" + ApiKeyScopeOrganizationMemberCreate APIKeyScope = "organization_member:create" + ApiKeyScopeOrganizationMemberDelete APIKeyScope = "organization_member:delete" + ApiKeyScopeOrganizationMemberRead APIKeyScope = "organization_member:read" + ApiKeyScopeOrganizationMemberUpdate APIKeyScope = "organization_member:update" + ApiKeyScopePrebuiltWorkspaceDelete APIKeyScope = "prebuilt_workspace:delete" + ApiKeyScopePrebuiltWorkspaceUpdate APIKeyScope = "prebuilt_workspace:update" + ApiKeyScopeProvisionerDaemonCreate APIKeyScope = "provisioner_daemon:create" + ApiKeyScopeProvisionerDaemonDelete APIKeyScope = "provisioner_daemon:delete" + ApiKeyScopeProvisionerDaemonRead APIKeyScope = "provisioner_daemon:read" + ApiKeyScopeProvisionerDaemonUpdate APIKeyScope = "provisioner_daemon:update" + ApiKeyScopeProvisionerJobsCreate APIKeyScope = "provisioner_jobs:create" + ApiKeyScopeProvisionerJobsRead APIKeyScope = "provisioner_jobs:read" + ApiKeyScopeProvisionerJobsUpdate APIKeyScope = "provisioner_jobs:update" + ApiKeyScopeReplicasRead APIKeyScope = "replicas:read" + ApiKeyScopeSystemCreate APIKeyScope = "system:create" + ApiKeyScopeSystemDelete APIKeyScope = "system:delete" + ApiKeyScopeSystemRead APIKeyScope = "system:read" + ApiKeyScopeSystemUpdate APIKeyScope = "system:update" + ApiKeyScopeTailnetCoordinatorCreate APIKeyScope = "tailnet_coordinator:create" + ApiKeyScopeTailnetCoordinatorDelete APIKeyScope = "tailnet_coordinator:delete" + ApiKeyScopeTailnetCoordinatorRead APIKeyScope = "tailnet_coordinator:read" + ApiKeyScopeTailnetCoordinatorUpdate APIKeyScope = "tailnet_coordinator:update" + ApiKeyScopeTemplateCreate APIKeyScope = "template:create" + ApiKeyScopeTemplateDelete APIKeyScope = "template:delete" + ApiKeyScopeTemplateRead APIKeyScope = "template:read" + ApiKeyScopeTemplateUpdate APIKeyScope = "template:update" + ApiKeyScopeTemplateUse APIKeyScope = "template:use" + ApiKeyScopeTemplateViewInsights APIKeyScope = "template:view_insights" + ApiKeyScopeUsageEventCreate APIKeyScope = "usage_event:create" + ApiKeyScopeUsageEventRead APIKeyScope = "usage_event:read" + ApiKeyScopeUsageEventUpdate APIKeyScope = "usage_event:update" + ApiKeyScopeUserCreate APIKeyScope = "user:create" + ApiKeyScopeUserDelete APIKeyScope = "user:delete" + ApiKeyScopeUserRead APIKeyScope = "user:read" + ApiKeyScopeUserReadPersonal APIKeyScope = "user:read_personal" + ApiKeyScopeUserUpdate APIKeyScope = "user:update" + ApiKeyScopeUserUpdatePersonal APIKeyScope = "user:update_personal" + ApiKeyScopeUserSecretCreate APIKeyScope = "user_secret:create" + ApiKeyScopeUserSecretDelete APIKeyScope = "user_secret:delete" + ApiKeyScopeUserSecretRead APIKeyScope = "user_secret:read" + ApiKeyScopeUserSecretUpdate APIKeyScope = "user_secret:update" + ApiKeyScopeWebpushSubscriptionCreate APIKeyScope = "webpush_subscription:create" + ApiKeyScopeWebpushSubscriptionDelete APIKeyScope = "webpush_subscription:delete" + ApiKeyScopeWebpushSubscriptionRead APIKeyScope = "webpush_subscription:read" + ApiKeyScopeWorkspaceApplicationConnect APIKeyScope = "workspace:application_connect" + ApiKeyScopeWorkspaceCreate APIKeyScope = "workspace:create" + ApiKeyScopeWorkspaceCreateAgent APIKeyScope = "workspace:create_agent" + ApiKeyScopeWorkspaceDelete APIKeyScope = "workspace:delete" + ApiKeyScopeWorkspaceDeleteAgent APIKeyScope = "workspace:delete_agent" + ApiKeyScopeWorkspaceRead APIKeyScope = "workspace:read" + ApiKeyScopeWorkspaceSsh APIKeyScope = "workspace:ssh" + ApiKeyScopeWorkspaceStart APIKeyScope = "workspace:start" + ApiKeyScopeWorkspaceStop APIKeyScope = "workspace:stop" + ApiKeyScopeWorkspaceUpdate APIKeyScope = "workspace:update" + ApiKeyScopeWorkspaceAgentDevcontainersCreate APIKeyScope = "workspace_agent_devcontainers:create" + ApiKeyScopeWorkspaceAgentResourceMonitorCreate APIKeyScope = "workspace_agent_resource_monitor:create" + ApiKeyScopeWorkspaceAgentResourceMonitorRead APIKeyScope = "workspace_agent_resource_monitor:read" + ApiKeyScopeWorkspaceAgentResourceMonitorUpdate APIKeyScope = "workspace_agent_resource_monitor:update" + ApiKeyScopeWorkspaceDormantApplicationConnect APIKeyScope = "workspace_dormant:application_connect" + ApiKeyScopeWorkspaceDormantCreate APIKeyScope = "workspace_dormant:create" + ApiKeyScopeWorkspaceDormantCreateAgent APIKeyScope = "workspace_dormant:create_agent" + ApiKeyScopeWorkspaceDormantDelete APIKeyScope = "workspace_dormant:delete" + ApiKeyScopeWorkspaceDormantDeleteAgent APIKeyScope = "workspace_dormant:delete_agent" + ApiKeyScopeWorkspaceDormantRead APIKeyScope = "workspace_dormant:read" + ApiKeyScopeWorkspaceDormantSsh APIKeyScope = "workspace_dormant:ssh" + ApiKeyScopeWorkspaceDormantStart APIKeyScope = "workspace_dormant:start" + ApiKeyScopeWorkspaceDormantStop APIKeyScope = "workspace_dormant:stop" + ApiKeyScopeWorkspaceDormantUpdate APIKeyScope = "workspace_dormant:update" + ApiKeyScopeWorkspaceProxyCreate APIKeyScope = "workspace_proxy:create" + ApiKeyScopeWorkspaceProxyDelete APIKeyScope = "workspace_proxy:delete" + ApiKeyScopeWorkspaceProxyRead APIKeyScope = "workspace_proxy:read" + ApiKeyScopeWorkspaceProxyUpdate APIKeyScope = "workspace_proxy:update" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -61,7 +199,145 @@ func (ns NullAPIKeyScope) Value() (driver.Value, error) { func (e APIKeyScope) Valid() bool { switch e { case APIKeyScopeAll, - APIKeyScopeApplicationConnect: + APIKeyScopeApplicationConnect, + ApiKeyScopeAibridgeInterceptionCreate, + ApiKeyScopeAibridgeInterceptionRead, + ApiKeyScopeAibridgeInterceptionUpdate, + ApiKeyScopeApiKeyCreate, + ApiKeyScopeApiKeyDelete, + ApiKeyScopeApiKeyRead, + ApiKeyScopeApiKeyUpdate, + ApiKeyScopeAssignOrgRoleAssign, + ApiKeyScopeAssignOrgRoleCreate, + ApiKeyScopeAssignOrgRoleDelete, + ApiKeyScopeAssignOrgRoleRead, + ApiKeyScopeAssignOrgRoleUnassign, + ApiKeyScopeAssignOrgRoleUpdate, + ApiKeyScopeAssignRoleAssign, + ApiKeyScopeAssignRoleRead, + ApiKeyScopeAssignRoleUnassign, + ApiKeyScopeAuditLogCreate, + ApiKeyScopeAuditLogRead, + ApiKeyScopeConnectionLogRead, + ApiKeyScopeConnectionLogUpdate, + ApiKeyScopeCryptoKeyCreate, + ApiKeyScopeCryptoKeyDelete, + ApiKeyScopeCryptoKeyRead, + ApiKeyScopeCryptoKeyUpdate, + ApiKeyScopeDebugInfoRead, + ApiKeyScopeDeploymentConfigRead, + ApiKeyScopeDeploymentConfigUpdate, + ApiKeyScopeDeploymentStatsRead, + ApiKeyScopeFileCreate, + ApiKeyScopeFileRead, + ApiKeyScopeGroupCreate, + ApiKeyScopeGroupDelete, + ApiKeyScopeGroupRead, + ApiKeyScopeGroupUpdate, + ApiKeyScopeGroupMemberRead, + ApiKeyScopeIdpsyncSettingsRead, + ApiKeyScopeIdpsyncSettingsUpdate, + ApiKeyScopeInboxNotificationCreate, + ApiKeyScopeInboxNotificationRead, + ApiKeyScopeInboxNotificationUpdate, + ApiKeyScopeLicenseCreate, + ApiKeyScopeLicenseDelete, + ApiKeyScopeLicenseRead, + ApiKeyScopeNotificationMessageCreate, + ApiKeyScopeNotificationMessageDelete, + ApiKeyScopeNotificationMessageRead, + ApiKeyScopeNotificationMessageUpdate, + ApiKeyScopeNotificationPreferenceRead, + ApiKeyScopeNotificationPreferenceUpdate, + ApiKeyScopeNotificationTemplateRead, + ApiKeyScopeNotificationTemplateUpdate, + ApiKeyScopeOauth2AppCreate, + ApiKeyScopeOauth2AppDelete, + ApiKeyScopeOauth2AppRead, + ApiKeyScopeOauth2AppUpdate, + ApiKeyScopeOauth2AppCodeTokenCreate, + ApiKeyScopeOauth2AppCodeTokenDelete, + ApiKeyScopeOauth2AppCodeTokenRead, + ApiKeyScopeOauth2AppSecretCreate, + ApiKeyScopeOauth2AppSecretDelete, + ApiKeyScopeOauth2AppSecretRead, + ApiKeyScopeOauth2AppSecretUpdate, + ApiKeyScopeOrganizationCreate, + ApiKeyScopeOrganizationDelete, + ApiKeyScopeOrganizationRead, + ApiKeyScopeOrganizationUpdate, + ApiKeyScopeOrganizationMemberCreate, + ApiKeyScopeOrganizationMemberDelete, + ApiKeyScopeOrganizationMemberRead, + ApiKeyScopeOrganizationMemberUpdate, + ApiKeyScopePrebuiltWorkspaceDelete, + ApiKeyScopePrebuiltWorkspaceUpdate, + ApiKeyScopeProvisionerDaemonCreate, + ApiKeyScopeProvisionerDaemonDelete, + ApiKeyScopeProvisionerDaemonRead, + ApiKeyScopeProvisionerDaemonUpdate, + ApiKeyScopeProvisionerJobsCreate, + ApiKeyScopeProvisionerJobsRead, + ApiKeyScopeProvisionerJobsUpdate, + ApiKeyScopeReplicasRead, + ApiKeyScopeSystemCreate, + ApiKeyScopeSystemDelete, + ApiKeyScopeSystemRead, + ApiKeyScopeSystemUpdate, + ApiKeyScopeTailnetCoordinatorCreate, + ApiKeyScopeTailnetCoordinatorDelete, + ApiKeyScopeTailnetCoordinatorRead, + ApiKeyScopeTailnetCoordinatorUpdate, + ApiKeyScopeTemplateCreate, + ApiKeyScopeTemplateDelete, + ApiKeyScopeTemplateRead, + ApiKeyScopeTemplateUpdate, + ApiKeyScopeTemplateUse, + ApiKeyScopeTemplateViewInsights, + ApiKeyScopeUsageEventCreate, + ApiKeyScopeUsageEventRead, + ApiKeyScopeUsageEventUpdate, + ApiKeyScopeUserCreate, + ApiKeyScopeUserDelete, + ApiKeyScopeUserRead, + ApiKeyScopeUserReadPersonal, + ApiKeyScopeUserUpdate, + ApiKeyScopeUserUpdatePersonal, + ApiKeyScopeUserSecretCreate, + ApiKeyScopeUserSecretDelete, + ApiKeyScopeUserSecretRead, + ApiKeyScopeUserSecretUpdate, + ApiKeyScopeWebpushSubscriptionCreate, + ApiKeyScopeWebpushSubscriptionDelete, + ApiKeyScopeWebpushSubscriptionRead, + ApiKeyScopeWorkspaceApplicationConnect, + ApiKeyScopeWorkspaceCreate, + ApiKeyScopeWorkspaceCreateAgent, + ApiKeyScopeWorkspaceDelete, + ApiKeyScopeWorkspaceDeleteAgent, + ApiKeyScopeWorkspaceRead, + ApiKeyScopeWorkspaceSsh, + ApiKeyScopeWorkspaceStart, + ApiKeyScopeWorkspaceStop, + ApiKeyScopeWorkspaceUpdate, + ApiKeyScopeWorkspaceAgentDevcontainersCreate, + ApiKeyScopeWorkspaceAgentResourceMonitorCreate, + ApiKeyScopeWorkspaceAgentResourceMonitorRead, + ApiKeyScopeWorkspaceAgentResourceMonitorUpdate, + ApiKeyScopeWorkspaceDormantApplicationConnect, + ApiKeyScopeWorkspaceDormantCreate, + ApiKeyScopeWorkspaceDormantCreateAgent, + ApiKeyScopeWorkspaceDormantDelete, + ApiKeyScopeWorkspaceDormantDeleteAgent, + ApiKeyScopeWorkspaceDormantRead, + ApiKeyScopeWorkspaceDormantSsh, + ApiKeyScopeWorkspaceDormantStart, + ApiKeyScopeWorkspaceDormantStop, + ApiKeyScopeWorkspaceDormantUpdate, + ApiKeyScopeWorkspaceProxyCreate, + ApiKeyScopeWorkspaceProxyDelete, + ApiKeyScopeWorkspaceProxyRead, + ApiKeyScopeWorkspaceProxyUpdate: return true } return false @@ -71,6 +347,144 @@ func AllAPIKeyScopeValues() []APIKeyScope { return []APIKeyScope{ APIKeyScopeAll, APIKeyScopeApplicationConnect, + ApiKeyScopeAibridgeInterceptionCreate, + ApiKeyScopeAibridgeInterceptionRead, + ApiKeyScopeAibridgeInterceptionUpdate, + ApiKeyScopeApiKeyCreate, + ApiKeyScopeApiKeyDelete, + ApiKeyScopeApiKeyRead, + ApiKeyScopeApiKeyUpdate, + ApiKeyScopeAssignOrgRoleAssign, + ApiKeyScopeAssignOrgRoleCreate, + ApiKeyScopeAssignOrgRoleDelete, + ApiKeyScopeAssignOrgRoleRead, + ApiKeyScopeAssignOrgRoleUnassign, + ApiKeyScopeAssignOrgRoleUpdate, + ApiKeyScopeAssignRoleAssign, + ApiKeyScopeAssignRoleRead, + ApiKeyScopeAssignRoleUnassign, + ApiKeyScopeAuditLogCreate, + ApiKeyScopeAuditLogRead, + ApiKeyScopeConnectionLogRead, + ApiKeyScopeConnectionLogUpdate, + ApiKeyScopeCryptoKeyCreate, + ApiKeyScopeCryptoKeyDelete, + ApiKeyScopeCryptoKeyRead, + ApiKeyScopeCryptoKeyUpdate, + ApiKeyScopeDebugInfoRead, + ApiKeyScopeDeploymentConfigRead, + ApiKeyScopeDeploymentConfigUpdate, + ApiKeyScopeDeploymentStatsRead, + ApiKeyScopeFileCreate, + ApiKeyScopeFileRead, + ApiKeyScopeGroupCreate, + ApiKeyScopeGroupDelete, + ApiKeyScopeGroupRead, + ApiKeyScopeGroupUpdate, + ApiKeyScopeGroupMemberRead, + ApiKeyScopeIdpsyncSettingsRead, + ApiKeyScopeIdpsyncSettingsUpdate, + ApiKeyScopeInboxNotificationCreate, + ApiKeyScopeInboxNotificationRead, + ApiKeyScopeInboxNotificationUpdate, + ApiKeyScopeLicenseCreate, + ApiKeyScopeLicenseDelete, + ApiKeyScopeLicenseRead, + ApiKeyScopeNotificationMessageCreate, + ApiKeyScopeNotificationMessageDelete, + ApiKeyScopeNotificationMessageRead, + ApiKeyScopeNotificationMessageUpdate, + ApiKeyScopeNotificationPreferenceRead, + ApiKeyScopeNotificationPreferenceUpdate, + ApiKeyScopeNotificationTemplateRead, + ApiKeyScopeNotificationTemplateUpdate, + ApiKeyScopeOauth2AppCreate, + ApiKeyScopeOauth2AppDelete, + ApiKeyScopeOauth2AppRead, + ApiKeyScopeOauth2AppUpdate, + ApiKeyScopeOauth2AppCodeTokenCreate, + ApiKeyScopeOauth2AppCodeTokenDelete, + ApiKeyScopeOauth2AppCodeTokenRead, + ApiKeyScopeOauth2AppSecretCreate, + ApiKeyScopeOauth2AppSecretDelete, + ApiKeyScopeOauth2AppSecretRead, + ApiKeyScopeOauth2AppSecretUpdate, + ApiKeyScopeOrganizationCreate, + ApiKeyScopeOrganizationDelete, + ApiKeyScopeOrganizationRead, + ApiKeyScopeOrganizationUpdate, + ApiKeyScopeOrganizationMemberCreate, + ApiKeyScopeOrganizationMemberDelete, + ApiKeyScopeOrganizationMemberRead, + ApiKeyScopeOrganizationMemberUpdate, + ApiKeyScopePrebuiltWorkspaceDelete, + ApiKeyScopePrebuiltWorkspaceUpdate, + ApiKeyScopeProvisionerDaemonCreate, + ApiKeyScopeProvisionerDaemonDelete, + ApiKeyScopeProvisionerDaemonRead, + ApiKeyScopeProvisionerDaemonUpdate, + ApiKeyScopeProvisionerJobsCreate, + ApiKeyScopeProvisionerJobsRead, + ApiKeyScopeProvisionerJobsUpdate, + ApiKeyScopeReplicasRead, + ApiKeyScopeSystemCreate, + ApiKeyScopeSystemDelete, + ApiKeyScopeSystemRead, + ApiKeyScopeSystemUpdate, + ApiKeyScopeTailnetCoordinatorCreate, + ApiKeyScopeTailnetCoordinatorDelete, + ApiKeyScopeTailnetCoordinatorRead, + ApiKeyScopeTailnetCoordinatorUpdate, + ApiKeyScopeTemplateCreate, + ApiKeyScopeTemplateDelete, + ApiKeyScopeTemplateRead, + ApiKeyScopeTemplateUpdate, + ApiKeyScopeTemplateUse, + ApiKeyScopeTemplateViewInsights, + ApiKeyScopeUsageEventCreate, + ApiKeyScopeUsageEventRead, + ApiKeyScopeUsageEventUpdate, + ApiKeyScopeUserCreate, + ApiKeyScopeUserDelete, + ApiKeyScopeUserRead, + ApiKeyScopeUserReadPersonal, + ApiKeyScopeUserUpdate, + ApiKeyScopeUserUpdatePersonal, + ApiKeyScopeUserSecretCreate, + ApiKeyScopeUserSecretDelete, + ApiKeyScopeUserSecretRead, + ApiKeyScopeUserSecretUpdate, + ApiKeyScopeWebpushSubscriptionCreate, + ApiKeyScopeWebpushSubscriptionDelete, + ApiKeyScopeWebpushSubscriptionRead, + ApiKeyScopeWorkspaceApplicationConnect, + ApiKeyScopeWorkspaceCreate, + ApiKeyScopeWorkspaceCreateAgent, + ApiKeyScopeWorkspaceDelete, + ApiKeyScopeWorkspaceDeleteAgent, + ApiKeyScopeWorkspaceRead, + ApiKeyScopeWorkspaceSsh, + ApiKeyScopeWorkspaceStart, + ApiKeyScopeWorkspaceStop, + ApiKeyScopeWorkspaceUpdate, + ApiKeyScopeWorkspaceAgentDevcontainersCreate, + ApiKeyScopeWorkspaceAgentResourceMonitorCreate, + ApiKeyScopeWorkspaceAgentResourceMonitorRead, + ApiKeyScopeWorkspaceAgentResourceMonitorUpdate, + ApiKeyScopeWorkspaceDormantApplicationConnect, + ApiKeyScopeWorkspaceDormantCreate, + ApiKeyScopeWorkspaceDormantCreateAgent, + ApiKeyScopeWorkspaceDormantDelete, + ApiKeyScopeWorkspaceDormantDeleteAgent, + ApiKeyScopeWorkspaceDormantRead, + ApiKeyScopeWorkspaceDormantSsh, + ApiKeyScopeWorkspaceDormantStart, + ApiKeyScopeWorkspaceDormantStop, + ApiKeyScopeWorkspaceDormantUpdate, + ApiKeyScopeWorkspaceProxyCreate, + ApiKeyScopeWorkspaceProxyDelete, + ApiKeyScopeWorkspaceProxyRead, + ApiKeyScopeWorkspaceProxyUpdate, } } @@ -3009,17 +3423,18 @@ type AIBridgeUserPrompt struct { type APIKey struct { ID string `db:"id" json:"id"` // hashed_secret contains a SHA256 hash of the key secret. This is considered a secret and MUST NOT be returned from the API as it is used for API key encryption in app proxying code. - HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - LastUsed time.Time `db:"last_used" json:"last_used"` - ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - LoginType LoginType `db:"login_type" json:"login_type"` - LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` - IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` - Scope APIKeyScope `db:"scope" json:"scope"` - TokenName string `db:"token_name" json:"token_name"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + LastUsed time.Time `db:"last_used" json:"last_used"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + LoginType LoginType `db:"login_type" json:"login_type"` + LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` + IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` + TokenName string `db:"token_name" json:"token_name"` + Scopes APIKeyScopes `db:"scopes" json:"scopes"` + AllowList AllowList `db:"allow_list" json:"allow_list"` } type AuditLog struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a40bd72467..71715b7f18 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -287,7 +287,7 @@ DELETE FROM api_keys WHERE user_id = $1 AND - scope = 'application_connect'::api_key_scope + 'application_connect'::api_key_scope = ANY(scopes) ` func (q *sqlQuerier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error { @@ -337,7 +337,7 @@ func (q *sqlQuerier) ExpirePrebuildsAPIKeys(ctx context.Context, now time.Time) const getAPIKeyByID = `-- name: GetAPIKeyByID :one SELECT - id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name + id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list FROM api_keys WHERE @@ -360,15 +360,16 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + &i.Scopes, + &i.AllowList, ) return i, err } const getAPIKeyByName = `-- name: GetAPIKeyByName :one SELECT - id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name + id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list FROM api_keys WHERE @@ -399,14 +400,15 @@ func (q *sqlQuerier) GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNamePar &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + &i.Scopes, + &i.AllowList, ) return i, err } const getAPIKeysByLoginType = `-- name: GetAPIKeysByLoginType :many -SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE login_type = $1 +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list FROM api_keys WHERE login_type = $1 ` func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) { @@ -429,8 +431,9 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + &i.Scopes, + &i.AllowList, ); err != nil { return nil, err } @@ -446,7 +449,7 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT } const getAPIKeysByUserID = `-- name: GetAPIKeysByUserID :many -SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE login_type = $1 AND user_id = $2 +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list FROM api_keys WHERE login_type = $1 AND user_id = $2 ` type GetAPIKeysByUserIDParams struct { @@ -474,8 +477,9 @@ func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUse &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + &i.Scopes, + &i.AllowList, ); err != nil { return nil, err } @@ -491,7 +495,7 @@ func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUse } const getAPIKeysLastUsedAfter = `-- name: GetAPIKeysLastUsedAfter :many -SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name FROM api_keys WHERE last_used > $1 +SELECT id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list FROM api_keys WHERE last_used > $1 ` func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) { @@ -514,8 +518,9 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time. &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + &i.Scopes, + &i.AllowList, ); err != nil { return nil, err } @@ -543,7 +548,8 @@ INSERT INTO created_at, updated_at, login_type, - scope, + scopes, + allow_list, token_name ) VALUES @@ -553,22 +559,23 @@ VALUES WHEN 0 THEN 86400 ELSE $2::bigint END - , $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, scope, token_name + , $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes, allow_list ` type InsertAPIKeyParams struct { - ID string `db:"id" json:"id"` - LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` - HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` - IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - LastUsed time.Time `db:"last_used" json:"last_used"` - ExpiresAt time.Time `db:"expires_at" json:"expires_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - LoginType LoginType `db:"login_type" json:"login_type"` - Scope APIKeyScope `db:"scope" json:"scope"` - TokenName string `db:"token_name" json:"token_name"` + ID string `db:"id" json:"id"` + LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"` + HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"` + IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + LastUsed time.Time `db:"last_used" json:"last_used"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + LoginType LoginType `db:"login_type" json:"login_type"` + Scopes APIKeyScopes `db:"scopes" json:"scopes"` + AllowList AllowList `db:"allow_list" json:"allow_list"` + TokenName string `db:"token_name" json:"token_name"` } func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) { @@ -583,7 +590,8 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( arg.CreatedAt, arg.UpdatedAt, arg.LoginType, - arg.Scope, + arg.Scopes, + arg.AllowList, arg.TokenName, ) var i APIKey @@ -598,8 +606,9 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + &i.Scopes, + &i.AllowList, ) return i, err } diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 98be411ca6..a211c49e32 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -43,7 +43,8 @@ INSERT INTO created_at, updated_at, login_type, - scope, + scopes, + allow_list, token_name ) VALUES @@ -53,7 +54,7 @@ VALUES WHEN 0 THEN 86400 ELSE @lifetime_seconds::bigint END - , @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @scope, @token_name) RETURNING *; + , @hashed_secret, @ip_address, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @scopes, @allow_list, @token_name) RETURNING *; -- name: UpdateAPIKeyByID :exec UPDATE @@ -76,7 +77,7 @@ DELETE FROM api_keys WHERE user_id = $1 AND - scope = 'application_connect'::api_key_scope; + 'application_connect'::api_key_scope = ANY(scopes); -- name: DeleteAPIKeysByUserID :exec DELETE FROM diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index f23d8df2aa..702064ecf2 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -28,6 +28,12 @@ sql: emit_enum_valid_method: true emit_all_enum_values: true overrides: + - column: "api_keys.scopes" + go_type: + type: "APIKeyScopes" + - column: "api_keys.allow_list" + go_type: + type: "AllowList" - db_type: "agent_id_name_pair" go_type: type: "AgentIDNamePair" diff --git a/coderd/database/types.go b/coderd/database/types.go index 01a7cce231..b534e9e269 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -9,9 +9,11 @@ import ( "time" "github.com/google/uuid" + "github.com/lib/pq" "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" ) @@ -161,6 +163,29 @@ func (m StringMapOfInt) Value() (driver.Value, error) { type CustomRolePermissions []CustomRolePermission +// APIKeyScopes implements sql.Scanner and driver.Valuer so it can be read from +// and written to the Postgres api_key_scope[] enum array column. +func (s *APIKeyScopes) Scan(src interface{}) error { + var arr []string + if err := pq.Array(&arr).Scan(src); err != nil { + return err + } + out := make(APIKeyScopes, len(arr)) + for i, v := range arr { + out[i] = APIKeyScope(v) + } + *s = out + return nil +} + +func (s APIKeyScopes) Value() (driver.Value, error) { + arr := make([]string, len(s)) + for i, v := range s { + arr[i] = string(v) + } + return pq.Array(arr).Value() +} + func (a *CustomRolePermissions) Scan(src interface{}) error { switch v := src.(type) { case string: @@ -288,3 +313,63 @@ func ParseIP(ipStr string) pqtype.Inet { Valid: ip != nil, } } + +// AllowListTarget represents a single scope allow-list entry. +// It encodes a resource tuple (type, id) and provides helpers for +// consistent string and JSON representations across the codebase. +type AllowListTarget struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// String returns the canonical database representation "type:id". +func (t AllowListTarget) String() string { + return t.Type + ":" + t.ID +} + +// ParseAllowListTarget parses the canonical string form "type:id". +func ParseAllowListTarget(s string) (AllowListTarget, error) { + targetType, id, ok := rbac.ParseResourceAction(s) + if !ok { + return AllowListTarget{}, xerrors.Errorf("invalid allow list target: %q", s) + } + return AllowListTarget{Type: targetType, ID: id}, nil +} + +// AllowListWildcard returns the wildcard allow-list entry {"*","*"}. +func AllowListWildcard() AllowListTarget { return AllowListTarget{Type: "*", ID: "*"} } + +// AllowList is a typed wrapper around a list of AllowListTarget entries. +// It implements sql.Scanner and driver.Valuer so it can be stored in and +// loaded from a Postgres text[] column that stores each entry in the +// canonical form "type:id". +type AllowList []AllowListTarget + +// Scan implements sql.Scanner. It supports inputs that pq.Array can decode +// into []string, and then converts each element to an AllowListTarget. +func (a *AllowList) Scan(src any) error { + var raw []string + if err := pq.Array(&raw).Scan(src); err != nil { + return err + } + out := make([]AllowListTarget, len(raw)) + for i, s := range raw { + t, err := ParseAllowListTarget(s) + if err != nil { + return err + } + out[i] = t + } + *a = out + return nil +} + +// Value implements driver.Valuer by converting the list to []string using the +// canonical "type:id" form and delegating to pq.Array for encoding. +func (a AllowList) Value() (driver.Value, error) { + raw := make([]string, len(a)) + for i, t := range a { + raw[i] = t.String() + } + return pq.Array(raw).Value() +} diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 164cff6d18..b534a124dd 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -434,7 +434,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon // If the key is valid, we also fetch the user roles and status. // The roles are used for RBAC authorize checks, and the status // is to block 'suspended' users from accessing the platform. - actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, rbac.ScopeName(key.Scope)) + actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.Scopes) if err != nil { return write(http.StatusUnauthorized, codersdk.Response{ Message: internalErrorMessage, diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 85f3695947..c9d62cb824 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -313,7 +313,7 @@ func TestAPIKey(t *testing.T) { _, token = dbgen.APIKey(t, db, database.APIKey{ UserID: user.ID, ExpiresAt: dbtime.Now().AddDate(0, 0, 1), - Scope: database.APIKeyScopeApplicationConnect, + Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect}, }) r = httptest.NewRequest("GET", "/", nil) @@ -330,7 +330,7 @@ func TestAPIKey(t *testing.T) { })(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { // Checks that it exists on the context! apiKey := httpmw.APIKey(r) - assert.Equal(t, database.APIKeyScopeApplicationConnect, apiKey.Scope) + assert.Equal(t, database.APIKeyScopeApplicationConnect, apiKey.Scopes[0]) assertActorOk(t, r) httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{ diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 4991dbeb9c..d01c50e331 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -172,7 +172,8 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s LastUsed: dbtime.Now(), ExpiresAt: dbtime.Now().Add(time.Minute), LoginType: database.LoginTypePassword, - Scope: database.APIKeyScopeAll, + Scopes: database.APIKeyScopes{database.APIKeyScopeAll}, + AllowList: database.AllowList{database.AllowListWildcard()}, IPAddress: pqtype.Inet{ IPNet: net.IPNet{ IP: net.ParseIP("0.0.0.0"), diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 85e11cf397..76cd229632 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -66,7 +66,8 @@ func TestWorkspaceParam(t *testing.T) { LastUsed: dbtime.Now(), ExpiresAt: dbtime.Now().Add(time.Minute), LoginType: database.LoginTypePassword, - Scope: database.APIKeyScopeAll, + Scopes: database.APIKeyScopes{database.APIKeyScopeAll}, + AllowList: database.AllowList{database.AllowListWildcard()}, IPAddress: pqtype.Inet{ IPNet: net.IPNet{ IP: net.IPv4(127, 0, 0, 1), diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 08754e4219..86cba9e1c7 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -2,6 +2,7 @@ package rbac import ( "fmt" + "strings" "github.com/google/uuid" @@ -143,18 +144,73 @@ func AllowListAll() AllowListElement { return AllowListElement{ID: policy.WildcardSymbol, Type: policy.WildcardSymbol} } +// String encodes the allow list element into the canonical database representation +// "type:id". This avoids fragile manual concatenations scattered across the codebase. +func (e AllowListElement) String() string { + return e.Type + ":" + e.ID +} + func (s Scope) Expand() (Scope, error) { return s, nil } func (s Scope) Name() RoleIdentifier { - return s.Role.Identifier + return s.Identifier } func ExpandScope(scope ScopeName) (Scope, error) { - role, ok := builtinScopes[scope] + if role, ok := builtinScopes[scope]; ok { + return role, nil + } + if res, act, ok := parseLowLevelScope(scope); ok { + return expandLowLevel(res, act), nil + } + return Scope{}, xerrors.Errorf("no scope named %q", scope) +} + +// ParseResourceAction parses a scope string formatted as ":" +// and returns the resource and action components. This is the common parsing +// logic shared between RBAC and database validation. +func ParseResourceAction(scope string) (resource string, action string, ok bool) { + parts := strings.SplitN(scope, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// parseLowLevelScope parses a low-level scope name formatted as +// ":" and validates it against RBACPermissions. +// Returns the resource and action if valid. +func parseLowLevelScope(name ScopeName) (resource string, action policy.Action, ok bool) { + res, act, ok := ParseResourceAction(string(name)) if !ok { - return Scope{}, xerrors.Errorf("no scope named %q", scope) + return "", "", false + } + + def, exists := policy.RBACPermissions[res] + if !exists { + return "", "", false + } + if _, exists := def.Actions[policy.Action(act)]; !exists { + return "", "", false + } + return res, policy.Action(act), true +} + +// expandLowLevel constructs a site-only Scope with a single permission for the +// given resource and action. This mirrors how builtin scopes are represented +// but is restricted to site-level only. +func expandLowLevel(resource string, action policy.Action) Scope { + return Scope{ + Role: Role{ + Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s:%s", resource, action)}, + DisplayName: fmt.Sprintf("%s:%s", resource, action), + Site: []Permission{{ResourceType: resource, Action: action}}, + Org: map[string][]Permission{}, + User: []Permission{}, + }, + // Low-level scopes intentionally return an empty allow list. + AllowIDList: []AllowListElement{}, } - return role, nil } diff --git a/coderd/rbac/scopes_test.go b/coderd/rbac/scopes_test.go new file mode 100644 index 0000000000..d3c1bf8cfb --- /dev/null +++ b/coderd/rbac/scopes_test.go @@ -0,0 +1,63 @@ +package rbac_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" +) + +func TestExpandScope(t *testing.T) { + t.Parallel() + + t.Run("low_level_pairs", func(t *testing.T) { + t.Parallel() + cases := []struct { + name string + resource string + action policy.Action + }{ + {name: "workspace:start", resource: rbac.ResourceWorkspace.Type, action: policy.ActionWorkspaceStart}, + {name: "workspace:ssh", resource: rbac.ResourceWorkspace.Type, action: policy.ActionSSH}, + {name: "template:use", resource: rbac.ResourceTemplate.Type, action: policy.ActionUse}, + {name: "api_key:read", resource: rbac.ResourceApiKey.Type, action: policy.ActionRead}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s, err := rbac.ScopeName(tc.name).Expand() + require.NoError(t, err) + + // site-only single permission + require.Len(t, s.Site, 1) + require.Equal(t, tc.resource, s.Site[0].ResourceType) + require.Equal(t, tc.action, s.Site[0].Action) + require.Empty(t, s.Org) + require.Empty(t, s.User) + + require.Len(t, s.AllowIDList, 0) + }) + } + }) + + t.Run("invalid_low_level", func(t *testing.T) { + t.Parallel() + invalid := []string{ + "", // empty + "workspace:", // missing action + ":read", // missing resource + "unknown:read", // unknown resource + "workspace:bogus", // unknown action + "a:b:c", // too many parts + } + for _, name := range invalid { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := rbac.ScopeName(name).Expand() + require.Error(t, err) + }) + } + }) +} diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 504b102e9e..e8393e2fd9 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -1955,20 +1955,20 @@ func TestUserLogout(t *testing.T) { } // Create a few application_connect-scoped API keys that should be deleted. - for i := 0; i < 3; i++ { + for i := range 3 { key, _ := dbgen.APIKey(t, db, database.APIKey{ UserID: newUser.ID, - Scope: database.APIKeyScopeApplicationConnect, + Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect}, }) shouldBeDeleted[fmt.Sprintf("application_connect key owned by logout user %d", i)] = key.ID } // Create a few application_connect-scoped API keys for the admin user that // should not be deleted. - for i := 0; i < 3; i++ { + for i := range 3 { key, _ := dbgen.APIKey(t, db, database.APIKey{ UserID: firstUser.UserID, - Scope: database.APIKeyScopeApplicationConnect, + Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect}, }) shouldNotBeDeleted[fmt.Sprintf("application_connect key owned by admin user %d", i)] = key.ID } diff --git a/coderd/users.go b/coderd/users.go index d38d40a1fc..ddfde55fa6 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1570,6 +1570,12 @@ func userOrganizationIDs(ctx context.Context, api *API, user database.User) ([]u } func convertAPIKey(k database.APIKey) codersdk.APIKey { + // Derive a single scope from arrays for response compatibility. + scope := database.APIKeyScopeAll + if k.Scopes.Has(database.APIKeyScopeApplicationConnect) { + scope = database.APIKeyScopeApplicationConnect + } + return codersdk.APIKey{ ID: k.ID, UserID: k.UserID, @@ -1578,7 +1584,7 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey { CreatedAt: k.CreatedAt, UpdatedAt: k.UpdatedAt, LoginType: codersdk.LoginType(k.LoginType), - Scope: codersdk.APIKeyScope(k.Scope), + Scope: codersdk.APIKeyScope(scope), LifetimeSeconds: k.LifetimeSeconds, TokenName: k.TokenName, } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 69d85b0d67..37ed2a1c00 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -15,7 +15,7 @@ We track the following resources: | Resource | | | |----------------------------------------------------------|----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| APIKey
login, logout, register, create, delete | |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| APIKey
login, logout, register, create, delete | |
FieldTracked
allow_listfalse
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopesfalse
token_namefalse
updated_atfalse
user_idtrue
| | AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| | Group
create, write, delete | |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| | AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 0519efd72f..8cba29f2e9 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -221,7 +221,8 @@ var auditableResourcesTypes = map[any]map[string]Action{ "login_type": ActionIgnore, "lifetime_seconds": ActionIgnore, "ip_address": ActionIgnore, - "scope": ActionIgnore, + "scopes": ActionIgnore, + "allow_list": ActionIgnore, "token_name": ActionIgnore, }, &database.AuditOAuthConvertState{}: { diff --git a/scripts/generate_api_key_scope_enum/main.go b/scripts/generate_api_key_scope_enum/main.go new file mode 100644 index 0000000000..9569db77b4 --- /dev/null +++ b/scripts/generate_api_key_scope_enum/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "sort" + + "github.com/coder/coder/v2/coderd/rbac/policy" +) + +func main() { + seen := map[string]struct{}{} + var vals []string + for resource, def := range policy.RBACPermissions { + if resource == policy.WildcardSymbol { + continue + } + for action := range def.Actions { + vals = append(vals, fmt.Sprintf("%s:%s", resource, action)) + } + } + sort.Strings(vals) + for _, v := range vals { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + _, _ = fmt.Printf("ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS '%s';\n", v) + } +}