feat: add allow_list to resource-scoped API tokens (#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.
This commit is contained in:
Thomas Kosiewski
2025-10-09 14:53:08 +02:00
committed by GitHub
parent f31e6e09ba
commit ed90ecf00e
25 changed files with 930 additions and 94 deletions
+9 -5
View File
@@ -225,6 +225,10 @@ 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()
@@ -232,10 +236,9 @@ func (s *MethodTestSuite) SubtestWithDB(db database.Store, testCaseF func(db dat
// 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
@@ -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)
}
}
+2 -1
View File
@@ -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"
@@ -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 {
+52 -28
View File
@@ -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
// 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
@@ -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()
@@ -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
@@ -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
}
@@ -235,6 +228,37 @@ func (s APIKeyScopes) Name() rbac.RoleIdentifier {
return rbac.RoleIdentifier{Name: "scopes[" + strings.Join(names, "+") + "]"}
}
// 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())
+62 -6
View File
@@ -3,6 +3,7 @@ package database
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/rbac"
@@ -38,7 +39,7 @@ func TestAPIKeyScopesExpand(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, err := tc.scopes.Expand()
s, err := tc.scopes.expandRBACScope()
require.NoError(t, err)
tc.want(t, s)
})
@@ -59,7 +60,7 @@ func TestAPIKeyScopesExpand(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, err := tc.scopes.Expand()
s, err := tc.scopes.expandRBACScope()
require.NoError(t, err)
requirePermission(t, s, tc.res, tc.act)
requireAllowAll(t, s)
@@ -70,7 +71,7 @@ func TestAPIKeyScopesExpand(t *testing.T) {
t.Run("merge", func(t *testing.T) {
t.Parallel()
scopes := APIKeyScopes{ApiKeyScopeCoderApplicationConnect, ApiKeyScopeCoderAll, ApiKeyScopeWorkspaceRead}
s, err := scopes.Expand()
s, err := scopes.expandRBACScope()
require.NoError(t, err)
requirePermission(t, s, rbac.ResourceWildcard.Type, policy.Action(policy.WildcardSymbol))
requirePermission(t, s, rbac.ResourceWorkspace.Type, policy.ActionApplicationConnect)
@@ -78,13 +79,68 @@ func TestAPIKeyScopesExpand(t *testing.T) {
requireAllowAll(t, s)
})
t.Run("empty_defaults_to_all", func(t *testing.T) {
t.Run("effective_scope_keep_types", func(t *testing.T) {
t.Parallel()
s, err := (APIKeyScopes{}).Expand()
workspaceID := uuid.New()
effective := APIKeyScopeSet{
Scopes: APIKeyScopes{ApiKeyScopeWorkspaceRead},
AllowList: AllowList{
{Type: rbac.ResourceWorkspace.Type, ID: workspaceID.String()},
},
}
expanded, err := effective.Expand()
require.NoError(t, err)
requirePermission(t, s, rbac.ResourceWildcard.Type, policy.Action(policy.WildcardSymbol))
require.Len(t, expanded.AllowIDList, 1)
require.Equal(t, "workspace", expanded.AllowIDList[0].Type)
require.Equal(t, workspaceID.String(), expanded.AllowIDList[0].ID)
})
t.Run("empty_rejected", func(t *testing.T) {
t.Parallel()
_, err := (APIKeyScopes{}).expandRBACScope()
require.Error(t, err)
require.ErrorContains(t, err, "no scopes provided")
})
t.Run("allow_list_overrides", func(t *testing.T) {
t.Parallel()
allowID := uuid.NewString()
set := APIKeyScopes{ApiKeyScopeWorkspaceRead}.WithAllowList(AllowList{
{Type: rbac.ResourceWorkspace.Type, ID: allowID},
})
s, err := set.Expand()
require.NoError(t, err)
require.Len(t, s.AllowIDList, 1)
require.Equal(t, rbac.AllowListElement{Type: rbac.ResourceWorkspace.Type, ID: allowID}, s.AllowIDList[0])
})
t.Run("allow_list_wildcard_keeps_merged", func(t *testing.T) {
t.Parallel()
set := APIKeyScopes{ApiKeyScopeWorkspaceRead}.WithAllowList(AllowList{
{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol},
})
s, err := set.Expand()
require.NoError(t, err)
requirePermission(t, s, rbac.ResourceWorkspace.Type, policy.ActionRead)
requireAllowAll(t, s)
})
t.Run("scope_set_helper", func(t *testing.T) {
t.Parallel()
allowID := uuid.NewString()
key := APIKey{
Scopes: APIKeyScopes{ApiKeyScopeWorkspaceRead},
AllowList: AllowList{
{Type: rbac.ResourceWorkspace.Type, ID: allowID},
},
}
s, err := key.ScopeSet().Expand()
require.NoError(t, err)
require.Len(t, s.AllowIDList, 1)
require.Equal(t, rbac.AllowListElement{Type: rbac.ResourceWorkspace.Type, ID: allowID}, s.AllowIDList[0])
})
}
// Helpers
+5 -32
View File
@@ -163,9 +163,7 @@ func (m StringMapOfInt) Value() (driver.Value, error) {
type CustomRolePermissions []CustomRolePermission
// APIKeyScopes implements sql.Scanner and driver.Valuer so it can be read from
// and written to the Postgres api_key_scope[] enum array column.
func (s *APIKeyScopes) Scan(src interface{}) error {
func (s *APIKeyScopes) Scan(src any) error {
var arr []string
if err := pq.Array(&arr).Scan(src); err != nil {
return err
@@ -314,36 +312,11 @@ func ParseIP(ipStr string) pqtype.Inet {
}
}
// AllowListTarget represents a single scope allow-list entry.
// It encodes a resource tuple (type, id) and provides helpers for
// consistent string and JSON representations across the codebase.
type AllowListTarget struct {
Type string `json:"type"`
ID string `json:"id"`
}
// String returns the canonical database representation "type:id".
func (t AllowListTarget) String() string {
return t.Type + ":" + t.ID
}
// ParseAllowListTarget parses the canonical string form "type:id".
func ParseAllowListTarget(s string) (AllowListTarget, error) {
targetType, id, ok := rbac.ParseResourceAction(s)
if !ok {
return AllowListTarget{}, xerrors.Errorf("invalid allow list target: %q", s)
}
return AllowListTarget{Type: targetType, ID: id}, nil
}
// AllowListWildcard returns the wildcard allow-list entry {"*","*"}.
func AllowListWildcard() AllowListTarget { return AllowListTarget{Type: "*", ID: "*"} }
// AllowList is a typed wrapper around a list of AllowListTarget entries.
// It implements sql.Scanner and driver.Valuer so it can be stored in and
// loaded from a Postgres text[] column that stores each entry in the
// canonical form "type:id".
type AllowList []AllowListTarget
type AllowList []rbac.AllowListElement
// Scan implements sql.Scanner. It supports inputs that pq.Array can decode
// into []string, and then converts each element to an AllowListTarget.
@@ -352,13 +325,13 @@ func (a *AllowList) Scan(src any) error {
if err := pq.Array(&raw).Scan(src); err != nil {
return err
}
out := make([]AllowListTarget, len(raw))
out := make([]rbac.AllowListElement, len(raw))
for i, s := range raw {
t, err := ParseAllowListTarget(s)
e, err := rbac.ParseAllowListEntry(s)
if err != nil {
return err
}
out[i] = t
out[i] = e
}
*a = out
return nil