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",