Skip to content

Commit 6b3d164

Browse files
committed
feat: add public API key scope endpoint
Add /auth/scopes endpoint returning curated list of public low-level API key scopes (resource:action format). This read-only endpoint requires no authentication and provides SDK constants for all public scopes.
1 parent 8e56891 commit 6b3d164

16 files changed

Lines changed: 623 additions & 57 deletions

File tree

Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,7 @@ GEN_FILES := \
647647
coderd/rbac/object_gen.go \
648648
codersdk/rbacresources_gen.go \
649649
coderd/rbac/scopes_constants_gen.go \
650+
codersdk/apikey_scopes_gen.go \
650651
docs/admin/integrations/prometheus.md \
651652
docs/reference/cli/index.md \
652653
docs/admin/security/audit-logs.md \
@@ -860,6 +861,12 @@ codersdk/rbacresources_gen.go: scripts/typegen/codersdk.gotmpl scripts/typegen/m
860861
mv /tmp/rbacresources_gen.go codersdk/rbacresources_gen.go
861862
touch "$@"
862863

864+
codersdk/apikey_scopes_gen.go: scripts/apikeyscopesgen/main.go coderd/rbac/scopes_catalog.go coderd/rbac/scopes.go
865+
# Generate SDK constants for public low-level API key scopes.
866+
go run ./scripts/apikeyscopesgen > /tmp/apikey_scopes_gen.go
867+
mv /tmp/apikey_scopes_gen.go codersdk/apikey_scopes_gen.go
868+
touch "$@"
869+
863870
site/src/api/rbacresourcesGenerated.ts: site/node_modules/.installed scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
864871
go run scripts/typegen/main.go rbac typescript > "$@"
865872
(cd site/ && pnpm exec biome format --write src/api/rbacresourcesGenerated.ts)

coderd/apidoc/docs.go

Lines changed: 86 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 88 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apikey.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,19 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
6666
return
6767
}
6868

69-
scope := database.APIKeyScopeAll
70-
if scope != "" {
71-
scope = database.APIKeyScope(createToken.Scope)
69+
// Map and validate requested scope.
70+
// Accept special scopes (all, application_connect) and curated public low-level scopes.
71+
scopes := database.APIKeyScopes{database.APIKeyScopeAll}
72+
if createToken.Scope != "" {
73+
name := string(createToken.Scope)
74+
if !rbac.IsExternalScope(rbac.ScopeName(name)) {
75+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
76+
Message: "Failed to create API key.",
77+
Detail: fmt.Sprintf("invalid API key scope: %q", name),
78+
})
79+
return
80+
}
81+
scopes = database.APIKeyScopes{database.APIKeyScope(name)}
7282
}
7383

7484
tokenName := namesgenerator.GetRandomName(1)
@@ -81,7 +91,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
8191
UserID: user.ID,
8292
LoginType: database.LoginTypeToken,
8393
DefaultLifetime: api.DeploymentValues.Sessions.DefaultTokenDuration.Value(),
84-
Scope: scope,
94+
Scopes: scopes,
8595
TokenName: tokenName,
8696
}
8797

coderd/apikey/apikey.go

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@ type CreateParams struct {
2525
// Optional.
2626
ExpiresAt time.Time
2727
LifetimeSeconds int64
28-
Scope database.APIKeyScope
29-
TokenName string
30-
RemoteAddr string
28+
// Scope is legacy single-scope input kept for backward compatibility.
29+
//
30+
// Deprecated: Prefer Scopes for new code.
31+
Scope database.APIKeyScope
32+
// Scopes is the full list of scopes to attach to the key.
33+
// If empty and Scope is set, the generator will use [Scope].
34+
// If both are empty, the generator will default to [APIKeyScopeAll].
35+
Scopes database.APIKeyScopes
36+
TokenName string
37+
RemoteAddr string
3138
}
3239

3340
// Generate generates an API key, returning the key as a string as well as the
@@ -62,14 +69,20 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
6269

6370
bitlen := len(ip) * 8
6471

65-
scope := database.APIKeyScopeAll
66-
if params.Scope != "" {
67-
scope = params.Scope
68-
}
69-
switch scope {
70-
case database.APIKeyScopeAll, database.APIKeyScopeApplicationConnect:
72+
var scopes database.APIKeyScopes
73+
switch {
74+
case len(params.Scopes) > 0:
75+
scopes = params.Scopes
76+
case params.Scope != "":
77+
scopes = database.APIKeyScopes{params.Scope}
7178
default:
72-
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", scope)
79+
scopes = database.APIKeyScopes{database.APIKeyScopeAll}
80+
}
81+
82+
for _, s := range scopes {
83+
if !s.Valid() {
84+
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", s)
85+
}
7386
}
7487

7588
token := fmt.Sprintf("%s-%s", keyID, keySecret)
@@ -92,7 +105,7 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
92105
UpdatedAt: dbtime.Now(),
93106
HashedSecret: hashed[:],
94107
LoginType: params.LoginType,
95-
Scopes: database.APIKeyScopes{scope},
108+
Scopes: scopes,
96109
AllowList: database.AllowList{database.AllowListWildcard()},
97110
TokenName: params.TokenName,
98111
}, token, nil
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package coderd_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/coder/coder/v2/coderd/coderdtest"
10+
"github.com/coder/coder/v2/codersdk"
11+
"github.com/coder/coder/v2/testutil"
12+
)
13+
14+
func TestTokenCreation_ScopeValidation(t *testing.T) {
15+
t.Parallel()
16+
17+
cases := []struct {
18+
name string
19+
scope codersdk.APIKeyScope
20+
wantErr bool
21+
}{
22+
{name: "AllowsPublicLowLevelScope", scope: "workspace:read", wantErr: false},
23+
{name: "RejectsInternalOnlyScope", scope: "debug_info:read", wantErr: true},
24+
{name: "AllowsLegacyScopes", scope: "application_connect", wantErr: false},
25+
{name: "AllowsCanonicalSpecialScope", scope: "all", wantErr: false},
26+
}
27+
28+
for _, tc := range cases {
29+
t.Run(tc.name, func(t *testing.T) {
30+
t.Parallel()
31+
32+
client := coderdtest.New(t, nil)
33+
_ = coderdtest.CreateFirstUser(t, client)
34+
35+
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitShort)
36+
defer cancel()
37+
38+
resp, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{Scope: tc.scope})
39+
if tc.wantErr {
40+
require.Error(t, err)
41+
return
42+
}
43+
require.NoError(t, err)
44+
require.NotEmpty(t, resp.Key)
45+
})
46+
}
47+
}

0 commit comments

Comments
 (0)