fix: canonicalize API key scope aliases at ingress (#28322)

`rbac.IsExternalScope` accepts `all` and `application_connect`, neither
of which is an `api_key_scope` enum member. The plural `Scopes` field
stored the name as given, so `{"scopes":["all"]}` passed validation and
then 500'd inside `apikey.Generate` with `invalid API key scope: "all"`.
Reachable from `coder tokens create --scope all`, from the spellings
`docs/admin/users/sessions-tokens.md` taught, and from the `codersdk`
constants exported for exactly this purpose.

- Canonicalize inside `apikey.Generate`, in the loop that already
validates each name. One statement covers every caller and all three
input paths: plural `Scopes`, deprecated singular `Scope`, and the
default. Replaces two open-coded switches and the handler's own
per-element copy, so one place decides the stored spelling and the
handler only decides what may be requested.
- Deduplicate in the same pass. An alias and its canonical spelling are
two names going in and one name in the column.
- `ExternalScopeNames()` lists every name `IsExternalScope` accepts,
instead of dropping catalog entries that fail to parse. A curated entry
that cannot be stored used to pass every test and 500 at runtime; it now
fails two.
- Split the 400. A misspelled name and an internal `api_key_scope`
member need different words, since no re-spelling makes the second
requestable. Both rejection sites share one helper that names the case
and links the docs.
- Add reject-path tests for `not_a_real_scope` and `debug_info:read`.
The second is a valid enum member the rbac catalog treats as internal,
so before this both handler guards could be deleted with the suite still
green. A further case pins that plural `Scopes` wins when a caller sets
both fields.
- `TestExternalScopesAreStorable` pins the class rather than the two
known instances: every public rbac scope name must be storable.
`coderd/rbac` cannot assert this itself, since `database` imports `rbac`
and not the reverse.
- Docs use the canonical spellings, link the `codersdk.APIKeyScope`
schema, and say which scopes a token cannot request.

Egress is unchanged: `convertAPIKey` still derives a legacy singular
name on the way out. `ExternalScopeNames()` returns the same 57 names as
before, and `codersdk/apikey_scopes_gen.go` regenerates identically.

`rbac.CanonicalScopeName` merged with #28167, so this applies to `main`
and reviews on its own. The OAuth2 provider ignores scopes entirely
today (`authorize.go:237`, `tokens.go:377`, `tokens.go:520`), so it is
unaffected until those TODOs resolve.

Deferred from review: PLAT-528, PLAT-529, PLAT-530, and PLAT-532.
PLAT-532 covers `{"scopes":[]}` and `{"allow_list":[]}` defaulting open,
which predates this PR.

---------

Co-authored-by: McKayla はな <mckayla@hey.com>
This commit is contained in:
Bobby Ho
2026-08-25 17:12:51 -07:00
committed by GitHub
parent 895c5c77a1
commit c9fc5b7ea1
6 changed files with 232 additions and 50 deletions
+28 -23
View File
@@ -25,6 +25,24 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// 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"
// 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() {
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,40 +83,27 @@ 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.
// 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. 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 := 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),
})
name := rbac.ScopeName(s)
if !rbac.IsExternalScope(name) {
writeUnrequestableScope(ctx, rw, 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 or unsupported API key scope: %q", name),
})
name := rbac.ScopeName(createToken.Scope)
if !rbac.IsExternalScope(name) {
writeUnrequestableScope(ctx, rw, 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(name)}
}
tokenName := namesgenerator.NameDigitWith("_")
+15 -15
View File
@@ -13,7 +13,9 @@ 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/coderd/util/slice"
"github.com/coder/coder/v2/cryptorand"
)
@@ -82,31 +84,29 @@ 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 != "":
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}
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() {
// 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)))
if !canonical.Valid() {
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", s)
}
scopes = append(scopes, canonical)
}
// Ensure scopes are still unique after canonicalizing.
scopes = slice.Unique(scopes)
token := fmt.Sprintf("%s-%s", keyID, keySecret)
+65
View File
@@ -1,6 +1,7 @@
package apikey_test
import (
"slices"
"strings"
"testing"
"time"
@@ -173,6 +174,70 @@ func TestGenerate(t *testing.T) {
}
}
func TestGenerateCanonicalizesScopeAliases(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 := slices.Clone(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) {
+108
View File
@@ -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,6 +181,113 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) {
}
}
func TestCreateTokenScopes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sendScopes []codersdk.APIKeyScope
sendLegacyScope codersdk.APIKeyScope
wantScopes []codersdk.APIKeyScope
wantLegacyScope codersdk.APIKeyScope
wantErrDetail string
}{
{
name: "alias all is stored as coder:all",
sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll},
wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll},
wantLegacyScope: codersdk.APIKeyScopeAll,
},
{
name: "alias application_connect is stored as coder:application_connect",
sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect},
wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderApplicationConnect},
wantLegacyScope: codersdk.APIKeyScopeApplicationConnect,
},
{
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,
},
{
name: "alias and canonical spelling collapse to one scope",
sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll, codersdk.APIKeyScopeCoderAll},
wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll},
wantLegacyScope: codersdk.APIKeyScopeAll,
},
{
// 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: "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 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",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scope: tc.sendLegacyScope,
Scopes: tc.sendScopes,
})
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.sendScopes[0]))
require.Contains(t, sdkErr.Detail, tc.wantErrDetail)
return
}
require.NoError(t, err)
keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
require.NoError(t, err)
require.Len(t, keys, 1)
require.ElementsMatch(t, tc.wantScopes, keys[0].Scopes)
require.Equal(t, tc.wantLegacyScope, keys[0].Scope)
})
}
}
// 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",
name, canonical)
}
}
func TestUserSetTokenDuration(t *testing.T) {
t.Parallel()
+6 -8
View File
@@ -142,20 +142,18 @@ 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.
// 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, filtered for validity
// 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 {
if _, _, ok := parseLowLevelScope(name); ok {
names = append(names, string(name))
}
names = append(names, string(name))
}
// curated composite names
+10 -4
View File
@@ -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.
@@ -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,9 +145,15 @@ 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
[`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`.
### Allow lists (advanced)