From 6826da9397fd3a2506d06d86d841055e3d1cdd5a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:34:26 +0200 Subject: [PATCH] feat: restore agents-access as a default organization role (#30004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 fb441dfae58, covering case variants of built-in role names on create, update, delete, default roles, and member grants. The previous round, on a6fef66b3fe, passed the upgrade from main 5dde49dffbd on a fresh database. 6b7f107e0b9 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.
Review record - Remote UAT (UAT Critic sub-agent `6636c855ec`): round 1 on 8bec029d353 passed with issues, fixed in d7ba937a108 and e0f16e1a45f; round 2 on a6fef66b3fe passed; round 3 on fb441dfae58 passed. - Codex: round 1 on a6fef66b3fe found one P2 (case variants of built-in role names), fixed in fb441dfae58 with a red-green test. Rounds 2 and 3 on fb441dfae58 found no major issues. - mafredri on fb441dfae58 (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.
> 🤖 This PR was opened by [Xum](https://mux.coder.com) on behalf of @ibetitsmike. --- cli/organizationmanage.go | 9 +- .../coder_organizations_edit_--help.golden | 10 +- coderd/database/dbauthz/dbauthz.go | 38 ------- ...estore_agents_access_default_role.down.sql | 8 ++ ..._restore_agents_access_default_role.up.sql | 7 ++ coderd/database/migrations/migrate_test.go | 100 ++++++++++++++++++ coderd/database/querier_test.go | 40 ++++++- coderd/database/queries.sql.go | 8 +- coderd/database/queries/users.sql | 8 +- coderd/exp_chats_test.go | 57 ++++++++++ coderd/httpmw/authorize_test.go | 3 +- coderd/idpsync/group_test.go | 6 +- coderd/idpsync/role_test.go | 36 +++++++ .../provisionerdserver_test.go | 7 +- coderd/rbac/roles.go | 91 ++++++++-------- coderd/rbac/roles_test.go | 91 ++++++++++------ coderd/rbac/rolestore/rolestore.go | 8 -- coderd/rbac/rolestore/rolestore_test.go | 24 ----- coderd/roles.go | 7 -- codersdk/rbacroles.go | 6 +- docs/admin/users/groups-roles.md | 1 + docs/admin/users/organizations.md | 20 ++-- docs/ai-coder/agents/getting-started.md | 34 +++++- docs/ai-coder/agents/models.md | 5 +- docs/reference/cli/organizations_edit.md | 8 +- enterprise/coderd/exp_chats_test.go | 54 ++++++++++ enterprise/coderd/organizations.go | 13 +++ enterprise/coderd/organizations_test.go | 31 ++++++ enterprise/coderd/roles.go | 11 +- enterprise/coderd/roles_test.go | 54 ++++------ site/src/api/typesGenerated.ts | 6 +- site/src/modules/roles/index.ts | 2 + .../AgentsPage/components/AgentCreateForm.tsx | 19 ++-- .../components/ChatAccessDeniedAlert.tsx | 7 +- .../CustomRolesPageView.stories.tsx | 2 + .../CustomRolesPage/CustomRolesPageView.tsx | 5 +- .../CustomRolesPage/DefaultRolesDialog.tsx | 3 +- .../OrganizationMembersPage.test.tsx | 45 +++++++- .../OrganizationMembersPage.tsx | 18 +++- site/src/testHelpers/entities.ts | 14 ++- 40 files changed, 670 insertions(+), 246 deletions(-) create mode 100644 coderd/database/migrations/000602_restore_agents_access_default_role.down.sql create mode 100644 coderd/database/migrations/000602_restore_agents_access_default_role.up.sql diff --git a/cli/organizationmanage.go b/cli/organizationmanage.go index 6632532d07..8bedbd9761 100644 --- a/cli/organizationmanage.go +++ b/cli/organizationmanage.go @@ -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(), diff --git a/cli/testdata/coder_organizations_edit_--help.golden b/cli/testdata/coder_organizations_edit_--help.golden index 8722ce5c3d..38ce475418 100644 --- a/cli/testdata/coder_organizations_edit_--help.golden +++ b/cli/testdata/coder_organizations_edit_--help.golden @@ -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. diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index d112731431..db7f4655c1 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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. diff --git a/coderd/database/migrations/000602_restore_agents_access_default_role.down.sql b/coderd/database/migrations/000602_restore_agents_access_default_role.down.sql new file mode 100644 index 0000000000..0123d796f8 --- /dev/null +++ b/coderd/database/migrations/000602_restore_agents_access_default_role.down.sql @@ -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); diff --git a/coderd/database/migrations/000602_restore_agents_access_default_role.up.sql b/coderd/database/migrations/000602_restore_agents_access_default_role.up.sql new file mode 100644 index 0000000000..5e89183017 --- /dev/null +++ b/coderd/database/migrations/000602_restore_agents_access_default_role.up.sql @@ -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)); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 594886c038..3cb89f83f5 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -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() diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 7f6fab2599..547da59659 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5f2adba75d..8344dc0dd6 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 4aefa0e315..dac1001245 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -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 diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 9a6ec3d47b..717ee49d37 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -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() diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index dc04d1c519..7507233118 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -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 }, diff --git a/coderd/idpsync/group_test.go b/coderd/idpsync/group_test.go index 1e23712fe3..5825ce1b02 100644 --- a/coderd/idpsync/group_test.go +++ b/coderd/idpsync/group_test.go @@ -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 diff --git a/coderd/idpsync/role_test.go b/coderd/idpsync/role_test.go index 421a19c051..e961bd8328 100644 --- a/coderd/idpsync/role_test.go +++ b/coderd/idpsync/role_test.go @@ -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()}, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index f3fcf82e62..7951471226 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -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 diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 1d0d0648ed..a011e88236 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -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}, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index abbff4a4bc..5a2a883412 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -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) -} diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 145a6c27fd..233df7ae52 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -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 { diff --git a/coderd/rbac/rolestore/rolestore_test.go b/coderd/rbac/rolestore/rolestore_test.go index 1d35b90ff0..78a2c3de62 100644 --- a/coderd/rbac/rolestore/rolestore_test.go +++ b/coderd/rbac/rolestore/rolestore_test.go @@ -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() diff --git a/coderd/roles.go b/coderd/roles.go index db6009c53d..500ada46e4 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -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)) diff --git a/codersdk/rbacroles.go b/codersdk/rbacroles.go index ea87127a39..e3e5181396 100644 --- a/codersdk/rbacroles.go +++ b/codersdk/rbacroles.go @@ -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" diff --git a/docs/admin/users/groups-roles.md b/docs/admin/users/groups-roles.md index 8db7204a51..ac3b730202 100644 --- a/docs/admin/users/groups-roles.md +++ b/docs/admin/users/groups-roles.md @@ -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. diff --git a/docs/admin/users/organizations.md b/docs/admin/users/organizations.md index eac655730c..d96888735c 100644 --- a/docs/admin/users/organizations.md +++ b/docs/admin/users/organizations.md @@ -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 \ - --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 diff --git a/docs/ai-coder/agents/getting-started.md b/docs/ai-coder/agents/getting-started.md index a53a16add6..25aa42b053 100644 --- a/docs/ai-coder/agents/getting-started.md +++ b/docs/ai-coder/agents/getting-started.md @@ -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 --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. diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 4ad4408c10..f2b91664ab 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -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**. diff --git a/docs/reference/cli/organizations_edit.md b/docs/reference/cli/organizations_edit.md index 15cccb253c..78e3a0516a 100644 --- a/docs/reference/cli/organizations_edit.md +++ b/docs/reference/cli/organizations_edit.md @@ -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 | string-array | -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 diff --git a/enterprise/coderd/exp_chats_test.go b/enterprise/coderd/exp_chats_test.go index 2aeacb9767..f805e8ec15 100644 --- a/enterprise/coderd/exp_chats_test.go +++ b/enterprise/coderd/exp_chats_test.go @@ -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 diff --git a/enterprise/coderd/organizations.go b/enterprise/coderd/organizations.go index 0b1eacd292..2e40635eb8 100644 --- a/enterprise/coderd/organizations.go +++ b/enterprise/coderd/organizations.go @@ -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.", diff --git a/enterprise/coderd/organizations_test.go b/enterprise/coderd/organizations_test.go index 7ea2da96c3..24f94c2746 100644 --- a/enterprise/coderd/organizations_test.go +++ b/enterprise/coderd/organizations_test.go @@ -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) + }) }) } diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index c4ea6607bf..d33b4dd489 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -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), diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 1def3e0846..b2c44658ef 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -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, }), }, } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b607cac34b..495e758e9a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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"; diff --git a/site/src/modules/roles/index.ts b/site/src/modules/roles/index.ts index 55246db385..0f61146889 100644 --- a/site/src/modules/roles/index.ts +++ b/site/src/modules/roles/index.ts @@ -10,6 +10,7 @@ export const roleDescriptions: Record = { "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", ]; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 9f6dd9dbff..d665b2aca8 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -434,12 +434,17 @@ export const AgentCreateForm: FC = ({ // 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 = ({ 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 diff --git a/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx b/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx index a61aed9810..7bc76ca514 100644 --- a/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx +++ b/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx @@ -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 ( { > Permission required - 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.{" "} View Docs diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx index e28c84f5f6..cbdba090ca 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx @@ -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", diff --git a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 4d55a04802..e89c40bf83 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -204,9 +204,8 @@ const DefaultRolesSection: FC = ({
{organization.default_org_member_roles.length === 0 ? ( - 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. ) : ( = ({ 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. { 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( + `/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", () => { diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx index 23911f3f67..31c357baae 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationMembersPage.tsx @@ -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 { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 33c3e8e5a5..5fc98c6e2d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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",