Skip to content

Commit 7d68b72

Browse files
committed
feat: add multiple API key scopes support with granular permissions
Change-Id: I5857fd833f8114d53f575b9fa48a8e5e7dbfdb2c Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 99e7a7b commit 7d68b72

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+1612
-459
lines changed

.claude/notes/token-scopes.md

Lines changed: 541 additions & 0 deletions
Large diffs are not rendered by default.

cli/exp_scaletest.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1232,7 +1232,7 @@ func (r *RootCmd) scaletestDashboard() *serpent.Command {
12321232
name := fmt.Sprintf("dashboard-%s", usr.Username)
12331233
userTokResp, err := client.CreateToken(ctx, usr.ID.String(), codersdk.CreateTokenRequest{
12341234
Lifetime: 30 * 24 * time.Hour,
1235-
Scope: "",
1235+
Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll},
12361236
TokenName: fmt.Sprintf("scaletest-%d", time.Now().Unix()),
12371237
})
12381238
if err != nil {

coderd/apidoc/docs.go

Lines changed: 41 additions & 23 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: 47 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apikey.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ import (
2424
"github.com/coder/coder/v2/codersdk"
2525
)
2626

27+
// convertAPIKeyScopesToDatabase converts SDK API key scopes to database API key scopes
28+
func convertAPIKeyScopesToDatabase(scopes []codersdk.APIKeyScope) []database.APIKeyScope {
29+
dbScopes := make([]database.APIKeyScope, 0, len(scopes))
30+
for _, scope := range scopes {
31+
dbScopes = append(dbScopes, database.APIKeyScope(scope))
32+
}
33+
return dbScopes
34+
}
35+
2736
// Creates a new token API key with the given scope and lifetime.
2837
//
2938
// @Summary Create token API key
@@ -56,9 +65,10 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
5665
return
5766
}
5867

59-
scope := database.APIKeyScopeAll
60-
if scope != "" {
61-
scope = database.APIKeyScope(createToken.Scope)
68+
// Use the scopes from the request, or default to 'all' if empty
69+
scopes := createToken.Scopes
70+
if len(scopes) == 0 {
71+
scopes = []codersdk.APIKeyScope{codersdk.APIKeyScopeAll}
6272
}
6373

6474
tokenName := namesgenerator.GetRandomName(1)
@@ -71,7 +81,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
7181
UserID: user.ID,
7282
LoginType: database.LoginTypeToken,
7383
DefaultLifetime: api.DeploymentValues.Sessions.DefaultTokenDuration.Value(),
74-
Scope: scope,
84+
Scopes: convertAPIKeyScopesToDatabase(scopes), // New scopes array
7585
TokenName: tokenName,
7686
}
7787

@@ -380,7 +390,7 @@ func (api *API) validateAPIKeyLifetime(ctx context.Context, userID uuid.UUID, li
380390
// getMaxTokenLifetime returns the maximum allowed token lifetime for a user.
381391
// It distinguishes between regular users and owners.
382392
func (api *API) getMaxTokenLifetime(ctx context.Context, userID uuid.UUID) (time.Duration, error) {
383-
subject, _, err := httpmw.UserRBACSubject(ctx, api.Database, userID, rbac.ScopeAll)
393+
subject, _, err := httpmw.UserRBACSubject(ctx, api.Database, userID, []rbac.ExpandableScope{rbac.ScopeAll})
384394
if err != nil {
385395
return 0, xerrors.Errorf("failed to get user rbac subject: %w", err)
386396
}

coderd/apikey/apikey.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ type CreateParams struct {
2525
// Optional.
2626
ExpiresAt time.Time
2727
LifetimeSeconds int64
28-
Scope database.APIKeyScope
28+
Scope database.APIKeyScope // Legacy single scope (for backward compatibility)
29+
Scopes []database.APIKeyScope // New scopes array
2930
TokenName string
3031
RemoteAddr string
3132
}
@@ -62,14 +63,24 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
6263

6364
bitlen := len(ip) * 8
6465

65-
scope := database.APIKeyScopeAll
66-
if params.Scope != "" {
67-
scope = params.Scope
66+
// Determine scopes - prioritize Scopes array, fallback to legacy Scope
67+
var scopes []database.APIKeyScope
68+
if len(params.Scopes) > 0 {
69+
scopes = params.Scopes
70+
} else {
71+
// Fallback to legacy single scope
72+
scope := database.APIKeyScopeAll
73+
if params.Scope != "" {
74+
scope = params.Scope
75+
}
76+
scopes = []database.APIKeyScope{scope}
6877
}
69-
switch scope {
70-
case database.APIKeyScopeAll, database.APIKeyScopeApplicationConnect:
71-
default:
72-
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", scope)
78+
79+
// Validate all scopes
80+
for _, scope := range scopes {
81+
if !scope.Valid() {
82+
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", scope)
83+
}
7384
}
7485

7586
token := fmt.Sprintf("%s-%s", keyID, keySecret)
@@ -92,7 +103,7 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
92103
UpdatedAt: dbtime.Now(),
93104
HashedSecret: hashed[:],
94105
LoginType: params.LoginType,
95-
Scope: scope,
106+
Scopes: scopes, // New scopes array
96107
TokenName: params.TokenName,
97108
}, token, nil
98109
}

coderd/apikey/apikey_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func TestGenerate(t *testing.T) {
3535
LifetimeSeconds: int64(time.Hour.Seconds()),
3636
TokenName: "hello",
3737
RemoteAddr: "1.2.3.4",
38-
Scope: database.APIKeyScopeApplicationConnect,
38+
Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect},
3939
},
4040
},
4141
{
@@ -48,7 +48,7 @@ func TestGenerate(t *testing.T) {
4848
LifetimeSeconds: int64(time.Hour.Seconds()),
4949
TokenName: "hello",
5050
RemoteAddr: "1.2.3.4",
51-
Scope: database.APIKeyScope("test"),
51+
Scopes: []database.APIKeyScope{database.APIKeyScope("test")},
5252
},
5353
fail: true,
5454
},
@@ -62,7 +62,7 @@ func TestGenerate(t *testing.T) {
6262
ExpiresAt: time.Time{},
6363
TokenName: "hello",
6464
RemoteAddr: "1.2.3.4",
65-
Scope: database.APIKeyScopeApplicationConnect,
65+
Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect},
6666
},
6767
},
6868
{
@@ -75,7 +75,7 @@ func TestGenerate(t *testing.T) {
7575
ExpiresAt: time.Time{},
7676
TokenName: "hello",
7777
RemoteAddr: "1.2.3.4",
78-
Scope: database.APIKeyScopeApplicationConnect,
78+
Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect},
7979
},
8080
},
8181
{
@@ -88,7 +88,7 @@ func TestGenerate(t *testing.T) {
8888
LifetimeSeconds: int64(time.Hour.Seconds()),
8989
TokenName: "hello",
9090
RemoteAddr: "",
91-
Scope: database.APIKeyScopeApplicationConnect,
91+
Scopes: []database.APIKeyScope{database.APIKeyScopeApplicationConnect},
9292
},
9393
},
9494
{
@@ -101,7 +101,7 @@ func TestGenerate(t *testing.T) {
101101
LifetimeSeconds: int64(time.Hour.Seconds()),
102102
TokenName: "hello",
103103
RemoteAddr: "1.2.3.4",
104-
Scope: "",
104+
Scopes: []database.APIKeyScope{},
105105
},
106106
},
107107
}
@@ -158,10 +158,10 @@ func TestGenerate(t *testing.T) {
158158
assert.Equal(t, "0.0.0.0", key.IPAddress.IPNet.IP.String())
159159
}
160160

161-
if tc.params.Scope != "" {
162-
assert.Equal(t, tc.params.Scope, key.Scope)
161+
if len(tc.params.Scopes) > 0 {
162+
assert.Equal(t, tc.params.Scopes, key.Scopes)
163163
} else {
164-
assert.Equal(t, database.APIKeyScopeAll, key.Scope)
164+
assert.Equal(t, []database.APIKeyScope{database.APIKeyScopeAll}, key.Scopes)
165165
}
166166

167167
if tc.params.TokenName != "" {

coderd/apikey_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func TestTokenCRUD(t *testing.T) {
4747
// expires_at should default to 30 days
4848
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*6))
4949
require.Less(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*24*8))
50-
require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope)
50+
require.Equal(t, []codersdk.APIKeyScope{codersdk.APIKeyScopeAll}, keys[0].Scopes)
5151

5252
// no update
5353

@@ -73,7 +73,7 @@ func TestTokenScoped(t *testing.T) {
7373
_ = coderdtest.CreateFirstUser(t, client)
7474

7575
res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
76-
Scope: codersdk.APIKeyScopeApplicationConnect,
76+
Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect},
7777
})
7878
require.NoError(t, err)
7979
require.Greater(t, len(res.Key), 2)
@@ -82,7 +82,7 @@ func TestTokenScoped(t *testing.T) {
8282
require.NoError(t, err)
8383
require.EqualValues(t, len(keys), 1)
8484
require.Contains(t, res.Key, keys[0].ID)
85-
require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect)
85+
require.Equal(t, []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect}, keys[0].Scopes)
8686
}
8787

8888
func TestUserSetTokenDuration(t *testing.T) {

0 commit comments

Comments
 (0)