mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user