mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
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:
+28
-23
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user