Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
51 changes: 28 additions & 23 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
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)) {
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) {
Comment thread
BobbyHo marked this conversation as resolved.
Comment thread
BobbyHo marked this conversation as resolved.
writeUnrequestableScope(ctx, rw, name)
return
}
scopes = append(scopes, database.APIKeyScope(name))
}
} else if string(createToken.Scope) != "" {
Comment thread
BobbyHo marked this conversation as resolved.
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("_")
Expand Down
30 changes: 15 additions & 15 deletions coderd/apikey/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)

Expand Down
65 changes: 65 additions & 0 deletions coderd/apikey/apikey_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package apikey_test

import (
"slices"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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) {
Expand Down
108 changes: 108 additions & 0 deletions coderd/apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Comment thread
BobbyHo marked this conversation as resolved.
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()

Expand Down
14 changes: 6 additions & 8 deletions coderd/rbac/scopes_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions docs/admin/users/sessions-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
### 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.

Expand Down Expand Up @@ -123,11 +123,11 @@

## 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.

### Creating tokens with scopes

Check warning on line 130 in docs/admin/users/sessions-tokens.md

View workflow job for this annotation

GitHub Actions / lint-docs

Coder.GerundHeading

Heading starts with an -ing word ('Creating'); prefer the imperative ('Install') or the noun ('Installation'). See capitalization-and-punctuation.md#no-gerund-leading-headings.

You can specify scopes when creating a token using the `--scope` flag:

Expand All @@ -145,9 +145,15 @@
- `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)

Expand Down
Loading