Skip to content

Commit ff0d568

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 2547799 commit ff0d568

17 files changed

Lines changed: 707 additions & 27 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/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/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,

coderd/rbac/allowlist.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package rbac
2+
3+
import (
4+
"sort"
5+
"strings"
6+
7+
"github.com/google/uuid"
8+
"golang.org/x/xerrors"
9+
10+
"github.com/coder/coder/v2/coderd/rbac/policy"
11+
)
12+
13+
// ParseAllowListEntry parses a single allow-list entry string in the form
14+
// "*:*", "<resource_type>:*", or "<resource_type>:<uuid>" into an
15+
// AllowListElement with validation.
16+
func ParseAllowListEntry(s string) (AllowListElement, error) {
17+
s = strings.TrimSpace(strings.ToLower(s))
18+
res, id, ok := ParseResourceAction(s)
19+
if !ok {
20+
return AllowListElement{}, xerrors.Errorf("invalid allow_list entry %q: want <type>:<id>", s)
21+
}
22+
23+
return NewAllowListElement(res, id)
24+
}
25+
26+
func NewAllowListElement(resourceType string, id string) (AllowListElement, error) {
27+
if resourceType != policy.WildcardSymbol {
28+
if _, ok := policy.RBACPermissions[resourceType]; !ok {
29+
return AllowListElement{}, xerrors.Errorf("unknown resource type %q", resourceType)
30+
}
31+
}
32+
if id != policy.WildcardSymbol {
33+
if _, err := uuid.Parse(id); err != nil {
34+
return AllowListElement{}, xerrors.Errorf("invalid %s ID (must be UUID): %q", resourceType, id)
35+
}
36+
}
37+
38+
return AllowListElement{Type: resourceType, ID: id}, nil
39+
}
40+
41+
// ParseAllowList parses, validates, normalizes, and deduplicates a list of
42+
// allow-list entries. If max is <=0, a default cap of 128 is applied.
43+
func ParseAllowList(inputs []string, max int) ([]AllowListElement, error) {
44+
if len(inputs) == 0 {
45+
return nil, nil
46+
}
47+
if max <= 0 {
48+
max = 128
49+
}
50+
if len(inputs) > max {
51+
return nil, xerrors.Errorf("allow_list has %d entries; max allowed is %d", len(inputs), max)
52+
}
53+
54+
elems := make([]AllowListElement, 0, len(inputs))
55+
for _, s := range inputs {
56+
e, err := ParseAllowListEntry(s)
57+
if err != nil {
58+
return nil, err
59+
}
60+
// Global wildcard short-circuits
61+
if e.Type == policy.WildcardSymbol && e.ID == policy.WildcardSymbol {
62+
return []AllowListElement{AllowListAll()}, nil
63+
}
64+
elems = append(elems, e)
65+
}
66+
67+
return NewAllowList(elems, max)
68+
}
69+
70+
func NewAllowList(inputs []AllowListElement, max int) ([]AllowListElement, error) {
71+
if len(inputs) == 0 {
72+
return nil, nil
73+
}
74+
if max <= 0 {
75+
max = 128
76+
}
77+
if len(inputs) > max {
78+
return nil, xerrors.Errorf("allow_list has %d entries; max allowed is %d", len(inputs), max)
79+
}
80+
81+
// Collapse typed wildcards and drop shadowed IDs
82+
typedWildcard := map[string]struct{}{}
83+
idsByType := map[string]map[string]struct{}{}
84+
for _, e := range inputs {
85+
// Global wildcard short-circuits
86+
if e.Type == policy.WildcardSymbol && e.ID == policy.WildcardSymbol {
87+
return []AllowListElement{AllowListAll()}, nil
88+
}
89+
90+
if e.ID == policy.WildcardSymbol {
91+
typedWildcard[e.Type] = struct{}{}
92+
continue
93+
}
94+
if idsByType[e.Type] == nil {
95+
idsByType[e.Type] = map[string]struct{}{}
96+
}
97+
idsByType[e.Type][e.ID] = struct{}{}
98+
}
99+
100+
out := make([]AllowListElement, 0)
101+
for t, ids := range idsByType {
102+
if _, ok := typedWildcard[t]; ok {
103+
out = append(out, AllowListElement{Type: t, ID: policy.WildcardSymbol})
104+
continue
105+
}
106+
for id := range ids {
107+
out = append(out, AllowListElement{Type: t, ID: id})
108+
}
109+
}
110+
111+
sort.Slice(out, func(i, j int) bool {
112+
if out[i].Type == out[j].Type {
113+
return out[i].ID < out[j].ID
114+
}
115+
return out[i].Type < out[j].Type
116+
})
117+
return out, nil
118+
}

coderd/rbac/allowlist_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package rbac
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/uuid"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestParseAllowListEntry(t *testing.T) {
11+
t.Parallel()
12+
e, err := ParseAllowListEntry("*:*")
13+
require.NoError(t, err)
14+
require.Equal(t, AllowListElement{Type: "*", ID: "*"}, e)
15+
16+
e, err = ParseAllowListEntry("workspace:*")
17+
require.NoError(t, err)
18+
require.Equal(t, AllowListElement{Type: "workspace", ID: "*"}, e)
19+
20+
id := uuid.New().String()
21+
e, err = ParseAllowListEntry("template:" + id)
22+
require.NoError(t, err)
23+
require.Equal(t, AllowListElement{Type: "template", ID: id}, e)
24+
25+
_, err = ParseAllowListEntry("unknown:*")
26+
require.Error(t, err)
27+
_, err = ParseAllowListEntry("workspace:bad-uuid")
28+
require.Error(t, err)
29+
_, err = ParseAllowListEntry(":")
30+
require.Error(t, err)
31+
}
32+
33+
func TestParseAllowListNormalize(t *testing.T) {
34+
t.Parallel()
35+
id1 := uuid.New().String()
36+
id2 := uuid.New().String()
37+
38+
// Global wildcard short-circuits
39+
out, err := ParseAllowList([]string{"workspace:" + id1, "*:*", "template:" + id2}, 128)
40+
require.NoError(t, err)
41+
require.Equal(t, []AllowListElement{{Type: "*", ID: "*"}}, out)
42+
43+
// Typed wildcard collapses typed ids
44+
out, err = ParseAllowList([]string{"workspace:*", "workspace:" + id1, "workspace:" + id2}, 128)
45+
require.NoError(t, err)
46+
require.Equal(t, []AllowListElement{{Type: "workspace", ID: "*"}}, out)
47+
48+
// Dedup ids and sort deterministically
49+
out, err = ParseAllowList([]string{"template:" + id2, "template:" + id2, "template:" + id1}, 128)
50+
require.NoError(t, err)
51+
require.Len(t, out, 2)
52+
require.Equal(t, "template", out[0].Type)
53+
require.Equal(t, "template", out[1].Type)
54+
}
55+
56+
func TestParseAllowListLimit(t *testing.T) {
57+
t.Parallel()
58+
inputs := make([]string, 0, 130)
59+
for range 130 {
60+
inputs = append(inputs, "workspace:"+uuid.New().String())
61+
}
62+
_, err := ParseAllowList(inputs, 128)
63+
require.Error(t, err)
64+
}

codersdk/allowlist.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package codersdk
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/coder/coder/v2/coderd/rbac/policy"
9+
"github.com/coder/coder/v2/x/wildcard"
10+
"github.com/google/uuid"
11+
)
12+
13+
// APIAllowListTarget is a typed allow-list entry that marshals to a single string
14+
// "<resource_type>:<id>" where "*" is used as a wildcard for either side.
15+
type APIAllowListTarget struct {
16+
Type wildcard.Value[RBACResource]
17+
ID wildcard.Value[uuid.UUID]
18+
}
19+
20+
func AllowAllTarget() APIAllowListTarget {
21+
return APIAllowListTarget{}
22+
}
23+
24+
func AllowTypeTarget(r RBACResource) APIAllowListTarget {
25+
return APIAllowListTarget{Type: wildcard.Of(r)}
26+
}
27+
28+
func AllowResourceTarget(r RBACResource, id uuid.UUID) APIAllowListTarget {
29+
return APIAllowListTarget{Type: wildcard.Of(r), ID: wildcard.Of(id)}
30+
}
31+
32+
// String returns the canonical string representation "<type>:<id>" with "*" wildcards.
33+
func (t APIAllowListTarget) String() string {
34+
return t.Type.String() + ":" + t.ID.String()
35+
}
36+
37+
// MarshalJSON encodes as a JSON string: "<type>:<id>".
38+
func (t APIAllowListTarget) MarshalJSON() ([]byte, error) {
39+
return json.Marshal(t.String())
40+
}
41+
42+
// UnmarshalJSON decodes from a JSON string: "<type>:<id>".
43+
func (t *APIAllowListTarget) UnmarshalJSON(b []byte) error {
44+
var s string
45+
if err := json.Unmarshal(b, &s); err != nil {
46+
return err
47+
}
48+
parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
49+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
50+
return fmt.Errorf("invalid allow_list entry %q: want <type>:<id>", s)
51+
}
52+
53+
// Type
54+
if parts[0] != policy.WildcardSymbol {
55+
t.Type = wildcard.Of(RBACResource(parts[0]))
56+
}
57+
58+
// ID
59+
if parts[1] != policy.WildcardSymbol {
60+
u, err := uuid.Parse(parts[1])
61+
if err != nil {
62+
return fmt.Errorf("invalid %s ID (must be UUID): %q", parts[0], parts[1])
63+
}
64+
t.ID = wildcard.Of(u)
65+
}
66+
return nil
67+
}
68+
69+
// Implement encoding.TextMarshaler/Unmarshaler for broader compatibility
70+
71+
func (t APIAllowListTarget) MarshalText() ([]byte, error) { return []byte(t.String()), nil }
72+
73+
func (t *APIAllowListTarget) UnmarshalText(b []byte) error {
74+
return t.UnmarshalJSON([]byte("\"" + string(b) + "\""))
75+
}

0 commit comments

Comments
 (0)