From d0db9ec88f34c23e8ba4a1f39b45d03523519c72 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 26 Sep 2025 11:56:34 +0200 Subject: [PATCH] feat: add multi-scope support to API keys (#19917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Canonicalize API Key Scopes This PR introduces canonical API key scopes with a `coder:` namespace prefix to avoid collisions with low-level resource:action names. It: 1. Renames special API key scopes in the database: - `all` → `coder:all` - `application_connect` → `coder:application_connect` 2. Adds support for a new `scopes` field in the API key creation request, allowing multiple scopes to be specified while maintaining backward compatibility with the singular `scope` field. 3. Updates the API documentation to reflect these changes, including the new endpoint for listing public API key scopes. 4. Ensures backward compatibility by mapping between legacy and canonical scope names in relevant code paths. --- Makefile | 2 +- coderd/apidoc/docs.go | 29 +++++++++-- coderd/apidoc/swagger.json | 29 +++++++++-- coderd/apikey.go | 31 ++++++++++-- coderd/apikey/apikey.go | 19 ++++++-- coderd/apikey/apikey_test.go | 10 ++-- coderd/apikey_scopes_validation_test.go | 19 +++++++- coderd/apikey_test.go | 48 +++++++++++++++++++ coderd/database/dbauthz/dbauthz_test.go | 4 +- coderd/database/dbgen/dbgen.go | 2 +- coderd/database/dump.sql | 4 +- ...nonicalize_special_api_key_scopes.down.sql | 5 ++ ...canonicalize_special_api_key_scopes.up.sql | 5 ++ coderd/database/modelmethods.go | 7 +-- coderd/database/modelmethods_internal_test.go | 6 +-- coderd/database/models.go | 12 ++--- coderd/database/queries.sql.go | 2 +- coderd/database/queries/apikeys.sql | 2 +- coderd/httpmw/apikey_test.go | 4 +- coderd/httpmw/authorize_test.go | 2 +- coderd/httpmw/workspaceparam_test.go | 2 +- coderd/rbac/scopes.go | 4 +- coderd/rbac/scopes_catalog.go | 3 +- coderd/rbac/scopes_constants_gen.go | 8 ++-- coderd/userauth_test.go | 4 +- coderd/users.go | 22 +++++++-- coderd/workspaceapps.go | 2 +- codersdk/apikey.go | 24 +++++----- codersdk/apikey_scopes_gen.go | 12 +++-- docs/reference/api/schemas.md | 46 +++++++++++------- docs/reference/api/users.md | 39 ++++++++++----- .../x/aibridgedserver/aibridgedserver_test.go | 2 +- scripts/apikeyscopesgen/main.go | 25 +++++++++- site/src/api/typesGenerated.ts | 8 +++- site/src/testHelpers/entities.ts | 2 + 35 files changed, 332 insertions(+), 113 deletions(-) create mode 100644 coderd/database/migrations/000373_canonicalize_special_api_key_scopes.down.sql create mode 100644 coderd/database/migrations/000373_canonicalize_special_api_key_scopes.up.sql diff --git a/Makefile b/Makefile index 2f9a4ffd73..8b17b88e20 100644 --- a/Makefile +++ b/Makefile @@ -872,7 +872,7 @@ codersdk/rbacresources_gen.go: scripts/typegen/codersdk.gotmpl scripts/typegen/m touch "$@" codersdk/apikey_scopes_gen.go: scripts/apikeyscopesgen/main.go coderd/rbac/scopes_catalog.go coderd/rbac/scopes.go - # Generate SDK constants for public low-level API key scopes. + # Generate SDK constants for external API key scopes. go run ./scripts/apikeyscopesgen > /tmp/apikey_scopes_gen.go mv /tmp/apikey_scopes_gen.go codersdk/apikey_scopes_gen.go touch "$@" diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0961bbd904..21681cd19b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11254,7 +11254,6 @@ const docTemplate = `{ "last_used", "lifetime_seconds", "login_type", - "scope", "token_name", "updated_at", "user_id" @@ -11292,6 +11291,7 @@ const docTemplate = `{ ] }, "scope": { + "description": "Deprecated: use Scopes instead.", "enum": [ "all", "application_connect" @@ -11302,6 +11302,12 @@ const docTemplate = `{ } ] }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } + }, "token_name": { "type": "string" }, @@ -11319,12 +11325,14 @@ const docTemplate = `{ "type": "string", "enum": [ "all", + "application_connect", "api_key:*", "api_key:create", "api_key:delete", "api_key:read", "api_key:update", - "application_connect", + "coder:all", + "coder:application_connect", "file:*", "file:create", "file:read", @@ -11353,12 +11361,14 @@ const docTemplate = `{ ], "x-enum-varnames": [ "APIKeyScopeAll", + "APIKeyScopeApplicationConnect", "APIKeyScopeApiKeyAll", "APIKeyScopeApiKeyCreate", "APIKeyScopeApiKeyDelete", "APIKeyScopeApiKeyRead", "APIKeyScopeApiKeyUpdate", - "APIKeyScopeApplicationConnect", + "APIKeyScopeCoderAll", + "APIKeyScopeCoderApplicationConnect", "APIKeyScopeFileAll", "APIKeyScopeFileCreate", "APIKeyScopeFileRead", @@ -12453,7 +12463,18 @@ const docTemplate = `{ "type": "integer" }, "scope": { - "$ref": "#/definitions/codersdk.APIKeyScope" + "description": "Deprecated: use Scopes instead.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.APIKeyScope" + } + ] + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } }, "token_name": { "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1d5cdf296a..203d7533bc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9982,7 +9982,6 @@ "last_used", "lifetime_seconds", "login_type", - "scope", "token_name", "updated_at", "user_id" @@ -10015,6 +10014,7 @@ ] }, "scope": { + "description": "Deprecated: use Scopes instead.", "enum": ["all", "application_connect"], "allOf": [ { @@ -10022,6 +10022,12 @@ } ] }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } + }, "token_name": { "type": "string" }, @@ -10039,12 +10045,14 @@ "type": "string", "enum": [ "all", + "application_connect", "api_key:*", "api_key:create", "api_key:delete", "api_key:read", "api_key:update", - "application_connect", + "coder:all", + "coder:application_connect", "file:*", "file:create", "file:read", @@ -10073,12 +10081,14 @@ ], "x-enum-varnames": [ "APIKeyScopeAll", + "APIKeyScopeApplicationConnect", "APIKeyScopeApiKeyAll", "APIKeyScopeApiKeyCreate", "APIKeyScopeApiKeyDelete", "APIKeyScopeApiKeyRead", "APIKeyScopeApiKeyUpdate", - "APIKeyScopeApplicationConnect", + "APIKeyScopeCoderAll", + "APIKeyScopeCoderApplicationConnect", "APIKeyScopeFileAll", "APIKeyScopeFileCreate", "APIKeyScopeFileRead", @@ -11114,7 +11124,18 @@ "type": "integer" }, "scope": { - "$ref": "#/definitions/codersdk.APIKeyScope" + "description": "Deprecated: use Scopes instead.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.APIKeyScope" + } + ] + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.APIKeyScope" + } }, "token_name": { "type": "string" diff --git a/coderd/apikey.go b/coderd/apikey.go index 226c50c15b..c7a7eef3c6 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -67,18 +67,39 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { } // Map and validate requested scope. - // Accept special scopes (all, application_connect) and curated public low-level scopes. - scopes := database.APIKeyScopes{database.APIKeyScopeAll} - if createToken.Scope != "" { + // Accept legacy special scopes (all, application_connect) and external scopes. + // Default to coder:all scopes for backward compatibility. + scopes := database.APIKeyScopes{database.ApiKeyScopeCoderAll} + if len(createToken.Scopes) > 0 { + scopes = make(database.APIKeyScopes, 0, len(createToken.Scopes)) + for _, s := range createToken.Scopes { + name := string(s) + if !rbac.IsExternalScope(rbac.ScopeName(name)) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to create API key.", + Detail: fmt.Sprintf("invalid or unsupported API key scope: %q", name), + }) + return + } + scopes = append(scopes, database.APIKeyScope(name)) + } + } else if string(createToken.Scope) != "" { name := string(createToken.Scope) if !rbac.IsExternalScope(rbac.ScopeName(name)) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Failed to create API key.", - Detail: fmt.Sprintf("invalid API key scope: %q", name), + Detail: fmt.Sprintf("invalid or unsupported API key scope: %q", name), }) return } - scopes = database.APIKeyScopes{database.APIKeyScope(name)} + switch name { + case "all": + scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll} + case "application_connect": + scopes = database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect} + default: + scopes = database.APIKeyScopes{database.APIKeyScope(name)} + } } tokenName := namesgenerator.GetRandomName(1) diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index 738b2c2126..ea186223a1 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -25,13 +25,12 @@ type CreateParams struct { // Optional. ExpiresAt time.Time LifetimeSeconds int64 + // Scope is legacy single-scope input kept for backward compatibility. // - // Deprecated: Prefer Scopes for new code. + // Deprecated: use Scopes instead. Scope database.APIKeyScope // Scopes is the full list of scopes to attach to the key. - // If empty and Scope is set, the generator will use [Scope]. - // If both are empty, the generator will default to [APIKeyScopeAll]. Scopes database.APIKeyScopes TokenName string RemoteAddr string @@ -74,9 +73,19 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) case len(params.Scopes) > 0: scopes = params.Scopes case params.Scope != "": - scopes = database.APIKeyScopes{params.Scope} + var scope database.APIKeyScope + switch params.Scope { + case "all": + scope = database.ApiKeyScopeCoderAll + case "application_connect": + scope = database.ApiKeyScopeCoderApplicationConnect + default: + scope = params.Scope + } + scopes = database.APIKeyScopes{scope} default: - scopes = database.APIKeyScopes{database.APIKeyScopeAll} + // Default to coder:all scope for backward compatibility. + scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll} } for _, s := range scopes { diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index 3bb71538ee..1f5de3aa18 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, + Scope: database.ApiKeyScopeCoderApplicationConnect, }, }, { @@ -62,7 +62,7 @@ func TestGenerate(t *testing.T) { ExpiresAt: time.Time{}, TokenName: "hello", RemoteAddr: "1.2.3.4", - Scope: database.APIKeyScopeApplicationConnect, + Scope: database.ApiKeyScopeCoderApplicationConnect, }, }, { @@ -75,7 +75,7 @@ func TestGenerate(t *testing.T) { ExpiresAt: time.Time{}, TokenName: "hello", RemoteAddr: "1.2.3.4", - Scope: database.APIKeyScopeApplicationConnect, + Scope: database.ApiKeyScopeCoderApplicationConnect, }, }, { @@ -88,7 +88,7 @@ func TestGenerate(t *testing.T) { LifetimeSeconds: int64(time.Hour.Seconds()), TokenName: "hello", RemoteAddr: "", - Scope: database.APIKeyScopeApplicationConnect, + Scope: database.ApiKeyScopeCoderApplicationConnect, }, }, { @@ -161,7 +161,7 @@ func TestGenerate(t *testing.T) { if tc.params.Scope != "" { assert.True(t, key.Scopes.Has(tc.params.Scope)) } else { - assert.True(t, key.Scopes.Has(database.APIKeyScopeAll)) + assert.True(t, key.Scopes.Has(database.ApiKeyScopeCoderAll)) } if tc.params.TokenName != "" { diff --git a/coderd/apikey_scopes_validation_test.go b/coderd/apikey_scopes_validation_test.go index 187ec77792..2a57f39a2f 100644 --- a/coderd/apikey_scopes_validation_test.go +++ b/coderd/apikey_scopes_validation_test.go @@ -22,7 +22,8 @@ func TestTokenCreation_ScopeValidation(t *testing.T) { {name: "AllowsPublicLowLevelScope", scope: "workspace:read", wantErr: false}, {name: "RejectsInternalOnlyScope", scope: "debug_info:read", wantErr: true}, {name: "AllowsLegacyScopes", scope: "application_connect", wantErr: false}, - {name: "AllowsCanonicalSpecialScope", scope: "all", wantErr: false}, + {name: "AllowsLegacyScopes2", scope: "all", wantErr: false}, + {name: "AllowsCanonicalSpecialScope", scope: "coder:all", wantErr: false}, } for _, tc := range cases { @@ -42,6 +43,22 @@ func TestTokenCreation_ScopeValidation(t *testing.T) { } require.NoError(t, err) require.NotEmpty(t, resp.Key) + + // Fetch and verify the stored scopes match expectation. + keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) + require.NoError(t, err) + require.Len(t, keys, 1) + + // Normalize legacy singular scopes to canonical coder:* values. + expected := tc.scope + switch tc.scope { + case codersdk.APIKeyScopeAll: + expected = codersdk.APIKeyScopeCoderAll + case codersdk.APIKeyScopeApplicationConnect: + expected = codersdk.APIKeyScopeCoderApplicationConnect + } + + require.Contains(t, keys[0].Scopes, expected) }) } } diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 73655754a8..f980706d6e 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -88,6 +88,54 @@ func TestTokenScoped(t *testing.T) { require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect) } +// Ensure backward-compat: when a token is created using the legacy singular +// scope names ("all" or "application_connect"), the API returns the same +// legacy value in the deprecated singular Scope field while also supporting +// the new multi-scope field. +func TestTokenLegacySingularScopeCompat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + scope codersdk.APIKeyScope + scopes []codersdk.APIKeyScope + }{ + { + name: "all", + scope: codersdk.APIKeyScopeAll, + scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll}, + }, + { + name: "application_connect", + scope: codersdk.APIKeyScopeApplicationConnect, + scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderApplicationConnect}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // Create with legacy singular scope. + _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scope: tc.scope, + }) + require.NoError(t, err) + + // Read back and ensure the deprecated singular field matches exactly. + keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) + require.NoError(t, err) + require.Len(t, keys, 1) + require.Equal(t, tc.scope, keys[0].Scope) + require.ElementsMatch(t, keys[0].Scopes, tc.scopes) + }) + } +} + func TestUserSetTokenDuration(t *testing.T) { t.Parallel() diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index a20efb8be4..e94e7275d9 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -251,7 +251,7 @@ func (s *MethodTestSuite) TestAPIKey() { })) s.Run("InsertAPIKey", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) - arg := database.InsertAPIKeyParams{UserID: u.ID, LoginType: database.LoginTypePassword, Scopes: database.APIKeyScopes{database.APIKeyScopeAll}, IPAddress: defaultIPAddress()} + arg := database.InsertAPIKeyParams{UserID: u.ID, LoginType: database.LoginTypePassword, Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderAll}, IPAddress: defaultIPAddress()} ret := testutil.Fake(s.T(), faker, database.APIKey{UserID: u.ID, LoginType: database.LoginTypePassword}) dbm.EXPECT().InsertAPIKey(gomock.Any(), arg).Return(ret, nil).AnyTimes() check.Args(arg).Asserts(rbac.ResourceApiKey.WithOwner(u.ID.String()), policy.ActionCreate) @@ -265,7 +265,7 @@ func (s *MethodTestSuite) TestAPIKey() { check.Args(arg).Asserts(a, policy.ActionUpdate).Returns() })) s.Run("DeleteApplicationConnectAPIKeysByUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { - a := testutil.Fake(s.T(), faker, database.APIKey{Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect}}) + a := testutil.Fake(s.T(), faker, database.APIKey{Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect}}) dbm.EXPECT().DeleteApplicationConnectAPIKeysByUserID(gomock.Any(), a.UserID).Return(nil).AnyTimes() check.Args(a.UserID).Asserts(rbac.ResourceApiKey.WithOwner(a.UserID.String()), policy.ActionDelete).Returns() })) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 8101846cf8..ef4248ce1c 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -185,7 +185,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey, munge ...func CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), LoginType: takeFirst(seed.LoginType, database.LoginTypePassword), - Scopes: takeFirstSlice([]database.APIKeyScope(seed.Scopes), []database.APIKeyScope{database.APIKeyScopeAll}), + Scopes: takeFirstSlice([]database.APIKeyScope(seed.Scopes), []database.APIKeyScope{database.ApiKeyScopeCoderAll}), AllowList: takeFirstSlice(seed.AllowList, database.AllowList{database.AllowListWildcard()}), TokenName: takeFirst(seed.TokenName), } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 07b11cd511..b800962266 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -11,8 +11,8 @@ CREATE TYPE agent_key_scope_enum AS ENUM ( ); CREATE TYPE api_key_scope AS ENUM ( - 'all', - 'application_connect', + 'coder:all', + 'coder:application_connect', 'aibridge_interception:create', 'aibridge_interception:read', 'aibridge_interception:update', diff --git a/coderd/database/migrations/000373_canonicalize_special_api_key_scopes.down.sql b/coderd/database/migrations/000373_canonicalize_special_api_key_scopes.down.sql new file mode 100644 index 0000000000..44206667b3 --- /dev/null +++ b/coderd/database/migrations/000373_canonicalize_special_api_key_scopes.down.sql @@ -0,0 +1,5 @@ +-- Revert canonicalization of special API key scopes +-- Rename enum values back: 'coder:all' -> 'all', 'coder:application_connect' -> 'application_connect' + +ALTER TYPE api_key_scope RENAME VALUE 'coder:all' TO 'all'; +ALTER TYPE api_key_scope RENAME VALUE 'coder:application_connect' TO 'application_connect'; diff --git a/coderd/database/migrations/000373_canonicalize_special_api_key_scopes.up.sql b/coderd/database/migrations/000373_canonicalize_special_api_key_scopes.up.sql new file mode 100644 index 0000000000..3ad99b47ff --- /dev/null +++ b/coderd/database/migrations/000373_canonicalize_special_api_key_scopes.up.sql @@ -0,0 +1,5 @@ +-- Canonicalize special API key scopes to coder:* namespace +-- Rename enum values: 'all' -> 'coder:all', 'application_connect' -> 'coder:application_connect' + +ALTER TYPE api_key_scope RENAME VALUE 'all' TO 'coder:all'; +ALTER TYPE api_key_scope RENAME VALUE 'application_connect' TO 'coder:application_connect'; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 59dd8e1f17..765a053a14 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -134,9 +134,9 @@ func (w ConnectionLog) RBACObject() rbac.Object { func (s APIKeyScope) ToRBAC() rbac.ScopeName { switch s { - case APIKeyScopeAll: + case ApiKeyScopeCoderAll: return rbac.ScopeAll - case APIKeyScopeApplicationConnect: + case ApiKeyScopeCoderApplicationConnect: return rbac.ScopeApplicationConnect default: // Allow low-level resource:action scopes to flow through to RBAC for @@ -218,7 +218,8 @@ func (s APIKeyScopes) Expand() (rbac.Scope, error) { // Name returns a human-friendly identifier for tracing/logging. func (s APIKeyScopes) Name() rbac.RoleIdentifier { if len(s) == 0 { - return rbac.RoleIdentifier{Name: string(APIKeyScopeAll)} + // Return all for backward compatibility. + return rbac.RoleIdentifier{Name: string(ApiKeyScopeCoderAll)} } names := make([]string, 0, len(s)) for _, s := range s { diff --git a/coderd/database/modelmethods_internal_test.go b/coderd/database/modelmethods_internal_test.go index 65075c0d7d..16d80d69c1 100644 --- a/coderd/database/modelmethods_internal_test.go +++ b/coderd/database/modelmethods_internal_test.go @@ -20,7 +20,7 @@ func TestAPIKeyScopesExpand(t *testing.T) { }{ { name: "all", - scopes: APIKeyScopes{APIKeyScopeAll}, + scopes: APIKeyScopes{ApiKeyScopeCoderAll}, want: func(t *testing.T, s rbac.Scope) { requirePermission(t, s, rbac.ResourceWildcard.Type, policy.Action(policy.WildcardSymbol)) requireAllowAll(t, s) @@ -28,7 +28,7 @@ func TestAPIKeyScopesExpand(t *testing.T) { }, { name: "application_connect", - scopes: APIKeyScopes{APIKeyScopeApplicationConnect}, + scopes: APIKeyScopes{ApiKeyScopeCoderApplicationConnect}, want: func(t *testing.T, s rbac.Scope) { requirePermission(t, s, rbac.ResourceWorkspace.Type, policy.ActionApplicationConnect) requireAllowAll(t, s) @@ -69,7 +69,7 @@ func TestAPIKeyScopesExpand(t *testing.T) { t.Run("merge", func(t *testing.T) { t.Parallel() - scopes := APIKeyScopes{APIKeyScopeApplicationConnect, APIKeyScopeAll, ApiKeyScopeWorkspaceRead} + scopes := APIKeyScopes{ApiKeyScopeCoderApplicationConnect, ApiKeyScopeCoderAll, ApiKeyScopeWorkspaceRead} s, err := scopes.Expand() require.NoError(t, err) requirePermission(t, s, rbac.ResourceWildcard.Type, policy.Action(policy.WildcardSymbol)) diff --git a/coderd/database/models.go b/coderd/database/models.go index d797132e15..a3224f3857 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -19,8 +19,8 @@ import ( type APIKeyScope string const ( - APIKeyScopeAll APIKeyScope = "all" - APIKeyScopeApplicationConnect APIKeyScope = "application_connect" + ApiKeyScopeCoderAll APIKeyScope = "coder:all" + ApiKeyScopeCoderApplicationConnect APIKeyScope = "coder:application_connect" ApiKeyScopeAibridgeInterceptionCreate APIKeyScope = "aibridge_interception:create" ApiKeyScopeAibridgeInterceptionRead APIKeyScope = "aibridge_interception:read" ApiKeyScopeAibridgeInterceptionUpdate APIKeyScope = "aibridge_interception:update" @@ -198,8 +198,8 @@ func (ns NullAPIKeyScope) Value() (driver.Value, error) { func (e APIKeyScope) Valid() bool { switch e { - case APIKeyScopeAll, - APIKeyScopeApplicationConnect, + case ApiKeyScopeCoderAll, + ApiKeyScopeCoderApplicationConnect, ApiKeyScopeAibridgeInterceptionCreate, ApiKeyScopeAibridgeInterceptionRead, ApiKeyScopeAibridgeInterceptionUpdate, @@ -345,8 +345,8 @@ func (e APIKeyScope) Valid() bool { func AllAPIKeyScopeValues() []APIKeyScope { return []APIKeyScope{ - APIKeyScopeAll, - APIKeyScopeApplicationConnect, + ApiKeyScopeCoderAll, + ApiKeyScopeCoderApplicationConnect, ApiKeyScopeAibridgeInterceptionCreate, ApiKeyScopeAibridgeInterceptionRead, ApiKeyScopeAibridgeInterceptionUpdate, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index edc5966f55..c367852e5d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -432,7 +432,7 @@ DELETE FROM api_keys WHERE user_id = $1 AND - 'application_connect'::api_key_scope = ANY(scopes) + 'coder:application_connect'::api_key_scope = ANY(scopes) ` func (q *sqlQuerier) DeleteApplicationConnectAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error { diff --git a/coderd/database/queries/apikeys.sql b/coderd/database/queries/apikeys.sql index a211c49e32..c067305755 100644 --- a/coderd/database/queries/apikeys.sql +++ b/coderd/database/queries/apikeys.sql @@ -77,7 +77,7 @@ DELETE FROM api_keys WHERE user_id = $1 AND - 'application_connect'::api_key_scope = ANY(scopes); + 'coder:application_connect'::api_key_scope = ANY(scopes); -- name: DeleteAPIKeysByUserID :exec DELETE FROM diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index c9d62cb824..6e00b7a453 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -313,7 +313,7 @@ func TestAPIKey(t *testing.T) { _, token = dbgen.APIKey(t, db, database.APIKey{ UserID: user.ID, ExpiresAt: dbtime.Now().AddDate(0, 0, 1), - Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect}, + Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect}, }) r = httptest.NewRequest("GET", "/", nil) @@ -330,7 +330,7 @@ func TestAPIKey(t *testing.T) { })(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { // Checks that it exists on the context! apiKey := httpmw.APIKey(r) - assert.Equal(t, database.APIKeyScopeApplicationConnect, apiKey.Scopes[0]) + assert.Equal(t, database.ApiKeyScopeCoderApplicationConnect, apiKey.Scopes[0]) assertActorOk(t, r) httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{ diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index d01c50e331..3ec4ca5a4c 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, - Scopes: database.APIKeyScopes{database.APIKeyScopeAll}, + Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderAll}, AllowList: database.AllowList{database.AllowListWildcard()}, IPAddress: pqtype.Inet{ IPNet: net.IPNet{ diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 76cd229632..78e0929df7 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, - Scopes: database.APIKeyScopes{database.APIKeyScopeAll}, + Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderAll}, AllowList: database.AllowList{database.AllowListWildcard()}, IPAddress: pqtype.Inet{ IPNet: net.IPNet{ diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 9a800aa03d..eb057e1dc1 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -61,8 +61,8 @@ func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope { } const ( - ScopeAll ScopeName = "all" - ScopeApplicationConnect ScopeName = "application_connect" + ScopeAll ScopeName = "coder:all" + ScopeApplicationConnect ScopeName = "coder:application_connect" ScopeNoUserData ScopeName = "no_user_data" ) diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go index 70f8720a61..a5ae10199c 100644 --- a/coderd/rbac/scopes_catalog.go +++ b/coderd/rbac/scopes_catalog.go @@ -57,7 +57,8 @@ var externalLowLevel = map[ScopeName]struct{}{ // low-level resource:action scopes. func IsExternalScope(name ScopeName) bool { switch name { - case ScopeAll, ScopeApplicationConnect: + // Include `all` and `application_connect` for backward compatibility. + case "all", ScopeAll, "application_connect", ScopeApplicationConnect: return true } if _, ok := externalLowLevel[name]; ok { diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index 586ae1c583..ccd9622cd4 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -153,8 +153,8 @@ const ( // ensure changes in rbac/scopes.go remain in sync here. func (e ScopeName) Valid() bool { switch e { - case ScopeName("all"), - ScopeName("application_connect"), + case ScopeName("coder:all"), + ScopeName("coder:application_connect"), ScopeName("no_user_data"), ScopeAibridgeInterceptionCreate, ScopeAibridgeInterceptionRead, @@ -303,8 +303,8 @@ func (e ScopeName) Valid() bool { // including builtin and generated low-level scopes. func AllScopeNameValues() []ScopeName { return []ScopeName{ - ScopeName("all"), - ScopeName("application_connect"), + ScopeName("coder:all"), + ScopeName("coder:application_connect"), ScopeName("no_user_data"), ScopeAibridgeInterceptionCreate, ScopeAibridgeInterceptionRead, diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index e8393e2fd9..86fe30bf3c 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -1958,7 +1958,7 @@ func TestUserLogout(t *testing.T) { for i := range 3 { key, _ := dbgen.APIKey(t, db, database.APIKey{ UserID: newUser.ID, - Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect}, + Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect}, }) shouldBeDeleted[fmt.Sprintf("application_connect key owned by logout user %d", i)] = key.ID } @@ -1968,7 +1968,7 @@ func TestUserLogout(t *testing.T) { for i := range 3 { key, _ := dbgen.APIKey(t, db, database.APIKey{ UserID: firstUser.UserID, - Scopes: database.APIKeyScopes{database.APIKeyScopeApplicationConnect}, + Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect}, }) 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 ddfde55fa6..b4b66611b2 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1570,10 +1570,21 @@ func userOrganizationIDs(ctx context.Context, api *API, user database.User) ([]u } func convertAPIKey(k database.APIKey) codersdk.APIKey { - // Derive a single scope from arrays for response compatibility. - scope := database.APIKeyScopeAll - if k.Scopes.Has(database.APIKeyScopeApplicationConnect) { - scope = database.APIKeyScopeApplicationConnect + // Derive a single legacy scope name for response compatibility. + // Historically, the API exposed only two scope strings: "all" and + // "application_connect". Continue to return those for clients even + // though the database stores canonical values (e.g. "coder:all") + // and may include low-level scopes. + var legacyScope codersdk.APIKeyScope + if k.Scopes.Has(database.ApiKeyScopeCoderApplicationConnect) { + legacyScope = codersdk.APIKeyScopeApplicationConnect + } else if k.Scopes.Has(database.ApiKeyScopeCoderAll) { + legacyScope = codersdk.APIKeyScopeAll + } + + scopes := make([]codersdk.APIKeyScope, 0, len(k.Scopes)) + for _, s := range k.Scopes { + scopes = append(scopes, codersdk.APIKeyScope(s)) } return codersdk.APIKey{ @@ -1584,7 +1595,8 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey { CreatedAt: k.CreatedAt, UpdatedAt: k.UpdatedAt, LoginType: codersdk.LoginType(k.LoginType), - Scope: codersdk.APIKeyScope(scope), + Scope: legacyScope, + Scopes: scopes, LifetimeSeconds: k.LifetimeSeconds, TokenName: k.TokenName, } diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index e264dbd80b..afc9538235 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -113,7 +113,7 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request DefaultLifetime: api.DeploymentValues.Sessions.DefaultDuration.Value(), ExpiresAt: exp, LifetimeSeconds: lifetimeSeconds, - Scope: database.APIKeyScopeApplicationConnect, + Scope: database.ApiKeyScopeCoderApplicationConnect, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/codersdk/apikey.go b/codersdk/apikey.go index fd9acf7a64..82828fcd7d 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -12,16 +12,17 @@ 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"` + Scope APIKeyScope `json:"scope" enums:"all,application_connect"` // Deprecated: use Scopes instead. + Scopes []APIKeyScope `json:"scopes"` + 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. @@ -44,7 +45,8 @@ type APIKeyScope string type CreateTokenRequest struct { Lifetime time.Duration `json:"lifetime"` - Scope APIKeyScope `json:"scope"` + Scope APIKeyScope `json:"scope,omitempty"` // Deprecated: use Scopes instead. + Scopes []APIKeyScope `json:"scopes,omitempty"` TokenName string `json:"token_name"` } diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index eee7d57e7b..4378b1847c 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -2,13 +2,17 @@ package codersdk const ( - APIKeyScopeAll APIKeyScope = "all" + // Deprecated: use codersdk.APIKeyScopeCoderAll instead. + APIKeyScopeAll APIKeyScope = "all" + // Deprecated: use codersdk.APIKeyScopeCoderApplicationConnect instead. + APIKeyScopeApplicationConnect APIKeyScope = "application_connect" APIKeyScopeApiKeyAll APIKeyScope = "api_key:*" APIKeyScopeApiKeyCreate APIKeyScope = "api_key:create" APIKeyScopeApiKeyDelete APIKeyScope = "api_key:delete" APIKeyScopeApiKeyRead APIKeyScope = "api_key:read" APIKeyScopeApiKeyUpdate APIKeyScope = "api_key:update" - APIKeyScopeApplicationConnect APIKeyScope = "application_connect" + APIKeyScopeCoderAll APIKeyScope = "coder:all" + APIKeyScopeCoderApplicationConnect APIKeyScope = "coder:application_connect" APIKeyScopeFileAll APIKeyScope = "file:*" APIKeyScopeFileCreate APIKeyScope = "file:create" APIKeyScopeFileRead APIKeyScope = "file:read" @@ -38,13 +42,13 @@ const ( // PublicAPIKeyScopes lists all public low-level API key scopes. var PublicAPIKeyScopes = []APIKeyScope{ - APIKeyScopeAll, APIKeyScopeApiKeyAll, APIKeyScopeApiKeyCreate, APIKeyScopeApiKeyDelete, APIKeyScopeApiKeyRead, APIKeyScopeApiKeyUpdate, - APIKeyScopeApplicationConnect, + APIKeyScopeCoderAll, + APIKeyScopeCoderApplicationConnect, APIKeyScopeFileAll, APIKeyScopeFileCreate, APIKeyScopeFileRead, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index a121ea236f..a1ac1dafb5 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -426,6 +426,9 @@ "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" @@ -434,18 +437,19 @@ ### 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 | | | +| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | +| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | +| `token_name` | string | true | | | +| `updated_at` | string | true | | | +| `user_id` | string | true | | | #### Enumerated Values @@ -471,12 +475,14 @@ | Value | |---------------------------------| | `all` | +| `application_connect` | | `api_key:*` | | `api_key:create` | | `api_key:delete` | | `api_key:read` | | `api_key:update` | -| `application_connect` | +| `coder:all` | +| `coder:application_connect` | | `file:*` | | `file:create` | | `file:read` | @@ -1774,17 +1780,21 @@ This is required on creation to enable a user-flow of validating a template work { "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 | | | +| Name | Type | Required | Restrictions | Description | +|--------------|-------------------------------------------------------|----------|--------------|---------------------------------| +| `lifetime` | integer | false | | | +| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | +| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | +| `token_name` | string | false | | | ## codersdk.CreateUserRequestWithOrgs diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index bef79ddaad..9815ba5406 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -764,6 +764,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens \ "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,19 +784,20 @@ 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 | | | +| `» scope` | [codersdk.APIKeyScope](schemas.md#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. | +| `» scopes` | array | false | | | +| `» token_name` | string | true | | | +| `» updated_at` | string(date-time) | true | | | +| `» user_id` | string(uuid) | true | | | #### Enumerated Values @@ -828,6 +832,9 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/keys/tokens \ { "lifetime": 0, "scope": "all", + "scopes": [ + "all" + ], "token_name": "string" } ``` @@ -890,6 +897,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/tokens/{keyname} \ "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" @@ -937,6 +947,9 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/keys/{keyid} \ "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/x/aibridgedserver/aibridgedserver_test.go b/enterprise/x/aibridgedserver/aibridgedserver_test.go index c464c7c368..fcf8deea47 100644 --- a/enterprise/x/aibridgedserver/aibridgedserver_test.go +++ b/enterprise/x/aibridgedserver/aibridgedserver_test.go @@ -158,7 +158,7 @@ func TestAuthorization(t *testing.T) { CreatedAt: now, UpdatedAt: now, LoginType: database.LoginTypePassword, - Scopes: []database.APIKeyScope{database.APIKeyScopeAll}, + Scopes: []database.APIKeyScope{database.ApiKeyScopeCoderAll}, TokenName: "", } if tc.key == "" { diff --git a/scripts/apikeyscopesgen/main.go b/scripts/apikeyscopesgen/main.go index 7a131df4a6..4bb0bd3727 100644 --- a/scripts/apikeyscopesgen/main.go +++ b/scripts/apikeyscopesgen/main.go @@ -5,7 +5,7 @@ import ( "fmt" "go/format" "os" - "sort" + "slices" "strings" "github.com/coder/coder/v2/coderd/rbac" @@ -26,7 +26,7 @@ func main() { func generate() ([]byte, error) { names := rbac.ExternalScopeNames() - sort.Strings(names) + slices.Sort(names) var b bytes.Buffer if _, err := b.WriteString("// Code generated by scripts/apikeyscopesgen. DO NOT EDIT.\n"); err != nil { @@ -35,11 +35,32 @@ func generate() ([]byte, error) { if _, err := b.WriteString("package codersdk\n\n"); err != nil { return nil, err } + // NOTE: Keep all APIKeyScope constants in a single generated file. + // Some tooling (e.g. swaggo) can behave non-deterministically when + // enums are spread across multiple files: + // https://github.com/swaggo/swag/issues/2038 + // We generate everything into codersdk/apikey_scopes_gen.go as the + // single source of truth so doc generation remains stable. // Constants if _, err := b.WriteString("const (\n"); err != nil { return nil, err } + // Always include legacy/deprecated aliases for backward compatibility. + // These are kept in generated code to ensure consistent availability + // across releases even if hand-written files change. + if _, err := b.WriteString("\t// Deprecated: use codersdk.APIKeyScopeCoderAll instead.\n"); err != nil { + return nil, err + } + if _, err := b.WriteString("\tAPIKeyScopeAll APIKeyScope = \"all\"\n"); err != nil { + return nil, err + } + if _, err := b.WriteString("\t// Deprecated: use codersdk.APIKeyScopeCoderApplicationConnect instead.\n"); err != nil { + return nil, err + } + if _, err := b.WriteString("\tAPIKeyScopeApplicationConnect APIKeyScope = \"application_connect\"\n"); err != nil { + return nil, err + } for _, n := range names { res, act := splitRA(n) if act == policy.WildcardSymbol { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bfd5532510..d4e82d96c5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -48,6 +48,7 @@ export interface APIKey { readonly updated_at: string; readonly login_type: LoginType; readonly scope: APIKeyScope; + readonly scopes: readonly APIKeyScope[]; readonly token_name: string; readonly lifetime_seconds: number; } @@ -61,6 +62,8 @@ export type APIKeyScope = | "api_key:read" | "api_key:update" | "application_connect" + | "coder:all" + | "coder:application_connect" | "file:*" | "file:create" | "file:read" @@ -95,6 +98,8 @@ export const APIKeyScopes: APIKeyScope[] = [ "api_key:read", "api_key:update", "application_connect", + "coder:all", + "coder:application_connect", "file:*", "file:create", "file:read", @@ -632,7 +637,8 @@ export interface CreateTestAuditLogRequest { // From codersdk/apikey.go export interface CreateTokenRequest { readonly lifetime: number; - readonly scope: APIKeyScope; + readonly scope?: APIKeyScope; + readonly scopes?: readonly APIKeyScope[]; readonly token_name: string; } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 571bcb5e3d..ecd0657b1d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -72,6 +72,7 @@ export const MockToken: TypesGen.APIKeyWithOwner = { updated_at: "2022-12-16T20:10:45.637452Z", login_type: "token", scope: "all", + scopes: ["coder:all"], lifetime_seconds: 2592000, token_name: "token-one", username: "admin", @@ -88,6 +89,7 @@ export const MockTokens: TypesGen.APIKeyWithOwner[] = [ updated_at: "2022-12-16T20:10:45.637452Z", login_type: "token", scope: "all", + scopes: ["coder:all"], lifetime_seconds: 2592000, token_name: "token-two", username: "admin",