Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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.
  • Loading branch information
BobbyHo committed Aug 24, 2026
commit 4a402d352ca4aaedd2ea20257ee368eff1ae789f
21 changes: 8 additions & 13 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
// members, so every accepted name is canonicalized before it is stored.
scopes := database.APIKeyScopes{database.ApiKeyScopeCoderAll}
if len(createToken.Scopes) > 0 {
Comment thread
BobbyHo marked this conversation as resolved.
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) {
Comment thread
BobbyHo marked this conversation as resolved.
Comment thread
BobbyHo marked this conversation as resolved.
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),
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
})
return
}
scopes = append(scopes, database.APIKeyScope(name))
scopes = append(scopes, database.APIKeyScope(rbac.CanonicalScopeName(name)))
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
}
} else if string(createToken.Scope) != "" {
Comment thread
BobbyHo marked this conversation as resolved.
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("_")
Expand Down
15 changes: 5 additions & 10 deletions coderd/apikey/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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))
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
scopes = database.APIKeyScopes{database.APIKeyScope(canonical)}
default:
// Default to coder:all scope for backward compatibility.
scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll}
Expand Down
48 changes: 48 additions & 0 deletions coderd/apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,54 @@ func TestTokenLegacySingularScopeCompat(t *testing.T) {
}
}

// The plural Scopes field accepts the same legacy names as the singular Scope
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
// 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) {
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
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) {
Comment thread
BobbyHo marked this conversation as resolved.
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()

Expand Down
Loading