diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0a22d84d13642..b9dd40ae9239f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4286,6 +4286,32 @@ const docTemplate = `{ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Members" + ], + "summary": "Upsert a custom site-wide role", + "operationId": "upsert-a-custom-site-wide-role", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } } }, "/users/{user}": { @@ -9547,17 +9573,20 @@ const docTemplate = `{ "enum": [ "example", "auto-fill-parameters", - "multi-organization" + "multi-organization", + "custom-roles" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", - "ExperimentMultiOrganization" + "ExperimentMultiOrganization", + "ExperimentCustomRoles" ] }, "codersdk.ExternalAuth": { @@ -10372,7 +10401,7 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "updated_at": { @@ -10452,6 +10481,21 @@ const docTemplate = `{ } } }, + "codersdk.Permission": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/codersdk.RBACAction" + }, + "negate": { + "description": "Negate makes this a negative permission", + "type": "boolean" + }, + "resource_type": { + "$ref": "#/definitions/codersdk.RBACResource" + } + } + }, "codersdk.PostOAuth2ProviderAppRequest": { "type": "object", "required": [ @@ -11094,6 +11138,28 @@ const docTemplate = `{ }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, @@ -11160,6 +11226,17 @@ const docTemplate = `{ } } }, + "codersdk.SlimRole": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.SupportConfig": { "type": "object", "properties": { @@ -11673,7 +11750,7 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { @@ -12210,7 +12287,7 @@ const docTemplate = `{ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 331b1512393f7..f315cfe20584f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3775,6 +3775,28 @@ } } } + }, + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Members"], + "summary": "Upsert a custom site-wide role", + "operationId": "upsert-a-custom-site-wide-role", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Role" + } + } + } + } } }, "/users/{user}": { @@ -8545,16 +8567,23 @@ }, "codersdk.Experiment": { "type": "string", - "enum": ["example", "auto-fill-parameters", "multi-organization"], + "enum": [ + "example", + "auto-fill-parameters", + "multi-organization", + "custom-roles" + ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", + "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", - "ExperimentMultiOrganization" + "ExperimentMultiOrganization", + "ExperimentCustomRoles" ] }, "codersdk.ExternalAuth": { @@ -9316,7 +9345,7 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "updated_at": { @@ -9391,6 +9420,21 @@ } } }, + "codersdk.Permission": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/codersdk.RBACAction" + }, + "negate": { + "description": "Negate makes this a negative permission", + "type": "boolean" + }, + "resource_type": { + "$ref": "#/definitions/codersdk.RBACResource" + } + } + }, "codersdk.PostOAuth2ProviderAppRequest": { "type": "object", "required": ["callback_url", "name"], @@ -9996,6 +10040,28 @@ }, "name": { "type": "string" + }, + "organization_permissions": { + "description": "map[\u003corg_id\u003e] -\u003e Permissions", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + } + }, + "site_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } + }, + "user_permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Permission" + } } } }, @@ -10062,6 +10128,17 @@ } } }, + "codersdk.SlimRole": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "codersdk.SupportConfig": { "type": "object", "properties": { @@ -10555,7 +10632,7 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { @@ -11049,7 +11126,7 @@ "roles": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.Role" + "$ref": "#/definitions/codersdk.SlimRole" } }, "status": { diff --git a/coderd/audit.go b/coderd/audit.go index 782c977afcf1c..315913dff49c2 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -196,12 +196,12 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs CreatedAt: dblog.UserCreatedAt.Time, Status: codersdk.UserStatus(dblog.UserStatus.UserStatus), }, - Roles: []codersdk.Role{}, + Roles: []codersdk.SlimRole{}, } for _, roleName := range dblog.UserRoles { rbacRole, _ := rbac.RoleByName(roleName) - user.Roles = append(user.Roles, db2sdk.Role(rbacRole)) + user.Roles = append(user.Roles, db2sdk.SlimRole(rbacRole)) } } diff --git a/coderd/coderd.go b/coderd/coderd.go index c0631c0752c0c..f30d58f8cd726 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -61,6 +61,7 @@ import ( "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/tracing" @@ -631,6 +632,7 @@ func New(options *Options) *API { httpmw.AttachRequestID, httpmw.ExtractRealIP(api.RealIPConfig), httpmw.Logger(api.Logger), + rolestore.CustomRoleMW, prometheusMW, // Build-Version is helpful for debugging. func(next http.Handler) http.Handler { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 14a24e66316d0..ab6f3aa82b3f6 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/parameter" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -28,9 +29,25 @@ import ( // database types to slices of codersdk types. // Only works if the function takes a single argument. func List[F any, T any](list []F, convert func(F) T) []T { - into := make([]T, 0, len(list)) - for _, item := range list { - into = append(into, convert(item)) + return ListLazy(convert)(list) +} + +// ListLazy returns the converter function for a list, but does not eval +// the input. Helpful for combining the Map and the List functions. +func ListLazy[F any, T any](convert func(F) T) func(list []F) []T { + return func(list []F) []T { + into := make([]T, 0, len(list)) + for _, item := range list { + into = append(into, convert(item)) + } + return into + } +} + +func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T { + into := make(map[K]T) + for k, item := range params { + into[k] = convert(item) } return into } @@ -150,12 +167,20 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User { convertedUser := codersdk.User{ ReducedUser: ReducedUser(user), OrganizationIDs: organizationIDs, - Roles: make([]codersdk.Role, 0, len(user.RBACRoles)), + Roles: make([]codersdk.SlimRole, 0, len(user.RBACRoles)), } for _, roleName := range user.RBACRoles { - rbacRole, _ := rbac.RoleByName(roleName) - convertedUser.Roles = append(convertedUser.Roles, Role(rbacRole)) + rbacRole, err := rbac.RoleByName(roleName) + if err == nil { + convertedUser.Roles = append(convertedUser.Roles, SlimRole(rbacRole)) + } else { + // TODO: Fix this for custom roles to display the actual display_name + // Requires plumbing either a cached role value, or the db. + convertedUser.Roles = append(convertedUser.Roles, codersdk.SlimRole{ + Name: roleName, + }) + } } return convertedUser @@ -180,8 +205,8 @@ func Group(group database.Group, members []database.User) codersdk.Group { } } -func Role(role rbac.Role) codersdk.Role { - return codersdk.Role{ +func SlimRole(role rbac.Role) codersdk.SlimRole { + return codersdk.SlimRole{ DisplayName: role.DisplayName, Name: role.Name, } @@ -500,3 +525,39 @@ func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.Provisioner } return result } + +func Role(role rbac.Role) codersdk.Role { + return codersdk.Role{ + Name: role.Name, + DisplayName: role.DisplayName, + SitePermissions: List(role.Site, Permission), + OrganizationPermissions: Map(role.Org, ListLazy(Permission)), + UserPermissions: List(role.Site, Permission), + } +} + +func Permission(permission rbac.Permission) codersdk.Permission { + return codersdk.Permission{ + Negate: permission.Negate, + ResourceType: codersdk.RBACResource(permission.ResourceType), + Action: codersdk.RBACAction(permission.Action), + } +} + +func RoleToRBAC(role codersdk.Role) rbac.Role { + return rbac.Role{ + Name: role.Name, + DisplayName: role.DisplayName, + Site: List(role.SitePermissions, PermissionToRBAC), + Org: Map(role.OrganizationPermissions, ListLazy(PermissionToRBAC)), + User: List(role.UserPermissions, PermissionToRBAC), + } +} + +func PermissionToRBAC(permission codersdk.Permission) rbac.Permission { + return rbac.Permission{ + Negate: permission.Negate, + ResourceType: string(permission.ResourceType), + Action: policy.Action(permission.Action), + } +} diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go new file mode 100644 index 0000000000000..aaa2c7a34bbf3 --- /dev/null +++ b/coderd/database/dbauthz/customroles_test.go @@ -0,0 +1,258 @@ +package dbauthz_test + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/testutil" +) + +// TestUpsertCustomRoles verifies creating custom roles cannot escalate permissions. +func TestUpsertCustomRoles(t *testing.T) { + t.Parallel() + + userID := uuid.New() + subjectFromRoles := func(roles rbac.ExpandableRoles) rbac.Subject { + return rbac.Subject{ + FriendlyName: "Test user", + ID: userID.String(), + Roles: roles, + Groups: nil, + Scope: rbac.ScopeAll, + } + } + + canAssignRole := rbac.Role{ + Name: "can-assign", + DisplayName: "", + Site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceAssignRole.Type: {policy.ActionCreate}, + }), + } + + merge := func(u ...interface{}) rbac.Roles { + all := make([]rbac.Role, 0) + for _, v := range u { + v := v + switch t := v.(type) { + case rbac.Role: + all = append(all, t) + case rbac.ExpandableRoles: + all = append(all, must(t.Expand())...) + case string: + all = append(all, must(rbac.RoleByName(t))) + default: + panic("unknown type") + } + } + + return all + } + + orgID := uuid.New() + testCases := []struct { + name string + + subject rbac.ExpandableRoles + + // Perms to create on new custom role + site []rbac.Permission + org map[string][]rbac.Permission + user []rbac.Permission + errorContains string + }{ + { + // No roles, so no assign role + name: "no-roles", + subject: rbac.RoleNames([]string{}), + errorContains: "forbidden", + }, + { + // This works because the new role has 0 perms + name: "empty", + subject: merge(canAssignRole), + }, + { + name: "mixed-scopes", + subject: merge(canAssignRole, rbac.RoleOwner()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + org: map[string][]rbac.Permission{ + uuid.New().String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + errorContains: "cannot assign both org and site permissions", + }, + { + name: "multiple-org", + subject: merge(canAssignRole, rbac.RoleOwner()), + org: map[string][]rbac.Permission{ + uuid.New().String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + uuid.New().String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + errorContains: "cannot assign permissions to more than 1", + }, + { + name: "invalid-action", + subject: merge(canAssignRole, rbac.RoleOwner()), + site: rbac.Permissions(map[string][]policy.Action{ + // Action does not go with resource + rbac.ResourceWorkspace.Type: {policy.ActionViewInsights}, + }), + errorContains: "invalid action", + }, + { + name: "invalid-resource", + subject: merge(canAssignRole, rbac.RoleOwner()), + site: rbac.Permissions(map[string][]policy.Action{ + "foobar": {policy.ActionViewInsights}, + }), + errorContains: "invalid resource", + }, + { + // Not allowing these at this time. + name: "negative-permission", + subject: merge(canAssignRole, rbac.RoleOwner()), + site: []rbac.Permission{ + { + Negate: true, + ResourceType: rbac.ResourceWorkspace.Type, + Action: policy.ActionRead, + }, + }, + errorContains: "no negative permissions", + }, + { + name: "wildcard", // not allowed + subject: merge(canAssignRole, rbac.RoleOwner()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.WildcardSymbol}, + }), + errorContains: "no wildcard symbols", + }, + // escalation checks + { + name: "read-workspace-escalation", + subject: merge(canAssignRole), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + errorContains: "not allowed to grant this permission", + }, + { + name: "read-workspace-outside-org", + subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID)), + org: map[string][]rbac.Permission{ + // The org admin is for a different org + uuid.NewString(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + errorContains: "not allowed to grant this permission", + }, + { + name: "user-escalation", + // These roles do not grant user perms + subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID)), + user: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + errorContains: "not allowed to grant this permission", + }, + { + name: "template-admin-escalation", + subject: merge(canAssignRole, rbac.RoleTemplateAdmin()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, // ok! + rbac.ResourceDeploymentConfig.Type: {policy.ActionUpdate}, // not ok! + }), + user: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, // ok! + }), + errorContains: "deployment_config", + }, + // ok! + { + name: "read-workspace-template-admin", + subject: merge(canAssignRole, rbac.RoleTemplateAdmin()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + { + name: "read-workspace-in-org", + subject: merge(canAssignRole, rbac.RoleOrgAdmin(orgID)), + org: map[string][]rbac.Permission{ + // Org admin of this org, this is ok! + orgID.String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + }, + { + name: "user-perms", + // This is weird, but is ok + subject: merge(canAssignRole, rbac.RoleMember()), + user: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + { + name: "site+user-perms", + subject: merge(canAssignRole, rbac.RoleMember(), rbac.RoleTemplateAdmin()), + site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + user: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }), + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db := dbmem.New() + rec := &coderdtest.RecordingAuthorizer{ + Wrapped: rbac.NewAuthorizer(prometheus.NewRegistry()), + } + az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer()) + + subject := subjectFromRoles(tc.subject) + ctx := testutil.Context(t, testutil.WaitMedium) + ctx = dbauthz.As(ctx, subject) + + _, err := az.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ + Name: "test-role", + DisplayName: "", + SitePermissions: must(json.Marshal(tc.site)), + OrgPermissions: must(json.Marshal(tc.org)), + UserPermissions: must(json.Marshal(tc.user)), + }) + if tc.errorContains != "" { + require.ErrorContains(t, err, tc.errorContains) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a096346f57064..614bcfc3b63be 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync/atomic" "time" @@ -17,6 +18,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -580,6 +582,7 @@ func (q *querier) authorizeUpdateFileTemplate(ctx context.Context, file database } } +// canAssignRoles handles assigning built in and custom roles. func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, removed []string) error { actor, ok := ActorFromContext(ctx) if !ok { @@ -594,6 +597,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r } grantedRoles := append(added, removed...) + notBuiltInRoles := make([]string, 0) // Validate that the roles being assigned are valid. for _, r := range grantedRoles { _, isOrgRole := rbac.IsOrgRole(r) @@ -606,7 +610,36 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r // All roles should be valid roles if _, err := rbac.RoleByName(r); err != nil { - return xerrors.Errorf("%q is not a supported role", r) + notBuiltInRoles = append(notBuiltInRoles, r) + } + } + + notBuiltInRolesMap := make(map[string]struct{}, len(notBuiltInRoles)) + for _, r := range notBuiltInRoles { + notBuiltInRolesMap[r] = struct{}{} + } + + if len(notBuiltInRoles) > 0 { + // See if they are custom roles. Use rolestore to leverage any cached + // role fetches. + customRoles, err := rolestore.Expand(ctx, q.db, notBuiltInRoles) + if err != nil { + return xerrors.Errorf("fetching custom roles: %w", err) + } + + // If the lists are not identical, then have a problem, as some roles + // provided do no exist. + if len(customRoles) != len(notBuiltInRoles) { + for _, role := range notBuiltInRoles { + // Stop at the first one found. We could make a better error that + // returns them all, but then someone could pass in a large list to make us do + // a lot of loop iterations. + if !slices.ContainsFunc(customRoles, func(customRole rbac.Role) bool { + return strings.EqualFold(customRole.Name, role) + }) { + return xerrors.Errorf("%q is not a supported role", role) + } + } } } @@ -623,6 +656,11 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID *uuid.UUID, added, r } for _, roleName := range grantedRoles { + if _, isCustom := notBuiltInRolesMap[roleName]; isCustom { + // For now, use a constant name so our static assign map still works. + roleName = rbac.CustomSiteRole() + } + if !rbac.CanAssignRole(actor.Roles, roleName) { return xerrors.Errorf("not authorized to assign role %q", roleName) } @@ -704,6 +742,31 @@ func (q *querier) authorizeTemplateInsights(ctx context.Context, templateIDs []u return nil } +// customRoleEscalationCheck checks to make sure the caller has every permission they are adding +// to a custom role. This prevents permission escalation. +func (q *querier) customRoleEscalationCheck(ctx context.Context, actor rbac.Subject, perm rbac.Permission, object rbac.Object) error { + if perm.Negate { + // Users do not need negative permissions. We can include it later if required. + return xerrors.Errorf("invalid permission for action=%q type=%q, no negative permissions", perm.Action, perm.ResourceType) + } + + if perm.Action == policy.WildcardSymbol || perm.ResourceType == policy.WildcardSymbol { + // It is possible to check for supersets with wildcards, but wildcards can also + // include resources and actions that do not exist. Custom roles should only be allowed + // to include permissions for existing resources. + return xerrors.Errorf("invalid permission for action=%q type=%q, no wildcard symbols", perm.Action, perm.ResourceType) + } + + object.Type = perm.ResourceType + if err := q.auth.Authorize(ctx, actor, perm.Action, object); err != nil { + // This is a forbidden error, but we can provide more context. Since the user can create a role, just not + // with this perm. + return xerrors.Errorf("invalid permission for action=%q type=%q, not allowed to grant this permission", perm.Action, perm.ResourceType) + } + + return nil +} + func (q *querier) AcquireLock(ctx context.Context, id int64) error { return q.db.AcquireLock(ctx, id) } @@ -773,6 +836,13 @@ func (q *querier) CleanTailnetTunnels(ctx context.Context) error { return q.db.CleanTailnetTunnels(ctx) } +func (q *querier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAssignRole); err != nil { + return nil, err + } + return q.db.CustomRolesByName(ctx, lookupRoles) +} + func (q *querier) DeleteAPIKeyByID(ctx context.Context, id string) error { return deleteQ(q.log, q.auth, q.db.GetAPIKeyByID, q.db.DeleteAPIKeyByID)(ctx, id) } @@ -3291,6 +3361,78 @@ func (q *querier) UpsertApplicationName(ctx context.Context, value string) error return q.db.UpsertApplicationName(ctx, value) } +// UpsertCustomRole does a series of authz checks to protect custom roles. +// - Check custom roles are valid for their resource types + actions +// - Check the actor can create the custom role +// - Check the custom role does not grant perms the actor does not have +// - Prevent negative perms +// - Prevent roles with site and org permissions. +func (q *querier) UpsertCustomRole(ctx context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { + act, ok := ActorFromContext(ctx) + if !ok { + return database.CustomRole{}, NoActorError + } + + // TODO: If this is an org role, check the org assign role type. + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceAssignRole); err != nil { + return database.CustomRole{}, err + } + + // There is quite a bit of validation we should do here. First, let's make sure the json data is correct. + rbacRole, err := rolestore.ConvertDBRole(database.CustomRole{ + Name: arg.Name, + DisplayName: arg.DisplayName, + SitePermissions: arg.SitePermissions, + OrgPermissions: arg.OrgPermissions, + UserPermissions: arg.UserPermissions, + }) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("invalid args: %w", err) + } + + err = rbacRole.Valid() + if err != nil { + return database.CustomRole{}, xerrors.Errorf("invalid role: %w", err) + } + + if len(rbacRole.Org) > 0 && len(rbacRole.Site) > 0 { + // This is a choice to keep roles simple. If we allow mixing site and org scoped perms, then knowing who can + // do what gets more complicated. + return database.CustomRole{}, xerrors.Errorf("invalid custom role, cannot assign both org and site permissions at the same time") + } + + if len(rbacRole.Org) > 1 { + // Again to avoid more complexity in our roles + return database.CustomRole{}, xerrors.Errorf("invalid custom role, cannot assign permissions to more than 1 org at a time") + } + + // Prevent escalation + for _, sitePerm := range rbacRole.Site { + err := q.customRoleEscalationCheck(ctx, act, sitePerm, rbac.Object{Type: sitePerm.ResourceType}) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("site permission: %w", err) + } + } + + for orgID, perms := range rbacRole.Org { + for _, orgPerm := range perms { + err := q.customRoleEscalationCheck(ctx, act, orgPerm, rbac.Object{OrgID: orgID, Type: orgPerm.ResourceType}) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("org=%q: %w", orgID, err) + } + } + } + + for _, userPerm := range rbacRole.User { + err := q.customRoleEscalationCheck(ctx, act, userPerm, rbac.Object{Type: userPerm.ResourceType, Owner: act.ID}) + if err != nil { + return database.CustomRole{}, xerrors.Errorf("user permission: %w", err) + } + } + + return q.db.UpsertCustomRole(ctx, arg) +} + func (q *querier) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e8dcb2f8ee5bc..7d04a0d20a52e 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1167,6 +1167,67 @@ func (s *MethodTestSuite) TestUser() { b := dbgen.User(s.T(), db, database.User{}) check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(slice.New(a.ID, b.ID)) })) + s.Run("CustomRolesByName", s.Subtest(func(db database.Store, check *expects) { + check.Args([]string{}).Asserts(rbac.ResourceAssignRole, policy.ActionRead).Returns([]database.CustomRole{}) + })) + s.Run("Blank/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { + // Blank is no perms in the role + check.Args(database.UpsertCustomRoleParams{ + Name: "test", + DisplayName: "Test Name", + SitePermissions: []byte(`[]`), + OrgPermissions: []byte(`{}`), + UserPermissions: []byte(`[]`), + }).Asserts(rbac.ResourceAssignRole, policy.ActionCreate) + })) + s.Run("SitePermissions/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.UpsertCustomRoleParams{ + Name: "test", + DisplayName: "Test Name", + SitePermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete, policy.ActionViewInsights}, + }))), + OrgPermissions: []byte(`{}`), + UserPermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }))), + }).Asserts( + // First check + rbac.ResourceAssignRole, policy.ActionCreate, + // Escalation checks + rbac.ResourceTemplate, policy.ActionCreate, + rbac.ResourceTemplate, policy.ActionRead, + rbac.ResourceTemplate, policy.ActionUpdate, + rbac.ResourceTemplate, policy.ActionDelete, + rbac.ResourceTemplate, policy.ActionViewInsights, + + rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead, + ) + })) + s.Run("OrgPermissions/UpsertCustomRole", s.Subtest(func(db database.Store, check *expects) { + orgID := uuid.New() + check.Args(database.UpsertCustomRoleParams{ + Name: "test", + DisplayName: "Test Name", + SitePermissions: []byte(`[]`), + OrgPermissions: must(json.Marshal(map[string][]rbac.Permission{ + orgID.String(): rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceTemplate.Type: {policy.ActionCreate, policy.ActionRead}, + }), + })), + UserPermissions: must(json.Marshal(rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceWorkspace.Type: {policy.ActionRead}, + }))), + }).Asserts( + // First check + rbac.ResourceAssignRole, policy.ActionCreate, + // Escalation checks + rbac.ResourceTemplate.InOrg(orgID), policy.ActionCreate, + rbac.ResourceTemplate.InOrg(orgID), policy.ActionRead, + + rbac.ResourceWorkspace.WithOwner(testActorID.String()), policy.ActionRead, + ) + })) } func (s *MethodTestSuite) TestWorkspace() { diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 16829cdef669e..3385ca3f3240c 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -99,6 +99,8 @@ func (s *MethodTestSuite) TearDownSuite() { }) } +var testActorID = uuid.New() + // Subtest is a helper function that returns a function that can be passed to // s.Run(). This function will run the test case for the method that is being // tested. The check parameter is used to assert the results of the method. @@ -120,7 +122,7 @@ func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expec } az := dbauthz.New(db, rec, slog.Make(), coderdtest.AccessControlStorePointer()) actor := rbac.Subject{ - ID: uuid.NewString(), + ID: testActorID.String(), Roles: rbac.RoleNames{rbac.RoleOwner()}, Groups: []string{}, Scope: rbac.ScopeAll, diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 8a2ce25b34367..01cea6709814b 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -75,6 +75,7 @@ func New() database.Store { workspaces: make([]database.Workspace, 0), licenses: make([]database.License, 0), workspaceProxies: make([]database.WorkspaceProxy, 0), + customRoles: make([]database.CustomRole, 0), locks: map[int64]struct{}{}, }, } @@ -179,6 +180,7 @@ type data struct { workspaceResources []database.WorkspaceResource workspaces []database.Workspace workspaceProxies []database.WorkspaceProxy + customRoles []database.CustomRole // Locks is a map of lock names. Any keys within the map are currently // locked. locks map[int64]struct{} @@ -1172,6 +1174,23 @@ func (*FakeQuerier) CleanTailnetTunnels(context.Context) error { return ErrUnimplemented } +func (q *FakeQuerier) CustomRolesByName(_ context.Context, lookupRoles []string) ([]database.CustomRole, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + found := make([]database.CustomRole, 0) + for _, role := range q.data.customRoles { + if slices.ContainsFunc(lookupRoles, func(s string) bool { + return strings.EqualFold(s, role.Name) + }) { + role := role + found = append(found, role) + } + } + + return found, nil +} + func (q *FakeQuerier) DeleteAPIKeyByID(_ context.Context, id string) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -8188,6 +8207,39 @@ func (q *FakeQuerier) UpsertApplicationName(_ context.Context, data string) erro return nil } +func (q *FakeQuerier) UpsertCustomRole(_ context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.CustomRole{}, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + for i := range q.customRoles { + if strings.EqualFold(q.customRoles[i].Name, arg.Name) { + q.customRoles[i].DisplayName = arg.DisplayName + q.customRoles[i].SitePermissions = arg.SitePermissions + q.customRoles[i].OrgPermissions = arg.OrgPermissions + q.customRoles[i].UserPermissions = arg.UserPermissions + q.customRoles[i].LastUpdated = dbtime.Now() + return q.customRoles[i], nil + } + } + + role := database.CustomRole{ + Name: arg.Name, + DisplayName: arg.DisplayName, + SitePermissions: arg.SitePermissions, + OrgPermissions: arg.OrgPermissions, + UserPermissions: arg.UserPermissions, + CreatedAt: dbtime.Now(), + LastUpdated: dbtime.Now(), + } + q.customRoles = append(q.customRoles, role) + + return role, nil +} + func (q *FakeQuerier) UpsertDefaultProxy(_ context.Context, arg database.UpsertDefaultProxyParams) error { q.defaultProxyDisplayName = arg.DisplayName q.defaultProxyIconURL = arg.IconUrl diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 77ebfd6718757..4e0c2b8fed158 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -144,6 +144,13 @@ func (m metricsStore) CleanTailnetTunnels(ctx context.Context) error { return r0 } +func (m metricsStore) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]database.CustomRole, error) { + start := time.Now() + r0, r1 := m.s.CustomRolesByName(ctx, lookupRoles) + m.queryLatencies.WithLabelValues("CustomRolesByName").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) DeleteAPIKeyByID(ctx context.Context, id string) error { start := time.Now() err := m.s.DeleteAPIKeyByID(ctx, id) @@ -2153,6 +2160,13 @@ func (m metricsStore) UpsertApplicationName(ctx context.Context, value string) e return r0 } +func (m metricsStore) UpsertCustomRole(ctx context.Context, arg database.UpsertCustomRoleParams) (database.CustomRole, error) { + start := time.Now() + r0, r1 := m.s.UpsertCustomRole(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertCustomRole").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error { start := time.Now() r0 := m.s.UpsertDefaultProxy(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index e651c8301c933..69558e884c6a6 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -173,6 +173,21 @@ func (mr *MockStoreMockRecorder) CleanTailnetTunnels(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanTailnetTunnels", reflect.TypeOf((*MockStore)(nil).CleanTailnetTunnels), arg0) } +// CustomRolesByName mocks base method. +func (m *MockStore) CustomRolesByName(arg0 context.Context, arg1 []string) ([]database.CustomRole, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CustomRolesByName", arg0, arg1) + ret0, _ := ret[0].([]database.CustomRole) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CustomRolesByName indicates an expected call of CustomRolesByName. +func (mr *MockStoreMockRecorder) CustomRolesByName(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomRolesByName", reflect.TypeOf((*MockStore)(nil).CustomRolesByName), arg0, arg1) +} + // DeleteAPIKeyByID mocks base method. func (m *MockStore) DeleteAPIKeyByID(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() @@ -4507,6 +4522,21 @@ func (mr *MockStoreMockRecorder) UpsertApplicationName(arg0, arg1 any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertApplicationName", reflect.TypeOf((*MockStore)(nil).UpsertApplicationName), arg0, arg1) } +// UpsertCustomRole mocks base method. +func (m *MockStore) UpsertCustomRole(arg0 context.Context, arg1 database.UpsertCustomRoleParams) (database.CustomRole, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertCustomRole", arg0, arg1) + ret0, _ := ret[0].(database.CustomRole) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertCustomRole indicates an expected call of UpsertCustomRole. +func (mr *MockStoreMockRecorder) UpsertCustomRole(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertCustomRole", reflect.TypeOf((*MockStore)(nil).UpsertCustomRole), arg0, arg1) +} + // UpsertDefaultProxy mocks base method. func (m *MockStore) UpsertDefaultProxy(arg0 context.Context, arg1 database.UpsertDefaultProxyParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ed400cf82198f..0495751c45bf2 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -404,6 +404,18 @@ CREATE TABLE audit_logs ( resource_icon text NOT NULL ); +CREATE TABLE custom_roles ( + name text NOT NULL, + display_name text NOT NULL, + site_permissions jsonb DEFAULT '[]'::jsonb NOT NULL, + org_permissions jsonb DEFAULT '{}'::jsonb NOT NULL, + user_permissions jsonb DEFAULT '[]'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_updated timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime'; + CREATE TABLE dbcrypt_keys ( number integer NOT NULL, active_key_digest text, @@ -1398,6 +1410,9 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY custom_roles + ADD CONSTRAINT custom_roles_pkey PRIMARY KEY (name); + ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); @@ -1606,6 +1621,8 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id); CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC); +CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); + CREATE INDEX idx_organization_member_organization_id_uuid ON organization_members USING btree (organization_id); CREATE INDEX idx_organization_member_user_id_uuid ON organization_members USING btree (user_id); diff --git a/coderd/database/migrations/000209_custom_roles.down.sql b/coderd/database/migrations/000209_custom_roles.down.sql new file mode 100644 index 0000000000000..b0f9b2a8cc76c --- /dev/null +++ b/coderd/database/migrations/000209_custom_roles.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_custom_roles_name_lower; +DROP TABLE IF EXISTS custom_roles; diff --git a/coderd/database/migrations/000209_custom_roles.up.sql b/coderd/database/migrations/000209_custom_roles.up.sql new file mode 100644 index 0000000000000..402ab19635cfc --- /dev/null +++ b/coderd/database/migrations/000209_custom_roles.up.sql @@ -0,0 +1,26 @@ +CREATE TABLE custom_roles ( + -- name is globally unique. Org scoped roles have their orgid appended + -- like: "name":"organization-admin:bbe8c156-c61e-4d36-b91e-697c6b1477e8" + name text primary key, + -- display_name is the actual name of the role displayed to the user. + display_name text NOT NULL, + + -- Unfortunately these values are schemaless json documents. + -- If there was a permission table for these, that would involve + -- many necessary joins to accomplish this simple json. + + -- site_permissions is '[]Permission' + site_permissions jsonb NOT NULL default '[]', + -- org_permissions is 'map[][]Permission' + org_permissions jsonb NOT NULL default '{}', + -- user_permissions is '[]Permission' + user_permissions jsonb NOT NULL default '[]', + + -- extra convenience meta data. + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_updated timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Ensure no case variants of the same roles +CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); +COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime'; diff --git a/coderd/database/migrations/testdata/fixtures/000209_custom_roles.up.sql b/coderd/database/migrations/testdata/fixtures/000209_custom_roles.up.sql new file mode 100644 index 0000000000000..b085f460afdcc --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000209_custom_roles.up.sql @@ -0,0 +1,20 @@ +INSERT INTO + custom_roles ( + name, + display_name, + site_permissions, + org_permissions, + user_permissions, + created_at, + last_updated +) +VALUES + ( + 'custom-role', + 'Custom Role', + '[{"negate":false,"resource_type":"deployment_config","action":"update"},{"negate":false,"resource_type":"workspace","action":"read"}]', + '{}', + '[{"negate":false,"resource_type":"workspace","action":"read"}]', + date_trunc('hour', NOW()), + date_trunc('hour', NOW()) + '30 minute'::interval + ); diff --git a/coderd/database/models.go b/coderd/database/models.go index 18587b05ade1a..ca0222bf35f83 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1781,6 +1781,17 @@ type AuditLog struct { ResourceIcon string `db:"resource_icon" json:"resource_icon"` } +// Custom roles allow dynamic roles expanded at runtime +type CustomRole struct { + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + SitePermissions json.RawMessage `db:"site_permissions" json:"site_permissions"` + OrgPermissions json.RawMessage `db:"org_permissions" json:"org_permissions"` + UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + LastUpdated time.Time `db:"last_updated" json:"last_updated"` +} + // A table used to store the keys used to encrypt the database. type DBCryptKey struct { // An integer used to identify the key. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 405f86bf47688..01615a58e06bd 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -48,6 +48,7 @@ type sqlcQuerier interface { CleanTailnetCoordinators(ctx context.Context) error CleanTailnetLostPeers(ctx context.Context) error CleanTailnetTunnels(ctx context.Context) error + CustomRolesByName(ctx context.Context, lookupRoles []string) ([]CustomRole, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteAPIKeysByUserID(ctx context.Context, userID uuid.UUID) error DeleteAllTailnetClientSubscriptions(ctx context.Context, arg DeleteAllTailnetClientSubscriptionsParams) error @@ -413,6 +414,7 @@ type sqlcQuerier interface { UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) error UpsertAppSecurityKey(ctx context.Context, value string) error UpsertApplicationName(ctx context.Context, value string) error + UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleParams) (CustomRole, error) // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. // The functional values are immutable and controlled implicitly. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e0fba2dad35bd..2fcc0edff0d82 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5519,6 +5519,107 @@ func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams) return i, err } +const customRolesByName = `-- name: CustomRolesByName :many +SELECT + name, display_name, site_permissions, org_permissions, user_permissions, created_at, last_updated +FROM + custom_roles +WHERE + -- Case insensitive + name ILIKE ANY($1 :: text []) +` + +func (q *sqlQuerier) CustomRolesByName(ctx context.Context, lookupRoles []string) ([]CustomRole, error) { + rows, err := q.db.QueryContext(ctx, customRolesByName, pq.Array(lookupRoles)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CustomRole + for rows.Next() { + var i CustomRole + if err := rows.Scan( + &i.Name, + &i.DisplayName, + &i.SitePermissions, + &i.OrgPermissions, + &i.UserPermissions, + &i.CreatedAt, + &i.LastUpdated, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertCustomRole = `-- name: UpsertCustomRole :one +INSERT INTO + custom_roles ( + name, + display_name, + site_permissions, + org_permissions, + user_permissions, + created_at, + last_updated +) +VALUES ( + -- Always force lowercase names + lower($1), + $2, + $3, + $4, + $5, + now(), + now() + ) +ON CONFLICT (name) + DO UPDATE SET + display_name = $2, + site_permissions = $3, + org_permissions = $4, + user_permissions = $5, + last_updated = now() +RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, last_updated +` + +type UpsertCustomRoleParams struct { + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + SitePermissions json.RawMessage `db:"site_permissions" json:"site_permissions"` + OrgPermissions json.RawMessage `db:"org_permissions" json:"org_permissions"` + UserPermissions json.RawMessage `db:"user_permissions" json:"user_permissions"` +} + +func (q *sqlQuerier) UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleParams) (CustomRole, error) { + row := q.db.QueryRowContext(ctx, upsertCustomRole, + arg.Name, + arg.DisplayName, + arg.SitePermissions, + arg.OrgPermissions, + arg.UserPermissions, + ) + var i CustomRole + err := row.Scan( + &i.Name, + &i.DisplayName, + &i.SitePermissions, + &i.OrgPermissions, + &i.UserPermissions, + &i.CreatedAt, + &i.LastUpdated, + ) + return i, err +} + const getAppSecurityKey = `-- name: GetAppSecurityKey :one SELECT value FROM site_configs WHERE key = 'app_signing_key' ` diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql new file mode 100644 index 0000000000000..e2077bce3d9d7 --- /dev/null +++ b/coderd/database/queries/roles.sql @@ -0,0 +1,41 @@ +-- name: CustomRolesByName :many +SELECT + * +FROM + custom_roles +WHERE + -- Case insensitive + name ILIKE ANY(@lookup_roles :: text []) +; + + +-- name: UpsertCustomRole :one +INSERT INTO + custom_roles ( + name, + display_name, + site_permissions, + org_permissions, + user_permissions, + created_at, + last_updated +) +VALUES ( + -- Always force lowercase names + lower(@name), + @display_name, + @site_permissions, + @org_permissions, + @user_permissions, + now(), + now() + ) +ON CONFLICT (name) + DO UPDATE SET + display_name = @display_name, + site_permissions = @site_permissions, + org_permissions = @org_permissions, + user_permissions = @user_permissions, + last_updated = now() +RETURNING * +; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9db8af72c8cf6..9dfc8c124aa75 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -9,6 +9,7 @@ const ( UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); UniqueAPIKeysPkey UniqueConstraint = "api_keys_pkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); UniqueAuditLogsPkey UniqueConstraint = "audit_logs_pkey" // ALTER TABLE ONLY audit_logs ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); + UniqueCustomRolesPkey UniqueConstraint = "custom_roles_pkey" // ALTER TABLE ONLY custom_roles ADD CONSTRAINT custom_roles_pkey PRIMARY KEY (name); UniqueDbcryptKeysActiveKeyDigestKey UniqueConstraint = "dbcrypt_keys_active_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_active_key_digest_key UNIQUE (active_key_digest); UniqueDbcryptKeysPkey UniqueConstraint = "dbcrypt_keys_pkey" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_pkey PRIMARY KEY (number); UniqueDbcryptKeysRevokedKeyDigestKey UniqueConstraint = "dbcrypt_keys_revoked_key_digest_key" // ALTER TABLE ONLY dbcrypt_keys ADD CONSTRAINT dbcrypt_keys_revoked_key_digest_key UNIQUE (revoked_key_digest); @@ -74,6 +75,7 @@ const ( UniqueWorkspaceResourcesPkey UniqueConstraint = "workspace_resources_pkey" // ALTER TABLE ONLY workspace_resources ADD CONSTRAINT workspace_resources_pkey PRIMARY KEY (id); UniqueWorkspacesPkey UniqueConstraint = "workspaces_pkey" // ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); UniqueIndexAPIKeyName UniqueConstraint = "idx_api_key_name" // CREATE UNIQUE INDEX idx_api_key_name ON api_keys USING btree (user_id, token_name) WHERE (login_type = 'token'::login_type); + UniqueIndexCustomRolesNameLower UniqueConstraint = "idx_custom_roles_name_lower" // CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name)); UniqueIndexOrganizationName UniqueConstraint = "idx_organization_name" // CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name); UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name)); UniqueIndexProvisionerDaemonsNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_name_owner_key ON provisioner_daemons USING btree (name, lower(COALESCE((tags ->> 'owner'::text), ''::text))); diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 0bd064bf8e28a..5bb45424b57f9 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" ) @@ -437,11 +438,21 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } + //nolint:gocritic // Permission to lookup custom roles the user has assigned. + rbacRoles, err := rolestore.Expand(dbauthz.AsSystemRestricted(ctx), cfg.DB, roles.Roles) + if err != nil { + return write(http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to expand authenticated user roles", + Detail: err.Error(), + Validations: nil, + }) + } + // Actor is the user's authorization context. actor := rbac.Subject{ FriendlyName: roles.Username, ID: key.UserID.String(), - Roles: rbac.RoleNames(roles.Roles), + Roles: rbacRoles, Groups: roles.Groups, Scope: rbac.ScopeName(key.Scope), }.WithCachedASTValue() diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index c67be2ca2bdf7..6b513682cb68c 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -128,7 +128,8 @@ func TestExtractUserRoles(t *testing.T) { rtr.Get("/", func(_ http.ResponseWriter, r *http.Request) { roles := httpmw.UserAuthorization(r) require.Equal(t, user.ID.String(), roles.ID) - require.ElementsMatch(t, expRoles, roles.Roles.Names()) + foundRoles := roles.Roles.Names() + require.ElementsMatch(t, expRoles, foundRoles) }) req := httptest.NewRequest("GET", "/", nil) diff --git a/coderd/members.go b/coderd/members.go index 038851870cf8d..6a3fe3b2bcb09 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -82,7 +82,7 @@ func (api *API) updateOrganizationMemberRoles(ctx context.Context, args database } if _, err := rbac.RoleByName(r); err != nil { - return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported role", r) + return database.OrganizationMember{}, xerrors.Errorf("%q is not a supported organization role", r) } } @@ -99,12 +99,12 @@ func convertOrganizationMember(mem database.OrganizationMember) codersdk.Organiz OrganizationID: mem.OrganizationID, CreatedAt: mem.CreatedAt, UpdatedAt: mem.UpdatedAt, - Roles: make([]codersdk.Role, 0, len(mem.Roles)), + Roles: make([]codersdk.SlimRole, 0, len(mem.Roles)), } for _, roleName := range mem.Roles { rbacRole, _ := rbac.RoleByName(roleName) - convertedMember.Roles = append(convertedMember.Roles, db2sdk.Role(rbacRole)) + convertedMember.Roles = append(convertedMember.Roles, db2sdk.SlimRole(rbacRole)) } return convertedMember } diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 30a74e4f825dd..dfd8ab6b55b23 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -1,9 +1,8 @@ package rbac import ( - "fmt" - "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/rbac/policy" ) @@ -36,10 +35,10 @@ type Object struct { func (z Object) ValidAction(action policy.Action) error { perms, ok := policy.RBACPermissions[z.Type] if !ok { - return fmt.Errorf("invalid type %q", z.Type) + return xerrors.Errorf("invalid type %q", z.Type) } if _, ok := perms.Actions[action]; !ok { - return fmt.Errorf("invalid action %q for type %q", action, z.Type) + return xerrors.Errorf("invalid action %q for type %q", action, z.Type) } return nil diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 57ec0982a15ae..9ab848d795b1c 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -37,7 +37,8 @@ var ( // ResourceAssignRole // Valid Actions // - "ActionAssign" :: ability to assign roles - // - "ActionDelete" :: ability to delete roles + // - "ActionCreate" :: ability to create/delete/edit custom roles + // - "ActionDelete" :: ability to unassign roles // - "ActionRead" :: view what roles are assignable ResourceAssignRole = Object{ Type: "assign_role", diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 26afb0e011ca7..2d3213264a514 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -209,7 +209,8 @@ var RBACPermissions = map[string]PermissionDefinition{ Actions: map[Action]ActionDefinition{ ActionAssign: actDef("ability to assign roles"), ActionRead: actDef("view what roles are assignable"), - ActionDelete: actDef("ability to delete roles"), + ActionDelete: actDef("ability to unassign roles"), + ActionCreate: actDef("ability to create/delete/edit custom roles"), }, }, "assign_org_role": { diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index cee365d06624c..33a3d24610a6b 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -1,6 +1,7 @@ package rbac import ( + "errors" "sort" "strings" @@ -19,6 +20,10 @@ const ( templateAdmin string = "template-admin" userAdmin string = "user-admin" auditor string = "auditor" + // customSiteRole is a placeholder for all custom site roles. + // This is used for what roles can assign other roles. + // TODO: Make this more dynamic to allow other roles to grant. + customSiteRole string = "custom-site-role" orgAdmin string = "organization-admin" orgMember string = "organization-member" @@ -47,6 +52,8 @@ func (names RoleNames) Names() []string { // Once we have a database implementation, the "default" roles can be defined on the // site and orgs, and these functions can be removed. +func CustomSiteRole() string { return roleName(customSiteRole, "") } + func RoleOwner() string { return roleName(owner, "") } @@ -319,22 +326,24 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // map[actor_role][assign_role] var assignRoles = map[string]map[string]bool{ "system": { - owner: true, - auditor: true, - member: true, - orgAdmin: true, - orgMember: true, - templateAdmin: true, - userAdmin: true, + owner: true, + auditor: true, + member: true, + orgAdmin: true, + orgMember: true, + templateAdmin: true, + userAdmin: true, + customSiteRole: true, }, owner: { - owner: true, - auditor: true, - member: true, - orgAdmin: true, - orgMember: true, - templateAdmin: true, - userAdmin: true, + owner: true, + auditor: true, + member: true, + orgAdmin: true, + orgMember: true, + templateAdmin: true, + userAdmin: true, + customSiteRole: true, }, userAdmin: { member: true, @@ -369,6 +378,26 @@ type Permission struct { Action policy.Action `json:"action"` } +func (perm Permission) Valid() error { + if perm.ResourceType == policy.WildcardSymbol { + // Wildcard is tricky to check. Just allow it. + return nil + } + + resource, ok := policy.RBACPermissions[perm.ResourceType] + if !ok { + return xerrors.Errorf("invalid resource type %q", perm.ResourceType) + } + + if perm.Action != policy.WildcardSymbol { + _, ok := resource.Actions[perm.Action] + if !ok { + return xerrors.Errorf("invalid action %q for resource %q", perm.Action, perm.ResourceType) + } + } + return nil +} + // Role is a set of permissions at multiple levels: // - Site level permissions apply EVERYWHERE // - Org level permissions apply to EVERYTHING in a given ORG @@ -393,6 +422,34 @@ type Role struct { cachedRegoValue ast.Value } +// Valid will check all it's permissions and ensure they are all correct +// according to the policy. This verifies every action specified make sense +// for the given resource. +func (role Role) Valid() error { + var errs []error + for _, perm := range role.Site { + if err := perm.Valid(); err != nil { + errs = append(errs, xerrors.Errorf("site: %w", err)) + } + } + + for orgID, permissions := range role.Org { + for _, perm := range permissions { + if err := perm.Valid(); err != nil { + errs = append(errs, xerrors.Errorf("org=%q: %w", orgID, err)) + } + } + } + + for _, perm := range role.User { + if err := perm.Valid(); err != nil { + errs = append(errs, xerrors.Errorf("user: %w", err)) + } + } + + return errors.Join(errs...) +} + type Roles []Role func (roles Roles) Expand() ([]Role, error) { @@ -402,7 +459,7 @@ func (roles Roles) Expand() ([]Role, error) { func (roles Roles) Names() []string { names := make([]string, 0, len(roles)) for _, r := range roles { - return append(names, r.Name) + names = append(names, r.Name) } return names } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 44ef83b74cd20..d90f045284c5b 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -20,6 +20,27 @@ type authSubject struct { Actor rbac.Subject } +// TestBuiltInRoles makes sure our built-in roles are valid by our own policy +// rules. If this is incorrect, that is a mistake. +func TestBuiltInRoles(t *testing.T) { + t.Parallel() + for _, r := range rbac.SiteRoles() { + r := r + t.Run(r.Name, func(t *testing.T) { + t.Parallel() + require.NoError(t, r.Valid(), "invalid role") + }) + } + + for _, r := range rbac.OrganizationRoles(uuid.New()) { + r := r + t.Run(r.Name, func(t *testing.T) { + t.Parallel() + require.NoError(t, r.Valid(), "invalid role") + }) + } +} + //nolint:tparallel,paralleltest func TestOwnerExec(t *testing.T) { owner := rbac.Subject{ @@ -227,6 +248,15 @@ func TestRolePermissions(t *testing.T) { false: {otherOrgAdmin, otherOrgMember, memberMe, userAdmin}, }, }, + { + Name: "CreateCustomRole", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceAssignRole, + AuthorizeMap: map[bool][]authSubject{ + true: {owner}, + false: {userAdmin, orgAdmin, orgMemberMe, otherOrgAdmin, otherOrgMember, memberMe, templateAdmin}, + }, + }, { Name: "RoleAssignment", Actions: []policy.Action{policy.ActionAssign, policy.ActionDelete}, @@ -359,7 +389,7 @@ func TestRolePermissions(t *testing.T) { }, // Some admin style resources { - Name: "Licences", + Name: "Licenses", Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, Resource: rbac.ResourceLicense, AuthorizeMap: map[bool][]authSubject{ diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go new file mode 100644 index 0000000000000..0ed8b2f12fcdb --- /dev/null +++ b/coderd/rbac/rolestore/rolestore.go @@ -0,0 +1,146 @@ +package rolestore + +import ( + "context" + "encoding/json" + "net/http" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/syncmap" +) + +type customRoleCtxKey struct{} + +// CustomRoleMW adds a custom role cache on the ctx to prevent duplicate +// db fetches. +func CustomRoleMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(CustomRoleCacheContext(r.Context())) + next.ServeHTTP(w, r) + }) +} + +// CustomRoleCacheContext prevents needing to lookup custom roles within the +// same request lifecycle. Optimizing this to span requests should be done +// in the future. +func CustomRoleCacheContext(ctx context.Context) context.Context { + return context.WithValue(ctx, customRoleCtxKey{}, syncmap.New[string, rbac.Role]()) +} + +func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] { + c, ok := ctx.Value(customRoleCtxKey{}).(*syncmap.Map[string, rbac.Role]) + if !ok { + return syncmap.New[string, rbac.Role]() + } + return c +} + +// Expand will expand built in roles, and fetch custom roles from the database. +func Expand(ctx context.Context, db database.Store, names []string) (rbac.Roles, error) { + if len(names) == 0 { + // That was easy + return []rbac.Role{}, nil + } + + cache := roleCache(ctx) + lookup := make([]string, 0) + roles := make([]rbac.Role, 0, len(names)) + + for _, name := range names { + // Remove any built in roles + expanded, err := rbac.RoleByName(name) + if err == nil { + roles = append(roles, expanded) + continue + } + + // Check custom role cache + customRole, ok := cache.Load(name) + if ok { + roles = append(roles, customRole) + continue + } + + // Defer custom role lookup + lookup = append(lookup, name) + } + + if len(lookup) > 0 { + // If some roles are missing from the database, they are omitted from + // the expansion. These roles are no-ops. Should we raise some kind of + // warning when this happens? + dbroles, err := db.CustomRolesByName(ctx, lookup) + if err != nil { + return nil, xerrors.Errorf("fetch custom roles: %w", err) + } + + // convert dbroles -> roles + for _, dbrole := range dbroles { + converted, err := ConvertDBRole(dbrole) + if err != nil { + return nil, xerrors.Errorf("convert db role %q: %w", dbrole, err) + } + roles = append(roles, converted) + cache.Store(dbrole.Name, converted) + } + } + + return roles, nil +} + +func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { + role := rbac.Role{ + Name: dbRole.Name, + DisplayName: dbRole.DisplayName, + Site: nil, + Org: nil, + User: nil, + } + + err := json.Unmarshal(dbRole.SitePermissions, &role.Site) + if err != nil { + return role, xerrors.Errorf("unmarshal site permissions: %w", err) + } + + err = json.Unmarshal(dbRole.OrgPermissions, &role.Org) + if err != nil { + return role, xerrors.Errorf("unmarshal org permissions: %w", err) + } + + err = json.Unmarshal(dbRole.UserPermissions, &role.User) + if err != nil { + return role, xerrors.Errorf("unmarshal user permissions: %w", err) + } + + return role, nil +} + +func ConvertRoleToDB(role rbac.Role) (database.CustomRole, error) { + dbRole := database.CustomRole{ + Name: role.Name, + DisplayName: role.DisplayName, + } + + siteData, err := json.Marshal(role.Site) + if err != nil { + return dbRole, xerrors.Errorf("marshal site permissions: %w", err) + } + dbRole.SitePermissions = siteData + + orgData, err := json.Marshal(role.Org) + if err != nil { + return dbRole, xerrors.Errorf("marshal org permissions: %w", err) + } + dbRole.OrgPermissions = orgData + + userData, err := json.Marshal(role.User) + if err != nil { + return dbRole, xerrors.Errorf("marshal user permissions: %w", err) + } + dbRole.UserPermissions = userData + + return dbRole, nil +} diff --git a/coderd/roles.go b/coderd/roles.go index 5665e298f0e5d..d18c8f0991611 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -66,7 +66,7 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role) []coder continue } assignable = append(assignable, codersdk.AssignableRoles{ - Role: codersdk.Role{ + SlimRole: codersdk.SlimRole{ Name: role.Name, DisplayName: role.DisplayName, }, diff --git a/coderd/roles_test.go b/coderd/roles_test.go index c50f24eb467a0..6754ddc17c9c2 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -143,9 +143,9 @@ func TestListRoles(t *testing.T) { } } -func convertRole(roleName string) codersdk.Role { +func convertRole(roleName string) codersdk.SlimRole { role, _ := rbac.RoleByName(roleName) - return codersdk.Role{ + return codersdk.SlimRole{ DisplayName: role.DisplayName, Name: role.Name, } @@ -156,7 +156,7 @@ func convertRoles(assignableRoles map[string]bool) []codersdk.AssignableRoles { for roleName, assignable := range assignableRoles { role := convertRole(roleName) converted = append(converted, codersdk.AssignableRoles{ - Role: role, + SlimRole: role, Assignable: assignable, }) } diff --git a/coderd/users.go b/coderd/users.go index c8ca04e390c7f..8db74cadadc9b 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1095,7 +1095,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { return } - updatedUser, err := UpdateSiteUserRoles(ctx, api.Database, database.UpdateUserRolesParams{ + updatedUser, err := api.Database.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ GrantedRoles: params.Roles, ID: user.ID, }) @@ -1123,27 +1123,6 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs)) } -// UpdateSiteUserRoles will ensure only site wide roles are passed in as arguments. -// If an organization role is included, an error is returned. -func UpdateSiteUserRoles(ctx context.Context, db database.Store, args database.UpdateUserRolesParams) (database.User, error) { - // Enforce only site wide roles. - for _, r := range args.GrantedRoles { - if _, ok := rbac.IsOrgRole(r); ok { - return database.User{}, xerrors.Errorf("Must only update site wide roles") - } - - if _, err := rbac.RoleByName(r); err != nil { - return database.User{}, xerrors.Errorf("%q is not a supported role", r) - } - } - - updatedUser, err := db.UpdateUserRoles(ctx, args) - if err != nil { - return database.User{}, xerrors.Errorf("update site roles: %w", err) - } - return updatedUser, nil -} - // Returns organizations the parameterized user has access to. // // @Summary Get organizations by user diff --git a/coderd/users_test.go b/coderd/users_test.go index 588a2e107566b..01cac4d1c8251 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1049,7 +1049,7 @@ func TestGrantSiteRoles(t *testing.T) { c.AssignToUser = newUser.ID.String() } - var newRoles []codersdk.Role + var newRoles []codersdk.SlimRole if c.OrgID != uuid.Nil { // Org assign var mem codersdk.OrganizationMember diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 8f8499e51f13b..dd52cae77d1b4 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -54,6 +54,7 @@ const ( FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions" FeatureAccessControl FeatureName = "access_control" FeatureControlSharedPorts FeatureName = "control_shared_ports" + FeatureCustomRoles FeatureName = "custom_roles" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -74,6 +75,7 @@ var FeatureNames = []FeatureName{ FeatureWorkspaceBatchActions, FeatureAccessControl, FeatureControlSharedPorts, + FeatureCustomRoles, } // Humanize returns the feature name in a human-readable format. @@ -98,6 +100,7 @@ func (n FeatureName) AlwaysEnable() bool { FeatureAppearance: true, FeatureWorkspaceBatchActions: true, FeatureHighAvailability: true, + FeatureCustomRoles: true, }[n] } @@ -2218,6 +2221,7 @@ const ( ExperimentExample Experiment = "example" // This isn't used for anything. ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentMultiOrganization Experiment = "multi-organization" // Requires organization context for interactions, default org is assumed. + ExperimentCustomRoles Experiment = "custom-roles" // Allows creating runtime custom roles ) // ExperimentsAll should include all experiments that are safe for diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 441f4774f2441..4c9cf81c497d3 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -48,11 +48,11 @@ type Organization struct { } type OrganizationMember struct { - UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"` - CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"` - Roles []Role `db:"roles" json:"roles"` + UserID uuid.UUID `db:"user_id" json:"user_id" format:"uuid"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"` + CreatedAt time.Time `db:"created_at" json:"created_at" format:"date-time"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at" format:"date-time"` + Roles []SlimRole `db:"roles" json:"roles"` } // CreateTemplateVersionRequest enables callers to create a new Template Version. diff --git a/codersdk/roles.go b/codersdk/roles.go index 5ed9a92539654..a16c7f587f0d1 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -9,16 +9,48 @@ import ( "github.com/google/uuid" ) -type Role struct { +type SlimRole struct { Name string `json:"name"` DisplayName string `json:"display_name"` } type AssignableRoles struct { - Role + SlimRole Assignable bool `json:"assignable"` } +// Permission is the format passed into the rego. +type Permission struct { + // Negate makes this a negative permission + Negate bool `json:"negate"` + ResourceType RBACResource `json:"resource_type"` + Action RBACAction `json:"action"` +} + +// Role is a longer form of SlimRole used to edit custom roles. +type Role struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + SitePermissions []Permission `json:"site_permissions"` + // map[] -> Permissions + OrganizationPermissions map[string][]Permission `json:"organization_permissions"` + UserPermissions []Permission `json:"user_permissions"` +} + +// UpsertCustomSiteRole will upsert a custom site wide role +func (c *Client) UpsertCustomSiteRole(ctx context.Context, req Role) (Role, error) { + res, err := c.Request(ctx, http.MethodPatch, "/api/v2/users/roles", req) + if err != nil { + return Role{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return Role{}, ReadBodyAsError(res) + } + var role Role + return role, json.NewDecoder(res.Body).Decode(&role) +} + // ListSiteRoles lists all assignable site wide roles. func (c *Client) ListSiteRoles(ctx context.Context) ([]AssignableRoles, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/roles", nil) @@ -46,3 +78,17 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]As var roles []AssignableRoles return roles, json.NewDecoder(res.Body).Decode(&roles) } + +// CreatePermissions is a helper function to quickly build permissions. +func CreatePermissions(mapping map[RBACResource][]RBACAction) []Permission { + perms := make([]Permission, 0) + for t, actions := range mapping { + for _, action := range actions { + perms = append(perms, Permission{ + ResourceType: t, + Action: action, + }) + } + } + return perms +} diff --git a/codersdk/users.go b/codersdk/users.go index 7eb7604fc57b7..80ca583141c9b 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -63,7 +63,7 @@ type User struct { ReducedUser `table:"r,recursive_inline"` OrganizationIDs []uuid.UUID `json:"organization_ids" format:"uuid"` - Roles []Role `json:"roles"` + Roles []SlimRole `json:"roles"` } type GetUsersResponse struct { diff --git a/docs/api/members.md b/docs/api/members.md index e44056664588a..43ae4e8f23da1 100644 --- a/docs/api/members.md +++ b/docs/api/members.md @@ -154,3 +154,127 @@ Status Code **200** | `» name` | string | false | | | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Upsert a custom site-wide role + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/users/roles \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /users/roles` + +### Example responses + +> 200 Response + +```json +[ + { + "display_name": "string", + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Role](schemas.md#codersdkrole) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | -------------------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `[array item]` | array | false | | | +| `» display_name` | string | false | | | +| `» name` | string | false | | | +| `» organization_permissions` | object | false | | map[] -> Permissions | +| `»» [any property]` | array | false | | | +| `»»» action` | [codersdk.RBACAction](schemas.md#codersdkrbacaction) | false | | | +| `»»» negate` | boolean | false | | Negate makes this a negative permission | +| `»»» resource_type` | [codersdk.RBACResource](schemas.md#codersdkrbacresource) | false | | | +| `» site_permissions` | array | false | | | +| `» user_permissions` | array | false | | | + +#### Enumerated Values + +| Property | Value | +| --------------- | ----------------------- | +| `action` | `application_connect` | +| `action` | `assign` | +| `action` | `create` | +| `action` | `delete` | +| `action` | `read` | +| `action` | `read_personal` | +| `action` | `ssh` | +| `action` | `update` | +| `action` | `update_personal` | +| `action` | `use` | +| `action` | `view_insights` | +| `action` | `start` | +| `action` | `stop` | +| `resource_type` | `*` | +| `resource_type` | `api_key` | +| `resource_type` | `assign_org_role` | +| `resource_type` | `assign_role` | +| `resource_type` | `audit_log` | +| `resource_type` | `debug_info` | +| `resource_type` | `deployment_config` | +| `resource_type` | `deployment_stats` | +| `resource_type` | `file` | +| `resource_type` | `group` | +| `resource_type` | `license` | +| `resource_type` | `oauth2_app` | +| `resource_type` | `oauth2_app_code_token` | +| `resource_type` | `oauth2_app_secret` | +| `resource_type` | `organization` | +| `resource_type` | `organization_member` | +| `resource_type` | `provisioner_daemon` | +| `resource_type` | `replicas` | +| `resource_type` | `system` | +| `resource_type` | `tailnet_coordinator` | +| `resource_type` | `template` | +| `resource_type` | `user` | +| `resource_type` | `workspace` | +| `resource_type` | `workspace_dormant` | +| `resource_type` | `workspace_proxy` | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 42f8f43517233..a50962cba09e7 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2694,6 +2694,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `example` | | `auto-fill-parameters` | | `multi-organization` | +| `custom-roles` | ## codersdk.ExternalAuth @@ -3579,13 +3580,13 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------- | --------------------------------------- | -------- | ------------ | ----------- | -| `created_at` | string | false | | | -| `organization_id` | string | false | | | -| `roles` | array of [codersdk.Role](#codersdkrole) | false | | | -| `updated_at` | string | false | | | -| `user_id` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ----------------- | ----------------------------------------------- | -------- | ------------ | ----------- | +| `created_at` | string | false | | | +| `organization_id` | string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `updated_at` | string | false | | | +| `user_id` | string | false | | | ## codersdk.PatchGroupRequest @@ -3649,6 +3650,24 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `name` | string | true | | | | `regenerate_token` | boolean | false | | | +## codersdk.Permission + +```json +{ + "action": "application_connect", + "negate": true, + "resource_type": "*" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------------- | ---------------------------------------------- | -------- | ------------ | --------------------------------------- | +| `action` | [codersdk.RBACAction](#codersdkrbacaction) | false | | | +| `negate` | boolean | false | | Negate makes this a negative permission | +| `resource_type` | [codersdk.RBACResource](#codersdkrbacresource) | false | | | + ## codersdk.PostOAuth2ProviderAppRequest ```json @@ -4271,16 +4290,50 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ```json { "display_name": "string", - "name": "string" + "name": "string", + "organization_permissions": { + "property1": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "property2": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] + }, + "site_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ], + "user_permissions": [ + { + "action": "application_connect", + "negate": true, + "resource_type": "*" + } + ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | ------ | -------- | ------------ | ----------- | -| `display_name` | string | false | | | -| `name` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------------------- | --------------------------------------------------- | -------- | ------------ | ---------------------------- | +| `display_name` | string | false | | | +| `name` | string | false | | | +| `organization_permissions` | object | false | | map[] -> Permissions | +| » `[any property]` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `site_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | +| `user_permissions` | array of [codersdk.Permission](#codersdkpermission) | false | | | ## codersdk.SSHConfig @@ -4356,6 +4409,22 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `disable_expiry_refresh` | boolean | false | | Disable expiry refresh will disable automatically refreshing api keys when they are used from the api. This means the api key lifetime at creation is the lifetime of the api key. | | `max_token_lifetime` | integer | false | | | +## codersdk.SlimRole + +```json +{ + "display_name": "string", + "name": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `display_name` | string | false | | | +| `name` | string | false | | | + ## codersdk.SupportConfig ```json @@ -4902,21 +4971,21 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ---------------------------------------------- | -------- | ------------ | ----------- | -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | -| `roles` | array of [codersdk.Role](#codersdkrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ----------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `role` | [codersdk.TemplateRole](#codersdktemplaterole) | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | | +| `username` | string | true | | | #### Enumerated Values @@ -5493,20 +5562,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ------------------------------------------ | -------- | ------------ | ----------- | -| `avatar_url` | string | false | | | -| `created_at` | string | true | | | -| `email` | string | true | | | -| `id` | string | true | | | -| `last_seen_at` | string | false | | | -| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | -| `name` | string | false | | | -| `organization_ids` | array of string | false | | | -| `roles` | array of [codersdk.Role](#codersdkrole) | false | | | -| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | -| `theme_preference` | string | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ----------------------------------------------- | -------- | ------------ | ----------- | +| `avatar_url` | string | false | | | +| `created_at` | string | true | | | +| `email` | string | true | | | +| `id` | string | true | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +| `name` | string | false | | | +| `organization_ids` | array of string | false | | | +| `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | +| `theme_preference` | string | false | | | +| `username` | string | true | | | #### Enumerated Values diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 56c774911018b..9b2ad0752c37b 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -326,6 +326,16 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Put("/", api.putAppearance) }) }) + + r.Route("/users/roles", func(r chi.Router) { + r.Use( + api.customRolesEnabledMW, + apiKeyMiddleware, + ) + + r.Patch("/", api.patchRole) + }) + r.Route("/users/{user}/quiet-hours", func(r chi.Router) { r.Use( api.autostopRequirementEnabledMW, diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go new file mode 100644 index 0000000000000..2224e7f25c0bf --- /dev/null +++ b/enterprise/coderd/roles.go @@ -0,0 +1,80 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac/rolestore" + "github.com/coder/coder/v2/codersdk" +) + +// patchRole will allow creating a custom role +// +// @Summary Upsert a custom site-wide role +// @ID upsert-a-custom-site-wide-role +// @Security CoderSessionToken +// @Produce json +// @Tags Members +// @Success 200 {array} codersdk.Role +// @Router /users/roles [patch] +func (api *API) patchRole(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req codersdk.Role + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if len(req.OrganizationPermissions) > 0 { + // Org perms should be assigned only in org specific roles. Otherwise, + // it gets complicated to keep track of who can do what. + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request, not allowed to assign organization permissions for a site wide role.", + Detail: "site wide roles may not contain organization specific permissions", + }) + return + } + + // Make sure all permissions inputted are valid according to our policy. + rbacRole := db2sdk.RoleToRBAC(req) + args, err := rolestore.ConvertRoleToDB(rbacRole) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Detail: err.Error(), + }) + return + } + + inserted, err := api.Database.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{ + Name: args.Name, + DisplayName: args.DisplayName, + SitePermissions: args.SitePermissions, + OrgPermissions: args.OrgPermissions, + UserPermissions: args.UserPermissions, + }) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to update role permissions", + Detail: err.Error(), + }) + return + } + + convertedInsert, err := rolestore.ConvertDBRole(inserted) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Permissions were updated, unable to read them back out of the database.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Role(convertedInsert)) +} diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go new file mode 100644 index 0000000000000..57bac91260012 --- /dev/null +++ b/enterprise/coderd/roles_test.go @@ -0,0 +1,63 @@ +package coderd_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestCustomRole(t *testing.T) { + t.Parallel() + + // Create, assign, and use a custom role + //nolint:gocritic + t.Run("Success", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentCustomRoles)} + owner, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureCustomRoles: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + + //nolint:gocritic // owner is required for this + role, err := owner.UpsertCustomSiteRole(ctx, codersdk.Role{ + Name: "test-role", + DisplayName: "Testing Purposes", + // Basically creating a template admin manually + SitePermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{ + codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionViewInsights}, + codersdk.ResourceFile: {codersdk.ActionCreate, codersdk.ActionRead}, + codersdk.ResourceWorkspace: {codersdk.ActionRead}, + }), + OrganizationPermissions: nil, + UserPermissions: nil, + }) + require.NoError(t, err, "upsert role") + + // Assign the custom template admin role + tmplAdmin, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, role.Name) + + // Assert the role exists + roleNamesF := func(role codersdk.SlimRole) string { return role.Name } + require.Contains(t, db2sdk.List(user.Roles, roleNamesF), role.Name) + + // Try to create a template version + coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil) + }) +} diff --git a/enterprise/coderd/userauth.go b/enterprise/coderd/userauth.go index f35d38ca448d9..a2dcac6085c2a 100644 --- a/enterprise/coderd/userauth.go +++ b/enterprise/coderd/userauth.go @@ -7,7 +7,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/codersdk" @@ -96,7 +95,7 @@ func (api *API) setUserSiteRoles(ctx context.Context, logger slog.Logger, db dat // Should this be feature protected? return db.InTx(func(tx database.Store) error { - _, err := coderd.UpdateSiteUserRoles(ctx, db, database.UpdateUserRolesParams{ + _, err := db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ GrantedRoles: roles, ID: userID, }) diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 935eeb8f6e689..a29aa1836557d 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -14,6 +14,31 @@ import ( "github.com/coder/coder/v2/codersdk" ) +func (api *API) customRolesEnabledMW(next http.Handler) http.Handler { + return httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentCustomRoles)( + http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // Entitlement must be enabled. + api.entitlementsMu.RLock() + entitled := api.entitlements.Features[codersdk.FeatureCustomRoles].Entitlement != codersdk.EntitlementNotEntitled + enabled := api.entitlements.Features[codersdk.FeatureCustomRoles].Enabled + api.entitlementsMu.RUnlock() + if !entitled { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "Custom roles is an Enterprise feature. Contact sales!", + }) + return + } + if !enabled { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "Custom roles is not enabled", + }) + return + } + + next.ServeHTTP(rw, r) + })) +} + func (api *API) autostopRequirementEnabledMW(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { // Entitlement must be enabled. diff --git a/scripts/rbacgen/main.go b/scripts/rbacgen/main.go index 38f13434c77e4..1eb186c1b5ce4 100644 --- a/scripts/rbacgen/main.go +++ b/scripts/rbacgen/main.go @@ -16,6 +16,8 @@ import ( "slices" "strings" + "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/rbac/policy" ) @@ -148,7 +150,7 @@ func generateRbacObjects(templateSource string) ([]byte, error) { // Parse the policy.go file for the action enums f, err := parser.ParseFile(token.NewFileSet(), "./coderd/rbac/policy/policy.go", nil, parser.ParseComments) if err != nil { - return nil, fmt.Errorf("parsing policy.go: %w", err) + return nil, xerrors.Errorf("parsing policy.go: %w", err) } actionMap := fileActions(f) actionList := make([]ActionDetails, 0) @@ -176,14 +178,14 @@ func generateRbacObjects(templateSource string) ([]byte, error) { x++ v, ok := actionMap[string(action)] if !ok { - errorList = append(errorList, fmt.Errorf("action value %q does not have a constant a matching enum constant", action)) + errorList = append(errorList, xerrors.Errorf("action value %q does not have a constant a matching enum constant", action)) } return v }, "concat": func(strs ...string) string { return strings.Join(strs, "") }, }).Parse(templateSource) if err != nil { - return nil, fmt.Errorf("parse template: %w", err) + return nil, xerrors.Errorf("parse template: %w", err) } // Convert to sorted list for autogen consistency. @@ -203,7 +205,7 @@ func generateRbacObjects(templateSource string) ([]byte, error) { err = tpl.Execute(&out, list) if err != nil { - return nil, fmt.Errorf("execute template: %w", err) + return nil, xerrors.Errorf("execute template: %w", err) } if len(errorList) > 0 { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ed7f18ef1472c..8baa6a5edfc1c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1102,7 +1102,7 @@ class ApiMethods { }; updateUserRoles = async ( - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], userId: TypesGen.User["id"], ): Promise => { const response = await this.axios.put( diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9331339ed1aa1..2bf254250b741 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -65,7 +65,7 @@ export interface ArchiveTemplateVersionsResponse { } // From codersdk/roles.go -export interface AssignableRoles extends Role { +export interface AssignableRoles extends SlimRole { readonly assignable: boolean; } @@ -786,7 +786,7 @@ export interface OrganizationMember { readonly organization_id: string; readonly created_at: string; readonly updated_at: string; - readonly roles: readonly Role[]; + readonly roles: readonly SlimRole[]; } // From codersdk/pagination.go @@ -821,6 +821,13 @@ export interface PatchWorkspaceProxy { readonly regenerate_token: boolean; } +// From codersdk/roles.go +export interface Permission { + readonly negate: boolean; + readonly resource_type: RBACResource; + readonly action: RBACAction; +} + // From codersdk/oauth2.go export interface PostOAuth2ProviderAppRequest { readonly name: string; @@ -970,6 +977,9 @@ export interface Response { export interface Role { readonly name: string; readonly display_name: string; + readonly site_permissions: readonly Permission[]; + readonly organization_permissions: Record; + readonly user_permissions: readonly Permission[]; } // From codersdk/deployment.go @@ -1014,6 +1024,12 @@ export interface SessionLifetime { readonly max_token_lifetime?: number; } +// From codersdk/roles.go +export interface SlimRole { + readonly name: string; + readonly display_name: string; +} + // From codersdk/deployment.go export interface SupportConfig { readonly links: readonly LinkConfig[]; @@ -1404,7 +1420,7 @@ export interface UpsertWorkspaceAgentPortShareRequest { // From codersdk/users.go export interface User extends ReducedUser { readonly organization_ids: readonly string[]; - readonly roles: readonly Role[]; + readonly roles: readonly SlimRole[]; } // From codersdk/insights.go @@ -1909,10 +1925,12 @@ export const Entitlements: Entitlement[] = [ // From codersdk/deployment.go export type Experiment = | "auto-fill-parameters" + | "custom-roles" | "example" | "multi-organization"; export const Experiments: Experiment[] = [ "auto-fill-parameters", + "custom-roles", "example", "multi-organization", ]; @@ -1925,6 +1943,7 @@ export type FeatureName = | "audit_log" | "browser_only" | "control_shared_ports" + | "custom_roles" | "external_provisioner_daemons" | "external_token_encryption" | "high_availability" @@ -1942,6 +1961,7 @@ export const FeatureNames: FeatureName[] = [ "audit_log", "browser_only", "control_shared_ports", + "custom_roles", "external_provisioner_daemons", "external_token_encryption", "high_availability", diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index ebc5e24a5e6b6..edbc0118b09f2 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { API } from "api/api"; -import type { Role } from "api/typesGenerated"; +import type { SlimRole } from "api/typesGenerated"; import { MockUser, MockUser2, @@ -102,7 +102,7 @@ const resetUserPassword = async (setupActionSpies: () => void) => { fireEvent.click(confirmButton); }; -const updateUserRole = async (role: Role) => { +const updateUserRole = async (role: SlimRole) => { // Get the first user in the table const users = await screen.findAllByText(/.*@coder.com/); const userRow = users[0].closest("tr"); diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 9349311392146..be5f50b6ff9b8 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -25,7 +25,7 @@ export interface UsersPageViewProps { onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( user: TypesGen.User, - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], ) => void; filterProps: ComponentProps; isNonInitialPage: boolean; diff --git a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx index 76099c9796205..b304bbed01f89 100644 --- a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx +++ b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx @@ -3,7 +3,7 @@ import UserIcon from "@mui/icons-material/PersonOutline"; import Checkbox from "@mui/material/Checkbox"; import IconButton from "@mui/material/IconButton"; import type { FC } from "react"; -import type { Role } from "api/typesGenerated"; +import type { SlimRole } from "api/typesGenerated"; import { HelpTooltip, HelpTooltipContent, @@ -69,9 +69,9 @@ const Option: FC = ({ export interface EditRolesButtonProps { isLoading: boolean; - roles: readonly Role[]; + roles: readonly SlimRole[]; selectedRoleNames: Set; - onChange: (roles: Role["name"][]) => void; + onChange: (roles: SlimRole["name"][]) => void; isDefaultOpen?: boolean; oidcRoleSync: boolean; userLoginType: string; diff --git a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx b/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx index 36090dbfdeff8..398354f94ee69 100644 --- a/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx +++ b/site/src/pages/UsersPage/UsersTable/UserRoleCell.tsx @@ -17,7 +17,7 @@ import { useTheme } from "@emotion/react"; import Stack from "@mui/material/Stack"; import TableCell from "@mui/material/TableCell"; import type { FC } from "react"; -import type { Role, User } from "api/typesGenerated"; +import type { SlimRole, User } from "api/typesGenerated"; import { Pill } from "components/Pill/Pill"; import { Popover, @@ -28,7 +28,7 @@ import { EditRolesButton } from "./EditRolesButton"; type UserRoleCellProps = { canEditUsers: boolean; - allAvailableRoles: Role[] | undefined; + allAvailableRoles: SlimRole[] | undefined; user: User; isLoading: boolean; oidcRoleSyncEnabled: boolean; @@ -90,7 +90,7 @@ export const UserRoleCell: FC = ({ }; type OverflowRolePillProps = { - roles: readonly Role[]; + roles: readonly SlimRole[]; }; const OverflowRolePill: FC = ({ roles }) => { @@ -148,7 +148,7 @@ const OverflowRolePill: FC = ({ roles }) => { ); }; -const fallbackRole: Role = { +const fallbackRole: SlimRole = { name: "member", display_name: "Member", } as const; @@ -160,7 +160,9 @@ const roleNamesByAccessLevel: readonly string[] = [ "auditor", ]; -function sortRolesByAccessLevel(roles: readonly Role[]): readonly Role[] { +function sortRolesByAccessLevel( + roles: readonly SlimRole[], +): readonly SlimRole[] { if (roles.length === 0) { return roles; } @@ -172,7 +174,7 @@ function sortRolesByAccessLevel(roles: readonly Role[]): readonly Role[] { ); } -function getSelectedRoleNames(roles: readonly Role[]) { +function getSelectedRoleNames(roles: readonly SlimRole[]) { const roleNameSet = new Set(roles.map((role) => role.name)); if (roleNameSet.size === 0) { roleNameSet.add(fallbackRole.name); diff --git a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx index 119b00a851146..d3748f2d8ea95 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTable.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTable.tsx @@ -36,7 +36,7 @@ export interface UsersTableProps { onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( user: TypesGen.User, - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], ) => void; isNonInitialPage: boolean; actorID: string; diff --git a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx index 03222edbedd6d..03a99bd423bf9 100644 --- a/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx +++ b/site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx @@ -52,7 +52,7 @@ interface UsersTableBodyProps { onResetUserPassword: (user: TypesGen.User) => void; onUpdateUserRoles: ( user: TypesGen.User, - roles: TypesGen.Role["name"][], + roles: TypesGen.SlimRole["name"][], ) => void; isNonInitialPage: boolean; actorID: string; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6cf97131aba67..5fe1e9cc7b0ff 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -229,27 +229,27 @@ export const MockUpdateCheck: TypesGen.UpdateCheckResponse = { version: "v99.999.9999+c9cdf14", }; -export const MockOwnerRole: TypesGen.Role = { +export const MockOwnerRole: TypesGen.SlimRole = { name: "owner", display_name: "Owner", }; -export const MockUserAdminRole: TypesGen.Role = { +export const MockUserAdminRole: TypesGen.SlimRole = { name: "user_admin", display_name: "User Admin", }; -export const MockTemplateAdminRole: TypesGen.Role = { +export const MockTemplateAdminRole: TypesGen.SlimRole = { name: "template_admin", display_name: "Template Admin", }; -export const MockMemberRole: TypesGen.Role = { +export const MockMemberRole: TypesGen.SlimRole = { name: "member", display_name: "Member", }; -export const MockAuditorRole: TypesGen.Role = { +export const MockAuditorRole: TypesGen.SlimRole = { name: "auditor", display_name: "Auditor", }; @@ -257,7 +257,7 @@ export const MockAuditorRole: TypesGen.Role = { // assignableRole takes a role and a boolean. The boolean implies if the // actor can assign (add/remove) the role from other users. export function assignableRole( - role: TypesGen.Role, + role: TypesGen.SlimRole, assignable: boolean, ): TypesGen.AssignableRoles { return {