refactor(coderd): restore agents-access cleanup migration (#28587)

## Summary

- restore the `agents-access` data cleanup migration as `000587`
(renumbered from `000585`; main advanced past that number)
- revert the temporary RBAC tolerance for retired role names now that
the migration strips stale grants
- make the migration test discover its predecessor dynamically, since
this branch does not carry main's `000585`/`000586`

## Stack context

Stacked on #28186, which retires the `agents-access` role but
intentionally ships without a migration so it can be backported across
diverged migration numbers. This layer completes the retirement on main:
it cleans up stale role grants in `users.rbac_roles`,
`organization_members.roles`, and
`organizations.default_org_member_roles`, then reverts the interim
leniency (role expansion dropping legacy names, assignment validation
ignoring them) that existed only to keep the migration-less base safe.
`rbac.ReservedRoleName` still reserves the retired name so custom roles
cannot shadow it on rollback. Do not backport this PR alongside the base
PR.

## Rollback note

The down migration restores effective access broadly because it cannot
reconstruct which users previously held the removed role.

## Validation

- `go build ./coderd/... ./enterprise/...`
- full `coderd/database/migrations`, `coderd/rbac/...`, and
`coderd/database/dbauthz` suites
- targeted members tests in `coderd`
- repository pre-commit checks (`lint/go`, `lint/ts`, etc.)

> Mux created this pull request on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-08-26 15:29:26 +02:00
committed by GitHub
parent 31146642ba
commit 45f2eb48cc
8 changed files with 107 additions and 173 deletions
+5 -14
View File
@@ -847,12 +847,6 @@ func SlimRolesFromNames(names []string) []codersdk.SlimRole {
convertedRoles := make([]codersdk.SlimRole, 0, len(names))
for _, name := range names {
// Stored role arrays may retain retired built-in role names until
// a cleanup migration lands. Hide them so consumers do not display
// or resubmit them.
if rbac.IsRetiredRoleName(name) {
continue
}
convertedRoles = append(convertedRoles, SlimRoleFromName(name))
}
@@ -925,14 +919,11 @@ func Organization(organization database.Organization) codersdk.Organization {
DisplayName: organization.DisplayName,
Icon: organization.Icon,
},
Description: organization.Description,
CreatedAt: organization.CreatedAt,
UpdatedAt: organization.UpdatedAt,
IsDefault: organization.IsDefault,
// Stored default role lists may retain retired built-in role names
// until a cleanup migration lands. Hide them so settings forms do
// not display or resubmit them.
DefaultOrgMemberRoles: slices.DeleteFunc(slices.Clone(organization.DefaultOrgMemberRoles), rbac.IsRetiredRoleName),
Description: organization.Description,
CreatedAt: organization.CreatedAt,
UpdatedAt: organization.UpdatedAt,
IsDefault: organization.IsDefault,
DefaultOrgMemberRoles: organization.DefaultOrgMemberRoles,
}
}
@@ -0,0 +1,13 @@
-- Older binaries need agents-access unless another role grants chat access.
-- Exact prior grants cannot be reconstructed, so restore the role to all
-- current non-service-account memberships.
UPDATE organization_members om
SET roles = array_append(om.roles, 'agents-access')
FROM users u
WHERE u.id = om.user_id
AND NOT u.is_service_account
AND NOT ('agents-access' = ANY(om.roles));
-- Defaults are not restored: older binaries union default_org_member_roles
-- into service-account memberships too. Pre-upgrade explicit service-account
-- grants stay lost; an admin can re-grant them.
@@ -0,0 +1,11 @@
UPDATE users
SET rbac_roles = array_remove(rbac_roles, 'agents-access')
WHERE 'agents-access' = ANY(rbac_roles);
UPDATE organization_members
SET roles = array_remove(roles, 'agents-access')
WHERE 'agents-access' = ANY(roles);
UPDATE organizations
SET default_org_member_roles = array_remove(default_org_member_roles, 'agents-access')
WHERE 'agents-access' = ANY(default_org_member_roles);
@@ -1191,6 +1191,74 @@ func TestMigration000475AgentsAccessOrgRole(t *testing.T) {
)
}
func TestMigration000587RemoveAgentsAccessRole(t *testing.T) {
t.Parallel()
const migrationVersion = 587
// The immediately preceding migration numbers may not exist in this
// tree (the target is numbered past migrations that landed on main
// separately), so step to the highest version below the target rather
// than assuming migrationVersion-1 exists.
entries, err := os.ReadDir(".")
require.NoError(t, err)
prevVersion := uint(0)
for _, entry := range entries {
var version uint
if _, err := fmt.Sscanf(entry.Name(), "%d_", &version); err != nil {
continue
}
if version > prevVersion && version < migrationVersion {
prevVersion = version
}
}
require.NotZero(t, prevVersion)
sqlDB := testSQLDB(t)
next, err := migrations.Stepper(sqlDB)
require.NoError(t, err)
for {
version, more, err := next()
require.NoError(t, err)
if !more {
t.Fatalf("migration %d not found", migrationVersion)
}
if version == prevVersion {
break
}
}
db := database.New(sqlDB)
user := dbgen.User(t, db, database.User{
RBACRoles: []string{"auditor", "agents-access"},
})
org := dbgen.Organization(t, db, database.Organization{
DefaultOrgMemberRoles: []string{"organization-workspace-access", "agents-access"},
})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org.ID,
UserID: user.ID,
Roles: []string{"organization-auditor", "agents-access"},
})
version, _, err := next()
require.NoError(t, err)
require.EqualValues(t, migrationVersion, version)
ctx := testutil.Context(t, testutil.WaitLong)
var siteRoles, orgRoles, defaultRoles pq.StringArray
err = sqlDB.QueryRowContext(ctx, "SELECT rbac_roles FROM users WHERE id = $1", user.ID).Scan(&siteRoles)
require.NoError(t, err)
err = sqlDB.QueryRowContext(ctx, "SELECT roles FROM organization_members WHERE organization_id = $1 AND user_id = $2", org.ID, user.ID).Scan(&orgRoles)
require.NoError(t, err)
err = sqlDB.QueryRowContext(ctx, "SELECT default_org_member_roles FROM organizations WHERE id = $1", org.ID).Scan(&defaultRoles)
require.NoError(t, err)
require.Equal(t, []string{"auditor"}, []string(siteRoles))
require.Equal(t, []string{"organization-auditor"}, []string(orgRoles))
require.Equal(t, []string{"organization-workspace-access"}, []string(defaultRoles))
}
func TestMigration000504AIProvidersBackfill(t *testing.T) {
t.Parallel()
+1 -7
View File
@@ -5,7 +5,6 @@ import (
"database/sql"
"fmt"
"net/http"
"slices"
"github.com/google/uuid"
"golang.org/x/xerrors"
@@ -511,17 +510,12 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d
roleLookup := make([]database.NameOrganizationPair, 0)
for _, m := range mems {
// Stored role arrays may retain retired built-in role names until a
// cleanup migration lands. They grant nothing, so hide them from
// responses to keep role editors from displaying or resubmitting
// them.
activeRoles := slices.DeleteFunc(slices.Clone(m.Roles), rbac.IsRetiredRoleName)
converted = append(converted, codersdk.OrganizationMember{
UserID: m.UserID,
OrganizationID: m.OrganizationID,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
Roles: slice.List(activeRoles, func(r string) codersdk.SlimRole {
Roles: slice.List(m.Roles, func(r string) codersdk.SlimRole {
// If it is a built-in role, no lookups are needed.
rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: r, OrganizationID: m.OrganizationID})
if err == nil {
-142
View File
@@ -6,17 +6,12 @@ import (
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
@@ -55,143 +50,6 @@ func TestAddMember(t *testing.T) {
})
}
// TestMembersWithRetiredRole verifies that stale grants of retired built-in
// roles, which linger in the database until a cleanup migration lands, do
// not break membership management.
func TestMembersWithRetiredRole(t *testing.T) {
t.Parallel()
// The raw, unauthorized store is required to seed stale data without
// tripping the assignment validation under test.
db, ps := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps})
owner := coderdtest.CreateFirstUser(t, client)
memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitMedium)
// Seed stale data in the store: the retired agents-access role remains
// in a member's site-wide and org role arrays and in the org's default
// member roles.
_, err := db.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{
GrantedRoles: []string{"agents-access"},
UserID: member.ID,
OrgID: owner.OrganizationID,
})
require.NoError(t, err)
_, err = db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{
GrantedRoles: []string{"agents-access"},
ID: member.ID,
})
require.NoError(t, err)
org, err := db.GetOrganizationByID(ctx, owner.OrganizationID)
require.NoError(t, err)
updateOrg := database.UpdateOrganizationParams{
ID: org.ID,
UpdatedAt: dbtime.Now(),
Name: org.Name,
DisplayName: org.DisplayName,
Description: org.Description,
Icon: org.Icon,
DefaultOrgMemberRoles: append(org.DefaultOrgMemberRoles, "agents-access"),
}
_, err = db.UpdateOrganization(ctx, updateOrg)
require.NoError(t, err)
// Requests expand the member's stored roles, including the stale grant.
_, err = memberClient.User(ctx, codersdk.Me)
require.NoError(t, err)
// User creation inserts an organization membership, which validates the
// org's default member roles. The stale default must not fail it.
coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// Membership responses hide the stale grant so role editors do not
// display or resubmit it.
members, err := client.OrganizationMembers(ctx, owner.OrganizationID)
require.NoError(t, err)
for _, m := range members {
if m.UserID != member.ID {
continue
}
for _, role := range m.Roles {
require.NotEqual(t, "agents-access", role.Name)
}
for _, role := range m.GlobalRoles {
require.NotEqual(t, "agents-access", role.Name)
}
}
// User payloads hide the stale site-wide grant the same way.
userResp, err := client.User(ctx, member.ID.String())
require.NoError(t, err)
for _, role := range userResp.Roles {
require.NotEqual(t, "agents-access", role.Name)
}
// Organization responses hide the stale default role the same way.
orgResp, err := client.Organization(ctx, owner.OrganizationID)
require.NoError(t, err)
require.NotContains(t, orgResp.DefaultOrgMemberRoles, "agents-access")
// The user-roles endpoint hides the stale grant the same way.
userRoles, err := memberClient.UserRoles(ctx, codersdk.Me)
require.NoError(t, err)
require.NotContains(t, userRoles.Roles, "agents-access")
require.NotContains(t, userRoles.OrganizationRoles[owner.OrganizationID], "agents-access")
// Restore the defaults so the stale grant is no longer implied and the
// next update must validate its removal.
updateOrg.DefaultOrgMemberRoles = org.DefaultOrgMemberRoles
_, err = db.UpdateOrganization(ctx, updateOrg)
require.NoError(t, err)
// Role updates validate the removed set, which includes the stale
// grant. The update must succeed and strip the retired role.
updated, err := client.UpdateOrganizationMemberRoles(ctx, owner.OrganizationID, member.ID.String(), codersdk.UpdateRoles{
Roles: []string{codersdk.RoleOrganizationAuditor},
})
require.NoError(t, err)
names := make([]string, 0, len(updated.Roles))
for _, role := range updated.Roles {
names = append(names, role.Name)
}
require.Contains(t, names, codersdk.RoleOrganizationAuditor)
require.NotContains(t, names, "agents-access")
// Explicitly granting the retired role again is rejected for both org
// and site scope, so tolerance of stale data cannot be used to store
// fresh grants that a binary rollback would resolve again.
_, err = client.UpdateOrganizationMemberRoles(ctx, owner.OrganizationID, member.ID.String(), codersdk.UpdateRoles{
Roles: []string{codersdk.RoleOrganizationAuditor, "agents-access"},
})
require.ErrorContains(t, err, "retired")
_, err = client.UpdateUserRoles(ctx, member.ID.String(), codersdk.UpdateRoles{
Roles: []string{"agents-access"},
})
require.ErrorContains(t, err, "retired")
// Adding the retired role to the org defaults is rejected at the
// authorization boundary, mirroring the explicit grant paths. The
// authorized store is used on purpose, unlike the raw seeding above.
authzdb := dbauthz.New(db, rbac.NewAuthorizer(prometheus.NewRegistry()), testutil.Logger(t), coderdtest.AccessControlStorePointer())
ownerUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err)
ownerSubject := coderdtest.AuthzUserSubjectWithDB(ctx, t, db, ownerUser)
_, err = authzdb.UpdateOrganization(dbauthz.As(ctx, ownerSubject), database.UpdateOrganizationParams{
ID: org.ID,
UpdatedAt: dbtime.Now(),
Name: org.Name,
DisplayName: org.DisplayName,
Description: org.Description,
Icon: org.Icon,
DefaultOrgMemberRoles: append(org.DefaultOrgMemberRoles, "agents-access"),
})
require.ErrorContains(t, err, "retired")
}
func TestDeleteMember(t *testing.T) {
t.Parallel()
+7 -5
View File
@@ -343,15 +343,17 @@ type RoleOptions struct {
// retiredRoleNames contains retired built-in role names. They stay reserved so
// a custom role cannot take a name that older binaries still resolve as a
// built-in role, which would silently shadow the custom permissions on
// rollback. Stored role arrays and org default role lists may still contain
// these names until a data cleanup migration lands, so role expansion and
// assignment validation treat them as grants of nothing instead of failing.
// rollback. Role expansion and assignment validation keep treating retired
// names as grants of nothing so a pre-reservation custom role with the same
// name cannot be granted or activated again.
var retiredRoleNames = map[string]struct{}{
"agents-access": {},
}
// IsRetiredRoleName reports whether name is a retired built-in role name that
// may still appear in stored role arrays.
// IsRetiredRoleName reports whether name is a retired built-in role name.
// Retired names are reserved against creation and updates and are excluded
// from assignment and expansion; a custom role that took the name before it
// was reserved remains deletable.
func IsRetiredRoleName(name string) bool {
_, ok := retiredRoleNames[name]
return ok
+2 -5
View File
@@ -1767,11 +1767,8 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) {
}
// TODO: Replace this with "GetAuthorizationUserRoles"
// Stored role arrays may retain retired built-in role names until a
// cleanup migration lands. Hide them so consumers do not display or
// resubmit them.
resp := codersdk.UserRoles{
Roles: slices.DeleteFunc(slices.Clone(user.RBACRoles), rbac.IsRetiredRoleName),
Roles: user.RBACRoles,
OrganizationRoles: make(map[uuid.UUID][]string),
}
@@ -1790,7 +1787,7 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) {
}
for _, mem := range memberships {
resp.OrganizationRoles[mem.OrganizationMember.OrganizationID] = slices.DeleteFunc(slices.Clone(mem.OrganizationMember.Roles), rbac.IsRetiredRoleName)
resp.OrganizationRoles[mem.OrganizationMember.OrganizationID] = mem.OrganizationMember.Roles
}
httpapi.Write(ctx, rw, http.StatusOK, resp)