feat: restore agents-access as a default organization role (#30004)

Restores `agents-access` (**Coder Agents User**) as a built-in
organization role and adds it to every organization's
`default_org_member_roles`. Members keep Coder Agents by default, and
admins can now restrict it per organization by removing the role from
the defaults and granting it through IdP role sync or direct assignment.

Since #28186, chat permissions lived in the organization-member floor,
so the only way to limit Coder Agents was narrowing per-model access
lists, which did not hide the feature.

- Migration 000602 appends `agents-access` to every organization's
defaults, so upgrading keeps every member's access. New organizations
get it from `rbac.DefaultOrgMemberRoles()`.
- Service accounts do not inherit the role from the defaults
(`GetAuthorizationUserRoles`), so they still need an explicit grant for
chat.
- Owners and Organization Admins keep chat access regardless of
defaults. Chats shared with a user stay readable without the role.
- Role sync is unchanged. It does not store default roles, so after the
default is removed a mapped user gets `agents-access` at their next
sign-in.
- The retired-role mechanism (`IsRetiredRoleName`) is removed.
- `PATCH /api/v2/organizations/{organization}` rejects duplicate
`default_org_member_roles` entries (the CLI already de-duplicated them).
- Custom organization role requests treat built-in role names
case-insensitively, since custom role names are stored lowercase and
`Agents-Access` would otherwise become an undeletable shadow of the
built-in role.
- The Agents permission alert tells restricted members their chats are
kept, and the model selector for users without chat permission no longer
claims that no models are configured.

Docs add "Control who can use Coder Agents" to the Agents
getting-started guide.

**NOTE**: Migration 000602 deletes custom roles named `agents-access`,
which the built-in role would shadow. The down migration does not
restore them. Stored member grants of `agents-access` from the earlier
built-in role or a deleted custom role stay and now grant chat access.

## Validation

- Remote dogfood
[UAT](https://dogfood.cdr.dev/agents/e1c18dc6-f6f0-49a1-b6cd-6ce014b51a86)
passed on fb441dfae5, covering case variants of built-in role names on
create, update, delete, default roles, and member grants. The previous
round, on a6fef66b3f, passed the upgrade from main 5dde49dffb on a
fresh database. 6b7f107e0b only changes wording (docs, comments, UI
copy, CLI help) and did not go through UAT.
- Local: rbac, dbauthz, idpsync, migrations (chain including 000602 down
and up), querier, httpmw, provisionerdserver, coderd and enterprise
chat, role, and organization tests, chatd, toolsdk, CLI; frontend
vitest, story tests, biome, oxlint, tsc.

## Known issues (not changed here)

- Sending from a chat page that was already open when access was removed
shows a generic 404 panel until reload.
- The members page role editor is not locked for OIDC users under org
role sync; the API rejects the change with 400.
- Creating a custom role named after a system role
(`organization-member`, `organization-service-account`) returns 400 with
a raw database unique-constraint error.

<details>
<summary>Review record</summary>

- Remote UAT (UAT Critic sub-agent `6636c855ec`): round 1 on 8bec029d35
passed with issues, fixed in d7ba937a10 and e0f16e1a45f; round 2 on
a6fef66b3f passed; round 3 on fb441dfae5 passed.
- Codex: round 1 on a6fef66b3f found one P2 (case variants of built-in
role names), fixed in fb441dfae5 with a red-green test. Rounds 2 and 3
on fb441dfae5 found no major issues.
- mafredri on fb441dfae5 (PR text only): nine wording suggestions
applied in 6b7f107e0b9; NOTE extended for stored grants; description
trimmed.
- Frontend: FE1 to FE10 and coder-frontend-review checkpoints
self-audited.

</details>

> 🤖 This PR was opened by [Xum](https://mux.coder.com) on behalf of
@ibetitsmike.
This commit is contained in:
Michael Suchacz
2026-09-27 23:34:26 +02:00
committed by GitHub
parent 756ffb97af
commit 6826da9397
40 changed files with 670 additions and 246 deletions
+7 -2
View File
@@ -70,7 +70,11 @@ func (r *RootCmd) editOrganization(orgContext *OrganizationContext) *serpent.Com
Long: FormatExamples(
Example{
Description: "Replace the roles every member of the organization holds",
Command: "coder organizations edit --default-org-member-roles organization-workspace-access,organization-template-admin",
Command: "coder organizations edit --default-org-member-roles organization-workspace-access,agents-access,organization-template-admin",
},
Example{
Description: "Restrict Coder Agents to members assigned agents-access directly",
Command: "coder organizations edit --default-org-member-roles organization-workspace-access",
},
Example{
Description: "Grant members no roles at all",
@@ -87,7 +91,8 @@ func (r *RootCmd) editOrganization(orgContext *OrganizationContext) *serpent.Com
Description: "Replaces the roles every member of the organization holds. " +
"Accepts a comma-separated list and may be repeated. " +
"An empty value removes every role. " +
"New organizations start with organization-workspace-access, which grants members access to their own workspaces.",
"New organizations start with organization-workspace-access, which grants members access to their own workspaces, " +
"and agents-access, which grants members access to Coder Agents.",
Value: serpent.StringArrayOf(&defaultOrgMemberRoles),
},
cliui.SkipPromptOption(),
+8 -2
View File
@@ -8,7 +8,12 @@ USAGE:
- Replace the roles every member of the organization holds:
$ coder organizations edit --default-org-member-roles
organization-workspace-access,organization-template-admin
organization-workspace-access,agents-access,organization-template-admin
- Restrict Coder Agents to members assigned agents-access directly:
$ coder organizations edit --default-org-member-roles
organization-workspace-access
- Grant members no roles at all:
@@ -22,7 +27,8 @@ OPTIONS:
Replaces the roles every member of the organization holds. Accepts a
comma-separated list and may be repeated. An empty value removes every
role. New organizations start with organization-workspace-access,
which grants members access to their own workspaces.
which grants members access to their own workspaces, and
agents-access, which grants members access to Coder Agents.
-y, --yes bool
Bypass confirmation prompts.
-38
View File
@@ -1416,15 +1416,6 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, re
grantedRoles := make([]rbac.RoleIdentifier, 0, len(added)+len(removed))
grantedRoles = append(grantedRoles, added...)
grantedRoles = append(grantedRoles, removed...)
// Retired role names may linger in stored role arrays and org default
// role lists until a data cleanup migration lands. They expand to no
// permissions and cannot be re-created as custom roles, so validating
// them is unnecessary: the added set only contains them via stored
// data (implied org defaults), never via explicit grants, which the
// role-update paths reject before reaching this filter.
grantedRoles = slices.DeleteFunc(grantedRoles, func(r rbac.RoleIdentifier) bool {
return rbac.IsRetiredRoleName(r.Name)
})
customRoles := make([]rbac.RoleIdentifier, 0)
// Validate that the roles being assigned are valid.
for _, r := range grantedRoles {
@@ -7923,17 +7914,6 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb
if err != nil {
return database.OrganizationMember{}, err
}
// Explicitly granting a retired role is rejected. Retired-name tolerance
// only covers stored grants and implied org defaults that linger until
// the cleanup migration lands; without this check the request would
// skip validation and persist a hidden grant that a binary rollback
// resolves again.
for _, role := range scopedGranted {
if rbac.IsRetiredRoleName(role.Name) {
return database.OrganizationMember{}, xerrors.Errorf("role %q is retired and cannot be assigned", role.Name)
}
}
// The org's default_org_member_roles are implied at request time by
// GetAuthorizationUserRoles. Include them in the implied set so
// canAssignRoles validates the caller can grant the full effective set
@@ -8009,15 +7989,6 @@ func (q *querier) UpdateOrganization(ctx context.Context, arg database.UpdateOrg
scopedOrgRoleIdentifiers(existing.DefaultOrgMemberRoles, arg.ID),
scopedOrgRoleIdentifiers(arg.DefaultOrgMemberRoles, arg.ID),
)
// Newly added defaults must not include retired names, which
// canAssignRoles tolerates only so stale stored defaults keep
// working until the cleanup migration lands. Removals stay
// tolerated so those stale defaults can be cleaned up.
for _, role := range added {
if rbac.IsRetiredRoleName(role.Name) {
return database.Organization{}, xerrors.Errorf("role %q is retired and cannot be a default role", role.Name)
}
}
if err := q.canAssignRoles(ctx, arg.ID, added, removed); err != nil {
return database.Organization{}, err
}
@@ -8556,15 +8527,6 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo
return database.User{}, err
}
// Explicitly granting a retired role is rejected. Retired-name tolerance
// only covers stored grants that linger until the cleanup migration
// lands.
for _, roleName := range arg.GrantedRoles {
if rbac.IsRetiredRoleName(roleName) {
return database.User{}, xerrors.Errorf("role %q is retired and cannot be assigned", roleName)
}
}
// The member role is always implied.
impliedTypes := append(q.convertToDeploymentRoles(arg.GrantedRoles), rbac.RoleMember())
// If the changeset is nothing, less rbac checks need to be done.
@@ -0,0 +1,8 @@
-- Deleted legacy custom roles named agents-access are not restored.
UPDATE organizations
SET default_org_member_roles = array_remove(default_org_member_roles, 'agents-access')
WHERE 'agents-access' = ANY(default_org_member_roles);
UPDATE organization_members
SET roles = array_remove(roles, 'agents-access')
WHERE 'agents-access' = ANY(roles);
@@ -0,0 +1,7 @@
-- Custom roles named agents-access could predate the built-in role. The
-- built-in would shadow them and make them undeletable.
DELETE FROM custom_roles WHERE name = 'agents-access';
UPDATE organizations
SET default_org_member_roles = array_append(default_org_member_roles, 'agents-access')
WHERE NOT ('agents-access' = ANY(default_org_member_roles));
+100
View File
@@ -403,6 +403,7 @@ func TestMigrationChain(t *testing.T) {
{"Migration000587RemoveAgentsAccessRole", 587, testMigration000587RemoveAgentsAccessRole},
{"Migration000590WorkspaceAgentSessionCounts", 590, testMigration000590WorkspaceAgentSessionCounts},
{"Migration000595RemoveTaskPermissions", 595, testMigration000595RemoveTaskPermissions},
{"Migration000602RestoreAgentsAccessDefaultRole", 602, testMigration000602RestoreAgentsAccessDefaultRole},
}
for _, step := range steps {
stepTo(step.version - 1)
@@ -1662,6 +1663,105 @@ func testMigration000587RemoveAgentsAccessRole(t *testing.T, sqlDB *sql.DB, next
require.Equal(t, []string{"organization-workspace-access"}, []string(defaultRoles))
}
func testMigration000602RestoreAgentsAccessDefaultRole(t *testing.T, sqlDB *sql.DB, next migrationStepper) {
const migrationVersion = 602
ctx := testutil.Context(t, testutil.WaitLong)
db := database.New(sqlDB)
orgs := []struct {
defaults []string
want []string
}{
{[]string{"organization-workspace-access"}, []string{"organization-workspace-access", "agents-access"}},
{[]string{}, []string{"agents-access"}},
{[]string{"organization-workspace-access", "organization-auditor"}, []string{"organization-workspace-access", "organization-auditor", "agents-access"}},
}
orgIDs := make([]uuid.UUID, len(orgs))
for i, o := range orgs {
org := dbgen.Organization(t, db, database.Organization{})
// dbgen replaces an empty slice with the current defaults.
_, err := sqlDB.ExecContext(ctx, "UPDATE organizations SET default_org_member_roles = $1 WHERE id = $2", pq.StringArray(o.defaults), org.ID)
require.NoError(t, err)
orgIDs[i] = org.ID
}
member := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: orgIDs[0],
UserID: member.ID,
Roles: []string{"organization-auditor"},
})
serviceAccount := dbgen.User(t, db, database.User{IsServiceAccount: true})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: orgIDs[0],
UserID: serviceAccount.ID,
Roles: []string{},
})
legacyRole := dbgen.CustomRole(t, db, database.CustomRole{
Name: "agents-access",
OrganizationID: uuid.NullUUID{UUID: orgIDs[1], Valid: true},
})
unrelatedRole := dbgen.CustomRole(t, db, database.CustomRole{
OrganizationID: uuid.NullUUID{UUID: orgIDs[1], Valid: true},
})
memberRoles := func(userID uuid.UUID) []string {
t.Helper()
var roles pq.StringArray
err := sqlDB.QueryRowContext(ctx, "SELECT roles FROM organization_members WHERE organization_id = $1 AND user_id = $2", orgIDs[0], userID).Scan(&roles)
require.NoError(t, err)
return roles
}
assertDefaults := func(withAgentsAccess bool) {
t.Helper()
for i, o := range orgs {
var roles pq.StringArray
err := sqlDB.QueryRowContext(ctx, "SELECT default_org_member_roles FROM organizations WHERE id = $1", orgIDs[i]).Scan(&roles)
require.NoError(t, err)
want := o.defaults
if withAgentsAccess {
want = o.want
}
require.Equal(t, want, []string(roles), "org %d", i)
}
}
customRoleExists := func(id uuid.UUID) bool {
t.Helper()
var exists bool
err := sqlDB.QueryRowContext(ctx, "SELECT EXISTS (SELECT 1 FROM custom_roles WHERE id = $1)", id).Scan(&exists)
require.NoError(t, err)
return exists
}
version, _, err := next()
require.NoError(t, err)
require.EqualValues(t, migrationVersion, version)
assertDefaults(true)
require.Equal(t, []string{"organization-auditor"}, memberRoles(member.ID))
require.Empty(t, memberRoles(serviceAccount.ID))
require.False(t, customRoleExists(legacyRole.ID))
require.True(t, customRoleExists(unrelatedRole.ID))
// An explicit grant made after the upgrade is removed on downgrade.
_, err = sqlDB.ExecContext(ctx, "UPDATE organization_members SET roles = array_append(roles, 'agents-access') WHERE user_id = $1", serviceAccount.ID)
require.NoError(t, err)
downSQL, err := os.ReadFile("000602_restore_agents_access_default_role.down.sql")
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, string(downSQL))
require.NoError(t, err)
assertDefaults(false)
require.Equal(t, []string{"organization-auditor"}, memberRoles(member.ID))
require.Empty(t, memberRoles(serviceAccount.ID))
upSQL, err := os.ReadFile("000602_restore_agents_access_default_role.up.sql")
require.NoError(t, err)
_, err = sqlDB.ExecContext(ctx, string(upSQL))
require.NoError(t, err)
assertDefaults(true)
}
func TestMigration000504AIProvidersBackfill(t *testing.T) {
t.Parallel()
+39 -1
View File
@@ -3758,7 +3758,7 @@ func TestGetAuthorizationUserRolesImpliedOrgRole(t *testing.T) {
// every member's effective roles include the org's defaults, and changes
// to the column propagate on the next request. The union applies to
// regular users and to service accounts; the SQL array_cats the column
// for both code paths.
// for both code paths (minus agents-access for service accounts).
func TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles(t *testing.T) {
t.Parallel()
@@ -3809,6 +3809,44 @@ func TestGetAuthorizationUserRolesUnionsDefaultOrgMemberRoles(t *testing.T) {
require.NotContains(t, shrunkSA.Roles, wantWorkspaceAccess)
}
// TestGetAuthorizationUserRolesServiceAccountAgentsAccess verifies service
// accounts do not inherit agents-access from the org defaults but keep an
// explicit grant.
func TestGetAuthorizationUserRolesServiceAccountAgentsAccess(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
require.Contains(t, org.DefaultOrgMemberRoles, rbac.RoleAgentsAccess())
user := dbgen.User(t, db, database.User{})
sa := dbgen.User(t, db, database.User{IsServiceAccount: true})
grantedSA := dbgen.User(t, db, database.User{IsServiceAccount: true})
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: org.ID, UserID: user.ID})
dbgen.OrganizationMember(t, db, database.OrganizationMember{OrganizationID: org.ID, UserID: sa.ID})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: org.ID,
UserID: grantedSA.ID,
Roles: []string{rbac.RoleAgentsAccess()},
})
ctx := testutil.Context(t, testutil.WaitShort)
wantAgentsAccess := rbac.ScopedRoleAgentsAccess(org.ID).String()
wantWorkspaceAccess := rbac.ScopedRoleOrgWorkspaceAccess(org.ID).String()
userRoles, err := db.GetAuthorizationUserRoles(ctx, user.ID)
require.NoError(t, err)
require.Contains(t, userRoles.Roles, wantAgentsAccess)
saRoles, err := db.GetAuthorizationUserRoles(ctx, sa.ID)
require.NoError(t, err)
require.NotContains(t, saRoles.Roles, wantAgentsAccess)
require.Contains(t, saRoles.Roles, wantWorkspaceAccess)
grantedRoles, err := db.GetAuthorizationUserRoles(ctx, grantedSA.ID)
require.NoError(t, err)
require.Contains(t, grantedRoles.Roles, wantAgentsAccess)
}
func TestUpdateOrganizationWorkspaceSharingSettings(t *testing.T) {
t.Parallel()
+7 -1
View File
@@ -31515,6 +31515,8 @@ SELECT
--
-- organizations.default_org_member_roles is unioned in so changes
-- to org defaults propagate to every member on the next request.
-- Service accounts do not inherit agents-access from the defaults
-- so they only get chat access through an explicit grant.
unnest(
array_cat(
array_append(
@@ -31525,7 +31527,11 @@ SELECT
'organization-member'
END
),
organizations.default_org_member_roles
CASE WHEN users.is_service_account THEN
array_remove(organizations.default_org_member_roles, 'agents-access')
ELSE
organizations.default_org_member_roles
END
)
) AS org_roles
WHERE
+7 -1
View File
@@ -620,6 +620,8 @@ SELECT
--
-- organizations.default_org_member_roles is unioned in so changes
-- to org defaults propagate to every member on the next request.
-- Service accounts do not inherit agents-access from the defaults
-- so they only get chat access through an explicit grant.
unnest(
array_cat(
array_append(
@@ -630,7 +632,11 @@ SELECT
'organization-member'
END
),
organizations.default_org_member_roles
CASE WHEN users.is_service_account THEN
array_remove(organizations.default_org_member_roles, 'agents-access')
ELSE
organizations.default_org_member_roles
END
)
) AS org_roles
WHERE
+57
View File
@@ -1203,6 +1203,63 @@ func TestPostChats(t *testing.T) {
require.Equal(t, member.ID, chat.OwnerID)
})
t.Run("AgentsAccessDefaultRole", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.Database = rawDB
opts.Pubsub = pubsub
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModel(t, client)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
createChat := func() error {
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "hello"},
},
})
return err
}
setDefaults := func(roles []string) {
t.Helper()
org, err := rawDB.GetOrganizationByID(ctx, firstUser.OrganizationID)
require.NoError(t, err)
_, err = rawDB.UpdateOrganization(ctx, database.UpdateOrganizationParams{
ID: org.ID,
UpdatedAt: dbtime.Now(),
Name: org.Name,
DisplayName: org.DisplayName,
Description: org.Description,
Icon: org.Icon,
DefaultOrgMemberRoles: roles,
})
require.NoError(t, err)
}
setMemberRoles := func(roles []string) {
t.Helper()
_, err := client.UpdateOrganizationMemberRoles(ctx, firstUser.OrganizationID, member.ID.String(), codersdk.UpdateRoles{Roles: roles})
require.NoError(t, err)
}
setDefaults([]string{codersdk.RoleOrganizationWorkspaceAccess})
requireSDKError(t, createChat(), http.StatusForbidden)
setMemberRoles([]string{codersdk.RoleAgentsAccess})
require.NoError(t, createChat())
setMemberRoles([]string{})
requireSDKError(t, createChat(), http.StatusForbidden)
setDefaults(rbac.DefaultOrgMemberRoles())
require.NoError(t, createChat())
})
t.Run("WithReasoningEffort", func(t *testing.T) {
t.Parallel()
+2 -1
View File
@@ -68,7 +68,7 @@ func TestExtractUserRoles(t *testing.T) {
Roles: orgRoles,
})
require.NoError(t, err)
return user, []rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID), rbac.ScopedRoleOrgWorkspaceAccess(org.ID)}, token
return user, []rbac.RoleIdentifier{rbac.RoleMember(), rbac.ScopedRoleOrgMember(org.ID), rbac.ScopedRoleOrgWorkspaceAccess(org.ID), rbac.ScopedRoleAgentsAccess(org.ID)}, token
},
},
{
@@ -103,6 +103,7 @@ func TestExtractUserRoles(t *testing.T) {
require.NoError(t, err)
expected = append(expected, rbac.ScopedRoleOrgMember(organization.ID))
expected = append(expected, rbac.ScopedRoleOrgWorkspaceAccess(organization.ID))
expected = append(expected, rbac.ScopedRoleAgentsAccess(organization.ID))
}
return user, expected, token
},
+5 -1
View File
@@ -748,7 +748,8 @@ func SetupOrganization(t *testing.T, s *idpsync.AGPLIDPSync, db database.Store,
org, err := db.GetOrganizationByID(context.Background(), orgID)
if xerrors.Is(err, sql.ErrNoRows) {
org = dbgen.Organization(t, db, database.Organization{
ID: orgID,
ID: orgID,
DefaultOrgMemberRoles: def.DefaultOrgMemberRoles,
})
}
@@ -835,6 +836,9 @@ type orgSetupDefinition struct {
GroupNames map[string]bool
OrganizationRoles []string
CustomRoles []string
// DefaultOrgMemberRoles overrides the organization's default member
// roles when non-empty.
DefaultOrgMemberRoles []string
// NotMember if true will ensure the user is not a member of the organization.
NotMember bool
+36
View File
@@ -163,6 +163,42 @@ func TestRoleSyncTable(t *testing.T) {
},
},
},
{
// agents-access removed from the org defaults is granted
// through role sync like any other role.
Name: "AgentsAccessNotDefault",
OrganizationRoles: []string{},
DefaultOrgMemberRoles: []string{rbac.RoleOrgWorkspaceAccess()},
RoleSettings: &idpsync.RoleSyncSettings{
Field: "roles",
Mapping: map[string][]string{
"foo": {rbac.RoleAgentsAccess()},
},
},
assertRoles: &orgRoleAssert{
ExpectedOrgRoles: []string{
rbac.RoleOrgAuditor(),
rbac.RoleAgentsAccess(),
},
},
},
{
// While agents-access is a default it is implicit, so role
// sync neither stores nor strips it.
Name: "AgentsAccessDefault",
OrganizationRoles: []string{},
RoleSettings: &idpsync.RoleSyncSettings{
Field: "roles",
Mapping: map[string][]string{
"foo": {rbac.RoleAgentsAccess()},
},
},
assertRoles: &orgRoleAssert{
ExpectedOrgRoles: []string{
rbac.RoleOrgAuditor(),
},
},
},
{
Name: "NonExistentClaim",
OrganizationRoles: []string{rbac.RoleOrgAuditor()},
@@ -691,6 +691,11 @@ func TestAcquireJob(t *testing.T) {
<-startPublished
if wk, ok := job.Type.(*proto.AcquiredJob_WorkspaceBuild_); ok {
slices.SortFunc(wk.WorkspaceBuild.Metadata.WorkspaceOwnerRbacRoles, func(a, b *sdkproto.Role) int {
return strings.Compare(a.Name+a.OrgId, b.Name+b.OrgId)
})
}
got, err := json.Marshal(job.Type)
require.NoError(t, err)
@@ -724,7 +729,7 @@ func TestAcquireJob(t *testing.T) {
WorkspaceOwnerSshPrivateKey: sshKey.PrivateKey,
WorkspaceBuildId: build.ID.String(),
WorkspaceOwnerLoginType: string(user.LoginType),
WorkspaceOwnerRbacRoles: []*sdkproto.Role{{Name: rbac.RoleOrgMember(), OrgId: pd.OrganizationID.String()}, {Name: "member", OrgId: ""}, {Name: rbac.RoleOrgAuditor(), OrgId: pd.OrganizationID.String()}, {Name: rbac.RoleOrgWorkspaceAccess(), OrgId: pd.OrganizationID.String()}},
WorkspaceOwnerRbacRoles: []*sdkproto.Role{{Name: rbac.RoleOrgMember(), OrgId: pd.OrganizationID.String()}, {Name: "member", OrgId: ""}, {Name: rbac.RoleOrgAuditor(), OrgId: pd.OrganizationID.String()}, {Name: rbac.RoleOrgWorkspaceAccess(), OrgId: pd.OrganizationID.String()}, {Name: rbac.RoleAgentsAccess(), OrgId: pd.OrganizationID.String()}},
}
if prebuiltWorkspaceBuildStage == sdkproto.PrebuiltWorkspaceBuildStage_CLAIM {
// For claimed prebuilds, we expect the prebuild state to be set to CLAIM
+47 -44
View File
@@ -36,6 +36,7 @@ const (
orgTemplateAdmin string = "organization-template-admin"
orgWorkspaceCreationBan string = "organization-workspace-creation-ban"
orgWorkspaceAccess string = "organization-workspace-access"
agentsAccess string = "agents-access"
)
func init() {
@@ -175,6 +176,10 @@ func RoleOrgWorkspaceAccess() string {
return orgWorkspaceAccess
}
func RoleAgentsAccess() string {
return agentsAccess
}
// ScopedRoleOrgAdmin is the org role with the organization ID
func ScopedRoleOrgAdmin(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgAdmin(), OrganizationID: organizationID}
@@ -205,6 +210,10 @@ func ScopedRoleOrgWorkspaceAccess(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleOrgWorkspaceAccess(), OrganizationID: organizationID}
}
func ScopedRoleAgentsAccess(organizationID uuid.UUID) RoleIdentifier {
return RoleIdentifier{Name: RoleAgentsAccess(), OrganizationID: organizationID}
}
// DefaultOrgMemberRoles is the deployment-wide default for the
// organizations.default_org_member_roles column, applied to every new
// organization at creation time. The column has no SQL DEFAULT, so this
@@ -213,7 +222,7 @@ func ScopedRoleOrgWorkspaceAccess(organizationID uuid.UUID) RoleIdentifier {
// Returned as a fresh slice each call to prevent accidental mutation of
// the shared default through append or index assignment.
func DefaultOrgMemberRoles() []string {
return []string{orgWorkspaceAccess}
return []string{orgWorkspaceAccess, agentsAccess}
}
// orgWorkspaceAccessMemberPerms returns the member-scoped permissions
@@ -338,31 +347,9 @@ type RoleOptions struct {
NoChatSharing bool
}
// 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. 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.
// 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
}
// ReservedRoleName exists because the database should only allow unique role
// names, but some roles are built in. So these names are reserved
func ReservedRoleName(name string) bool {
if _, ok := retiredRoleNames[name]; ok {
return true
}
_, ok := loadBuiltinRoles()[name]
return ok
}
@@ -743,6 +730,29 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
},
}
},
// ActionDelete is intentionally excluded because hard-deletion goes through
// ResourceSystem in 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.ActionShare,
policy.ActionUpdate,
},
}),
},
},
}
},
}
builtInRoles.Store(&roles)
@@ -765,6 +775,7 @@ var assignRoles = map[string]map[string]bool{
orgTemplateAdmin: true,
orgWorkspaceCreationBan: true,
orgWorkspaceAccess: true,
agentsAccess: true,
templateAdmin: true,
userAdmin: true,
customSiteRole: true,
@@ -781,6 +792,7 @@ var assignRoles = map[string]map[string]bool{
orgTemplateAdmin: true,
orgWorkspaceCreationBan: true,
orgWorkspaceAccess: true,
agentsAccess: true,
templateAdmin: true,
userAdmin: true,
customSiteRole: true,
@@ -790,6 +802,7 @@ var assignRoles = map[string]map[string]bool{
member: true,
orgMember: true,
orgWorkspaceAccess: true,
agentsAccess: true,
},
orgAdmin: {
orgAdmin: true,
@@ -799,11 +812,13 @@ var assignRoles = map[string]map[string]bool{
orgTemplateAdmin: true,
orgWorkspaceCreationBan: true,
orgWorkspaceAccess: true,
agentsAccess: true,
customOrganizationRole: true,
},
orgUserAdmin: {
orgMember: true,
orgWorkspaceAccess: true,
agentsAccess: true,
},
}
@@ -988,12 +1003,6 @@ func RoleByName(name RoleIdentifier) (Role, error) {
func rolesByNames(roleNames []RoleIdentifier) ([]Role, error) {
roles := make([]Role, 0, len(roleNames))
for _, n := range roleNames {
if IsRetiredRoleName(n.Name) {
// Retired role names grant nothing and cannot be re-created as
// custom roles, so stale stored grants are dropped instead of
// failing expansion.
continue
}
r, err := RoleByName(n)
if err != nil {
return nil, xerrors.Errorf("get role permissions: %w", err)
@@ -1135,13 +1144,13 @@ type OrgRolePermissions struct {
//
// organization-member carries only the "floor": the minimum permission
// set every member of an organization holds (read-self records,
// notifications, and similar). It deliberately grants no workspace
// access. The ability to create and use workspaces lives exclusively on
// the organization-workspace-access role (see
// orgWorkspaceAccessMemberPerms), which organizations attach to members
// notifications, and similar). It deliberately grants no workspace or
// Coder Agents chat access. Those live exclusively on the
// organization-workspace-access role (see orgWorkspaceAccessMemberPerms)
// and the agents-access role, which organizations attach to members
// through default_org_member_roles or explicit assignment. This is what
// makes restricted "gateway account" members possible: clear the
// default roles and members keep the floor but cannot touch workspaces.
// makes restricted members possible: remove a role from the defaults and
// members keep the floor but lose that access unless granted explicitly.
func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
// Organization-level permissions that all org members get.
orgPermMap := map[string][]policy.Action{
@@ -1201,14 +1210,6 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions {
ResourceNotificationMessage.Type: {policy.ActionRead, policy.ActionUpdate},
ResourceNotificationPreference.Type: ResourceNotificationPreference.AvailableActions(),
ResourceInboxNotification.Type: ResourceInboxNotification.AvailableActions(),
// Hard deletion is authorized through ResourceSystem in dbpurge.
ResourceChat.Type: {
policy.ActionCreate,
policy.ActionRead,
policy.ActionShare,
policy.ActionUpdate,
},
})
if org.ShareableWorkspaceOwners != ShareableWorkspaceOwnersEveryone {
@@ -1256,7 +1257,9 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions {
})
}
// Chat permissions are intentionally omitted for service accounts.
// Chat permissions are intentionally omitted for service accounts, and
// GetAuthorizationUserRoles does not union agents-access from the org
// defaults for them, so chat requires an explicit agents-access grant.
memberPerms := Permissions(map[string][]policy.Action{
// Read-self org-member record.
ResourceOrganizationMember.Type: {policy.ActionRead},
+56 -35
View File
@@ -131,6 +131,8 @@ func TestChatSharingPermissions(t *testing.T) {
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
agentsAccessRole, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID))
require.NoError(t, err)
perms := rbac.OrgMemberPermissions(rbac.OrgSettings{})
orgMemberRole := rbac.Role{
Identifier: rbac.ScopedRoleOrgMember(orgID),
@@ -145,7 +147,7 @@ func TestChatSharingPermissions(t *testing.T) {
auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry())
return auth.Authorize(context.Background(), rbac.Subject{
ID: userID,
Roles: rbac.Roles{memberRole, orgMemberRole},
Roles: rbac.Roles{memberRole, orgMemberRole, agentsAccessRole},
Scope: rbac.ScopeAll,
}, policy.ActionShare, resource)
}
@@ -234,7 +236,7 @@ func TestMemberRolesExcludeWorkspacePerms(t *testing.T) {
member := rbac.OrgMemberPermissions(orgSettings).Member
require.False(t, hasResource(member, rbac.ResourceWorkspace.Type), "organization-member must not grant workspace permissions")
require.True(t, hasResource(member, rbac.ResourceOrganizationMember.Type), "organization-member should grant read-self")
require.True(t, hasResource(member, rbac.ResourceChat.Type), "organization-member should grant chat access")
require.False(t, hasResource(member, rbac.ResourceChat.Type), "organization-member must not grant chat access")
sa := rbac.OrgServiceAccountPermissions(orgSettings).Member
require.False(t, hasResource(sa, rbac.ResourceWorkspace.Type), "organization-service-account must not grant workspace permissions")
@@ -250,6 +252,37 @@ func TestMemberRolesExcludeWorkspacePerms(t *testing.T) {
"organization-workspace-access should grant workspace permissions")
}
func TestAgentsAccessRole(t *testing.T) {
t.Parallel()
orgID := uuid.New()
require.True(t, rbac.ReservedRoleName(rbac.RoleAgentsAccess()), "custom roles must not shadow agents-access")
require.Equal(t, []string{rbac.RoleOrgWorkspaceAccess(), rbac.RoleAgentsAccess()}, rbac.DefaultOrgMemberRoles())
role, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID))
require.NoError(t, err)
require.Empty(t, role.Site)
require.Empty(t, role.User)
require.Empty(t, role.ByOrgID[orgID.String()].Org)
require.ElementsMatch(t, rbac.Permissions(map[string][]policy.Action{
rbac.ResourceChat.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionShare, policy.ActionUpdate},
}), role.ByOrgID[orgID.String()].Member)
// The role is organization scoped only.
_, err = rbac.RoleByName(rbac.RoleIdentifier{Name: rbac.RoleAgentsAccess()})
require.Error(t, err)
for _, assigner := range []rbac.RoleIdentifier{
rbac.RoleOwner(),
rbac.RoleUserAdmin(),
rbac.ScopedRoleOrgAdmin(orgID),
rbac.ScopedRoleOrgUserAdmin(orgID),
} {
require.True(t, rbac.CanAssignRole(rbac.RoleIdentifiers{assigner}, rbac.ScopedRoleAgentsAccess(orgID)), assigner.String())
}
require.False(t, rbac.CanAssignRole(rbac.RoleIdentifiers{rbac.ScopedRoleOrgTemplateAdmin(orgID)}, rbac.ScopedRoleAgentsAccess(orgID)))
}
// These were "pared down" in https://github.com/coder/coder/pull/21359 to avoid
// using the now DB-backed organization-member role. As a result, they no longer
// model real-world org-scoped users (who also have organization-member).
@@ -313,6 +346,21 @@ func TestRolePermissions(t *testing.T) {
}
}()
orgAgentsAccessUser := func() authSubject {
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
agentsAccessRole, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID))
require.NoError(t, err)
return authSubject{
Name: "org_agents_access",
Actor: rbac.Subject{
ID: currentUser.String(),
Roles: rbac.Roles{memberRole, agentsAccessRole},
Scope: rbac.ScopeAll,
}.WithCachedASTValue(),
}
}()
orgMemberMe := func() authSubject {
memberRole, err := rbac.RoleByName(rbac.RoleMember())
require.NoError(t, err)
@@ -1411,8 +1459,8 @@ func TestRolePermissions(t *testing.T) {
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, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
true: {owner, orgAdmin, orgAgentsAccessUser},
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
},
},
{
@@ -1420,8 +1468,8 @@ func TestRolePermissions(t *testing.T) {
Actions: []policy.Action{policy.ActionShare},
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin, orgMemberMe},
false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
true: {owner, orgAdmin, orgAgentsAccessUser},
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
},
},
{
@@ -1430,7 +1478,7 @@ func TestRolePermissions(t *testing.T) {
Resource: rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, orgAdmin},
false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
false: {setOtherOrg, memberMe, orgMemberMe, orgAgentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser},
},
},
{
@@ -1617,6 +1665,7 @@ func TestListRoles(t *testing.T) {
fmt.Sprintf("organization-template-admin:%s", orgID.String()),
fmt.Sprintf("organization-workspace-creation-ban:%s", orgID.String()),
fmt.Sprintf("organization-workspace-access:%s", orgID.String()),
fmt.Sprintf("agents-access:%s", orgID.String()),
},
orgRoleNames)
}
@@ -1795,31 +1844,3 @@ func TestDBPurgeBoundaryLogDelete(t *testing.T) {
rbac.ResourceBoundaryLog)
require.Error(t, err, "DBPurge must not read boundary logs")
}
// TestRetiredRoleNames verifies retired built-in role names stay reserved and
// that stale stored grants of them expand to nothing instead of failing.
func TestRetiredRoleNames(t *testing.T) {
t.Parallel()
const retiredName = "agents-access"
orgID := uuid.New()
require.True(t, rbac.IsRetiredRoleName(retiredName))
// Retired names stay reserved so custom roles cannot shadow them.
require.True(t, rbac.ReservedRoleName(retiredName))
// Retired names do not resolve to a role...
_, err := rbac.RoleByName(rbac.RoleIdentifier{Name: retiredName})
require.Error(t, err)
// ...but stored role arrays may still contain them, so expansion drops
// them instead of failing.
roles, err := rbac.RoleIdentifiers{
{Name: retiredName},
{Name: retiredName, OrganizationID: orgID},
rbac.ScopedRoleOrgAuditor(orgID),
}.Expand()
require.NoError(t, err)
require.Len(t, roles, 1)
require.Equal(t, rbac.ScopedRoleOrgAuditor(orgID), roles[0].Identifier)
}
-8
View File
@@ -82,14 +82,6 @@ func Expand(ctx context.Context, db database.Store, names []rbac.RoleIdentifier)
roles := make([]rbac.Role, 0, len(names))
for _, name := range names {
if rbac.IsRetiredRoleName(name.Name) {
// Retired built-in role names may linger in stored role arrays
// until a cleanup migration lands. They grant nothing and stay
// reserved, so skip them instead of falling through to the
// custom-role lookup, which could resurrect an unrelated
// pre-reservation custom role with the same name.
continue
}
// Remove any built in roles
expanded, err := rbac.RoleByName(name)
if err == nil {
-24
View File
@@ -46,30 +46,6 @@ func TestExpandCustomRoleRoles(t *testing.T) {
require.Len(t, roles, 1, "role found")
}
func TestExpandRetiredRoleName(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
org := dbgen.Organization(t, db, database.Organization{})
// Simulate a custom role that took the name before it became a
// reserved built-in role name. Expanding a stored grant of the
// retired name must not resurrect the custom role.
dbgen.CustomRole(t, db, database.CustomRole{
Name: "agents-access",
OrganizationID: uuid.NullUUID{
UUID: org.ID,
Valid: true,
},
})
ctx := testutil.Context(t, testutil.WaitShort)
roles, err := rolestore.Expand(ctx, db, []rbac.RoleIdentifier{{Name: "agents-access", OrganizationID: org.ID}})
require.NoError(t, err)
require.Empty(t, roles, "retired role names expand to nothing")
}
func TestPrefetchCustomRoles(t *testing.T) {
t.Parallel()
-7
View File
@@ -101,13 +101,6 @@ func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role, customR
}
for _, role := range customRoles {
// A custom role that predates the reservation of a retired
// built-in name can no longer be granted, so do not offer it.
// Custom-role management surfaces still list it so it can be
// deleted.
if rbac.IsRetiredRoleName(role.Name) {
continue
}
canAssign := rbac.CanAssignRole(actorRoles, rbac.CustomSiteRole())
if role.RoleIdentifier().IsOrgRole() {
canAssign = rbac.CanAssignRole(actorRoles, rbac.CustomOrganizationRole(role.OrganizationID.UUID))
+3 -3
View File
@@ -7,9 +7,9 @@ const (
RoleTemplateAdmin string = "template-admin"
RoleUserAdmin string = "user-admin"
RoleAuditor string = "auditor"
// Deprecated: the agents-access role was removed. Coder Agents chat
// access is part of the organization-member permission floor, and
// servers without this built-in role reject assigning it.
// RoleAgentsAccess is the organization role that grants Coder Agents
// chat access. Organizations include it in their default member roles
// unless an administrator removes it.
RoleAgentsAccess string = "agents-access"
RoleOrganizationAdmin string = "organization-admin"
+1
View File
@@ -64,6 +64,7 @@ Coder ships the following organization roles:
- **Organization Auditor**: reads the organization's audit logs and connection logs, along with the resources those logs reference.
- **Organization Workspace Access**: creates and operates the user's own workspaces in the organization.
- **Organization Workspace Creation Ban**: blocks creating and deleting workspaces in the organization, and overrides any role that would otherwise allow it.
- **Coder Agents User**: uses [Coder Agents](../../ai-coder/agents/index.md) in the organization. Members other than service accounts hold this role through the organization's [default member roles](./organizations.md#default-member-roles) unless an administrator removes it.
Organization Admin doesn't include SSH, application, or terminal access to workspaces other members own.
A user with that role can read, build, stop, and delete those workspaces.
+13 -7
View File
@@ -133,12 +133,18 @@ names. Coder unions this list into every member's effective roles at request
time, so changes propagate to all current and future members on their next
request without re-issuing tokens or editing per-user role assignments.
The default value is `["organization-workspace-access"]`. With that default,
every organization member can read, build, ssh into, and execute commands in
workspaces they own. Removing `organization-workspace-access` from the list
creates organization members that cannot create or use workspaces unless the
role is assigned to them directly, which is useful for restricted accounts
that should only hold the minimal member permissions.
The default value is `["organization-workspace-access", "agents-access"]`.
With that default, every organization member can read, build, ssh into, and
execute commands in workspaces they own, and can use Coder Agents. Removing
`organization-workspace-access` from the list creates organization members
that cannot create or use workspaces unless the role is assigned to them
directly, which is useful for restricted accounts that should only hold the
minimal member permissions. Removing `agents-access` limits Coder Agents to
members who hold the role directly. Refer to
[Control who can use Coder Agents](../../ai-coder/agents/getting-started.md#control-who-can-use-coder-agents).
Service accounts don't inherit `agents-access` from this list. Assign the
role to a service account directly to let it use Coder Agents.
To edit the default roles in the dashboard, go to
**Admin settings** > **Organizations** > **Roles** > **Default Roles**.
@@ -147,7 +153,7 @@ From the CLI, use `coder organizations edit`:
```shell
coder organizations edit --org <organization> \
--default-org-member-roles organization-workspace-access,organization-template-admin
--default-org-member-roles organization-workspace-access,agents-access,organization-template-admin
```
The flag replaces the current list rather than adding to it, and accepts a
+33 -1
View File
@@ -23,8 +23,10 @@ Before you begin, confirm the following:
The **Organization Admin** role and the **Owner** role include this access.
A custom role with model configuration access also works.
- **Organization membership** for each user who uses Coder Agents.
Every organization member can use Coder Agents by default.
To restrict access, see [Control who can use Coder Agents](#control-who-can-use-coder-agents).
Users also need read access to at least one model in the organization.
New models are shared with the whole organization by default; to restrict who can use Coder Agents, narrow the model access lists.
New models are shared with the whole organization by default.
See [Manage model permissions](./models.md#manage-model-permissions).
## Step 1: Configure an LLM provider and model
@@ -72,6 +74,34 @@ When the conversation and available tools are sufficient, it works without provi
If missing tools, skills, MCP integrations, or context block progress, the agent selects a template and provisions a workspace to continue.
It also provisions a workspace for tasks that need file access, command execution, or code changes.
## Control who can use Coder Agents
Organization members can use Coder Agents by default.
Access comes from the **Coder Agents User** (`agents-access`) organization role, which each organization grants to members through its [default member roles](../../admin/users/organizations.md#default-member-roles).
Members who don't hold the role directly lose Coder Agents access when you remove **Coder Agents User** from the default roles.
Role sync assigns roles at sign-in and doesn't store default roles, so members who get the role through role sync regain access at their next sign-in, and mapping the role in advance doesn't shorten the gap.
To limit Coder Agents to a subset of members in an organization (requires a Premium license):
1. Remove **Coder Agents User** from the organization's default roles.
In the dashboard, go to **Admin settings** > **Organizations** > **Roles** > **Default Roles**.
From the CLI, run `coder organizations edit --org <organization> --default-org-member-roles organization-workspace-access`, keeping any other default roles in the list.
1. Grant `agents-access` to the members who need it:
- With [IdP organization role sync](../../admin/users/idp-sync.md#role-sync), map an IdP group or role to `agents-access`.
- Without role sync, assign **Coder Agents User** from **Admin settings** > **Organizations** > **Members**.
Keep the following behavior in mind:
- Removing a user from the mapped IdP group or role removes their Coder Agents access at their next sign-in.
- Owners and Organization Admins can always use Coder Agents, whatever the default roles are.
- Service accounts don't inherit `agents-access` from the default roles.
Assign the role to a service account directly if it needs Coder Agents.
- Members without the role can't use Coder Agents or open chats they created earlier.
Their chats are kept and become available again when they regain the role.
A chat shared with them stays readable.
- [Model permissions](./models.md#manage-model-permissions) still apply: users also need read access to at least one model.
## Optimize your templates
The agent selects templates based on their **name, description, and README**.
@@ -197,6 +227,8 @@ Good starting points:
- **Prototyping** — building proof-of-concept implementations, simple
dashboards, internal tools.
To limit Coder Agents to the pilot group, see [Control who can use Coder Agents](#control-who-can-use-coder-agents).
Set expectations for how the team reviews agent output.
Developers should still review all agent-produced code before merging.
The agent is a force multiplier, not a replacement for developer judgment.
+3 -2
View File
@@ -148,8 +148,9 @@ Create, update, delete, and share permissions control their corresponding action
Members with model share permission can let members and groups in the selected organization use the model.
Model access lists control who can use Coder Agents.
All organization members except service accounts hold chat permissions, but a member without read access to at least one model in the organization can't use the feature.
Model access lists control which models a member can use in Coder Agents.
Chat permissions come from the **Coder Agents User** role, which every member except service accounts holds by default; see [Control who can use Coder Agents](./getting-started.md#control-who-can-use-coder-agents).
A member without read access to at least one model in the organization can't use the feature.
New models grant read access to the whole organization by default.
1. Navigate to **Admin settings** > **AI** > **Models**.
+6 -2
View File
@@ -19,7 +19,11 @@ coder organizations edit [flags]
```console
- Replace the roles every member of the organization holds:
$ coder organizations edit --default-org-member-roles organization-workspace-access,organization-template-admin
$ coder organizations edit --default-org-member-roles organization-workspace-access,agents-access,organization-template-admin
- Restrict Coder Agents to members assigned agents-access directly:
$ coder organizations edit --default-org-member-roles organization-workspace-access
- Grant members no roles at all:
@@ -34,7 +38,7 @@ coder organizations edit [flags]
|------|---------------------------|
| Type | <code>string-array</code> |
Replaces the roles every member of the organization holds. Accepts a comma-separated list and may be repeated. An empty value removes every role. New organizations start with organization-workspace-access, which grants members access to their own workspaces.
Replaces the roles every member of the organization holds. Accepts a comma-separated list and may be repeated. An empty value removes every role. New organizations start with organization-workspace-access, which grants members access to their own workspaces, and agents-access, which grants members access to Coder Agents.
### -y, --yes
+54
View File
@@ -1153,6 +1153,60 @@ func TestCreateChatUsesOrganizationLocalModel(t *testing.T) {
require.True(t, found, "chat should be visible in list")
}
func TestCreateChatAgentsAccessDefaultRole(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, firstUser := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
expClient := codersdk.NewExperimentalClient(client)
provider := createOpenAIProviderForTest(ctx, t, expClient, "test-key", "https://example.com")
_, err := expClient.CreateChatModel(ctx, firstUser.OrganizationID, codersdk.CreateChatModelRequest{
AIProviderID: &provider.ID,
Model: "gpt-4o-mini",
IsDefault: new(true),
ContextLimit: new(int64(1000)),
})
require.NoError(t, err)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
createChat := func() error {
_, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: firstUser.OrganizationID,
Content: []codersdk.ChatInputPart{
{Type: codersdk.ChatInputPartTypeText, Text: "hello"},
},
})
return err
}
setDefaults := func(roles []string) {
t.Helper()
//nolint:gocritic // Only owners can update organization settings.
updated, err := client.UpdateOrganization(ctx, firstUser.OrganizationID.String(), codersdk.UpdateOrganizationRequest{
DefaultOrgMemberRoles: &roles,
})
require.NoError(t, err)
require.Equal(t, roles, updated.DefaultOrgMemberRoles)
}
require.NoError(t, createChat())
setDefaults([]string{codersdk.RoleOrganizationWorkspaceAccess})
err = createChat()
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
setDefaults(rbac.DefaultOrgMemberRoles())
require.NoError(t, createChat())
}
// TestCreateChatCrossOrgModelConfigRejected proves an explicit
// model_config_id naming a config in a DIFFERENT org than the chat is
// rejected as unavailable: post-cutover validation is org-aware, so a
+13
View File
@@ -67,7 +67,20 @@ func (api *API) patchOrganization(rw http.ResponseWriter, r *http.Request) {
// for every member of the org. A future change can extend this to
// custom org roles by routing through canAssignRoles in dbauthz.
if req.DefaultOrgMemberRoles != nil {
seen := make(map[string]struct{}, len(*req.DefaultOrgMemberRoles))
for _, name := range *req.DefaultOrgMemberRoles {
if _, ok := seen[name]; ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid default_org_member_roles entry.",
Detail: fmt.Sprintf("%q is listed more than once.", name),
Validations: []codersdk.ValidationError{{
Field: "default_org_member_roles",
Detail: fmt.Sprintf("%q is listed more than once.", name),
}},
})
return
}
seen[name] = struct{}{}
if _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: name, OrganizationID: organization.ID}); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid default_org_member_roles entry.",
+31
View File
@@ -519,6 +519,37 @@ func TestPatchOrganizationsByUser(t *testing.T) {
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Invalid default_org_member_roles entry")
})
t.Run("DuplicateRoleRejected", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
ctx := testutil.Context(t, testutil.WaitMedium)
o := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{})
//nolint:gocritic // Only owners can update organization settings.
_, err := client.UpdateOrganization(ctx, o.ID.String(), codersdk.UpdateOrganizationRequest{
DefaultOrgMemberRoles: new([]string{
codersdk.RoleOrganizationWorkspaceAccess,
codersdk.RoleAgentsAccess,
codersdk.RoleAgentsAccess,
}),
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Detail, "listed more than once")
//nolint:gocritic // The owner verifies the stored defaults are unchanged.
got, err := client.Organization(ctx, o.ID)
require.NoError(t, err)
require.Equal(t, rbac.DefaultOrgMemberRoles(), got.DefaultOrgMemberRoles)
})
})
}
+5 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@@ -205,10 +206,8 @@ func (api *API) deleteOrgRole(rw http.ResponseWriter, r *http.Request) {
rolename := chi.URLParam(r, "roleName")
// Catch requests that try to delete system roles. Retired built-in
// names skip this: they stay reserved for creation and updates, but a
// custom role that predates the reservation must remain deletable.
if !rbac.IsRetiredRoleName(rolename) && !validOrganizationRoleRequest(ctx, codersdk.CustomRoleRequest{Name: rolename}, rw) {
// Catch requests that try to delete system roles.
if !validOrganizationRoleRequest(ctx, codersdk.CustomRoleRequest{Name: rolename}, rw) {
return
}
@@ -293,8 +292,8 @@ func sdkPermissionToDB(p codersdk.Permission) database.CustomRolePermission {
func validOrganizationRoleRequest(ctx context.Context, req codersdk.CustomRoleRequest, rw http.ResponseWriter) bool {
// This check is not ideal, but we cannot enforce a unique role name in the db against
// the built-in role names.
if rbac.ReservedRoleName(req.Name) {
// the built-in role names. Custom role names are stored lowercase.
if rbac.ReservedRoleName(strings.ToLower(req.Name)) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Reserved role name",
Detail: fmt.Sprintf("%q is a reserved role name, and not allowed to be used", req.Name),
+21 -33
View File
@@ -11,10 +11,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
@@ -220,6 +217,24 @@ func TestCustomOrganizationRole(t *testing.T) {
UserPermissions: nil,
})
require.ErrorContains(t, err, "Reserved")
//nolint:gocritic // owner is required for this
_, err = owner.CreateOrganizationRole(ctx, codersdk.Role{
Name: codersdk.RoleAgentsAccess,
DisplayName: "Testing Purposes",
OrganizationID: first.OrganizationID.String(),
})
require.ErrorContains(t, err, "Reserved")
// Custom role names are stored lowercase, so a case variant would
// shadow the built-in role.
//nolint:gocritic // owner is required for this
_, err = owner.CreateOrganizationRole(ctx, codersdk.Role{
Name: "Agents-Access",
DisplayName: "Testing Purposes",
OrganizationID: first.OrganizationID.String(),
})
require.ErrorContains(t, err, "Reserved")
})
// Attempt to add site & user permissions, which is not allowed
@@ -333,36 +348,6 @@ func TestCustomOrganizationRole(t *testing.T) {
require.ErrorContains(t, err, "Resource not found")
})
t.Run("DeleteRetiredName", func(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
owner, first := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{Database: db, Pubsub: ps},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
orgAdmin, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID, rbac.ScopedRoleOrgAdmin(first.OrganizationID))
ctx := testutil.Context(t, testutil.WaitMedium)
// A custom role that took the name before it became a reserved
// retired built-in name. It cannot be created or updated through
// the API anymore, but it must remain deletable.
dbgen.CustomRole(t, db, database.CustomRole{
Name: "agents-access",
OrganizationID: uuid.NullUUID{
UUID: first.OrganizationID,
Valid: true,
},
})
err := orgAdmin.DeleteOrganizationRole(ctx, first.OrganizationID, "agents-access")
require.NoError(t, err)
})
t.Run("Delete", func(t *testing.T) {
t.Parallel()
owner, first := coderdenttest.New(t, &coderdenttest.Options{
@@ -539,6 +524,7 @@ func TestListRoles(t *testing.T) {
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: false,
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: false,
{Name: codersdk.RoleOrganizationWorkspaceAccess, OrganizationID: owner.OrganizationID}: false,
{Name: codersdk.RoleAgentsAccess, OrganizationID: owner.OrganizationID}: false,
}),
},
{
@@ -573,6 +559,7 @@ func TestListRoles(t *testing.T) {
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleOrganizationWorkspaceAccess, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleAgentsAccess, OrganizationID: owner.OrganizationID}: true,
}),
},
{
@@ -607,6 +594,7 @@ func TestListRoles(t *testing.T) {
{Name: codersdk.RoleOrganizationUserAdmin, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleOrganizationWorkspaceCreationBan, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleOrganizationWorkspaceAccess, OrganizationID: owner.OrganizationID}: true,
{Name: codersdk.RoleAgentsAccess, OrganizationID: owner.OrganizationID}: true,
}),
},
}
+3 -3
View File
@@ -8719,9 +8719,9 @@ export interface Role {
// From codersdk/rbacroles.go
/**
* Ideally these roles would be generated from the rbac/roles.go package.
* @deprecated the agents-access role was removed. Coder Agents chat
* access is part of the organization-member permission floor, and
* servers without this built-in role reject assigning it.
* RoleAgentsAccess is the organization role that grants Coder Agents
* chat access. Organizations include it in their default member roles
* unless an administrator removes it.
*/
export const RoleAgentsAccess = "agents-access";
+2
View File
@@ -10,6 +10,7 @@ export const roleDescriptions: Record<string, string> = {
"user-admin": "User admin can manage all users and groups.",
"template-admin": "Template admin can manage all templates and workspaces.",
auditor: "Auditor can access the audit logs.",
"agents-access": "Coder Agents User can use Coder Agents chat.",
"organization-admin":
"Organization admin can manage all resources within this organization.",
"organization-user-admin":
@@ -52,6 +53,7 @@ const roleNamesByAccessLevel: readonly string[] = [
"organization-template-admin",
"auditor",
"organization-auditor",
"agents-access",
"member",
"organization-member",
];
@@ -434,12 +434,17 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
// keeps its loading state instead of flashing the provisional organization's
// catalog before permissions resolve.
const isModelDataPending = !orgSelectionSettled || isModelCatalogLoading;
const modelSelectorPlaceholder = getModelSelectorPlaceholder(
modelOptions,
isModelDataPending,
hasConfiguredModels,
modelCatalog,
);
const isForbidden = !canCreateChat || noPermittedOrgs;
// A forbidden user may have no organization to read models from, and the
// catalog-based placeholder would then wrongly report that none exist.
const modelSelectorPlaceholder = isForbidden
? "Select model"
: getModelSelectorPlaceholder(
modelOptions,
isModelDataPending,
hasConfiguredModels,
modelCatalog,
);
const modelSelectorHelp = getModelSelectorHelp({
isModelCatalogLoading: isModelDataPending,
hasModelOptions,
@@ -492,8 +497,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
setUserSelectedModel(value);
};
const isForbidden = !canCreateChat || noPermittedOrgs;
// Filter workspaces by the selected organization. We use
// client-side filtering of the full "owner:me" fetch rather
// than re-querying with an org filter because it avoids
@@ -5,7 +5,9 @@ import { Link } from "#/components/Link/Link";
import { docs } from "#/utils/docs";
export const ChatAccessDeniedAlert: FC = () => {
const docsLink = docs("/ai-coder/agents/getting-started");
const docsLink = docs(
"/ai-coder/agents/getting-started#control-who-can-use-coder-agents",
);
return (
<Alert
@@ -18,7 +20,8 @@ export const ChatAccessDeniedAlert: FC = () => {
>
<AlertTitle>Permission required</AlertTitle>
<AlertDescription>
You don't have permission to use Coder Agents. Contact your Coder
You don't have permission to use Coder Agents. Your existing chats are
kept and reappear when your access is restored. Contact your Coder
administrator, then refresh this page.{" "}
<Link href={docsLink} target="_blank" rel="noreferrer">
View Docs
@@ -3,6 +3,7 @@ import { action } from "storybook/actions";
import { expect, userEvent, within } from "storybook/test";
import type { AssignableRoles } from "#/api/typesGenerated";
import {
MockAgentsAccessRole,
MockOrganization,
MockOrganizationAuditorRole,
MockPermissions,
@@ -22,6 +23,7 @@ const mockOrgRoles: AssignableRoles[] = [
assignable: true,
built_in: true,
},
MockAgentsAccessRole,
{
name: "organization-admin",
display_name: "Organization Admin",
@@ -204,9 +204,8 @@ const DefaultRolesSection: FC<DefaultRolesSectionProps> = ({
<div className="text-sm">
{organization.default_org_member_roles.length === 0 ? (
<span className="text-content-secondary">
No default roles. Members have only the permissions of their
directly assigned roles, which excludes creating and using
workspaces.
No default roles. Members can create and use workspaces or use Coder
Agents only through directly assigned roles.
</span>
) : (
<DefaultRolesSummary
@@ -79,7 +79,8 @@ const ActiveDefaultRolesDialog: FC<ActiveProps> = ({
These roles are granted to every member of this organization,
current and future. Removing a role removes it from all members that
are not assigned that role directly. Without Organization Workspace
Access, members cannot create or use workspaces.
Access, members cannot create or use workspaces. Without Coder
Agents User, members cannot use Coder Agents.
</DialogDescription>
</DialogHeader>
<RoleSelector
@@ -1,11 +1,14 @@
import { screen, within } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HttpResponse, http } from "msw";
import type { SlimRole } from "#/api/typesGenerated";
import { HttpResponse, http, type PathParams } from "msw";
import type { SlimRole, UpdateRoles } from "#/api/typesGenerated";
import {
MockAgentsAccessRole,
MockEntitlementsWithMultiOrg,
MockOrganization,
MockOrganizationAuditorRole,
MockOrganizationMember,
MockOrganizationMember2,
MockOrganizationPermissions,
MockUserMember,
} from "#/testHelpers/entities";
@@ -123,6 +126,42 @@ describe("OrganizationMembersPage", () => {
await updateUserRole(MockOrganizationAuditorRole);
await screen.findByText(/TestUser2's roles have been updated\./);
});
it("grants agents-access explicitly to a service account", async () => {
let requestedRoles: readonly string[] = [];
server.use(
http.get("/api/v2/organizations/:organizationId/members/roles", () =>
HttpResponse.json([
MockOrganizationAuditorRole,
MockAgentsAccessRole,
]),
),
http.get(
"/api/v2/organizations/:organizationId/paginated-members",
() =>
HttpResponse.json({
members: [
MockOrganizationMember,
{ ...MockOrganizationMember2, is_service_account: true },
],
count: 2,
}),
),
http.put<PathParams, UpdateRoles>(
`/api/v2/organizations/:organizationId/members/${MockUserMember.id}/roles`,
async ({ request }) => {
requestedRoles = (await request.json()).roles;
return HttpResponse.json(MockOrganizationMember2);
},
),
);
await renderPage();
await updateUserRole(MockAgentsAccessRole);
await waitFor(() =>
expect(requestedRoles).toEqual([MockAgentsAccessRole.name]),
);
});
});
describe("when it fails", () => {
@@ -11,10 +11,11 @@ import {
updateOrganizationMemberRoles,
} from "#/api/queries/organizations";
import { organizationRoles } from "#/api/queries/roles";
import type {
AssignableRoles,
OrganizationMemberWithUserData,
User,
import {
type AssignableRoles,
type OrganizationMemberWithUserData,
RoleAgentsAccess,
type User,
} from "#/api/typesGenerated";
import { ConfirmDialog } from "#/components/Dialog/ConfirmDialog/ConfirmDialog";
import { EmptyState } from "#/components/EmptyState/EmptyState";
@@ -143,7 +144,14 @@ const OrganizationMembersPage: FC = () => {
key={memberToEditRoles?.username}
user={memberToEditRoles}
availableRoles={organizationRolesQuery.data}
additionalImpliedRoles={defaultMemberImpliedRoles}
// Service accounts do not inherit agents-access from the org defaults.
additionalImpliedRoles={
memberToEditRoles?.is_service_account
? defaultMemberImpliedRoles.filter(
(r) => r.name !== RoleAgentsAccess,
)
: defaultMemberImpliedRoles
}
onCancel={() => setMemberToEditRoles(undefined)}
onUpdateRoles={async (roles) => {
try {
+13 -1
View File
@@ -21,7 +21,7 @@ export const MockOrganization: TypesGen.Organization = {
created_at: "",
updated_at: "",
is_default: false,
default_org_member_roles: ["organization-workspace-access"],
default_org_member_roles: ["organization-workspace-access", "agents-access"],
};
export const MockDefaultOrganization: TypesGen.Organization = {
@@ -378,6 +378,18 @@ export const MockOrganizationAuditorRole: TypesGen.AssignableRoles = {
organization_member_permissions: [],
};
export const MockAgentsAccessRole: TypesGen.AssignableRoles = {
name: "agents-access",
display_name: "Coder Agents User",
assignable: true,
built_in: true,
site_permissions: [],
user_permissions: [],
organization_id: MockOrganization.id,
organization_permissions: [],
organization_member_permissions: [],
};
export const MockRoleWithOrgPermissions: TypesGen.AssignableRoles = {
name: "my-role-1",
display_name: "My Role 1",