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", pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy