Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
25 changes: 9 additions & 16 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,40 +65,33 @@ 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
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
// 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 {
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
33 changes: 18 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,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 != "":
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() {
// Callers may pass an alias spelling such as "all", which is not an
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
// 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.
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
scopes = slice.Unique(scopes)

token := fmt.Sprintf("%s-%s", keyID, keySecret)

Expand Down
69 changes: 69 additions & 0 deletions coderd/apikey/apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Comment thread
BobbyHo marked this conversation as resolved.
Outdated

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
113 changes: 113 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,118 @@ 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.
func TestTokenLegacyPluralScopeCompat(t *testing.T) {
Comment thread
BobbyHo marked this conversation as resolved.
Outdated
t.Parallel()

cases := []struct {
name string
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.APIKeyScope{codersdk.APIKeyScopeAll},
canonical: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll},
legacy: codersdk.APIKeyScopeAll,
},
{
name: "application_connect",
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) {
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{
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.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()

Expand Down
6 changes: 4 additions & 2 deletions docs/admin/users/sessions-tokens.md
Original file line number Diff line number Diff line change
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,10 +145,12 @@
- `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.
Comment thread
BobbyHo marked this conversation as resolved.
Outdated

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:
Expand Down
Loading