From 7d68b729b660531d33980424d950121182d9823f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sat, 19 Jul 2025 09:32:01 +0200 Subject: [PATCH] feat: add multiple API key scopes support with granular permissions Change-Id: I5857fd833f8114d53f575b9fa48a8e5e7dbfdb2c Signed-off-by: Thomas Kosiewski --- .claude/notes/token-scopes.md | 541 ++++++++++++++++++ cli/exp_scaletest.go | 2 +- coderd/apidoc/docs.go | 64 ++- coderd/apidoc/swagger.json | 64 ++- coderd/apikey.go | 20 +- coderd/apikey/apikey.go | 29 +- coderd/apikey/apikey_test.go | 18 +- coderd/apikey_test.go | 6 +- coderd/authorize.go | 7 +- coderd/coderdtest/authorize.go | 9 +- coderd/coderdtest/coderdtest.go | 8 +- coderd/database/dbauthz/customroles_test.go | 2 +- coderd/database/dbauthz/dbauthz.go | 26 +- coderd/database/dbauthz/dbauthz_test.go | 34 +- coderd/database/dbauthz/groupsauth_test.go | 14 +- coderd/database/dbauthz/setup_test.go | 2 +- coderd/database/dbfake/dbfake.go | 2 +- coderd/database/dbgen/dbgen.go | 6 +- coderd/database/dump.sql | 23 +- ...00354_add_multiple_api_key_scopes.down.sql | 24 + .../000354_add_multiple_api_key_scopes.up.sql | 42 ++ coderd/database/models.go | 66 ++- coderd/database/querier_test.go | 73 +-- coderd/database/queries.sql.go | 114 ++-- coderd/database/queries/apikeys.sql | 6 +- coderd/database/queries/oauth2.sql | 6 +- coderd/files/cache_test.go | 8 +- coderd/httpmw/apikey.go | 18 +- coderd/httpmw/apikey_test.go | 16 +- coderd/httpmw/authorize_test.go | 2 +- coderd/httpmw/oauth2.go | 2 +- coderd/httpmw/workspaceagent.go | 4 +- coderd/httpmw/workspaceparam_test.go | 2 +- coderd/insights_test.go | 2 +- coderd/oauth2_test.go | 4 +- coderd/oauth2provider/apps.go | 28 +- coderd/oauth2provider/registration.go | 23 +- coderd/oauth2provider/tokens.go | 8 +- coderd/presets_test.go | 4 +- coderd/rbac/astvalue.go | 14 +- coderd/rbac/authz.go | 30 +- coderd/rbac/authz_internal_test.go | 64 ++- coderd/rbac/authz_test.go | 24 +- coderd/rbac/policy.rego | 20 +- coderd/rbac/roles_internal_test.go | 47 +- coderd/rbac/roles_test.go | 10 +- coderd/rbac/scopes.go | 230 ++++++++ coderd/rbac/subject_test.go | 8 +- coderd/userauth.go | 2 +- coderd/userauth_test.go | 4 +- coderd/users.go | 8 +- coderd/workspaceapps/apptest/apptest.go | 2 +- coderd/workspaceupdates_test.go | 2 +- codersdk/apikey.go | 59 +- docs/admin/security/audit-logs.md | 4 +- docs/reference/api/schemas.md | 76 +-- docs/reference/api/users.md | 56 +- enterprise/audit/table.go | 4 +- enterprise/coderd/coderd_test.go | 2 +- enterprise/coderd/workspaces_test.go | 6 +- enterprise/tailnet/pgcoord.go | 2 +- scripts/rbac-authz/gen_input.go | 21 +- site/src/api/typesGenerated.ts | 41 +- .../pages/CreateTokenPage/CreateTokenPage.tsx | 2 +- site/src/testHelpers/entities.ts | 4 +- 65 files changed, 1612 insertions(+), 459 deletions(-) create mode 100644 .claude/notes/token-scopes.md create mode 100644 coderd/database/migrations/000354_add_multiple_api_key_scopes.down.sql create mode 100644 coderd/database/migrations/000354_add_multiple_api_key_scopes.up.sql diff --git a/.claude/notes/token-scopes.md b/.claude/notes/token-scopes.md new file mode 100644 index 0000000000000..f074997495ab3 --- /dev/null +++ b/.claude/notes/token-scopes.md @@ -0,0 +1,541 @@ +# Enhanced OAuth2 & API Key Scoping System Implementation Plan + +## Overview + +Design and implement a comprehensive multi-scope system for both OAuth2 applications and API keys that builds on Coder's existing RBAC infrastructure to provide fine-grained authorization control. + +## Current State Analysis + +### Existing Systems + +- **API Keys**: Single `scope` enum field (`all` | `application_connect`) in `api_keys` table +- **OAuth2 Apps**: Single `scope` text field in `oauth2_provider_apps` table +- **RBAC System**: 33+ resource types with specific actions, policy-based authorization using OPA +- **Built-in Scopes**: `ScopeAll`, `ScopeApplicationConnect`, `ScopeNoUserData` + +## Implementation Plan + +### Phase 1: Database Schema Migration + +#### 1.1 Extend Existing Scope Enum + +```sql +-- Extend existing api_key_scope enum (instead of creating v2) +ALTER TYPE api_key_scope ADD VALUE 'user:read'; +ALTER TYPE api_key_scope ADD VALUE 'user:write'; +ALTER TYPE api_key_scope ADD VALUE 'workspace:read'; +ALTER TYPE api_key_scope ADD VALUE 'workspace:write'; +ALTER TYPE api_key_scope ADD VALUE 'workspace:ssh'; +ALTER TYPE api_key_scope ADD VALUE 'workspace:apps'; +ALTER TYPE api_key_scope ADD VALUE 'template:read'; +ALTER TYPE api_key_scope ADD VALUE 'template:write'; +ALTER TYPE api_key_scope ADD VALUE 'organization:read'; +ALTER TYPE api_key_scope ADD VALUE 'organization:write'; +ALTER TYPE api_key_scope ADD VALUE 'audit:read'; +ALTER TYPE api_key_scope ADD VALUE 'system:read'; +ALTER TYPE api_key_scope ADD VALUE 'system:write'; +``` + +#### 1.2 API Keys Migration + +```sql +-- Add new column with enum array (reusing existing enum) +ALTER TABLE api_keys ADD COLUMN scopes api_key_scope[]; + +-- Migrate existing data +UPDATE api_keys SET scopes = ARRAY[scope] WHERE scopes IS NULL; + +-- Make non-null with default +ALTER TABLE api_keys ALTER COLUMN scopes SET NOT NULL; +ALTER TABLE api_keys ALTER COLUMN scopes SET DEFAULT '{"all"}'; + +-- Drop old single scope column +ALTER TABLE api_keys DROP COLUMN scope; +``` + +#### 1.3 OAuth2 Apps Migration + +```sql +-- Add new column with enum array (reusing existing enum) +ALTER TABLE oauth2_provider_apps ADD COLUMN scopes api_key_scope[]; + +-- Migrate existing data (split space-delimited scopes and convert to enum) +UPDATE oauth2_provider_apps SET scopes = + CASE + WHEN scope = '' THEN '{}'::api_key_scope[] + ELSE string_to_array(scope, ' ')::api_key_scope[] + END +WHERE scopes IS NULL; + +-- Make non-null with default +ALTER TABLE oauth2_provider_apps ALTER COLUMN scopes SET NOT NULL; +ALTER TABLE oauth2_provider_apps ALTER COLUMN scopes SET DEFAULT '{}'; + +-- Drop old column +ALTER TABLE oauth2_provider_apps DROP COLUMN scope; +``` + +### Phase 2: Core Scope Infrastructure + +#### 2.1 Built-in Scope Definitions with Resource Prefixes + +Using resource-based prefixes for clarity: + +- `user:read` - Read user profile information +- `user:write` - Update user profile (self-service) +- `workspace:read` - Read workspaces and workspace metadata +- `workspace:write` - Create, update, delete workspaces +- `workspace:ssh` - SSH access to workspaces +- `workspace:apps` - Connect to workspace applications +- `template:read` - Read templates and template metadata +- `template:write` - Create, update, delete templates (admin-level) +- `organization:read` - Read organization information +- `organization:write` - Manage organization resources +- `audit:read` - Read audit logs (admin-level) +- `system:read` - Read system information +- `system:write` - Manage system resources (owner-level) +- `all` - Full access (backward compatibility) +- `application_connect` - Legacy scope for backward compatibility + +#### 2.2 Enhanced Reusable Scope Building System + +Enhanced helper function with support for site and org permissions: + +```go +// Extend existing ScopeName constants +const ( + // Existing scopes (unchanged) + ScopeAll ScopeName = "all" + ScopeApplicationConnect ScopeName = "application_connect" + ScopeNoUserData ScopeName = "no_user_data" + + // New granular scopes + ScopeUserRead ScopeName = "user:read" + ScopeUserWrite ScopeName = "user:write" + ScopeWorkspaceRead ScopeName = "workspace:read" + ScopeWorkspaceWrite ScopeName = "workspace:write" + ScopeWorkspaceSSH ScopeName = "workspace:ssh" + ScopeWorkspaceApps ScopeName = "workspace:apps" + ScopeTemplateRead ScopeName = "template:read" + ScopeTemplateWrite ScopeName = "template:write" + ScopeOrganizationRead ScopeName = "organization:read" + ScopeOrganizationWrite ScopeName = "organization:write" + ScopeAuditRead ScopeName = "audit:read" + ScopeSystemRead ScopeName = "system:read" + ScopeSystemWrite ScopeName = "system:write" +) + +// Additional permissions for write scopes +type AdditionalPermissions struct { + Site map[string][]policy.Action // Site-level permissions + Org map[string][]Permission // Organization-level permissions + User []Permission // User-level permissions +} + +// Enhanced reusable function to build write scopes from read scopes +func buildWriteScopeFromRead(readScopeName ScopeName, writeScopeName ScopeName, displayName string, additionalPerms AdditionalPermissions) Scope { + // Deep copy the read scope + readScope := builtinScopes[readScopeName] + writeScope := Scope{ + Role: Role{ + Identifier: RoleIdentifier{Name: fmt.Sprintf("Scope_%s", writeScopeName)}, + DisplayName: displayName, + Site: make(Permissions), + Org: make(map[string][]Permission), + User: make([]Permission, len(readScope.Role.User)), + }, + AllowIDList: make([]string, len(readScope.AllowIDList)), + } + + // Deep copy read permissions - Site level + for resource, actions := range readScope.Role.Site { + writeScope.Role.Site[resource] = make([]policy.Action, len(actions)) + copy(writeScope.Role.Site[resource], actions) + } + + // Deep copy read permissions - Org level + for resource, perms := range readScope.Role.Org { + writeScope.Role.Org[resource] = make([]Permission, len(perms)) + copy(writeScope.Role.Org[resource], perms) + } + + // Deep copy read permissions - User level + copy(writeScope.Role.User, readScope.Role.User) + + // Deep copy AllowIDList + copy(writeScope.AllowIDList, readScope.AllowIDList) + + // Add additional site permissions + for resource, actions := range additionalPerms.Site { + if existing, exists := writeScope.Role.Site[resource]; exists { + // Merge with existing permissions (avoid duplicates) + combined := append(existing, actions...) + writeScope.Role.Site[resource] = removeDuplicateActions(combined) + } else { + writeScope.Role.Site[resource] = actions + } + } + + // Add additional org permissions + for resource, perms := range additionalPerms.Org { + if existing, exists := writeScope.Role.Org[resource]; exists { + // Merge with existing permissions (avoid duplicates) + combined := append(existing, perms...) + writeScope.Role.Org[resource] = removeDuplicatePermissions(combined) + } else { + writeScope.Role.Org[resource] = perms + } + } + + // Add additional user permissions + if len(additionalPerms.User) > 0 { + writeScope.Role.User = append(writeScope.Role.User, additionalPerms.User...) + writeScope.Role.User = removeDuplicatePermissions(writeScope.Role.User) + } + + return writeScope +} + +// Helper function to remove duplicate actions +func removeDuplicateActions(actions []policy.Action) []policy.Action { + seen := make(map[policy.Action]bool) + result := []policy.Action{} + for _, action := range actions { + if !seen[action] { + seen[action] = true + result = append(result, action) + } + } + return result +} + +// Helper function to remove duplicate permissions +func removeDuplicatePermissions(permissions []Permission) []Permission { + seen := make(map[string]bool) + result := []Permission{} + for _, perm := range permissions { + key := fmt.Sprintf("%s:%s", perm.ResourceType, perm.Action) + if !seen[key] { + seen[key] = true + result = append(result, perm) + } + } + return result +} + +// Build all scopes with read/write pairs grouped together +var builtinScopes = map[ScopeName]Scope{ + // Existing scopes (unchanged) + ScopeAll: { /* existing definition */ }, + ScopeApplicationConnect: { /* existing definition */ }, + ScopeNoUserData: { /* existing definition */ }, + + // User scopes (read + write pair) + ScopeUserRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_user:read"}, + DisplayName: "Read user profile", + Site: Permissions(map[string][]policy.Action{ + ResourceUser.Type: {policy.ActionReadPersonal}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeUserWrite: buildWriteScopeFromRead( + ScopeUserRead, + ScopeUserWrite, + "Manage user profile", + AdditionalPermissions{ + Site: map[string][]policy.Action{ + ResourceUser.Type: {policy.ActionUpdatePersonal}, + }, + Org: map[string][]Permission{}, + User: []Permission{}, + }, + ), + + // Workspace scopes (read + write pair) + ScopeWorkspaceRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_workspace:read"}, + DisplayName: "Read workspaces", + Site: Permissions(map[string][]policy.Action{ + ResourceWorkspace.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{ + ResourceWorkspace.Type: {{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeWorkspaceWrite: buildWriteScopeFromRead( + ScopeWorkspaceRead, + ScopeWorkspaceWrite, + "Manage workspaces", + AdditionalPermissions{ + Site: map[string][]policy.Action{ + ResourceWorkspace.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + }, + Org: map[string][]Permission{ + ResourceWorkspace.Type: { + {ResourceType: ResourceWorkspace.Type, Action: policy.ActionCreate}, + {ResourceType: ResourceWorkspace.Type, Action: policy.ActionUpdate}, + {ResourceType: ResourceWorkspace.Type, Action: policy.ActionDelete}, + }, + }, + User: []Permission{}, + }, + ), + + // Workspace special scopes (SSH and Apps) + ScopeWorkspaceSSH: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_workspace:ssh"}, + DisplayName: "SSH to workspaces", + Site: Permissions(map[string][]policy.Action{ + ResourceWorkspace.Type: {policy.ActionSSH}, + }), + Org: map[string][]Permission{ + ResourceWorkspace.Type: {{ResourceType: ResourceWorkspace.Type, Action: policy.ActionSSH}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeWorkspaceApps: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_workspace:apps"}, + DisplayName: "Connect to workspace applications", + Site: Permissions(map[string][]policy.Action{ + ResourceWorkspace.Type: {policy.ActionApplicationConnect}, + }), + Org: map[string][]Permission{ + ResourceWorkspace.Type: {{ResourceType: ResourceWorkspace.Type, Action: policy.ActionApplicationConnect}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + // Template scopes (read + write pair) + ScopeTemplateRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_template:read"}, + DisplayName: "Read templates", + Site: Permissions(map[string][]policy.Action{ + ResourceTemplate.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{ + ResourceTemplate.Type: {{ResourceType: ResourceTemplate.Type, Action: policy.ActionRead}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeTemplateWrite: buildWriteScopeFromRead( + ScopeTemplateRead, + ScopeTemplateWrite, + "Manage templates", + AdditionalPermissions{ + Site: map[string][]policy.Action{ + ResourceTemplate.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + }, + Org: map[string][]Permission{ + ResourceTemplate.Type: { + {ResourceType: ResourceTemplate.Type, Action: policy.ActionCreate}, + {ResourceType: ResourceTemplate.Type, Action: policy.ActionUpdate}, + {ResourceType: ResourceTemplate.Type, Action: policy.ActionDelete}, + }, + }, + User: []Permission{}, + }, + ), + + // Organization scopes (read + write pair) + ScopeOrganizationRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_organization:read"}, + DisplayName: "Read organization", + Site: Permissions(map[string][]policy.Action{ + ResourceOrganization.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{ + ResourceOrganization.Type: {{ResourceType: ResourceOrganization.Type, Action: policy.ActionRead}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeOrganizationWrite: buildWriteScopeFromRead( + ScopeOrganizationRead, + ScopeOrganizationWrite, + "Manage organization", + AdditionalPermissions{ + Site: map[string][]policy.Action{ + ResourceOrganization.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + }, + Org: map[string][]Permission{ + ResourceOrganization.Type: { + {ResourceType: ResourceOrganization.Type, Action: policy.ActionCreate}, + {ResourceType: ResourceOrganization.Type, Action: policy.ActionUpdate}, + {ResourceType: ResourceOrganization.Type, Action: policy.ActionDelete}, + }, + }, + User: []Permission{}, + }, + ), + + // Audit scopes (read only - no write needed) + ScopeAuditRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_audit:read"}, + DisplayName: "Read audit logs", + Site: Permissions(map[string][]policy.Action{ + ResourceAuditLog.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + // System scopes (read + write pair) + ScopeSystemRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_system:read"}, + DisplayName: "Read system information", + Site: Permissions(map[string][]policy.Action{ + ResourceSystem.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeSystemWrite: buildWriteScopeFromRead( + ScopeSystemRead, + ScopeSystemWrite, + "Manage system", + AdditionalPermissions{ + Site: map[string][]policy.Action{ + ResourceSystem.Type: {policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + }, + Org: map[string][]Permission{}, + User: []Permission{}, + }, + ), +} +``` + +### Phase 3: Authorization Integration + +#### 3.1 Multi-Scope Validation + +- **Scope combination**: Merge permissions from multiple scopes +- **Hierarchy support**: Higher scopes automatically include lower scope permissions +- **Validation**: Ensure scope combinations are valid and don't conflict + +#### 3.2 API & Database Layer Updates + +- **Update SDKs**: Change `APIKeyScope` to `[]APIKeyScope` in `codersdk` +- **Update database queries**: Modify OAuth2 and API key queries to handle enum arrays +- **Update audit tables**: Add scopes array support to audit logging + +### Phase 4: Backward Compatibility & API Evolution + +#### 4.1 Dual Field Support in API Responses + +```go +type APIKey struct { + ID string `json:"id"` + UserID uuid.UUID `json:"user_id"` + // ... other fields + Scopes []APIKeyScope `json:"scopes"` // New array field + Scope APIKeyScope `json:"scope"` // Legacy field for compatibility + // ... other fields +} + +// When returning API response: +func (k *APIKey) populateCompatibilityField() { + if len(k.Scopes) == 1 { + k.Scope = k.Scopes[0] + } else if len(k.Scopes) > 1 { + // Join multiple scopes with space (OAuth2 standard) + k.Scope = APIKeyScope(strings.Join(scopesToStrings(k.Scopes), " ")) + } +} +``` + +#### 4.2 API Input Handling + +- **Accept both formats**: Support both `scope` (string) and `scopes` (array) in requests +- **Automatic conversion**: Convert single scope to array internally +- **Validation**: Ensure provided scopes are valid enum values + +### Phase 5: Migration & Validation + +#### 5.1 Schema Constraints + +```sql +-- Add constraint to ensure scopes array is not empty for active tokens +ALTER TABLE api_keys ADD CONSTRAINT api_keys_scopes_not_empty + CHECK (array_length(scopes, 1) > 0); + +-- Add constraint to ensure valid scope combinations +ALTER TABLE api_keys ADD CONSTRAINT api_keys_scopes_valid + CHECK (NOT ('all' = ANY(scopes) AND array_length(scopes, 1) > 1)); +``` + +#### 5.2 Validation Logic + +- **Enum validation**: PostgreSQL automatically validates enum values +- **Combination validation**: Prevent `all` scope from being combined with others +- **Hierarchy validation**: Ensure higher scopes include lower scope permissions + +## Code Structure + +``` +coderd/rbac/ +├── scopes.go # Enhanced scope infrastructure with organized read/write pairs +└── scope_validator.go # Multi-scope validation logic + +codersdk/ +├── apikey.go # Updated with scopes array + backward compatibility +└── oauth2.go # Updated with scopes array + +coderd/database/ +├── migrations/ # Schema migration files +└── queries/ # Updated SQL queries for enum arrays +``` + +## Success Criteria + +1. **Type safety**: Enum arrays prevent invalid scope values +2. **Multi-scope support**: Both API keys and OAuth2 apps support multiple scopes +3. **Organized scope definitions**: Read/write pairs grouped together for clarity +4. **Comprehensive permission support**: Site, org, and user-level permissions in helper function +5. **Reusable scope building**: DRY principle applied to scope creation +6. **Backward compatibility**: Existing single-scope tokens continue working +7. **Permission embedding**: Higher scopes automatically include lower scope permissions +8. **Dual API support**: Both `scope` and `scopes` fields in responses +9. **Fine-grained control**: GitHub-style scope granularity for permissions +10. **Enum reuse**: Extends existing enum instead of creating new types + +## Migration Strategy + +1. **Phase 1**: Extend existing enum and migrate database schema +2. **Phase 2**: Update Go code with organized, enhanced reusable scope building system +3. **Phase 3**: Frontend updates to support multi-scope selection +4. **Phase 4**: Deprecate single-scope APIs (with warnings) +5. **Phase 5**: Remove single-scope support (future major version) + +This plan provides a well-organized, comprehensive approach to scope building with read/write pairs grouped together, full support for site, org, and user-level permissions, while maintaining type safety and full backward compatibility. diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index a844a7e8c6258..ff555d1084175 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -1232,7 +1232,7 @@ func (r *RootCmd) scaletestDashboard() *serpent.Command { name := fmt.Sprintf("dashboard-%s", usr.Username) userTokResp, err := client.CreateToken(ctx, usr.ID.String(), codersdk.CreateTokenRequest{ Lifetime: 30 * 24 * time.Hour, - Scope: "", + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll}, TokenName: fmt.Sprintf("scaletest-%d", time.Now().Unix()), }) if err != nil { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 84d459e16a16c..0ca06cc3c2bd4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11060,7 +11060,7 @@ const docTemplate = `{ "last_used", "lifetime_seconds", "login_type", - "scope", + "scopes", "token_name", "updated_at", "user_id" @@ -11097,16 +11097,12 @@ const docTemplate = `{ } ] }, - "scope": { - "enum": [ - "all", - "application_connect" - ], - "allOf": [ - { - "$ref": "#/definitions/codersdk.APIKeyScope" - } - ] + "scopes": { + "description": "New array field", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } }, "token_name": { "type": "string" @@ -11125,11 +11121,37 @@ const docTemplate = `{ "type": "string", "enum": [ "all", - "application_connect" + "application_connect", + "user:read", + "user:write", + "workspace:read", + "workspace:write", + "workspace:ssh", + "workspace:apps", + "template:read", + "template:write", + "organization:read", + "organization:write", + "audit:read", + "system:read", + "system:write" ], "x-enum-varnames": [ "APIKeyScopeAll", - "APIKeyScopeApplicationConnect" + "APIKeyScopeApplicationConnect", + "APIKeyScopeUserRead", + "APIKeyScopeUserWrite", + "APIKeyScopeWorkspaceRead", + "APIKeyScopeWorkspaceWrite", + "APIKeyScopeWorkspaceSSH", + "APIKeyScopeWorkspaceApps", + "APIKeyScopeTemplateRead", + "APIKeyScopeTemplateWrite", + "APIKeyScopeOrganizationRead", + "APIKeyScopeOrganizationWrite", + "APIKeyScopeAuditRead", + "APIKeyScopeSystemRead", + "APIKeyScopeSystemWrite" ] }, "codersdk.AddLicenseRequest": { @@ -12179,16 +12201,12 @@ const docTemplate = `{ "lifetime": { "type": "integer" }, - "scope": { - "enum": [ - "all", - "application_connect" - ], - "allOf": [ - { - "$ref": "#/definitions/codersdk.APIKeyScope" - } - ] + "scopes": { + "description": "New array field", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } }, "token_name": { "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f1bddf479da42..a13381cdc268f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9803,7 +9803,7 @@ "last_used", "lifetime_seconds", "login_type", - "scope", + "scopes", "token_name", "updated_at", "user_id" @@ -9835,13 +9835,12 @@ } ] }, - "scope": { - "enum": ["all", "application_connect"], - "allOf": [ - { - "$ref": "#/definitions/codersdk.APIKeyScope" - } - ] + "scopes": { + "description": "New array field", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } }, "token_name": { "type": "string" @@ -9858,8 +9857,40 @@ }, "codersdk.APIKeyScope": { "type": "string", - "enum": ["all", "application_connect"], - "x-enum-varnames": ["APIKeyScopeAll", "APIKeyScopeApplicationConnect"] + "enum": [ + "all", + "application_connect", + "user:read", + "user:write", + "workspace:read", + "workspace:write", + "workspace:ssh", + "workspace:apps", + "template:read", + "template:write", + "organization:read", + "organization:write", + "audit:read", + "system:read", + "system:write" + ], + "x-enum-varnames": [ + "APIKeyScopeAll", + "APIKeyScopeApplicationConnect", + "APIKeyScopeUserRead", + "APIKeyScopeUserWrite", + "APIKeyScopeWorkspaceRead", + "APIKeyScopeWorkspaceWrite", + "APIKeyScopeWorkspaceSSH", + "APIKeyScopeWorkspaceApps", + "APIKeyScopeTemplateRead", + "APIKeyScopeTemplateWrite", + "APIKeyScopeOrganizationRead", + "APIKeyScopeOrganizationWrite", + "APIKeyScopeAuditRead", + "APIKeyScopeSystemRead", + "APIKeyScopeSystemWrite" + ] }, "codersdk.AddLicenseRequest": { "type": "object", @@ -10855,13 +10886,12 @@ "lifetime": { "type": "integer" }, - "scope": { - "enum": ["all", "application_connect"], - "allOf": [ - { - "$ref": "#/definitions/codersdk.APIKeyScope" - } - ] + "scopes": { + "description": "New array field", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } }, "token_name": { "type": "string" diff --git a/coderd/apikey.go b/coderd/apikey.go index 895be440ef930..a1f3fc822cb9f 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -24,6 +24,15 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// convertAPIKeyScopesToDatabase converts SDK API key scopes to database API key scopes +func convertAPIKeyScopesToDatabase(scopes []codersdk.APIKeyScope) []database.APIKeyScope { + dbScopes := make([]database.APIKeyScope, 0, len(scopes)) + for _, scope := range scopes { + dbScopes = append(dbScopes, database.APIKeyScope(scope)) + } + return dbScopes +} + // Creates a new token API key with the given scope and lifetime. // // @Summary Create token API key @@ -56,9 +65,10 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { return } - scope := database.APIKeyScopeAll - if scope != "" { - scope = database.APIKeyScope(createToken.Scope) + // Use the scopes from the request, or default to 'all' if empty + scopes := createToken.Scopes + if len(scopes) == 0 { + scopes = []codersdk.APIKeyScope{codersdk.APIKeyScopeAll} } tokenName := namesgenerator.GetRandomName(1) @@ -71,7 +81,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { UserID: user.ID, LoginType: database.LoginTypeToken, DefaultLifetime: api.DeploymentValues.Sessions.DefaultTokenDuration.Value(), - Scope: scope, + Scopes: convertAPIKeyScopesToDatabase(scopes), // New scopes array TokenName: tokenName, } @@ -380,7 +390,7 @@ func (api *API) validateAPIKeyLifetime(ctx context.Context, userID uuid.UUID, li // getMaxTokenLifetime returns the maximum allowed token lifetime for a user. // It distinguishes between regular users and owners. func (api *API) getMaxTokenLifetime(ctx context.Context, userID uuid.UUID) (time.Duration, error) { - subject, _, err := httpmw.UserRBACSubject(ctx, api.Database, userID, rbac.ScopeAll) + subject, _, err := httpmw.UserRBACSubject(ctx, api.Database, userID, []rbac.ExpandableScope{rbac.ScopeAll}) if err != nil { return 0, xerrors.Errorf("failed to get user rbac subject: %w", err) } diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index ce6960dd53412..c88e6521b7902 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -25,7 +25,8 @@ type CreateParams struct { // Optional. ExpiresAt time.Time LifetimeSeconds int64 - Scope database.APIKeyScope + Scope database.APIKeyScope // Legacy single scope (for backward compatibility) + Scopes []database.APIKeyScope // New scopes array TokenName string RemoteAddr string } @@ -62,14 +63,24 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) bitlen := len(ip) * 8 - scope := database.APIKeyScopeAll - if params.Scope != "" { - scope = params.Scope + // Determine scopes - prioritize Scopes array, fallback to legacy Scope + var scopes []database.APIKeyScope + if len(params.Scopes) > 0 { + scopes = params.Scopes + } else { + // Fallback to legacy single scope + scope := database.APIKeyScopeAll + if params.Scope != "" { + scope = params.Scope + } + scopes = []database.APIKeyScope{scope} } - switch scope { - case database.APIKeyScopeAll, database.APIKeyScopeApplicationConnect: - default: - return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", scope) + + // Validate all scopes + for _, scope := range scopes { + if !scope.Valid() { + return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", scope) + } } token := fmt.Sprintf("%s-%s", keyID, keySecret) @@ -92,7 +103,7 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) UpdatedAt: dbtime.Now(), HashedSecret: hashed[:], LoginType: params.LoginType, - Scope: scope, + Scopes: scopes, // New scopes array TokenName: params.TokenName, }, token, nil } diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index 198ef11511b3e..a2c1f640b97f8 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -35,7 +35,7 @@ func TestGenerate(t *testing.T) { LifetimeSeconds: int64(time.Hour.Seconds()), TokenName: "hello", RemoteAddr: "1.2.3.4", - Scope: database.APIKeyScopeApplicationConnect, + Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect}, }, }, { @@ -48,7 +48,7 @@ func TestGenerate(t *testing.T) { LifetimeSeconds: int64(time.Hour.Seconds()), TokenName: "hello", RemoteAddr: "1.2.3.4", - Scope: database.APIKeyScope("test"), + Scopes: []database.APIKeyScope{database.APIKeyScope("test")}, }, fail: true, }, @@ -62,7 +62,7 @@ func TestGenerate(t *testing.T) { ExpiresAt: time.Time{}, TokenName: "hello", RemoteAddr: "1.2.3.4", - Scope: database.APIKeyScopeApplicationConnect, + Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect}, }, }, { @@ -75,7 +75,7 @@ func TestGenerate(t *testing.T) { ExpiresAt: time.Time{}, TokenName: "hello", RemoteAddr: "1.2.3.4", - Scope: database.APIKeyScopeApplicationConnect, + Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect}, }, }, { @@ -88,7 +88,7 @@ func TestGenerate(t *testing.T) { LifetimeSeconds: int64(time.Hour.Seconds()), TokenName: "hello", RemoteAddr: "", - Scope: database.APIKeyScopeApplicationConnect, + Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect}, }, }, { @@ -101,7 +101,7 @@ func TestGenerate(t *testing.T) { LifetimeSeconds: int64(time.Hour.Seconds()), TokenName: "hello", RemoteAddr: "1.2.3.4", - Scope: "", + Scopes: []database.APIKeyScope{}, }, }, } @@ -158,10 +158,10 @@ func TestGenerate(t *testing.T) { assert.Equal(t, "0.0.0.0", key.IPAddress.IPNet.IP.String()) } - if tc.params.Scope != "" { - assert.Equal(t, tc.params.Scope, key.Scope) + if len(tc.params.Scopes) > 0 { + assert.Equal(t, tc.params.Scopes, key.Scopes) } else { - assert.Equal(t, database.APIKeyScopeAll, key.Scope) + assert.Equal(t, []database.APIKeyScope{database.APIKeyScopeAll}, key.Scopes) } if tc.params.TokenName != "" { diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index dbf5a3520a6f0..58fe8008fa3de 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -47,7 +47,7 @@ func TestTokenCRUD(t *testing.T) { // expires_at should default to 30 days require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*6)) require.Less(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*8)) - require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope) + require.Equal(t, []codersdk.APIKeyScope{codersdk.APIKeyScopeAll}, keys[0].Scopes) // no update @@ -73,7 +73,7 @@ func TestTokenScoped(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ - Scope: codersdk.APIKeyScopeApplicationConnect, + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect}, }) require.NoError(t, err) require.Greater(t, len(res.Key), 2) @@ -82,7 +82,7 @@ func TestTokenScoped(t *testing.T) { require.NoError(t, err) require.EqualValues(t, len(keys), 1) require.Contains(t, res.Key, keys[0].ID) - require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect) + require.Equal(t, []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect}, keys[0].Scopes) } func TestUserSetTokenDuration(t *testing.T) { diff --git a/coderd/authorize.go b/coderd/authorize.go index 575bb5e98baf6..3f9ccef50806d 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -3,6 +3,7 @@ package coderd import ( "fmt" "net/http" + "strings" "github.com/google/uuid" "golang.org/x/xerrors" @@ -28,7 +29,7 @@ func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action slog.F("user_id", roles.ID), slog.F("username", roles), slog.F("roles", roles.SafeRoleNames()), - slog.F("scope", roles.SafeScopeName()), + slog.F("scope", strings.Join(roles.SafeScopeNames(), ",")), slog.F("route", r.URL.Path), slog.F("action", action), ) @@ -80,7 +81,7 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action policy.Action, object slog.F("roles", roles.SafeRoleNames()), slog.F("actor_id", roles.ID), slog.F("actor_name", roles), - slog.F("scope", roles.SafeScopeName()), + slog.F("scope", strings.Join(roles.SafeScopeNames(), ",")), slog.F("route", r.URL.Path), slog.F("action", action), slog.F("object", object), @@ -132,7 +133,7 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { slog.F("got_id", auth.ID), slog.F("name", auth), slog.F("roles", auth.SafeRoleNames()), - slog.F("scope", auth.SafeScopeName()), + slog.F("scope", strings.Join(auth.SafeScopeNames(), ",")), ) response := make(codersdk.AuthorizationResponse) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 68ab5a27e5a18..8047744ebef00 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -63,12 +63,17 @@ func AssertRBAC(t *testing.T, api *coderd.API, client *codersdk.Client) RBACAsse roleNames, err := roles.RoleNames() require.NoError(t, err) + scopes := make([]rbac.ExpandableScope, len(key.Scopes)) + for i, scope := range key.Scopes { + scopes[i] = rbac.ScopeName(scope) + } + return RBACAsserter{ Subject: rbac.Subject{ ID: key.UserID.String(), Roles: rbac.RoleIdentifiers(roleNames), Groups: roles.Groups, - Scope: rbac.ScopeName(key.Scope), + Scopes: scopes, }, Recorder: recorder, } @@ -472,7 +477,7 @@ func RandomRBACSubject() rbac.Subject { ID: uuid.NewString(), Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, Groups: []string{namesgenerator.GetRandomName(1)}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } } diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 7085068e97ff4..6a253ab4b37f8 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -306,8 +306,8 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can // DisableOwnerWorkspaceExec modifies the 'global' RBAC roles. Fast-fail tests if we detect this. if !options.DeploymentValues.DisableOwnerWorkspaceExec.Value() { ownerSubj := rbac.Subject{ - Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, - Scope: rbac.ScopeAll, + Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } if err := options.Authorizer.Authorize(context.Background(), ownerSubj, policy.ActionSSH, rbac.ResourceWorkspace); err != nil { if rbac.IsUnauthorizedError(err) { @@ -782,7 +782,7 @@ func AuthzUserSubject(user codersdk.User, orgID uuid.UUID) rbac.Subject { ID: user.ID.String(), Roles: roles, Groups: []string{}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } } @@ -818,7 +818,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI // the client making this user. token, err := client.CreateToken(context.Background(), user.ID.String(), codersdk.CreateTokenRequest{ Lifetime: time.Hour * 24, - Scope: codersdk.APIKeyScopeAll, + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll}, TokenName: "no-password-user-token", }) require.NoError(t, err) diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go index 54541d4670c2c..b983a124cf12f 100644 --- a/coderd/database/dbauthz/customroles_test.go +++ b/coderd/database/dbauthz/customroles_test.go @@ -30,7 +30,7 @@ func TestInsertCustomRoles(t *testing.T) { ID: userID.String(), Roles: roles, Groups: nil, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 441a470ed241a..a230b5d9f9a60 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -218,7 +218,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() subjectAutostart = rbac.Subject{ @@ -243,7 +243,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() // See reaper package. @@ -265,7 +265,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() // See cryptokeys package. @@ -284,7 +284,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() // See cryptokeys package. @@ -303,7 +303,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() subjectConnectionLogger = rbac.Subject{ @@ -321,7 +321,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() subjectNotifier = rbac.Subject{ @@ -342,7 +342,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() subjectResourceMonitor = rbac.Subject{ @@ -361,7 +361,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() subjectSubAgentAPI = func(userID uuid.UUID, orgID uuid.UUID) rbac.Subject { @@ -382,7 +382,7 @@ var ( }), }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() } @@ -423,7 +423,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() subjectSystemReadProvisionerDaemons = rbac.Subject{ @@ -441,7 +441,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() subjectPrebuildsOrchestrator = rbac.Subject{ @@ -489,7 +489,7 @@ var ( }), }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() subjectFileReader = rbac.Subject{ @@ -508,7 +508,7 @@ var ( User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() ) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 393c06596db73..13250435ff777 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -89,7 +89,7 @@ func TestInTX(t *testing.T) { ID: uuid.NewString(), Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, Groups: []string{}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } u := dbgen.User(t, db, database.User{}) o := dbgen.Organization(t, db, database.Organization{}) @@ -162,7 +162,7 @@ func TestDBAuthzRecursive(t *testing.T) { ID: uuid.NewString(), Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, Groups: []string{}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } for i := 0; i < reflect.TypeOf(q).NumMethod(); i++ { var ins []reflect.Value @@ -261,7 +261,7 @@ func (s *MethodTestSuite) TestAPIKey() { check.Args(database.InsertAPIKeyParams{ UserID: u.ID, LoginType: database.LoginTypePassword, - Scope: database.APIKeyScopeAll, + Scopes: []database.APIKeyScope{database.APIKeyScopeAll}, IPAddress: defaultIPAddress(), }).Asserts(rbac.ResourceApiKey.WithOwner(u.ID.String()), policy.ActionCreate) })) @@ -278,7 +278,7 @@ func (s *MethodTestSuite) TestAPIKey() { s.Run("DeleteApplicationConnectAPIKeysByUserID", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) a, _ := dbgen.APIKey(s.T(), db, database.APIKey{ - Scope: database.APIKeyScopeApplicationConnect, + Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect}, }) check.Args(a.UserID).Asserts(rbac.ResourceApiKey.WithOwner(a.UserID.String()), policy.ActionDelete).Returns() })) @@ -5310,7 +5310,7 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { GrantTypes: app1.GrantTypes, ResponseTypes: app1.ResponseTypes, TokenEndpointAuthMethod: app1.TokenEndpointAuthMethod, - Scope: app1.Scope, + Scopes: app1.Scopes, Contacts: app1.Contacts, ClientUri: app1.ClientUri, LogoUri: app1.LogoUri, @@ -5340,7 +5340,7 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { GrantTypes: app2.GrantTypes, ResponseTypes: app2.ResponseTypes, TokenEndpointAuthMethod: app2.TokenEndpointAuthMethod, - Scope: app2.Scope, + Scopes: app2.Scopes, Contacts: app2.Contacts, ClientUri: app2.ClientUri, LogoUri: app2.LogoUri, @@ -5380,7 +5380,7 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { GrantTypes: app.GrantTypes, ResponseTypes: app.ResponseTypes, TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, - Scope: app.Scope, + Scopes: app.Scopes, Contacts: app.Contacts, ClientUri: app.ClientUri, LogoUri: app.LogoUri, @@ -5447,6 +5447,18 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, TokenEndpointAuthMethod: sql.NullString{String: "client_secret_basic", Valid: true}, + Scopes: []database.APIKeyScope{}, + Contacts: []string{}, + ClientUri: sql.NullString{}, + LogoUri: sql.NullString{}, + TosUri: sql.NullString{}, + PolicyUri: sql.NullString{}, + JwksUri: sql.NullString{}, + Jwks: pqtype.NullRawMessage{}, + SoftwareID: sql.NullString{}, + SoftwareVersion: sql.NullString{}, + RegistrationAccessToken: sql.NullString{}, + RegistrationClientUri: sql.NullString{}, }).Asserts(rbac.ResourceOauth2App, policy.ActionCreate) })) s.Run("UpdateOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) { @@ -5465,7 +5477,7 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { GrantTypes: app.GrantTypes, ResponseTypes: app.ResponseTypes, TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, - Scope: app.Scope, + Scopes: app.Scopes, Contacts: app.Contacts, ClientUri: app.ClientUri, LogoUri: app.LogoUri, @@ -5512,7 +5524,7 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { GrantTypes: app1.GrantTypes, ResponseTypes: app1.ResponseTypes, TokenEndpointAuthMethod: app1.TokenEndpointAuthMethod, - Scope: app1.Scope, + Scopes: app1.Scopes, Contacts: app1.Contacts, ClientUri: app1.ClientUri, LogoUri: app1.LogoUri, @@ -5542,7 +5554,7 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { GrantTypes: app2.GrantTypes, ResponseTypes: app2.ResponseTypes, TokenEndpointAuthMethod: app2.TokenEndpointAuthMethod, - Scope: app2.Scope, + Scopes: app2.Scopes, Contacts: app2.Contacts, ClientUri: app2.ClientUri, LogoUri: app2.LogoUri, @@ -5590,7 +5602,7 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() { GrantTypes: app.GrantTypes, ResponseTypes: app.ResponseTypes, TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, - Scope: app.Scope, + Scopes: app.Scopes, Contacts: app.Contacts, ClientUri: app.ClientUri, LogoUri: app.LogoUri, diff --git a/coderd/database/dbauthz/groupsauth_test.go b/coderd/database/dbauthz/groupsauth_test.go index 79f936e103e09..56faea69632b0 100644 --- a/coderd/database/dbauthz/groupsauth_test.go +++ b/coderd/database/dbauthz/groupsauth_test.go @@ -31,7 +31,7 @@ func TestGroupsAuth(t *testing.T) { ID: "owner", Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleOwner()}.Expand())), Groups: []string{}, - Scope: rbac.ExpandableScope(rbac.ScopeAll), + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) org := dbgen.Organization(t, db, database.Organization{}) @@ -64,7 +64,7 @@ func TestGroupsAuth(t *testing.T) { ID: "owner", Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleOwner()}.Expand())), Groups: []string{}, - Scope: rbac.ExpandableScope(rbac.ScopeAll), + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, ReadGroup: true, ReadMembers: true, @@ -76,7 +76,7 @@ func TestGroupsAuth(t *testing.T) { ID: "useradmin", Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleUserAdmin()}.Expand())), Groups: []string{}, - Scope: rbac.ExpandableScope(rbac.ScopeAll), + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, ReadGroup: true, ReadMembers: true, @@ -88,7 +88,7 @@ func TestGroupsAuth(t *testing.T) { ID: "orgadmin", Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.ScopedRoleOrgAdmin(org.ID)}.Expand())), Groups: []string{}, - Scope: rbac.ExpandableScope(rbac.ScopeAll), + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, ReadGroup: true, ReadMembers: true, @@ -100,7 +100,7 @@ func TestGroupsAuth(t *testing.T) { ID: "orgUserAdmin", Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.ScopedRoleOrgUserAdmin(org.ID)}.Expand())), Groups: []string{}, - Scope: rbac.ExpandableScope(rbac.ScopeAll), + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, ReadGroup: true, ReadMembers: true, @@ -114,7 +114,7 @@ func TestGroupsAuth(t *testing.T) { Groups: []string{ group.ID.String(), }, - Scope: rbac.ExpandableScope(rbac.ScopeAll), + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, ReadGroup: true, ReadMembers: true, @@ -127,7 +127,7 @@ func TestGroupsAuth(t *testing.T) { ID: "orgadmin", Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.ScopedRoleOrgUserAdmin(uuid.New())}.Expand())), Groups: []string{}, - Scope: rbac.ExpandableScope(rbac.ScopeAll), + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, ReadGroup: false, ReadMembers: false, diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 3fc4b06b7f69d..20f029b892462 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -127,7 +127,7 @@ func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expec ID: testActorID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleOwner()}, Groups: []string{}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } ctx := dbauthz.As(context.Background(), actor) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 98e98122e74e5..4d5d77e7f3eeb 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -29,7 +29,7 @@ var ownerCtx = dbauthz.As(context.Background(), rbac.Subject{ ID: "owner", Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleOwner()}.Expand())), Groups: []string{}, - Scope: rbac.ExpandableScope(rbac.ScopeAll), + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) type WorkspaceResponse struct { diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index c9ee6c9cce19d..48bedb1011857 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -42,7 +42,7 @@ var genCtx = dbauthz.As(context.Background(), rbac.Subject{ ID: "owner", Roles: rbac.Roles(must(rbac.RoleIdentifiers{rbac.RoleOwner()}.Expand())), Groups: []string{}, - Scope: rbac.ExpandableScope(rbac.ScopeAll), + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) func AuditLog(t testing.TB, db database.Store, seed database.AuditLog) database.AuditLog { @@ -185,7 +185,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey) (key database 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(seed.Scopes, []database.APIKeyScope{database.APIKeyScopeAll}), TokenName: takeFirst(seed.TokenName), }) require.NoError(t, err, "insert api key") @@ -1213,7 +1213,7 @@ func OAuth2ProviderApp(t testing.TB, db database.Store, seed database.OAuth2Prov GrantTypes: takeFirstSlice(seed.GrantTypes, []string{"authorization_code", "refresh_token"}), ResponseTypes: takeFirstSlice(seed.ResponseTypes, []string{"code"}), TokenEndpointAuthMethod: takeFirst(seed.TokenEndpointAuthMethod, sql.NullString{String: "client_secret_basic", Valid: true}), - Scope: takeFirst(seed.Scope, sql.NullString{}), + Scopes: takeFirstSlice(seed.Scopes, []database.APIKeyScope{}), Contacts: takeFirstSlice(seed.Contacts, []string{}), ClientUri: takeFirst(seed.ClientUri, sql.NullString{}), LogoUri: takeFirst(seed.LogoUri, sql.NullString{}), diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index d9a7b1ea71954..dd9406c0d9c60 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -12,7 +12,20 @@ CREATE TYPE agent_key_scope_enum AS ENUM ( CREATE TYPE api_key_scope AS ENUM ( 'all', - 'application_connect' + 'application_connect', + 'user:read', + 'user:write', + 'workspace:read', + 'workspace:write', + 'workspace:ssh', + 'workspace:apps', + 'template:read', + 'template:write', + 'organization:read', + 'organization:write', + 'audit:read', + 'system:read', + 'system:write' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -827,8 +840,8 @@ 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[] DEFAULT '{all}'::api_key_scope[] 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.'; @@ -1227,7 +1240,6 @@ CREATE TABLE oauth2_provider_apps ( grant_types text[] DEFAULT '{authorization_code,refresh_token}'::text[], response_types text[] DEFAULT '{code}'::text[], token_endpoint_auth_method text DEFAULT 'client_secret_basic'::text, - scope text DEFAULT ''::text, contacts text[], client_uri text, logo_uri text, @@ -1240,6 +1252,7 @@ CREATE TABLE oauth2_provider_apps ( registration_access_token text, registration_client_uri text, user_id uuid, + scopes api_key_scope[] NOT NULL, CONSTRAINT redirect_uris_not_empty_unless_client_credentials CHECK ((((grant_types = ARRAY['client_credentials'::text]) AND (cardinality(redirect_uris) >= 0)) OR ((grant_types <> ARRAY['client_credentials'::text]) AND (cardinality(redirect_uris) > 0)))) ); @@ -1261,8 +1274,6 @@ COMMENT ON COLUMN oauth2_provider_apps.response_types IS 'RFC 7591: Array of res COMMENT ON COLUMN oauth2_provider_apps.token_endpoint_auth_method IS 'RFC 7591: Authentication method for token endpoint'; -COMMENT ON COLUMN oauth2_provider_apps.scope IS 'RFC 7591: Space-delimited scope values the client can request'; - COMMENT ON COLUMN oauth2_provider_apps.contacts IS 'RFC 7591: Array of email addresses for responsible parties'; COMMENT ON COLUMN oauth2_provider_apps.client_uri IS 'RFC 7591: URL of the client home page'; diff --git a/coderd/database/migrations/000354_add_multiple_api_key_scopes.down.sql b/coderd/database/migrations/000354_add_multiple_api_key_scopes.down.sql new file mode 100644 index 0000000000000..fd56375af5b10 --- /dev/null +++ b/coderd/database/migrations/000354_add_multiple_api_key_scopes.down.sql @@ -0,0 +1,24 @@ +-- Restore the old scope columns +ALTER TABLE api_keys ADD COLUMN scope api_key_scope NOT NULL DEFAULT 'all'; +ALTER TABLE oauth2_provider_apps ADD COLUMN scope text NOT NULL DEFAULT ''; + +-- Migrate data back from scopes array to single scope column +UPDATE api_keys SET scope = + CASE + WHEN array_length(scopes, 1) IS NULL OR array_length(scopes, 1) = 0 THEN 'all' + ELSE scopes[1] + END; + +UPDATE oauth2_provider_apps SET scope = + CASE + WHEN array_length(scopes, 1) IS NULL OR array_length(scopes, 1) = 0 THEN '' + ELSE array_to_string(scopes, ' ') + END; + +-- Drop the scopes array columns +ALTER TABLE api_keys DROP COLUMN scopes; +ALTER TABLE oauth2_provider_apps DROP COLUMN scopes; + +-- Note: PostgreSQL doesn't support removing enum values directly +-- This migration would require recreating the enum type entirely +-- For safety, we don't remove the new enum values diff --git a/coderd/database/migrations/000354_add_multiple_api_key_scopes.up.sql b/coderd/database/migrations/000354_add_multiple_api_key_scopes.up.sql new file mode 100644 index 0000000000000..d5292f5fd712f --- /dev/null +++ b/coderd/database/migrations/000354_add_multiple_api_key_scopes.up.sql @@ -0,0 +1,42 @@ +-- Extend existing api_key_scope enum with new granular scopes +ALTER TYPE api_key_scope ADD VALUE 'user:read'; +ALTER TYPE api_key_scope ADD VALUE 'user:write'; +ALTER TYPE api_key_scope ADD VALUE 'workspace:read'; +ALTER TYPE api_key_scope ADD VALUE 'workspace:write'; +ALTER TYPE api_key_scope ADD VALUE 'workspace:ssh'; +ALTER TYPE api_key_scope ADD VALUE 'workspace:apps'; +ALTER TYPE api_key_scope ADD VALUE 'template:read'; +ALTER TYPE api_key_scope ADD VALUE 'template:write'; +ALTER TYPE api_key_scope ADD VALUE 'organization:read'; +ALTER TYPE api_key_scope ADD VALUE 'organization:write'; +ALTER TYPE api_key_scope ADD VALUE 'audit:read'; +ALTER TYPE api_key_scope ADD VALUE 'system:read'; +ALTER TYPE api_key_scope ADD VALUE 'system:write'; + +-- Add new scopes column as enum array to api_keys table +ALTER TABLE api_keys ADD COLUMN scopes api_key_scope[]; + +-- Migrate existing data: convert single scope to array +UPDATE api_keys SET scopes = ARRAY[scope] WHERE scopes IS NULL; + +-- Make scopes column non-null with default +ALTER TABLE api_keys ALTER COLUMN scopes SET NOT NULL; +ALTER TABLE api_keys ALTER COLUMN scopes SET DEFAULT '{"all"}'; + +-- Add new scopes column as enum array to oauth2_provider_apps table +ALTER TABLE oauth2_provider_apps ADD COLUMN scopes api_key_scope[]; + +-- Migrate existing data: split space-delimited scopes and convert to enum array +UPDATE oauth2_provider_apps SET scopes = + CASE + WHEN scope = '' THEN '{}'::api_key_scope[] + ELSE string_to_array(scope, ' ')::api_key_scope[] + END +WHERE scopes IS NULL; + +-- Make scopes column non-null +ALTER TABLE oauth2_provider_apps ALTER COLUMN scopes SET NOT NULL; + +-- Remove the old scope columns as they are now replaced by the scopes array +ALTER TABLE api_keys DROP COLUMN scope; +ALTER TABLE oauth2_provider_apps DROP COLUMN scope; diff --git a/coderd/database/models.go b/coderd/database/models.go index 7eb55e8e01c7f..013da3aa4aa18 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -21,6 +21,19 @@ type APIKeyScope string const ( APIKeyScopeAll APIKeyScope = "all" APIKeyScopeApplicationConnect APIKeyScope = "application_connect" + ApiKeyScopeUserRead APIKeyScope = "user:read" + ApiKeyScopeUserWrite APIKeyScope = "user:write" + ApiKeyScopeWorkspaceRead APIKeyScope = "workspace:read" + ApiKeyScopeWorkspaceWrite APIKeyScope = "workspace:write" + ApiKeyScopeWorkspaceSsh APIKeyScope = "workspace:ssh" + ApiKeyScopeWorkspaceApps APIKeyScope = "workspace:apps" + ApiKeyScopeTemplateRead APIKeyScope = "template:read" + ApiKeyScopeTemplateWrite APIKeyScope = "template:write" + ApiKeyScopeOrganizationRead APIKeyScope = "organization:read" + ApiKeyScopeOrganizationWrite APIKeyScope = "organization:write" + ApiKeyScopeAuditRead APIKeyScope = "audit:read" + ApiKeyScopeSystemRead APIKeyScope = "system:read" + ApiKeyScopeSystemWrite APIKeyScope = "system:write" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -61,7 +74,20 @@ func (ns NullAPIKeyScope) Value() (driver.Value, error) { func (e APIKeyScope) Valid() bool { switch e { case APIKeyScopeAll, - APIKeyScopeApplicationConnect: + APIKeyScopeApplicationConnect, + ApiKeyScopeUserRead, + ApiKeyScopeUserWrite, + ApiKeyScopeWorkspaceRead, + ApiKeyScopeWorkspaceWrite, + ApiKeyScopeWorkspaceSsh, + ApiKeyScopeWorkspaceApps, + ApiKeyScopeTemplateRead, + ApiKeyScopeTemplateWrite, + ApiKeyScopeOrganizationRead, + ApiKeyScopeOrganizationWrite, + ApiKeyScopeAuditRead, + ApiKeyScopeSystemRead, + ApiKeyScopeSystemWrite: return true } return false @@ -71,6 +97,19 @@ func AllAPIKeyScopeValues() []APIKeyScope { return []APIKeyScope{ APIKeyScopeAll, APIKeyScopeApplicationConnect, + ApiKeyScopeUserRead, + ApiKeyScopeUserWrite, + ApiKeyScopeWorkspaceRead, + ApiKeyScopeWorkspaceWrite, + ApiKeyScopeWorkspaceSsh, + ApiKeyScopeWorkspaceApps, + ApiKeyScopeTemplateRead, + ApiKeyScopeTemplateWrite, + ApiKeyScopeOrganizationRead, + ApiKeyScopeOrganizationWrite, + ApiKeyScopeAuditRead, + ApiKeyScopeSystemRead, + ApiKeyScopeSystemWrite, } } @@ -2961,17 +3000,17 @@ func AllWorkspaceTransitionValues() []WorkspaceTransition { 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 []APIKeyScope `db:"scopes" json:"scopes"` } type AuditLog struct { @@ -3232,8 +3271,6 @@ type OAuth2ProviderApp struct { ResponseTypes []string `db:"response_types" json:"response_types"` // RFC 7591: Authentication method for token endpoint TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` - // RFC 7591: Space-delimited scope values the client can request - Scope sql.NullString `db:"scope" json:"scope"` // RFC 7591: Array of email addresses for responsible parties Contacts []string `db:"contacts" json:"contacts"` // RFC 7591: URL of the client home page @@ -3257,6 +3294,7 @@ type OAuth2ProviderApp struct { // RFC 7592: URI for client configuration endpoint RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"` UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Scopes []APIKeyScope `db:"scopes" json:"scopes"` } // Codes are meant to be exchanged for access tokens. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 983d2611d0cd9..9a266512f195e 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -962,7 +962,7 @@ func TestGetAuthorizedWorkspacesAndAgentsByOwnerID(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.ID, rbac.ExpandableScope(rbac.ScopeAll)) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.ID, []rbac.ExpandableScope{rbac.ScopeAll}) require.NoError(t, err) preparedUser, err := authorizer.Prepare(ctx, userSubject, policy.ActionRead, rbac.ResourceWorkspace.Type) require.NoError(t, err) @@ -971,7 +971,7 @@ func TestGetAuthorizedWorkspacesAndAgentsByOwnerID(t *testing.T) { require.NoError(t, err) require.Len(t, userRows, 0) - ownerSubject, _, err := httpmw.UserRBACSubject(ctx, db, owner.ID, rbac.ExpandableScope(rbac.ScopeAll)) + ownerSubject, _, err := httpmw.UserRBACSubject(ctx, db, owner.ID, []rbac.ExpandableScope{rbac.ScopeAll}) require.NoError(t, err) preparedOwner, err := authorizer.Prepare(ctx, ownerSubject, policy.ActionRead, rbac.ResourceWorkspace.Type) require.NoError(t, err) @@ -987,11 +987,11 @@ func TestGetAuthorizedWorkspacesAndAgentsByOwnerID(t *testing.T) { authzdb := dbauthz.New(db, authorizer, slogtest.Make(t, &slogtest.Options{}), coderdtest.AccessControlStorePointer()) - userSubject, _, err := httpmw.UserRBACSubject(ctx, authzdb, user.ID, rbac.ExpandableScope(rbac.ScopeAll)) + userSubject, _, err := httpmw.UserRBACSubject(ctx, authzdb, user.ID, []rbac.ExpandableScope{rbac.ScopeAll}) require.NoError(t, err) userCtx := dbauthz.As(ctx, userSubject) - ownerSubject, _, err := httpmw.UserRBACSubject(ctx, authzdb, owner.ID, rbac.ExpandableScope(rbac.ScopeAll)) + ownerSubject, _, err := httpmw.UserRBACSubject(ctx, authzdb, owner.ID, []rbac.ExpandableScope{rbac.ScopeAll}) require.NoError(t, err) ownerCtx := dbauthz.As(ctx, ownerSubject) @@ -1249,7 +1249,7 @@ func TestQueuePosition(t *testing.T) { jobCount := 10 jobs := []database.ProvisionerJob{} jobIDs := []uuid.UUID{} - for i := 0; i < jobCount; i++ { + for range jobCount { job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ OrganizationID: org.ID, Tags: database.StringMap{}, @@ -1652,7 +1652,7 @@ func TestAuditLogDefaultLimit(t *testing.T) { require.NoError(t, err) db := database.New(sqlDB) - for i := 0; i < 110; i++ { + for range 110 { dbgen.AuditLog(t, db, database.AuditLog{}) } @@ -1795,7 +1795,7 @@ func TestReadCustomRoles(t *testing.T) { allRoles := make([]database.CustomRole, 0) siteRoles := make([]database.CustomRole, 0) orgRoles := make([]database.CustomRole, 0) - for i := 0; i < 15; i++ { + for i := range 15 { orgID := uuid.NullUUID{ UUID: orgIDs[i%len(orgIDs)], Valid: true, @@ -2060,7 +2060,7 @@ func TestAuthorizedAuditLogs(t *testing.T) { FriendlyName: "member", ID: uuid.NewString(), Roles: rbac.Roles{memberRole}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: The user queries for audit logs @@ -2082,7 +2082,7 @@ func TestAuthorizedAuditLogs(t *testing.T) { FriendlyName: "owner", ID: uuid.NewString(), Roles: rbac.Roles{auditorRole}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: the auditor queries for audit logs @@ -2105,7 +2105,7 @@ func TestAuthorizedAuditLogs(t *testing.T) { FriendlyName: "org-auditor", ID: uuid.NewString(), Roles: rbac.Roles{orgAuditorRoles(t, orgID)}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: The auditor queries for audit logs @@ -2129,7 +2129,7 @@ func TestAuthorizedAuditLogs(t *testing.T) { FriendlyName: "org-auditor", ID: uuid.NewString(), Roles: rbac.Roles{orgAuditorRoles(t, first), orgAuditorRoles(t, second)}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: The user queries for audit logs @@ -2153,7 +2153,7 @@ func TestAuthorizedAuditLogs(t *testing.T) { FriendlyName: "org-auditor", ID: uuid.NewString(), Roles: rbac.Roles{orgAuditorRoles(t, uuid.New())}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: The user queries for audit logs @@ -2189,7 +2189,7 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { var allLogs []database.ConnectionLog db, _ := dbtestutil.NewDB(t) authz := rbac.NewAuthorizer(prometheus.NewRegistry()) - authDb := dbauthz.New(db, authz, slogtest.Make(t, &slogtest.Options{}), coderdtest.AccessControlStorePointer()) + authDB := dbauthz.New(db, authz, slogtest.Make(t, &slogtest.Options{}), coderdtest.AccessControlStorePointer()) orgA := dbfake.Organization(t, db).Do() orgB := dbfake.Organization(t, db).Do() @@ -2222,7 +2222,7 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { } for orgID, ids := range orgConnectionLogs { for _, id := range ids { - allLogs = append(allLogs, dbgen.ConnectionLog(t, authDb, database.UpsertConnectionLogParams{ + allLogs = append(allLogs, dbgen.ConnectionLog(t, authDB, database.UpsertConnectionLogParams{ WorkspaceID: wsID, WorkspaceOwnerID: user.ID, ID: id, @@ -2255,16 +2255,16 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { FriendlyName: "member", ID: uuid.NewString(), Roles: rbac.Roles{memberRole}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: The user queries for connection logs - logs, err := authDb.GetConnectionLogsOffset(memberCtx, database.GetConnectionLogsOffsetParams{}) + logs, err := authDB.GetConnectionLogsOffset(memberCtx, database.GetConnectionLogsOffsetParams{}) require.NoError(t, err) // Then: No logs returned require.Len(t, logs, 0, "no logs should be returned") // And: The count matches the number of logs returned - count, err := authDb.CountConnectionLogs(memberCtx, database.CountConnectionLogsParams{}) + count, err := authDB.CountConnectionLogs(memberCtx, database.CountConnectionLogsParams{}) require.NoError(t, err) require.EqualValues(t, len(logs), count) }) @@ -2277,16 +2277,16 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { FriendlyName: "owner", ID: uuid.NewString(), Roles: rbac.Roles{auditorRole}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: the auditor queries for connection logs - logs, err := authDb.GetConnectionLogsOffset(siteAuditorCtx, database.GetConnectionLogsOffsetParams{}) + logs, err := authDB.GetConnectionLogsOffset(siteAuditorCtx, database.GetConnectionLogsOffsetParams{}) require.NoError(t, err) // Then: All logs are returned require.ElementsMatch(t, connectionOnlyIDs(allLogs), connectionOnlyIDs(logs)) // And: The count matches the number of logs returned - count, err := authDb.CountConnectionLogs(siteAuditorCtx, database.CountConnectionLogsParams{}) + count, err := authDB.CountConnectionLogs(siteAuditorCtx, database.CountConnectionLogsParams{}) require.NoError(t, err) require.EqualValues(t, len(logs), count) }) @@ -2300,16 +2300,16 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { FriendlyName: "org-auditor", ID: uuid.NewString(), Roles: rbac.Roles{orgAuditorRoles(t, orgID)}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: The auditor queries for connection logs - logs, err := authDb.GetConnectionLogsOffset(orgAuditCtx, database.GetConnectionLogsOffsetParams{}) + logs, err := authDB.GetConnectionLogsOffset(orgAuditCtx, database.GetConnectionLogsOffsetParams{}) require.NoError(t, err) // Then: Only the logs for the organization are returned require.ElementsMatch(t, orgConnectionLogs[orgID], connectionOnlyIDs(logs)) // And: The count matches the number of logs returned - count, err := authDb.CountConnectionLogs(orgAuditCtx, database.CountConnectionLogsParams{}) + count, err := authDB.CountConnectionLogs(orgAuditCtx, database.CountConnectionLogsParams{}) require.NoError(t, err) require.EqualValues(t, len(logs), count) }) @@ -2324,16 +2324,16 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { FriendlyName: "org-auditor", ID: uuid.NewString(), Roles: rbac.Roles{orgAuditorRoles(t, first), orgAuditorRoles(t, second)}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: The user queries for connection logs - logs, err := authDb.GetConnectionLogsOffset(multiOrgAuditCtx, database.GetConnectionLogsOffsetParams{}) + logs, err := authDB.GetConnectionLogsOffset(multiOrgAuditCtx, database.GetConnectionLogsOffsetParams{}) require.NoError(t, err) // Then: All logs for both organizations are returned require.ElementsMatch(t, append(orgConnectionLogs[first], orgConnectionLogs[second]...), connectionOnlyIDs(logs)) // And: The count matches the number of logs returned - count, err := authDb.CountConnectionLogs(multiOrgAuditCtx, database.CountConnectionLogsParams{}) + count, err := authDB.CountConnectionLogs(multiOrgAuditCtx, database.CountConnectionLogsParams{}) require.NoError(t, err) require.EqualValues(t, len(logs), count) }) @@ -2346,16 +2346,16 @@ func TestGetAuthorizedConnectionLogsOffset(t *testing.T) { FriendlyName: "org-auditor", ID: uuid.NewString(), Roles: rbac.Roles{orgAuditorRoles(t, uuid.New())}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // When: The user queries for audit logs - logs, err := authDb.GetConnectionLogsOffset(userCtx, database.GetConnectionLogsOffsetParams{}) + logs, err := authDB.GetConnectionLogsOffset(userCtx, database.GetConnectionLogsOffsetParams{}) require.NoError(t, err) // Then: No logs are returned require.Len(t, logs, 0, "no logs should be returned") // And: The count matches the number of logs returned - count, err := authDb.CountConnectionLogs(userCtx, database.CountConnectionLogsParams{}) + count, err := authDB.CountConnectionLogs(userCtx, database.CountConnectionLogsParams{}) require.NoError(t, err) require.EqualValues(t, len(logs), count) }) @@ -2378,7 +2378,7 @@ func TestCountConnectionLogs(t *testing.T) { wsB := dbgen.Workspace(t, db, database.WorkspaceTable{OwnerID: userB.ID, OrganizationID: orgB.Org.ID, TemplateID: tplB.ID}) // Create logs for two different orgs. - for i := 0; i < 20; i++ { + for range 20 { dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ OrganizationID: wsA.OrganizationID, WorkspaceOwnerID: wsA.OwnerID, @@ -2386,7 +2386,7 @@ func TestCountConnectionLogs(t *testing.T) { Type: database.ConnectionTypeSsh, }) } - for i := 0; i < 10; i++ { + for range 10 { dbgen.ConnectionLog(t, db, database.UpsertConnectionLogParams{ OrganizationID: wsB.OrganizationID, WorkspaceOwnerID: wsB.OwnerID, @@ -2652,7 +2652,6 @@ func TestConnectionLogsOffsetFilters(t *testing.T) { } for _, tc := range testCases { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() logs, err := db.GetConnectionLogsOffset(ctx, tc.params) @@ -4114,15 +4113,17 @@ func TestGetUserStatusCounts(t *testing.T) { case row.Date.Before(createdAt): require.Equal(t, int64(0), row.Count) case row.Date.Before(firstTransitionTime): - if row.Status == tc.initialStatus { + switch row.Status { + case tc.initialStatus: require.Equal(t, int64(1), row.Count) - } else if row.Status == tc.targetStatus { + case tc.targetStatus: require.Equal(t, int64(0), row.Count) } case !row.Date.After(today): - if row.Status == tc.initialStatus { + switch row.Status { + case tc.initialStatus: require.Equal(t, int64(0), row.Count) - } else if row.Status == tc.targetStatus { + case tc.targetStatus: require.Equal(t, int64(1), row.Count) } default: diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7b0939527be93..31a0f265e748d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -136,7 +136,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 { @@ -146,7 +146,7 @@ func (q *sqlQuerier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context 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 FROM api_keys WHERE @@ -169,15 +169,15 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + pq.Array(&i.Scopes), ) 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 FROM api_keys WHERE @@ -208,14 +208,14 @@ func (q *sqlQuerier) GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNamePar &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + pq.Array(&i.Scopes), ) 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 FROM api_keys WHERE login_type = $1 ` func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) { @@ -238,8 +238,8 @@ func (q *sqlQuerier) GetAPIKeysByLoginType(ctx context.Context, loginType LoginT &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + pq.Array(&i.Scopes), ); err != nil { return nil, err } @@ -255,7 +255,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 FROM api_keys WHERE login_type = $1 AND user_id = $2 ` type GetAPIKeysByUserIDParams struct { @@ -283,8 +283,8 @@ func (q *sqlQuerier) GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUse &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + pq.Array(&i.Scopes), ); err != nil { return nil, err } @@ -300,7 +300,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 FROM api_keys WHERE last_used > $1 ` func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) { @@ -323,8 +323,8 @@ func (q *sqlQuerier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time. &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + pq.Array(&i.Scopes), ); err != nil { return nil, err } @@ -352,7 +352,7 @@ INSERT INTO created_at, updated_at, login_type, - scope, + scopes, token_name ) VALUES @@ -362,22 +362,22 @@ 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) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, lifetime_seconds, ip_address, token_name, scopes ` 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 []APIKeyScope `db:"scopes" json:"scopes"` + TokenName string `db:"token_name" json:"token_name"` } func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) { @@ -392,7 +392,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( arg.CreatedAt, arg.UpdatedAt, arg.LoginType, - arg.Scope, + pq.Array(arg.Scopes), arg.TokenName, ) var i APIKey @@ -407,8 +407,8 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) ( &i.LoginType, &i.LifetimeSeconds, &i.IPAddress, - &i.Scope, &i.TokenName, + pq.Array(&i.Scopes), ) return i, err } @@ -5491,7 +5491,7 @@ func (q *sqlQuerier) DeleteOAuth2ProviderDeviceCodeByID(ctx context.Context, id const getOAuth2ProviderAppByClientID = `-- name: GetOAuth2ProviderAppByClientID :one -SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps WHERE id = $1 +SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id, scopes FROM oauth2_provider_apps WHERE id = $1 ` // RFC 7591/7592 Dynamic Client Registration queries @@ -5512,7 +5512,6 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid pq.Array(&i.GrantTypes), pq.Array(&i.ResponseTypes), &i.TokenEndpointAuthMethod, - &i.Scope, pq.Array(&i.Contacts), &i.ClientUri, &i.LogoUri, @@ -5525,13 +5524,14 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + pq.Array(&i.Scopes), ) return i, err } const getOAuth2ProviderAppByID = `-- name: GetOAuth2ProviderAppByID :one SELECT - oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, + oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, oauth2_provider_apps.scopes, users.username, users.email FROM oauth2_provider_apps @@ -5553,7 +5553,6 @@ type GetOAuth2ProviderAppByIDRow struct { GrantTypes []string `db:"grant_types" json:"grant_types"` ResponseTypes []string `db:"response_types" json:"response_types"` TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` - Scope sql.NullString `db:"scope" json:"scope"` Contacts []string `db:"contacts" json:"contacts"` ClientUri sql.NullString `db:"client_uri" json:"client_uri"` LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"` @@ -5566,6 +5565,7 @@ type GetOAuth2ProviderAppByIDRow struct { RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"` RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"` UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Scopes []APIKeyScope `db:"scopes" json:"scopes"` Username sql.NullString `db:"username" json:"username"` Email sql.NullString `db:"email" json:"email"` } @@ -5587,7 +5587,6 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) pq.Array(&i.GrantTypes), pq.Array(&i.ResponseTypes), &i.TokenEndpointAuthMethod, - &i.Scope, pq.Array(&i.Contacts), &i.ClientUri, &i.LogoUri, @@ -5600,6 +5599,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + pq.Array(&i.Scopes), &i.Username, &i.Email, ) @@ -5607,7 +5607,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) } const getOAuth2ProviderAppByRegistrationToken = `-- name: GetOAuth2ProviderAppByRegistrationToken :one -SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id FROM oauth2_provider_apps WHERE registration_access_token = $1 +SELECT id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id, scopes FROM oauth2_provider_apps WHERE registration_access_token = $1 ` func (q *sqlQuerier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context, registrationAccessToken sql.NullString) (OAuth2ProviderApp, error) { @@ -5627,7 +5627,6 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context pq.Array(&i.GrantTypes), pq.Array(&i.ResponseTypes), &i.TokenEndpointAuthMethod, - &i.Scope, pq.Array(&i.Contacts), &i.ClientUri, &i.LogoUri, @@ -5640,6 +5639,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByRegistrationToken(ctx context.Context &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + pq.Array(&i.Scopes), ) return i, err } @@ -5808,7 +5808,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hash const getOAuth2ProviderApps = `-- name: GetOAuth2ProviderApps :many SELECT - oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, + oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, oauth2_provider_apps.scopes, users.username, users.email FROM oauth2_provider_apps @@ -5830,7 +5830,6 @@ type GetOAuth2ProviderAppsRow struct { GrantTypes []string `db:"grant_types" json:"grant_types"` ResponseTypes []string `db:"response_types" json:"response_types"` TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` - Scope sql.NullString `db:"scope" json:"scope"` Contacts []string `db:"contacts" json:"contacts"` ClientUri sql.NullString `db:"client_uri" json:"client_uri"` LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"` @@ -5843,6 +5842,7 @@ type GetOAuth2ProviderAppsRow struct { RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"` RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"` UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Scopes []APIKeyScope `db:"scopes" json:"scopes"` Username sql.NullString `db:"username" json:"username"` Email sql.NullString `db:"email" json:"email"` } @@ -5870,7 +5870,6 @@ func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]GetOAuth2Prov pq.Array(&i.GrantTypes), pq.Array(&i.ResponseTypes), &i.TokenEndpointAuthMethod, - &i.Scope, pq.Array(&i.Contacts), &i.ClientUri, &i.LogoUri, @@ -5883,6 +5882,7 @@ func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]GetOAuth2Prov &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + pq.Array(&i.Scopes), &i.Username, &i.Email, ); err != nil { @@ -5901,7 +5901,7 @@ func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]GetOAuth2Prov const getOAuth2ProviderAppsByOwnerID = `-- name: GetOAuth2ProviderAppsByOwnerID :many SELECT - oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, + oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, oauth2_provider_apps.scopes, users.username, users.email FROM oauth2_provider_apps @@ -5924,7 +5924,6 @@ type GetOAuth2ProviderAppsByOwnerIDRow struct { GrantTypes []string `db:"grant_types" json:"grant_types"` ResponseTypes []string `db:"response_types" json:"response_types"` TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` - Scope sql.NullString `db:"scope" json:"scope"` Contacts []string `db:"contacts" json:"contacts"` ClientUri sql.NullString `db:"client_uri" json:"client_uri"` LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"` @@ -5937,6 +5936,7 @@ type GetOAuth2ProviderAppsByOwnerIDRow struct { RegistrationAccessToken sql.NullString `db:"registration_access_token" json:"registration_access_token"` RegistrationClientUri sql.NullString `db:"registration_client_uri" json:"registration_client_uri"` UserID uuid.NullUUID `db:"user_id" json:"user_id"` + Scopes []APIKeyScope `db:"scopes" json:"scopes"` Username sql.NullString `db:"username" json:"username"` Email sql.NullString `db:"email" json:"email"` } @@ -5964,7 +5964,6 @@ func (q *sqlQuerier) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID pq.Array(&i.GrantTypes), pq.Array(&i.ResponseTypes), &i.TokenEndpointAuthMethod, - &i.Scope, pq.Array(&i.Contacts), &i.ClientUri, &i.LogoUri, @@ -5977,6 +5976,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + pq.Array(&i.Scopes), &i.Username, &i.Email, ); err != nil { @@ -5996,7 +5996,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppsByOwnerID(ctx context.Context, userID const getOAuth2ProviderAppsByUserID = `-- name: GetOAuth2ProviderAppsByUserID :many SELECT COUNT(DISTINCT oauth2_provider_app_tokens.id) as token_count, - oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.scope, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id + oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered, oauth2_provider_apps.client_id_issued_at, oauth2_provider_apps.client_secret_expires_at, oauth2_provider_apps.grant_types, oauth2_provider_apps.response_types, oauth2_provider_apps.token_endpoint_auth_method, oauth2_provider_apps.contacts, oauth2_provider_apps.client_uri, oauth2_provider_apps.logo_uri, oauth2_provider_apps.tos_uri, oauth2_provider_apps.policy_uri, oauth2_provider_apps.jwks_uri, oauth2_provider_apps.jwks, oauth2_provider_apps.software_id, oauth2_provider_apps.software_version, oauth2_provider_apps.registration_access_token, oauth2_provider_apps.registration_client_uri, oauth2_provider_apps.user_id, oauth2_provider_apps.scopes FROM oauth2_provider_app_tokens INNER JOIN oauth2_provider_app_secrets ON oauth2_provider_app_secrets.id = oauth2_provider_app_tokens.app_secret_id @@ -6037,7 +6037,6 @@ func (q *sqlQuerier) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID u pq.Array(&i.OAuth2ProviderApp.GrantTypes), pq.Array(&i.OAuth2ProviderApp.ResponseTypes), &i.OAuth2ProviderApp.TokenEndpointAuthMethod, - &i.OAuth2ProviderApp.Scope, pq.Array(&i.OAuth2ProviderApp.Contacts), &i.OAuth2ProviderApp.ClientUri, &i.OAuth2ProviderApp.LogoUri, @@ -6050,6 +6049,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID u &i.OAuth2ProviderApp.RegistrationAccessToken, &i.OAuth2ProviderApp.RegistrationClientUri, &i.OAuth2ProviderApp.UserID, + pq.Array(&i.OAuth2ProviderApp.Scopes), ); err != nil { return nil, err } @@ -6201,7 +6201,7 @@ INSERT INTO oauth2_provider_apps ( grant_types, response_types, token_endpoint_auth_method, - scope, + scopes, contacts, client_uri, logo_uri, @@ -6241,7 +6241,7 @@ INSERT INTO oauth2_provider_apps ( $24, $25, $26 -) RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id +) RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id, scopes ` type InsertOAuth2ProviderAppParams struct { @@ -6258,7 +6258,7 @@ type InsertOAuth2ProviderAppParams struct { GrantTypes []string `db:"grant_types" json:"grant_types"` ResponseTypes []string `db:"response_types" json:"response_types"` TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` - Scope sql.NullString `db:"scope" json:"scope"` + Scopes []APIKeyScope `db:"scopes" json:"scopes"` Contacts []string `db:"contacts" json:"contacts"` ClientUri sql.NullString `db:"client_uri" json:"client_uri"` LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"` @@ -6288,7 +6288,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAut pq.Array(arg.GrantTypes), pq.Array(arg.ResponseTypes), arg.TokenEndpointAuthMethod, - arg.Scope, + pq.Array(arg.Scopes), pq.Array(arg.Contacts), arg.ClientUri, arg.LogoUri, @@ -6317,7 +6317,6 @@ func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAut pq.Array(&i.GrantTypes), pq.Array(&i.ResponseTypes), &i.TokenEndpointAuthMethod, - &i.Scope, pq.Array(&i.Contacts), &i.ClientUri, &i.LogoUri, @@ -6330,6 +6329,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAut &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + pq.Array(&i.Scopes), ) return i, err } @@ -6612,7 +6612,7 @@ UPDATE oauth2_provider_apps SET grant_types = $8, response_types = $9, token_endpoint_auth_method = $10, - scope = $11, + scopes = $11, contacts = $12, client_uri = $13, logo_uri = $14, @@ -6622,7 +6622,7 @@ UPDATE oauth2_provider_apps SET jwks = $18, software_id = $19, software_version = $20 -WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id +WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id, scopes ` type UpdateOAuth2ProviderAppByClientIDParams struct { @@ -6636,7 +6636,7 @@ type UpdateOAuth2ProviderAppByClientIDParams struct { GrantTypes []string `db:"grant_types" json:"grant_types"` ResponseTypes []string `db:"response_types" json:"response_types"` TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` - Scope sql.NullString `db:"scope" json:"scope"` + Scopes []APIKeyScope `db:"scopes" json:"scopes"` Contacts []string `db:"contacts" json:"contacts"` ClientUri sql.NullString `db:"client_uri" json:"client_uri"` LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"` @@ -6660,7 +6660,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByClientID(ctx context.Context, arg pq.Array(arg.GrantTypes), pq.Array(arg.ResponseTypes), arg.TokenEndpointAuthMethod, - arg.Scope, + pq.Array(arg.Scopes), pq.Array(arg.Contacts), arg.ClientUri, arg.LogoUri, @@ -6686,7 +6686,6 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByClientID(ctx context.Context, arg pq.Array(&i.GrantTypes), pq.Array(&i.ResponseTypes), &i.TokenEndpointAuthMethod, - &i.Scope, pq.Array(&i.Contacts), &i.ClientUri, &i.LogoUri, @@ -6699,6 +6698,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByClientID(ctx context.Context, arg &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + pq.Array(&i.Scopes), ) return i, err } @@ -6715,7 +6715,7 @@ UPDATE oauth2_provider_apps SET grant_types = $9, response_types = $10, token_endpoint_auth_method = $11, - scope = $12, + scopes = $12, contacts = $13, client_uri = $14, logo_uri = $15, @@ -6725,7 +6725,7 @@ UPDATE oauth2_provider_apps SET jwks = $19, software_id = $20, software_version = $21 -WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id +WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri, user_id, scopes ` type UpdateOAuth2ProviderAppByIDParams struct { @@ -6740,7 +6740,7 @@ type UpdateOAuth2ProviderAppByIDParams struct { GrantTypes []string `db:"grant_types" json:"grant_types"` ResponseTypes []string `db:"response_types" json:"response_types"` TokenEndpointAuthMethod sql.NullString `db:"token_endpoint_auth_method" json:"token_endpoint_auth_method"` - Scope sql.NullString `db:"scope" json:"scope"` + Scopes []APIKeyScope `db:"scopes" json:"scopes"` Contacts []string `db:"contacts" json:"contacts"` ClientUri sql.NullString `db:"client_uri" json:"client_uri"` LogoUri sql.NullString `db:"logo_uri" json:"logo_uri"` @@ -6765,7 +6765,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg Update pq.Array(arg.GrantTypes), pq.Array(arg.ResponseTypes), arg.TokenEndpointAuthMethod, - arg.Scope, + pq.Array(arg.Scopes), pq.Array(arg.Contacts), arg.ClientUri, arg.LogoUri, @@ -6791,7 +6791,6 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg Update pq.Array(&i.GrantTypes), pq.Array(&i.ResponseTypes), &i.TokenEndpointAuthMethod, - &i.Scope, pq.Array(&i.Contacts), &i.ClientUri, &i.LogoUri, @@ -6804,6 +6803,7 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg Update &i.RegistrationAccessToken, &i.RegistrationClientUri, &i.UserID, + pq.Array(&i.Scopes), ) return i, err } diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index 4ff77cb469cd5..736258b105de3 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -43,7 +43,7 @@ INSERT INTO created_at, updated_at, login_type, - scope, + scopes, token_name ) VALUES @@ -53,7 +53,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, @token_name) RETURNING *; -- name: UpdateAPIKeyByID :exec UPDATE @@ -76,7 +76,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/queries/oauth2.sql b/coderd/database/queries/oauth2.sql index de21d94f3f13f..1437fe76bbcc4 100644 --- a/coderd/database/queries/oauth2.sql +++ b/coderd/database/queries/oauth2.sql @@ -41,7 +41,7 @@ INSERT INTO oauth2_provider_apps ( grant_types, response_types, token_endpoint_auth_method, - scope, + scopes, contacts, client_uri, logo_uri, @@ -95,7 +95,7 @@ UPDATE oauth2_provider_apps SET grant_types = $9, response_types = $10, token_endpoint_auth_method = $11, - scope = $12, + scopes = $12, contacts = $13, client_uri = $14, logo_uri = $15, @@ -257,7 +257,7 @@ UPDATE oauth2_provider_apps SET grant_types = $8, response_types = $9, token_endpoint_auth_method = $10, - scope = $11, + scopes = $11, contacts = $12, client_uri = $13, logo_uri = $14, diff --git a/coderd/files/cache_test.go b/coderd/files/cache_test.go index 6f8f74e74fe8e..7815f11b21fb6 100644 --- a/coderd/files/cache_test.go +++ b/coderd/files/cache_test.go @@ -137,9 +137,9 @@ func TestCacheRBAC(t *testing.T) { nobodyID := uuid.New() nobody := dbauthz.As(ctx, rbac.Subject{ - ID: nobodyID.String(), - Roles: rbac.Roles{}, - Scope: rbac.ScopeAll, + ID: nobodyID.String(), + Roles: rbac.Roles{}, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) userID := uuid.New() @@ -148,7 +148,7 @@ func TestCacheRBAC(t *testing.T) { Roles: rbac.Roles{ must(rbac.RoleByName(rbac.RoleTemplateAdmin())), }, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) //nolint:gocritic // Unit testing diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 8fb68579a91e5..001b8d127c8d3 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -434,7 +434,19 @@ 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)) + + // Convert database scopes to ExpandableScope slice + scopes := make([]rbac.ExpandableScope, len(key.Scopes)) + for i, scope := range key.Scopes { + scopes[i] = rbac.ScopeName(scope) + } + + // Use default scope if no scopes provided + if len(scopes) == 0 { + scopes = []rbac.ExpandableScope{rbac.ScopeAll} + } + + actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, scopes) if err != nil { return write(http.StatusUnauthorized, codersdk.Response{ Message: internalErrorMessage, @@ -668,7 +680,7 @@ func extractExpectedAudience(accessURL *url.URL, r *http.Request) string { // UserRBACSubject fetches a user's rbac.Subject from the database. It pulls all roles from both // site and organization scopes. It also pulls the groups, and the user's status. -func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, scope rbac.ExpandableScope) (rbac.Subject, database.UserStatus, error) { +func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, scopes []rbac.ExpandableScope) (rbac.Subject, database.UserStatus, error) { //nolint:gocritic // system needs to update user roles roles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), userID) if err != nil { @@ -693,7 +705,7 @@ func UserRBACSubject(ctx context.Context, db database.Store, userID uuid.UUID, s ID: userID.String(), Roles: rbacRoles, Groups: roles.Groups, - Scope: scope, + Scopes: scopes, }.WithCachedASTValue() return actor, roles.Status, nil } diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 85f36959476b3..682d522538242 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -51,8 +51,10 @@ func TestAPIKey(t *testing.T) { _, err := actor.Roles.Expand() assert.NoError(t, err, "actor roles ok") - _, err = actor.Scope.Expand() - assert.NoError(t, err, "actor scope ok") + for _, scope := range actor.Scopes { + _, err := scope.Expand() + assert.NoError(t, err, "actor scope ok") + } err = actor.RegoValueOk() assert.NoError(t, err, "actor rego ok") @@ -64,8 +66,10 @@ func TestAPIKey(t *testing.T) { _, err := auth.Roles.Expand() assert.NoError(t, err, "auth roles ok") - _, err = auth.Scope.Expand() - assert.NoError(t, err, "auth scope ok") + for _, scope := range auth.Scopes { + _, err = scope.Expand() + assert.NoError(t, err, "auth scope ok") + } err = auth.RegoValueOk() assert.NoError(t, err, "auth rego ok") @@ -313,7 +317,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.APIKeyScope{database.APIKeyScopeApplicationConnect}, }) r = httptest.NewRequest("GET", "/", nil) @@ -330,7 +334,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.APIKeyScope{database.APIKeyScopeApplicationConnect}, apiKey.Scopes) 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 4991dbeb9c46e..8c6ede621f1c2 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -172,7 +172,7 @@ 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.APIKeyScope{database.APIKeyScopeAll}, IPAddress: pqtype.Inet{ IPNet: net.IPNet{ IP: net.ParseIP("0.0.0.0"), diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 7e1be737ae673..cbd1eb7e1ff24 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -214,7 +214,7 @@ func OAuth2ProviderApp(r *http.Request) database.OAuth2ProviderApp { GrantTypes: appRow.GrantTypes, ResponseTypes: appRow.ResponseTypes, TokenEndpointAuthMethod: appRow.TokenEndpointAuthMethod, - Scope: appRow.Scope, + Scopes: appRow.Scopes, Contacts: appRow.Contacts, ClientUri: appRow.ClientUri, LogoUri: appRow.LogoUri, diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index 0ee231b2f5a12..6a8d9e56212af 100644 --- a/coderd/httpmw/workspaceagent.go +++ b/coderd/httpmw/workspaceagent.go @@ -113,13 +113,13 @@ func ExtractWorkspaceAgentAndLatestBuild(opts ExtractWorkspaceAgentAndLatestBuil ctx, opts.DB, row.WorkspaceTable.OwnerID, - rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{ + []rbac.ExpandableScope{rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{ WorkspaceID: row.WorkspaceTable.ID, OwnerID: row.WorkspaceTable.OwnerID, TemplateID: row.WorkspaceTable.TemplateID, VersionID: row.WorkspaceBuild.TemplateVersionID, BlockUserData: row.WorkspaceAgent.APIKeyScope == database.AgentKeyScopeEnumNoUserData, - }), + })}, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 85e11cf3975fd..bbc4f2865e1ed 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -66,7 +66,7 @@ func TestWorkspaceParam(t *testing.T) { LastUsed: dbtime.Now(), ExpiresAt: dbtime.Now().Add(time.Minute), LoginType: database.LoginTypePassword, - Scope: database.APIKeyScopeAll, + Scopes: []database.APIKeyScope{database.APIKeyScopeAll}, IPAddress: pqtype.Inet{ IPNet: net.IPNet{ IP: net.IPv4(127, 0, 0, 1), diff --git a/coderd/insights_test.go b/coderd/insights_test.go index ded030351a3b3..deff3c6c0f0a2 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -1464,7 +1464,7 @@ func TestUserActivityInsights_Golden(t *testing.T) { }) token, err := client.CreateToken(context.Background(), user.id.String(), codersdk.CreateTokenRequest{ Lifetime: time.Hour * 24, - Scope: codersdk.APIKeyScopeAll, + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll}, TokenName: "no-password-user-token", }) require.NoError(t, err) diff --git a/coderd/oauth2_test.go b/coderd/oauth2_test.go index 25c554f576365..4a7801278552f 100644 --- a/coderd/oauth2_test.go +++ b/coderd/oauth2_test.go @@ -2625,7 +2625,7 @@ func TestOAuth2DeviceAuthorizationRBAC(t *testing.T) { ID: user.ID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, Groups: []string{user.OrganizationIDs[0].String()}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // Extract the actual prefix from device code format: cdr_device_{prefix}_{secret} @@ -2661,7 +2661,7 @@ func TestOAuth2DeviceAuthorizationRBAC(t *testing.T) { ID: user.ID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, Groups: []string{user.OrganizationIDs[0].String()}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) // Extract the actual prefix from device code format: cdr_device_{prefix}_{secret} diff --git a/coderd/oauth2provider/apps.go b/coderd/oauth2provider/apps.go index 8f3a3899370c5..d100ec110fa99 100644 --- a/coderd/oauth2provider/apps.go +++ b/coderd/oauth2provider/apps.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "slices" + "strings" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" @@ -22,6 +23,29 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// parseScopeString parses a space-delimited OAuth2 scope string into APIKeyScope array +func parseScopeString(scope string) []database.APIKeyScope { + if scope == "" { + return []database.APIKeyScope{} + } + + scopeTokens := strings.Split(strings.TrimSpace(scope), " ") + scopes := make([]database.APIKeyScope, 0, len(scopeTokens)) + + for _, token := range scopeTokens { + token = strings.TrimSpace(token) + if token != "" { + // Convert to database APIKeyScope, only include valid scopes + dbScope := database.APIKeyScope(token) + if dbScope.Valid() { + scopes = append(scopes, dbScope) + } + } + } + + return scopes +} + // ListApps returns an http.HandlerFunc that handles GET /oauth2-provider/apps func ListApps(db database.Store, accessURL *url.URL, logger slog.Logger) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { @@ -170,7 +194,7 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo GrantTypes: codersdk.OAuth2ProviderGrantTypesToStrings(grantTypes), ResponseTypes: []string{string(codersdk.OAuth2ProviderResponseTypeCode)}, TokenEndpointAuthMethod: sql.NullString{String: "client_secret_post", Valid: true}, - Scope: sql.NullString{}, + Scopes: []database.APIKeyScope{}, // New scopes array (empty for now, OAuth2 apps don't specify scopes at creation) Contacts: []string{}, ClientUri: sql.NullString{}, LogoUri: sql.NullString{}, @@ -250,7 +274,7 @@ func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo GrantTypes: grantTypes, // Allow updates ResponseTypes: app.ResponseTypes, // Keep existing value TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, // Keep existing value - Scope: app.Scope, // Keep existing value + Scopes: app.Scopes, // Keep existing value Contacts: app.Contacts, // Keep existing value ClientUri: app.ClientUri, // Keep existing value LogoUri: app.LogoUri, // Keep existing value diff --git a/coderd/oauth2provider/registration.go b/coderd/oauth2provider/registration.go index dc1de1a29e161..55e574e7b15cb 100644 --- a/coderd/oauth2provider/registration.go +++ b/coderd/oauth2provider/registration.go @@ -32,6 +32,19 @@ const ( displaySecretLength = 6 // Length of visible part in UI (last 6 characters) ) +// convertScopesToStrings converts a slice of APIKeyScope to a slice of strings +func convertScopesToStrings(scopes []database.APIKeyScope) []string { + if len(scopes) == 0 { + return []string{} + } + + result := make([]string, len(scopes)) + for i, scope := range scopes { + result[i] = string(scope) + } + return result +} + // CreateDynamicClientRegistration returns an http.HandlerFunc that handles POST /oauth2/register func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { @@ -96,7 +109,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi GrantTypes: req.GrantTypes, ResponseTypes: req.ResponseTypes, TokenEndpointAuthMethod: sql.NullString{String: req.TokenEndpointAuthMethod, Valid: true}, - Scope: sql.NullString{String: req.Scope, Valid: true}, + Scopes: parseScopeString(req.Scope), // Parse scope string into array Contacts: req.Contacts, ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""}, LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""}, @@ -166,7 +179,7 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi GrantTypes: app.GrantTypes, ResponseTypes: app.ResponseTypes, TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String, - Scope: app.Scope.String, + Scope: strings.Join(convertScopesToStrings(app.Scopes), " "), Contacts: app.Contacts, RegistrationAccessToken: registrationToken, RegistrationClientURI: app.RegistrationClientUri.String, @@ -229,7 +242,7 @@ func GetClientConfiguration(db database.Store) http.HandlerFunc { GrantTypes: app.GrantTypes, ResponseTypes: app.ResponseTypes, TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String, - Scope: app.Scope.String, + Scope: strings.Join(convertScopesToStrings(app.Scopes), " "), Contacts: app.Contacts, RegistrationAccessToken: "", // RFC 7592: Not returned in GET responses for security RegistrationClientURI: app.RegistrationClientUri.String, @@ -314,7 +327,7 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger GrantTypes: req.GrantTypes, ResponseTypes: req.ResponseTypes, TokenEndpointAuthMethod: sql.NullString{String: req.TokenEndpointAuthMethod, Valid: true}, - Scope: sql.NullString{String: req.Scope, Valid: true}, + Scopes: parseScopeString(req.Scope), // Parse scope string into array Contacts: req.Contacts, ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""}, LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""}, @@ -352,7 +365,7 @@ func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger GrantTypes: updatedApp.GrantTypes, ResponseTypes: updatedApp.ResponseTypes, TokenEndpointAuthMethod: updatedApp.TokenEndpointAuthMethod.String, - Scope: updatedApp.Scope.String, + Scope: strings.Join(convertScopesToStrings(updatedApp.Scopes), " "), Contacts: updatedApp.Contacts, RegistrationAccessToken: updatedApp.RegistrationAccessToken.String, RegistrationClientURI: updatedApp.RegistrationClientUri.String, diff --git a/coderd/oauth2provider/tokens.go b/coderd/oauth2provider/tokens.go index 76830db6d6650..6ef68cf02ecd3 100644 --- a/coderd/oauth2provider/tokens.go +++ b/coderd/oauth2provider/tokens.go @@ -327,7 +327,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database } // Grab the user roles so we can perform the exchange as the user. - actor, _, err := httpmw.UserRBACSubject(ctx, db, dbCode.UserID, rbac.ScopeAll) + actor, _, err := httpmw.UserRBACSubject(ctx, db, dbCode.UserID, []rbac.ExpandableScope{rbac.ScopeAll}) if err != nil { return oauth2.Token{}, xerrors.Errorf("fetch user actor: %w", err) } @@ -428,7 +428,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut return oauth2.Token{}, err } - actor, _, err := httpmw.UserRBACSubject(ctx, db, prevKey.UserID, rbac.ScopeAll) + actor, _, err := httpmw.UserRBACSubject(ctx, db, prevKey.UserID, []rbac.ExpandableScope{rbac.ScopeAll}) if err != nil { return oauth2.Token{}, xerrors.Errorf("fetch user actor: %w", err) } @@ -579,7 +579,7 @@ func clientCredentialsGrant(ctx context.Context, db database.Store, app database } // Grab the user roles so we can perform the exchange as the user. - actor, _, err := httpmw.UserRBACSubject(ctx, db, app.UserID.UUID, rbac.ScopeAll) + actor, _, err := httpmw.UserRBACSubject(ctx, db, app.UserID.UUID, []rbac.ExpandableScope{rbac.ScopeAll}) if err != nil { return oauth2.Token{}, xerrors.Errorf("fetch user actor: %w", err) } @@ -769,7 +769,7 @@ func deviceCodeGrant(ctx context.Context, db database.Store, app database.OAuth2 } // Get user roles for authorization context - actor, _, err := httpmw.UserRBACSubject(ctx, db, dbDeviceCode.UserID.UUID, rbac.ScopeAll) + actor, _, err := httpmw.UserRBACSubject(ctx, db, dbDeviceCode.UserID.UUID, []rbac.ExpandableScope{rbac.ScopeAll}) if err != nil { return oauth2.Token{}, xerrors.Errorf("fetch user actor: %w", err) } diff --git a/coderd/presets_test.go b/coderd/presets_test.go index 99472a013600d..bf2f23c1a5dc2 100644 --- a/coderd/presets_test.go +++ b/coderd/presets_test.go @@ -110,7 +110,7 @@ func TestTemplateVersionPresets(t *testing.T) { } } - userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.UserID, rbac.ScopeAll) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.UserID, []rbac.ExpandableScope{rbac.ScopeAll}) require.NoError(t, err) userCtx := dbauthz.As(ctx, userSubject) @@ -206,7 +206,7 @@ func TestTemplateVersionPresetsDefault(t *testing.T) { } // Get presets via API - userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.UserID, rbac.ScopeAll) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, user.UserID, []rbac.ExpandableScope{rbac.ScopeAll}) require.NoError(t, err) userCtx := dbauthz.As(ctx, userSubject) diff --git a/coderd/rbac/astvalue.go b/coderd/rbac/astvalue.go index e2fcedbd439f3..8b5df3ef55414 100644 --- a/coderd/rbac/astvalue.go +++ b/coderd/rbac/astvalue.go @@ -76,9 +76,13 @@ func (s Subject) regoValue() (ast.Value, error) { return nil, xerrors.Errorf("expand roles: %w", err) } - subjScope, err := s.Scope.Expand() - if err != nil { - return nil, xerrors.Errorf("expand scope: %w", err) + subjScopes := make([]Scope, len(s.Scopes)) + for i, scope := range s.Scopes { + expanded, err := scope.Expand() + if err != nil { + return nil, xerrors.Errorf("expand scope %d: %w", i, err) + } + subjScopes[i] = expanded } subj := ast.NewObject( [2]*ast.Term{ @@ -90,8 +94,8 @@ func (s Subject) regoValue() (ast.Value, error) { ast.NewTerm(regoSlice(subjRoles)), }, [2]*ast.Term{ - ast.StringTerm("scope"), - ast.NewTerm(subjScope.regoValue()), + ast.StringTerm("scopes"), + ast.NewTerm(regoSlice(subjScopes)), }, [2]*ast.Term{ ast.StringTerm("groups"), diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index fcb6621a34cee..736939a8977b2 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -102,7 +102,7 @@ type Subject struct { ID string Roles ExpandableRoles Groups []string - Scope ExpandableScope + Scopes []ExpandableScope // cachedASTValue is the cached ast value for this subject. cachedASTValue ast.Value @@ -143,18 +143,22 @@ func (s Subject) Equal(b Subject) bool { return false } - if s.SafeScopeName() != b.SafeScopeName() { + if !slice.SameElements(s.SafeScopeNames(), b.SafeScopeNames()) { return false } return true } -// SafeScopeName prevent nil pointer dereference. -func (s Subject) SafeScopeName() string { - if s.Scope == nil { - return "no-scope" +// SafeScopeNames prevent nil pointer dereference. +func (s Subject) SafeScopeNames() []string { + if len(s.Scopes) == 0 { + return []string{"no-scope"} } - return s.Scope.Name().String() + names := make([]string, len(s.Scopes)) + for i, scope := range s.Scopes { + names[i] = scope.Name().String() + } + return names } // SafeRoleNames prevent nil pointer dereference. @@ -368,7 +372,7 @@ type authSubject struct { ID string `json:"id"` Roles []Role `json:"roles"` Groups []string `json:"groups"` - Scope Scope `json:"scope"` + Scopes []Scope `json:"scopes"` } // Authorize is the intended function to be used outside this package. @@ -416,8 +420,8 @@ func (a RegoAuthorizer) authorize(ctx context.Context, subject Subject, action p if subject.Roles == nil { return xerrors.Errorf("subject must have roles") } - if subject.Scope == nil { - return xerrors.Errorf("subject must have a scope") + if len(subject.Scopes) == 0 { + return xerrors.Errorf("subject must have scopes") } // The caller should use either 1 or the other (or none). @@ -595,8 +599,8 @@ func (a RegoAuthorizer) newPartialAuthorizer(ctx context.Context, subject Subjec if subject.Roles == nil { return nil, xerrors.Errorf("subject must have roles") } - if subject.Scope == nil { - return nil, xerrors.Errorf("subject must have a scope") + if len(subject.Scopes) == 0 { + return nil, xerrors.Errorf("subject must have scopes") } input, err := regoPartialInputValue(subject, action, objectType) @@ -768,7 +772,7 @@ func rbacTraceAttributes(actor Subject, action policy.Action, objectType string, attribute.StringSlice("subject_roles", roleStrings), attribute.Int("num_subject_roles", len(actor.SafeRoleNames())), attribute.Int("num_groups", len(actor.Groups)), - attribute.String("scope", actor.SafeScopeName()), + attribute.StringSlice("scopes", actor.SafeScopeNames()), attribute.String("action", string(action)), attribute.String("object_type", objectType), )...) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 838c7bce1c5e8..9c472801b72ef 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -58,7 +58,7 @@ func TestFilterError(t *testing.T) { ID: uuid.NewString(), Roles: RoleIdentifiers{}, Groups: []string{}, - Scope: ScopeAll, + Scopes: []ExpandableScope{ScopeAll}, } _, err := Filter(context.Background(), auth, subject, policy.ActionRead, []Object{ResourceUser, ResourceWorkspace}) @@ -81,7 +81,7 @@ func TestFilterError(t *testing.T) { RoleOwner(), }, Groups: []string{}, - Scope: ScopeAll, + Scopes: []ExpandableScope{ScopeAll}, } t.Run("SmallSet", func(t *testing.T) { @@ -252,9 +252,9 @@ func TestFilter(t *testing.T) { auth := NewAuthorizer(prometheus.NewRegistry()) - if actor.Scope == nil { + if len(actor.Scopes) == 0 { // Default to ScopeAll - actor.Scope = ScopeAll + actor.Scopes = []ExpandableScope{ScopeAll} } ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -293,7 +293,7 @@ func TestAuthorizeDomain(t *testing.T) { // orphanedUser has no organization orphanedUser := Subject{ ID: "me", - Scope: must(ExpandScope(ScopeAll)), + Scopes: []ExpandableScope{must(ExpandScope(ScopeAll))}, Groups: []string{}, Roles: Roles{ must(RoleByName(RoleMember())), @@ -308,7 +308,7 @@ func TestAuthorizeDomain(t *testing.T) { user := Subject{ ID: "me", - Scope: must(ExpandScope(ScopeAll)), + Scopes: []ExpandableScope{must(ExpandScope(ScopeAll))}, Groups: []string{allUsersGroup}, Roles: Roles{ must(RoleByName(RoleMember())), @@ -410,8 +410,8 @@ func TestAuthorizeDomain(t *testing.T) { }) user = Subject{ - ID: "me", - Scope: must(ExpandScope(ScopeAll)), + ID: "me", + Scopes: []ExpandableScope{must(ExpandScope(ScopeAll))}, Roles: Roles{{ Identifier: RoleIdentifier{Name: "deny-all"}, // List out deny permissions explicitly @@ -451,8 +451,8 @@ func TestAuthorizeDomain(t *testing.T) { }) user = Subject{ - ID: "me", - Scope: must(ExpandScope(ScopeAll)), + ID: "me", + Scopes: []ExpandableScope{must(ExpandScope(ScopeAll))}, Roles: Roles{ must(RoleByName(ScopedRoleOrgAdmin(defOrg))), must(RoleByName(RoleMember())), @@ -491,8 +491,8 @@ func TestAuthorizeDomain(t *testing.T) { }) user = Subject{ - ID: "me", - Scope: must(ExpandScope(ScopeAll)), + ID: "me", + Scopes: []ExpandableScope{must(ExpandScope(ScopeAll))}, Roles: Roles{ must(RoleByName(RoleOwner())), must(RoleByName(RoleMember())), @@ -528,8 +528,8 @@ func TestAuthorizeDomain(t *testing.T) { }) user = Subject{ - ID: "me", - Scope: must(ExpandScope(ScopeApplicationConnect)), + ID: "me", + Scopes: []ExpandableScope{must(ExpandScope(ScopeApplicationConnect))}, Roles: Roles{ must(RoleByName(ScopedRoleOrgMember(defOrg))), must(RoleByName(RoleMember())), @@ -627,8 +627,8 @@ func TestAuthorizeDomain(t *testing.T) { // In practice this is a token scope on a regular subject user = Subject{ - ID: "me", - Scope: must(ExpandScope(ScopeAll)), + ID: "me", + Scopes: []ExpandableScope{must(ExpandScope(ScopeAll))}, Roles: Roles{ { Identifier: RoleIdentifier{Name: "ReadOnlyOrgAndUser"}, @@ -720,8 +720,8 @@ func TestAuthorizeLevels(t *testing.T) { unusedID := uuid.New() user := Subject{ - ID: "me", - Scope: must(ExpandScope(ScopeAll)), + ID: "me", + Scopes: []ExpandableScope{must(ExpandScope(ScopeAll))}, Roles: Roles{ must(RoleByName(RoleOwner())), { @@ -781,8 +781,8 @@ func TestAuthorizeLevels(t *testing.T) { })) user = Subject{ - ID: "me", - Scope: must(ExpandScope(ScopeAll)), + ID: "me", + Scopes: []ExpandableScope{must(ExpandScope(ScopeAll))}, Roles: Roles{ { Identifier: RoleIdentifier{Name: "site-noise"}, @@ -846,9 +846,9 @@ func TestAuthorizeScope(t *testing.T) { defOrg := uuid.New() unusedID := uuid.New() user := Subject{ - ID: "me", - Roles: Roles{must(RoleByName(RoleOwner()))}, - Scope: must(ExpandScope(ScopeApplicationConnect)), + ID: "me", + Roles: Roles{must(RoleByName(RoleOwner()))}, + Scopes: []ExpandableScope{must(ExpandScope(ScopeApplicationConnect))}, } testAuthorize(t, "Admin_ScopeApplicationConnect", user, @@ -882,7 +882,7 @@ func TestAuthorizeScope(t *testing.T) { must(RoleByName(RoleMember())), must(RoleByName(ScopedRoleOrgMember(defOrg))), }, - Scope: must(ExpandScope(ScopeApplicationConnect)), + Scopes: []ExpandableScope{must(ExpandScope(ScopeApplicationConnect))}, } testAuthorize(t, "User_ScopeApplicationConnect", user, @@ -918,7 +918,7 @@ func TestAuthorizeScope(t *testing.T) { must(RoleByName(RoleMember())), must(RoleByName(ScopedRoleOrgMember(defOrg))), }, - Scope: Scope{ + Scopes: []ExpandableScope{Scope{ Role: Role{ Identifier: RoleIdentifier{Name: "workspace_agent"}, DisplayName: "Workspace Agent", @@ -930,7 +930,7 @@ func TestAuthorizeScope(t *testing.T) { User: []Permission{}, }, AllowIDList: []string{workspaceID.String()}, - }, + }}, } testAuthorize(t, "User_WorkspaceAgent", user, @@ -1007,7 +1007,7 @@ func TestAuthorizeScope(t *testing.T) { must(RoleByName(RoleMember())), must(RoleByName(ScopedRoleOrgMember(defOrg))), }, - Scope: Scope{ + Scopes: []ExpandableScope{Scope{ Role: Role{ Identifier: RoleIdentifier{Name: "create_workspace"}, DisplayName: "Create Workspace", @@ -1020,7 +1020,7 @@ func TestAuthorizeScope(t *testing.T) { }, // Empty string allow_list is allowed for actions like 'create' AllowIDList: []string{""}, - }, + }}, } testAuthorize(t, "CreatWorkspaceScope", user, @@ -1060,7 +1060,7 @@ func TestAuthorizeScope(t *testing.T) { must(RoleByName(RoleMember())), must(RoleByName(ScopedRoleOrgMember(defOrg))), }, - Scope: must(ScopeNoUserData.Expand()), + Scopes: []ExpandableScope{must(ScopeNoUserData.Expand())}, } // Test 1: Verify that no_user_data scope prevents accessing user data @@ -1143,13 +1143,17 @@ func testAuthorize(t *testing.T, name string, subject Subject, sets ...[]authTes authError := authorizer.Authorize(ctx, subject, a, c.resource) + scopes := make([]Scope, len(subject.Scopes)) + for i, scope := range subject.Scopes { + scopes[i] = must(scope.Expand()) + } d, _ := json.Marshal(map[string]interface{}{ // This is not perfect marshal, but it is good enough for debugging this test. "subject": authSubject{ ID: subject.ID, Roles: must(subject.Roles.Expand()), Groups: subject.Groups, - Scope: must(subject.Scope.Expand()), + Scopes: scopes, }, "object": c.resource, "action": a, diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index cd2bbb808add9..23e131398fb17 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -40,9 +40,9 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U { Name: "NoRoles", Actor: rbac.Subject{ - ID: user.String(), - Roles: rbac.RoleIdentifiers{}, - Scope: rbac.ScopeAll, + ID: user.String(), + Roles: rbac.RoleIdentifiers{}, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, }, { @@ -51,7 +51,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U // Give some extra roles that an admin might have Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember()}, ID: user.String(), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, Groups: noiseGroups, }, }, @@ -60,7 +60,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U Actor: rbac.Subject{ Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgAdmin(orgs[0]), rbac.RoleMember()}, ID: user.String(), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, Groups: noiseGroups, }, }, @@ -70,7 +70,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U // Member of 2 orgs Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.ScopedRoleOrgMember(orgs[1]), rbac.RoleMember()}, ID: user.String(), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, Groups: noiseGroups, }, }, @@ -85,7 +85,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U rbac.RoleMember(), }, ID: user.String(), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, Groups: noiseGroups, }, }, @@ -100,7 +100,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U rbac.RoleMember(), }, ID: user.String(), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, Groups: noiseGroups, }.WithCachedASTValue(), }, @@ -110,7 +110,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U // Give some extra roles that an admin might have Roles: rbac.RoleIdentifiers{rbac.ScopedRoleOrgMember(orgs[0]), rbac.RoleAuditor(), rbac.RoleOwner(), rbac.RoleMember()}, ID: user.String(), - Scope: rbac.ScopeApplicationConnect, + Scopes: []rbac.ExpandableScope{rbac.ScopeApplicationConnect}, Groups: noiseGroups, }, }, @@ -124,7 +124,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), }, ID: user.String(), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, Groups: noiseGroups, }, }, @@ -138,7 +138,7 @@ func benchmarkUserCases() (cases []benchmarkCase, users uuid.UUID, orgs []uuid.U rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin(), }, ID: user.String(), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, Groups: noiseGroups, }.WithCachedASTValue(), }, @@ -197,7 +197,7 @@ func BenchmarkRBACAuthorizeGroups(b *testing.B) { b.Run(c.Name+"GroupACL", func(b *testing.B) { userGroupAllow := uuid.NewString() c.Actor.Groups = append(c.Actor.Groups, userGroupAllow) - c.Actor.Scope = rbac.ScopeAll + c.Actor.Scopes = []rbac.ExpandableScope{rbac.ScopeAll} objects := benchmarkSetup(orgs, users, b.N, func(object rbac.Object) rbac.Object { m := map[string][]policy.Action{ // Add the user's group diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 2ee47c35c8952..b60cf001fbc47 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -81,7 +81,7 @@ default site := 0 site := site_allow(input.subject.roles) default scope_site := 0 -scope_site := site_allow([input.subject.scope]) +scope_site := site_allow(input.subject.scopes) # site_allow receives a list of roles and returns a single number: # -1 if any matching permission denies access @@ -116,7 +116,7 @@ default org := 0 org := org_allow(input.subject.roles) default scope_org := 0 -scope_org := org_allow([input.scope]) +scope_org := org_allow(input.scopes) # org_allow_set is a helper function that iterates over all orgs that the actor # is a member of. For each organization it sets the numerical allow value @@ -187,6 +187,7 @@ org_allow(roles) := num if { ]) } + # 'org_mem' is set to true if the user is an org member # If 'any_org' is set to true, use the other block to determine org membership. org_mem if { @@ -221,7 +222,7 @@ default user := 0 user := user_allow(input.subject.roles) default scope_user := 0 -scope_user := user_allow([input.scope]) +scope_user := user_allow(input.scopes) user_allow(roles) := num if { input.object.owner != "" @@ -242,15 +243,16 @@ user_allow(roles) := num if { # Scope allow_list is a list of resource IDs explicitly allowed by the scope. # If the list is '*', then all resources are allowed. scope_allow_list if { - "*" in input.subject.scope.allow_list + # If ANY scope allows all resources + scope := input.subject.scopes[_] + "*" in scope.allow_list } scope_allow_list if { - # If the wildcard is listed in the allow_list, we do not care about the - # object.id. This line is included to prevent partial compilations from - # ever needing to include the object.id. - not "*" in input.subject.scope.allow_list - input.object.id in input.subject.scope.allow_list + # If ANY scope explicitly allows this resource + scope := input.subject.scopes[_] + not "*" in scope.allow_list + input.object.id in scope.allow_list } # ------------------- diff --git a/coderd/rbac/roles_internal_test.go b/coderd/rbac/roles_internal_test.go index f851280a0417e..b37eb7a17cd9d 100644 --- a/coderd/rbac/roles_internal_test.go +++ b/coderd/rbac/roles_internal_test.go @@ -22,7 +22,7 @@ func BenchmarkRBACValueAllocation(b *testing.B) { actor := Subject{ Roles: RoleIdentifiers{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}, ID: uuid.NewString(), - Scope: ScopeAll, + Scopes: []ExpandableScope{ScopeAll}, Groups: []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}, } obj := ResourceTemplate. @@ -38,11 +38,16 @@ func BenchmarkRBACValueAllocation(b *testing.B) { uuid.NewString(): {policy.ActionRead, policy.ActionCreate}, }) + scopes := make([]Scope, len(actor.Scopes)) + for i, scope := range actor.Scopes { + scopes[i] = must(scope.Expand()) + } + jsonSubject := authSubject{ ID: actor.ID, Roles: must(actor.Roles.Expand()), Groups: actor.Groups, - Scope: must(actor.Scope.Expand()), + Scopes: scopes, } b.Run("ManualRegoValue", func(b *testing.B) { @@ -84,7 +89,7 @@ func TestRegoInputValue(t *testing.T) { actor := Subject{ Roles: Roles(roles), ID: uuid.NewString(), - Scope: ScopeAll, + Scopes: []ExpandableScope{ScopeAll}, Groups: []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}, } @@ -106,13 +111,18 @@ func TestRegoInputValue(t *testing.T) { t.Run("InputValue", func(t *testing.T) { t.Parallel() + scopes := make([]Scope, len(actor.Scopes)) + for i, scope := range actor.Scopes { + scopes[i] = must(scope.Expand()) + } + // This is the input that would be passed to the rego policy. - jsonInput := map[string]interface{}{ + jsonInput := map[string]any{ "subject": authSubject{ ID: actor.ID, Roles: must(actor.Roles.Expand()), Groups: actor.Groups, - Scope: must(actor.Scope.Expand()), + Scopes: scopes, }, "action": action, "object": obj, @@ -137,13 +147,18 @@ func TestRegoInputValue(t *testing.T) { t.Run("PartialInputValue", func(t *testing.T) { t.Parallel() + scopes := make([]Scope, len(actor.Scopes)) + for i, scope := range actor.Scopes { + scopes[i] = must(scope.Expand()) + } + // This is the input that would be passed to the rego policy. jsonInput := map[string]interface{}{ "subject": authSubject{ ID: actor.ID, Roles: must(actor.Roles.Expand()), Groups: actor.Groups, - Scope: must(actor.Scope.Expand()), + Scopes: scopes, }, "action": action, "object": map[string]interface{}{ @@ -190,19 +205,25 @@ func ignoreNames(t *testing.T, value ast.Value) { obj.Insert(ast.StringTerm("display_name"), ast.StringTerm("ignore")) }) - // Override the names of the scope role + // Override the names of the scope roles (now an array) ref = ast.Ref{ ast.StringTerm("subject"), - ast.StringTerm("scope"), + ast.StringTerm("scopes"), } - scope, err := value.Find(ref) + scopes, err := value.Find(ref) require.NoError(t, err) - scopeObj, ok := scope.(ast.Object) - require.True(t, ok, "scope is expected to be an object") + scopesArray, ok := scopes.(*ast.Array) + require.True(t, ok, "scopes is expected to be an array") - scopeObj.Insert(ast.StringTerm("name"), ast.StringTerm("ignore")) - scopeObj.Insert(ast.StringTerm("display_name"), ast.StringTerm("ignore")) + // Override names for each scope in the array + for i := 0; i < scopesArray.Len(); i++ { + scopeObj, ok := scopesArray.Elem(i).Value.(ast.Object) + require.True(t, ok, "scope element is expected to be an object") + + scopeObj.Insert(ast.StringTerm("name"), ast.StringTerm("ignore")) + scopeObj.Insert(ast.StringTerm("display_name"), ast.StringTerm("ignore")) + } } func TestRoleByName(t *testing.T) { diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index bcbfc612d9aa1..4c6680634f8c6 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -54,9 +54,9 @@ func TestBuiltInRoles(t *testing.T) { //nolint:tparallel,paralleltest func TestOwnerExec(t *testing.T) { owner := rbac.Subject{ - ID: uuid.NewString(), - Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}, - Scope: rbac.ScopeAll, + ID: uuid.NewString(), + Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleOwner()}, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } t.Run("NoExec", func(t *testing.T) { @@ -917,8 +917,8 @@ func TestRolePermissions(t *testing.T) { // TODO: scopey actor := subj.Actor // Actor is missing some fields - if actor.Scope == nil { - actor.Scope = rbac.ScopeAll + if len(actor.Scopes) == 0 { + actor.Scopes = []rbac.ExpandableScope{rbac.ScopeAll} } delete(remainingPermissions[c.Resource.Type], action) diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 4dd930699a053..b4c5c28a9b242 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -58,11 +58,34 @@ func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope { } const ( + // Existing scopes (unchanged) ScopeAll ScopeName = "all" ScopeApplicationConnect ScopeName = "application_connect" ScopeNoUserData ScopeName = "no_user_data" + + // New granular scopes + ScopeUserRead ScopeName = "user:read" + ScopeUserWrite ScopeName = "user:write" + ScopeWorkspaceRead ScopeName = "workspace:read" + ScopeWorkspaceWrite ScopeName = "workspace:write" + ScopeWorkspaceSSH ScopeName = "workspace:ssh" + ScopeWorkspaceApps ScopeName = "workspace:apps" + ScopeTemplateRead ScopeName = "template:read" + ScopeTemplateWrite ScopeName = "template:write" + ScopeOrganizationRead ScopeName = "organization:read" + ScopeOrganizationWrite ScopeName = "organization:write" + ScopeAuditRead ScopeName = "audit:read" + ScopeSystemRead ScopeName = "system:read" + ScopeSystemWrite ScopeName = "system:write" ) +// AdditionalPermissions represents additional permissions for write scopes +type AdditionalPermissions struct { + Site map[string][]policy.Action // Site-level permissions + Org map[string][]Permission // Organization-level permissions + User []Permission // User-level permissions +} + // TODO: Support passing in scopeID list for allowlisting resources. var builtinScopes = map[ScopeName]Scope{ // ScopeAll is a special scope that allows access to all resources. During @@ -103,6 +126,213 @@ var builtinScopes = map[ScopeName]Scope{ }, AllowIDList: []string{policy.WildcardSymbol}, }, + + // User scopes (read + write pair) + ScopeUserRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_user:read"}, + DisplayName: "Read user profile", + Site: Permissions(map[string][]policy.Action{ + ResourceUser.Type: {policy.ActionReadPersonal}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeUserWrite: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_user:write"}, + DisplayName: "Manage user profile", + Site: Permissions(map[string][]policy.Action{ + ResourceUser.Type: {policy.ActionReadPersonal, policy.ActionUpdatePersonal}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + // Workspace scopes (read + write pair) + ScopeWorkspaceRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_workspace:read"}, + DisplayName: "Read workspaces", + Site: Permissions(map[string][]policy.Action{ + ResourceWorkspace.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{ + ResourceWorkspace.Type: {{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeWorkspaceWrite: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_workspace:write"}, + DisplayName: "Manage workspaces", + Site: Permissions(map[string][]policy.Action{ + ResourceWorkspace.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + }), + Org: map[string][]Permission{ + ResourceWorkspace.Type: { + {ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}, + {ResourceType: ResourceWorkspace.Type, Action: policy.ActionCreate}, + {ResourceType: ResourceWorkspace.Type, Action: policy.ActionUpdate}, + {ResourceType: ResourceWorkspace.Type, Action: policy.ActionDelete}, + }, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + // Workspace special scopes (SSH and Apps) + ScopeWorkspaceSSH: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_workspace:ssh"}, + DisplayName: "SSH to workspaces", + Site: Permissions(map[string][]policy.Action{ + ResourceWorkspace.Type: {policy.ActionSSH}, + }), + Org: map[string][]Permission{ + ResourceWorkspace.Type: {{ResourceType: ResourceWorkspace.Type, Action: policy.ActionSSH}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeWorkspaceApps: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_workspace:apps"}, + DisplayName: "Connect to workspace applications", + Site: Permissions(map[string][]policy.Action{ + ResourceWorkspace.Type: {policy.ActionApplicationConnect}, + }), + Org: map[string][]Permission{ + ResourceWorkspace.Type: {{ResourceType: ResourceWorkspace.Type, Action: policy.ActionApplicationConnect}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + // Template scopes (read + write pair) + ScopeTemplateRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_template:read"}, + DisplayName: "Read templates", + Site: Permissions(map[string][]policy.Action{ + ResourceTemplate.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{ + ResourceTemplate.Type: {{ResourceType: ResourceTemplate.Type, Action: policy.ActionRead}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeTemplateWrite: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_template:write"}, + DisplayName: "Manage templates", + Site: Permissions(map[string][]policy.Action{ + ResourceTemplate.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + }), + Org: map[string][]Permission{ + ResourceTemplate.Type: { + {ResourceType: ResourceTemplate.Type, Action: policy.ActionRead}, + {ResourceType: ResourceTemplate.Type, Action: policy.ActionCreate}, + {ResourceType: ResourceTemplate.Type, Action: policy.ActionUpdate}, + {ResourceType: ResourceTemplate.Type, Action: policy.ActionDelete}, + }, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + // Organization scopes (read + write pair) + ScopeOrganizationRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_organization:read"}, + DisplayName: "Read organization", + Site: Permissions(map[string][]policy.Action{ + ResourceOrganization.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{ + ResourceOrganization.Type: {{ResourceType: ResourceOrganization.Type, Action: policy.ActionRead}}, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeOrganizationWrite: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_organization:write"}, + DisplayName: "Manage organization", + Site: Permissions(map[string][]policy.Action{ + ResourceOrganization.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + }), + Org: map[string][]Permission{ + ResourceOrganization.Type: { + {ResourceType: ResourceOrganization.Type, Action: policy.ActionRead}, + {ResourceType: ResourceOrganization.Type, Action: policy.ActionCreate}, + {ResourceType: ResourceOrganization.Type, Action: policy.ActionUpdate}, + {ResourceType: ResourceOrganization.Type, Action: policy.ActionDelete}, + }, + }, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + // Audit scopes (read only - no write needed) + ScopeAuditRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_audit:read"}, + DisplayName: "Read audit logs", + Site: Permissions(map[string][]policy.Action{ + ResourceAuditLog.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + // System scopes (read + write pair) + ScopeSystemRead: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_system:read"}, + DisplayName: "Read system information", + Site: Permissions(map[string][]policy.Action{ + ResourceSystem.Type: {policy.ActionRead}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, + + ScopeSystemWrite: { + Role: Role{ + Identifier: RoleIdentifier{Name: "Scope_system:write"}, + DisplayName: "Manage system", + Site: Permissions(map[string][]policy.Action{ + ResourceSystem.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}, + }), + Org: map[string][]Permission{}, + User: []Permission{}, + }, + AllowIDList: []string{policy.WildcardSymbol}, + }, } type ExpandableScope interface { diff --git a/coderd/rbac/subject_test.go b/coderd/rbac/subject_test.go index c1462b073ec35..03247f67fe144 100644 --- a/coderd/rbac/subject_test.go +++ b/coderd/rbac/subject_test.go @@ -26,13 +26,13 @@ func TestSubjectEqual(t *testing.T) { ID: "id", Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, Groups: []string{"group"}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, B: rbac.Subject{ ID: "id", Roles: rbac.RoleIdentifiers{rbac.RoleMember()}, Groups: []string{"group"}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, Expected: true, }, @@ -109,10 +109,10 @@ func TestSubjectEqual(t *testing.T) { { Name: "DifferentScope", A: rbac.Subject{ - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }, B: rbac.Subject{ - Scope: rbac.ScopeApplicationConnect, + Scopes: []rbac.ExpandableScope{rbac.ScopeApplicationConnect}, }, Expected: false, }, diff --git a/coderd/userauth.go b/coderd/userauth.go index 91472996737aa..bdb807837ea9a 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -620,7 +620,7 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co return user, rbac.Subject{}, false } - subject, userStatus, err := httpmw.UserRBACSubject(ctx, api.Database, user.ID, rbac.ScopeAll) + subject, userStatus, err := httpmw.UserRBACSubject(ctx, api.Database, user.ID, []rbac.ExpandableScope{rbac.ScopeAll}) if err != nil { logger.Error(ctx, "unable to fetch authorization user roles", slog.Error(err)) httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 4c9412fda3fb7..568894ad5e188 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -1960,7 +1960,7 @@ func TestUserLogout(t *testing.T) { for i := 0; i < 3; i++ { key, _ := dbgen.APIKey(t, db, database.APIKey{ UserID: newUser.ID, - Scope: database.APIKeyScopeApplicationConnect, + Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect}, }) shouldBeDeleted[fmt.Sprintf("application_connect key owned by logout user %d", i)] = key.ID } @@ -1970,7 +1970,7 @@ func TestUserLogout(t *testing.T) { for i := 0; i < 3; i++ { key, _ := dbgen.APIKey(t, db, database.APIKey{ UserID: firstUser.UserID, - Scope: database.APIKeyScopeApplicationConnect, + Scopes: []database.APIKeyScope{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 7fbb8e7d04cdf..2df11a8cf2a1e 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1567,6 +1567,12 @@ func userOrganizationIDs(ctx context.Context, api *API, user database.User) ([]u } func convertAPIKey(k database.APIKey) codersdk.APIKey { + // Convert database scopes to SDK scopes + scopes := make([]codersdk.APIKeyScope, 0, len(k.Scopes)) + for _, scope := range k.Scopes { + scopes = append(scopes, codersdk.APIKeyScope(scope)) + } + return codersdk.APIKey{ ID: k.ID, UserID: k.UserID, @@ -1575,7 +1581,7 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey { CreatedAt: k.CreatedAt, UpdatedAt: k.UpdatedAt, LoginType: codersdk.LoginType(k.LoginType), - Scope: codersdk.APIKeyScope(k.Scope), + Scopes: scopes, LifetimeSeconds: k.LifetimeSeconds, TokenName: k.TokenName, } diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index d0f3acda77278..584c4c2de060f 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -1453,7 +1453,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { sessionTokens := []string{client.SessionToken()} if client.SessionToken() != "" { token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ - Scope: codersdk.APIKeyScopeApplicationConnect, + Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect}, }) require.NoError(t, err) diff --git a/coderd/workspaceupdates_test.go b/coderd/workspaceupdates_test.go index e2b5db0fcc606..e8895dc752da0 100644 --- a/coderd/workspaceupdates_test.go +++ b/coderd/workspaceupdates_test.go @@ -47,7 +47,7 @@ func TestWorkspaceUpdates(t *testing.T) { FriendlyName: "member", ID: ownerID.String(), Roles: rbac.Roles{memberRole}, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } t.Run("Basic", func(t *testing.T) { diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 32c97cf538417..ee394b12e5d4c 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -12,16 +12,16 @@ import ( // APIKey: do not ever return the HashedSecret type APIKey struct { - ID string `json:"id" validate:"required"` - UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"` - LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"` - ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"` - CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"` - UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` - LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"` - Scope APIKeyScope `json:"scope" validate:"required" enums:"all,application_connect"` - TokenName string `json:"token_name" validate:"required"` - LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` + ID string `json:"id" validate:"required"` + UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"` + LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"` + ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"` + CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"` + LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"` + Scopes []APIKeyScope `json:"scopes" validate:"required"` // New array field + TokenName string `json:"token_name" validate:"required"` + LifetimeSeconds int64 `json:"lifetime_seconds" validate:"required"` } // LoginType is the type of login used to create the API key. @@ -43,16 +43,53 @@ const ( type APIKeyScope string const ( + // Legacy scopes (backward compatibility) + // APIKeyScopeAll is a scope that allows the user to do everything. APIKeyScopeAll APIKeyScope = "all" // APIKeyScopeApplicationConnect is a scope that allows the user // to connect to applications in a workspace. APIKeyScopeApplicationConnect APIKeyScope = "application_connect" + + // New granular scopes + + APIKeyScopeUserRead APIKeyScope = "user:read" + APIKeyScopeUserWrite APIKeyScope = "user:write" + APIKeyScopeWorkspaceRead APIKeyScope = "workspace:read" + APIKeyScopeWorkspaceWrite APIKeyScope = "workspace:write" + APIKeyScopeWorkspaceSSH APIKeyScope = "workspace:ssh" // #nosec G101 + APIKeyScopeWorkspaceApps APIKeyScope = "workspace:apps" + APIKeyScopeTemplateRead APIKeyScope = "template:read" + APIKeyScopeTemplateWrite APIKeyScope = "template:write" + APIKeyScopeOrganizationRead APIKeyScope = "organization:read" + APIKeyScopeOrganizationWrite APIKeyScope = "organization:write" + APIKeyScopeAuditRead APIKeyScope = "audit:read" + APIKeyScopeSystemRead APIKeyScope = "system:read" + APIKeyScopeSystemWrite APIKeyScope = "system:write" ) +// APIKeyScopes is a list of all available scopes +var APIKeyScopes = []APIKeyScope{ + APIKeyScopeAll, + APIKeyScopeApplicationConnect, + APIKeyScopeUserRead, + APIKeyScopeUserWrite, + APIKeyScopeWorkspaceRead, + APIKeyScopeWorkspaceWrite, + APIKeyScopeWorkspaceSSH, + APIKeyScopeWorkspaceApps, + APIKeyScopeTemplateRead, + APIKeyScopeTemplateWrite, + APIKeyScopeOrganizationRead, + APIKeyScopeOrganizationWrite, + APIKeyScopeAuditRead, + APIKeyScopeSystemRead, + APIKeyScopeSystemWrite, +} + type CreateTokenRequest struct { Lifetime time.Duration `json:"lifetime"` - Scope APIKeyScope `json:"scope" enums:"all,application_connect"` + Scopes []APIKeyScope `json:"scopes,omitempty"` TokenName string `json:"token_name"` } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 5a4a9fe0cbb32..a226681c4daa4 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
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopestrue
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
| @@ -26,7 +26,7 @@ We track the following resources: | License
create, delete | |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| | NotificationTemplate
| |
FieldTracked
actionstrue
body_templatetrue
enabled_by_defaulttrue
grouptrue
idfalse
kindtrue
methodtrue
nametrue
title_templatetrue
| | NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| -| OAuth2ProviderApp
| |
FieldTracked
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
user_idtrue
| +| OAuth2ProviderApp
| |
FieldTracked
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopestrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
user_idtrue
| | OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
app_owner_user_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| | OAuth2ProviderDeviceCode
create, write, delete | |
FieldTracked
client_idtrue
created_atfalse
device_code_prefixtrue
expires_atfalse
idfalse
polling_intervalfalse
resource_uritrue
scopetrue
statustrue
user_codetrue
user_idtrue
verification_uritrue
verification_uri_completetrue
| | Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9644db4e71ad9..192b953d68c48 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -345,7 +345,9 @@ "last_used": "2019-08-24T14:15:22Z", "lifetime_seconds": 0, "login_type": "password", - "scope": "all", + "scopes": [ + "all" + ], "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" @@ -354,29 +356,27 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|----------------------------------------------|----------|--------------|-------------| -| `created_at` | string | true | | | -| `expires_at` | string | true | | | -| `id` | string | true | | | -| `last_used` | string | true | | | -| `lifetime_seconds` | integer | true | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | | -| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | true | | | -| `token_name` | string | true | | | -| `updated_at` | string | true | | | -| `user_id` | string | true | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|-------------------------------------------------------|----------|--------------|-----------------| +| `created_at` | string | true | | | +| `expires_at` | string | true | | | +| `id` | string | true | | | +| `last_used` | string | true | | | +| `lifetime_seconds` | integer | true | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | true | | | +| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | true | | New array field | +| `token_name` | string | true | | | +| `updated_at` | string | true | | | +| `user_id` | string | true | | | #### Enumerated Values -| Property | Value | -|--------------|-----------------------| -| `login_type` | `password` | -| `login_type` | `github` | -| `login_type` | `oidc` | -| `login_type` | `token` | -| `scope` | `all` | -| `scope` | `application_connect` | +| Property | Value | +|--------------|------------| +| `login_type` | `password` | +| `login_type` | `github` | +| `login_type` | `oidc` | +| `login_type` | `token` | ## codersdk.APIKeyScope @@ -392,6 +392,19 @@ |-----------------------| | `all` | | `application_connect` | +| `user:read` | +| `user:write` | +| `workspace:read` | +| `workspace:write` | +| `workspace:ssh` | +| `workspace:apps` | +| `template:read` | +| `template:write` | +| `organization:read` | +| `organization:write` | +| `audit:read` | +| `system:read` | +| `system:write` | ## codersdk.AddLicenseRequest @@ -1646,25 +1659,20 @@ This is required on creation to enable a user-flow of validating a template work ```json { "lifetime": 0, - "scope": "all", + "scopes": [ + "all" + ], "token_name": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------|----------------------------------------------|----------|--------------|-------------| -| `lifetime` | integer | false | | | -| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | -| `token_name` | string | false | | | - -#### Enumerated Values - -| Property | Value | -|----------|-----------------------| -| `scope` | `all` | -| `scope` | `application_connect` | +| Name | Type | Required | Restrictions | Description | +|--------------|-------------------------------------------------------|----------|--------------|-----------------| +| `lifetime` | integer | false | | | +| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | New array field | +| `token_name` | string | false | | | ## codersdk.CreateUserRequestWithOrgs diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 43842fde6539b..68cf3db62ca37 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -763,7 +763,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \ "last_used": "2019-08-24T14:15:22Z", "lifetime_seconds": 0, "login_type": "password", - "scope": "all", + "scopes": [ + "all" + ], "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" @@ -781,30 +783,28 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \ Status Code **200** -| Name | Type | Required | Restrictions | Description | -|----------------------|--------------------------------------------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» created_at` | string(date-time) | true | | | -| `» expires_at` | string(date-time) | true | | | -| `» id` | string | true | | | -| `» last_used` | string(date-time) | true | | | -| `» lifetime_seconds` | integer | true | | | -| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | | -| `» scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | true | | | -| `» token_name` | string | true | | | -| `» updated_at` | string(date-time) | true | | | -| `» user_id` | string(uuid) | true | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|----------------------------------------------------|----------|--------------|-----------------| +| `[array item]` | array | false | | | +| `» created_at` | string(date-time) | true | | | +| `» expires_at` | string(date-time) | true | | | +| `» id` | string | true | | | +| `» last_used` | string(date-time) | true | | | +| `» lifetime_seconds` | integer | true | | | +| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | true | | | +| `» scopes` | array | true | | New array field | +| `» token_name` | string | true | | | +| `» updated_at` | string(date-time) | true | | | +| `» user_id` | string(uuid) | true | | | #### Enumerated Values -| Property | Value | -|--------------|-----------------------| -| `login_type` | `password` | -| `login_type` | `github` | -| `login_type` | `oidc` | -| `login_type` | `token` | -| `scope` | `all` | -| `scope` | `application_connect` | +| Property | Value | +|--------------|------------| +| `login_type` | `password` | +| `login_type` | `github` | +| `login_type` | `oidc` | +| `login_type` | `token` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -827,7 +827,9 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/keys/tokens \ ```json { "lifetime": 0, - "scope": "all", + "scopes": [ + "all" + ], "token_name": "string" } ``` @@ -889,7 +891,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens/{keyname} \ "last_used": "2019-08-24T14:15:22Z", "lifetime_seconds": 0, "login_type": "password", - "scope": "all", + "scopes": [ + "all" + ], "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" @@ -936,7 +940,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ "last_used": "2019-08-24T14:15:22Z", "lifetime_seconds": 0, "login_type": "password", - "scope": "all", + "scopes": [ + "all" + ], "token_name": "string", "updated_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index a18e74d6610a7..673e8907f2a66 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -217,7 +217,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "login_type": ActionIgnore, "lifetime_seconds": ActionIgnore, "ip_address": ActionIgnore, - "scope": ActionIgnore, + "scopes": ActionTrack, // New array field for granular scopes "token_name": ActionIgnore, }, &database.AuditOAuthConvertState{}: { @@ -280,7 +280,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "grant_types": ActionTrack, // Security relevant - authorization capabilities "response_types": ActionTrack, // Security relevant - response flow types "token_endpoint_auth_method": ActionTrack, // Security relevant - auth method - "scope": ActionTrack, // Security relevant - permissions scope + "scopes": ActionTrack, // Security relevant - permissions scope array "contacts": ActionTrack, // Contact info for responsible parties "client_uri": ActionTrack, // Client identification info "logo_uri": ActionTrack, // Client branding diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 94d9e4fda20df..47100d19fe6c2 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -746,7 +746,7 @@ func testDBAuthzRole(ctx context.Context) context.Context { User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }) } diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 2278fb2a71939..4a4429576badf 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -3321,7 +3321,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { }, }, }) - userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, rbac.ExpandableScope(rbac.ScopeAll)) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, []rbac.ExpandableScope{rbac.ScopeAll}) require.NoError(t, err) user, err := client.User(ctx, userSubject.ID) require.NoError(t, err) @@ -3380,7 +3380,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, rbac.ExpandableScope(rbac.ScopeAll)) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, []rbac.ExpandableScope{rbac.ScopeAll}) require.NoError(t, err) user, err := client.User(ctx, userSubject.ID) require.NoError(t, err) @@ -3457,7 +3457,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, rbac.ExpandableScope(rbac.ScopeAll)) + userSubject, _, err := httpmw.UserRBACSubject(ctx, db, userResponse.UserID, []rbac.ExpandableScope{rbac.ScopeAll}) require.NoError(t, err) user, err := client.User(ctx, userSubject.ID) require.NoError(t, err) diff --git a/enterprise/tailnet/pgcoord.go b/enterprise/tailnet/pgcoord.go index 1283d9f3531b7..28b503bfcb636 100644 --- a/enterprise/tailnet/pgcoord.go +++ b/enterprise/tailnet/pgcoord.go @@ -110,7 +110,7 @@ var pgCoordSubject = rbac.Subject{ User: []rbac.Permission{}, }, }), - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, }.WithCachedASTValue() // NewPGCoord creates a high-availability coordinator that stores state in the PostgreSQL database and diff --git a/scripts/rbac-authz/gen_input.go b/scripts/rbac-authz/gen_input.go index 3028b402437b3..96d8b28fb800a 100644 --- a/scripts/rbac-authz/gen_input.go +++ b/scripts/rbac-authz/gen_input.go @@ -17,10 +17,10 @@ import ( ) type SubjectJSON struct { - ID string `json:"id"` - Roles []rbac.Role `json:"roles"` - Groups []string `json:"groups"` - Scope rbac.Scope `json:"scope"` + ID string `json:"id"` + Roles []rbac.Role `json:"roles"` + Groups []string `json:"groups"` + Scopes []rbac.Scope `json:"scopes"` } type OutputData struct { Action policy.Action `json:"action"` @@ -33,15 +33,18 @@ func newSubjectJSON(s rbac.Subject) (*SubjectJSON, error) { if err != nil { return nil, xerrors.Errorf("failed to expand subject roles: %w", err) } - scopes, err := s.Scope.Expand() - if err != nil { - return nil, xerrors.Errorf("failed to expand subject scopes: %w", err) + scopes := make([]rbac.Scope, len(s.Scopes)) + for i, scope := range s.Scopes { + scopes[i], err = scope.Expand() + if err != nil { + return nil, xerrors.Errorf("failed to expand subject scopes: %w", err) + } } return &SubjectJSON{ ID: s.ID, Roles: roles, Groups: s.Groups, - Scope: scopes, + Scopes: scopes, }, nil } @@ -59,7 +62,7 @@ func main() { Roles: rbac.RoleIdentifiers{ rbac.RoleTemplateAdmin(), }, - Scope: rbac.ScopeAll, + Scopes: []rbac.ExpandableScope{rbac.ScopeAll}, } subjectJSON, err := newSubjectJSON(subject) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fcee3e1401561..79e2a822b985e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -23,15 +23,46 @@ export interface APIKey { readonly created_at: string; readonly updated_at: string; readonly login_type: LoginType; - readonly scope: APIKeyScope; + readonly scopes: readonly APIKeyScope[]; readonly token_name: string; readonly lifetime_seconds: number; } // From codersdk/apikey.go -export type APIKeyScope = "all" | "application_connect"; - -export const APIKeyScopes: APIKeyScope[] = ["all", "application_connect"]; +export type APIKeyScope = + | "all" + | "application_connect" + | "audit:read" + | "organization:read" + | "organization:write" + | "system:read" + | "system:write" + | "template:read" + | "template:write" + | "user:read" + | "user:write" + | "workspace:apps" + | "workspace:read" + | "workspace:ssh" + | "workspace:write"; + +export const APIKeyScopes: APIKeyScope[] = [ + "all", + "application_connect", + "audit:read", + "organization:read", + "organization:write", + "system:read", + "system:write", + "template:read", + "template:write", + "user:read", + "user:write", + "workspace:apps", + "workspace:read", + "workspace:ssh", + "workspace:write", +]; // From codersdk/apikey.go export interface APIKeyWithOwner extends APIKey { @@ -529,7 +560,7 @@ export interface CreateTestAuditLogRequest { // From codersdk/apikey.go export interface CreateTokenRequest { readonly lifetime: number; - readonly scope: APIKeyScope; + readonly scopes?: readonly APIKeyScope[]; readonly token_name: string; } diff --git a/site/src/pages/CreateTokenPage/CreateTokenPage.tsx b/site/src/pages/CreateTokenPage/CreateTokenPage.tsx index 57e68600e0bf8..845b1bffc2914 100644 --- a/site/src/pages/CreateTokenPage/CreateTokenPage.tsx +++ b/site/src/pages/CreateTokenPage/CreateTokenPage.tsx @@ -58,7 +58,7 @@ const CreateTokenPage: FC = () => { { lifetime: values.lifetime * 24 * NANO_HOUR, token_name: values.name, - scope: "all", // tokens are currently unscoped + scopes: ["all"], // tokens are currently unscoped }, { onError: onCreateError, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 9a178a21d0d1c..30510ba9bf0fb 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -71,7 +71,7 @@ export const MockToken: TypesGen.APIKeyWithOwner = { created_at: "2022-12-16T20:10:45.637452Z", updated_at: "2022-12-16T20:10:45.637452Z", login_type: "token", - scope: "all", + scopes: ["all"], lifetime_seconds: 2592000, token_name: "token-one", username: "admin", @@ -87,7 +87,7 @@ export const MockTokens: TypesGen.APIKeyWithOwner[] = [ created_at: "2022-12-16T20:10:45.637452Z", updated_at: "2022-12-16T20:10:45.637452Z", login_type: "token", - scope: "all", + scopes: ["all"], lifetime_seconds: 2592000, token_name: "token-two", username: "admin",