From 855be76f9590059324e781b82e77eea5ee09c3d2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 1 Jun 2026 16:18:23 +0000 Subject: [PATCH 1/3] refactor(coderd/rbac): enumerate org-member and org-service-account perms Replace allPermsExcept in OrgMemberPermissions and OrgServiceAccountPermissions with explicit per-resource enumerations. allPermsExcept granted wildcard actions on every resource not in its exclusion list, which auto-granted any new resource added to the codebase and made the actual perm surface hard to audit. The enumeration grants only the resources actually relevant to member/service-account operations: workspace lifecycle and runtime support, template apply, file upload/read for builds, provisioner jobs, tasks, group reads for ACL eval, org-member read-self, AI Bridge interception writes, own API keys, user-scoped notification surfaces, and replica metadata. Behavior-preserving: all rbac, dbauthz, coderd workspace/template/ user/org/notification/key/provisioner/audit/proxy/task tests, and enterprise/coderd tests pass. --- coderd/rbac/roles.go | 197 +++++++++++++++++++++++++++---------------- 1 file changed, 122 insertions(+), 75 deletions(-) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 1b19947ea65..07003df50a9 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -1055,44 +1055,69 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { }) } - // Uses allPermsExcept to automatically include permissions for new resources. - memberPerms := append( - allPermsExcept( - ResourceWorkspaceDormant, - ResourcePrebuiltWorkspace, - ResourceUser, - ResourceOrganizationMember, - ResourceBoundaryLog, - ResourceAibridgeInterception, - // Chat access requires the agents-access role. - ResourceChat, - ), + // 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. + 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, + }, - Permissions(map[string][]policy.Action{ - // Reduced permission set on dormant workspaces. No build, - // ssh, or exec. - ResourceWorkspaceDormant.Type: { - policy.ActionRead, - policy.ActionDelete, - policy.ActionCreate, - policy.ActionUpdate, - policy.ActionWorkspaceStop, - policy.ActionCreateAgent, - policy.ActionDeleteAgent, - policy.ActionUpdateAgent, - }, - // Can read their own organization member record. - ResourceOrganizationMember.Type: { - policy.ActionRead, - }, - // Members can create and update AI Bridge interceptions but - // cannot read them back. - ResourceAibridgeInterception.Type: { - policy.ActionCreate, - policy.ActionUpdate, - }, - })..., - ) + // Workspace runtime support: proxies, agent monitors, + // devcontainer setup, and tailnet coordination. + ResourceWorkspaceProxy.Type: {policy.ActionRead}, + ResourceWorkspaceAgentResourceMonitor.Type: ResourceWorkspaceAgentResourceMonitor.AvailableActions(), + ResourceWorkspaceAgentDevcontainers.Type: ResourceWorkspaceAgentDevcontainers.AvailableActions(), + ResourceTailnetCoordinator.Type: ResourceTailnetCoordinator.AvailableActions(), + + // Apply templates; full template lifecycle is restricted to + // template-admin. + ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse}, + + // Upload and read template files used during workspace build. + ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, + + // Provisioner jobs back workspace builds. + ResourceProvisionerJobs.Type: ResourceProvisionerJobs.AvailableActions(), + + // Tasks ride along with workspaces. + ResourceTask.Type: ResourceTask.AvailableActions(), + + // Read groups and group memberships for ACL evaluation. + ResourceGroup.Type: {policy.ActionRead}, + ResourceGroupMember.Type: {policy.ActionRead}, + + // Read-self org-member record. + ResourceOrganizationMember.Type: {policy.ActionRead}, + + // Members can create and update AI Bridge interceptions 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. + ResourceNotificationMessage.Type: {policy.ActionRead, policy.ActionUpdate}, + ResourceNotificationPreference.Type: ResourceNotificationPreference.AvailableActions(), + ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(), + + // Replica metadata (read-only is the only defined action). + ResourceReplicas.Type: {policy.ActionRead}, + }) if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersEveryone { memberPerms = append(memberPerms, Permission{ @@ -1140,45 +1165,67 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { } // service account-scoped permissions (resources owned by the - // service account). Uses allPermsExcept to automatically include - // permissions for new resources. - memberPerms := append( - allPermsExcept( - ResourceWorkspaceDormant, - ResourcePrebuiltWorkspace, - ResourceUser, - ResourceOrganizationMember, - ResourceBoundaryLog, - ResourceAibridgeInterception, - // Chat access requires the agents-access role. - ResourceChat, - ), + // service account). Enumerated explicitly so new resources do not + // auto-grant to service accounts. + 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, + }, - Permissions(map[string][]policy.Action{ - // Reduced permission set on dormant workspaces. No build, - // ssh, or exec. - ResourceWorkspaceDormant.Type: { - policy.ActionRead, - policy.ActionDelete, - policy.ActionCreate, - policy.ActionUpdate, - policy.ActionWorkspaceStop, - policy.ActionCreateAgent, - policy.ActionDeleteAgent, - policy.ActionUpdateAgent, - }, - // Can read their own organization member record. - ResourceOrganizationMember.Type: { - policy.ActionRead, - }, - // Service accounts can create and update AI Bridge - // interceptions but cannot read them back. - ResourceAibridgeInterception.Type: { - policy.ActionCreate, - policy.ActionUpdate, - }, - })..., - ) + // Workspace runtime support. + ResourceWorkspaceProxy.Type: {policy.ActionRead}, + ResourceWorkspaceAgentResourceMonitor.Type: ResourceWorkspaceAgentResourceMonitor.AvailableActions(), + ResourceWorkspaceAgentDevcontainers.Type: ResourceWorkspaceAgentDevcontainers.AvailableActions(), + ResourceTailnetCoordinator.Type: ResourceTailnetCoordinator.AvailableActions(), + + // Apply templates; full template lifecycle is restricted to + // template-admin. + ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse}, + + // Upload and read template files used during workspace build. + ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, + + // Provisioner jobs back workspace builds. + ResourceProvisionerJobs.Type: ResourceProvisionerJobs.AvailableActions(), + + // Tasks ride along with workspaces. + ResourceTask.Type: ResourceTask.AvailableActions(), + + // Read groups and group memberships for ACL evaluation. + ResourceGroup.Type: {policy.ActionRead}, + ResourceGroupMember.Type: {policy.ActionRead}, + + // Read-self org-member record. + ResourceOrganizationMember.Type: {policy.ActionRead}, + + // Service accounts can create and update AI Bridge interceptions + // 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. + ResourceNotificationMessage.Type: {policy.ActionRead, policy.ActionUpdate}, + ResourceNotificationPreference.Type: ResourceNotificationPreference.AvailableActions(), + ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(), + + // Replica metadata (read-only is the only defined action). + ResourceReplicas.Type: {policy.ActionRead}, + }) return OrgRolePermissions{Org: orgPerms, Member: memberPerms} } From 0e23625c2584d1b42234bf7e69865e8c15776738 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 1 Jun 2026 19:48:45 +0000 Subject: [PATCH 2/3] refactor(coderd/rbac): drop dead Member-scoped perms from org roles Member-level perms in OrgPermissions only fire when input.object.owner == input.subject.id (see the org_member rule in coderd/rbac/policy.rego). Resources whose RBACObject() does not set WithOwner(...) at production call sites can never satisfy that condition; granting them at Member scope is dead code. PR 1's enumeration inherited these from the legacy allPermsExcept(...) wildcard. This commit drops them so the floor matches its documented scope and adds an "Intentionally omitted" block in roles.go listing each removed type and the reason it stays out, for posterity. Removed from both OrgMemberPermissions and OrgServiceAccountPermissions Member maps: - ResourceTemplate {read, use} Template.RBACObject sets InOrg and ACLs but no Owner. Org-member template.use is granted via the "Everyone" ACL path (acl_group_list[org_owner] populated on each template's GroupACL); that is the rule that fires in createWorkspace, not the Member-level grant. - ResourceGroup {read} Group.RBACObject sets a per-group GroupACL granting read to the group's own ID, but no Owner. "Groups I'm a member of can read themselves" is the ACL path. Reading other groups requires a higher role. - ResourceWorkspaceProxy {read} WorkspaceProxy.RBACObject sets only WithID. All production call sites use the bare resource; Member-level grant never fires. - ResourceProvisionerJobs {*} No DB model implements RBACObject. Handler call sites use .InOrg(org.ID) only; coderd/provisionerjobs.go:100 documents the intent as "only owners and template admins can access provisioner jobs." - ResourceWorkspaceAgentResourceMonitor {*} Dbauthz call sites use the bare resource for system / telemetry reads. Owner-scoped checks (e.g. FetchVolumesResourceMonitorsByAgentID) route through the workspace object instead, so the Member-level monitor grant is never the path that authorizes. - ResourceWorkspaceAgentDevcontainers {*} Dbauthz call sites use the bare resource. Agent-side perms come from system roles. - ResourceTailnetCoordinator {*} Dbauthz call sites use the bare resource. Tailnet ops are granted to system / agent roles. - ResourceReplicas {read} Bare resource at the single call site in enterprise/coderd/replicas.go; Member-level never fires. Behavior-preserving: all eight grants were also dead under the legacy allPermsExcept(...) wildcard. The rbac, dbauthz, coderd, and enterprise/coderd test suites pass at the same scope verified for the initial PR 1 commit. --- coderd/rbac/roles.go | 109 ++++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 47 deletions(-) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 07003df50a9..146f06cb04f 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -1058,6 +1058,12 @@ 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(), @@ -1075,48 +1081,58 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { policy.ActionUpdateAgent, }, - // Workspace runtime support: proxies, agent monitors, - // devcontainer setup, and tailnet coordination. - ResourceWorkspaceProxy.Type: {policy.ActionRead}, - ResourceWorkspaceAgentResourceMonitor.Type: ResourceWorkspaceAgentResourceMonitor.AvailableActions(), - ResourceWorkspaceAgentDevcontainers.Type: ResourceWorkspaceAgentDevcontainers.AvailableActions(), - ResourceTailnetCoordinator.Type: ResourceTailnetCoordinator.AvailableActions(), - - // Apply templates; full template lifecycle is restricted to - // template-admin. - ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse}, - - // Upload and read template files used during workspace build. + // Upload and read template files the member created during + // workspace build (File.RBACObject sets WithOwner(CreatedBy)). ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, - // Provisioner jobs back workspace builds. - ResourceProvisionerJobs.Type: ResourceProvisionerJobs.AvailableActions(), - - // Tasks ride along with workspaces. + // Tasks ride along with workspaces and are owner-scoped. ResourceTask.Type: ResourceTask.AvailableActions(), - // Read groups and group memberships for ACL evaluation. - ResourceGroup.Type: {policy.ActionRead}, + // Read-self group-membership record. GroupMember.RBACObject + // sets WithOwner to the user's own ID. ResourceGroupMember.Type: {policy.ActionRead}, // Read-self org-member record. ResourceOrganizationMember.Type: {policy.ActionRead}, - // Members can create and update AI Bridge interceptions but - // cannot read them back. Chat access requires the agents-access - // role and is intentionally not granted here. + // 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. ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate}, // Own session tokens and workspace agent auth keys. ResourceApiKey.Type: ResourceApiKey.AvailableActions(), - // User-scoped notification surfaces. + // 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(), - // Replica metadata (read-only is the only defined action). - ResourceReplicas.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 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. }) if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersEveryone { @@ -1167,6 +1183,12 @@ 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(), @@ -1184,47 +1206,40 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { policy.ActionUpdateAgent, }, - // Workspace runtime support. - ResourceWorkspaceProxy.Type: {policy.ActionRead}, - ResourceWorkspaceAgentResourceMonitor.Type: ResourceWorkspaceAgentResourceMonitor.AvailableActions(), - ResourceWorkspaceAgentDevcontainers.Type: ResourceWorkspaceAgentDevcontainers.AvailableActions(), - ResourceTailnetCoordinator.Type: ResourceTailnetCoordinator.AvailableActions(), - - // Apply templates; full template lifecycle is restricted to - // template-admin. - ResourceTemplate.Type: {policy.ActionRead, policy.ActionUse}, - - // Upload and read template files used during workspace build. + // Upload and read template files the service account created + // during workspace build (File.RBACObject sets + // WithOwner(CreatedBy)). ResourceFile.Type: {policy.ActionCreate, policy.ActionRead}, - // Provisioner jobs back workspace builds. - ResourceProvisionerJobs.Type: ResourceProvisionerJobs.AvailableActions(), - - // Tasks ride along with workspaces. + // Tasks ride along with workspaces and are owner-scoped. ResourceTask.Type: ResourceTask.AvailableActions(), - // Read groups and group memberships for ACL evaluation. - ResourceGroup.Type: {policy.ActionRead}, + // Read-self group-membership record. GroupMember.RBACObject + // sets WithOwner to the user's own ID. ResourceGroupMember.Type: {policy.ActionRead}, // Read-self org-member record. ResourceOrganizationMember.Type: {policy.ActionRead}, - // Service accounts can create and update AI Bridge interceptions - // 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. ResourceApiKey.Type: ResourceApiKey.AvailableActions(), - // User-scoped notification surfaces. + // 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(), - // Replica metadata (read-only is the only defined action). - ResourceReplicas.Type: {policy.ActionRead}, + // Intentionally omitted at Member scope. See + // OrgMemberPermissions above for the rationale; the service + // account role mirrors the same partition. }) return OrgRolePermissions{Org: orgPerms, Member: memberPerms} From 5253fe8e7184eea7ce341aad948b4dbd0126ad98 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 1 Jun 2026 20:18:02 +0000 Subject: [PATCH 3/3] fix(coderd/rbac): grant org members provisioner_daemon.{create,read} for user-scoped daemons The enumerate-org-member refactor dropped ResourceProvisionerDaemon from the Member perm sets, which broke TestProvisionerDaemonServe/UserLocal: creating a user-scoped daemon (tags={"scope":"user","owner":}) goes through UpsertProvisionerDaemon in dbauthz, which sets WithOwner(tag_owner) on the RBAC object, causing the policy to evaluate the Member-scope grant. Add ResourceProvisionerDaemon.Type: {policy.ActionCreate, policy.ActionRead} to both the organization-member and organization-service-account Member perm sets. Create restores the daemon-serve flow; read is granted for symmetry with workspace ownership so members can inspect daemons they spawned. No production call site exercises the member-scope read path today (read on the bare InOrg object continues to require Org-level perms), but granting it keeps the role consistent with how members own their other workspace-adjacent resources. Update and delete remain dead at Member scope. --- coderd/rbac/roles.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 146f06cb04f..2fee0942a44 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -1085,6 +1085,15 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { // 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(), @@ -1211,6 +1220,15 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { // 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(),