From 3ff9069f7003cea84b6243cbd87303afab5f1097 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:52:10 +0000 Subject: [PATCH 01/17] feat: enable Coder Agents for organization members --- .../000585_remove_agents_access_role.down.sql | 13 + .../000585_remove_agents_access_role.up.sql | 11 + coderd/database/migrations/migrate_test.go | 50 ++++ coderd/database/querier_test.go | 12 +- coderd/exp_chats_acl_test.go | 4 +- coderd/exp_chats_model_config_list_test.go | 3 +- coderd/exp_chats_test.go | 169 +++++------- coderd/rbac/roles.go | 54 ++-- coderd/rbac/roles_test.go | 260 ++++++++---------- coderd/x/chatd/forced_mcp_test.go | 7 - codersdk/rbacroles.go | 1 - docs/ai-coder/agents/getting-started.md | 66 +---- docs/reference/glossary.md | 5 - enterprise/coderd/exp_chats_test.go | 20 +- enterprise/coderd/roles_test.go | 3 - site/site_test.go | 43 ++- site/src/api/typesGenerated.ts | 6 - .../dashboard/Navbar/NavbarView.stories.tsx | 4 +- .../modules/roles/RoleSelector.stories.tsx | 6 +- site/src/modules/roles/index.ts | 2 - .../components/AgentCreateForm.stories.tsx | 23 +- .../AgentsPage/components/AgentCreateForm.tsx | 3 +- .../components/ChatAccessDeniedAlert.tsx | 9 +- .../CustomRolesPageView.stories.tsx | 11 - site/src/testHelpers/entities.ts | 10 - 25 files changed, 325 insertions(+), 470 deletions(-) create mode 100644 coderd/database/migrations/000585_remove_agents_access_role.down.sql create mode 100644 coderd/database/migrations/000585_remove_agents_access_role.up.sql diff --git a/coderd/database/migrations/000585_remove_agents_access_role.down.sql b/coderd/database/migrations/000585_remove_agents_access_role.down.sql new file mode 100644 index 00000000000..668c33f4a5b --- /dev/null +++ b/coderd/database/migrations/000585_remove_agents_access_role.down.sql @@ -0,0 +1,13 @@ +-- Older binaries need agents-access unless another role grants chat access. +-- Exact prior grants cannot be reconstructed, so restore the role to all +-- current non-service-account memberships. +UPDATE organization_members om +SET roles = array_append(om.roles, 'agents-access') +FROM users u +WHERE u.id = om.user_id + AND NOT u.is_service_account + AND NOT ('agents-access' = ANY(om.roles)); + +-- Defaults are not restored: older binaries union default_org_member_roles +-- into service-account memberships too. Pre-upgrade explicit service-account +-- grants stay lost; an admin can re-grant them. diff --git a/coderd/database/migrations/000585_remove_agents_access_role.up.sql b/coderd/database/migrations/000585_remove_agents_access_role.up.sql new file mode 100644 index 00000000000..086d97045e9 --- /dev/null +++ b/coderd/database/migrations/000585_remove_agents_access_role.up.sql @@ -0,0 +1,11 @@ +UPDATE users +SET rbac_roles = array_remove(rbac_roles, 'agents-access') +WHERE 'agents-access' = ANY(rbac_roles); + +UPDATE organization_members +SET roles = array_remove(roles, 'agents-access') +WHERE 'agents-access' = ANY(roles); + +UPDATE organizations +SET default_org_member_roles = array_remove(default_org_member_roles, 'agents-access') +WHERE 'agents-access' = ANY(default_org_member_roles); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 53050bb8ed7..41b66a9aeda 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1191,6 +1191,56 @@ func TestMigration000475AgentsAccessOrgRole(t *testing.T) { ) } +func TestMigration000585RemoveAgentsAccessRole(t *testing.T) { + t.Parallel() + + const migrationVersion = 585 + + sqlDB := testSQLDB(t) + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more { + t.Fatalf("migration %d not found", migrationVersion) + } + if version == migrationVersion-1 { + break + } + } + + db := database.New(sqlDB) + user := dbgen.User(t, db, database.User{ + RBACRoles: []string{"auditor", "agents-access"}, + }) + org := dbgen.Organization(t, db, database.Organization{ + DefaultOrgMemberRoles: []string{"organization-workspace-access", "agents-access"}, + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: org.ID, + UserID: user.ID, + Roles: []string{"organization-auditor", "agents-access"}, + }) + + version, _, err := next() + require.NoError(t, err) + require.EqualValues(t, migrationVersion, version) + + ctx := testutil.Context(t, testutil.WaitLong) + var siteRoles, orgRoles, defaultRoles pq.StringArray + err = sqlDB.QueryRowContext(ctx, "SELECT rbac_roles FROM users WHERE id = $1", user.ID).Scan(&siteRoles) + require.NoError(t, err) + err = sqlDB.QueryRowContext(ctx, "SELECT roles FROM organization_members WHERE organization_id = $1 AND user_id = $2", org.ID, user.ID).Scan(&orgRoles) + require.NoError(t, err) + err = sqlDB.QueryRowContext(ctx, "SELECT default_org_member_roles FROM organizations WHERE id = $1", org.ID).Scan(&defaultRoles) + require.NoError(t, err) + + require.Equal(t, []string{"auditor"}, []string(siteRoles)) + require.Equal(t, []string{"organization-auditor"}, []string(orgRoles)) + require.Equal(t, []string{"organization-workspace-access"}, []string(defaultRoles)) +} + func TestMigration000504AIProvidersBackfill(t *testing.T) { t.Parallel() diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 39e84ae1b72..87d44e19039 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -1465,8 +1465,8 @@ func TestGetAuthorizedChats(t *testing.T) { org := dbgen.Organization(t, db, database.Organization{}) dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: owner.ID, OrganizationID: org.ID}) - dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: member.ID, OrganizationID: org.ID, Roles: []string{rbac.RoleAgentsAccess()}}) - dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: secondMember.ID, OrganizationID: org.ID, Roles: []string{rbac.RoleAgentsAccess()}}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: member.ID, OrganizationID: org.ID}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: secondMember.ID, OrganizationID: org.ID}) // Create FK dependencies: a chat provider and model config. _ = dbgen.ChatProvider(t, db, database.ChatProvider{ @@ -1653,7 +1653,7 @@ func TestGetAuthorizedChats(t *testing.T) { // Use a dedicated user for pagination to avoid interference // with the other parallel subtests. paginationUser := dbgen.User(t, db, database.User{}) - dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: paginationUser.ID, OrganizationID: org.ID, Roles: []string{rbac.RoleAgentsAccess()}}) + dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: paginationUser.ID, OrganizationID: org.ID}) for i := range 7 { dbgen.Chat(t, db, database.Chat{ OrganizationID: org.ID, @@ -1727,12 +1727,10 @@ func TestGetAuthorizedChatsACLSharing(t *testing.T) { dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: owner.ID, OrganizationID: org.ID, - Roles: []string{rbac.RoleAgentsAccess()}, }) dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: recipient.ID, OrganizationID: org.ID, - Roles: []string{rbac.RoleAgentsAccess()}, }) dbgen.ChatProvider(t, db, database.ChatProvider{Provider: "openai", DisplayName: "OpenAI"}) @@ -1845,12 +1843,10 @@ func TestGetAuthorizedChatsACLSharingGroupACL(t *testing.T) { dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: owner.ID, OrganizationID: org.ID, - Roles: []string{rbac.RoleAgentsAccess()}, }) dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: recipient.ID, OrganizationID: org.ID, - Roles: []string{rbac.RoleAgentsAccess()}, }) group := dbgen.Group(t, db, database.Group{OrganizationID: org.ID}) dbgen.GroupMember(t, db, database.GroupMemberTable{UserID: recipient.ID, GroupID: group.ID}) @@ -1949,12 +1945,10 @@ func TestGetAuthorizedChatsByChatFileIDACLSharing(t *testing.T) { dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: owner.ID, OrganizationID: org.ID, - Roles: []string{rbac.RoleAgentsAccess()}, }) dbgen.OrganizationMember(t, db, database.OrganizationMember{ UserID: recipient.ID, OrganizationID: org.ID, - Roles: []string{rbac.RoleAgentsAccess()}, }) dbgen.ChatProvider(t, db, database.ChatProvider{Provider: "openai", DisplayName: "OpenAI"}) diff --git a/coderd/exp_chats_acl_test.go b/coderd/exp_chats_acl_test.go index 837dd590e04..a4c6a790983 100644 --- a/coderd/exp_chats_acl_test.go +++ b/coderd/exp_chats_acl_test.go @@ -463,7 +463,7 @@ func TestListChatsSharedScope(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModel(t, client) - viewerClient, viewer := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + viewerClient, viewer := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) viewerClientExp := codersdk.NewExperimentalClient(viewerClient) sharedChat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -562,7 +562,7 @@ func TestChatSharingDisabled(t *testing.T) { }) firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModel(t, client) - viewerClient, viewer := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + viewerClient, viewer := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) viewerClientExp := codersdk.NewExperimentalClient(viewerClient) chat := dbgen.Chat(t, store, database.Chat{ diff --git a/coderd/exp_chats_model_config_list_test.go b/coderd/exp_chats_model_config_list_test.go index d746f9b0bdf..5554f2eca9f 100644 --- a/coderd/exp_chats_model_config_list_test.go +++ b/coderd/exp_chats_model_config_list_test.go @@ -189,13 +189,12 @@ func TestChatModelConfigListReadContracts(t *testing.T) { seesDenied bool }{ { - name: "AgentsAccess", + name: "Member", client: func(t *testing.T, _ context.Context) *codersdk.ExperimentalClient { rawClient, _ := coderdtest.CreateAnotherUser( t, client.Client, defaultOrg.ID, - rbac.ScopedRoleAgentsAccess(defaultOrg.ID), ) return codersdk.NewExperimentalClient(rawClient) }, diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index ed5e8a238a3..1c755cdf838 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -760,7 +760,7 @@ func TestPostChats(t *testing.T) { Name: database.EveryoneGroup, OrganizationID: org.ID, }) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) contextLimit := int64(4096) @@ -791,7 +791,7 @@ func TestPostChats(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client.Client) _ = createChatModel(t, client) org := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -822,7 +822,7 @@ func TestPostChats(t *testing.T) { }, func(params *database.InsertChatModelConfigParams) { params.Enabled = false }) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID, rbac.ScopedRoleAgentsAccess(org.ID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, org.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -845,7 +845,7 @@ func TestPostChats(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client.Client) defaultConfig := createChatModel(t, client) otherOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID, rbac.ScopedRoleAgentsAccess(otherOrg.ID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, otherOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -869,7 +869,7 @@ func TestPostChats(t *testing.T) { defaultConfig := createChatModel(t, client) chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) thirdOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) thirdConfig := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{ AIProviderID: uuid.NullUUID{UUID: defaultConfig.AIProviderID, Valid: true}, @@ -902,9 +902,7 @@ func TestPostChats(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModel(t, client) - // Use a member with agents-access instead of the owner to - // verify least-privilege access. - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -975,7 +973,7 @@ func TestPostChats(t *testing.T) { OrganizationID: secondOrg.ID, IsDefault: true, }) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -1011,7 +1009,7 @@ func TestPostChats(t *testing.T) { OrganizationID: secondOrg.ID, Enabled: true, }) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -1060,7 +1058,7 @@ func TestPostChats(t *testing.T) { }) require.NoError(t, err) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) _, err = memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -1115,7 +1113,7 @@ func TestPostChats(t *testing.T) { Enabled: true, }) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -1196,7 +1194,7 @@ func TestPostChats(t *testing.T) { OrganizationID: secondOrg.ID, IsDefault: true, }) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID, rbac.ScopedRoleAgentsAccess(secondOrg.ID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, secondOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -1214,7 +1212,7 @@ func TestPostChats(t *testing.T) { require.Equal(t, "Invalid IDs: "+thirdOrgConfig.ID.String(), sdkErr.Detail) }) - t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { + t.Run("MemberCanCreateChat", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -1222,20 +1220,20 @@ func TestPostChats(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModel(t, client) - // Member without agents-access should be denied. - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) - _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ OrganizationID: firstUser.OrganizationID, Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, - Text: "this should fail", + Text: "hello from an organization member", }, }, }) - requireSDKError(t, err, http.StatusForbidden) + require.NoError(t, err) + require.Equal(t, member.ID, chat.OwnerID) }) t.Run("WithReasoningEffort", func(t *testing.T) { @@ -1420,7 +1418,6 @@ func TestPostChats(t *testing.T) { t, client.Client, firstUser.OrganizationID, - rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), ) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -1453,7 +1450,6 @@ func TestPostChats(t *testing.T) { t, client.Client, firstUser.OrganizationID, - rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), ) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -1561,7 +1557,7 @@ func TestPostChats(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) adminClient, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ @@ -1598,7 +1594,6 @@ func TestPostChats(t *testing.T) { adminClient.Client, firstUser.OrganizationID, rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID), - rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), ) orgAdminClient := codersdk.NewExperimentalClient(orgAdminClientRaw) @@ -1767,7 +1762,7 @@ func TestPostChats(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModel(t, client) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) _, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -1789,7 +1784,7 @@ func TestPostChats(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModel(t, client) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) // Create a second organization via the database since the @@ -1870,7 +1865,7 @@ func TestChats_ForceOnMCPServerEnforced(t *testing.T) { // A regular member tampers with the request by clearing // mcp_server_ids (Cure53 CDM-02-010 reproduction). - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ @@ -1909,7 +1904,7 @@ func TestPostChats_ClientType(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) _ = createChatModel(t, client) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) newChat := func(t *testing.T, clientType codersdk.ChatClientType) codersdk.Chat { @@ -2014,7 +2009,7 @@ func TestListChats(t *testing.T) { }) require.NoError(t, err) - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) memberDBChat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, @@ -2088,7 +2083,7 @@ func TestListChats(t *testing.T) { modelConfig := createChatModel(t, client) ownerClientRaw, owner := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.RoleOwner()) ownerClient := codersdk.NewExperimentalClient(ownerClientRaw) - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) ownedChat := dbgen.Chat(t, db, database.Chat{ @@ -2164,18 +2159,13 @@ func TestListChats(t *testing.T) { require.NotContains(t, memberChatIDs, unsharedReadableChat.ID) }) - t.Run("OrgMemberWithoutAgentsAccessCannotAccessOwnChats", func(t *testing.T) { + t.Run("OrgMemberCanAccessOwnChats", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModel(t, client) - // Create a member without agents-access and insert a chat - // owned by them via system context. Without agents-access, - // the member has no ResourceChat permissions at all, so - // listing returns 0 chats (SQL auth filter) and getting - // a specific chat returns 404 (dbauthz wraps as not found). memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat := dbgen.Chat(t, db, database.Chat{ @@ -2185,18 +2175,15 @@ func TestListChats(t *testing.T) { Title: "member chat", }) - // Listing chats returns empty because the SQL auth - // filter excludes chats the member cannot read. chats, err := memberClient.ListChats(ctx, nil) require.NoError(t, err) - require.Len(t, chats, 0) + require.Len(t, chats, 1) + require.Equal(t, chat.ID, chats[0].ID) - // Getting a specific chat returns 404 because dbauthz - // wraps authorization failures as not-found. err = memberClient.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{ Title: ptr.Ref("new title"), }) - requireSDKError(t, err, http.StatusNotFound) + require.NoError(t, err) }) t.Run("Unauthenticated", func(t *testing.T) { @@ -2756,7 +2743,7 @@ func TestListChatModels(t *testing.T) { AIProviderID: uuid.NullUUID{UUID: provider.ID, Valid: true}, OrganizationID: firstUser.OrganizationID, Model: "private-" + uuid.NewString(), Enabled: true, GroupACL: database.ChatACL{}, }) - memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) response, err := codersdk.NewExperimentalClient(memberRaw).ChatModels(ctx, firstUser.OrganizationID) require.NoError(t, err) require.False(t, containsModel(response, privateConfig.ID)) @@ -2776,7 +2763,7 @@ func TestListChatModels(t *testing.T) { require.NoError(t, err) third, err := client.CreateChatModel(ctx, thirdOrg.ID, codersdk.CreateChatModelRequest{AIProviderID: &provider.ID, Model: "third-" + uuid.NewString(), ContextLimit: &contextLimit}) require.NoError(t, err) - memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID) response, err := codersdk.NewExperimentalClient(memberRaw).ChatModels(ctx, chatOrg.ID) require.NoError(t, err) require.True(t, containsModel(response, local.ID)) @@ -2882,7 +2869,7 @@ func TestListChatModels(t *testing.T) { client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) provider := createAIProviderForTest(t, client, "anthropic", "") - otherRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + otherRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) other := codersdk.NewExperimentalClient(otherRaw) _, err := client.UpsertUserAIProviderKey(ctx, "me", provider.ID, codersdk.CreateUserAIProviderKeyRequest{APIKey: "owner-key"}) require.NoError(t, err) @@ -5817,7 +5804,7 @@ func TestUpdateChatModel(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) adminClient := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) - memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) modelConfig := createChatModel(t, adminClient) @@ -6507,7 +6494,7 @@ func TestGetChat(t *testing.T) { }) require.NoError(t, err) - otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) otherClient := codersdk.NewExperimentalClient(otherClientRaw) _, err = otherClient.GetChat(ctx, createdChat.ID) requireSDKError(t, err, http.StatusNotFound) @@ -8503,7 +8490,7 @@ func TestPostChatMessages(t *testing.T) { IsDefault: true, OrganizationID: chatOrg.ID, }) - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: chatOrg.ID, @@ -8533,7 +8520,7 @@ func TestPostChatMessages(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client.Client) foreignConfig := createChatModel(t, client) chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: chatOrg.ID, @@ -8588,7 +8575,7 @@ func TestPostChatMessages(t *testing.T) { require.Equal(t, "Ask an organization administrator to configure and enable a chat model.", sdkErr.Detail) }) - t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { + t.Run("MemberCanCreateChatMessage", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -8596,11 +8583,6 @@ func TestPostChatMessages(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModel(t, client) - // Create a member without agents-access and insert a - // chat owned by them via system context. Without - // agents-access the member has no ResourceChat - // permissions, so the ChatParam middleware returns 404 - // before the handler can check agents-access. memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat := dbgen.Chat(t, db, database.Chat{ @@ -8614,11 +8596,11 @@ func TestPostChatMessages(t *testing.T) { Content: []codersdk.ChatInputPart{ { Type: codersdk.ChatInputPartTypeText, - Text: "this should fail", + Text: "hello from member", }, }, }) - requireSDKError(t, err, http.StatusNotFound) + require.NoError(t, err) }) t.Run("EmptyText", func(t *testing.T) { @@ -10232,7 +10214,7 @@ func TestPatchChatMessage(t *testing.T) { IsDefault: true, OrganizationID: chatOrg.ID, }) - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: chatOrg.ID, @@ -10270,7 +10252,7 @@ func TestPatchChatMessage(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client.Client) foreignConfig := createChatModel(t, client) chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: chatOrg.ID, @@ -11822,7 +11804,7 @@ func TestGetChatDiffStatus(t *testing.T) { }) require.NoError(t, err) - otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) otherClient := codersdk.NewExperimentalClient(otherClientRaw) _, err = otherClient.GetChat(ctx, createdChat.ID) requireSDKError(t, err, http.StatusNotFound) @@ -11931,7 +11913,7 @@ func TestGetChatDiffContents(t *testing.T) { }) require.NoError(t, err) - otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) otherClient := codersdk.NewExperimentalClient(otherClientRaw) _, err = otherClient.GetChatDiffContents(ctx, createdChat.ID) requireSDKError(t, err, http.StatusNotFound) @@ -12092,7 +12074,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client.Client) foreignConfig := createChatModel(t, client) chatOrg := dbgen.Organization(t, db, database.Organization{IsDefault: false}) - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID, rbac.ScopedRoleAgentsAccess(chatOrg.ID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, chatOrg.ID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: chatOrg.ID, @@ -12160,7 +12142,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { require.Contains(t, sdkErr.Detail, "invalid syntax") }) - t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { + t.Run("MemberCanPromoteOwnQueuedMessage", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -12168,21 +12150,17 @@ func TestPromoteChatQueuedMessage(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModel(t, client) - // Create a member without agents-access. Without - // agents-access the member has no ResourceChat - // permissions, so the ChatParam middleware returns 404 - // before the handler can check agents-access. memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat := dbgen.Chat(t, db, database.Chat{ OrganizationID: firstUser.OrganizationID, OwnerID: member.ID, LastModelConfigID: modelConfig.ID, - Title: "promote queued no agents access", + Title: "promote queued as member", }) queuedContent, err := json.Marshal([]codersdk.ChatMessagePart{ - codersdk.ChatMessageText("queued message no agents access"), + codersdk.ChatMessageText("queued message promoted by member"), }) require.NoError(t, err) queuedMessage := insertTestChatQueuedMessage(ctx, t, db, chat.ID, queuedContent, chat.LastModelConfigID) @@ -12195,7 +12173,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { ) require.NoError(t, err) defer promoteRes.Body.Close() - require.Equal(t, http.StatusNotFound, promoteRes.StatusCode) + require.Equal(t, http.StatusAccepted, promoteRes.StatusCode) }) t.Run("ArchivedChat", func(t *testing.T) { @@ -12690,20 +12668,20 @@ This arrived as octet-stream. requireSDKError(t, err, http.StatusUnauthorized) }) - t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { + t.Run("MemberCanUploadChatFile", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) client := newChatClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - // Member without agents-access should be denied. memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) - _, err := memberClient.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data)) - requireSDKError(t, err, http.StatusForbidden) + uploaded, err := memberClient.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data)) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, uploaded.ID) }) } @@ -12887,7 +12865,7 @@ func TestGetChatFile(t *testing.T) { uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "image/png", "test.png", bytes.NewReader(data)) require.NoError(t, err) - otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) otherClient := codersdk.NewExperimentalClient(otherClientRaw) _, _, err = otherClient.GetChatFile(ctx, uploaded.ID) requireSDKError(t, err, http.StatusNotFound) @@ -13033,7 +13011,7 @@ func TestChatFileDownloadURL(t *testing.T) { client, _ := newClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) uploaded, _ := uploadPNG(t, ctx, client, firstUser.OrganizationID, "owner.png") - otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) otherClient := codersdk.NewExperimentalClient(otherClientRaw) _, err := otherClient.ChatFileDownloadURL(ctx, uploaded.ID) @@ -13056,7 +13034,7 @@ func TestChatFileDownloadURL(t *testing.T) { }) require.NoError(t, err) - memberRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberRaw) err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{ UserRoles: map[string]codersdk.ChatRole{member.ID.String(): codersdk.ChatRoleRead}, @@ -13084,7 +13062,7 @@ func TestChatFileDownloadURL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, _ := newClient(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - memberRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberRaw) uploaded, _ := uploadPNG(t, ctx, memberClient, firstUser.OrganizationID, "suspended.png") @@ -13459,9 +13437,7 @@ func TestGetChatCost(t *testing.T) { client, db := newChatClientWithDatabase(t) firstUser := coderdtest.CreateFirstUser(t, client.Client) - // agents-access is what grants ResourceChat; plain members cannot - // create or read chats at all, so they never reach this endpoint. - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) modelConfig := createChatModel(t, client) everyoneGroup := uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true} @@ -13627,21 +13603,13 @@ func TestWatchChatGitAuthz(t *testing.T) { }) require.NoError(t, err) - // Demote adminClient via the second owner. template-admin grants - // workspace:read (site) but not workspace:ssh or - // workspace:application_connect; agents-access preserves - // chat:create|read|update on chats the user owns, so the - // demoted user still passes ExtractChatParam for their own chat. + // Demote adminClient to template-admin. Membership still allows reading this + // chat, so ExtractChatParam passes before workspace authorization fails. _, err = secondAdminClient.UpdateUserRoles(ctx, firstUser.UserID.String(), codersdk.UpdateRoles{ Roles: []string{rbac.RoleTemplateAdmin().String()}, }) require.NoError(t, err) - _, err = secondAdminClient.UpdateOrganizationMemberRoles(ctx, firstUser.OrganizationID, firstUser.UserID.String(), codersdk.UpdateRoles{ - Roles: []string{rbac.RoleAgentsAccess()}, - }) - require.NoError(t, err) - res, err := adminClient.Request( ctx, http.MethodGet, @@ -15708,7 +15676,6 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { t, adminClient.Client, firstUser.OrganizationID, - rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), ) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -15835,7 +15802,6 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { t, adminClient.Client, org.ID, - rbac.ScopedRoleAgentsAccess(org.ID), ) otherClient := codersdk.NewExperimentalClient(otherClientRaw) @@ -16235,7 +16201,7 @@ func TestChatDebugRuns(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModel(t, client) - memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) chat := dbgen.Chat(t, db, database.Chat{ @@ -16347,7 +16313,7 @@ func TestChatDebugRuns(t *testing.T) { seedChatDebugRun(ctx, t, db, chat.ID, time.Now().UTC()) - otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + otherClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) otherClient := codersdk.NewExperimentalClient(otherClientRaw) _, err := otherClient.GetChatDebugRuns(ctx, chat.ID) @@ -17653,7 +17619,6 @@ func TestSubmitToolResults(t *testing.T) { // to user A's chat. otherClientRaw, _ := coderdtest.CreateAnotherUser( t, client.Client, user.OrganizationID, - rbac.ScopedRoleAgentsAccess(user.OrganizationID), ) otherClient := codersdk.NewExperimentalClient(otherClientRaw) @@ -17665,7 +17630,7 @@ func TestSubmitToolResults(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) }) - t.Run("MemberWithoutAgentsAccess", func(t *testing.T) { + t.Run("MemberCanSubmitToolResults", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) @@ -17673,24 +17638,20 @@ func TestSubmitToolResults(t *testing.T) { firstUser := coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModel(t, client) - // Create a member without agents-access. Without - // agents-access the member has no ResourceChat - // permissions, so the ChatParam middleware returns 404 - // before the handler can check agents-access. memberClientRaw, member := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID) memberClient := codersdk.NewExperimentalClient(memberClientRaw) const toolName = "my_dynamic_tool" - toolCallIDs := []string{"call_noaccess"} + toolCallIDs := []string{"call_member"} chat := setupRequiresAction(ctx, t, db, member.ID, firstUser.OrganizationID, modelConfig.ID, toolName, toolCallIDs) err := memberClient.SubmitToolResults(ctx, chat.ID, codersdk.SubmitToolResultsRequest{ Results: []codersdk.ToolResult{ - {ToolCallID: "call_noaccess", Output: json.RawMessage(`"should fail"`)}, + {ToolCallID: "call_member", Output: json.RawMessage(`"result"`)}, }, }) - requireSDKError(t, err, http.StatusNotFound) + require.NoError(t, err) }) t.Run("ArchivedChat", func(t *testing.T) { @@ -18282,7 +18243,6 @@ func TestChatReadOnlySharedWriteHandlers(t *testing.T) { t, ownerClient.Client, owner.OrganizationID, - rbac.ScopedRoleAgentsAccess(owner.OrganizationID), ) sharedClient = codersdk.NewExperimentalClient(sharedRaw) @@ -18514,7 +18474,6 @@ func TestChatOwnerOnlyWriteHandlers(t *testing.T) { ownerClient.Client, firstUser.OrganizationID, rbac.ScopedRoleOrgAdmin(firstUser.OrganizationID), - rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), ) adminClient = codersdk.NewExperimentalClient(orgAdminRaw) return ownerClient, adminClient, chat, db diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 3aceb983238..6dd9d79260b 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -22,7 +22,6 @@ const ( templateAdmin string = "template-admin" userAdmin string = "user-admin" auditor string = "auditor" - agentsAccess string = "agents-access" // customSiteRole is a placeholder for all custom site roles. // This is used for what roles can assign other roles. // TODO: Make this more dynamic to allow other roles to grant. @@ -143,7 +142,6 @@ func RoleTemplateAdmin() RoleIdentifier { return RoleIdentifier{Name: templateAd func RoleUserAdmin() RoleIdentifier { return RoleIdentifier{Name: userAdmin} } func RoleMember() RoleIdentifier { return RoleIdentifier{Name: member} } func RoleAuditor() RoleIdentifier { return RoleIdentifier{Name: auditor} } -func RoleAgentsAccess() string { return agentsAccess } func RoleOrgAdmin() string { return orgAdmin @@ -203,10 +201,6 @@ func ScopedRoleOrgWorkspaceCreationBan(organizationID uuid.UUID) RoleIdentifier return RoleIdentifier{Name: RoleOrgWorkspaceCreationBan(), OrganizationID: organizationID} } -func ScopedRoleAgentsAccess(organizationID uuid.UUID) RoleIdentifier { - return RoleIdentifier{Name: RoleAgentsAccess(), OrganizationID: organizationID} -} - func ScopedRoleOrgWorkspaceAccess(organizationID uuid.UUID) RoleIdentifier { return RoleIdentifier{Name: RoleOrgWorkspaceAccess(), OrganizationID: organizationID} } @@ -349,6 +343,12 @@ type RoleOptions struct { // 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 { + // Retired built-in role names 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. + if name == "agents-access" { + return true + } _, ok := loadBuiltinRoles()[name] return ok } @@ -729,29 +729,6 @@ 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) @@ -778,7 +755,6 @@ var assignRoles = map[string]map[string]bool{ userAdmin: true, customSiteRole: true, customOrganizationRole: true, - agentsAccess: true, }, owner: { owner: true, @@ -795,13 +771,11 @@ var assignRoles = map[string]map[string]bool{ userAdmin: true, customSiteRole: true, customOrganizationRole: true, - agentsAccess: true, }, userAdmin: { member: true, orgMember: true, orgWorkspaceAccess: true, - agentsAccess: true, }, orgAdmin: { orgAdmin: true, @@ -812,12 +786,10 @@ var assignRoles = map[string]map[string]bool{ orgWorkspaceCreationBan: true, orgWorkspaceAccess: true, customOrganizationRole: true, - agentsAccess: true, }, orgUserAdmin: { orgMember: true, orgWorkspaceAccess: true, - agentsAccess: true, }, } @@ -1188,8 +1160,6 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { }) } - // Chat access requires the agents-access role and is intentionally - // not granted in the floor. memberPerms := Permissions(map[string][]policy.Action{ // Read-self org-member record. ResourceOrganizationMember.Type: {policy.ActionRead}, @@ -1211,6 +1181,14 @@ 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 { @@ -1258,6 +1236,7 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { }) } + // Chat permissions are intentionally omitted for service accounts. memberPerms := Permissions(map[string][]policy.Action{ // Read-self org-member record. ResourceOrganizationMember.Type: {policy.ActionRead}, @@ -1268,8 +1247,7 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { // Service accounts can create and update AI Bridge interceptions // they initiate (dbauthz layer sets WithOwner(InitiatorID)) but - // cannot read them back. Chat access requires the agents-access - // role and is intentionally not granted here. + // cannot read them back. ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate}, // Own session tokens and workspace agent auth keys. diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 060ff175b05..7d8b9b2cd8b 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -126,18 +126,26 @@ func TestChatSharingPermissions(t *testing.T) { userID := uuid.NewString() resource := rbac.ResourceChat.WithID(uuid.New()).InOrg(orgID).WithOwner(userID) - authorizeAgentsAccessUser := func(t *testing.T) error { + authorizeOrgMember := func(t *testing.T) error { t.Helper() memberRole, err := rbac.RoleByName(rbac.RoleMember()) require.NoError(t, err) - agentsRole, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID)) - require.NoError(t, err) + perms := rbac.OrgMemberPermissions(rbac.OrgSettings{}) + orgMemberRole := rbac.Role{ + Identifier: rbac.ScopedRoleOrgMember(orgID), + ByOrgID: map[string]rbac.OrgPermissions{ + orgID.String(): { + Org: perms.Org, + Member: perms.Member, + }, + }, + } auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry()) return auth.Authorize(context.Background(), rbac.Subject{ ID: userID, - Roles: rbac.Roles{memberRole, agentsRole}, + Roles: rbac.Roles{memberRole, orgMemberRole}, Scope: rbac.ScopeAll, }, policy.ActionShare, resource) } @@ -149,7 +157,7 @@ func TestChatSharingPermissions(t *testing.T) { memberRole, err := rbac.RoleByName(rbac.RoleMember()) require.NoError(t, err) assert.False(t, permissionGranted(memberRole.Site, target)) - require.NoError(t, authorizeAgentsAccessUser(t)) + require.NoError(t, authorizeOrgMember(t)) }) t.Run("Disabled", func(t *testing.T) { @@ -162,7 +170,7 @@ func TestChatSharingPermissions(t *testing.T) { require.NoError(t, err) assert.True(t, permissionGranted(memberRole.Site, target)) - err = authorizeAgentsAccessUser(t) + err = authorizeOrgMember(t) require.ErrorAs(t, err, &rbac.UnauthorizedError{}) }) } @@ -226,10 +234,12 @@ 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") sa := rbac.OrgServiceAccountPermissions(orgSettings).Member require.False(t, hasResource(sa, rbac.ResourceWorkspace.Type), "organization-service-account must not grant workspace permissions") require.True(t, hasResource(sa, rbac.ResourceOrganizationMember.Type), "organization-service-account should grant read-self") + require.False(t, hasResource(sa, rbac.ResourceChat.Type), "organization-service-account must not grant chat access") // The registered organization-workspace-access role is the grant // path for workspace permissions. @@ -288,21 +298,6 @@ func TestRolePermissions(t *testing.T) { orgUserAdmin := authSubject{Name: "org_user_admin", Actor: rbac.Subject{ID: templateAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgUserAdmin(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()} orgTemplateAdmin := authSubject{Name: "org_template_admin", Actor: rbac.Subject{ID: userAdminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgTemplateAdmin(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()} orgAdminBanWorkspace := authSubject{Name: "org_admin_workspace_ban", Actor: rbac.Subject{ID: adminID.String(), Roles: rbac.RoleIdentifiers{rbac.RoleMember(), rbac.ScopedRoleOrgAdmin(orgID), rbac.ScopedRoleOrgWorkspaceCreationBan(orgID)}, Scope: rbac.ScopeAll}.WithCachedASTValue()} - agentsAccessUser := func() authSubject { - memberRole, err := rbac.RoleByName(rbac.RoleMember()) - require.NoError(t, err) - agentsRole, err := rbac.RoleByName(rbac.ScopedRoleAgentsAccess(orgID)) - require.NoError(t, err) - return authSubject{ - Name: "agents_access", - Actor: rbac.Subject{ - ID: currentUser.String(), - Roles: rbac.Roles{memberRole, agentsRole}, - Scope: rbac.ScopeAll, - }.WithCachedASTValue(), - } - }() - orgWorkspaceAccessUser := func() authSubject { memberRole, err := rbac.RoleByName(rbac.RoleMember()) require.NoError(t, err) @@ -357,7 +352,7 @@ func TestRolePermissions(t *testing.T) { // requiredSubjects are required to be asserted in each test case. This is // to make sure one is not forgotten. requiredSubjects := []authSubject{ - memberMe, owner, agentsAccessUser, orgWorkspaceAccessUser, + memberMe, owner, orgWorkspaceAccessUser, orgAdmin, otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin, templateAdmin, userAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, } @@ -380,7 +375,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceUserObject(currentUser), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgUserAdmin, otherOrgAdmin, otherOrgUserAdmin, orgAdmin, orgWorkspaceAccessUser}, + true: {owner, memberMe, templateAdmin, userAdmin, orgUserAdmin, otherOrgAdmin, otherOrgUserAdmin, orgAdmin, orgWorkspaceAccessUser}, false: { orgTemplateAdmin, orgAuditor, otherOrgAuditor, otherOrgTemplateAdmin, @@ -393,7 +388,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceUser, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, userAdmin}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, orgWorkspaceAccessUser}, }, }, { @@ -403,7 +398,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin, orgAdminBanWorkspace, orgWorkspaceAccessUser}, - false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgAuditor, orgUserAdmin}, + false: {setOtherOrg, memberMe, userAdmin, orgAuditor, orgUserAdmin}, }, }, { @@ -413,7 +408,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, orgAdminBanWorkspace, orgWorkspaceAccessUser}, - false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, { @@ -423,7 +418,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, orgWorkspaceAccessUser}, - false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace}, + false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace}, }, }, { @@ -433,7 +428,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspace.InOrg(orgID).WithOwner(policy.WildcardSymbol), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin}, - false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgWorkspaceAccessUser}, }, }, { @@ -443,7 +438,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgWorkspaceAccessUser}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin}, }, }, { @@ -453,7 +448,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgWorkspaceAccessUser}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin}, }, }, { @@ -462,7 +457,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, orgWorkspaceAccessUser}, - false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace}, + false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace}, }, }, { @@ -471,7 +466,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspace.WithID(workspaceID).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, orgAdminBanWorkspace, orgWorkspaceAccessUser}, - false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, { @@ -484,7 +479,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, orgAdminBanWorkspace, orgWorkspaceAccessUser}, false: { - memberMe, agentsAccessUser, setOtherOrg, + memberMe, setOtherOrg, templateAdmin, userAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, }, @@ -501,8 +496,7 @@ func TestRolePermissions(t *testing.T) { true: {}, false: { orgAdmin, owner, setOtherOrg, - userAdmin, memberMe, agentsAccessUser, - templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, + userAdmin, memberMe, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgAdminBanWorkspace, orgWorkspaceAccessUser, }, @@ -514,7 +508,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceTemplate.WithID(templateID).InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin}, - false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, agentsAccessUser, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgUserAdmin, orgAuditor, memberMe, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -523,7 +517,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceTemplate.InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAuditor, orgAdmin, templateAdmin, orgTemplateAdmin}, - false: {setOtherOrg, orgUserAdmin, memberMe, agentsAccessUser, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgUserAdmin, memberMe, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -534,7 +528,7 @@ func TestRolePermissions(t *testing.T) { }), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin}, - false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, agentsAccessUser, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -545,7 +539,7 @@ func TestRolePermissions(t *testing.T) { true: {owner, templateAdmin}, // Org template admins can only read org scoped files. // File scope is currently not org scoped :cry: - false: {setOtherOrg, orgTemplateAdmin, orgAdmin, memberMe, agentsAccessUser, userAdmin, orgAuditor, orgUserAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgTemplateAdmin, orgAdmin, memberMe, userAdmin, orgAuditor, orgUserAdmin, orgWorkspaceAccessUser}, }, }, { @@ -553,7 +547,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionCreate, policy.ActionRead}, Resource: rbac.ResourceFile.WithID(fileID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, memberMe, agentsAccessUser, templateAdmin, orgWorkspaceAccessUser}, + true: {owner, memberMe, templateAdmin, orgWorkspaceAccessUser}, false: {setOtherOrg, setOrgNotMe, userAdmin}, }, }, @@ -563,7 +557,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganization, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -572,7 +566,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganization.WithID(orgID).InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin}, - false: {setOtherOrg, orgTemplateAdmin, orgUserAdmin, orgAuditor, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgTemplateAdmin, orgUserAdmin, orgAuditor, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -581,7 +575,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganization.WithID(orgID).InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin, auditor, orgAuditor, userAdmin, orgUserAdmin}, - false: {setOtherOrg, memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, orgWorkspaceAccessUser}, }, }, { @@ -590,7 +584,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceAssignOrgRole, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, userAdmin, memberMe, agentsAccessUser, templateAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, userAdmin, memberMe, templateAdmin, orgWorkspaceAccessUser}, }, }, { @@ -599,7 +593,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceAssignRole, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, userAdmin}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, orgWorkspaceAccessUser}, }, }, { @@ -607,7 +601,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceAssignRole, AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {setOtherOrg, setOrgNotMe, owner, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + true: {setOtherOrg, setOrgNotMe, owner, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, false: {}, }, }, @@ -617,7 +611,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceAssignOrgRole.InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, userAdmin, orgUserAdmin}, - false: {setOtherOrg, memberMe, agentsAccessUser, templateAdmin, orgTemplateAdmin, orgAuditor, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, templateAdmin, orgTemplateAdmin, orgAuditor, orgWorkspaceAccessUser}, }, }, { @@ -626,7 +620,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceAssignOrgRole.InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin}, - false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -635,7 +629,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceAssignOrgRole.InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, orgUserAdmin, userAdmin, templateAdmin}, - false: {setOtherOrg, memberMe, agentsAccessUser, orgAuditor, orgTemplateAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, orgAuditor, orgTemplateAdmin, orgWorkspaceAccessUser}, }, }, { @@ -643,7 +637,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete, policy.ActionUpdate}, Resource: rbac.ResourceApiKey.WithID(apiKeyID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + true: {owner, memberMe, orgWorkspaceAccessUser}, false: {setOtherOrg, setOrgNotMe, templateAdmin, userAdmin}, }, }, @@ -655,7 +649,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceInboxNotification.WithID(uuid.New()).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin}, - false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, templateAdmin, userAdmin, memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgUserAdmin, orgTemplateAdmin, orgAuditor, templateAdmin, userAdmin, memberMe, orgWorkspaceAccessUser}, }, }, { @@ -663,7 +657,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionReadPersonal, policy.ActionUpdatePersonal}, Resource: rbac.ResourceUserObject(currentUser), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, memberMe, agentsAccessUser, userAdmin, orgWorkspaceAccessUser}, + true: {owner, memberMe, userAdmin, orgWorkspaceAccessUser}, false: {setOtherOrg, setOrgNotMe, templateAdmin}, }, }, @@ -673,7 +667,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, userAdmin, orgUserAdmin}, - false: {setOtherOrg, orgTemplateAdmin, orgAuditor, memberMe, agentsAccessUser, templateAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgTemplateAdmin, orgAuditor, memberMe, templateAdmin, orgWorkspaceAccessUser}, }, }, { @@ -682,7 +676,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgUserAdmin, orgTemplateAdmin}, - false: {memberMe, agentsAccessUser, setOtherOrg, orgWorkspaceAccessUser}, + false: {memberMe, setOtherOrg, orgWorkspaceAccessUser}, }, }, { @@ -694,7 +688,7 @@ func TestRolePermissions(t *testing.T) { }), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, orgAdmin, templateAdmin, orgUserAdmin, orgTemplateAdmin, orgAuditor, agentsAccessUser, orgWorkspaceAccessUser}, + true: {owner, orgAdmin, templateAdmin, orgUserAdmin, orgTemplateAdmin, orgAuditor, orgWorkspaceAccessUser}, false: {setOtherOrg, memberMe, userAdmin}, }, }, @@ -708,7 +702,7 @@ func TestRolePermissions(t *testing.T) { }), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, userAdmin, orgUserAdmin}, - false: {setOtherOrg, memberMe, agentsAccessUser, templateAdmin, orgTemplateAdmin, orgAuditor, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, templateAdmin, orgTemplateAdmin, orgAuditor, orgWorkspaceAccessUser}, }, }, { @@ -721,7 +715,7 @@ func TestRolePermissions(t *testing.T) { }), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, - false: {setOtherOrg, memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, orgWorkspaceAccessUser}, }, }, { @@ -730,7 +724,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceGroupMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin}, - false: {setOtherOrg, memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, orgWorkspaceAccessUser}, }, }, { @@ -739,7 +733,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceGroupMember.WithID(adminID).InOrg(orgID).WithOwner(adminID.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAuditor, orgAdmin, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin}, - false: {setOtherOrg, memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, orgWorkspaceAccessUser}, }, }, { @@ -748,7 +742,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {orgAdmin, owner, templateAdmin, orgTemplateAdmin, orgWorkspaceAccessUser}, - false: {setOtherOrg, userAdmin, memberMe, agentsAccessUser, orgUserAdmin, orgAuditor}, + false: {setOtherOrg, userAdmin, memberMe, orgUserAdmin, orgAuditor}, }, }, { @@ -757,7 +751,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {orgAdmin, owner, orgWorkspaceAccessUser}, - false: {setOtherOrg, userAdmin, memberMe, agentsAccessUser, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + false: {setOtherOrg, userAdmin, memberMe, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, { @@ -766,7 +760,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspaceDormant.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, userAdmin, owner, templateAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, userAdmin, owner, templateAdmin, orgWorkspaceAccessUser}, }, }, { @@ -775,7 +769,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspace.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, orgWorkspaceAccessUser}, - false: {setOtherOrg, userAdmin, templateAdmin, memberMe, agentsAccessUser, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + false: {setOtherOrg, userAdmin, templateAdmin, memberMe, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, { @@ -784,7 +778,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourcePrebuiltWorkspace.WithID(uuid.New()).InOrg(orgID).WithOwner(database.PrebuildsSystemUserID.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, templateAdmin, orgTemplateAdmin}, - false: {setOtherOrg, userAdmin, memberMe, agentsAccessUser, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, + false: {setOtherOrg, userAdmin, memberMe, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, }, }, { @@ -793,7 +787,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceTask.WithID(uuid.New()).InOrg(orgID).WithOwner(memberMe.Actor.ID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, orgWorkspaceAccessUser}, - false: {setOtherOrg, userAdmin, templateAdmin, memberMe, agentsAccessUser, orgTemplateAdmin, orgUserAdmin, orgAuditor}, + false: {setOtherOrg, userAdmin, templateAdmin, memberMe, orgTemplateAdmin, orgUserAdmin, orgAuditor}, }, }, // Some admin style resources @@ -803,7 +797,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceLicense, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -812,7 +806,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceDeploymentStats, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -821,7 +815,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceDeploymentConfig, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -831,7 +825,7 @@ func TestRolePermissions(t *testing.T) { orgID.String(): {policy.ActionRead}, }), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, orgAdmin, orgAuditor, orgMemberMe, agentsAccessUser, orgWorkspaceAccessUser, orgUserAdmin, orgTemplateAdmin}, + true: {owner, orgAdmin, orgAuditor, orgMemberMe, orgWorkspaceAccessUser, orgUserAdmin, orgTemplateAdmin}, false: {setOtherOrg, memberMe, templateAdmin, userAdmin, auditor}, }, }, @@ -841,7 +835,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceMCPServerConfig.WithID(uuid.New()).InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin}, - false: {setOtherOrg, orgAuditor, auditor, orgMemberMe, memberMe, agentsAccessUser, orgWorkspaceAccessUser, orgUserAdmin, orgTemplateAdmin, templateAdmin, userAdmin}, + false: {setOtherOrg, orgAuditor, auditor, orgMemberMe, memberMe, orgWorkspaceAccessUser, orgUserAdmin, orgTemplateAdmin, templateAdmin, userAdmin}, }, }, { @@ -850,7 +844,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceDebugInfo, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -859,7 +853,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceReplicas, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -868,7 +862,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceTailnetCoordinator, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -877,7 +871,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceAuditLog, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -886,7 +880,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceProvisionerDaemon.InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, templateAdmin, orgAdmin, orgTemplateAdmin}, - false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, agentsAccessUser, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, orgAuditor, orgUserAdmin, memberMe, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -895,7 +889,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceProvisionerDaemon.InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, templateAdmin, orgAdmin, orgTemplateAdmin}, - false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgAuditor, orgUserAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, userAdmin, orgAuditor, orgUserAdmin, orgWorkspaceAccessUser}, }, }, { @@ -904,7 +898,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceProvisionerDaemon.WithOwner(currentUser.String()).InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, templateAdmin, orgTemplateAdmin, orgAdmin, orgWorkspaceAccessUser}, - false: {setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgUserAdmin, orgAuditor}, + false: {setOtherOrg, memberMe, userAdmin, orgUserAdmin, orgAuditor}, }, }, { @@ -913,7 +907,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceProvisionerDaemon.WithOwner(currentUser.String()).InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, templateAdmin, orgTemplateAdmin, orgAdmin}, - false: {orgWorkspaceAccessUser, setOtherOrg, memberMe, agentsAccessUser, userAdmin, orgUserAdmin, orgAuditor}, + false: {orgWorkspaceAccessUser, setOtherOrg, memberMe, userAdmin, orgUserAdmin, orgAuditor}, }, }, { @@ -922,7 +916,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceProvisionerJobs.InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgTemplateAdmin, orgAdmin}, - false: {setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, templateAdmin, userAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, }, }, { @@ -931,7 +925,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceSystem, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -940,7 +934,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOauth2App, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -948,7 +942,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceOauth2App, AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + true: {owner, setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, false: {}, }, }, @@ -958,7 +952,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOauth2AppSecret, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -967,7 +961,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceOauth2AppCodeToken, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -976,7 +970,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspaceProxy, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -984,7 +978,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead}, Resource: rbac.ResourceWorkspaceProxy, AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + true: {owner, setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, false: {}, }, }, @@ -994,7 +988,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceWorkspaceBuildOrchestration.InOrg(orgID), AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOrgNotMe, setOtherOrg, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOrgNotMe, setOtherOrg, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -1004,7 +998,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate}, Resource: rbac.ResourceNotificationPreference.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {orgWorkspaceAccessUser, memberMe, agentsAccessUser, owner}, + true: {orgWorkspaceAccessUser, memberMe, owner}, false: { userAdmin, orgUserAdmin, templateAdmin, orgAuditor, orgTemplateAdmin, @@ -1021,7 +1015,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, false: { - orgWorkspaceAccessUser, memberMe, agentsAccessUser, userAdmin, orgUserAdmin, templateAdmin, + orgWorkspaceAccessUser, memberMe, userAdmin, orgUserAdmin, templateAdmin, orgAuditor, orgTemplateAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, orgAdmin, otherOrgAdmin, @@ -1035,8 +1029,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, false: { - memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1054,7 +1047,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, false: { - orgWorkspaceAccessUser, memberMe, agentsAccessUser, templateAdmin, orgUserAdmin, userAdmin, + orgWorkspaceAccessUser, memberMe, templateAdmin, orgUserAdmin, userAdmin, orgAdmin, orgAuditor, orgTemplateAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, otherOrgAdmin, @@ -1067,7 +1060,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionDelete}, Resource: rbac.ResourceWebpushSubscription.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + true: {owner, memberMe, orgWorkspaceAccessUser}, false: {orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin}, }, }, @@ -1079,7 +1072,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, userAdmin, orgAdmin, otherOrgAdmin, orgUserAdmin, otherOrgUserAdmin}, false: { - memberMe, agentsAccessUser, templateAdmin, + memberMe, templateAdmin, orgTemplateAdmin, orgAuditor, otherOrgAuditor, otherOrgTemplateAdmin, orgWorkspaceAccessUser, @@ -1093,8 +1086,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, orgAdmin, otherOrgAdmin}, false: { - userAdmin, memberMe, agentsAccessUser, - orgAuditor, orgUserAdmin, + userAdmin, memberMe, orgAuditor, orgUserAdmin, otherOrgAuditor, otherOrgUserAdmin, orgWorkspaceAccessUser, }, @@ -1107,7 +1099,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, otherOrgAdmin, orgWorkspaceAccessUser}, false: { - memberMe, agentsAccessUser, userAdmin, templateAdmin, + memberMe, userAdmin, templateAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, }, @@ -1119,7 +1111,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceCryptoKey, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -1130,7 +1122,7 @@ func TestRolePermissions(t *testing.T) { true: {owner, orgAdmin, orgUserAdmin, userAdmin}, false: { otherOrgAdmin, - memberMe, agentsAccessUser, templateAdmin, + memberMe, templateAdmin, orgAuditor, orgTemplateAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, orgWorkspaceAccessUser, @@ -1146,7 +1138,7 @@ func TestRolePermissions(t *testing.T) { false: { orgAdmin, orgUserAdmin, otherOrgAdmin, - memberMe, agentsAccessUser, templateAdmin, + memberMe, templateAdmin, orgAuditor, orgTemplateAdmin, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, orgWorkspaceAccessUser, @@ -1160,8 +1152,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, false: { - memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1176,8 +1167,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, false: { - memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1191,7 +1181,7 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceConnectionLog, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, // Only the user themselves can access their own secrets — no one else. @@ -1200,7 +1190,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, Resource: rbac.ResourceUserSecret.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + true: {memberMe, orgWorkspaceAccessUser}, false: { owner, orgAdmin, otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin, @@ -1215,7 +1205,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead, policy.ActionDelete}, Resource: rbac.ResourceUserSkill.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {owner, memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + true: {owner, memberMe, orgWorkspaceAccessUser}, false: { orgAdmin, otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin, @@ -1228,7 +1218,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate}, Resource: rbac.ResourceUserSkill.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {memberMe, agentsAccessUser, orgWorkspaceAccessUser}, + true: {memberMe, orgWorkspaceAccessUser}, false: { owner, orgAdmin, otherOrgAdmin, orgAuditor, orgUserAdmin, orgTemplateAdmin, @@ -1244,8 +1234,7 @@ func TestRolePermissions(t *testing.T) { true: {}, false: { owner, - memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1259,7 +1248,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionCreate, policy.ActionUpdate}, Resource: rbac.ResourceAibridgeInterception.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {orgWorkspaceAccessUser, owner, memberMe, agentsAccessUser}, + true: {orgWorkspaceAccessUser, owner, memberMe}, false: { orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, @@ -1276,8 +1265,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, auditor}, false: { - orgWorkspaceAccessUser, memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + orgWorkspaceAccessUser, memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1295,8 +1283,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, false: { - orgWorkspaceAccessUser, memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + orgWorkspaceAccessUser, memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1313,8 +1300,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, false: { - orgWorkspaceAccessUser, memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + orgWorkspaceAccessUser, memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1332,8 +1318,7 @@ func TestRolePermissions(t *testing.T) { true: {}, false: { owner, - orgWorkspaceAccessUser, memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + orgWorkspaceAccessUser, memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1345,7 +1330,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, Resource: rbac.ResourceBoundaryUsage, AuthorizeMap: map[bool][]hasAuthSubjects{ - false: {owner, setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {owner, setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -1353,7 +1338,7 @@ func TestRolePermissions(t *testing.T) { Actions: []policy.Action{policy.ActionCreate, policy.ActionRead}, Resource: rbac.ResourceAiSeat, AuthorizeMap: map[bool][]hasAuthSubjects{ - false: {owner, setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {owner, setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { @@ -1362,18 +1347,17 @@ func TestRolePermissions(t *testing.T) { Resource: rbac.ResourceAiModelPrice, AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner}, - false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin, orgWorkspaceAccessUser}, + false: {setOtherOrg, setOrgNotMe, memberMe, templateAdmin, userAdmin, orgWorkspaceAccessUser}, }, }, { - // Boundary logs: members can create logs they own (user-scoped). - // memberMe and agentsAccessUser have ID == currentUser, so they - // match the resource owner. Other subjects have different IDs. + // Both allowed subjects use currentUser as their ID, matching the + // resource owner. Name: "BoundaryLogCreate", Actions: []policy.Action{policy.ActionCreate}, Resource: rbac.ResourceBoundaryLog.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]hasAuthSubjects{ - true: {orgWorkspaceAccessUser, memberMe, agentsAccessUser}, + true: {orgWorkspaceAccessUser, memberMe}, false: { owner, orgAdmin, otherOrgAdmin, @@ -1393,8 +1377,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {}, false: { - orgWorkspaceAccessUser, owner, memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + orgWorkspaceAccessUser, owner, memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, auditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1410,8 +1393,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {}, false: { - orgWorkspaceAccessUser, owner, memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + orgWorkspaceAccessUser, owner, memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, auditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1426,8 +1408,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, auditor}, false: { - orgWorkspaceAccessUser, memberMe, agentsAccessUser, - orgAdmin, otherOrgAdmin, + orgWorkspaceAccessUser, memberMe, orgAdmin, otherOrgAdmin, orgAuditor, otherOrgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1439,8 +1420,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, agentsAccessUser}, - false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, + true: {owner, orgAdmin, orgMemberMe}, + false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, }, }, { @@ -1448,8 +1429,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, agentsAccessUser}, - false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, + true: {owner, orgAdmin, orgMemberMe}, + false: {setOtherOrg, memberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, }, }, { @@ -1458,7 +1439,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, agentsAccessUser, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, + false: {setOtherOrg, memberMe, orgMemberMe, userAdmin, templateAdmin, orgTemplateAdmin, orgUserAdmin, orgAuditor, orgWorkspaceAccessUser}, }, }, { @@ -1468,7 +1449,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin, auditor, orgAuditor}, false: { - memberMe, orgMemberMe, agentsAccessUser, orgWorkspaceAccessUser, + memberMe, orgMemberMe, orgWorkspaceAccessUser, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, otherOrgAdmin, otherOrgAuditor, @@ -1482,7 +1463,7 @@ func TestRolePermissions(t *testing.T) { AuthorizeMap: map[bool][]hasAuthSubjects{ true: {owner, orgAdmin}, false: { - memberMe, orgMemberMe, agentsAccessUser, orgWorkspaceAccessUser, + memberMe, orgMemberMe, orgWorkspaceAccessUser, auditor, orgAuditor, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, userAdmin, orgUserAdmin, otherOrgUserAdmin, @@ -1645,7 +1626,6 @@ 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) } diff --git a/coderd/x/chatd/forced_mcp_test.go b/coderd/x/chatd/forced_mcp_test.go index 4f8e6d35d08..97d131e9512 100644 --- a/coderd/x/chatd/forced_mcp_test.go +++ b/coderd/x/chatd/forced_mcp_test.go @@ -123,13 +123,6 @@ func TestCreateChat_ForceOnMCPServerEnforced(t *testing.T) { cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) }) - // The seeded member needs agents-access to create a chat. - _, err := db.UpdateMemberRoles(dbauthz.AsSystemRestricted(ctx), database.UpdateMemberRolesParams{ - GrantedRoles: []string{rbac.RoleAgentsAccess()}, - UserID: user.ID, - OrgID: org.ID, - }) - require.NoError(t, err) ownerSubject, _, err := httpmw.UserRBACSubject(dbauthz.AsSystemRestricted(ctx), db, user.ID, rbac.ScopeAll) require.NoError(t, err) ownerCtx := dbauthz.As(ctx, ownerSubject) diff --git a/codersdk/rbacroles.go b/codersdk/rbacroles.go index 71b82c6340d..ddb66e2d891 100644 --- a/codersdk/rbacroles.go +++ b/codersdk/rbacroles.go @@ -7,7 +7,6 @@ const ( RoleTemplateAdmin string = "template-admin" RoleUserAdmin string = "user-admin" RoleAuditor string = "auditor" - RoleAgentsAccess string = "agents-access" RoleOrganizationAdmin string = "organization-admin" RoleOrganizationMember string = "organization-member" diff --git a/docs/ai-coder/agents/getting-started.md b/docs/ai-coder/agents/getting-started.md index 080ef8936bc..1959737ba7d 100644 --- a/docs/ai-coder/agents/getting-started.md +++ b/docs/ai-coder/agents/getting-started.md @@ -17,11 +17,7 @@ Before you begin, confirm the following: [descriptive name and description](./platform-controls/template-optimization.md) for the agent to select when provisioning workspaces. - **Admin access** to the Coder deployment for configuring providers. -- **Coder Agents User role** assigned to each user who needs to interact with Coder Agents. - This role is granted **per organization**. Owners and organization admins can - assign it from **Admin settings** > **Organizations** > _[your organization]_ > - **Members**. See [Grant Coder Agents User](#step-2-grant-coder-agents-user) - below. +- **Organization membership** for each user who uses Coder Agents. ## Step 1: Configure an LLM provider and model @@ -47,65 +43,7 @@ Detailed instructions for each provider and model option are in the > Start with a single frontier model to validate your setup before adding > additional providers. -## Step 2: Grant Coder Agents User - -The **Coder Agents User** role controls which users can interact with Coder -Agents. The role is assigned **per organization**, so a user must be granted -it in each organization where they need access. Members do not have it by -default. - -Owners always have full access and do not need the role. Repeat the following -steps for each user who needs access in each organization. - -**Dashboard (individual):** - -1. Open **Admin settings** > **Organizations** in the Coder dashboard, then - select the organization where you want to grant access. -1. The **Members** tab opens by default. Find the user in the table. -1. Click the **Roles** cell for that user to open the role editor. -1. Toggle on **Coder Agents User** and save. - -> [!TIP] -> If your deployment has multiple organizations, repeat this for each -> organization where the user needs access. - -**CLI (bulk, per organization):** - -Granting the role via CLI is org-scoped. The `edit-roles` command **replaces** -the member's full set of org roles, so include every role you want them to -keep. To grant `agents-access` to a single user while preserving their -existing org roles: - -```sh -ORG="my-org" -USER="alice" -ROLES=$(coder organizations members list -O "$ORG" -o json \ - | jq -r --arg user "$USER" \ - '.[] | select(.username == $user) | [.roles[].name, "agents-access"] - | unique | join(" ")') -# shellcheck disable=SC2086 -coder organizations members edit-roles "$USER" -O "$ORG" $ROLES -``` - -To grant the role to every member of an organization while preserving their -existing roles: - -```sh -ORG="my-org" -coder organizations members list -O "$ORG" -o json \ - | jq -c '.[] | {user_id, roles: [.roles[].name]}' \ - | while read -r row; do - user_id=$(echo "$row" | jq -r '.user_id') - roles=$(echo "$row" | jq -r '(.roles + ["agents-access"]) | unique | join(" ")') - # shellcheck disable=SC2086 - coder organizations members edit-roles "$user_id" -O "$ORG" $roles - done -``` - -You can also set the organization with the `CODER_ORGANIZATION` environment -variable instead of `-O`. - -## Step 3: Start your first Coder Agent +## Step 2: Start your first Coder Agent 1. Go to the **Agents** page in the Coder dashboard. 1. Select a model from the dropdown (your default will be pre-selected). diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 29b5d6d2d2c..63a67aed3f5 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -94,11 +94,6 @@ The agent loop runs in the control plane, and developers work through the dashbo Not to be confused with a [workspace agent](#workspace-agent) or the [`coder_agent`](#coder_agent) resource. Refer to [Coder Agents](../ai-coder/agents/index.md). -### Coder Agents User - -The per-organization role that a member needs to use [Coder Agents](#coder-agents). -Refer to [Coder Agents](../ai-coder/agents/index.md). - ### Coder CLI The single `coder` binary used for admin and user operations. diff --git a/enterprise/coderd/exp_chats_test.go b/enterprise/coderd/exp_chats_test.go index 6e1b301a4cb..791960e93e4 100644 --- a/enterprise/coderd/exp_chats_test.go +++ b/enterprise/coderd/exp_chats_test.go @@ -1118,11 +1118,10 @@ func TestCreateChatUsesOrganizationLocalModel(t *testing.T) { }) require.NoError(t, err) - // Create a member with agents-access in both orgs. + // Create a member in both orgs. memberClientRaw, member := coderdtest.CreateAnotherUser( t, client, firstUser.OrganizationID, - rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), - rbac.ScopedRoleAgentsAccess(secondOrg.ID), + rbac.ScopedRoleOrgMember(secondOrg.ID), ) memberClient := codersdk.NewExperimentalClient(memberClientRaw) // Create a chat in the non-default org. @@ -1190,12 +1189,11 @@ func TestCreateChatCrossOrgModelConfigRejected(t *testing.T) { secondOrg := coderdenttest.CreateOrganization(t, client, coderdenttest.CreateOrganizationOptions{}) - // A member with agents-access in both orgs still cannot bind a chat in - // the second org to the default org's config. + // A member of both orgs still cannot bind a chat in the second org to + // the default org's config. memberClientRaw, _ := coderdtest.CreateAnotherUser( t, client, firstUser.OrganizationID, - rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), - rbac.ScopedRoleAgentsAccess(secondOrg.ID), + rbac.ScopedRoleOrgMember(secondOrg.ID), ) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -1251,11 +1249,10 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) { }) require.NoError(t, err) - // Create a member with agents-access in both orgs. + // Create a member in both orgs. memberClientRaw, _ := coderdtest.CreateAnotherUser( t, client, firstUser.OrganizationID, - rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), - rbac.ScopedRoleAgentsAccess(secondOrg.ID), + rbac.ScopedRoleOrgMember(secondOrg.ID), ) memberExp := codersdk.NewExperimentalClient(memberClientRaw) // Member creates a chat in the second org. @@ -1271,10 +1268,9 @@ func TestListChats_OrgAdminOnlySeesOwnChats(t *testing.T) { require.NoError(t, err) require.Equal(t, secondOrg.ID, memberChat.OrganizationID) - // Create an org admin in the second org with agents access. adminClientRaw, _ := coderdtest.CreateAnotherUser( t, client, firstUser.OrganizationID, - rbac.ScopedRoleOrgAdmin(secondOrg.ID), rbac.ScopedRoleAgentsAccess(secondOrg.ID), + rbac.ScopedRoleOrgAdmin(secondOrg.ID), ) adminExp := codersdk.NewExperimentalClient(adminClientRaw) diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index e2cc4df5bb2..62fe743a561 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -506,7 +506,6 @@ 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, }), }, { @@ -541,7 +540,6 @@ 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, }), }, { @@ -576,7 +574,6 @@ 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/site_test.go b/site/site_test.go index 3aceee175e3..476ac29e5b2 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -248,61 +248,54 @@ func TestRenderPermissionsResolvesMe(t *testing.T) { }) require.NoError(t, err) - // GIVEN: a user with the agents-access role at the org level. org := dbgen.Organization(t, db, database.Organization{}) - userWithRole := dbgen.User(t, db, database.User{}) + orgMember := dbgen.User(t, db, database.User{}) dbgen.OrganizationMember(t, db, database.OrganizationMember{ OrganizationID: org.ID, - UserID: userWithRole.ID, - Roles: []string{rbac.RoleAgentsAccess()}, + UserID: orgMember.ID, }) - _, tokenWithRole := dbgen.APIKey(t, db, database.APIKey{ - UserID: userWithRole.ID, + _, orgMemberToken := dbgen.APIKey(t, db, database.APIKey{ + UserID: orgMember.ID, ExpiresAt: time.Now().Add(time.Hour), }) // WHEN: the user loads the page. r := httptest.NewRequest("GET", "/", nil) - r.Header.Set(codersdk.SessionTokenHeader, tokenWithRole) + r.Header.Set(codersdk.SessionTokenHeader, orgMemberToken) rw := httptest.NewRecorder() handler.ServeHTTP(rw, r) require.Equal(t, http.StatusOK, rw.Code) - // THEN: the SSR-rendered permissions include createChat = true - // because the agents-access role grants org-scoped chat create - // permission, and the any_org check picks it up. - var permsWithRole codersdk.AuthorizationResponse - err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithRole) + // The any_org check finds chat permission through the user's membership. + var memberPerms codersdk.AuthorizationResponse + err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &memberPerms) require.NoError(t, err) - assert.True(t, permsWithRole["createChat"], "user with agents-access role should have createChat = true") + assert.True(t, memberPerms["createChat"], "organization member should have createChat = true") // THEN: createWorkspace = true because the organization-member role // grants creating a workspace owned by the member, and owner_id "me" // resolves to the requesting user. - assert.True(t, permsWithRole["createWorkspace"], "org member should have createWorkspace = true") + assert.True(t, memberPerms["createWorkspace"], "org member should have createWorkspace = true") - // GIVEN: a user without the agents-access role. - userWithoutRole := dbgen.User(t, db, database.User{}) - _, tokenWithoutRole := dbgen.APIKey(t, db, database.APIKey{ - UserID: userWithoutRole.ID, + userWithoutMembership := dbgen.User(t, db, database.User{}) + _, tokenWithoutMembership := dbgen.APIKey(t, db, database.APIKey{ + UserID: userWithoutMembership.ID, ExpiresAt: time.Now().Add(time.Hour), }) // WHEN: the user loads the page. r = httptest.NewRequest("GET", "/", nil) - r.Header.Set(codersdk.SessionTokenHeader, tokenWithoutRole) + r.Header.Set(codersdk.SessionTokenHeader, tokenWithoutMembership) rw = httptest.NewRecorder() handler.ServeHTTP(rw, r) require.Equal(t, http.StatusOK, rw.Code) - // THEN: createChat = false because the member role does not - // grant chat permissions. - var permsWithoutRole codersdk.AuthorizationResponse - err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithoutRole) + var permsWithoutMembership codersdk.AuthorizationResponse + err = json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &permsWithoutMembership) require.NoError(t, err) - assert.False(t, permsWithoutRole["createChat"], "user without agents-access role should have createChat = false") + assert.False(t, permsWithoutMembership["createChat"], "user without an organization membership should have createChat = false") // THEN: createWorkspace = false because the user belongs to no // organization, so the any_org check has no memberships to satisfy it. - assert.False(t, permsWithoutRole["createWorkspace"], "user without an org membership should have createWorkspace = false") + assert.False(t, permsWithoutMembership["createWorkspace"], "user without an org membership should have createWorkspace = false") // GIVEN: an org member whose only membership carries the // workspace-creation ban role. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d8320f2cb68..7993a44891b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8311,12 +8311,6 @@ export interface Role { readonly organization_member_permissions: readonly Permission[]; } -// From codersdk/rbacroles.go -/** - * Ideally these roles would be generated from the rbac/roles.go package. - */ -export const RoleAgentsAccess = "agents-access"; - // From codersdk/rbacroles.go /** * Ideally these roles would be generated from the rbac/roles.go package. diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index ba5a6821d35..02c58753df9 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx @@ -274,7 +274,7 @@ export const ForSingleOrgOSSAdmin: Story = { }, }; -export const ForMember: Story = { +export const ForUserWithoutOrganization: Story = { args: { user: MockUserMember, adminPermissions: {}, @@ -315,7 +315,7 @@ export const ForMemberWithModelAccess: Story = { }, }; -export const ForMemberWithAgentsAccess: Story = { +export const ForMember: Story = { args: { user: MockUserMember, adminPermissions: {}, diff --git a/site/src/modules/roles/RoleSelector.stories.tsx b/site/src/modules/roles/RoleSelector.stories.tsx index 3f14e7fb0a9..417a8a846db 100644 --- a/site/src/modules/roles/RoleSelector.stories.tsx +++ b/site/src/modules/roles/RoleSelector.stories.tsx @@ -2,7 +2,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; import { assignableRole, - MockAgentsAccessRole, MockAuditorRole, MockOrganizationAdminRole, MockOrganizationAuditorRole, @@ -80,7 +79,6 @@ const orgMemberRoles = [ assignableRole(MockOrganizationUserAdminRole, true), assignableRole(MockOrganizationTemplateAdminRole, true), assignableRole(MockOrganizationAuditorRole, true), - assignableRole(MockAgentsAccessRole, true), assignableRole(MockWorkspaceCreationBanRole, true), ]; @@ -94,7 +92,7 @@ export const WithAdditionalImpliedRoles: Story = { args: { availableRoles: orgMemberRoles, additionalImpliedRoles: [ - assignableRole(MockAgentsAccessRole, true), + assignableRole(MockWorkspaceCreationBanRole, true), assignableRole(MockOrganizationAuditorRole, true), ], }, @@ -105,7 +103,7 @@ const mixedBuiltInAndCustomRoles = [ assignableRole(MockOrganizationUserAdminRole, true), assignableRole(MockOrganizationTemplateAdminRole, true), { ...assignableRole(MockOrganizationAuditorRole, true), built_in: false }, - { ...assignableRole(MockAgentsAccessRole, true), built_in: false }, + { ...assignableRole(MockWorkspaceCreationBanRole, true), built_in: false }, ]; export const WithDisabledReason: Story = { diff --git a/site/src/modules/roles/index.ts b/site/src/modules/roles/index.ts index 222ecee8002..55246db3853 100644 --- a/site/src/modules/roles/index.ts +++ b/site/src/modules/roles/index.ts @@ -10,7 +10,6 @@ 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": "Grants access to Coder Agents chat.", "organization-admin": "Organization admin can manage all resources within this organization.", "organization-user-admin": @@ -53,7 +52,6 @@ const roleNamesByAccessLevel: readonly string[] = [ "organization-template-admin", "auditor", "organization-auditor", - "agents-access", "member", "organization-member", ]; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index b47bf6dfdda..68ded008513 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -1392,7 +1392,7 @@ export const HookDenied: Story = { }, }; -export const ForbiddenErrorWithRole: Story = { +export const ForbiddenErrorWithPermission: Story = { args: { ...defaultArgs, canCreateChat: true, @@ -1400,14 +1400,10 @@ export const ForbiddenErrorWithRole: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // The friendly "role required" alert must NOT appear because the - // user has the agents-access role. await expect( canvas.queryByText("Permission required"), ).not.toBeInTheDocument(); - // The generic ErrorAlert should surface the real backend message. await expect(canvas.getByText("Forbidden.")).toBeInTheDocument(); - // The textbox should remain enabled since the user has the role. const textbox = canvas.getByRole("textbox"); await waitFor(() => expect(textbox).not.toHaveAttribute("aria-disabled", "true"), @@ -1553,7 +1549,6 @@ export const RestrictedMultiOrganizationUser: Story = { MockDefaultOrganization, MockOrganization2, ]); - // Model agents-access: "me" supplies the owner for member-scoped chat:create. spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => Object.fromEntries( Object.entries(checks).map(([id, check]) => [ @@ -2026,7 +2021,7 @@ export const EmptyPermittedSetPreservesStoredWorkspace: Story = { revocablePermissions[MockOrganization2.id] = false; await revocableQueryClient?.invalidateQueries(); - await canvas.findByText(/don't have permission/i); + await canvas.findByText(/You need access to an organization/i); revocablePermissions[MockOrganization2.id] = true; await revocableQueryClient?.invalidateQueries(); @@ -2252,7 +2247,7 @@ export const OrgChangeConfirmation: Story = { }, }; -export const ForbiddenNoAgentsRole: Story = { +export const ForbiddenNoOrganizationAccess: Story = { args: { ...defaultArgs, canCreateChat: false, @@ -2261,6 +2256,9 @@ export const ForbiddenNoAgentsRole: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Permission required")).toBeInTheDocument(); + await expect( + canvas.getByText(/You need access to an organization/), + ).toBeInTheDocument(); await expect( canvas.getByRole("link", { name: /View Docs/ }), ).toBeInTheDocument(); @@ -2312,7 +2310,9 @@ export const PermittedOrgsResolvesToEmpty: Story = { const canvas = within(canvasElement); await waitFor( () => { - expect(canvas.getByText(/don't have permission/i)).toBeInTheDocument(); + expect( + canvas.getByText(/You need access to an organization/i), + ).toBeInTheDocument(); }, { timeout: 3000 }, ); @@ -2386,11 +2386,6 @@ export const PermittedOrgsResolvesToSubset: Story = { }, }; -/** - * Member-scoped roles like agents-access grant chat:create only on - * chats the user owns, so the per-org check must carry owner context - * for the picker to render. - */ export const MemberScopedPermissionsShowOrgPicker: Story = { parameters: { showOrganizations: true, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 5c3517838f4..c8afa7821fa 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -194,8 +194,7 @@ export const AgentCreateForm: FC = ({ ); const permittedOrgsQuery = useQuery({ ...permittedOrganizations({ - // agents-access grants chat:create only at member scope. "me" is - // replaced with the caller ID so that permission can match. + // "me" resolves to the caller ID for owner-scoped permissions. object: { resource_type: "chat", owner_id: "me" }, action: "create", }), diff --git a/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx b/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx index e1a580e4ffd..15b4f0b1df3 100644 --- a/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx +++ b/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx @@ -5,9 +5,7 @@ import { Link } from "#/components/Link/Link"; import { docs } from "#/utils/docs"; export const ChatAccessDeniedAlert: FC = () => { - const docsLink = docs( - "/ai-coder/agents/getting-started#step-3-grant-coder-agents-user", - ); + const docsLink = docs("/ai-coder/agents/getting-started"); return ( { > Permission required - You don't have permission to use Coder Agents. Contact your Coder - administrator for access. Refresh this page after access has been - granted.{" "} + You need access to an organization to use Coder Agents. Contact your + Coder administrator for access, 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 b9b16b97d7d..3edbbc2b6ba 100644 --- a/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx @@ -33,17 +33,6 @@ const mockOrgRoles: AssignableRoles[] = [ assignable: true, built_in: true, }, - { - name: "agents-access", - display_name: "Agents Access", - organization_id: MockOrganization.id, - site_permissions: [], - organization_permissions: [], - organization_member_permissions: [], - user_permissions: [], - assignable: true, - built_in: true, - }, ]; const meta: Meta = { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 55800026e46..079eeda5bb8 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -377,16 +377,6 @@ export const MockOrganizationAuditorRole: TypesGen.AssignableRoles = { organization_member_permissions: [], }; -export const MockAgentsAccessRole: TypesGen.Role = { - name: "agents-access", - display_name: "Coder Agents User", - 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", From 3c49c509ceedec7c5aafcbfbdbaef2616b169857 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:11:23 +0000 Subject: [PATCH 02/17] fix(coderd): promote queued message from a terminal chat status in member test --- coderd/exp_chats_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 1c755cdf838..a1ae7dd4e85 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -12157,6 +12157,7 @@ func TestPromoteChatQueuedMessage(t *testing.T) { OwnerID: member.ID, LastModelConfigID: modelConfig.ID, Title: "promote queued as member", + Status: database.ChatStatusError, }) queuedContent, err := json.Marshal([]codersdk.ChatMessagePart{ From 94605771a3c539a879c319b9e14c6047ad3d7466 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:20:44 +0000 Subject: [PATCH 03/17] fix: keep RoleAgentsAccess as a deprecated compatibility alias --- codersdk/rbacroles.go | 4 ++++ site/src/api/typesGenerated.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/codersdk/rbacroles.go b/codersdk/rbacroles.go index ddb66e2d891..ea87127a395 100644 --- a/codersdk/rbacroles.go +++ b/codersdk/rbacroles.go @@ -7,6 +7,10 @@ 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 string = "agents-access" RoleOrganizationAdmin string = "organization-admin" RoleOrganizationMember string = "organization-member" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7993a44891b..290ae093f04 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8311,6 +8311,15 @@ export interface Role { readonly organization_member_permissions: readonly Permission[]; } +// 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. + */ +export const RoleAgentsAccess = "agents-access"; + // From codersdk/rbacroles.go /** * Ideally these roles would be generated from the rbac/roles.go package. From f0548eaf68b9db3f81757cd2a29520f3d7477bab Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:44:36 +0000 Subject: [PATCH 04/17] docs(docs/ai-coder/agents): document model access lists as the Coder Agents access control --- docs/ai-coder/agents/getting-started.md | 6 +++++- docs/ai-coder/agents/models.md | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/ai-coder/agents/getting-started.md b/docs/ai-coder/agents/getting-started.md index 1959737ba7d..eb428427738 100644 --- a/docs/ai-coder/agents/getting-started.md +++ b/docs/ai-coder/agents/getting-started.md @@ -17,7 +17,11 @@ Before you begin, confirm the following: [descriptive name and description](./platform-controls/template-optimization.md) for the agent to select when provisioning workspaces. - **Admin access** to the Coder deployment for configuring providers. -- **Organization membership** for each user who uses Coder Agents. +- **Organization membership** for each user who uses 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. See + [Share a model](./models.md#share-a-model). ## Step 1: Configure an LLM provider and model diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 855bbedc6ab..75953621081 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -141,6 +141,10 @@ Create, update, delete, and share permissions control their corresponding action Members with model share permission can grant model read access to members and groups in the selected organization. +Model access lists control who can use Coder Agents. +All organization members hold chat permissions, but 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**. 2. Select the organization that owns the model. 3. Select the model. From dca5c57ce0b4e9e853a81d43862b8f5e716c873a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:12:05 +0000 Subject: [PATCH 05/17] refactor(coderd/rbac): track retired role names in a legacy set --- coderd/rbac/roles.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 6dd9d79260b..5dd83b7e4dc 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -340,13 +340,18 @@ type RoleOptions struct { NoChatSharing bool } +// legacyRoleNames 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. +var legacyRoleNames = map[string]struct{}{ + "agents-access": {}, +} + // 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 { - // Retired built-in role names 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. - if name == "agents-access" { + if _, ok := legacyRoleNames[name]; ok { return true } _, ok := loadBuiltinRoles()[name] From 478dc27c93d80c62f2f2529d2de62098f54ef654 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:33:09 +0000 Subject: [PATCH 06/17] fix(site/src/pages/AgentsPage): align settings story assertion with current Coder Agents copy --- site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx index 03c60fd5d5f..b00152a7589 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx @@ -1253,7 +1253,9 @@ export const SettingsViewCoderAgentsLink: Story = { await waitFor(() => { expect( - screen.getByText(/Configure deployment-wide Coder Agents capabilities/), + screen.getByText( + /organization model choices and deployment-wide Coder Agents capabilities/, + ), ).toBeInTheDocument(); }); }, From 7e1a2c6b6a8557d2d40707baab3a774ae129d3b5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:32:31 +0000 Subject: [PATCH 07/17] refactor(coderd): drop agents-access cleanup migration Keep this PR migration-free so it backports cleanly across diverged migration numbers. Stale agents-access grants may now linger in role arrays and org default role lists, so treat retired role names as grants of nothing: role expansion drops them and assignment validation ignores them. A follow-up PR will clean up the stale data. --- coderd/database/dbauthz/dbauthz.go | 7 ++ .../000585_remove_agents_access_role.down.sql | 13 ---- .../000585_remove_agents_access_role.up.sql | 11 --- coderd/database/migrations/migrate_test.go | 50 -------------- coderd/members_test.go | 68 +++++++++++++++++++ coderd/rbac/roles.go | 17 ++++- coderd/rbac/roles_test.go | 28 ++++++++ 7 files changed, 119 insertions(+), 75 deletions(-) delete mode 100644 coderd/database/migrations/000585_remove_agents_access_role.down.sql delete mode 100644 coderd/database/migrations/000585_remove_agents_access_role.up.sql diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 01c525c677c..30ca93c0506 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1413,6 +1413,13 @@ 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 granting or + // revoking them needs no per-role validation. + grantedRoles = slices.DeleteFunc(grantedRoles, func(r rbac.RoleIdentifier) bool { + return rbac.IsLegacyRoleName(r.Name) + }) customRoles := make([]rbac.RoleIdentifier, 0) // Validate that the roles being assigned are valid. for _, r := range grantedRoles { diff --git a/coderd/database/migrations/000585_remove_agents_access_role.down.sql b/coderd/database/migrations/000585_remove_agents_access_role.down.sql deleted file mode 100644 index 668c33f4a5b..00000000000 --- a/coderd/database/migrations/000585_remove_agents_access_role.down.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Older binaries need agents-access unless another role grants chat access. --- Exact prior grants cannot be reconstructed, so restore the role to all --- current non-service-account memberships. -UPDATE organization_members om -SET roles = array_append(om.roles, 'agents-access') -FROM users u -WHERE u.id = om.user_id - AND NOT u.is_service_account - AND NOT ('agents-access' = ANY(om.roles)); - --- Defaults are not restored: older binaries union default_org_member_roles --- into service-account memberships too. Pre-upgrade explicit service-account --- grants stay lost; an admin can re-grant them. diff --git a/coderd/database/migrations/000585_remove_agents_access_role.up.sql b/coderd/database/migrations/000585_remove_agents_access_role.up.sql deleted file mode 100644 index 086d97045e9..00000000000 --- a/coderd/database/migrations/000585_remove_agents_access_role.up.sql +++ /dev/null @@ -1,11 +0,0 @@ -UPDATE users -SET rbac_roles = array_remove(rbac_roles, 'agents-access') -WHERE 'agents-access' = ANY(rbac_roles); - -UPDATE organization_members -SET roles = array_remove(roles, 'agents-access') -WHERE 'agents-access' = ANY(roles); - -UPDATE organizations -SET default_org_member_roles = array_remove(default_org_member_roles, 'agents-access') -WHERE 'agents-access' = ANY(default_org_member_roles); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 41b66a9aeda..53050bb8ed7 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1191,56 +1191,6 @@ func TestMigration000475AgentsAccessOrgRole(t *testing.T) { ) } -func TestMigration000585RemoveAgentsAccessRole(t *testing.T) { - t.Parallel() - - const migrationVersion = 585 - - sqlDB := testSQLDB(t) - next, err := migrations.Stepper(sqlDB) - require.NoError(t, err) - for { - version, more, err := next() - require.NoError(t, err) - if !more { - t.Fatalf("migration %d not found", migrationVersion) - } - if version == migrationVersion-1 { - break - } - } - - db := database.New(sqlDB) - user := dbgen.User(t, db, database.User{ - RBACRoles: []string{"auditor", "agents-access"}, - }) - org := dbgen.Organization(t, db, database.Organization{ - DefaultOrgMemberRoles: []string{"organization-workspace-access", "agents-access"}, - }) - dbgen.OrganizationMember(t, db, database.OrganizationMember{ - OrganizationID: org.ID, - UserID: user.ID, - Roles: []string{"organization-auditor", "agents-access"}, - }) - - version, _, err := next() - require.NoError(t, err) - require.EqualValues(t, migrationVersion, version) - - ctx := testutil.Context(t, testutil.WaitLong) - var siteRoles, orgRoles, defaultRoles pq.StringArray - err = sqlDB.QueryRowContext(ctx, "SELECT rbac_roles FROM users WHERE id = $1", user.ID).Scan(&siteRoles) - require.NoError(t, err) - err = sqlDB.QueryRowContext(ctx, "SELECT roles FROM organization_members WHERE organization_id = $1 AND user_id = $2", org.ID, user.ID).Scan(&orgRoles) - require.NoError(t, err) - err = sqlDB.QueryRowContext(ctx, "SELECT default_org_member_roles FROM organizations WHERE id = $1", org.ID).Scan(&defaultRoles) - require.NoError(t, err) - - require.Equal(t, []string{"auditor"}, []string(siteRoles)) - require.Equal(t, []string{"organization-auditor"}, []string(orgRoles)) - require.Equal(t, []string{"organization-workspace-access"}, []string(defaultRoles)) -} - func TestMigration000504AIProvidersBackfill(t *testing.T) { t.Parallel() diff --git a/coderd/members_test.go b/coderd/members_test.go index c2bf219c1eb..5b4b5c0f58f 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -12,6 +12,8 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -50,6 +52,72 @@ func TestAddMember(t *testing.T) { }) } +// TestMembersWithLegacyRole verifies that stale grants of retired built-in +// roles, which linger in the database until a cleanup migration lands, do +// not break membership management. +func TestMembersWithLegacyRole(t *testing.T) { + t.Parallel() + + // The raw, unauthorized store is required to seed stale data without + // tripping the assignment validation under test. + db, ps := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitMedium) + + // Seed stale data in the store: the retired agents-access role remains + // in a member's role array and in the org's default member roles. + _, err := db.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{ + GrantedRoles: []string{"agents-access"}, + UserID: member.ID, + OrgID: owner.OrganizationID, + }) + require.NoError(t, err) + + org, err := db.GetOrganizationByID(ctx, owner.OrganizationID) + require.NoError(t, err) + updateOrg := database.UpdateOrganizationParams{ + ID: org.ID, + UpdatedAt: dbtime.Now(), + Name: org.Name, + DisplayName: org.DisplayName, + Description: org.Description, + Icon: org.Icon, + DefaultOrgMemberRoles: append(org.DefaultOrgMemberRoles, "agents-access"), + } + _, err = db.UpdateOrganization(ctx, updateOrg) + require.NoError(t, err) + + // Requests expand the member's stored roles, including the stale grant. + _, err = memberClient.User(ctx, codersdk.Me) + require.NoError(t, err) + + // User creation inserts an organization membership, which validates the + // org's default member roles. The stale default must not fail it. + coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + // Restore the defaults so the stale grant is no longer implied and the + // next update must validate its removal. + updateOrg.DefaultOrgMemberRoles = org.DefaultOrgMemberRoles + _, err = db.UpdateOrganization(ctx, updateOrg) + require.NoError(t, err) + + // Role updates validate the removed set, which includes the stale + // grant. The update must succeed and strip the retired role. + updated, err := client.UpdateOrganizationMemberRoles(ctx, owner.OrganizationID, member.ID.String(), codersdk.UpdateRoles{ + Roles: []string{codersdk.RoleOrganizationAuditor}, + }) + require.NoError(t, err) + names := make([]string, 0, len(updated.Roles)) + for _, role := range updated.Roles { + names = append(names, role.Name) + } + require.Contains(t, names, codersdk.RoleOrganizationAuditor) + require.NotContains(t, names, "agents-access") +} + func TestDeleteMember(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 5dd83b7e4dc..9e67fe1ade9 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -343,11 +343,20 @@ type RoleOptions struct { // legacyRoleNames 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. +// rollback. Stored role arrays and org default role lists may still contain +// these names until a data cleanup migration lands, so role expansion and +// assignment validation treat them as grants of nothing instead of failing. var legacyRoleNames = map[string]struct{}{ "agents-access": {}, } +// IsLegacyRoleName reports whether name is a retired built-in role name that +// may still appear in stored role arrays. +func IsLegacyRoleName(name string) bool { + _, ok := legacyRoleNames[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 { @@ -979,6 +988,12 @@ func RoleByName(name RoleIdentifier) (Role, error) { func rolesByNames(roleNames []RoleIdentifier) ([]Role, error) { roles := make([]Role, 0, len(roleNames)) for _, n := range roleNames { + if IsLegacyRoleName(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) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 7d8b9b2cd8b..0363f9da81e 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1804,3 +1804,31 @@ func TestDBPurgeBoundaryLogDelete(t *testing.T) { rbac.ResourceBoundaryLog) require.Error(t, err, "DBPurge must not read boundary logs") } + +// TestLegacyRoleNames verifies retired built-in role names stay reserved and +// that stale stored grants of them expand to nothing instead of failing. +func TestLegacyRoleNames(t *testing.T) { + t.Parallel() + + const legacyName = "agents-access" + orgID := uuid.New() + + require.True(t, rbac.IsLegacyRoleName(legacyName)) + // Legacy names stay reserved so custom roles cannot shadow them. + require.True(t, rbac.ReservedRoleName(legacyName)) + + // Legacy names do not resolve to a role... + _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: legacyName}) + require.Error(t, err) + + // ...but stored role arrays may still contain them, so expansion drops + // them instead of failing. + roles, err := rbac.RoleIdentifiers{ + {Name: legacyName}, + {Name: legacyName, OrganizationID: orgID}, + rbac.ScopedRoleOrgAuditor(orgID), + }.Expand() + require.NoError(t, err) + require.Len(t, roles, 1) + require.Equal(t, rbac.ScopedRoleOrgAuditor(orgID), roles[0].Identifier) +} From b722a80a34bec4dbafe02b1019cc80cca8f79b3d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:36:09 +0000 Subject: [PATCH 08/17] refactor(coderd): restore agents-access cleanup migration Reinstate the data cleanup that the base PR dropped to stay backportable, renumbered to 000587 because main advanced past 585. With stale grants migrated away, revert the temporary RBAC tolerance for retired role names: role expansion is strict again and assignment validation no longer filters legacy names. The migration test steps to the highest existing version below the target because this branch does not contain migrations 585 and 586 from main. --- coderd/database/dbauthz/dbauthz.go | 7 -- .../000587_remove_agents_access_role.down.sql | 13 ++++ .../000587_remove_agents_access_role.up.sql | 11 +++ coderd/database/migrations/migrate_test.go | 68 +++++++++++++++++++ coderd/members_test.go | 68 ------------------- coderd/rbac/roles.go | 17 +---- coderd/rbac/roles_test.go | 28 -------- 7 files changed, 93 insertions(+), 119 deletions(-) create mode 100644 coderd/database/migrations/000587_remove_agents_access_role.down.sql create mode 100644 coderd/database/migrations/000587_remove_agents_access_role.up.sql diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 30ca93c0506..01c525c677c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1413,13 +1413,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 granting or - // revoking them needs no per-role validation. - grantedRoles = slices.DeleteFunc(grantedRoles, func(r rbac.RoleIdentifier) bool { - return rbac.IsLegacyRoleName(r.Name) - }) customRoles := make([]rbac.RoleIdentifier, 0) // Validate that the roles being assigned are valid. for _, r := range grantedRoles { diff --git a/coderd/database/migrations/000587_remove_agents_access_role.down.sql b/coderd/database/migrations/000587_remove_agents_access_role.down.sql new file mode 100644 index 00000000000..668c33f4a5b --- /dev/null +++ b/coderd/database/migrations/000587_remove_agents_access_role.down.sql @@ -0,0 +1,13 @@ +-- Older binaries need agents-access unless another role grants chat access. +-- Exact prior grants cannot be reconstructed, so restore the role to all +-- current non-service-account memberships. +UPDATE organization_members om +SET roles = array_append(om.roles, 'agents-access') +FROM users u +WHERE u.id = om.user_id + AND NOT u.is_service_account + AND NOT ('agents-access' = ANY(om.roles)); + +-- Defaults are not restored: older binaries union default_org_member_roles +-- into service-account memberships too. Pre-upgrade explicit service-account +-- grants stay lost; an admin can re-grant them. diff --git a/coderd/database/migrations/000587_remove_agents_access_role.up.sql b/coderd/database/migrations/000587_remove_agents_access_role.up.sql new file mode 100644 index 00000000000..086d97045e9 --- /dev/null +++ b/coderd/database/migrations/000587_remove_agents_access_role.up.sql @@ -0,0 +1,11 @@ +UPDATE users +SET rbac_roles = array_remove(rbac_roles, 'agents-access') +WHERE 'agents-access' = ANY(rbac_roles); + +UPDATE organization_members +SET roles = array_remove(roles, 'agents-access') +WHERE 'agents-access' = ANY(roles); + +UPDATE organizations +SET default_org_member_roles = array_remove(default_org_member_roles, 'agents-access') +WHERE 'agents-access' = ANY(default_org_member_roles); diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 53050bb8ed7..3fd9040d22b 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -1191,6 +1191,74 @@ func TestMigration000475AgentsAccessOrgRole(t *testing.T) { ) } +func TestMigration000587RemoveAgentsAccessRole(t *testing.T) { + t.Parallel() + + const migrationVersion = 587 + + // The immediately preceding migration numbers may not exist in this + // tree (the target is numbered past migrations that landed on main + // separately), so step to the highest version below the target rather + // than assuming migrationVersion-1 exists. + entries, err := os.ReadDir(".") + require.NoError(t, err) + prevVersion := uint(0) + for _, entry := range entries { + var version uint + if _, err := fmt.Sscanf(entry.Name(), "%d_", &version); err != nil { + continue + } + if version > prevVersion && version < migrationVersion { + prevVersion = version + } + } + require.NotZero(t, prevVersion) + + sqlDB := testSQLDB(t) + next, err := migrations.Stepper(sqlDB) + require.NoError(t, err) + for { + version, more, err := next() + require.NoError(t, err) + if !more { + t.Fatalf("migration %d not found", migrationVersion) + } + if version == prevVersion { + break + } + } + + db := database.New(sqlDB) + user := dbgen.User(t, db, database.User{ + RBACRoles: []string{"auditor", "agents-access"}, + }) + org := dbgen.Organization(t, db, database.Organization{ + DefaultOrgMemberRoles: []string{"organization-workspace-access", "agents-access"}, + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: org.ID, + UserID: user.ID, + Roles: []string{"organization-auditor", "agents-access"}, + }) + + version, _, err := next() + require.NoError(t, err) + require.EqualValues(t, migrationVersion, version) + + ctx := testutil.Context(t, testutil.WaitLong) + var siteRoles, orgRoles, defaultRoles pq.StringArray + err = sqlDB.QueryRowContext(ctx, "SELECT rbac_roles FROM users WHERE id = $1", user.ID).Scan(&siteRoles) + require.NoError(t, err) + err = sqlDB.QueryRowContext(ctx, "SELECT roles FROM organization_members WHERE organization_id = $1 AND user_id = $2", org.ID, user.ID).Scan(&orgRoles) + require.NoError(t, err) + err = sqlDB.QueryRowContext(ctx, "SELECT default_org_member_roles FROM organizations WHERE id = $1", org.ID).Scan(&defaultRoles) + require.NoError(t, err) + + require.Equal(t, []string{"auditor"}, []string(siteRoles)) + require.Equal(t, []string{"organization-auditor"}, []string(orgRoles)) + require.Equal(t, []string{"organization-workspace-access"}, []string(defaultRoles)) +} + func TestMigration000504AIProvidersBackfill(t *testing.T) { t.Parallel() diff --git a/coderd/members_test.go b/coderd/members_test.go index 5b4b5c0f58f..c2bf219c1eb 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -12,8 +12,6 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -52,72 +50,6 @@ func TestAddMember(t *testing.T) { }) } -// TestMembersWithLegacyRole verifies that stale grants of retired built-in -// roles, which linger in the database until a cleanup migration lands, do -// not break membership management. -func TestMembersWithLegacyRole(t *testing.T) { - t.Parallel() - - // The raw, unauthorized store is required to seed stale data without - // tripping the assignment validation under test. - db, ps := dbtestutil.NewDB(t) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) - owner := coderdtest.CreateFirstUser(t, client) - memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - ctx := testutil.Context(t, testutil.WaitMedium) - - // Seed stale data in the store: the retired agents-access role remains - // in a member's role array and in the org's default member roles. - _, err := db.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{ - GrantedRoles: []string{"agents-access"}, - UserID: member.ID, - OrgID: owner.OrganizationID, - }) - require.NoError(t, err) - - org, err := db.GetOrganizationByID(ctx, owner.OrganizationID) - require.NoError(t, err) - updateOrg := database.UpdateOrganizationParams{ - ID: org.ID, - UpdatedAt: dbtime.Now(), - Name: org.Name, - DisplayName: org.DisplayName, - Description: org.Description, - Icon: org.Icon, - DefaultOrgMemberRoles: append(org.DefaultOrgMemberRoles, "agents-access"), - } - _, err = db.UpdateOrganization(ctx, updateOrg) - require.NoError(t, err) - - // Requests expand the member's stored roles, including the stale grant. - _, err = memberClient.User(ctx, codersdk.Me) - require.NoError(t, err) - - // User creation inserts an organization membership, which validates the - // org's default member roles. The stale default must not fail it. - coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - // Restore the defaults so the stale grant is no longer implied and the - // next update must validate its removal. - updateOrg.DefaultOrgMemberRoles = org.DefaultOrgMemberRoles - _, err = db.UpdateOrganization(ctx, updateOrg) - require.NoError(t, err) - - // Role updates validate the removed set, which includes the stale - // grant. The update must succeed and strip the retired role. - updated, err := client.UpdateOrganizationMemberRoles(ctx, owner.OrganizationID, member.ID.String(), codersdk.UpdateRoles{ - Roles: []string{codersdk.RoleOrganizationAuditor}, - }) - require.NoError(t, err) - names := make([]string, 0, len(updated.Roles)) - for _, role := range updated.Roles { - names = append(names, role.Name) - } - require.Contains(t, names, codersdk.RoleOrganizationAuditor) - require.NotContains(t, names, "agents-access") -} - func TestDeleteMember(t *testing.T) { t.Parallel() diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 9e67fe1ade9..5dd83b7e4dc 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -343,20 +343,11 @@ type RoleOptions struct { // legacyRoleNames contains retired built-in role names. They stay reserved so // a custom role cannot take a name that older binaries still resolve as a // built-in role, which would silently shadow the custom permissions on -// rollback. Stored role arrays and org default role lists may still contain -// these names until a data cleanup migration lands, so role expansion and -// assignment validation treat them as grants of nothing instead of failing. +// rollback. var legacyRoleNames = map[string]struct{}{ "agents-access": {}, } -// IsLegacyRoleName reports whether name is a retired built-in role name that -// may still appear in stored role arrays. -func IsLegacyRoleName(name string) bool { - _, ok := legacyRoleNames[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 { @@ -988,12 +979,6 @@ func RoleByName(name RoleIdentifier) (Role, error) { func rolesByNames(roleNames []RoleIdentifier) ([]Role, error) { roles := make([]Role, 0, len(roleNames)) for _, n := range roleNames { - if IsLegacyRoleName(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) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 0363f9da81e..7d8b9b2cd8b 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1804,31 +1804,3 @@ func TestDBPurgeBoundaryLogDelete(t *testing.T) { rbac.ResourceBoundaryLog) require.Error(t, err, "DBPurge must not read boundary logs") } - -// TestLegacyRoleNames verifies retired built-in role names stay reserved and -// that stale stored grants of them expand to nothing instead of failing. -func TestLegacyRoleNames(t *testing.T) { - t.Parallel() - - const legacyName = "agents-access" - orgID := uuid.New() - - require.True(t, rbac.IsLegacyRoleName(legacyName)) - // Legacy names stay reserved so custom roles cannot shadow them. - require.True(t, rbac.ReservedRoleName(legacyName)) - - // Legacy names do not resolve to a role... - _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: legacyName}) - require.Error(t, err) - - // ...but stored role arrays may still contain them, so expansion drops - // them instead of failing. - roles, err := rbac.RoleIdentifiers{ - {Name: legacyName}, - {Name: legacyName, OrganizationID: orgID}, - rbac.ScopedRoleOrgAuditor(orgID), - }.Expand() - require.NoError(t, err) - require.Len(t, roles, 1) - require.Equal(t, rbac.ScopedRoleOrgAuditor(orgID), roles[0].Identifier) -} From cbdad541b41d934694fe76569c72b4af1bd0f923 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:04:03 +0000 Subject: [PATCH 09/17] fix(coderd): drop retired role names in rolestore expansion and API responses --- coderd/database/db2sdk/db2sdk.go | 13 ++++++++----- coderd/members.go | 8 +++++++- coderd/members_test.go | 18 ++++++++++++++++++ coderd/rbac/rolestore/rolestore.go | 8 ++++++++ coderd/rbac/rolestore/rolestore_test.go | 24 ++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 4713bbfeeee..bba228b555c 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -919,11 +919,14 @@ func Organization(organization database.Organization) codersdk.Organization { DisplayName: organization.DisplayName, Icon: organization.Icon, }, - Description: organization.Description, - CreatedAt: organization.CreatedAt, - UpdatedAt: organization.UpdatedAt, - IsDefault: organization.IsDefault, - DefaultOrgMemberRoles: organization.DefaultOrgMemberRoles, + Description: organization.Description, + CreatedAt: organization.CreatedAt, + UpdatedAt: organization.UpdatedAt, + IsDefault: organization.IsDefault, + // Stored default role lists may retain retired built-in role names + // until a cleanup migration lands. Hide them so settings forms do + // not display or resubmit them. + DefaultOrgMemberRoles: slices.DeleteFunc(slices.Clone(organization.DefaultOrgMemberRoles), rbac.IsLegacyRoleName), } } diff --git a/coderd/members.go b/coderd/members.go index 21728c065d5..4faf30627c1 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "net/http" + "slices" "github.com/google/uuid" "golang.org/x/xerrors" @@ -510,12 +511,17 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d roleLookup := make([]database.NameOrganizationPair, 0) for _, m := range mems { + // Stored role arrays may retain retired built-in role names until a + // cleanup migration lands. They grant nothing, so hide them from + // responses to keep role editors from displaying or resubmitting + // them. + activeRoles := slices.DeleteFunc(slices.Clone(m.Roles), rbac.IsLegacyRoleName) converted = append(converted, codersdk.OrganizationMember{ UserID: m.UserID, OrganizationID: m.OrganizationID, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, - Roles: slice.List(m.Roles, func(r string) codersdk.SlimRole { + Roles: slice.List(activeRoles, func(r string) codersdk.SlimRole { // If it is a built-in role, no lookups are needed. rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: r, OrganizationID: m.OrganizationID}) if err == nil { diff --git a/coderd/members_test.go b/coderd/members_test.go index 5b4b5c0f58f..ad4883e53f4 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -98,6 +98,24 @@ func TestMembersWithLegacyRole(t *testing.T) { // org's default member roles. The stale default must not fail it. coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + // Membership responses hide the stale grant so role editors do not + // display or resubmit it. + members, err := client.OrganizationMembers(ctx, owner.OrganizationID) + require.NoError(t, err) + for _, m := range members { + if m.UserID != member.ID { + continue + } + for _, role := range m.Roles { + require.NotEqual(t, "agents-access", role.Name) + } + } + + // Organization responses hide the stale default role the same way. + orgResp, err := client.Organization(ctx, owner.OrganizationID) + require.NoError(t, err) + require.NotContains(t, orgResp.DefaultOrgMemberRoles, "agents-access") + // Restore the defaults so the stale grant is no longer implied and the // next update must validate its removal. updateOrg.DefaultOrgMemberRoles = org.DefaultOrgMemberRoles diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 233df7ae527..ee0be298283 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -82,6 +82,14 @@ func Expand(ctx context.Context, db database.Store, names []rbac.RoleIdentifier) roles := make([]rbac.Role, 0, len(names)) for _, name := range names { + if rbac.IsLegacyRoleName(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 78a2c3de626..840a7f26130 100644 --- a/coderd/rbac/rolestore/rolestore_test.go +++ b/coderd/rbac/rolestore/rolestore_test.go @@ -46,6 +46,30 @@ func TestExpandCustomRoleRoles(t *testing.T) { require.Len(t, roles, 1, "role found") } +func TestExpandLegacyRoleName(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() From e6f8311ecf0ae1d8388efab941ee9e3ae20610f1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:04:41 +0000 Subject: [PATCH 10/17] fix(site/src/pages/AgentsPage): make chat access denied copy permission-neutral --- .../pages/AgentsPage/components/AgentCreateForm.stories.tsx | 6 +++--- .../pages/AgentsPage/components/ChatAccessDeniedAlert.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 59c44520389..ff8bf8beabb 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -2009,7 +2009,7 @@ export const EmptyPermittedSetPreservesStoredWorkspace: Story = { revocablePermissions[MockOrganization2.id] = false; await revocableQueryClient?.invalidateQueries(); - await canvas.findByText(/You need access to an organization/i); + await canvas.findByText(/don't have permission to use Coder Agents/i); revocablePermissions[MockOrganization2.id] = true; await revocableQueryClient?.invalidateQueries(); @@ -2238,7 +2238,7 @@ export const ForbiddenNoOrganizationAccess: Story = { const canvas = within(canvasElement); await expect(canvas.getByText("Permission required")).toBeInTheDocument(); await expect( - canvas.getByText(/You need access to an organization/), + canvas.getByText(/don't have permission to use Coder Agents/), ).toBeInTheDocument(); await expect( canvas.getByRole("link", { name: /View Docs/ }), @@ -2292,7 +2292,7 @@ export const PermittedOrgsResolvesToEmpty: Story = { await waitFor( () => { expect( - canvas.getByText(/You need access to an organization/i), + canvas.getByText(/don't have permission to use Coder Agents/i), ).toBeInTheDocument(); }, { timeout: 3000 }, diff --git a/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx b/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx index 15b4f0b1df3..a61aed98108 100644 --- a/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx +++ b/site/src/pages/AgentsPage/components/ChatAccessDeniedAlert.tsx @@ -18,8 +18,8 @@ export const ChatAccessDeniedAlert: FC = () => { > Permission required - You need access to an organization to use Coder Agents. Contact your - Coder administrator for access, then refresh this page.{" "} + You don't have permission to use Coder Agents. Contact your Coder + administrator, then refresh this page.{" "} View Docs From 861c3785cfebe53e877f91bf96745259e0a16ec4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:14:00 +0000 Subject: [PATCH 11/17] docs(docs/ai-coder/agents): reflow prerequisites bullet to one sentence per line --- docs/ai-coder/agents/getting-started.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/ai-coder/agents/getting-started.md b/docs/ai-coder/agents/getting-started.md index 010e86d834c..87de3ba8f6f 100644 --- a/docs/ai-coder/agents/getting-started.md +++ b/docs/ai-coder/agents/getting-started.md @@ -20,11 +20,10 @@ Before you begin, confirm the following: - **Access to configure models** in each organization where you configure models. 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. 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. See - [Manage model permissions](./models.md#manage-model-permissions). +- **Organization membership** for each user who uses 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. + See [Manage model permissions](./models.md#manage-model-permissions). ## Step 1: Configure an LLM provider and model From 73e1352c5318072b48b06092887db80d0fdaf28e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:24:39 +0000 Subject: [PATCH 12/17] fix(coderd): reject explicit grants of retired role names --- coderd/database/dbauthz/dbauthz.go | 25 +++++++++++++++++++++++-- coderd/members_test.go | 13 +++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6849c9af08e..f8aab4a70dc 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1415,8 +1415,10 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, re 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 granting or - // revoking them needs no per-role validation. + // 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.IsLegacyRoleName(r.Name) }) @@ -7873,6 +7875,16 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb if err != nil { return database.OrganizationMember{}, err } + // Explicitly granting a retired role is rejected. Legacy-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.IsLegacyRoleName(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 @@ -8499,6 +8511,15 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo return database.User{}, err } + // Explicitly granting a retired role is rejected. Legacy-name tolerance + // only covers stored grants that linger until the cleanup migration + // lands. + for _, roleName := range arg.GrantedRoles { + if rbac.IsLegacyRoleName(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/members_test.go b/coderd/members_test.go index ad4883e53f4..c770fa53911 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -134,6 +134,19 @@ func TestMembersWithLegacyRole(t *testing.T) { } require.Contains(t, names, codersdk.RoleOrganizationAuditor) require.NotContains(t, names, "agents-access") + + // Explicitly granting the retired role again is rejected for both org + // and site scope, so tolerance of stale data cannot be used to store + // fresh grants that a binary rollback would resolve again. + _, err = client.UpdateOrganizationMemberRoles(ctx, owner.OrganizationID, member.ID.String(), codersdk.UpdateRoles{ + Roles: []string{codersdk.RoleOrganizationAuditor, "agents-access"}, + }) + require.ErrorContains(t, err, "retired") + + _, err = client.UpdateUserRoles(ctx, member.ID.String(), codersdk.UpdateRoles{ + Roles: []string{"agents-access"}, + }) + require.ErrorContains(t, err, "retired") } func TestDeleteMember(t *testing.T) { From 68892380d62bf3355b88a2874a3d2df9104a187e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:42:09 +0000 Subject: [PATCH 13/17] fix(coderd): extend retired-role rejection to org defaults and assignment listings Also renames IsLegacyRoleName to IsRetiredRoleName so the helpers are named for behavior rather than history. --- coderd/database/db2sdk/db2sdk.go | 2 +- coderd/database/dbauthz/dbauthz.go | 19 ++++++++++++++----- coderd/members.go | 2 +- coderd/members_test.go | 25 +++++++++++++++++++++++-- coderd/rbac/roles.go | 14 +++++++------- coderd/rbac/roles_test.go | 20 ++++++++++---------- coderd/rbac/rolestore/rolestore.go | 2 +- coderd/rbac/rolestore/rolestore_test.go | 2 +- coderd/roles.go | 7 +++++++ 9 files changed, 65 insertions(+), 28 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index bba228b555c..64f1c82f198 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -926,7 +926,7 @@ func Organization(organization database.Organization) codersdk.Organization { // Stored default role lists may retain retired built-in role names // until a cleanup migration lands. Hide them so settings forms do // not display or resubmit them. - DefaultOrgMemberRoles: slices.DeleteFunc(slices.Clone(organization.DefaultOrgMemberRoles), rbac.IsLegacyRoleName), + DefaultOrgMemberRoles: slices.DeleteFunc(slices.Clone(organization.DefaultOrgMemberRoles), rbac.IsRetiredRoleName), } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index f8aab4a70dc..3bb40f84678 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1420,7 +1420,7 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, re // 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.IsLegacyRoleName(r.Name) + return rbac.IsRetiredRoleName(r.Name) }) customRoles := make([]rbac.RoleIdentifier, 0) // Validate that the roles being assigned are valid. @@ -7875,13 +7875,13 @@ func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemb if err != nil { return database.OrganizationMember{}, err } - // Explicitly granting a retired role is rejected. Legacy-name tolerance + // 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.IsLegacyRoleName(role.Name) { + if rbac.IsRetiredRoleName(role.Name) { return database.OrganizationMember{}, xerrors.Errorf("role %q is retired and cannot be assigned", role.Name) } } @@ -7961,6 +7961,15 @@ 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 } @@ -8511,11 +8520,11 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo return database.User{}, err } - // Explicitly granting a retired role is rejected. Legacy-name tolerance + // 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.IsLegacyRoleName(roleName) { + if rbac.IsRetiredRoleName(roleName) { return database.User{}, xerrors.Errorf("role %q is retired and cannot be assigned", roleName) } } diff --git a/coderd/members.go b/coderd/members.go index 4faf30627c1..4bf80b6c917 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -515,7 +515,7 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d // cleanup migration lands. They grant nothing, so hide them from // responses to keep role editors from displaying or resubmitting // them. - activeRoles := slices.DeleteFunc(slices.Clone(m.Roles), rbac.IsLegacyRoleName) + activeRoles := slices.DeleteFunc(slices.Clone(m.Roles), rbac.IsRetiredRoleName) converted = append(converted, codersdk.OrganizationMember{ UserID: m.UserID, OrganizationID: m.OrganizationID, diff --git a/coderd/members_test.go b/coderd/members_test.go index c770fa53911..a1110458226 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -6,14 +6,17 @@ import ( "testing" "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -52,10 +55,10 @@ func TestAddMember(t *testing.T) { }) } -// TestMembersWithLegacyRole verifies that stale grants of retired built-in +// TestMembersWithRetiredRole verifies that stale grants of retired built-in // roles, which linger in the database until a cleanup migration lands, do // not break membership management. -func TestMembersWithLegacyRole(t *testing.T) { +func TestMembersWithRetiredRole(t *testing.T) { t.Parallel() // The raw, unauthorized store is required to seed stale data without @@ -147,6 +150,24 @@ func TestMembersWithLegacyRole(t *testing.T) { Roles: []string{"agents-access"}, }) require.ErrorContains(t, err, "retired") + + // Adding the retired role to the org defaults is rejected at the + // authorization boundary, mirroring the explicit grant paths. The + // authorized store is used on purpose, unlike the raw seeding above. + authzdb := dbauthz.New(db, rbac.NewAuthorizer(prometheus.NewRegistry()), testutil.Logger(t), coderdtest.AccessControlStorePointer()) + ownerUser, err := client.User(ctx, codersdk.Me) + require.NoError(t, err) + ownerSubject := coderdtest.AuthzUserSubjectWithDB(ctx, t, db, ownerUser) + _, err = authzdb.UpdateOrganization(dbauthz.As(ctx, ownerSubject), database.UpdateOrganizationParams{ + ID: org.ID, + UpdatedAt: dbtime.Now(), + Name: org.Name, + DisplayName: org.DisplayName, + Description: org.Description, + Icon: org.Icon, + DefaultOrgMemberRoles: append(org.DefaultOrgMemberRoles, "agents-access"), + }) + require.ErrorContains(t, err, "retired") } func TestDeleteMember(t *testing.T) { diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 9e67fe1ade9..0e483d79172 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -340,27 +340,27 @@ type RoleOptions struct { NoChatSharing bool } -// legacyRoleNames contains retired built-in role names. They stay reserved so +// retiredRoleNames contains retired built-in role names. They stay reserved so // a custom role cannot take a name that older binaries still resolve as a // built-in role, which would silently shadow the custom permissions on // rollback. Stored role arrays and org default role lists may still contain // these names until a data cleanup migration lands, so role expansion and // assignment validation treat them as grants of nothing instead of failing. -var legacyRoleNames = map[string]struct{}{ +var retiredRoleNames = map[string]struct{}{ "agents-access": {}, } -// IsLegacyRoleName reports whether name is a retired built-in role name that +// IsRetiredRoleName reports whether name is a retired built-in role name that // may still appear in stored role arrays. -func IsLegacyRoleName(name string) bool { - _, ok := legacyRoleNames[name] +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 := legacyRoleNames[name]; ok { + if _, ok := retiredRoleNames[name]; ok { return true } _, ok := loadBuiltinRoles()[name] @@ -988,7 +988,7 @@ func RoleByName(name RoleIdentifier) (Role, error) { func rolesByNames(roleNames []RoleIdentifier) ([]Role, error) { roles := make([]Role, 0, len(roleNames)) for _, n := range roleNames { - if IsLegacyRoleName(n.Name) { + 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. diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 0363f9da81e..9029236c3ff 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1805,27 +1805,27 @@ func TestDBPurgeBoundaryLogDelete(t *testing.T) { require.Error(t, err, "DBPurge must not read boundary logs") } -// TestLegacyRoleNames verifies retired built-in role names stay reserved and +// TestRetiredRoleNames verifies retired built-in role names stay reserved and // that stale stored grants of them expand to nothing instead of failing. -func TestLegacyRoleNames(t *testing.T) { +func TestRetiredRoleNames(t *testing.T) { t.Parallel() - const legacyName = "agents-access" + const retiredName = "agents-access" orgID := uuid.New() - require.True(t, rbac.IsLegacyRoleName(legacyName)) - // Legacy names stay reserved so custom roles cannot shadow them. - require.True(t, rbac.ReservedRoleName(legacyName)) + require.True(t, rbac.IsRetiredRoleName(retiredName)) + // Retired names stay reserved so custom roles cannot shadow them. + require.True(t, rbac.ReservedRoleName(retiredName)) - // Legacy names do not resolve to a role... - _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: legacyName}) + // 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: legacyName}, - {Name: legacyName, OrganizationID: orgID}, + {Name: retiredName}, + {Name: retiredName, OrganizationID: orgID}, rbac.ScopedRoleOrgAuditor(orgID), }.Expand() require.NoError(t, err) diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index ee0be298283..145a6c27fdc 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -82,7 +82,7 @@ func Expand(ctx context.Context, db database.Store, names []rbac.RoleIdentifier) roles := make([]rbac.Role, 0, len(names)) for _, name := range names { - if rbac.IsLegacyRoleName(name.Name) { + 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 diff --git a/coderd/rbac/rolestore/rolestore_test.go b/coderd/rbac/rolestore/rolestore_test.go index 840a7f26130..1d35b90ff0f 100644 --- a/coderd/rbac/rolestore/rolestore_test.go +++ b/coderd/rbac/rolestore/rolestore_test.go @@ -46,7 +46,7 @@ func TestExpandCustomRoleRoles(t *testing.T) { require.Len(t, roles, 1, "role found") } -func TestExpandLegacyRoleName(t *testing.T) { +func TestExpandRetiredRoleName(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) diff --git a/coderd/roles.go b/coderd/roles.go index 500ada46e46..db6009c53de 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -101,6 +101,13 @@ 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)) From af0ab2904a1edc83539098bb7f72d1b74ceb83e5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:44:36 +0000 Subject: [PATCH 14/17] docs(docs/ai-coder/agents): exclude service accounts from chat permission claim --- docs/ai-coder/agents/models.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 2312f27f9cb..a3a8b17c416 100644 --- a/docs/ai-coder/agents/models.md +++ b/docs/ai-coder/agents/models.md @@ -142,7 +142,7 @@ 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 hold chat permissions, but a member without read access to at least one model in the organization can't use the feature. +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. New models grant read access to the whole organization by default. 1. Navigate to **Admin settings** > **AI** > **Models**. From e1e6f9f1f6d957e8b3ed91760b2445484489cffc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:54:26 +0000 Subject: [PATCH 15/17] fix(coderd): hide retired role names from the user-roles response --- coderd/members_test.go | 6 ++++++ coderd/users.go | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/coderd/members_test.go b/coderd/members_test.go index a1110458226..9876436b5ad 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -119,6 +119,12 @@ func TestMembersWithRetiredRole(t *testing.T) { require.NoError(t, err) require.NotContains(t, orgResp.DefaultOrgMemberRoles, "agents-access") + // The user-roles endpoint hides the stale grant the same way. + userRoles, err := memberClient.UserRoles(ctx, codersdk.Me) + require.NoError(t, err) + require.NotContains(t, userRoles.Roles, "agents-access") + require.NotContains(t, userRoles.OrganizationRoles[owner.OrganizationID], "agents-access") + // Restore the defaults so the stale grant is no longer implied and the // next update must validate its removal. updateOrg.DefaultOrgMemberRoles = org.DefaultOrgMemberRoles diff --git a/coderd/users.go b/coderd/users.go index 8dae291a5bd..b5dbf976869 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1767,8 +1767,11 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) { } // TODO: Replace this with "GetAuthorizationUserRoles" + // Stored role arrays may retain retired built-in role names until a + // cleanup migration lands. Hide them so consumers do not display or + // resubmit them. resp := codersdk.UserRoles{ - Roles: user.RBACRoles, + Roles: slices.DeleteFunc(slices.Clone(user.RBACRoles), rbac.IsRetiredRoleName), OrganizationRoles: make(map[uuid.UUID][]string), } @@ -1787,7 +1790,7 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) { } for _, mem := range memberships { - resp.OrganizationRoles[mem.OrganizationMember.OrganizationID] = mem.OrganizationMember.Roles + resp.OrganizationRoles[mem.OrganizationMember.OrganizationID] = slices.DeleteFunc(slices.Clone(mem.OrganizationMember.Roles), rbac.IsRetiredRoleName) } httpapi.Write(ctx, rw, http.StatusOK, resp) From f429c4cfded4ed3e238d973f4bed3c4ce29753b9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:08:36 +0000 Subject: [PATCH 16/17] fix: hide retired roles in user payloads and keep retired custom roles deletable --- coderd/database/db2sdk/db2sdk.go | 6 ++++++ coderd/members_test.go | 18 ++++++++++++++++- enterprise/coderd/roles.go | 6 ++++-- enterprise/coderd/roles_test.go | 33 ++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 64f1c82f198..9e0b19986c8 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -847,6 +847,12 @@ func SlimRolesFromNames(names []string) []codersdk.SlimRole { convertedRoles := make([]codersdk.SlimRole, 0, len(names)) for _, name := range names { + // Stored role arrays may retain retired built-in role names until + // a cleanup migration lands. Hide them so consumers do not display + // or resubmit them. + if rbac.IsRetiredRoleName(name) { + continue + } convertedRoles = append(convertedRoles, SlimRoleFromName(name)) } diff --git a/coderd/members_test.go b/coderd/members_test.go index 9876436b5ad..e83990b6e91 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -71,13 +71,19 @@ func TestMembersWithRetiredRole(t *testing.T) { ctx := testutil.Context(t, testutil.WaitMedium) // Seed stale data in the store: the retired agents-access role remains - // in a member's role array and in the org's default member roles. + // in a member's site-wide and org role arrays and in the org's default + // member roles. _, err := db.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{ GrantedRoles: []string{"agents-access"}, UserID: member.ID, OrgID: owner.OrganizationID, }) require.NoError(t, err) + _, err = db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ + GrantedRoles: []string{"agents-access"}, + ID: member.ID, + }) + require.NoError(t, err) org, err := db.GetOrganizationByID(ctx, owner.OrganizationID) require.NoError(t, err) @@ -112,6 +118,16 @@ func TestMembersWithRetiredRole(t *testing.T) { for _, role := range m.Roles { require.NotEqual(t, "agents-access", role.Name) } + for _, role := range m.GlobalRoles { + require.NotEqual(t, "agents-access", role.Name) + } + } + + // User payloads hide the stale site-wide grant the same way. + userResp, err := client.User(ctx, member.ID.String()) + require.NoError(t, err) + for _, role := range userResp.Roles { + require.NotEqual(t, "agents-access", role.Name) } // Organization responses hide the stale default role the same way. diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go index 318138c0b92..c4ea6607bf0 100644 --- a/enterprise/coderd/roles.go +++ b/enterprise/coderd/roles.go @@ -205,8 +205,10 @@ func (api *API) deleteOrgRole(rw http.ResponseWriter, r *http.Request) { rolename := chi.URLParam(r, "roleName") - // Catch requests that try to delete system roles. - if !validOrganizationRoleRequest(ctx, codersdk.CustomRoleRequest{Name: rolename}, rw) { + // 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) { return } diff --git a/enterprise/coderd/roles_test.go b/enterprise/coderd/roles_test.go index 62fe743a561..1def3e0846c 100644 --- a/enterprise/coderd/roles_test.go +++ b/enterprise/coderd/roles_test.go @@ -11,7 +11,10 @@ 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" @@ -330,6 +333,36 @@ 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{ From b33e3394d8591079e500233583c3736d21143bff Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:50:08 +0000 Subject: [PATCH 17/17] refactor(coderd): drop stale retired-role response filtering --- coderd/database/db2sdk/db2sdk.go | 19 ++--- coderd/members.go | 8 +- coderd/members_test.go | 142 ------------------------------- coderd/users.go | 7 +- 4 files changed, 8 insertions(+), 168 deletions(-) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 9e0b19986c8..4713bbfeeee 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -847,12 +847,6 @@ func SlimRolesFromNames(names []string) []codersdk.SlimRole { convertedRoles := make([]codersdk.SlimRole, 0, len(names)) for _, name := range names { - // Stored role arrays may retain retired built-in role names until - // a cleanup migration lands. Hide them so consumers do not display - // or resubmit them. - if rbac.IsRetiredRoleName(name) { - continue - } convertedRoles = append(convertedRoles, SlimRoleFromName(name)) } @@ -925,14 +919,11 @@ func Organization(organization database.Organization) codersdk.Organization { DisplayName: organization.DisplayName, Icon: organization.Icon, }, - Description: organization.Description, - CreatedAt: organization.CreatedAt, - UpdatedAt: organization.UpdatedAt, - IsDefault: organization.IsDefault, - // Stored default role lists may retain retired built-in role names - // until a cleanup migration lands. Hide them so settings forms do - // not display or resubmit them. - DefaultOrgMemberRoles: slices.DeleteFunc(slices.Clone(organization.DefaultOrgMemberRoles), rbac.IsRetiredRoleName), + Description: organization.Description, + CreatedAt: organization.CreatedAt, + UpdatedAt: organization.UpdatedAt, + IsDefault: organization.IsDefault, + DefaultOrgMemberRoles: organization.DefaultOrgMemberRoles, } } diff --git a/coderd/members.go b/coderd/members.go index 4bf80b6c917..21728c065d5 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -5,7 +5,6 @@ import ( "database/sql" "fmt" "net/http" - "slices" "github.com/google/uuid" "golang.org/x/xerrors" @@ -511,17 +510,12 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d roleLookup := make([]database.NameOrganizationPair, 0) for _, m := range mems { - // Stored role arrays may retain retired built-in role names until a - // cleanup migration lands. They grant nothing, so hide them from - // responses to keep role editors from displaying or resubmitting - // them. - activeRoles := slices.DeleteFunc(slices.Clone(m.Roles), rbac.IsRetiredRoleName) converted = append(converted, codersdk.OrganizationMember{ UserID: m.UserID, OrganizationID: m.OrganizationID, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, - Roles: slice.List(activeRoles, func(r string) codersdk.SlimRole { + Roles: slice.List(m.Roles, func(r string) codersdk.SlimRole { // If it is a built-in role, no lookups are needed. rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: r, OrganizationID: m.OrganizationID}) if err == nil { diff --git a/coderd/members_test.go b/coderd/members_test.go index e83990b6e91..c2bf219c1eb 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -6,17 +6,12 @@ import ( "testing" "github.com/google/uuid" - "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" - "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -55,143 +50,6 @@ func TestAddMember(t *testing.T) { }) } -// TestMembersWithRetiredRole verifies that stale grants of retired built-in -// roles, which linger in the database until a cleanup migration lands, do -// not break membership management. -func TestMembersWithRetiredRole(t *testing.T) { - t.Parallel() - - // The raw, unauthorized store is required to seed stale data without - // tripping the assignment validation under test. - db, ps := dbtestutil.NewDB(t) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) - owner := coderdtest.CreateFirstUser(t, client) - memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - ctx := testutil.Context(t, testutil.WaitMedium) - - // Seed stale data in the store: the retired agents-access role remains - // in a member's site-wide and org role arrays and in the org's default - // member roles. - _, err := db.UpdateMemberRoles(ctx, database.UpdateMemberRolesParams{ - GrantedRoles: []string{"agents-access"}, - UserID: member.ID, - OrgID: owner.OrganizationID, - }) - require.NoError(t, err) - _, err = db.UpdateUserRoles(ctx, database.UpdateUserRolesParams{ - GrantedRoles: []string{"agents-access"}, - ID: member.ID, - }) - require.NoError(t, err) - - org, err := db.GetOrganizationByID(ctx, owner.OrganizationID) - require.NoError(t, err) - updateOrg := database.UpdateOrganizationParams{ - ID: org.ID, - UpdatedAt: dbtime.Now(), - Name: org.Name, - DisplayName: org.DisplayName, - Description: org.Description, - Icon: org.Icon, - DefaultOrgMemberRoles: append(org.DefaultOrgMemberRoles, "agents-access"), - } - _, err = db.UpdateOrganization(ctx, updateOrg) - require.NoError(t, err) - - // Requests expand the member's stored roles, including the stale grant. - _, err = memberClient.User(ctx, codersdk.Me) - require.NoError(t, err) - - // User creation inserts an organization membership, which validates the - // org's default member roles. The stale default must not fail it. - coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - - // Membership responses hide the stale grant so role editors do not - // display or resubmit it. - members, err := client.OrganizationMembers(ctx, owner.OrganizationID) - require.NoError(t, err) - for _, m := range members { - if m.UserID != member.ID { - continue - } - for _, role := range m.Roles { - require.NotEqual(t, "agents-access", role.Name) - } - for _, role := range m.GlobalRoles { - require.NotEqual(t, "agents-access", role.Name) - } - } - - // User payloads hide the stale site-wide grant the same way. - userResp, err := client.User(ctx, member.ID.String()) - require.NoError(t, err) - for _, role := range userResp.Roles { - require.NotEqual(t, "agents-access", role.Name) - } - - // Organization responses hide the stale default role the same way. - orgResp, err := client.Organization(ctx, owner.OrganizationID) - require.NoError(t, err) - require.NotContains(t, orgResp.DefaultOrgMemberRoles, "agents-access") - - // The user-roles endpoint hides the stale grant the same way. - userRoles, err := memberClient.UserRoles(ctx, codersdk.Me) - require.NoError(t, err) - require.NotContains(t, userRoles.Roles, "agents-access") - require.NotContains(t, userRoles.OrganizationRoles[owner.OrganizationID], "agents-access") - - // Restore the defaults so the stale grant is no longer implied and the - // next update must validate its removal. - updateOrg.DefaultOrgMemberRoles = org.DefaultOrgMemberRoles - _, err = db.UpdateOrganization(ctx, updateOrg) - require.NoError(t, err) - - // Role updates validate the removed set, which includes the stale - // grant. The update must succeed and strip the retired role. - updated, err := client.UpdateOrganizationMemberRoles(ctx, owner.OrganizationID, member.ID.String(), codersdk.UpdateRoles{ - Roles: []string{codersdk.RoleOrganizationAuditor}, - }) - require.NoError(t, err) - names := make([]string, 0, len(updated.Roles)) - for _, role := range updated.Roles { - names = append(names, role.Name) - } - require.Contains(t, names, codersdk.RoleOrganizationAuditor) - require.NotContains(t, names, "agents-access") - - // Explicitly granting the retired role again is rejected for both org - // and site scope, so tolerance of stale data cannot be used to store - // fresh grants that a binary rollback would resolve again. - _, err = client.UpdateOrganizationMemberRoles(ctx, owner.OrganizationID, member.ID.String(), codersdk.UpdateRoles{ - Roles: []string{codersdk.RoleOrganizationAuditor, "agents-access"}, - }) - require.ErrorContains(t, err, "retired") - - _, err = client.UpdateUserRoles(ctx, member.ID.String(), codersdk.UpdateRoles{ - Roles: []string{"agents-access"}, - }) - require.ErrorContains(t, err, "retired") - - // Adding the retired role to the org defaults is rejected at the - // authorization boundary, mirroring the explicit grant paths. The - // authorized store is used on purpose, unlike the raw seeding above. - authzdb := dbauthz.New(db, rbac.NewAuthorizer(prometheus.NewRegistry()), testutil.Logger(t), coderdtest.AccessControlStorePointer()) - ownerUser, err := client.User(ctx, codersdk.Me) - require.NoError(t, err) - ownerSubject := coderdtest.AuthzUserSubjectWithDB(ctx, t, db, ownerUser) - _, err = authzdb.UpdateOrganization(dbauthz.As(ctx, ownerSubject), database.UpdateOrganizationParams{ - ID: org.ID, - UpdatedAt: dbtime.Now(), - Name: org.Name, - DisplayName: org.DisplayName, - Description: org.Description, - Icon: org.Icon, - DefaultOrgMemberRoles: append(org.DefaultOrgMemberRoles, "agents-access"), - }) - require.ErrorContains(t, err, "retired") -} - func TestDeleteMember(t *testing.T) { t.Parallel() diff --git a/coderd/users.go b/coderd/users.go index b5dbf976869..8dae291a5bd 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1767,11 +1767,8 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) { } // TODO: Replace this with "GetAuthorizationUserRoles" - // Stored role arrays may retain retired built-in role names until a - // cleanup migration lands. Hide them so consumers do not display or - // resubmit them. resp := codersdk.UserRoles{ - Roles: slices.DeleteFunc(slices.Clone(user.RBACRoles), rbac.IsRetiredRoleName), + Roles: user.RBACRoles, OrganizationRoles: make(map[uuid.UUID][]string), } @@ -1790,7 +1787,7 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) { } for _, mem := range memberships { - resp.OrganizationRoles[mem.OrganizationMember.OrganizationID] = slices.DeleteFunc(slices.Clone(mem.OrganizationMember.Roles), rbac.IsRetiredRoleName) + resp.OrganizationRoles[mem.OrganizationMember.OrganizationID] = mem.OrganizationMember.Roles } httpapi.Write(ctx, rw, http.StatusOK, resp)