mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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.
This commit is contained in:
+127
-133
@@ -3,6 +3,7 @@ package rbac
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -35,6 +36,7 @@ const (
|
||||
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"
|
||||
)
|
||||
@@ -173,6 +175,10 @@ 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}
|
||||
@@ -203,6 +209,74 @@ 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
|
||||
@@ -609,6 +683,23 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
||||
},
|
||||
}
|
||||
},
|
||||
// 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 {
|
||||
@@ -651,6 +742,7 @@ var assignRoles = map[string]map[string]bool{
|
||||
orgUserAdmin: true,
|
||||
orgTemplateAdmin: true,
|
||||
orgWorkspaceCreationBan: true,
|
||||
orgWorkspaceAccess: true,
|
||||
templateAdmin: true,
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
@@ -667,6 +759,7 @@ var assignRoles = map[string]map[string]bool{
|
||||
orgUserAdmin: true,
|
||||
orgTemplateAdmin: true,
|
||||
orgWorkspaceCreationBan: true,
|
||||
orgWorkspaceAccess: true,
|
||||
templateAdmin: true,
|
||||
userAdmin: true,
|
||||
customSiteRole: true,
|
||||
@@ -674,9 +767,10 @@ var assignRoles = map[string]map[string]bool{
|
||||
agentsAccess: true,
|
||||
},
|
||||
userAdmin: {
|
||||
member: true,
|
||||
orgMember: true,
|
||||
agentsAccess: true,
|
||||
member: true,
|
||||
orgMember: true,
|
||||
orgWorkspaceAccess: true,
|
||||
agentsAccess: true,
|
||||
},
|
||||
orgAdmin: {
|
||||
orgAdmin: true,
|
||||
@@ -685,12 +779,14 @@ var assignRoles = map[string]map[string]bool{
|
||||
orgUserAdmin: true,
|
||||
orgTemplateAdmin: true,
|
||||
orgWorkspaceCreationBan: true,
|
||||
orgWorkspaceAccess: true,
|
||||
customOrganizationRole: true,
|
||||
agentsAccess: true,
|
||||
},
|
||||
orgUserAdmin: {
|
||||
orgMember: true,
|
||||
agentsAccess: true,
|
||||
orgMember: true,
|
||||
orgWorkspaceAccess: true,
|
||||
agentsAccess: true,
|
||||
},
|
||||
|
||||
prebuildsOrchestrator: {
|
||||
@@ -1055,59 +1151,16 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
|
||||
})
|
||||
}
|
||||
|
||||
// Enumerate the per-member resources explicitly so new resources do
|
||||
// not auto-grant to org members. Adding a resource to the codebase
|
||||
// requires an explicit decision to expose it here.
|
||||
//
|
||||
// Member-level grants only fire when input.object.owner ==
|
||||
// input.subject.id (see the org_member rule in
|
||||
// coderd/rbac/policy.rego). Only resources whose RBACObject() calls
|
||||
// WithOwner(...) at production call sites belong here; see the
|
||||
// "Intentionally omitted" block at the bottom.
|
||||
memberPerms := Permissions(map[string][]policy.Action{
|
||||
// Workspace lifecycle on resources owned by this member.
|
||||
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 the member created 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: members can inspect
|
||||
// daemons they spawned even though no production call site
|
||||
// currently uses the member-scope read path (read on the bare
|
||||
// InOrg object continues to require Org-level perms).
|
||||
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},
|
||||
|
||||
// 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. Chat access requires the
|
||||
// agents-access role and is intentionally not granted here.
|
||||
// cannot read them back.
|
||||
ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate},
|
||||
|
||||
// Own session tokens and workspace agent auth keys.
|
||||
@@ -1118,32 +1171,17 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
|
||||
ResourceNotificationMessage.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
ResourceNotificationPreference.Type: ResourceNotificationPreference.AvailableActions(),
|
||||
ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(),
|
||||
|
||||
// Intentionally omitted at Member scope (resources without an
|
||||
// Owner field on their RBACObject; Member-level grants never
|
||||
// fire for them). Listed here so a future maintainer who sees
|
||||
// these dropped relative to the legacy allPermsExcept(...)
|
||||
// wildcard does not "restore" them:
|
||||
//
|
||||
// - ResourceTemplate: templates have no owner. Org-member
|
||||
// template.use is authorized via the ACL path
|
||||
// (acl_group_list[org_owner] "Everyone" group, populated
|
||||
// on each template's GroupACL).
|
||||
// - ResourceGroup: groups have no owner. "Groups I'm a
|
||||
// member of can read themselves" is granted via the
|
||||
// per-group GroupACL.
|
||||
// - 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 (e.g. ProvisionerDaemon
|
||||
// above), or through system / agent / template-admin
|
||||
// roles defined elsewhere.
|
||||
})
|
||||
|
||||
// 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,
|
||||
@@ -1189,61 +1227,17 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
|
||||
})
|
||||
}
|
||||
|
||||
// service account-scoped permissions (resources owned by the
|
||||
// service account). Enumerated explicitly so new resources do not
|
||||
// auto-grant to service accounts.
|
||||
//
|
||||
// Member-level grants only fire when input.object.owner ==
|
||||
// input.subject.id (see the org_member rule in
|
||||
// coderd/rbac/policy.rego). Only resources whose RBACObject() calls
|
||||
// WithOwner(...) at production call sites belong here; see the
|
||||
// "Intentionally omitted" block at the bottom.
|
||||
memberPerms := Permissions(map[string][]policy.Action{
|
||||
// Workspace lifecycle on resources owned by this service account.
|
||||
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 the service account created
|
||||
// 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
|
||||
// service accounts can run their own daemons. Read is granted
|
||||
// for symmetry with workspace ownership: service accounts can
|
||||
// inspect daemons they spawned even though no production call
|
||||
// site currently uses the member-scope read path (read on the
|
||||
// bare InOrg object continues to require Org-level perms).
|
||||
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},
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
@@ -1254,11 +1248,11 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
|
||||
ResourceNotificationMessage.Type: {policy.ActionRead, policy.ActionUpdate},
|
||||
ResourceNotificationPreference.Type: ResourceNotificationPreference.AvailableActions(),
|
||||
ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(),
|
||||
|
||||
// Intentionally omitted at Member scope. See
|
||||
// OrgMemberPermissions above for the rationale; the service
|
||||
// account role mirrors the same partition.
|
||||
})
|
||||
|
||||
elevation := OrgWorkspaceAccessMemberPerms()
|
||||
|
||||
memberPerms := slices.Concat(elevation, floor)
|
||||
|
||||
return OrgRolePermissions{Org: orgPerms, Member: memberPerms}
|
||||
}
|
||||
|
||||
@@ -1497,6 +1497,7 @@ func TestListRoles(t *testing.T) {
|
||||
fmt.Sprintf("organization-user-admin:%s", orgID.String()),
|
||||
fmt.Sprintf("organization-template-admin:%s", orgID.String()),
|
||||
fmt.Sprintf("organization-workspace-creation-ban:%s", orgID.String()),
|
||||
fmt.Sprintf("organization-workspace-access:%s", orgID.String()),
|
||||
fmt.Sprintf("agents-access:%s", orgID.String()),
|
||||
},
|
||||
orgRoleNames)
|
||||
|
||||
@@ -15,4 +15,5 @@ const (
|
||||
RoleOrganizationTemplateAdmin string = "organization-template-admin"
|
||||
RoleOrganizationUserAdmin string = "organization-user-admin"
|
||||
RoleOrganizationWorkspaceCreationBan string = "organization-workspace-creation-ban"
|
||||
RoleOrganizationWorkspaceAccess string = "organization-workspace-access"
|
||||
)
|
||||
|
||||
@@ -505,6 +505,7 @@ func TestListRoles(t *testing.T) {
|
||||
{Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleOrganizationWorkspaceAccess, OrganizationID: owner.OrganizationID}: false,
|
||||
{Name: codersdk.RoleAgentsAccess, OrganizationID: owner.OrganizationID}: false,
|
||||
}),
|
||||
},
|
||||
@@ -539,6 +540,7 @@ func TestListRoles(t *testing.T) {
|
||||
{Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationWorkspaceAccess, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleAgentsAccess, OrganizationID: owner.OrganizationID}: true,
|
||||
}),
|
||||
},
|
||||
@@ -573,6 +575,7 @@ func TestListRoles(t *testing.T) {
|
||||
{Name: codersdk.RoleOrganizationTemplateAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleOrganizationWorkspaceAccess, OrganizationID: owner.OrganizationID}: true,
|
||||
{Name: codersdk.RoleAgentsAccess, OrganizationID: owner.OrganizationID}: true,
|
||||
}),
|
||||
},
|
||||
|
||||
Generated
+6
@@ -7300,6 +7300,12 @@ export const RoleOrganizationTemplateAdmin = "organization-template-admin";
|
||||
*/
|
||||
export const RoleOrganizationUserAdmin = "organization-user-admin";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
*/
|
||||
export const RoleOrganizationWorkspaceAccess = "organization-workspace-access";
|
||||
|
||||
// From codersdk/rbacroles.go
|
||||
/**
|
||||
* Ideally these roles would be generated from the rbac/roles.go package.
|
||||
|
||||
Reference in New Issue
Block a user