Files
coder/coderd/rbac/roles.go
T
Steven Masley 6aef7c0858 refactor(coderd/rbac): extract organization-workspace-access role
Introduce the organization-workspace-access role and split the member
and service-account perms into a floor plus an elevation set. The
elevation lives in the new OrgWorkspaceAccessMemberPerms helper and is
mirrored onto the new role; both OrgMemberPermissions and
OrgServiceAccountPermissions compose floor + elevation today, so this
PR is behavior-preserving.

A future PR will gate the elevation on the minimum-implicit-member
experiment so a user without organization-workspace-access has only
the floor. Org admins, owners, user admins, and the system role can
assign the new role.

The helper carries the same "Intentionally omitted at Member scope"
rationale as the prior enumeration so that owner-less resources (e.g.
ResourceTemplate, ResourceWorkspaceProxy) are not re-added by mistake.
2026-06-02 14:44:38 +00:00

1259 lines
45 KiB
Go

package rbac
import (
"encoding/json"
"errors"
"slices"
"sort"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/open-policy-agent/opa/ast"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/slice"
)
const (
owner string = "owner"
member string = "member"
templateAdmin string = "template-admin"
userAdmin string = "user-admin"
auditor string = "auditor"
agentsAccess string = "agents-access"
// 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"
customOrganizationRole string = "custom-organization-role"
orgAdmin string = "organization-admin"
orgMember string = "organization-member"
orgServiceAccount string = "organization-service-account"
orgAuditor string = "organization-auditor"
orgUserAdmin string = "organization-user-admin"
orgTemplateAdmin string = "organization-template-admin"
orgWorkspaceCreationBan string = "organization-workspace-creation-ban"
orgWorkspaceAccess string = "organization-workspace-access"
prebuildsOrchestrator string = "prebuilds-orchestrator"
)
func init() {
// Always load defaults
ReloadBuiltinRoles(nil)
}
// RoleIdentifiers is a list of user assignable role names. The role names must be
// in the builtInRoles map. Any non-user assignable roles will generate an
// error on Expand.
type RoleIdentifiers []RoleIdentifier
func (names RoleIdentifiers) Expand() ([]Role, error) {
return rolesByNames(names)
}
func (names RoleIdentifiers) Names() []RoleIdentifier {
return names
}
// RoleIdentifier contains both the name of the role, and any organizational scope.
// Both fields are required to be globally unique and identifiable.
type RoleIdentifier struct {
Name string
// OrganizationID is uuid.Nil for unscoped roles (aka deployment wide)
OrganizationID uuid.UUID
}
func (r RoleIdentifier) IsOrgRole() bool {
return r.OrganizationID != uuid.Nil
}
// RoleNameFromString takes a formatted string '<role_name>[:org_id]'.
func RoleNameFromString(input string) (RoleIdentifier, error) {
var role RoleIdentifier
arr := strings.Split(input, ":")
if len(arr) > 2 {
return role, xerrors.Errorf("too many colons in role name")
}
if len(arr) == 0 {
return role, xerrors.Errorf("empty string not a valid role")
}
if arr[0] == "" {
return role, xerrors.Errorf("role cannot be the empty string")
}
role.Name = arr[0]
if len(arr) == 2 {
orgID, err := uuid.Parse(arr[1])
if err != nil {
return role, xerrors.Errorf("%q not a valid uuid: %w", arr[1], err)
}
role.OrganizationID = orgID
}
return role, nil
}
func (r RoleIdentifier) String() string {
if r.OrganizationID != uuid.Nil {
return r.Name + ":" + r.OrganizationID.String()
}
return r.Name
}
func (r RoleIdentifier) UniqueName() string {
return r.String()
}
func (r *RoleIdentifier) MarshalJSON() ([]byte, error) {
return json.Marshal(r.String())
}
func (r *RoleIdentifier) UnmarshalJSON(data []byte) error {
var str string
err := json.Unmarshal(data, &str)
if err != nil {
return err
}
v, err := RoleNameFromString(str)
if err != nil {
return err
}
*r = v
return nil
}
// The functions below ONLY need to exist for roles that are "defaulted" in some way.
// Any other roles (like auditor), can be listed and let the user select/assigned.
// Once we have a database implementation, the "default" roles can be defined on the
// site and orgs, and these functions can be removed.
func RoleOwner() RoleIdentifier { return RoleIdentifier{Name: owner} }
func CustomSiteRole() RoleIdentifier { return RoleIdentifier{Name: customSiteRole} }
func CustomOrganizationRole(orgID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: customOrganizationRole, OrganizationID: orgID}
}
func RoleTemplateAdmin() RoleIdentifier { return RoleIdentifier{Name: templateAdmin} }
func RoleUserAdmin() RoleIdentifier { return RoleIdentifier{Name: userAdmin} }
func RoleMember() RoleIdentifier { return RoleIdentifier{Name: member} }
func RoleAuditor() RoleIdentifier { return RoleIdentifier{Name: auditor} }
func RoleAgentsAccess() string { return agentsAccess }
func RoleOrgAdmin() string {
return orgAdmin
}
func RoleOrgMember() string {
return orgMember
}
func RoleOrgServiceAccount() string {
return orgServiceAccount
}
func RoleOrgAuditor() string {
return orgAuditor
}
func RoleOrgUserAdmin() string {
return orgUserAdmin
}
func RoleOrgTemplateAdmin() string {
return orgTemplateAdmin
}
func RoleOrgWorkspaceCreationBan() string {
return orgWorkspaceCreationBan
}
func RoleOrgWorkspaceAccess() string {
return orgWorkspaceAccess
}
// ScopedRoleOrgAdmin is the org role with the organization ID
func ScopedRoleOrgAdmin(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgAdmin(), OrganizationID: organizationID}
}
// ScopedRoleOrgMember is the org role with the organization ID
func ScopedRoleOrgMember(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgMember(), OrganizationID: organizationID}
}
func ScopedRoleOrgAuditor(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgAuditor(), OrganizationID: organizationID}
}
func ScopedRoleOrgUserAdmin(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgUserAdmin(), OrganizationID: organizationID}
}
func ScopedRoleOrgTemplateAdmin(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgTemplateAdmin(), OrganizationID: organizationID}
}
func ScopedRoleOrgWorkspaceCreationBan(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgWorkspaceCreationBan(), OrganizationID: organizationID}
}
func ScopedRoleAgentsAccess(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleAgentsAccess(), OrganizationID: organizationID}
}
func ScopedRoleOrgWorkspaceAccess(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgWorkspaceAccess(), OrganizationID: organizationID}
}
// OrgWorkspaceAccessMemberPerms returns the member-scoped permission set
// for the organization-workspace-access role.
func OrgWorkspaceAccessMemberPerms() []Permission {
return Permissions(map[string][]policy.Action{
// Members own their workspaces.
ResourceWorkspace.Type: ResourceWorkspace.AvailableActions(),
// Dormant workspaces share the workspace action set minus the
// build, ssh, and exec actions.
ResourceWorkspaceDormant.Type: {
policy.ActionRead,
policy.ActionDelete,
policy.ActionCreate,
policy.ActionUpdate,
policy.ActionWorkspaceStop,
policy.ActionCreateAgent,
policy.ActionDeleteAgent,
policy.ActionUpdateAgent,
},
// Upload and read template files used during workspace build
// (File.RBACObject sets WithOwner(CreatedBy)).
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
// Create and read user-scoped provisioner daemons. The Upsert
// path in dbauthz sets WithOwner(tag_owner) when scope=user, so
// members can run their own daemons. Read is granted for
// symmetry with workspace ownership; update and delete remain
// dead at Member scope.
ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead},
// Tasks ride along with workspaces and are owner-scoped.
ResourceTask.Type: ResourceTask.AvailableActions(),
// Read-self group-membership record. GroupMember.RBACObject
// sets WithOwner to the user's own ID.
ResourceGroupMember.Type: {policy.ActionRead},
// Intentionally omitted at Member scope (resources without an
// Owner field on their RBACObject; Member-level grants never
// fire for them). Listed here because these can be common
// misconceptions:
//
// - ResourceTemplate: templates are only owned by orgs, not
// users. Users granted access via ACL and (generally) the
// "Everyone" group.
// - ResourceGroup: groups have no owner. "Groups I'm a
// member of can read themselves" is handled by the ACL
// applied implicitly in RBACObject().
// - ResourceWorkspaceProxy, ResourceProvisionerJobs,
// ResourceWorkspaceAgentResourceMonitor,
// ResourceWorkspaceAgentDevcontainers,
// ResourceTailnetCoordinator, ResourceReplicas: these
// resources have no DB model that sets Owner; all
// production call sites use the bare resource or
// .InOrg(...) only. Access for these flows through Org
// perms on the appropriate role, or through system /
// agent / template-admin roles defined elsewhere.
// - ResourceProvisionerDaemon update/delete: only create and
// read fire at Member scope via the user-scoped Upsert
// path; other actions go through the bare InOrg path.
})
}
func allPermsExcept(excepts ...Objecter) []Permission {
resources := AllResources()
var perms []Permission
skip := make(map[string]bool)
for _, e := range excepts {
skip[e.RBACObject().Type] = true
}
for _, r := range resources {
// Exceptions
if skip[r.RBACObject().Type] {
continue
}
// This should always be skipped.
if r.RBACObject().Type == ResourceWildcard.Type {
continue
}
// Owners can do everything else
perms = append(perms, Permission{
Negate: false,
ResourceType: r.RBACObject().Type,
Action: policy.WildcardSymbol,
})
}
return perms
}
// builtInRoles are just a hard coded set for now. Ideally we store these in
// the database. Right now they are functions because the org id should scope
// certain roles. When we store them in the database, each organization should
// create the roles that are assignable in the org. This isn't a hard problem to solve,
// it's just easier as a function right now.
//
// This map will be replaced by database storage defined by this ticket.
// https://github.com/coder/coder/issues/1194
var builtInRoles map[string]func(orgID uuid.UUID) Role
type RoleOptions struct {
NoOwnerWorkspaceExec bool
NoWorkspaceSharing bool
NoChatSharing bool
}
// ReservedRoleName exists because the database should only allow unique role
// names, but some roles are built in. So these names are reserved
func ReservedRoleName(name string) bool {
_, ok := builtInRoles[name]
return ok
}
// ReloadBuiltinRoles loads the static roles into the builtInRoles map.
// This can be called again with a different config to change the behavior.
//
// TODO: @emyrk This would be great if it was instanced to a coderd rather
// than a global. But that is a much larger refactor right now.
// Essentially we did not foresee different deployments needing slightly
// different role permissions.
func ReloadBuiltinRoles(opts *RoleOptions) {
if opts == nil {
opts = &RoleOptions{}
}
denyPermissions := []Permission{}
if opts.NoWorkspaceSharing {
denyPermissions = append(denyPermissions, Permission{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionShare,
})
}
if opts.NoChatSharing {
denyPermissions = append(denyPermissions, Permission{
Negate: true,
ResourceType: ResourceChat.Type,
Action: policy.ActionShare,
})
}
ownerWorkspaceActions := ResourceWorkspace.AvailableActions()
if opts.NoOwnerWorkspaceExec {
// Remove ssh and application connect from the owner role. This
// prevents owners from have exec access to all workspaces.
ownerWorkspaceActions = slice.Omit(
ownerWorkspaceActions,
policy.ActionApplicationConnect, policy.ActionSSH,
)
}
// Static roles that never change should be allocated in a closure.
// This is to ensure these data structures are only allocated once and not
// on every authorize call. 'withCachedRegoValue' can be used as well to
// preallocate the rego value that is used by the rego eval engine.
ownerRole := Role{
Identifier: RoleOwner(),
DisplayName: "Owner",
Site: append(
// Workspace dormancy and workspace are omitted.
// Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec.
// Owners can inspect and delete personal skills for operability and
// abuse handling, but cannot create or edit user-authored instructions.
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat),
// This adds back in the Workspace permissions.
Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: ownerWorkspaceActions,
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
ResourceUserSkill.Type: {policy.ActionRead, policy.ActionDelete},
// PrebuiltWorkspaces are a subset of Workspaces.
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
// Owners can read all boundary logs. Delete is reserved for
// DBPurge only. Create is user-scoped (inherited from member).
ResourceBoundaryLog.Type: {policy.ActionRead},
})...,
),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
memberRole := Role{
Identifier: RoleMember(),
DisplayName: "Member",
Site: append(
Permissions(map[string][]policy.Action{
ResourceAssignRole.Type: {policy.ActionRead},
// All users can see OAuth2 provider applications.
ResourceOauth2App.Type: {policy.ActionRead},
ResourceWorkspaceProxy.Type: {policy.ActionRead},
}),
denyPermissions...,
),
User: append(
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat),
Permissions(map[string][]policy.Action{
// Users cannot do create/update/delete on themselves, but they
// can read their own details.
ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal},
// Users can create provisioner daemons scoped to themselves.
ResourceProvisionerDaemon.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
// Members can create and update AI Bridge interceptions but
// cannot read them back.
ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate},
// Workspace agents create boundary logs under their owner's
// identity. Create is user-scoped so agents can only write
// logs owned by their workspace owner.
// Read: owners and auditors. Delete: DBPurge only.
ResourceBoundaryLog.Type: {policy.ActionCreate},
})...,
),
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
auditorRole := Role{
Identifier: RoleAuditor(),
DisplayName: "Auditor",
Site: Permissions(map[string][]policy.Action{
ResourceAssignOrgRole.Type: {policy.ActionRead},
ResourceAuditLog.Type: {policy.ActionRead},
ResourceConnectionLog.Type: {policy.ActionRead},
// Allow auditors to see the resources that audit logs reflect.
ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights},
ResourceUser.Type: {policy.ActionRead},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
// Allow auditors to query deployment stats and insights.
ResourceDeploymentStats.Type: {policy.ActionRead},
ResourceDeploymentConfig.Type: {policy.ActionRead},
// Allow auditors to query AI Bridge interceptions.
ResourceAibridgeInterception.Type: {policy.ActionRead},
// Allow auditors to read boundary logs.
ResourceBoundaryLog.Type: {policy.ActionRead},
}),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
templateAdminRole := Role{
Identifier: RoleTemplateAdmin(),
DisplayName: "Template Admin",
Site: Permissions(map[string][]policy.Action{
ResourceAssignOrgRole.Type: {policy.ActionRead},
ResourceTemplate.Type: ResourceTemplate.AvailableActions(),
// CRUD all files, even those they did not upload.
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
ResourceWorkspace.Type: {policy.ActionRead},
ResourceWorkspaceDormant.Type: {policy.ActionRead},
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
// CRUD to provisioner daemons for now.
ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
// Needs to read all organizations since
ResourceUser.Type: {policy.ActionRead},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
}),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
userAdminRole := Role{
Identifier: RoleUserAdmin(),
DisplayName: "User Admin",
Site: Permissions(map[string][]policy.Action{
ResourceAssignRole.Type: {policy.ActionAssign, policy.ActionUnassign, policy.ActionRead},
// Need organization assign as well to create users. At present, creating a user
// will always assign them to some organization.
ResourceAssignOrgRole.Type: {policy.ActionAssign, policy.ActionUnassign, policy.ActionRead},
ResourceUser.Type: {
policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete,
policy.ActionUpdatePersonal, policy.ActionReadPersonal,
},
ResourceGroup.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
// Full perms to manage org members
ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
// Manage org membership based on OIDC claims
ResourceIdpsyncSettings.Type: {policy.ActionRead, policy.ActionUpdate},
}),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
builtInRoles = map[string]func(orgID uuid.UUID) Role{
// admin grants all actions to all resources.
owner: func(_ uuid.UUID) Role {
return ownerRole
},
// member grants all actions to all resources owned by the user
member: func(_ uuid.UUID) Role {
return memberRole
},
// auditor provides all permissions required to effectively read and understand
// audit log events.
// TODO: Finish the auditor as we add resources.
auditor: func(_ uuid.UUID) Role {
return auditorRole
},
// templateAdmin grants all actions on templates, files,
// provisioner daemons, and prebuilt workspaces.
templateAdmin: func(_ uuid.UUID) Role {
return templateAdminRole
},
// userAdmin grants all actions on users, groups, roles,
// and organization membership.
userAdmin: func(_ uuid.UUID) Role {
return userAdminRole
},
// orgAdmin returns a role with all actions allows in a given
// organization scope.
orgAdmin: func(organizationID uuid.UUID) Role {
return Role{
Identifier: RoleIdentifier{Name: orgAdmin, OrganizationID: organizationID},
DisplayName: "Organization Admin",
Site: Permissions(map[string][]policy.Action{
// To assign organization members, we need to be able to read
// users at the site wide to know they exist.
ResourceUser.Type: {policy.ActionRead},
}),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{
// Org admins should not have workspace exec perms.
organizationID.String(): {
Org: append(
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat),
Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
// PrebuiltWorkspaces are a subset of Workspaces.
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
})...,
),
Member: []Permission{},
},
},
}
},
orgAuditor: func(organizationID uuid.UUID) Role {
return Role{
Identifier: RoleIdentifier{Name: orgAuditor, OrganizationID: organizationID},
DisplayName: "Organization Auditor",
Site: []Permission{},
User: []Permission{},
ByOrgID: map[string]OrgPermissions{
organizationID.String(): {
Org: Permissions(map[string][]policy.Action{
ResourceAuditLog.Type: {policy.ActionRead},
ResourceConnectionLog.Type: {policy.ActionRead},
// Allow auditors to see the resources that audit logs reflect.
ResourceTemplate.Type: {policy.ActionRead, policy.ActionViewInsights},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
}),
Member: []Permission{},
},
},
}
},
orgUserAdmin: func(organizationID uuid.UUID) Role {
// Manages organization members and groups.
return Role{
Identifier: RoleIdentifier{Name: orgUserAdmin, OrganizationID: organizationID},
DisplayName: "Organization User Admin",
Site: Permissions(map[string][]policy.Action{
// To assign organization members, we need to be able to read
// users at the site wide to know they exist.
ResourceUser.Type: {policy.ActionRead},
}),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{
organizationID.String(): {
Org: Permissions(map[string][]policy.Action{
// Assign, remove, and read roles in the organization.
ResourceAssignOrgRole.Type: {policy.ActionAssign, policy.ActionUnassign, policy.ActionRead},
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
ResourceGroup.Type: ResourceGroup.AvailableActions(),
ResourceGroupMember.Type: ResourceGroupMember.AvailableActions(),
ResourceIdpsyncSettings.Type: {policy.ActionRead, policy.ActionUpdate},
}),
Member: []Permission{},
},
},
}
},
orgTemplateAdmin: func(organizationID uuid.UUID) Role {
// Manages organization members and groups.
return Role{
Identifier: RoleIdentifier{Name: orgTemplateAdmin, OrganizationID: organizationID},
DisplayName: "Organization Template Admin",
Site: []Permission{},
User: []Permission{},
ByOrgID: map[string]OrgPermissions{
organizationID.String(): {
Org: Permissions(map[string][]policy.Action{
ResourceTemplate.Type: ResourceTemplate.AvailableActions(),
ResourceFile.Type: {policy.ActionCreate, policy.ActionRead},
ResourceWorkspace.Type: {policy.ActionRead},
ResourceWorkspaceDormant.Type: {policy.ActionRead},
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
// Assigning template perms requires this permission.
ResourceOrganization.Type: {policy.ActionRead},
ResourceOrganizationMember.Type: {policy.ActionRead},
ResourceGroup.Type: {policy.ActionRead},
ResourceGroupMember.Type: {policy.ActionRead},
// Since templates have to correlate with provisioners,
// the ability to create templates and provisioners has
// a lot of overlap.
ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
ResourceProvisionerJobs.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionCreate},
}),
Member: []Permission{},
},
},
}
},
// orgWorkspaceCreationBan prevents creating & deleting workspaces. This
// overrides any permissions granted by the org or user level. It accomplishes
// this by using negative permissions.
orgWorkspaceCreationBan: func(organizationID uuid.UUID) Role {
return Role{
Identifier: RoleIdentifier{Name: orgWorkspaceCreationBan, OrganizationID: organizationID},
DisplayName: "Organization Workspace Creation Ban",
Site: []Permission{},
User: []Permission{},
ByOrgID: map[string]OrgPermissions{
organizationID.String(): {
Org: []Permission{
{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionCreate,
},
{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionDelete,
},
{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionCreateAgent,
},
{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionDeleteAgent,
},
},
Member: []Permission{},
},
},
}
},
// orgWorkspaceAccess grants the workspace-operations
// capabilities org members need to use their workspaces.
// See OrgWorkspaceAccessMemberPerms for the perm set.
orgWorkspaceAccess: func(organizationID uuid.UUID) Role {
return Role{
Identifier: RoleIdentifier{Name: orgWorkspaceAccess, OrganizationID: organizationID},
DisplayName: "Organization Workspace Access",
Site: []Permission{},
User: []Permission{},
ByOrgID: map[string]OrgPermissions{
organizationID.String(): {
Org: []Permission{},
Member: OrgWorkspaceAccessMemberPerms(),
},
},
}
},
// ActionDelete is intentionally excluded because hard-deletion goes through
// ResourceSystem in dbpurge.
agentsAccess: func(organizationID uuid.UUID) Role {
return Role{
Identifier: RoleIdentifier{Name: agentsAccess, OrganizationID: organizationID},
DisplayName: "Coder Agents User",
Site: []Permission{},
User: []Permission{},
ByOrgID: map[string]OrgPermissions{
organizationID.String(): {
Org: []Permission{},
Member: Permissions(map[string][]policy.Action{
ResourceChat.Type: {
policy.ActionCreate,
policy.ActionRead,
policy.ActionShare,
policy.ActionUpdate,
},
}),
},
},
}
},
}
}
// assignRoles is a map of roles that can be assigned if a user has a given
// role.
// The first key is the actor role, the second is the roles they can assign.
//
// map[actor_role][assign_role]<can_assign>
var assignRoles = map[string]map[string]bool{
"system": {
owner: true,
auditor: true,
member: true,
orgAdmin: true,
orgMember: true,
orgAuditor: true,
orgUserAdmin: true,
orgTemplateAdmin: true,
orgWorkspaceCreationBan: true,
orgWorkspaceAccess: true,
templateAdmin: true,
userAdmin: true,
customSiteRole: true,
customOrganizationRole: true,
agentsAccess: true,
},
owner: {
owner: true,
auditor: true,
member: true,
orgAdmin: true,
orgMember: true,
orgAuditor: true,
orgUserAdmin: true,
orgTemplateAdmin: true,
orgWorkspaceCreationBan: true,
orgWorkspaceAccess: true,
templateAdmin: true,
userAdmin: true,
customSiteRole: true,
customOrganizationRole: true,
agentsAccess: true,
},
userAdmin: {
member: true,
orgMember: true,
orgWorkspaceAccess: true,
agentsAccess: true,
},
orgAdmin: {
orgAdmin: true,
orgMember: true,
orgAuditor: true,
orgUserAdmin: true,
orgTemplateAdmin: true,
orgWorkspaceCreationBan: true,
orgWorkspaceAccess: true,
customOrganizationRole: true,
agentsAccess: true,
},
orgUserAdmin: {
orgMember: true,
orgWorkspaceAccess: true,
agentsAccess: true,
},
prebuildsOrchestrator: {
orgMember: true,
},
}
// ExpandableRoles is any type that can be expanded into a []Role. This is implemented
// as an interface so we can have RoleIdentifiers for user defined roles, and implement
// custom ExpandableRoles for system type users (eg autostart/autostop system role).
// We want a clear divide between the two types of roles so users have no codepath
// to interact or assign system roles.
//
// Note: We may also want to do the same thing with scopes to allow custom scope
// support unavailable to the user. Eg: Scope to a single resource.
type ExpandableRoles interface {
Expand() ([]Role, error)
// Names is for logging and tracing purposes, we want to know the human
// names of the expanded roles.
Names() []RoleIdentifier
}
// Permission is the format passed into the rego.
type Permission struct {
// Negate makes this a negative permission
Negate bool `json:"negate"`
ResourceType string `json:"resource_type"`
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)
}
// Wildcard action is always valid
if perm.Action == policy.WildcardSymbol {
return nil
}
_, 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 permissions apply EVERYWHERE
// - Org permissions apply to EVERYTHING in a given ORG
// - User permissions apply to all resources the user owns
// - OrgMember permissions apply to resources in the given org that the user owns
// This is the type passed into the rego as a json payload.
// Users of this package should instead **only** use the role names, and
// this package will expand the role names into their json payloads.
type Role struct {
Identifier RoleIdentifier `json:"name"`
// DisplayName is used for UI purposes. If the role has no display name,
// that means the UI should never display it.
DisplayName string `json:"display_name"`
Site []Permission `json:"site"`
User []Permission `json:"user"`
// ByOrgID is a map of organization IDs to permissions. Grouping by
// organization makes roles easy to combine.
ByOrgID map[string]OrgPermissions `json:"by_org_id"`
// cachedRegoValue can be used to cache the rego value for this role.
// This is helpful for static roles that never change.
cachedRegoValue ast.Value
}
type OrgPermissions struct {
Org []Permission `json:"org"`
Member []Permission `json:"member"`
}
// 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, orgPermissions := range role.ByOrgID {
for _, perm := range orgPermissions.Org {
if err := perm.Valid(); err != nil {
errs = append(errs, xerrors.Errorf("org=%q: org %w", orgID, err))
}
}
for _, perm := range orgPermissions.Member {
if err := perm.Valid(); err != nil {
errs = append(errs, xerrors.Errorf("org=%q: member: %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) {
return roles, nil
}
func (roles Roles) Names() []RoleIdentifier {
names := make([]RoleIdentifier, 0, len(roles))
for _, r := range roles {
names = append(names, r.Identifier)
}
return names
}
// CanAssignRole is a helper function that returns true if the user can assign
// the specified role. This also can be used for removing a role.
// This is a simple implementation for now.
func CanAssignRole(subjectHasRoles ExpandableRoles, assignedRole RoleIdentifier) bool {
// For CanAssignRole, we only care about the names of the roles.
roles := subjectHasRoles.Names()
for _, myRole := range roles {
if myRole.OrganizationID != uuid.Nil && myRole.OrganizationID != assignedRole.OrganizationID {
// Org roles only apply to the org they are assigned to.
continue
}
allowedAssignList, ok := assignRoles[myRole.Name]
if !ok {
continue
}
if allowedAssignList[assignedRole.Name] {
return true
}
}
return false
}
// RoleByName returns the permissions associated with a given role name.
// This allows just the role names to be stored and expanded when required.
//
// This function is exported so that the Display name can be returned to the
// api. We should maybe make an exported function that returns just the
// human-readable content of the Role struct (name + display name).
func RoleByName(name RoleIdentifier) (Role, error) {
roleFunc, ok := builtInRoles[name.Name]
if !ok {
// No role found
return Role{}, xerrors.Errorf("role %q not found", name.String())
}
// Ensure all org roles are properly scoped a non-empty organization id.
// This is just some defensive programming.
role := roleFunc(name.OrganizationID)
if len(role.ByOrgID) > 0 && name.OrganizationID == uuid.Nil {
return Role{}, xerrors.Errorf("expect a org id for role %q", name.String())
}
// This can happen if a custom role shares the same name as a built-in role.
// You could make an org role called "owner", and we should not return the
// owner role itself.
if name.OrganizationID != role.Identifier.OrganizationID {
return Role{}, xerrors.Errorf("role %q not found", name.String())
}
return role, nil
}
func rolesByNames(roleNames []RoleIdentifier) ([]Role, error) {
roles := make([]Role, 0, len(roleNames))
for _, n := range roleNames {
r, err := RoleByName(n)
if err != nil {
return nil, xerrors.Errorf("get role permissions: %w", err)
}
roles = append(roles, r)
}
return roles, nil
}
// OrganizationRoles lists all roles that can be applied to an organization user
// in the given organization. This is the list of available roles,
// and specific to an organization.
//
// This should be a list in a database, but until then we build
// the list from the builtins.
func OrganizationRoles(organizationID uuid.UUID) []Role {
var roles []Role
for _, roleF := range builtInRoles {
role := roleF(organizationID)
if role.Identifier.OrganizationID == organizationID {
roles = append(roles, role)
}
}
return roles
}
// SiteBuiltInRoles lists all roles that can be applied to a user.
// This is the list of available roles, and not specific to a user
//
// This should be a list in a database, but until then we build
// the list from the builtins.
func SiteBuiltInRoles() []Role {
var roles []Role
for _, roleF := range builtInRoles {
// Must provide some non-nil uuid to filter out org roles.
role := roleF(uuid.New())
if !role.Identifier.IsOrgRole() {
roles = append(roles, role)
}
}
return roles
}
// ChangeRoleSet is a helper function that finds the difference of 2 sets of
// roles. When setting a user's new roles, it is equivalent to adding and
// removing roles. This set determines the changes, so that the appropriate
// RBAC checks can be applied using "ActionCreate" and "ActionDelete" for
// "added" and "removed" roles respectively.
func ChangeRoleSet(from []RoleIdentifier, to []RoleIdentifier) (added []RoleIdentifier, removed []RoleIdentifier) {
return slice.SymmetricDifferenceFunc(from, to, func(a, b RoleIdentifier) bool {
return a.Name == b.Name && a.OrganizationID == b.OrganizationID
})
}
// Permissions is just a helper function to make building roles that list out resources
// and actions a bit easier.
func Permissions(perms map[string][]policy.Action) []Permission {
list := make([]Permission, 0, len(perms))
for k, actions := range perms {
for _, act := range actions {
list = append(list, Permission{
Negate: false,
ResourceType: k,
Action: act,
})
}
}
// Deterministic ordering of permissions
sort.Slice(list, func(i, j int) bool {
return list[i].ResourceType < list[j].ResourceType
})
return list
}
// DeduplicatePermissions removes duplicate Permission entries while preserving
// the original order of the first occurrence for deterministic evaluation.
func DeduplicatePermissions(perms []Permission) []Permission {
if len(perms) == 0 {
return perms
}
seen := make(map[string]struct{}, len(perms))
deduped := make([]Permission, 0, len(perms))
for _, perm := range perms {
key := perm.ResourceType + "\x00" + string(perm.Action) + "\x00" + strconv.FormatBool(perm.Negate)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
deduped = append(deduped, perm)
}
return deduped
}
// PermissionsEqual compares two permission slices as sets. Order and
// duplicate entries do not matter; it only checks that both slices
// contain the same unique permissions.
func PermissionsEqual(a, b []Permission) bool {
setA := make(map[Permission]struct{}, len(a))
for _, p := range a {
setA[p] = struct{}{}
}
setB := make(map[Permission]struct{}, len(b))
for _, p := range b {
if _, ok := setA[p]; !ok {
return false
}
setB[p] = struct{}{}
}
return len(setA) == len(setB)
}
// OrgSettings carries organization-level settings that affect system
// role permissions. It lives in the rbac package to avoid a cyclic
// dependency with the database package. Callers in rolestore map
// database.Organization fields onto this struct.
type OrgSettings struct {
ShareableWorkspaceOwners ShareableWorkspaceOwners
}
type ShareableWorkspaceOwners string
const (
ShareableWorkspaceOwnersNone ShareableWorkspaceOwners = "none"
ShareableWorkspaceOwnersEveryone ShareableWorkspaceOwners = "everyone"
ShareableWorkspaceOwnersServiceAccounts ShareableWorkspaceOwners = "service_accounts"
)
// OrgRolePermissions holds the two permission sets that make up a
// system role: org-wide permissions and member-scoped permissions.
type OrgRolePermissions struct {
Org []Permission
Member []Permission
}
// OrgMemberPermissions returns the permissions for the organization-member
// system role, which can vary based on the organization's workspace sharing
// settings.
func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
// Organization-level permissions that all org members get.
orgPermMap := map[string][]policy.Action{
// All users can see provisioner daemons for workspace creation.
ResourceProvisionerDaemon.Type: {policy.ActionRead},
// All org members can read the organization.
ResourceOrganization.Type: {policy.ActionRead},
// Can read available roles.
ResourceAssignOrgRole.Type: {policy.ActionRead},
}
// In all modes of workspace sharing but `none`, members need to
// see other org members (including service accounts) to either
// share with them or get access to their shared workspaces,
// resolved through GET /users/{user}/workspace/{workspace}
if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersNone {
orgPermMap[ResourceOrganizationMember.Type] = []policy.Action{policy.ActionRead}
}
// When workspace sharing is open to members, they also need to
// see org groups to share with them.
if org.ShareableWorkspaceOwners == ShareableWorkspaceOwnersEveryone {
orgPermMap[ResourceGroup.Type] = []policy.Action{policy.ActionRead}
}
orgPerms := Permissions(orgPermMap)
if org.ShareableWorkspaceOwners == ShareableWorkspaceOwnersNone {
// Org-level negation blocks sharing on ANY workspace in the
// org. This overrides any positive permission from other
// roles, including org-admin.
orgPerms = append(orgPerms, Permission{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionShare,
})
}
// Floor: perms every org member always has, regardless of whether
// organization-workspace-access is attached. Chat access requires
// the agents-access role and is intentionally not granted here.
floor := Permissions(map[string][]policy.Action{
// Read-self org-member record.
ResourceOrganizationMember.Type: {policy.ActionRead},
// Members can create and update AI Bridge interceptions they
// initiate (dbauthz layer sets WithOwner(InitiatorID)) but
// cannot read them back.
ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate},
// Own session tokens and workspace agent auth keys.
ResourceApiKey.Type: ResourceApiKey.AvailableActions(),
// User-scoped notification surfaces. All three resources are
// addressed by WithOwner(user_id) at the call sites.
ResourceNotificationMessage.Type: {policy.ActionRead, policy.ActionUpdate},
ResourceNotificationPreference.Type: ResourceNotificationPreference.AvailableActions(),
ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(),
})
// Workspace-ops elevation. Today bundled into organization-member;
// the minimum-implicit-member experiment will move the binding
// exclusively onto organization-workspace-access so a user without
// that role has only the floor. See OrgWorkspaceAccessMemberPerms
// for the perm set and the "Intentionally omitted" rationale.
elevation := OrgWorkspaceAccessMemberPerms()
memberPerms := slices.Concat(elevation, floor)
if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersEveryone {
memberPerms = append(memberPerms, Permission{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionShare,
})
}
return OrgRolePermissions{Org: orgPerms, Member: memberPerms}
}
// OrgServiceAccountPermissions returns the permissions for the
// organization-service-account system role, which can vary based on
// the organization's workspace sharing settings.
func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
// Organization-level permissions that all org service accounts get.
orgPermMap := map[string][]policy.Action{
// All users can see provisioner daemons for workspace creation.
ResourceProvisionerDaemon.Type: {policy.ActionRead},
// All org members can read the organization.
ResourceOrganization.Type: {policy.ActionRead},
// Can read available roles.
ResourceAssignOrgRole.Type: {policy.ActionRead},
}
// When workspace sharing is enabled, service accounts need to see
// other org members and groups to share workspaces with them.
if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersNone {
orgPermMap[ResourceOrganizationMember.Type] = []policy.Action{policy.ActionRead}
orgPermMap[ResourceGroup.Type] = []policy.Action{policy.ActionRead}
}
orgPerms := Permissions(orgPermMap)
if org.ShareableWorkspaceOwners == ShareableWorkspaceOwnersNone {
// Org-level negation blocks sharing on ANY workspace in the
// org. If a service account has any other roles assigned,
// this negation will override any positive perms in them, too.
orgPerms = append(orgPerms, Permission{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionShare,
})
}
// service account-scoped permissions. Composed from a floor plus the
// same workspace-ops elevation as OrgMemberPermissions; the service
// account role mirrors the org-member partition.
floor := Permissions(map[string][]policy.Action{
// Read-self org-member record.
ResourceOrganizationMember.Type: {policy.ActionRead},
// Service accounts can create and update AI Bridge interceptions
// they initiate (dbauthz layer sets WithOwner(InitiatorID)) but
// cannot read them back. Chat access requires the agents-access
// role and is intentionally not granted here.
ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate},
// Own session tokens and workspace agent auth keys.
ResourceApiKey.Type: ResourceApiKey.AvailableActions(),
// User-scoped notification surfaces. All three resources are
// addressed by WithOwner(user_id) at the call sites.
ResourceNotificationMessage.Type: {policy.ActionRead, policy.ActionUpdate},
ResourceNotificationPreference.Type: ResourceNotificationPreference.AvailableActions(),
ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(),
})
elevation := OrgWorkspaceAccessMemberPerms()
memberPerms := slices.Concat(elevation, floor)
return OrgRolePermissions{Org: orgPerms, Member: memberPerms}
}