From 4a402d352ca4aaedd2ea20257ee368eff1ae789f Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Wed, 19 Aug 2026 18:51:25 +0000 Subject: [PATCH 01/11] fix(coderd): canonicalize API key scope aliases at ingress IsExternalScope accepts `all` and `application_connect`, which are not api_key_scope enum members. The plural Scopes field appended the requested name verbatim, so POST /users/{user}/keys/tokens with {"scopes":["all"]} passed validation and then failed inside apikey.Generate, answering HTTP 500 with `invalid API key scope: "all"`. codersdk still exports APIKeyScopeAll and APIKeyScopeApplicationConnect, so a caller reaches this by passing the constants the SDK offers for exactly this purpose. Route every accepted name through rbac.CanonicalScopeName. That fixes the plural path and deletes the two open-coded alias switches, which restated a mapping the rbac package already owns and had to be kept in step by hand. The singular Scope field behaves as before, now by the shared table. --- coderd/apikey.go | 21 +++++++----------- coderd/apikey/apikey.go | 15 +++++-------- coderd/apikey_test.go | 48 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index 4eedd06126d..ceb4a2f8eb1 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -68,37 +68,32 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { // Map and validate requested scope. // Accept legacy special scopes (all, application_connect) and external scopes. // Default to coder:all scopes for backward compatibility. + // IsExternalScope accepts alias spellings that are not api_key_scope enum + // members, so every accepted name is canonicalized before it is stored. 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)) { + name := rbac.ScopeName(s) + if !rbac.IsExternalScope(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)) + scopes = append(scopes, database.APIKeyScope(rbac.CanonicalScopeName(name))) } } else if string(createToken.Scope) != "" { - name := string(createToken.Scope) - if !rbac.IsExternalScope(rbac.ScopeName(name)) { + name := rbac.ScopeName(createToken.Scope) + if !rbac.IsExternalScope(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 } - switch name { - case "all": - scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll} - case "application_connect": - scopes = database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect} - default: - scopes = database.APIKeyScopes{database.APIKeyScope(name)} - } + scopes = database.APIKeyScopes{database.APIKeyScope(rbac.CanonicalScopeName(name))} } tokenName := namesgenerator.NameDigitWith("_") diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index 0f89d239149..527baaa5fec 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -13,6 +13,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/cryptorand" ) @@ -87,16 +88,10 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) case len(params.Scopes) > 0: scopes = params.Scopes case 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} + // Callers may pass an alias spelling, which is not an api_key_scope enum + // member and so would fail the validity check below. + canonical := rbac.CanonicalScopeName(rbac.ScopeName(params.Scope)) + scopes = database.APIKeyScopes{database.APIKeyScope(canonical)} default: // Default to coder:all scope for backward compatibility. scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll} diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 14e22d02218..3332a2cf230 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -180,6 +180,54 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) { } } +// The plural Scopes field accepts the same legacy names as the singular Scope +// field above: IsExternalScope validates both spellings, and codersdk still +// exports APIKeyScopeAll and APIKeyScopeApplicationConnect for callers to pass. +// Both must persist canonically, since the api_key_scope enum has no member for +// either alias and convertAPIKey derives the deprecated singular field by +// looking for the canonical value. +func TestTokenLegacyPluralScopeCompat(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + requested codersdk.APIKeyScope + canonical codersdk.APIKeyScope + }{ + { + name: "all", + requested: codersdk.APIKeyScopeAll, + canonical: codersdk.APIKeyScopeCoderAll, + }, + { + name: "application_connect", + requested: codersdk.APIKeyScopeApplicationConnect, + canonical: 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) + + _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scopes: []codersdk.APIKeyScope{tc.requested}, + }) + require.NoError(t, err) + + keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) + require.NoError(t, err) + require.Len(t, keys, 1) + require.Equal(t, []codersdk.APIKeyScope{tc.canonical}, keys[0].Scopes) + require.Equal(t, tc.requested, keys[0].Scope) + }) + } +} + func TestUserSetTokenDuration(t *testing.T) { t.Parallel() From d9753b54b02808e4f633bc45a303c43f0c8d4107 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 24 Aug 2026 20:40:04 +0000 Subject: [PATCH 02/11] fix: canonicalize API key scopes in apikey.Generate Generate taught the deprecated singular Scope field to accept alias spellings and left the plural Scopes field passing its input to the enum check unchanged, so the alias bug this branch fixes at the handler still existed one layer down, on the field callers are being moved toward. No caller hits it today, but Generate is the choke point every key creation passes through, and the OAuth2 token paths will fill Scopes once their scope TODOs resolve. Canonicalize in the loop that already checks each name, so all three cases are covered by one statement, and build a new slice so the caller's is left alone. Deduplicate afterwards: an alias and its canonical spelling are two names on the way in and one name here, and without this "coder tokens create --scope all --scope coder:all" stored coder:all twice and listed it twice. Cover the rejection path, which no test watched. Both handler guards could be deleted with the suite still green, while {"scopes":["debug_info:read"]} would have persisted an internal-only scope that IsExternalScope deliberately refuses and the enum check accepts. Add TestExternalScopesAreStorable to pin the class rather than the two instances: any public scope name the api_key_scope enum cannot store fails inside Generate after the handler has accepted the request. The rbac package cannot check this itself, since database imports rbac. Use the canonical spellings in the token docs, which taught the commands that reproduced the original 500 and now report back a different name than the operator typed. --- coderd/apikey.go | 6 +- coderd/apikey/apikey.go | 26 +++++--- coderd/apikey/apikey_test.go | 69 ++++++++++++++++++++ coderd/apikey_test.go | 99 ++++++++++++++++++++++++----- docs/admin/users/sessions-tokens.md | 6 +- 5 files changed, 174 insertions(+), 32 deletions(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index ceb4a2f8eb1..3533b7bce56 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -65,11 +65,9 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { return } - // Map and validate requested scope. - // Accept legacy special scopes (all, application_connect) and external scopes. - // Default to coder:all scopes for backward compatibility. // IsExternalScope accepts alias spellings that are not api_key_scope enum - // members, so every accepted name is canonicalized before it is stored. + // members, so every accepted name is canonicalized before it goes into + // CreateParams. Defaulting to coder:all is for backward compatibility. scopes := database.APIKeyScopes{database.ApiKeyScopeCoderAll} if len(createToken.Scopes) > 0 { scopes = make(database.APIKeyScopes, 0, len(createToken.Scopes)) diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index 527baaa5fec..bd55c9b9b4e 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/cryptorand" ) @@ -83,25 +84,32 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) bitlen := len(ip) * 8 - var scopes database.APIKeyScopes + var requested database.APIKeyScopes switch { case len(params.Scopes) > 0: - scopes = params.Scopes + requested = params.Scopes case params.Scope != "": - // Callers may pass an alias spelling, which is not an api_key_scope enum - // member and so would fail the validity check below. - canonical := rbac.CanonicalScopeName(rbac.ScopeName(params.Scope)) - scopes = database.APIKeyScopes{database.APIKeyScope(canonical)} + requested = database.APIKeyScopes{params.Scope} default: // Default to coder:all scope for backward compatibility. - scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll} + requested = database.APIKeyScopes{database.ApiKeyScopeCoderAll} } - for _, s := range scopes { - if !s.Valid() { + // Callers may pass an alias spelling such as "all", which is not an + // api_key_scope enum member and so would fail the validity check. Build a new + // slice rather than canonicalizing in place, so params is left as the caller + // passed it. + scopes := make(database.APIKeyScopes, 0, len(requested)) + for _, s := range requested { + canonical := database.APIKeyScope(rbac.CanonicalScopeName(rbac.ScopeName(s))) + if !canonical.Valid() { return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", s) } + scopes = append(scopes, canonical) } + // An alias and its canonical spelling are distinct names on the way in and + // the same name here, so drop the repeats before they reach the column. + scopes = slice.Unique(scopes) token := fmt.Sprintf("%s-%s", keyID, keySecret) diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index aa17a02561e..245c3fe49d8 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -173,6 +173,75 @@ func TestGenerate(t *testing.T) { } } +// TestGenerateScopeNames asserts that Generate treats the singular Scope field +// and the plural Scopes field alike. Both accept the alias spellings +// IsExternalScope allows, neither of which is an api_key_scope member, so an +// alias that reached the enum check unchanged would fail here rather than at +// the handler that can answer 400. +func TestGenerateScopeNames(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + params apikey.CreateParams + want database.APIKeyScopes + fail bool + }{ + { + name: "SingularAlias", + params: apikey.CreateParams{Scope: "all"}, + want: database.APIKeyScopes{database.ApiKeyScopeCoderAll}, + }, + { + name: "PluralAlias", + params: apikey.CreateParams{Scopes: database.APIKeyScopes{"application_connect"}}, + want: database.APIKeyScopes{database.ApiKeyScopeCoderApplicationConnect}, + }, + { + name: "PluralAliasAndCanonical", + params: apikey.CreateParams{ + Scopes: database.APIKeyScopes{"all", database.ApiKeyScopeCoderAll}, + }, + want: database.APIKeyScopes{database.ApiKeyScopeCoderAll}, + }, + { + name: "PluralMixed", + params: apikey.CreateParams{ + Scopes: database.APIKeyScopes{"all", database.ApiKeyScopeWorkspaceRead}, + }, + want: database.APIKeyScopes{database.ApiKeyScopeCoderAll, database.ApiKeyScopeWorkspaceRead}, + }, + { + name: "PluralInvalid", + params: apikey.CreateParams{Scopes: database.APIKeyScopes{"not_a_real_scope"}}, + fail: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + params := tc.params + params.UserID = uuid.New() + params.LoginType = database.LoginTypePassword + params.DefaultLifetime = time.Hour + + requested := append(database.APIKeyScopes(nil), params.Scopes...) + + key, _, err := apikey.Generate(params) + if tc.fail { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, key.Scopes) + // Generate must not canonicalize through the caller's slice. + require.Equal(t, requested, params.Scopes) + }) + } +} + // TestInvalid just ensures the false case is asserted by some tests. // Otherwise, a function that just `returns true` might pass all tests incorrectly. func TestInvalid(t *testing.T) { diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 3332a2cf230..f9c07db2478 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" "github.com/coder/serpent" @@ -180,54 +181,118 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) { } } -// The plural Scopes field accepts the same legacy names as the singular Scope -// field above: IsExternalScope validates both spellings, and codersdk still -// exports APIKeyScopeAll and APIKeyScopeApplicationConnect for callers to pass. -// Both must persist canonically, since the api_key_scope enum has no member for -// either alias and convertAPIKey derives the deprecated singular field by -// looking for the canonical value. +// TestTokenLegacyPluralScopeCompat asserts that the plural Scopes field accepts +// the same legacy names as the singular Scope field covered by +// TestTokenLegacySingularScopeCompat: IsExternalScope validates both spellings, +// and codersdk still exports APIKeyScopeAll and APIKeyScopeApplicationConnect +// for callers to pass. Both must persist canonically, since the api_key_scope +// enum has no member for either alias and convertAPIKey derives the deprecated +// singular field by looking for the canonical value. Names IsExternalScope +// refuses must be rejected here with a 400 rather than reaching apikey.Generate, +// which validates against the enum and so would accept an internal-only scope. func TestTokenLegacyPluralScopeCompat(t *testing.T) { t.Parallel() cases := []struct { name string - requested codersdk.APIKeyScope - canonical codersdk.APIKeyScope + requested []codersdk.APIKeyScope + // canonical is the expected contents of the plural Scopes field. + canonical []codersdk.APIKeyScope + // legacy is the expected deprecated singular Scope field. + legacy codersdk.APIKeyScope + wantErr bool }{ { name: "all", - requested: codersdk.APIKeyScopeAll, - canonical: codersdk.APIKeyScopeCoderAll, + requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll}, + canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll}, + legacy: codersdk.APIKeyScopeAll, }, { name: "application_connect", - requested: codersdk.APIKeyScopeApplicationConnect, - canonical: codersdk.APIKeyScopeCoderApplicationConnect, + requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect}, + canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderApplicationConnect}, + legacy: codersdk.APIKeyScopeApplicationConnect, + }, + { + // More than one element, so a canonicalization that only handled + // single-element requests would fail here. + name: "alias alongside another scope", + requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll, codersdk.APIKeyScopeWorkspaceRead}, + canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll, codersdk.APIKeyScopeWorkspaceRead}, + legacy: codersdk.APIKeyScopeAll, + }, + { + // An alias and its canonical spelling are two names on the way in + // and one name once stored. + name: "alias and canonical spelling collapse", + requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll, codersdk.APIKeyScopeCoderAll}, + canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll}, + legacy: codersdk.APIKeyScopeAll, + }, + { + name: "unknown scope", + requested: []codersdk.APIKeyScope{"not_a_real_scope"}, + wantErr: true, + }, + { + // A real api_key_scope member that IsExternalScope refuses, so the + // enum check in apikey.Generate would not catch it. + name: "internal scope", + requested: []codersdk.APIKeyScope{codersdk.APIKeyScope(database.ApiKeyScopeDebugInfoRead)}, + wantErr: true, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) - defer cancel() + ctx := testutil.Context(t, testutil.WaitLong) client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ - Scopes: []codersdk.APIKeyScope{tc.requested}, + Scopes: tc.requested, }) + if tc.wantErr { + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Detail, string(tc.requested[0])) + return + } require.NoError(t, err) keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) require.NoError(t, err) require.Len(t, keys, 1) - require.Equal(t, []codersdk.APIKeyScope{tc.canonical}, keys[0].Scopes) - require.Equal(t, tc.requested, keys[0].Scope) + require.ElementsMatch(t, tc.canonical, keys[0].Scopes) + require.Equal(t, tc.legacy, keys[0].Scope) }) } } +// TestExternalScopesAreStorable pins the class of bug that +// TestTokenLegacyPluralScopeCompat pins two instances of: a name the rbac +// catalog calls public but the api_key_scope enum cannot store is accepted by +// the handler and then fails inside apikey.Generate. The rbac package cannot +// check this itself, since database imports rbac and not the other way around. +// +// ExternalScopeNames omits the bare aliases, so CanonicalScopeName is a no-op +// here today and is kept because that list is documented as canonical rather +// than guaranteed to be. New aliases are still covered: TestScopeAliases +// requires every alias to point at a name on this list. +func TestExternalScopesAreStorable(t *testing.T) { + t.Parallel() + + for _, name := range rbac.ExternalScopeNames() { + canonical := rbac.CanonicalScopeName(rbac.ScopeName(name)) + require.Truef(t, database.APIKeyScope(canonical).Valid(), + "external scope %q canonicalizes to %q, which is not an api_key_scope member", + name, canonical) + } +} + func TestUserSetTokenDuration(t *testing.T) { t.Parallel() diff --git a/docs/admin/users/sessions-tokens.md b/docs/admin/users/sessions-tokens.md index f07e4e44746..e201b379211 100644 --- a/docs/admin/users/sessions-tokens.md +++ b/docs/admin/users/sessions-tokens.md @@ -123,7 +123,7 @@ Deleting the user that owns a token revokes every token that user holds at the s ## API Key Scopes -API key scopes allow you to limit the permissions of a token to specific operations. By default, tokens are created with the `all` scope, granting full access to all actions the user can perform. For improved security, you can create tokens with limited scopes that restrict access to only the operations needed. +API key scopes allow you to limit the permissions of a token to specific operations. By default, tokens are created with the `coder:all` scope, granting full access to all actions the user can perform. For improved security, you can create tokens with limited scopes that restrict access to only the operations needed. Scopes follow the format `resource:action`, where `resource` is the type of object (like `workspace`, `template`, or `user`) and `action` is the operation (like `read`, `create`, `update`, or `delete`). You can also use wildcards like `workspace:*` to grant all permissions for a specific resource type. @@ -145,10 +145,12 @@ Common scope examples include: - `workspace:*` - Full workspace access (create, read, update, delete) - `template:read` - View template information - `api_key:read` - View API keys (useful for automation) -- `application_connect` - Connect to workspace applications +- `coder:application_connect` - Connect to workspace applications For a complete list of available scopes, see the API reference documentation. +The older names `all` and `application_connect` are still accepted for backward compatibility. Tokens created with them are stored and listed as `coder:all` and `coder:application_connect`. + ### Allow lists (advanced) For additional security, you can combine scopes with allow lists to restrict tokens to specific resources. Allow lists let you limit a token to only interact with particular workspaces, templates, or other resources by their UUID: From e827a276a96340cb7bf5773645d379a5a69ad071 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 24 Aug 2026 21:45:12 +0000 Subject: [PATCH 03/11] docs: use a relative link for the max token lifetime flag The two other server-flag links in this file point at ../../reference/cli/server.md. This one used an absolute coder.com URL with no .md extension, so it always resolved to the published docs rather than the version being read. Relative links follow the branch preview and the offline docs build. --- docs/admin/users/sessions-tokens.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/users/sessions-tokens.md b/docs/admin/users/sessions-tokens.md index e201b379211..a9c29e95f8d 100644 --- a/docs/admin/users/sessions-tokens.md +++ b/docs/admin/users/sessions-tokens.md @@ -92,7 +92,7 @@ Use our API reference for more information on how to ### Set max token length You can use the -[`CODER_MAX_TOKEN_LIFETIME`](https://coder.com/docs/reference/cli/server#--max-token-lifetime) +[`CODER_MAX_TOKEN_LIFETIME`](../../reference/cli/server.md#--max-token-lifetime) server flag to set the maximum duration for long-lived tokens in your deployment. From 5b7b46d16baa08aa728ba4d61cd6a44795bb36fb Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 24 Aug 2026 22:41:40 +0000 Subject: [PATCH 04/11] fix(coderd): list every external scope name ingress accepts IsExternalScope admits any key in externalLowLevel, but ExternalScopeNames dropped the keys parseLowLevelScope rejects. A curated entry that does not parse was therefore accepted by the token handler and absent from every list-driven check, so it reached the api_key_scope enum and failed there. Adding "workspace:reed" to the catalog reproduces the 500 this branch exists to remove, with the whole suite green. The filter also made an existing assertion unreachable: TestExternalScopeNames requires every entry to parse but iterates the already-filtered list, so it could never see a bad one. Dropping the filter makes the accepted set and the listed set the same, and that assertion plus TestExternalScopesAreStorable then both fail on the poisoned entry. Output is unchanged today: 57 names before and after, and codersdk/apikey_scopes_gen.go regenerates identically. --- coderd/apikey_test.go | 6 +++--- coderd/rbac/scopes_catalog.go | 17 +++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index f9c07db2478..a58ff3ac2ad 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -278,9 +278,9 @@ func TestTokenLegacyPluralScopeCompat(t *testing.T) { // the handler and then fails inside apikey.Generate. The rbac package cannot // check this itself, since database imports rbac and not the other way around. // -// ExternalScopeNames omits the bare aliases, so CanonicalScopeName is a no-op -// here today and is kept because that list is documented as canonical rather -// than guaranteed to be. New aliases are still covered: TestScopeAliases +// ExternalScopeNames is the set the handler accepts, less the bare aliases, so +// CanonicalScopeName is a no-op here today. It is kept so an alias added to the +// list later is still checked against the enum; TestScopeAliases separately // requires every alias to point at a name on this list. func TestExternalScopesAreStorable(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go index 8d6faf39b05..e6fd3ca186d 100644 --- a/coderd/rbac/scopes_catalog.go +++ b/coderd/rbac/scopes_catalog.go @@ -142,20 +142,21 @@ func CanonicalScopeName(name ScopeName) ScopeName { // `coder:all` and `coder:application_connect` spellings, the curated low-level // resource:action names, and the curated composite coder:* scopes. // -// Every name returned is canonical, so the list omits the bare `all` and -// `application_connect` aliases IsExternalScope also accepts. A caller matching -// a client-supplied name against this list must run it through -// CanonicalScopeName first, or reject a spelling the same package calls public. +// Apart from the bare `all` and `application_connect` aliases, this is exactly +// the set IsExternalScope accepts, so a name missing here is a name ingress +// rejects. Every name returned is canonical, so a caller matching a +// client-supplied name against this list must run it through CanonicalScopeName +// first, or reject a spelling the same package calls public. func ExternalScopeNames() []string { names := make([]string, 0, len(externalLowLevel)+len(externalComposite)+2) names = append(names, string(ScopeAll)) names = append(names, string(ScopeApplicationConnect)) - // curated low-level names, filtered for validity + // curated low-level names. Listed unfiltered: IsExternalScope accepts every + // key in this map, so filtering here would hide an unparsable entry from + // the callers that check this list while ingress still admitted it. for name := range externalLowLevel { - if _, _, ok := parseLowLevelScope(name); ok { - names = append(names, string(name)) - } + names = append(names, string(name)) } // curated composite names From 7105b636f2ab4cb177851a159c77e9d614d0aefc Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 24 Aug 2026 23:46:33 +0000 Subject: [PATCH 05/11] fix(coderd): give apikey.Generate sole ownership of scope canonicalization postToken canonicalized each accepted name and apikey.Generate canonicalized every element again, so the handler copy decided nothing: replacing both calls with the raw name leaves the token suite green. Two sites answering the same question is how a stored name drifts from the validated one, so the handler now only decides which names may be requested and Generate owns the spelling. The 400 also covered two problems with one sentence. A name that is no scope at all is a typo the caller can fix; an internal api_key_scope member is not, and no re-spelling makes it requestable. Both rejection sites now go through one helper that says which case the caller is in and points at the docs. Adds a case for a caller sending both scope and scopes. Nothing sends both today, so an inverted precedence would widen a workspace:read request to coder:all with every other case still green. --- coderd/apikey.go | 40 +++++++++++++++++++++++++++------------- coderd/apikey_test.go | 41 ++++++++++++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index 3533b7bce56..c225effc70d 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -25,6 +25,25 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// scopeDocsURL documents which scopes a token may carry. The api_key_scope enum +// table in the API reference is a superset: it lists internal scopes too. +const scopeDocsURL = "https://coder.com/docs/admin/users/sessions-tokens#api-key-scopes" + +// writeUnrequestableScope answers 400 for a scope name a token may not carry. +// The two reasons need different words: a name that is no scope at all is a +// typo the caller fixes by re-spelling it, while an internal scope is a real +// api_key_scope member that no spelling will make requestable. +func writeUnrequestableScope(ctx context.Context, rw http.ResponseWriter, name rbac.ScopeName) { + detail := fmt.Sprintf("unknown API key scope: %q. See %s for the scopes a token may request.", name, scopeDocsURL) + if database.APIKeyScope(name).Valid() { + detail = fmt.Sprintf("API key scope %q is internal and cannot be requested by a token. See %s for the scopes a token may request.", name, scopeDocsURL) + } + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to create API key.", + Detail: detail, + }) +} + // Creates a new token API key with the given scope and lifetime. // // @Summary Create token API key @@ -65,33 +84,28 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { return } - // IsExternalScope accepts alias spellings that are not api_key_scope enum - // members, so every accepted name is canonicalized before it goes into - // CreateParams. Defaulting to coder:all is for backward compatibility. + // This handler decides only which names may be requested. Rewriting an + // accepted alias to the spelling the enum stores belongs to apikey.Generate, + // which every caller goes through. Defaulting to coder:all is for backward + // compatibility. The plural field wins when both are set. scopes := database.APIKeyScopes{database.ApiKeyScopeCoderAll} if len(createToken.Scopes) > 0 { scopes = make(database.APIKeyScopes, 0, len(createToken.Scopes)) for _, s := range createToken.Scopes { name := rbac.ScopeName(s) if !rbac.IsExternalScope(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), - }) + writeUnrequestableScope(ctx, rw, name) return } - scopes = append(scopes, database.APIKeyScope(rbac.CanonicalScopeName(name))) + scopes = append(scopes, database.APIKeyScope(name)) } } else if string(createToken.Scope) != "" { name := rbac.ScopeName(createToken.Scope) if !rbac.IsExternalScope(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), - }) + writeUnrequestableScope(ctx, rw, name) return } - scopes = database.APIKeyScopes{database.APIKeyScope(rbac.CanonicalScopeName(name))} + scopes = database.APIKeyScopes{database.APIKeyScope(name)} } tokenName := namesgenerator.NameDigitWith("_") diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index a58ff3ac2ad..f61d74cb726 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -190,17 +190,25 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) { // singular field by looking for the canonical value. Names IsExternalScope // refuses must be rejected here with a 400 rather than reaching apikey.Generate, // which validates against the enum and so would accept an internal-only scope. +// It also pins two properties nothing else covers: the plural field wins when a +// caller sets both, and the 400 tells a misspelled name apart from an internal +// one. func TestTokenLegacyPluralScopeCompat(t *testing.T) { t.Parallel() cases := []struct { name string requested []codersdk.APIKeyScope + // requestedLegacy is sent in the deprecated singular Scope field. + requestedLegacy codersdk.APIKeyScope // canonical is the expected contents of the plural Scopes field. canonical []codersdk.APIKeyScope // legacy is the expected deprecated singular Scope field. legacy codersdk.APIKeyScope wantErr bool + // wantDetail is a substring the 400 must carry, so the two rejection + // reasons stay distinguishable to the caller. + wantDetail string }{ { name: "all", @@ -231,16 +239,33 @@ func TestTokenLegacyPluralScopeCompat(t *testing.T) { legacy: codersdk.APIKeyScopeAll, }, { - name: "unknown scope", - requested: []codersdk.APIKeyScope{"not_a_real_scope"}, - wantErr: true, + // Nothing sends both fields today. Pinning the precedence keeps a + // later edit from quietly widening a read-only request to coder:all, + // which no other case here would catch. + // The singular field is empty because convertAPIKey derives it only + // for coder:all and coder:application_connect. That the caller sent + // APIKeyScopeAll and reads back nothing is the clearest evidence the + // plural field decided the result. + name: "plural wins over singular", + requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, + requestedLegacy: codersdk.APIKeyScopeAll, + canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, + legacy: "", + }, + { + name: "unknown scope", + requested: []codersdk.APIKeyScope{"not_a_real_scope"}, + wantErr: true, + wantDetail: "unknown API key scope", }, { // A real api_key_scope member that IsExternalScope refuses, so the - // enum check in apikey.Generate would not catch it. - name: "internal scope", - requested: []codersdk.APIKeyScope{codersdk.APIKeyScope(database.ApiKeyScopeDebugInfoRead)}, - wantErr: true, + // enum check in apikey.Generate would not catch it. Re-spelling it + // never helps, so the message must not read like a typo. + name: "internal scope", + requested: []codersdk.APIKeyScope{codersdk.APIKeyScope(database.ApiKeyScopeDebugInfoRead)}, + wantErr: true, + wantDetail: "is internal and cannot be requested", }, } @@ -252,6 +277,7 @@ func TestTokenLegacyPluralScopeCompat(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Scope: tc.requestedLegacy, Scopes: tc.requested, }) if tc.wantErr { @@ -259,6 +285,7 @@ func TestTokenLegacyPluralScopeCompat(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) require.Contains(t, sdkErr.Detail, string(tc.requested[0])) + require.Contains(t, sdkErr.Detail, tc.wantDetail) return } require.NoError(t, err) From 5add97a5af420c68eddecd1e6a432ef151eddd44 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 24 Aug 2026 23:49:07 +0000 Subject: [PATCH 06/11] refactor(coderd/apikey): say clone with slices.Clone and fix a comment The test cloned params.Scopes with the pre-1.21 append idiom; the repo already uses slices.Clone in 59 other places, and it names the operation instead of leaving the reader to recognize it. The comment above the canonicalization loop said Generate leaves params as the caller passed it, which is not true: it also assigns ExpiresAt, LifetimeSeconds and AllowList on its local copy. State the property that actually matters, which is that params.Scopes shares a backing array with the caller, so canonicalizing in place would rewrite the caller's slice. --- coderd/apikey/apikey.go | 6 +++--- coderd/apikey/apikey_test.go | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index bd55c9b9b4e..af07d280333 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -96,9 +96,9 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) } // Callers may pass an alias spelling such as "all", which is not an - // api_key_scope enum member and so would fail the validity check. Build a new - // slice rather than canonicalizing in place, so params is left as the caller - // passed it. + // api_key_scope enum member and so would fail the validity check. + // params.Scopes shares its backing array with the caller, so canonicalize + // into a new slice rather than in place. scopes := make(database.APIKeyScopes, 0, len(requested)) for _, s := range requested { canonical := database.APIKeyScope(rbac.CanonicalScopeName(rbac.ScopeName(s))) diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index 245c3fe49d8..afa53743f8b 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -1,6 +1,7 @@ package apikey_test import ( + "slices" "strings" "testing" "time" @@ -227,7 +228,7 @@ func TestGenerateScopeNames(t *testing.T) { params.LoginType = database.LoginTypePassword params.DefaultLifetime = time.Hour - requested := append(database.APIKeyScopes(nil), params.Scopes...) + requested := slices.Clone(params.Scopes) key, _, err := apikey.Generate(params) if tc.fail { From 50d4e035ea3475fc9d3cbace1370fd34bbea98aa Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Mon, 24 Aug 2026 23:56:35 +0000 Subject: [PATCH 07/11] docs: name the scopes a token cannot request The page pointed at "the API reference documentation" with no link, and the only complete list there is the codersdk.APIKeyScope enum, a superset that includes debug_info:read and every other internal scope. An operator following that sentence copies one and gets a 400, which TestTokenLegacyPluralScopeCompat now pins as permanent behavior. Link the schema and say which half of it a token may not ask for. --- docs/admin/users/sessions-tokens.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/admin/users/sessions-tokens.md b/docs/admin/users/sessions-tokens.md index a9c29e95f8d..b9f03b9ee16 100644 --- a/docs/admin/users/sessions-tokens.md +++ b/docs/admin/users/sessions-tokens.md @@ -147,7 +147,11 @@ Common scope examples include: - `api_key:read` - View API keys (useful for automation) - `coder:application_connect` - Connect to workspace applications -For a complete list of available scopes, see the API reference documentation. +The +[`codersdk.APIKeyScope` schema](../../reference/api/schemas.md#codersdkapikeyscope) +lists every scope name Coder defines, but a token cannot request all of them. +Internal scopes such as `debug_info:read` are rejected with a `400` response, so +use the `resource:action` and `coder:` names described on this page. The older names `all` and `application_connect` are still accepted for backward compatibility. Tokens created with them are stored and listed as `coder:all` and `coder:application_connect`. From fef8883e4badcfd6650552f2c96e22aaf452a5cf Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 25 Aug 2026 01:27:53 +0000 Subject: [PATCH 08/11] test(coderd): name the scope tests for what they assert Rename TestTokenLegacyPluralScopeCompat to TestCreateTokenScopes and TestGenerateScopeNames to TestGenerateCanonicalizesScopeAliases, and drop their doc comments. The subtest names now state the behavior under test, and the rationale the comments carried already lives next to the code it describes: postToken documents the field precedence, apikey.Generate the canonicalization, and convertAPIKey the derived legacy field. Rename the table fields in TestCreateTokenScopes so the per-field comments are unnecessary, and keep only the two case comments whose expectations are surprising on their own. Also tighten two comments in the handler: scopeDocsURL describes the constant rather than the page it links, and the default coder:all scope is already explained where apikey.Generate applies it. --- coderd/apikey.go | 5 +- coderd/apikey/apikey_test.go | 7 +- coderd/apikey_test.go | 138 ++++++++++++++--------------------- 3 files changed, 56 insertions(+), 94 deletions(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index c225effc70d..0cdc5728715 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -25,7 +25,7 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// scopeDocsURL documents which scopes a token may carry. The api_key_scope enum +// scopeDocsURL points at the scopes a token may request. The api_key_scope enum // table in the API reference is a superset: it lists internal scopes too. const scopeDocsURL = "https://coder.com/docs/admin/users/sessions-tokens#api-key-scopes" @@ -86,8 +86,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { // This handler decides only which names may be requested. Rewriting an // accepted alias to the spelling the enum stores belongs to apikey.Generate, - // which every caller goes through. Defaulting to coder:all is for backward - // compatibility. The plural field wins when both are set. + // which every caller goes through. The plural field wins when both are set. scopes := database.APIKeyScopes{database.ApiKeyScopeCoderAll} if len(createToken.Scopes) > 0 { scopes = make(database.APIKeyScopes, 0, len(createToken.Scopes)) diff --git a/coderd/apikey/apikey_test.go b/coderd/apikey/apikey_test.go index afa53743f8b..afda11509e8 100644 --- a/coderd/apikey/apikey_test.go +++ b/coderd/apikey/apikey_test.go @@ -174,12 +174,7 @@ func TestGenerate(t *testing.T) { } } -// TestGenerateScopeNames asserts that Generate treats the singular Scope field -// and the plural Scopes field alike. Both accept the alias spellings -// IsExternalScope allows, neither of which is an api_key_scope member, so an -// alias that reached the enum check unchanged would fail here rather than at -// the handler that can answer 400. -func TestGenerateScopeNames(t *testing.T) { +func TestGenerateCanonicalizesScopeAliases(t *testing.T) { t.Parallel() cases := []struct { diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index f61d74cb726..a308ecf402e 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -181,91 +181,64 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) { } } -// TestTokenLegacyPluralScopeCompat asserts that the plural Scopes field accepts -// the same legacy names as the singular Scope field covered by -// TestTokenLegacySingularScopeCompat: IsExternalScope validates both spellings, -// and codersdk still exports APIKeyScopeAll and APIKeyScopeApplicationConnect -// for callers to pass. Both must persist canonically, since the api_key_scope -// enum has no member for either alias and convertAPIKey derives the deprecated -// singular field by looking for the canonical value. Names IsExternalScope -// refuses must be rejected here with a 400 rather than reaching apikey.Generate, -// which validates against the enum and so would accept an internal-only scope. -// It also pins two properties nothing else covers: the plural field wins when a -// caller sets both, and the 400 tells a misspelled name apart from an internal -// one. -func TestTokenLegacyPluralScopeCompat(t *testing.T) { +func TestCreateTokenScopes(t *testing.T) { t.Parallel() cases := []struct { - name string - requested []codersdk.APIKeyScope - // requestedLegacy is sent in the deprecated singular Scope field. - requestedLegacy codersdk.APIKeyScope - // canonical is the expected contents of the plural Scopes field. - canonical []codersdk.APIKeyScope - // legacy is the expected deprecated singular Scope field. - legacy codersdk.APIKeyScope - wantErr bool - // wantDetail is a substring the 400 must carry, so the two rejection - // reasons stay distinguishable to the caller. - wantDetail string + name string + sendScopes []codersdk.APIKeyScope + sendLegacyScope codersdk.APIKeyScope + wantScopes []codersdk.APIKeyScope + wantLegacyScope codersdk.APIKeyScope + wantErrDetail string }{ { - name: "all", - requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll}, - canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll}, - legacy: codersdk.APIKeyScopeAll, + name: "alias all is stored as coder:all", + sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll}, + wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll}, + wantLegacyScope: codersdk.APIKeyScopeAll, }, { - name: "application_connect", - requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect}, - canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderApplicationConnect}, - legacy: codersdk.APIKeyScopeApplicationConnect, + name: "alias application_connect is stored as coder:application_connect", + sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect}, + wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderApplicationConnect}, + wantLegacyScope: codersdk.APIKeyScopeApplicationConnect, }, { - // More than one element, so a canonicalization that only handled - // single-element requests would fail here. - name: "alias alongside another scope", - requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll, codersdk.APIKeyScopeWorkspaceRead}, - canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll, codersdk.APIKeyScopeWorkspaceRead}, - legacy: codersdk.APIKeyScopeAll, + name: "alias is stored canonically alongside another scope", + sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll, codersdk.APIKeyScopeWorkspaceRead}, + wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll, codersdk.APIKeyScopeWorkspaceRead}, + wantLegacyScope: codersdk.APIKeyScopeAll, }, { - // An alias and its canonical spelling are two names on the way in - // and one name once stored. - name: "alias and canonical spelling collapse", - requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll, codersdk.APIKeyScopeCoderAll}, - canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll}, - legacy: codersdk.APIKeyScopeAll, + name: "alias and canonical spelling collapse to one scope", + sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll, codersdk.APIKeyScopeCoderAll}, + wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll}, + wantLegacyScope: codersdk.APIKeyScopeAll, }, { - // Nothing sends both fields today. Pinning the precedence keeps a - // later edit from quietly widening a read-only request to coder:all, - // which no other case here would catch. - // The singular field is empty because convertAPIKey derives it only - // for coder:all and coder:application_connect. That the caller sent - // APIKeyScopeAll and reads back nothing is the clearest evidence the - // plural field decided the result. - name: "plural wins over singular", - requested: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, - requestedLegacy: codersdk.APIKeyScopeAll, - canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, - legacy: "", + // The read-only request must not widen to the coder:all sent in the + // deprecated field. The legacy field reads back empty because + // convertAPIKey derives it only for coder:all and + // coder:application_connect. + name: "plural Scopes wins over singular Scope", + sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, + sendLegacyScope: codersdk.APIKeyScopeAll, + wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead}, + wantLegacyScope: "", }, { - name: "unknown scope", - requested: []codersdk.APIKeyScope{"not_a_real_scope"}, - wantErr: true, - wantDetail: "unknown API key scope", + name: "name that is no scope at all is rejected", + sendScopes: []codersdk.APIKeyScope{"not_a_real_scope"}, + wantErrDetail: "unknown API key scope", }, { - // A real api_key_scope member that IsExternalScope refuses, so the - // enum check in apikey.Generate would not catch it. Re-spelling it - // never helps, so the message must not read like a typo. - name: "internal scope", - requested: []codersdk.APIKeyScope{codersdk.APIKeyScope(database.ApiKeyScopeDebugInfoRead)}, - wantErr: true, - wantDetail: "is internal and cannot be requested", + // A real api_key_scope member that IsExternalScope refuses, so no + // re-spelling makes it requestable and the message must not read + // like a typo. + name: "internal scope is rejected", + sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScope(database.ApiKeyScopeDebugInfoRead)}, + wantErrDetail: "is internal and cannot be requested", }, } @@ -277,15 +250,15 @@ func TestTokenLegacyPluralScopeCompat(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client) _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ - Scope: tc.requestedLegacy, - Scopes: tc.requested, + Scope: tc.sendLegacyScope, + Scopes: tc.sendScopes, }) - if tc.wantErr { + if tc.wantErrDetail != "" { var sdkErr *codersdk.Error require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - require.Contains(t, sdkErr.Detail, string(tc.requested[0])) - require.Contains(t, sdkErr.Detail, tc.wantDetail) + require.Contains(t, sdkErr.Detail, string(tc.sendScopes[0])) + require.Contains(t, sdkErr.Detail, tc.wantErrDetail) return } require.NoError(t, err) @@ -293,26 +266,21 @@ func TestTokenLegacyPluralScopeCompat(t *testing.T) { keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{}) require.NoError(t, err) require.Len(t, keys, 1) - require.ElementsMatch(t, tc.canonical, keys[0].Scopes) - require.Equal(t, tc.legacy, keys[0].Scope) + require.ElementsMatch(t, tc.wantScopes, keys[0].Scopes) + require.Equal(t, tc.wantLegacyScope, keys[0].Scope) }) } } -// TestExternalScopesAreStorable pins the class of bug that -// TestTokenLegacyPluralScopeCompat pins two instances of: a name the rbac -// catalog calls public but the api_key_scope enum cannot store is accepted by -// the handler and then fails inside apikey.Generate. The rbac package cannot -// check this itself, since database imports rbac and not the other way around. -// -// ExternalScopeNames is the set the handler accepts, less the bare aliases, so -// CanonicalScopeName is a no-op here today. It is kept so an alias added to the -// list later is still checked against the enum; TestScopeAliases separately -// requires every alias to point at a name on this list. +// Lives in this package because database imports rbac, so rbac cannot check its +// own names against the api_key_scope enum. func TestExternalScopesAreStorable(t *testing.T) { t.Parallel() for _, name := range rbac.ExternalScopeNames() { + // CanonicalScopeName is a no-op today, since ExternalScopeNames omits + // the bare aliases. It stays so an alias added to that list later is + // still checked against the enum. canonical := rbac.CanonicalScopeName(rbac.ScopeName(name)) require.Truef(t, database.APIKeyScope(canonical).Valid(), "external scope %q canonicalizes to %q, which is not an api_key_scope member", From 3c905fa08a8e07ca21706b43da4f4d8e808104b3 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 25 Aug 2026 01:35:27 +0000 Subject: [PATCH 09/11] docs(coderd): cut the scope comments down to what the code does Rewrite three comments flagged in review as narrating history instead of behavior. writeUnrequestableScope now says what it distinguishes rather than which mistake each case came from, and the two ExternalScopeNames comments say the same thing in about half the words. --- coderd/apikey.go | 7 +++---- coderd/rbac/scopes_catalog.go | 13 +++++-------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/coderd/apikey.go b/coderd/apikey.go index 0cdc5728715..79f437d55f5 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -29,10 +29,9 @@ import ( // table in the API reference is a superset: it lists internal scopes too. const scopeDocsURL = "https://coder.com/docs/admin/users/sessions-tokens#api-key-scopes" -// writeUnrequestableScope answers 400 for a scope name a token may not carry. -// The two reasons need different words: a name that is no scope at all is a -// typo the caller fixes by re-spelling it, while an internal scope is a real -// api_key_scope member that no spelling will make requestable. +// writeUnrequestableScope answers 400 for a scope name a token may not carry, +// telling a name that is no scope at all apart from a real api_key_scope member +// that is internal to Coder. func writeUnrequestableScope(ctx context.Context, rw http.ResponseWriter, name rbac.ScopeName) { detail := fmt.Sprintf("unknown API key scope: %q. See %s for the scopes a token may request.", name, scopeDocsURL) if database.APIKeyScope(name).Valid() { diff --git a/coderd/rbac/scopes_catalog.go b/coderd/rbac/scopes_catalog.go index e6fd3ca186d..ca496eb485b 100644 --- a/coderd/rbac/scopes_catalog.go +++ b/coderd/rbac/scopes_catalog.go @@ -142,19 +142,16 @@ func CanonicalScopeName(name ScopeName) ScopeName { // `coder:all` and `coder:application_connect` spellings, the curated low-level // resource:action names, and the curated composite coder:* scopes. // -// Apart from the bare `all` and `application_connect` aliases, this is exactly -// the set IsExternalScope accepts, so a name missing here is a name ingress -// rejects. Every name returned is canonical, so a caller matching a -// client-supplied name against this list must run it through CanonicalScopeName -// first, or reject a spelling the same package calls public. +// This is the set IsExternalScope accepts, minus the bare `all` and +// `application_connect` aliases. Every name here is canonical, so match a +// client-supplied name through CanonicalScopeName or an alias reads as unknown. func ExternalScopeNames() []string { names := make([]string, 0, len(externalLowLevel)+len(externalComposite)+2) names = append(names, string(ScopeAll)) names = append(names, string(ScopeApplicationConnect)) - // curated low-level names. Listed unfiltered: IsExternalScope accepts every - // key in this map, so filtering here would hide an unparsable entry from - // the callers that check this list while ingress still admitted it. + // curated low-level names, unfiltered: IsExternalScope accepts every key + // here, so filtering would hide an unparsable entry instead of failing on it. for name := range externalLowLevel { names = append(names, string(name)) } From 387e7433d4367f1d04d73d48e2d9e750059958f0 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 25 Aug 2026 16:13:31 -0700 Subject: [PATCH 10/11] Update coderd/apikey/apikey.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: McKayla はな --- coderd/apikey/apikey.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index af07d280333..9b9de5232db 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -107,8 +107,7 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) } scopes = append(scopes, canonical) } - // An alias and its canonical spelling are distinct names on the way in and - // the same name here, so drop the repeats before they reach the column. + // Ensure scopes are still unique after canonicalizing. scopes = slice.Unique(scopes) token := fmt.Sprintf("%s-%s", keyID, keySecret) From 89cbcce6ce8e9394064724b4cb20d1c22a7f3a22 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 25 Aug 2026 16:14:12 -0700 Subject: [PATCH 11/11] Update coderd/apikey/apikey.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: McKayla はな --- coderd/apikey/apikey.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index 9b9de5232db..92863f68a02 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -95,10 +95,8 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) requested = database.APIKeyScopes{database.ApiKeyScopeCoderAll} } - // Callers may pass an alias spelling such as "all", which is not an - // api_key_scope enum member and so would fail the validity check. - // params.Scopes shares its backing array with the caller, so canonicalize - // into a new slice rather than in place. + // Canonicalize scope names before validating them against the set of known + // scopes. scopes := make(database.APIKeyScopes, 0, len(requested)) for _, s := range requested { canonical := database.APIKeyScope(rbac.CanonicalScopeName(rbac.ScopeName(s)))