Skip to content

Commit ce693c0

Browse files
committed
refactor: add allow_list field to API keys for resource scoping
- Add allow_list field to CreateTokenRequest API and database schema - Implement APIKeyEffectiveScope that merges scopes with token allow_list - Create x/wildcard package for type-safe wildcard values - Add rbac.ParseAllowList for validating and normalizing allow lists - Support resource targeting like "workspace:*" or "template:<uuid>" - Default to wildcard (*:*) for backward compatibility
1 parent 4552f9e commit ce693c0

26 files changed

Lines changed: 836 additions & 40 deletions

File tree

.swaggo

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,11 @@ replace time.Duration int64
66
replace github.com/coder/coder/v2/codersdk.ProvisionerType string
77
// Do not render netip.Addr
88
replace netip.Addr string
9+
10+
// Map generics of wildcard.Value[T] to concrete, client-friendly types.
11+
12+
// Type: wildcard.Value[codersdk.RBACResource] -> codersdk.RBACResource (already includes "*")
13+
replace github.com/coder/coder/v2/x/wildcard.$wildcard.Value-codersdk_RBACResource github.com/coder/coder/v2/codersdk.RBACResource
14+
15+
// ID: wildcard.Value[uuid.UUID] -> string (we’ll add a doc note to allow "*")
16+
replace github.com/coder/coder/v2/x/wildcard.$wildcard.Value-uuid_UUID string

coderd/apidoc/docs.go

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

coderd/apikey.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,45 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
116116
TokenName: tokenName,
117117
}
118118

119+
if len(createToken.AllowList) > 0 {
120+
rbacAllowListElements := make([]rbac.AllowListElement, 0, len(createToken.AllowList))
121+
for _, t := range createToken.AllowList {
122+
entry, err := rbac.NewAllowListElement(t.Type.String(), t.ID.String())
123+
if err != nil {
124+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
125+
Message: "Failed to create API key.",
126+
Detail: err.Error(),
127+
})
128+
return
129+
}
130+
rbacAllowListElements = append(rbacAllowListElements, entry)
131+
}
132+
133+
rbacAllowList, err := rbac.NewAllowList(rbacAllowListElements, 128)
134+
if err != nil {
135+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
136+
Message: "Failed to create API key.",
137+
Detail: err.Error(),
138+
})
139+
return
140+
}
141+
142+
dbAllowList := make(database.AllowList, 0, len(rbacAllowList))
143+
for _, e := range rbacAllowList {
144+
target, err := database.NewAllowListTarget(e.Type, e.ID)
145+
if err != nil {
146+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
147+
Message: "Failed to create API key.",
148+
Detail: err.Error(),
149+
})
150+
return
151+
}
152+
dbAllowList = append(dbAllowList, target)
153+
}
154+
155+
params.AllowList = dbAllowList
156+
}
157+
119158
if createToken.Lifetime != 0 {
120159
err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime)
121160
if err != nil {

coderd/apikey/apikey.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ type CreateParams struct {
3434
Scopes database.APIKeyScopes
3535
TokenName string
3636
RemoteAddr string
37+
// AllowList is an optional, normalized allow-list
38+
// of resource type and uuid entries. If empty, defaults to wildcard.
39+
AllowList database.AllowList
3740
}
3841

3942
// Generate generates an API key, returning the key as a string as well as the
@@ -115,7 +118,7 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
115118
HashedSecret: hashed[:],
116119
LoginType: params.LoginType,
117120
Scopes: scopes,
118-
AllowList: database.AllowList{database.AllowListWildcard()},
121+
AllowList: params.AllowList,
119122
TokenName: params.TokenName,
120123
}, token, nil
121124
}

coderd/database/dbauthz/setup_test.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/coder/coder/v2/coderd/rbac/policy"
3232
"github.com/coder/coder/v2/coderd/rbac/regosql"
3333
"github.com/coder/coder/v2/coderd/util/slice"
34+
"github.com/coder/coder/v2/x/wildcard"
3435
)
3536

3637
var errMatchAny = xerrors.New("match any error")
@@ -225,17 +226,21 @@ func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db dat
225226
if testCase.outputs != nil {
226227
// Assert the required outputs
227228
s.Equal(len(testCase.outputs), len(outputs), "method %q returned unexpected number of outputs", methodName)
229+
cmpOptions := []cmp.Option{
230+
// Equate nil and empty slices.
231+
cmpopts.EquateEmpty(),
232+
cmpopts.EquateComparable(wildcard.Value[string]{}, wildcard.Value[uuid.UUID]{}),
233+
}
228234
for i := range outputs {
229235
a, b := testCase.outputs[i].Interface(), outputs[i].Interface()
230236

231237
// To avoid the extra small overhead of gob encoding, we can
232238
// first check if the values are equal with regard to order.
233239
// If not, re-check disregarding order and show a nice diff
234240
// output of the two values.
235-
if !cmp.Equal(a, b, cmpopts.EquateEmpty()) {
236-
if diff := cmp.Diff(a, b,
237-
// Equate nil and empty slices.
238-
cmpopts.EquateEmpty(),
241+
if !cmp.Equal(a, b, cmpOptions...) {
242+
diffOpts := append(
243+
append([]cmp.Option{}, cmpOptions...),
239244
// Allow slice order to be ignored.
240245
cmpopts.SortSlices(func(a, b any) bool {
241246
var ab, bb strings.Builder
@@ -247,7 +252,8 @@ func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db dat
247252
// https://github.com/google/go-cmp/issues/67
248253
return ab.String() < bb.String()
249254
}),
250-
); diff != "" {
255+
)
256+
if diff := cmp.Diff(a, b, diffOpts...); diff != "" {
251257
s.Failf("compare outputs failed", "method %q returned unexpected output %d (-want +got):\n%s", methodName, i, diff)
252258
}
253259
}

coderd/database/dbgen/dbgen.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey, munge ...func
186186
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
187187
LoginType: takeFirst(seed.LoginType, database.LoginTypePassword),
188188
Scopes: takeFirstSlice([]database.APIKeyScope(seed.Scopes), []database.APIKeyScope{database.ApiKeyScopeCoderAll}),
189-
AllowList: takeFirstSlice(seed.AllowList, database.AllowList{database.AllowListWildcard()}),
189+
AllowList: takeFirstSlice(seed.AllowList, database.AllowList{}),
190190
TokenName: takeFirst(seed.TokenName),
191191
}
192192
for _, fn := range munge {

coderd/database/modelmethods.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,41 @@ func (s APIKeyScopes) Name() rbac.RoleIdentifier {
235235
return rbac.RoleIdentifier{Name: "scopes[" + strings.Join(names, "+") + "]"}
236236
}
237237

238+
// APIKeyEffectiveScope merges expanded scopes with the API key's DB allow_list.
239+
// If the DB allow_list is a wildcard or empty, the merged scope's allow list is unchanged.
240+
// Otherwise, the DB allow_list overrides the merged AllowIDList to enforce the token's
241+
// resource scoping consistently across all permissions.
242+
type APIKeyEffectiveScope struct {
243+
Scopes APIKeyScopes
244+
AllowList AllowList
245+
}
246+
247+
func (e APIKeyEffectiveScope) Name() rbac.RoleIdentifier { return e.Scopes.Name() }
248+
249+
func (e APIKeyEffectiveScope) Expand() (rbac.Scope, error) {
250+
merged, err := e.Scopes.Expand()
251+
if err != nil {
252+
return rbac.Scope{}, err
253+
}
254+
if len(e.AllowList) == 0 {
255+
return merged, nil
256+
}
257+
258+
// If allow list contains a single wildcard (*:*), keep merged allow list as-is
259+
for _, entry := range e.AllowList {
260+
if entry.Type.IsAny() && entry.ID.IsAny() {
261+
return merged, nil
262+
}
263+
}
264+
265+
out := make([]rbac.AllowListElement, 0, len(e.AllowList))
266+
for _, t := range e.AllowList {
267+
out = append(out, rbac.AllowListElement{Type: t.Type.String(), ID: t.ID.String()})
268+
}
269+
merged.AllowIDList = out
270+
return merged, nil
271+
}
272+
238273
func (k APIKey) RBACObject() rbac.Object {
239274
return rbac.ResourceApiKey.WithIDString(k.ID).
240275
WithOwner(k.UserID.String())

coderd/database/types.go

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/coder/coder/v2/coderd/rbac"
1717
"github.com/coder/coder/v2/coderd/rbac/policy"
18+
"github.com/coder/coder/v2/x/wildcard"
1819
)
1920

2021
// AuditOAuthConvertState is never stored in the database. It is stored in a cookie
@@ -163,9 +164,7 @@ func (m StringMapOfInt) Value() (driver.Value, error) {
163164

164165
type CustomRolePermissions []CustomRolePermission
165166

166-
// APIKeyScopes implements sql.Scanner and driver.Valuer so it can be read from
167-
// and written to the Postgres api_key_scope[] enum array column.
168-
func (s *APIKeyScopes) Scan(src interface{}) error {
167+
func (s *APIKeyScopes) Scan(src any) error {
169168
var arr []string
170169
if err := pq.Array(&arr).Scan(src); err != nil {
171170
return err
@@ -318,26 +317,43 @@ func ParseIP(ipStr string) pqtype.Inet {
318317
// It encodes a resource tuple (type, id) and provides helpers for
319318
// consistent string and JSON representations across the codebase.
320319
type AllowListTarget struct {
321-
Type string `json:"type"`
322-
ID string `json:"id"`
320+
Type wildcard.Value[string] `json:"type"`
321+
ID wildcard.Value[uuid.UUID] `json:"id"`
323322
}
324323

325324
// String returns the canonical database representation "type:id".
326-
func (t AllowListTarget) String() string {
327-
return t.Type + ":" + t.ID
325+
func (t AllowListTarget) String() string { return t.Type.String() + ":" + t.ID.String() }
326+
327+
func NewAllowListTarget(typ string, id string) (AllowListTarget, error) {
328+
var (
329+
anyType wildcard.Value[string]
330+
anyID wildcard.Value[uuid.UUID]
331+
)
332+
333+
if typ != policy.WildcardSymbol {
334+
anyType = wildcard.Of(typ)
335+
}
336+
337+
if id != policy.WildcardSymbol {
338+
u, err := uuid.Parse(id)
339+
if err != nil {
340+
return AllowListTarget{}, xerrors.Errorf("invalid %s ID: %q", typ, id)
341+
}
342+
anyID = wildcard.Of(u)
343+
}
344+
345+
return AllowListTarget{Type: anyType, ID: anyID}, nil
328346
}
329347

330348
// ParseAllowListTarget parses the canonical string form "type:id".
331349
func ParseAllowListTarget(s string) (AllowListTarget, error) {
332-
targetType, id, ok := rbac.ParseResourceAction(s)
350+
targetType, rawID, ok := rbac.ParseResourceAction(s)
333351
if !ok {
334352
return AllowListTarget{}, xerrors.Errorf("invalid allow list target: %q", s)
335353
}
336-
return AllowListTarget{Type: targetType, ID: id}, nil
337-
}
338354

339-
// AllowListWildcard returns the wildcard allow-list entry {"*","*"}.
340-
func AllowListWildcard() AllowListTarget { return AllowListTarget{Type: "*", ID: "*"} }
355+
return NewAllowListTarget(targetType, rawID)
356+
}
341357

342358
// AllowList is a typed wrapper around a list of AllowListTarget entries.
343359
// It implements sql.Scanner and driver.Valuer so it can be stored in and

coderd/httpmw/apikey.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,10 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
434434
// If the key is valid, we also fetch the user roles and status.
435435
// The roles are used for RBAC authorize checks, and the status
436436
// is to block 'suspended' users from accessing the platform.
437-
actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.Scopes)
437+
actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, database.APIKeyEffectiveScope{
438+
Scopes: key.Scopes,
439+
AllowList: key.AllowList,
440+
})
438441
if err != nil {
439442
return write(http.StatusUnauthorized, codersdk.Response{
440443
Message: internalErrorMessage,

0 commit comments

Comments
 (0)