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",