Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,37 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
TokenName: tokenName,
}

if len(createToken.AllowList) > 0 {
rbacAllowListElements := make([]rbac.AllowListElement, 0, len(createToken.AllowList))
for _, t := range createToken.AllowList {
entry, err := rbac.NewAllowListElement(string(t.Type), t.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to create API key.",
Detail: err.Error(),
})
return
}
rbacAllowListElements = append(rbacAllowListElements, entry)
}

rbacAllowList, err := rbac.NormalizeAllowList(rbacAllowListElements)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to create API key.",
Detail: err.Error(),
})
return
}

dbAllowList := make(database.AllowList, 0, len(rbacAllowList))
for _, e := range rbacAllowList {
dbAllowList = append(dbAllowList, rbac.AllowListElement{Type: e.Type, ID: e.ID})
}

params.AllowList = dbAllowList
}

if createToken.Lifetime != 0 {
err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime)
if err != nil {
Expand Down
10 changes: 9 additions & 1 deletion coderd/apikey/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/cryptorand"
)

Expand All @@ -34,6 +35,9 @@ type CreateParams struct {
Scopes database.APIKeyScopes
TokenName string
RemoteAddr string
// AllowList is an optional, normalized allow-list
// of resource type and uuid entries. If empty, defaults to wildcard.
AllowList database.AllowList
Comment thread
Emyrk marked this conversation as resolved.
}

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

if len(params.AllowList) == 0 {
params.AllowList = database.AllowList{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}}
}

ip := net.ParseIP(params.RemoteAddr)
if ip == nil {
ip = net.IPv4(0, 0, 0, 0)
Expand Down Expand Up @@ -115,7 +123,7 @@ func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error)
HashedSecret: hashed[:],
LoginType: params.LoginType,
Scopes: scopes,
AllowList: database.AllowList{database.AllowListWildcard()},
AllowList: params.AllowList,
TokenName: params.TokenName,
}, token, nil
}
Expand Down
2 changes: 1 addition & 1 deletion coderd/coderdtest/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func AssertRBAC(t *testing.T, api *coderd.API, client *codersdk.Client) RBACAsse
ID: key.UserID.String(),
Roles: rbac.RoleIdentifiers(roleNames),
Groups: roles.Groups,
Scope: key.Scopes,
Scope: key.ScopeSet(),
},
Recorder: recorder,
}
Expand Down
14 changes: 9 additions & 5 deletions coderd/database/dbauthz/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,17 +225,20 @@ func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db dat
if testCase.outputs != nil {
// Assert the required outputs
s.Equal(len(testCase.outputs), len(outputs), "method %q returned unexpected number of outputs", methodName)
cmpOptions := []cmp.Option{
// Equate nil and empty slices.
cmpopts.EquateEmpty(),
}
for i := range outputs {
a, b := testCase.outputs[i].Interface(), outputs[i].Interface()

// To avoid the extra small overhead of gob encoding, we can
// first check if the values are equal with regard to order.
// If not, re-check disregarding order and show a nice diff
// output of the two values.
if !cmp.Equal(a, b, cmpopts.EquateEmpty()) {
if diff := cmp.Diff(a, b,
// Equate nil and empty slices.
cmpopts.EquateEmpty(),
if !cmp.Equal(a, b, cmpOptions...) {
diffOpts := append(
append([]cmp.Option{}, cmpOptions...),
// Allow slice order to be ignored.
cmpopts.SortSlices(func(a, b any) bool {
var ab, bb strings.Builder
Expand All @@ -247,7 +250,8 @@ func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db dat
// https://github.com/google/go-cmp/issues/67
return ab.String() < bb.String()
}),
); diff != "" {
)
if diff := cmp.Diff(a, b, diffOpts...); diff != "" {
s.Failf("compare outputs failed", "method %q returned unexpected output %d (-want +got):\n%s", methodName, i, diff)
}
}
Expand Down
3 changes: 2 additions & 1 deletion coderd/database/dbgen/dbgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/provisionerd/proto"
Expand Down Expand Up @@ -186,7 +187,7 @@ func APIKey(t testing.TB, db database.Store, seed database.APIKey, munge ...func
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
LoginType: takeFirst(seed.LoginType, database.LoginTypePassword),
Scopes: takeFirstSlice([]database.APIKeyScope(seed.Scopes), []database.APIKeyScope{database.ApiKeyScopeCoderAll}),
AllowList: takeFirstSlice(seed.AllowList, database.AllowList{database.AllowListWildcard()}),
AllowList: takeFirstSlice(seed.AllowList, database.AllowList{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}}),
TokenName: takeFirst(seed.TokenName),
}
for _, fn := range munge {
Expand Down
80 changes: 52 additions & 28 deletions coderd/database/modelmethods.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,24 +145,30 @@ func (s APIKeyScope) ToRBAC() rbac.ScopeName {
}
}

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

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

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

// Expand merges the permissions of all scopes in the list into a single scope.
// If the list is empty, it defaults to rbac.ScopeAll.
func (s APIKeyScopes) Expand() (rbac.Scope, error) {
// expandRBACScope merges the permissions of all scopes in the list into a
// single RBAC scope. If the list is empty, it defaults to rbac.ScopeAll for
Comment thread
ThomasK33 marked this conversation as resolved.
// backward compatibility. This method is internal; use ScopeSet() to combine
// scopes with the API key's allow list for authorization.
func (s APIKeyScopes) expandRBACScope() (rbac.Scope, error) {
// Default to ScopeAll for backward compatibility when no scopes provided.
if len(s) == 0 {
return rbac.ScopeAll.Expand()
return rbac.Scope{}, xerrors.New("no scopes provided")
}

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

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

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

// Merge allow lists.
for _, e := range expanded.AllowIDList {
if e.ID == policy.WildcardSymbol && e.Type == policy.WildcardSymbol {
allowAll = true
// No need to track other entries once wildcard is present.
continue
}
key := e.String()
allowSet[key] = e
}
allowLists = append(allowLists, expanded.AllowIDList)
}

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

if allowAll || len(allowSet) == 0 {
merged.AllowIDList = []rbac.AllowListElement{rbac.AllowListAll()}
} else {
merged.AllowIDList = make([]rbac.AllowListElement, 0, len(allowSet))
for _, v := range allowSet {
merged.AllowIDList = append(merged.AllowIDList, v)
}
union, err := rbac.UnionAllowLists(allowLists...)
if err != nil {
return rbac.Scope{}, err
}
merged.AllowIDList = union

return merged, nil
}
Expand All @@ -235,6 +228,37 @@ func (s APIKeyScopes) Name() rbac.RoleIdentifier {
return rbac.RoleIdentifier{Name: "scopes[" + strings.Join(names, "+") + "]"}
Comment thread
ThomasK33 marked this conversation as resolved.
}

// APIKeyScopeSet merges expanded scopes with the API key's DB allow_list. If
// the DB allow_list is a wildcard or empty, the merged scope's allow list is
// unchanged. Otherwise, the DB allow_list overrides the merged AllowIDList to
// enforce the token's resource scoping consistently across all permissions.
type APIKeyScopeSet struct {
Scopes APIKeyScopes
AllowList AllowList
}

var _ rbac.ExpandableScope = APIKeyScopeSet{}

func (s APIKeyScopeSet) Name() rbac.RoleIdentifier { return s.Scopes.Name() }

func (s APIKeyScopeSet) Expand() (rbac.Scope, error) {
merged, err := s.Scopes.expandRBACScope()
if err != nil {
return rbac.Scope{}, err
}
merged.AllowIDList = rbac.IntersectAllowLists(merged.AllowIDList, s.AllowList)
return merged, nil
}

// ScopeSet returns the scopes combined with the database allow list. It is the
// canonical way to expose an API key's effective scope for authorization.
func (k APIKey) ScopeSet() APIKeyScopeSet {
return APIKeyScopeSet{
Scopes: k.Scopes,
AllowList: k.AllowList,
}
}

func (k APIKey) RBACObject() rbac.Object {
return rbac.ResourceApiKey.WithIDString(k.ID).
WithOwner(k.UserID.String())
Expand Down
Loading
Loading