Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3ff9069
feat: enable Coder Agents for organization members
ibetitsmike Aug 14, 2026
3c49c50
fix(coderd): promote queued message from a terminal chat status in me…
ibetitsmike Aug 25, 2026
9460577
fix: keep RoleAgentsAccess as a deprecated compatibility alias
ibetitsmike Aug 25, 2026
f0548ea
docs(docs/ai-coder/agents): document model access lists as the Coder …
ibetitsmike Aug 25, 2026
dca5c57
refactor(coderd/rbac): track retired role names in a legacy set
ibetitsmike Aug 25, 2026
478dc27
fix(site/src/pages/AgentsPage): align settings story assertion with c…
ibetitsmike Aug 25, 2026
7e1a2c6
refactor(coderd): drop agents-access cleanup migration
ibetitsmike Aug 25, 2026
b722a80
refactor(coderd): restore agents-access cleanup migration
ibetitsmike Aug 25, 2026
f5291e2
Merge remote-tracking branch 'origin/main' into HEAD
ibetitsmike Aug 25, 2026
cbdad54
fix(coderd): drop retired role names in rolestore expansion and API r…
ibetitsmike Aug 25, 2026
e6f8311
fix(site/src/pages/AgentsPage): make chat access denied copy permissi…
ibetitsmike Aug 25, 2026
2cc13b4
Merge remote-tracking branch 'origin/mike/codagt-554-enable-agents-by…
ibetitsmike Aug 25, 2026
861c378
docs(docs/ai-coder/agents): reflow prerequisites bullet to one senten…
ibetitsmike Aug 25, 2026
73e1352
fix(coderd): reject explicit grants of retired role names
ibetitsmike Aug 25, 2026
6889238
fix(coderd): extend retired-role rejection to org defaults and assign…
ibetitsmike Aug 25, 2026
af0ab29
docs(docs/ai-coder/agents): exclude service accounts from chat permis…
ibetitsmike Aug 25, 2026
f94fa5f
Merge remote-tracking branch 'origin/mike/codagt-554-enable-agents-by…
ibetitsmike Aug 25, 2026
e1e6f9f
fix(coderd): hide retired role names from the user-roles response
ibetitsmike Aug 25, 2026
f429c4c
fix: hide retired roles in user payloads and keep retired custom role…
ibetitsmike Aug 25, 2026
86b3eef
Merge remote-tracking branch 'origin/mike/codagt-554-enable-agents-by…
ibetitsmike Aug 25, 2026
8f07c6a
Merge remote-tracking branch 'origin/main' into mike/codagt-554-agent…
ibetitsmike Aug 26, 2026
b33e339
refactor(coderd): drop stale retired-role response filtering
ibetitsmike Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 5 additions & 14 deletions coderd/database/db2sdk/db2sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down Expand Up @@ -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,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions coderd/database/migrations/000587_remove_agents_access_role.up.sql
Original file line number Diff line number Diff line change
@@ -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);
68 changes: 68 additions & 0 deletions coderd/database/migrations/migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
8 changes: 1 addition & 7 deletions coderd/members.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"database/sql"
"fmt"
"net/http"
"slices"

"github.com/google/uuid"
"golang.org/x/xerrors"
Expand Down Expand Up @@ -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 {
Expand Down
142 changes: 0 additions & 142 deletions coderd/members_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()

Expand Down
12 changes: 7 additions & 5 deletions coderd/rbac/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand All @@ -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)
Expand Down
Loading