Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit5050f89

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 parent84dc70d commit5050f89

File tree

17 files changed

+707
-27
lines changed

17 files changed

+707
-27
lines changed

‎.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 aboutcustomizing 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
typeCustomRolePermissions []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(srcinterface{})error {
167+
func (s*APIKeyScopes)Scan(srcany)error {
169168
vararr []string
170169
iferr:=pq.Array(&arr).Scan(src);err!=nil {
171170
returnerr
@@ -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
typeAllowListTargetstruct {
321-
Typestring`json:"type"`
322-
IDstring`json:"id"`
320+
Typewildcard.Value[string]`json:"type"`
321+
IDwildcard.Value[uuid.UUID]`json:"id"`
323322
}
324323

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

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

339-
// AllowListWildcard returns the wildcard allow-list entry {"*","*"}.
340-
funcAllowListWildcard()AllowListTarget {returnAllowListTarget{Type:"*",ID:"*"}}
355+
returnNewAllowListTarget(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
iferr!=nil {
439442
returnwrite(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+
funcParseAllowListEntry(sstring) (AllowListElement,error) {
17+
s=strings.TrimSpace(strings.ToLower(s))
18+
res,id,ok:=ParseResourceAction(s)
19+
if!ok {
20+
returnAllowListElement{},xerrors.Errorf("invalid allow_list entry %q: want <type>:<id>",s)
21+
}
22+
23+
returnNewAllowListElement(res,id)
24+
}
25+
26+
funcNewAllowListElement(resourceTypestring,idstring) (AllowListElement,error) {
27+
ifresourceType!=policy.WildcardSymbol {
28+
if_,ok:=policy.RBACPermissions[resourceType];!ok {
29+
returnAllowListElement{},xerrors.Errorf("unknown resource type %q",resourceType)
30+
}
31+
}
32+
ifid!=policy.WildcardSymbol {
33+
if_,err:=uuid.Parse(id);err!=nil {
34+
returnAllowListElement{},xerrors.Errorf("invalid %s ID (must be UUID): %q",resourceType,id)
35+
}
36+
}
37+
38+
returnAllowListElement{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+
funcParseAllowList(inputs []string,maxint) ([]AllowListElement,error) {
44+
iflen(inputs)==0 {
45+
returnnil,nil
46+
}
47+
ifmax<=0 {
48+
max=128
49+
}
50+
iflen(inputs)>max {
51+
returnnil,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:=rangeinputs {
56+
e,err:=ParseAllowListEntry(s)
57+
iferr!=nil {
58+
returnnil,err
59+
}
60+
// Global wildcard short-circuits
61+
ife.Type==policy.WildcardSymbol&&e.ID==policy.WildcardSymbol {
62+
return []AllowListElement{AllowListAll()},nil
63+
}
64+
elems=append(elems,e)
65+
}
66+
67+
returnNewAllowList(elems,max)
68+
}
69+
70+
funcNewAllowList(inputs []AllowListElement,maxint) ([]AllowListElement,error) {
71+
iflen(inputs)==0 {
72+
returnnil,nil
73+
}
74+
ifmax<=0 {
75+
max=128
76+
}
77+
iflen(inputs)>max {
78+
returnnil,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:=rangeinputs {
85+
// Global wildcard short-circuits
86+
ife.Type==policy.WildcardSymbol&&e.ID==policy.WildcardSymbol {
87+
return []AllowListElement{AllowListAll()},nil
88+
}
89+
90+
ife.ID==policy.WildcardSymbol {
91+
typedWildcard[e.Type]=struct{}{}
92+
continue
93+
}
94+
ifidsByType[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+
fort,ids:=rangeidsByType {
102+
if_,ok:=typedWildcard[t];ok {
103+
out=append(out,AllowListElement{Type:t,ID:policy.WildcardSymbol})
104+
continue
105+
}
106+
forid:=rangeids {
107+
out=append(out,AllowListElement{Type:t,ID:id})
108+
}
109+
}
110+
111+
sort.Slice(out,func(i,jint)bool {
112+
ifout[i].Type==out[j].Type {
113+
returnout[i].ID<out[j].ID
114+
}
115+
returnout[i].Type<out[j].Type
116+
})
117+
returnout,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+
funcTestParseAllowListEntry(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+
funcTestParseAllowListNormalize(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+
funcTestParseAllowListLimit(t*testing.T) {
57+
t.Parallel()
58+
inputs:=make([]string,0,130)
59+
forrange130 {
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+
typeAPIAllowListTargetstruct {
16+
Type wildcard.Value[RBACResource]
17+
ID wildcard.Value[uuid.UUID]
18+
}
19+
20+
funcAllowAllTarget()APIAllowListTarget {
21+
returnAPIAllowListTarget{}
22+
}
23+
24+
funcAllowTypeTarget(rRBACResource)APIAllowListTarget {
25+
returnAPIAllowListTarget{Type:wildcard.Of(r)}
26+
}
27+
28+
funcAllowResourceTarget(rRBACResource,id uuid.UUID)APIAllowListTarget {
29+
returnAPIAllowListTarget{Type:wildcard.Of(r),ID:wildcard.Of(id)}
30+
}
31+
32+
// String returns the canonical string representation "<type>:<id>" with "*" wildcards.
33+
func (tAPIAllowListTarget)String()string {
34+
returnt.Type.String()+":"+t.ID.String()
35+
}
36+
37+
// MarshalJSON encodes as a JSON string: "<type>:<id>".
38+
func (tAPIAllowListTarget)MarshalJSON() ([]byte,error) {
39+
returnjson.Marshal(t.String())
40+
}
41+
42+
// UnmarshalJSON decodes from a JSON string: "<type>:<id>".
43+
func (t*APIAllowListTarget)UnmarshalJSON(b []byte)error {
44+
varsstring
45+
iferr:=json.Unmarshal(b,&s);err!=nil {
46+
returnerr
47+
}
48+
parts:=strings.SplitN(strings.TrimSpace(s),":",2)
49+
iflen(parts)!=2||parts[0]==""||parts[1]=="" {
50+
returnfmt.Errorf("invalid allow_list entry %q: want <type>:<id>",s)
51+
}
52+
53+
// Type
54+
ifparts[0]!=policy.WildcardSymbol {
55+
t.Type=wildcard.Of(RBACResource(parts[0]))
56+
}
57+
58+
// ID
59+
ifparts[1]!=policy.WildcardSymbol {
60+
u,err:=uuid.Parse(parts[1])
61+
iferr!=nil {
62+
returnfmt.Errorf("invalid %s ID (must be UUID): %q",parts[0],parts[1])
63+
}
64+
t.ID=wildcard.Of(u)
65+
}
66+
returnnil
67+
}
68+
69+
// Implement encoding.TextMarshaler/Unmarshaler for broader compatibility
70+
71+
func (tAPIAllowListTarget)MarshalText() ([]byte,error) {return []byte(t.String()),nil }
72+
73+
func (t*APIAllowListTarget)UnmarshalText(b []byte)error {
74+
returnt.UnmarshalJSON([]byte("\""+string(b)+"\""))
75+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp