feat: implement API key scopes database migration (#19861)

Added database migration for API key scopes.

Fixes #19845
This commit is contained in:
Thomas Kosiewski
2025-09-22 19:26:51 +02:00
committed by GitHub
parent a30c30724b
commit fb0ce389a6
26 changed files with 1252 additions and 71 deletions
+2 -1
View File
@@ -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
}
+2 -2
View File
@@ -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 != "" {
+1 -1
View File
@@ -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,
}
+2 -2
View File
@@ -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()
}))
+2 -1
View File
@@ -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 {
+142 -3
View File
@@ -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.';
@@ -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.
@@ -0,0 +1,157 @@
-- Extend api_key_scope enum with low-level <resource>:<action> 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;
+87 -1
View File
@@ -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())
@@ -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)
}
+429 -14
View File
@@ -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 {
+36 -27
View File
@@ -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
}
+4 -3
View File
@@ -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
+6
View File
@@ -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"
+85
View File
@@ -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()
}
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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{
+2 -1
View File
@@ -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"),
+2 -1
View File
@@ -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),
+60 -4
View File
@@ -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 "<resource>:<action>"
// 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
// "<resource>:<action>" 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
}
+63
View File
@@ -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)
})
}
})
}
+4 -4
View File
@@ -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
}
+7 -1
View File
@@ -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,
}
+1 -1
View File
@@ -15,7 +15,7 @@ We track the following resources:
| <b>Resource<b> | | |
|----------------------------------------------------------|----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>allow_list</td><td>false</td></tr><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scopes</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
+2 -1
View File
@@ -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{}: {
@@ -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)
}
}