diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 9e0b19986c8..4713bbfeeee 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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, } } diff --git a/coderd/database/migrations/000587_remove_agents_access_role.down.sql b/coderd/database/migrations/000587_remove_agents_access_role.down.sql new file mode 100644 index 00000000000..668c33f4a5b --- /dev/null +++ b/coderd/database/migrations/000587_remove_agents_access_role.down.sql @@ -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. diff --git a/coderd/database/migrations/000587_remove_agents_access_role.up.sql b/coderd/database/migrations/000587_remove_agents_access_role.up.sql new file mode 100644 index 00000000000..086d97045e9 --- /dev/null +++ b/coderd/database/migrations/000587_remove_agents_access_role.up.sql @@ -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); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 42531844b72..7981fae4f3f 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -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() diff --git a/coderd/members.go b/coderd/members.go index 4bf80b6c917..21728c065d5 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -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 { diff --git a/coderd/members_test.go b/coderd/members_test.go index e83990b6e91..c2bf219c1eb 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -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() diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 0e483d79172..163a5e93efb 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -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 diff --git a/coderd/users.go b/coderd/users.go index b5dbf976869..8dae291a5bd 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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)