feat: migrate agents-access to org-scoped system role for proper chat RBAC (#24438)

The agents-access role previously granted chat permissions at user
scope, but chats are org-scoped objects. Rego skips user-level perms
when org_owner is set, making the grants invisible. Handler-level
band-aids used synthetic non-org-scoped objects as a workaround.

  - Migrates agents-access from users.rbac_roles (site-level) to
    organization_members.roles (org-scoped) via DB migration
  - Redefines agents-access as a predefined org-scoped builtin role
    alongside organization-admin, organization-auditor, etc., with
    Member permissions granting chat create/read/update
  - Excludes ResourceChat from OrgMemberPermissions so org membership
    alone no longer grants chat access
  - Fixes handler Authorize checks to use org-scoped objects with
semantically correct actions (ActionUpdate for message/tool operations)
  - Grants org admins the ability to assign agents-access

Closes #24250
Fixes CODAGT-174

Note: this does not update the "Usage" endpoints. Tracked by CODAGT-161.
> 🤖
This commit is contained in:
Cian Johnston
2026-04-23 17:59:42 +01:00
committed by GitHub
parent 7efccfa996
commit b5a625549e
19 changed files with 441 additions and 174 deletions
+40 -33
View File
@@ -143,7 +143,7 @@ func RoleTemplateAdmin() RoleIdentifier { return RoleIdentifier{Name: templateAd
func RoleUserAdmin() RoleIdentifier { return RoleIdentifier{Name: userAdmin} }
func RoleMember() RoleIdentifier { return RoleIdentifier{Name: member} }
func RoleAuditor() RoleIdentifier { return RoleIdentifier{Name: auditor} }
func RoleAgentsAccess() RoleIdentifier { return RoleIdentifier{Name: agentsAccess} }
func RoleAgentsAccess() string { return agentsAccess }
func RoleOrgAdmin() string {
return orgAdmin
@@ -199,6 +199,10 @@ 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 allPermsExcept(excepts ...Objecter) []Permission {
resources := AllResources()
var perms []Permission
@@ -404,21 +408,6 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
agentsAccessRole := Role{
Identifier: RoleAgentsAccess(),
DisplayName: "Coder Agents User",
Site: []Permission{},
User: Permissions(map[string][]policy.Action{
ResourceChat.Type: {
policy.ActionCreate,
policy.ActionRead,
policy.ActionUpdate,
policy.ActionDelete,
},
}),
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 {
@@ -449,14 +438,6 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
return userAdminRole
},
// agentsAccess grants all actions on chat resources owned
// by the user. Without this role, members can still read,
// update, and delete their own chats via org membership,
// but cannot create chats or trigger AI inference.
agentsAccess: func(_ uuid.UUID) Role {
return agentsAccessRole
},
// orgAdmin returns a role with all actions allows in a given
// organization scope.
orgAdmin: func(organizationID uuid.UUID) Role {
@@ -606,6 +587,30 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
},
}
},
// agentsAccess grants org members permission to create, read, and
// update chats. ActionDelete is intentionally excluded: no dbauthz
// function checks it on ResourceChat. Hard-deletion goes through
// ResourceSystem (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.ActionUpdate,
},
}),
},
},
}
},
}
}
@@ -660,10 +665,13 @@ var assignRoles = map[string]map[string]bool{
orgTemplateAdmin: true,
orgWorkspaceCreationBan: true,
customOrganizationRole: true,
agentsAccess: true,
},
orgUserAdmin: {
orgMember: true,
orgMember: true,
agentsAccess: true,
},
prebuildsOrchestrator: {
orgMember: true,
},
@@ -886,20 +894,13 @@ func SiteBuiltInRoles() []Role {
for _, roleF := range builtInRoles {
// Must provide some non-nil uuid to filter out org roles.
role := roleF(uuid.New())
if !role.Identifier.IsOrgRole() && role.Identifier != RoleAgentsAccess() {
if !role.Identifier.IsOrgRole() {
roles = append(roles, role)
}
}
return roles
}
// AgentsAccessRole returns the agents-access role for use by callers
// that need to include it conditionally (e.g. when the agents
// experiment is enabled).
func AgentsAccessRole() Role {
return builtInRoles[agentsAccess](uuid.Nil)
}
// 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
@@ -1041,7 +1042,10 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
ResourceUser,
ResourceOrganizationMember,
ResourceAibridgeInterception,
// Chat access requires the agents-access role.
ResourceChat,
),
Permissions(map[string][]policy.Action{
// Reduced permission set on dormant workspaces. No build,
// ssh, or exec.
@@ -1123,7 +1127,10 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
ResourceUser,
ResourceOrganizationMember,
ResourceAibridgeInterception,
// Chat access requires the agents-access role.
ResourceChat,
),
Permissions(map[string][]policy.Action{
// Reduced permission set on dormant workspaces. No build,
// ssh, or exec.
+31 -12
View File
@@ -49,11 +49,6 @@ func TestBuiltInRoles(t *testing.T) {
require.NoError(t, r.Valid(), "invalid role")
})
}
t.Run("agents-access", func(t *testing.T) {
t.Parallel()
require.NoError(t, rbac.AgentsAccessRole().Valid(), "invalid role")
})
}
// permissionGranted checks whether a permission list contains a
@@ -204,7 +199,21 @@ func TestRolePermissions(t *testing.T) {
orgUserAdmin := authSubject{Name: "org_user_admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgUserAdmin(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
orgTemplateAdmin := authSubject{Name: "org_template_admin", Actor: rbac.Subject{ID: userAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgTemplateAdmin(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
orgAdminBanWorkspace := authSubject{Name: "org_admin_workspace_ban", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(orgID), rbac.ScopedRoleOrgWorkspaceCreationBan(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
agentsAccessUser := authSubject{Name: "chat_access", Actor: rbac.Subject{ID: currentUser.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.RoleAgentsAccess()}, Scope: rbac.ScopeAll}.WithCachedASTValue()}
agentsAccessUser := func() authSubject {
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
agentsRole, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID))
require.NoError(t, err)
return authSubject{
Name: "agents_access",
Actor: rbac.Subject{
ID: currentUser.String(),
Roles: rbac.Roles{memberRole, agentsRole},
Scope: rbac.ScopeAll,
}.WithCachedASTValue(),
}
}()
orgMemberMe := func() authSubject {
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
@@ -580,8 +589,8 @@ func TestRolePermissions(t *testing.T) {
}),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, templateAdmin, orgUserAdmin, orgTemplateAdmin, orgAuditor},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin},
true: {owner, orgAdmin, templateAdmin, orgUserAdmin, orgTemplateAdmin, orgAuditor, agentsAccessUser},
false: {setOtherOrg, memberMe, userAdmin},
},
},
{
@@ -1096,12 +1105,21 @@ func TestRolePermissions(t *testing.T) {
},
},
{
Name: "ChatUsage",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete},
Name: "ChatUsageCRU",
Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, orgMemberMe},
false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
true: {owner, orgAdmin, agentsAccessUser},
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
{
Name: "ChatUsageDelete",
Actions: []policy.Action{policy.ActionDelete},
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, memberMe, orgMemberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor},
},
},
}
@@ -1258,6 +1276,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("agents-access:%s", orgID.String()),
},
orgRoleNames)
}