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
+17
View File
@@ -11713,6 +11713,17 @@ const docTemplate = `{
}
}
},
"codersdk.APIAllowListTarget": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"$ref": "#/definitions/codersdk.RBACResource"
}
}
},
"codersdk.APIKey": {
"type": "object",
"required": [
@@ -13256,6 +13267,12 @@ const docTemplate = `{
"codersdk.CreateTokenRequest": {
"type": "object",
"properties": {
"allow_list": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIAllowListTarget"
}
},
"lifetime": {
"type": "integer"
},
+17
View File
@@ -10425,6 +10425,17 @@
}
}
},
"codersdk.APIAllowListTarget": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"$ref": "#/definitions/codersdk.RBACResource"
}
}
},
"codersdk.APIKey": {
"type": "object",
"required": [
@@ -11901,6 +11912,12 @@
"codersdk.CreateTokenRequest": {
"type": "object",
"properties": {
"allow_list": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.APIAllowListTarget"
}
},
"lifetime": {
"type": "integer"
},
+31
View File
@@ -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 {
+9 -1
View File
@@ -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"
)
@@ -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
}
// 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)
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)
@@ -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
}
+1 -1
View File
@@ -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,
}
+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
+1 -1
View File
@@ -434,7 +434,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
// If the key is valid, we also fetch the user roles and status.
// The roles are used for RBAC authorize checks, and the status
// is to block 'suspended' users from accessing the platform.
actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.Scopes)
actor, userStatus, err := UserRBACSubject(ctx, cfg.DB, key.UserID, key.ScopeSet())
if err != nil {
return write(http.StatusUnauthorized, codersdk.Response{
Message: internalErrorMessage,
+4 -1
View File
@@ -20,6 +20,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)
@@ -173,7 +174,9 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s
ExpiresAt: dbtime.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderAll},
AllowList: database.AllowList{database.AllowListWildcard()},
AllowList: database.AllowList{
{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol},
},
IPAddress: pqtype.Inet{
IPNet: net.IPNet{
IP: net.ParseIP("0.0.0.0"),
+4 -1
View File
@@ -22,6 +22,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
)
@@ -67,7 +68,9 @@ func TestWorkspaceParam(t *testing.T) {
ExpiresAt: dbtime.Now().Add(time.Minute),
LoginType: database.LoginTypePassword,
Scopes: database.APIKeyScopes{database.ApiKeyScopeCoderAll},
AllowList: database.AllowList{database.AllowListWildcard()},
AllowList: database.AllowList{
{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol},
},
IPAddress: pqtype.Inet{
IPNet: net.IPNet{
IP: net.IPv4(127, 0, 0, 1),
+304
View File
@@ -0,0 +1,304 @@
package rbac
import (
"slices"
"sort"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/rbac/policy"
)
// maxAllowListEntries caps normalized allow lists to a manageable size. This
// limit is intentionally arbitrary—just high enough for current use cases—so we
// can revisit it without implying any semantic contract.
const maxAllowListEntries = 128
// ParseAllowListEntry parses a single allow-list entry string in the form
// "*:*", "<resource_type>:*", or "<resource_type>:<uuid>" into an
// AllowListElement with validation.
func ParseAllowListEntry(s string) (AllowListElement, error) {
s = strings.TrimSpace(strings.ToLower(s))
res, id, ok := ParseResourceAction(s)
if !ok {
return AllowListElement{}, xerrors.Errorf("invalid allow_list entry %q: want <type>:<id>", s)
}
return NewAllowListElement(res, id)
}
func NewAllowListElement(resourceType string, id string) (AllowListElement, error) {
if resourceType != policy.WildcardSymbol {
if _, ok := policy.RBACPermissions[resourceType]; !ok {
return AllowListElement{}, xerrors.Errorf("unknown resource type %q", resourceType)
}
}
if id != policy.WildcardSymbol {
if _, err := uuid.Parse(id); err != nil {
return AllowListElement{}, xerrors.Errorf("invalid %s ID (must be UUID): %q", resourceType, id)
}
}
return AllowListElement{Type: resourceType, ID: id}, nil
}
// ParseAllowList parses, validates, normalizes, and deduplicates a list of
// allow-list entries. If max is <=0, a default cap of 128 is applied.
func ParseAllowList(inputs []string, maxEntries int) ([]AllowListElement, error) {
if len(inputs) == 0 {
return nil, nil
}
if len(inputs) > maxEntries {
return nil, xerrors.Errorf("allow_list has %d entries; max allowed is %d", len(inputs), maxEntries)
}
elems := make([]AllowListElement, 0, len(inputs))
for _, s := range inputs {
e, err := ParseAllowListEntry(s)
if err != nil {
return nil, err
}
// Global wildcard short-circuits
if e.Type == policy.WildcardSymbol && e.ID == policy.WildcardSymbol {
return []AllowListElement{AllowListAll()}, nil
}
elems = append(elems, e)
}
return NormalizeAllowList(elems)
}
// NormalizeAllowList enforces max entry limits, collapses typed wildcards, and
// produces a deterministic, deduplicated allow list. A global wildcard returns
// early with a single `[*:*]` entry, typed wildcards shadow specific IDs, and
// the final slice is sorted to keep downstream comparisons stable. When the
// input is empty we return an empty (non-nil) slice so callers can differentiate
// between "no restriction" and "not provided" cases.
func NormalizeAllowList(inputs []AllowListElement) ([]AllowListElement, error) {
if len(inputs) == 0 {
return []AllowListElement{}, nil
}
if len(inputs) > maxAllowListEntries {
return nil, xerrors.Errorf("allow_list has %d entries; max allowed is %d", len(inputs), maxAllowListEntries)
}
// Collapse typed wildcards and drop shadowed IDs
typedWildcard := map[string]struct{}{}
idsByType := map[string]map[string]struct{}{}
for _, e := range inputs {
// Global wildcard short-circuits
if e.Type == policy.WildcardSymbol && e.ID == policy.WildcardSymbol {
return []AllowListElement{AllowListAll()}, nil
}
if e.ID == policy.WildcardSymbol {
typedWildcard[e.Type] = struct{}{}
continue
}
if idsByType[e.Type] == nil {
idsByType[e.Type] = map[string]struct{}{}
}
idsByType[e.Type][e.ID] = struct{}{}
}
out := make([]AllowListElement, 0)
for t := range typedWildcard {
out = append(out, AllowListElement{Type: t, ID: policy.WildcardSymbol})
}
for t, ids := range idsByType {
if _, ok := typedWildcard[t]; ok {
continue
}
for id := range ids {
out = append(out, AllowListElement{Type: t, ID: id})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Type == out[j].Type {
return out[i].ID < out[j].ID
}
return out[i].Type < out[j].Type
})
return out, nil
}
// UnionAllowLists merges multiple allow lists, returning the set of resources
// permitted by any input. A global wildcard short-circuits the merge. When no
// entries are present across all inputs, the result is an empty allow list.
func UnionAllowLists(lists ...[]AllowListElement) ([]AllowListElement, error) {
union := make([]AllowListElement, 0)
seen := make(map[string]struct{})
for _, list := range lists {
for _, elem := range list {
if elem.Type == policy.WildcardSymbol && elem.ID == policy.WildcardSymbol {
return []AllowListElement{AllowListAll()}, nil
}
key := elem.String()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
union = append(union, elem)
}
}
return NormalizeAllowList(union)
}
// IntersectAllowLists combines the allow list produced by RBAC expansion with the
// API key's stored allow list. The result enforces both constraints: any
// resource must be allowed by the scope *and* the database filter. Wildcards in
// either list are respected and short-circuit appropriately.
//
// Intuition: scope definitions provide the *ceiling* of what a key could touch,
// while the DB allow list can narrow that set. Technically, since this is
// an intersection, both can narrow each other.
//
// A few illustrative cases:
//
// | Scope AllowList | DB AllowList | Result |
// | ----------------- | ------------------------------------- | ----------------- |
// | `[*:*]` | `[workspace:A]` | `[workspace:A]` |
// | `[workspace:*]` | `[workspace:A, workspace:B]` | `[workspace:A, workspace:B]` |
// | `[workspace:A]` | `[workspace:A, workspace:B]` | `[workspace:A]` |
// | `[]` | `[workspace:A]` | `[workspace:A]` |
//
// Today most API key scopes expand with an empty allow list (meaning "no
// scope-level restriction"), so the merge simply mirrors what the database
// stored. Only scopes that intentionally embed resource filters would trim the
// DB entries.
func IntersectAllowLists(scopeList []AllowListElement, dbList []AllowListElement) []AllowListElement {
// Empty DB list means no additional restriction.
if len(dbList) == 0 {
// Defensive: API keys should always persist a non-empty allow list, but
// we cannot have an empty allow list, thus we fail close.
return nil
}
// If scope already allows everything, the db list is authoritative.
scopeAll := allowListContainsAll(scopeList)
dbAll := allowListContainsAll(dbList)
switch {
case scopeAll && dbAll:
return []AllowListElement{AllowListAll()}
case scopeAll:
return dbList
case dbAll:
return scopeList
}
// Otherwise compute intersection.
resultSet := make(map[string]AllowListElement)
for _, scopeElem := range scopeList {
matching := intersectAllow(scopeElem, dbList)
for _, elem := range matching {
resultSet[elem.String()] = elem
}
}
if len(resultSet) == 0 {
return []AllowListElement{}
}
result := make([]AllowListElement, 0, len(resultSet))
for _, elem := range resultSet {
result = append(result, elem)
}
slices.SortFunc(result, func(a, b AllowListElement) int {
if a.Type == b.Type {
return strings.Compare(a.ID, b.ID)
}
return strings.Compare(a.Type, b.Type)
})
normalized, err := NormalizeAllowList(result)
if err != nil {
return result
}
if normalized == nil {
return []AllowListElement{}
}
return normalized
}
func allowListContainsAll(elements []AllowListElement) bool {
if len(elements) == 0 {
return false
}
for _, e := range elements {
if e.Type == policy.WildcardSymbol && e.ID == policy.WildcardSymbol {
return true
}
}
return false
}
// intersectAllow returns the set of permit entries that satisfy both the scope
// element and the database allow list.
func intersectAllow(scopeElem AllowListElement, dbList []AllowListElement) []AllowListElement {
// Scope element is wildcard -> intersection is db list.
if scopeElem.Type == policy.WildcardSymbol && scopeElem.ID == policy.WildcardSymbol {
return dbList
}
result := make([]AllowListElement, 0)
for _, dbElem := range dbList {
// DB entry wildcard -> keep scope element.
if dbElem.Type == policy.WildcardSymbol && dbElem.ID == policy.WildcardSymbol {
result = append(result, scopeElem)
continue
}
if !typeMatches(scopeElem.Type, dbElem.Type) {
continue
}
if !idMatches(scopeElem.ID, dbElem.ID) {
continue
}
result = append(result, AllowListElement{
Type: intersectType(scopeElem.Type, dbElem.Type),
ID: intersectID(scopeElem.ID, dbElem.ID),
})
}
return result
}
func typeMatches(scopeType, dbType string) bool {
return scopeType == dbType || scopeType == policy.WildcardSymbol || dbType == policy.WildcardSymbol
}
func idMatches(scopeID, dbID string) bool {
return scopeID == dbID || scopeID == policy.WildcardSymbol || dbID == policy.WildcardSymbol
}
func intersectType(scopeType, dbType string) string {
if scopeType == dbType {
return scopeType
}
if scopeType == policy.WildcardSymbol {
return dbType
}
return scopeType
}
func intersectID(scopeID, dbID string) string {
switch {
case scopeID == dbID:
return scopeID
case scopeID == policy.WildcardSymbol:
return dbID
case dbID == policy.WildcardSymbol:
return scopeID
default:
// Should not happen when intersecting with matching IDs; fallback to scope ID.
return scopeID
}
}
+231
View File
@@ -0,0 +1,231 @@
package rbac_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
)
func TestParseAllowListEntry(t *testing.T) {
t.Parallel()
e, err := rbac.ParseAllowListEntry("*:*")
require.NoError(t, err)
require.Equal(t, rbac.AllowListElement{Type: "*", ID: "*"}, e)
e, err = rbac.ParseAllowListEntry("workspace:*")
require.NoError(t, err)
require.Equal(t, rbac.AllowListElement{Type: "workspace", ID: "*"}, e)
id := uuid.New().String()
e, err = rbac.ParseAllowListEntry("template:" + id)
require.NoError(t, err)
require.Equal(t, rbac.AllowListElement{Type: "template", ID: id}, e)
_, err = rbac.ParseAllowListEntry("unknown:*")
require.Error(t, err)
_, err = rbac.ParseAllowListEntry("workspace:bad-uuid")
require.Error(t, err)
_, err = rbac.ParseAllowListEntry(":")
require.Error(t, err)
}
func TestParseAllowListNormalize(t *testing.T) {
t.Parallel()
id1 := uuid.New().String()
id2 := uuid.New().String()
// Global wildcard short-circuits
out, err := rbac.ParseAllowList([]string{"workspace:" + id1, "*:*", "template:" + id2}, 128)
require.NoError(t, err)
require.Equal(t, []rbac.AllowListElement{{Type: "*", ID: "*"}}, out)
// Typed wildcard collapses typed ids
out, err = rbac.ParseAllowList([]string{"workspace:*", "workspace:" + id1, "workspace:" + id2}, 128)
require.NoError(t, err)
require.Equal(t, []rbac.AllowListElement{{Type: "workspace", ID: "*"}}, out)
// Typed wildcard entries persist even without explicit IDs
out, err = rbac.ParseAllowList([]string{"template:*"}, 128)
require.NoError(t, err)
require.Equal(t, []rbac.AllowListElement{{Type: "template", ID: "*"}}, out)
// Dedup ids and sort deterministically
out, err = rbac.ParseAllowList([]string{"template:" + id2, "template:" + id2, "template:" + id1}, 128)
require.NoError(t, err)
require.Len(t, out, 2)
require.Equal(t, "template", out[0].Type)
require.Equal(t, "template", out[1].Type)
}
func TestParseAllowListLimit(t *testing.T) {
t.Parallel()
inputs := make([]string, 0, 130)
for range 130 {
inputs = append(inputs, "workspace:"+uuid.New().String())
}
_, err := rbac.ParseAllowList(inputs, 128)
require.Error(t, err)
}
func TestIntersectAllowLists(t *testing.T) {
t.Parallel()
id := uuid.NewString()
id2 := uuid.NewString()
t.Run("scope_all_db_specific", func(t *testing.T) {
t.Parallel()
out := rbac.IntersectAllowLists(
[]rbac.AllowListElement{rbac.AllowListAll()},
[]rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id}},
)
require.Equal(t, []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id}}, out)
})
t.Run("db_all_keeps_scope", func(t *testing.T) {
t.Parallel()
scopeList := []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: policy.WildcardSymbol}}
out := rbac.IntersectAllowLists(scopeList, []rbac.AllowListElement{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}})
require.Equal(t, scopeList, out)
})
t.Run("typed_wildcard_intersection", func(t *testing.T) {
t.Parallel()
scopeList := []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: policy.WildcardSymbol}}
out := rbac.IntersectAllowLists(scopeList, []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id}})
require.Equal(t, []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id}}, out)
})
t.Run("db_wildcard_type_specific", func(t *testing.T) {
t.Parallel()
scopeList := []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id}}
out := rbac.IntersectAllowLists(scopeList, []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: policy.WildcardSymbol}})
require.Equal(t, []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id}}, out)
})
t.Run("disjoint_types", func(t *testing.T) {
t.Parallel()
scopeList := []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id}}
out := rbac.IntersectAllowLists(scopeList, []rbac.AllowListElement{{Type: rbac.ResourceTemplate.Type, ID: id}})
require.Empty(t, out)
})
t.Run("different_ids", func(t *testing.T) {
t.Parallel()
scopeList := []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: uuid.NewString()}}
out := rbac.IntersectAllowLists(scopeList, []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id}})
require.Empty(t, out)
})
t.Run("multi_entry_overlap", func(t *testing.T) {
t.Parallel()
templateSpecific := uuid.NewString()
scopeList := []rbac.AllowListElement{
{Type: rbac.ResourceWorkspace.Type, ID: id},
{Type: rbac.ResourceWorkspace.Type, ID: id2},
{Type: rbac.ResourceTemplate.Type, ID: policy.WildcardSymbol},
}
out := rbac.IntersectAllowLists(scopeList, []rbac.AllowListElement{
{Type: rbac.ResourceWorkspace.Type, ID: id2},
{Type: rbac.ResourceTemplate.Type, ID: templateSpecific},
{Type: rbac.ResourceTemplate.Type, ID: policy.WildcardSymbol},
})
require.Equal(t, []rbac.AllowListElement{
{Type: rbac.ResourceTemplate.Type, ID: policy.WildcardSymbol},
{Type: rbac.ResourceWorkspace.Type, ID: id2},
}, out)
})
t.Run("multi_entry_db_wildcards", func(t *testing.T) {
t.Parallel()
templateID := uuid.NewString()
dbList := []rbac.AllowListElement{
{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol},
{Type: rbac.ResourceWorkspace.Type, ID: id},
{Type: rbac.ResourceTemplate.Type, ID: policy.WildcardSymbol},
}
out := rbac.IntersectAllowLists([]rbac.AllowListElement{
{Type: rbac.ResourceWorkspace.Type, ID: id},
{Type: rbac.ResourceTemplate.Type, ID: templateID},
}, dbList)
require.Equal(t, []rbac.AllowListElement{
{Type: rbac.ResourceWorkspace.Type, ID: id},
{Type: rbac.ResourceTemplate.Type, ID: templateID},
}, out)
})
}
func TestUnionAllowLists(t *testing.T) {
t.Parallel()
id1 := uuid.NewString()
id2 := uuid.NewString()
t.Run("wildcard_short_circuit", func(t *testing.T) {
t.Parallel()
out, err := rbac.UnionAllowLists(
[]rbac.AllowListElement{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}},
[]rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id1}},
)
require.NoError(t, err)
require.Equal(t, []rbac.AllowListElement{rbac.AllowListAll()}, out)
})
t.Run("merge_unique_entries", func(t *testing.T) {
t.Parallel()
out, err := rbac.UnionAllowLists(
[]rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id1}},
[]rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id2}},
)
require.NoError(t, err)
require.Len(t, out, 2)
require.ElementsMatch(t, []rbac.AllowListElement{
{Type: rbac.ResourceWorkspace.Type, ID: id1},
{Type: rbac.ResourceWorkspace.Type, ID: id2},
}, out)
})
t.Run("typed_wildcard_collapse", func(t *testing.T) {
t.Parallel()
out, err := rbac.UnionAllowLists(
[]rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: policy.WildcardSymbol}},
[]rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id1}},
)
require.NoError(t, err)
require.Equal(t, []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: policy.WildcardSymbol}}, out)
})
t.Run("deduplicate_across_inputs", func(t *testing.T) {
t.Parallel()
out, err := rbac.UnionAllowLists(
[]rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id1}},
[]rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id1}},
)
require.NoError(t, err)
require.Equal(t, []rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id1}}, out)
})
t.Run("combine_multiple_types", func(t *testing.T) {
t.Parallel()
out, err := rbac.UnionAllowLists(
[]rbac.AllowListElement{{Type: rbac.ResourceWorkspace.Type, ID: id1}},
[]rbac.AllowListElement{{Type: rbac.ResourceTemplate.Type, ID: id2}},
)
require.NoError(t, err)
require.ElementsMatch(t, []rbac.AllowListElement{
{Type: rbac.ResourceTemplate.Type, ID: id2},
{Type: rbac.ResourceWorkspace.Type, ID: id1},
}, out)
})
t.Run("empty_returns_empty", func(t *testing.T) {
t.Parallel()
out, err := rbac.UnionAllowLists(nil, []rbac.AllowListElement{})
require.NoError(t, err)
require.Empty(t, out)
})
}
+3 -3
View File
@@ -236,7 +236,7 @@ func ExpandScope(scope ScopeName) (Scope, error) {
User: []Permission{},
},
// Composites are site-level; allow-list empty by default
AllowIDList: []AllowListElement{},
AllowIDList: []AllowListElement{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}},
}, nil
}
if res, act, ok := parseLowLevelScope(scope); ok {
@@ -292,7 +292,7 @@ func expandLowLevel(resource string, action policy.Action) Scope {
Org: map[string][]Permission{},
User: []Permission{},
},
// Low-level scopes intentionally return an empty allow list.
AllowIDList: []AllowListElement{},
// Low-level scopes intentionally return a wildcard allow list.
AllowIDList: []AllowListElement{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}},
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ func TestExpandScope(t *testing.T) {
require.Empty(t, s.Org)
require.Empty(t, s.User)
require.Len(t, s.AllowIDList, 0)
require.Equal(t, []rbac.AllowListElement{rbac.AllowListAll()}, s.AllowIDList)
})
}
})
+80
View File
@@ -0,0 +1,80 @@
package codersdk
import (
"encoding/json"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/rbac/policy"
)
// APIAllowListTarget represents a single allow-list entry using the canonical
// string form "<resource_type>:<id>". The wildcard symbol "*" is treated as a
// permissive match for either side.
type APIAllowListTarget struct {
Type RBACResource `json:"type"`
ID string `json:"id"`
}
func AllowAllTarget() APIAllowListTarget {
return APIAllowListTarget{Type: ResourceWildcard, ID: policy.WildcardSymbol}
}
func AllowTypeTarget(r RBACResource) APIAllowListTarget {
return APIAllowListTarget{Type: r, ID: policy.WildcardSymbol}
}
func AllowResourceTarget(r RBACResource, id uuid.UUID) APIAllowListTarget {
return APIAllowListTarget{Type: r, ID: id.String()}
}
// String returns the canonical string representation "<type>:<id>" with "*" wildcards.
func (t APIAllowListTarget) String() string {
return string(t.Type) + ":" + t.ID
}
// MarshalJSON encodes as a JSON string: "<type>:<id>".
func (t APIAllowListTarget) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}
// UnmarshalJSON decodes from a JSON string: "<type>:<id>".
func (t *APIAllowListTarget) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return xerrors.Errorf("invalid allow_list entry %q: want <type>:<id>", s)
}
resource, id := RBACResource(parts[0]), parts[1]
// Type
if resource != ResourceWildcard {
if _, ok := policy.RBACPermissions[string(resource)]; !ok {
return xerrors.Errorf("unknown resource type %q", resource)
}
}
t.Type = resource
// ID
if id != policy.WildcardSymbol {
if _, err := uuid.Parse(id); err != nil {
return xerrors.Errorf("invalid %s ID (must be UUID): %q", resource, id)
}
}
t.ID = id
return nil
}
// Implement encoding.TextMarshaler/Unmarshaler for broader compatibility
func (t APIAllowListTarget) MarshalText() ([]byte, error) { return []byte(t.String()), nil }
func (t *APIAllowListTarget) UnmarshalText(b []byte) error {
return t.UnmarshalJSON([]byte("\"" + string(b) + "\""))
}
+40
View File
@@ -0,0 +1,40 @@
package codersdk_test
import (
"encoding/json"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/codersdk"
)
func TestAPIAllowListTarget_JSONRoundTrip(t *testing.T) {
t.Parallel()
all := codersdk.AllowAllTarget()
b, err := json.Marshal(all)
require.NoError(t, err)
require.JSONEq(t, `"*:*"`, string(b))
var rt codersdk.APIAllowListTarget
require.NoError(t, json.Unmarshal(b, &rt))
require.Equal(t, codersdk.ResourceWildcard, rt.Type)
require.Equal(t, policy.WildcardSymbol, rt.ID)
ty := codersdk.AllowTypeTarget(codersdk.ResourceWorkspace)
b, err = json.Marshal(ty)
require.NoError(t, err)
require.JSONEq(t, `"workspace:*"`, string(b))
require.NoError(t, json.Unmarshal(b, &rt))
require.Equal(t, codersdk.ResourceWorkspace, rt.Type)
require.Equal(t, policy.WildcardSymbol, rt.ID)
id := uuid.New()
res := codersdk.AllowResourceTarget(codersdk.ResourceTemplate, id)
b, err = json.Marshal(res)
require.NoError(t, err)
exp := `"template:` + id.String() + `"`
require.JSONEq(t, exp, string(b))
}
+5 -4
View File
@@ -44,10 +44,11 @@ const (
type APIKeyScope string
type CreateTokenRequest struct {
Lifetime time.Duration `json:"lifetime"`
Scope APIKeyScope `json:"scope,omitempty"` // Deprecated: use Scopes instead.
Scopes []APIKeyScope `json:"scopes,omitempty"`
TokenName string `json:"token_name"`
Lifetime time.Duration `json:"lifetime"`
Scope APIKeyScope `json:"scope,omitempty"` // Deprecated: use Scopes instead.
Scopes []APIKeyScope `json:"scopes,omitempty"`
TokenName string `json:"token_name"`
AllowList []APIAllowListTarget `json:"allow_list,omitempty"`
}
// GenerateAPIKeyResponse contains an API key for a user.
+29 -6
View File
@@ -705,6 +705,22 @@
|----------|----------------------------------------------------|----------|--------------|-------------|
| `bridge` | [codersdk.AIBridgeConfig](#codersdkaibridgeconfig) | false | | |
## codersdk.APIAllowListTarget
```json
{
"id": "string",
"type": "*"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|--------|------------------------------------------------|----------|--------------|-------------|
| `id` | string | false | | |
| `type` | [codersdk.RBACResource](#codersdkrbacresource) | false | | |
## codersdk.APIKey
```json
@@ -2243,6 +2259,12 @@ This is required on creation to enable a user-flow of validating a template work
```json
{
"allow_list": [
{
"id": "string",
"type": "*"
}
],
"lifetime": 0,
"scope": "all",
"scopes": [
@@ -2254,12 +2276,13 @@ This is required on creation to enable a user-flow of validating a template work
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------|-------------------------------------------------------|----------|--------------|---------------------------------|
| `lifetime` | integer | false | | |
| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. |
| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | |
| `token_name` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|--------------|---------------------------------------------------------------------|----------|--------------|---------------------------------|
| `allow_list` | array of [codersdk.APIAllowListTarget](#codersdkapiallowlisttarget) | false | | |
| `lifetime` | integer | false | | |
| `scope` | [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | Deprecated: use Scopes instead. |
| `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | |
| `token_name` | string | false | | |
## codersdk.CreateUserRequestWithOrgs
+6
View File
@@ -830,6 +830,12 @@ curl -X POST http://coder-server:8080/api/v2/users/{user}/keys/tokens \
```json
{
"allow_list": [
{
"id": "string",
"type": "*"
}
],
"lifetime": 0,
"scope": "all",
"scopes": [
+2 -2
View File
@@ -56,7 +56,7 @@ func main() {
log.Fatalf("to typescript: %v", err)
}
TsMutations(ts)
TSMutations(ts)
output, err := ts.Serialize()
if err != nil {
@@ -65,7 +65,7 @@ func main() {
_, _ = fmt.Println(output)
}
func TsMutations(ts *guts.Typescript) {
func TSMutations(ts *guts.Typescript) {
ts.ApplyMutations(
// TODO: Remove 'NotNullMaps'. This is hiding potential bugs
// of referencing maps that are actually null.
+8 -1
View File
@@ -42,13 +42,20 @@ func TestGeneration(t *testing.T) {
err = gen.IncludeGenerate("./" + dir)
require.NoError(t, err)
// Include minimal references needed for tests that use external types.
for pkg, prefix := range map[string]string{
"github.com/google/uuid": "",
} {
require.NoError(t, gen.IncludeReference(pkg, prefix))
}
err = TypeMappings(gen)
require.NoError(t, err)
ts, err := gen.ToTypescript()
require.NoError(t, err)
TsMutations(ts)
TSMutations(ts)
output, err := ts.Serialize()
require.NoError(t, err)
+7
View File
@@ -95,6 +95,12 @@ export interface AITasksPromptsResponse {
readonly prompts: Record<string, string>;
}
// From codersdk/allowlist.go
export interface APIAllowListTarget {
readonly type: RBACResource;
readonly id: string;
}
// From codersdk/apikey.go
export interface APIKey {
readonly id: string;
@@ -1007,6 +1013,7 @@ export interface CreateTokenRequest {
readonly scope?: APIKeyScope;
readonly scopes?: readonly APIKeyScope[];
readonly token_name: string;
readonly allow_list?: readonly APIAllowListTarget[];
}
// From codersdk/users.go