diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d10e39fa06..6ee189e182 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index df4aae91d3..48aaadadb9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" }, diff --git a/coderd/apikey.go b/coderd/apikey.go index c7a7eef3c6..f2aec89e57 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -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 { diff --git a/coderd/apikey/apikey.go b/coderd/apikey/apikey.go index ea186223a1..0dd874b340 100644 --- a/coderd/apikey/apikey.go +++ b/coderd/apikey/apikey.go @@ -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 } diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index f0d35cd635..f53ef3fa3b 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -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, } diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index c9a1b2063d..6a3f1a2b3f 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -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) } } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index f0e5b58d1b..289d664a1a 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -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 { diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 230b9274ea..2ac62215b4 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -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()) diff --git a/coderd/database/modelmethods_internal_test.go b/coderd/database/modelmethods_internal_test.go index 16d80d69c1..574d189206 100644 --- a/coderd/database/modelmethods_internal_test.go +++ b/coderd/database/modelmethods_internal_test.go @@ -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 diff --git a/coderd/database/types.go b/coderd/database/types.go index b534e9e269..fefba8acb7 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -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 diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index b534a124dd..08b835ccc6 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -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, diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 3ec4ca5a4c..030f23935e 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -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"), diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 78e0929df7..7b9871ce6d 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -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), diff --git a/coderd/rbac/allowlist.go b/coderd/rbac/allowlist.go new file mode 100644 index 0000000000..387d84ee2c --- /dev/null +++ b/coderd/rbac/allowlist.go @@ -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 +// "*:*", ":*", or ":" 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 :", 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 + } +} diff --git a/coderd/rbac/allowlist_test.go b/coderd/rbac/allowlist_test.go new file mode 100644 index 0000000000..3db5c4096b --- /dev/null +++ b/coderd/rbac/allowlist_test.go @@ -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) + }) +} diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 72a825003c..ff8f08adb1 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -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}}, } } diff --git a/coderd/rbac/scopes_test.go b/coderd/rbac/scopes_test.go index d3c1bf8cfb..a6f50b0326 100644 --- a/coderd/rbac/scopes_test.go +++ b/coderd/rbac/scopes_test.go @@ -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) }) } }) diff --git a/codersdk/allowlist.go b/codersdk/allowlist.go new file mode 100644 index 0000000000..48f8214537 --- /dev/null +++ b/codersdk/allowlist.go @@ -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 ":". 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 ":" with "*" wildcards. +func (t APIAllowListTarget) String() string { + return string(t.Type) + ":" + t.ID +} + +// MarshalJSON encodes as a JSON string: ":". +func (t APIAllowListTarget) MarshalJSON() ([]byte, error) { + return json.Marshal(t.String()) +} + +// UnmarshalJSON decodes from a JSON string: ":". +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 :", 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) + "\"")) +} diff --git a/codersdk/allowlist_test.go b/codersdk/allowlist_test.go new file mode 100644 index 0000000000..46eec4549e --- /dev/null +++ b/codersdk/allowlist_test.go @@ -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)) +} diff --git a/codersdk/apikey.go b/codersdk/apikey.go index 82828fcd7d..ff5d749151 100644 --- a/codersdk/apikey.go +++ b/codersdk/apikey.go @@ -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. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 7b09607802..cb616c02e0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 9815ba5406..0cfdd07c74 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -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": [ diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index 1a2bab59a6..c820bc4e1d 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -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. diff --git a/scripts/apitypings/main_test.go b/scripts/apitypings/main_test.go index 1bb89c7ba5..77b304e215 100644 --- a/scripts/apitypings/main_test.go +++ b/scripts/apitypings/main_test.go @@ -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) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1e07cf1aa2..ebb9ed1a83 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -95,6 +95,12 @@ export interface AITasksPromptsResponse { readonly prompts: Record; } +// 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