Skip to content

Commit ed90ecf

Browse files
authored
feat: add allow_list to resource-scoped API tokens (coder#19964)
# Add API key allow_list for resource-scoped tokens This PR adds support for API key allow lists, enabling tokens to be scoped to specific resources. The implementation: 1. Adds a new `allow_list` field to the `CreateTokenRequest` struct, allowing clients to specify resource-specific scopes when creating API tokens 2. Implements `APIAllowListTarget` type to represent resource targets in the format `<type>:<id>` with support for wildcards 3. Adds validation and normalization logic for allow lists to handle wildcards and deduplication 4. Integrates with RBAC by creating an `APIKeyEffectiveScope` that merges API key scopes with allow list restrictions 5. Updates API documentation and TypeScript types to reflect the new functionality This feature enables creating tokens that are limited to specific resources (like workspaces or templates) by ID, making it possible to create more granular API tokens with limited access.
1 parent f31e6e0 commit ed90ecf

25 files changed

Lines changed: 930 additions & 94 deletions

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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,37 @@ 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(string(t.Type), t.ID)
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.NormalizeAllowList(rbacAllowListElements)
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+
dbAllowList = append(dbAllowList, rbac.AllowListElement{Type: e.Type, ID: e.ID})
145+
}
146+
147+
params.AllowList = dbAllowList
148+
}
149+
119150
if createToken.Lifetime != 0 {
120151
err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime)
121152
if err != nil {

coderd/apikey/apikey.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/coder/coder/v2/coderd/database"
1414
"github.com/coder/coder/v2/coderd/database/dbtime"
15+
"github.com/coder/coder/v2/coderd/rbac/policy"
1516
"github.com/coder/coder/v2/cryptorand"
1617
)
1718

@@ -34,6 +35,9 @@ type CreateParams struct {
3435
Scopes database.APIKeyScopes
3536
TokenName string
3637
RemoteAddr string
38+
// AllowList is an optional, normalized allow-list
39+
// of resource type and uuid entries. If empty, defaults to wildcard.
40+
AllowList database.AllowList
3741
}
3842

3943
// Generate generates an API key, returning the key as a string as well as the
@@ -61,6 +65,10 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
6165
params.LifetimeSeconds = int64(time.Until(params.ExpiresAt).Seconds())
6266
}
6367

68+
if len(params.AllowList) == 0 {
69+
params.AllowList = database.AllowList{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}}
70+
}
71+
6472
ip := net.ParseIP(params.RemoteAddr)
6573
if ip == nil {
6674
ip = net.IPv4(0, 0, 0, 0)
@@ -115,7 +123,7 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
115123
HashedSecret: hashed[:],
116124
LoginType: params.LoginType,
117125
Scopes: scopes,
118-
AllowList: database.AllowList{database.AllowListWildcard()},
126+
AllowList: params.AllowList,
119127
TokenName: params.TokenName,
120128
}, token, nil
121129
}

coderd/coderdtest/authorize.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func AssertRBAC(t *testing.T, api *coderd.API, client *codersdk.Client) RBACAsse
6868
ID: key.UserID.String(),
6969
Roles: rbac.RoleIdentifiers(roleNames),
7070
Groups: roles.Groups,
71-
Scope: key.Scopes,
71+
Scope: key.ScopeSet(),
7272
},
7373
Recorder: recorder,
7474
}

coderd/database/dbauthz/setup_test.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,17 +225,20 @@ func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db dat
225225
if testCase.outputs != nil {
226226
// Assert the required outputs
227227
s.Equal(len(testCase.outputs), len(outputs), "method %q returned unexpected number of outputs", methodName)
228+
cmpOptions := []cmp.Option{
229+
// Equate nil and empty slices.
230+
cmpopts.EquateEmpty(),
231+
}
228232
for i := range outputs {
229233
a, b := testCase.outputs[i].Interface(), outputs[i].Interface()
230234

231235
// To avoid the extra small overhead of gob encoding, we can
232236
// first check if the values are equal with regard to order.
233237
// If not, re-check disregarding order and show a nice diff
234238
// 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(),
239+
if !cmp.Equal(a, b, cmpOptions...) {
240+
diffOpts := append(
241+
append([]cmp.Option{}, cmpOptions...),
239242
// Allow slice order to be ignored.
240243
cmpopts.SortSlices(func(a, b any) bool {
241244
var ab, bb strings.Builder
@@ -247,7 +250,8 @@ func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db dat
247250
// https://github.com/google/go-cmp/issues/67
248251
return ab.String() < bb.String()
249252
}),
250-
); diff != "" {
253+
)
254+
if diff := cmp.Diff(a, b, diffOpts...); diff != "" {
251255
s.Failf("compare outputs failed", "method %q returned unexpected output %d (-want +got):\n%s", methodName, i, diff)
252256
}
253257
}

coderd/database/dbgen/dbgen.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
2828
"github.com/coder/coder/v2/coderd/database/pubsub"
2929
"github.com/coder/coder/v2/coderd/rbac"
30+
"github.com/coder/coder/v2/coderd/rbac/policy"
3031
"github.com/coder/coder/v2/codersdk"
3132
"github.com/coder/coder/v2/cryptorand"
3233
"github.com/coder/coder/v2/provisionerd/proto"
@@ -186,7 +187,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey, munge ...func
186187
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
187188
LoginType: takeFirst(seed.LoginType, database.LoginTypePassword),
188189
Scopes: takeFirstSlice([]database.APIKeyScope(seed.Scopes), []database.APIKeyScope{database.ApiKeyScopeCoderAll}),
189-
AllowList: takeFirstSlice(seed.AllowList, database.AllowList{database.AllowListWildcard()}),
190+
AllowList: takeFirstSlice(seed.AllowList, database.AllowList{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}}),
190191
TokenName: takeFirst(seed.TokenName),
191192
}
192193
for _, fn := range munge {

coderd/database/modelmethods.go

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -145,24 +145,30 @@ func (s APIKeyScope) ToRBAC() rbac.ScopeName {
145145
}
146146
}
147147

148-
// APIKeyScopes allows expanding multiple API key scopes into a single
149-
// RBAC scope for authorization. This implements rbac.ExpandableScope so
150-
// callers can pass the list directly without deriving a single scope.
148+
// APIKeyScopes represents a collection of individual API key scope names as
149+
// stored in the database. Helper methods on this type are used to derive the
150+
// RBAC scope that should be authorized for the key.
151151
type APIKeyScopes []APIKeyScope
152152

153-
var _ rbac.ExpandableScope = APIKeyScopes{}
153+
// WithAllowList wraps the scopes with a database allow list, producing an
154+
// ExpandableScope that always enforces the allow list overlay when expanded.
155+
func (s APIKeyScopes) WithAllowList(list AllowList) APIKeyScopeSet {
156+
return APIKeyScopeSet{Scopes: s, AllowList: list}
157+
}
154158

155159
// Has returns true if the slice contains the provided scope.
156160
func (s APIKeyScopes) Has(target APIKeyScope) bool {
157161
return slices.Contains(s, target)
158162
}
159163

160-
// Expand merges the permissions of all scopes in the list into a single scope.
161-
// If the list is empty, it defaults to rbac.ScopeAll.
162-
func (s APIKeyScopes) Expand() (rbac.Scope, error) {
164+
// expandRBACScope merges the permissions of all scopes in the list into a
165+
// single RBAC scope. If the list is empty, it defaults to rbac.ScopeAll for
166+
// backward compatibility. This method is internal; use ScopeSet() to combine
167+
// scopes with the API key's allow list for authorization.
168+
func (s APIKeyScopes) expandRBACScope() (rbac.Scope, error) {
163169
// Default to ScopeAll for backward compatibility when no scopes provided.
164170
if len(s) == 0 {
165-
return rbac.ScopeAll.Expand()
171+
return rbac.Scope{}, xerrors.New("no scopes provided")
166172
}
167173

168174
var merged rbac.Scope
@@ -174,9 +180,8 @@ func (s APIKeyScopes) Expand() (rbac.Scope, error) {
174180
User: nil,
175181
}
176182

177-
// Track allow list union, collapsing to wildcard if any child is wildcard.
178-
allowAll := false
179-
allowSet := make(map[string]rbac.AllowListElement)
183+
// Collect allow lists for a union after expanding all scopes.
184+
allowLists := make([][]rbac.AllowListElement, 0, len(s))
180185

181186
for _, s := range s {
182187
expanded, err := s.ToRBAC().Expand()
@@ -191,16 +196,7 @@ func (s APIKeyScopes) Expand() (rbac.Scope, error) {
191196
}
192197
merged.User = append(merged.User, expanded.User...)
193198

194-
// Merge allow lists.
195-
for _, e := range expanded.AllowIDList {
196-
if e.ID == policy.WildcardSymbol && e.Type == policy.WildcardSymbol {
197-
allowAll = true
198-
// No need to track other entries once wildcard is present.
199-
continue
200-
}
201-
key := e.String()
202-
allowSet[key] = e
203-
}
199+
allowLists = append(allowLists, expanded.AllowIDList)
204200
}
205201

206202
// De-duplicate permissions across Site/Org/User
@@ -210,14 +206,11 @@ func (s APIKeyScopes) Expand() (rbac.Scope, error) {
210206
}
211207
merged.User = rbac.DeduplicatePermissions(merged.User)
212208

213-
if allowAll || len(allowSet) == 0 {
214-
merged.AllowIDList = []rbac.AllowListElement{rbac.AllowListAll()}
215-
} else {
216-
merged.AllowIDList = make([]rbac.AllowListElement, 0, len(allowSet))
217-
for _, v := range allowSet {
218-
merged.AllowIDList = append(merged.AllowIDList, v)
219-
}
209+
union, err := rbac.UnionAllowLists(allowLists...)
210+
if err != nil {
211+
return rbac.Scope{}, err
220212
}
213+
merged.AllowIDList = union
221214

222215
return merged, nil
223216
}
@@ -235,6 +228,37 @@ func (s APIKeyScopes) Name() rbac.RoleIdentifier {
235228
return rbac.RoleIdentifier{Name: "scopes[" + strings.Join(names, "+") + "]"}
236229
}
237230

231+
// APIKeyScopeSet merges expanded scopes with the API key's DB allow_list. If
232+
// the DB allow_list is a wildcard or empty, the merged scope's allow list is
233+
// unchanged. Otherwise, the DB allow_list overrides the merged AllowIDList to
234+
// enforce the token's resource scoping consistently across all permissions.
235+
type APIKeyScopeSet struct {
236+
Scopes APIKeyScopes
237+
AllowList AllowList
238+
}
239+
240+
var _ rbac.ExpandableScope = APIKeyScopeSet{}
241+
242+
func (s APIKeyScopeSet) Name() rbac.RoleIdentifier { return s.Scopes.Name() }
243+
244+
func (s APIKeyScopeSet) Expand() (rbac.Scope, error) {
245+
merged, err := s.Scopes.expandRBACScope()
246+
if err != nil {
247+
return rbac.Scope{}, err
248+
}
249+
merged.AllowIDList = rbac.IntersectAllowLists(merged.AllowIDList, s.AllowList)
250+
return merged, nil
251+
}
252+
253+
// ScopeSet returns the scopes combined with the database allow list. It is the
254+
// canonical way to expose an API key's effective scope for authorization.
255+
func (k APIKey) ScopeSet() APIKeyScopeSet {
256+
return APIKeyScopeSet{
257+
Scopes: k.Scopes,
258+
AllowList: k.AllowList,
259+
}
260+
}
261+
238262
func (k APIKey) RBACObject() rbac.Object {
239263
return rbac.ResourceApiKey.WithIDString(k.ID).
240264
WithOwner(k.UserID.String())

0 commit comments

Comments
 (0)