diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5bba83571f503..95cb98d7b5aa2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7089,6 +7089,76 @@ const docTemplate = `{ } } }, + "/api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl/available": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "MCP" + ], + "summary": "Get available MCP server config ACL users and groups", + "operationId": "get-available-mcp-server-config-acl-users-and-groups", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User search query; free-text search also applies to groups", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "User after ID", + "name": "after_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit for users and groups, if 0 returns all candidates", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "User page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ACLAvailable" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": { "get": { "tags": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index bf76c6385927f..0a1abb109fc67 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6282,6 +6282,72 @@ } } }, + "/api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl/available": { + "get": { + "produces": ["application/json"], + "tags": ["MCP"], + "summary": "Get available MCP server config ACL users and groups", + "operationId": "get-available-mcp-server-config-acl-users-and-groups", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "MCP server config ID", + "name": "mcpserverconfig", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "User search query; free-text search also applies to groups", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "User after ID", + "name": "after_id", + "in": "query" + }, + { + "type": "integer", + "description": "Page limit for users and groups, if 0 returns all candidates", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "User page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.ACLAvailable" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/oauth2/connect": { "get": { "tags": ["MCP"], diff --git a/coderd/chat_routes.go b/coderd/chat_routes.go index 70a942fc3a43c..70dc896262dd8 100644 --- a/coderd/chat_routes.go +++ b/coderd/chat_routes.go @@ -227,7 +227,7 @@ func (api *API) registerUserAIProviderKeyRoutes(r chi.Router) { // registerOrganizationChatRoutes mounts the organization-scoped chat and // MCP server configuration routes; r must already extract the // organization parameter. -func (api *API) registerOrganizationChatRoutes(r chi.Router) { +func (api *API) registerOrganizationChatRoutes(r chi.Router, prefix chatAPIPrefix) { r.Route("/mcp-servers", func(r chi.Router) { r.Get("/", api.listMCPServerConfigs) r.Post("/", api.createMCPServerConfig) @@ -242,6 +242,10 @@ func (api *API) registerOrganizationChatRoutes(r chi.Router) { policy.ActionShare)).Get("/acl", api.mcpServerConfigACL) r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize, policy.ActionShare)).Patch("/acl", api.patchMCPServerConfigACL) + if prefix == chatAPIPrefixV2 { + r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize, + policy.ActionShare)).Get("/acl/available", api.mcpServerConfigACLAvailable) + } r.With(httpmw.ExtractMCPServerConfigParam(api.Database, api.HTTPAuth.Authorize, policy.ActionRead)).Get("/oauth2/connect", api.mcpServerOAuth2Connect) }) diff --git a/coderd/chat_routes_test.go b/coderd/chat_routes_test.go index 5b54c307884b8..764e3850303c2 100644 --- a/coderd/chat_routes_test.go +++ b/coderd/chat_routes_test.go @@ -54,6 +54,7 @@ func TestChatRoutesCompatibility(t *testing.T) { {http.MethodGet, "/api/v2/chats/config/advisor"}, {http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/debug/runs", chat.ID)}, {http.MethodGet, fmt.Sprintf("/api/v2/chats/%s/stream/desktop", chat.ID)}, + {http.MethodGet, fmt.Sprintf("/api/experimental/organizations/%s/mcp-servers/not-a-uuid/acl/available", firstUser.OrganizationID)}, {http.MethodGet, "/api/v2/mcp/servers/not-a-uuid/oauth2/callback"}, {http.MethodPost, "/api/v2/mcp/http/server"}, } { diff --git a/coderd/coderd.go b/coderd/coderd.go index ce04b0cb94c5e..ae228b1a41651 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1383,7 +1383,7 @@ func New(options *Options) *API { r.Use(apiKeyMiddleware) r.Route("/{organization}", func(r chi.Router) { r.Use(httpmw.ExtractOrganizationParam(options.Database)) - api.registerOrganizationChatRoutes(r) + api.registerOrganizationChatRoutes(r, chatAPIPrefixExperimental) r.Route("/members/{user}", func(r chi.Router) { r.Use(httpmw.ExtractOrganizationMemberParam(options.Database)) api.registerOrganizationMemberChatRoutes(r) @@ -1514,7 +1514,7 @@ func New(options *Options) *API { r.Use( httpmw.ExtractOrganizationParam(options.Database), ) - api.registerOrganizationChatRoutes(r) + api.registerOrganizationChatRoutes(r, chatAPIPrefixV2) r.Get("/", api.organization) r.Post("/templateversions", api.postTemplateVersionsByOrganization) r.Route("/templates", func(r chi.Router) { diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 4713bbfeeee49..9e0b19986c8b9 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)) } @@ -919,11 +925,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.IsRetiredRoleName), } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 85429080517e4..67b0be272ab3c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1413,6 +1413,15 @@ func (q *querier) canAssignRoles(ctx context.Context, orgID uuid.UUID, added, re grantedRoles := make([]rbac.RoleIdentifier, 0, len(added)+len(removed)) grantedRoles = append(grantedRoles, added...) grantedRoles = append(grantedRoles, removed...) + // Retired role names may linger in stored role arrays and org default + // role lists until a data cleanup migration lands. They expand to no + // permissions and cannot be re-created as custom roles, so validating + // them is unnecessary: the added set only contains them via stored + // data (implied org defaults), never via explicit grants, which the + // role-update paths reject before reaching this filter. + grantedRoles = slices.DeleteFunc(grantedRoles, func(r rbac.RoleIdentifier) bool { + return rbac.IsRetiredRoleName(r.Name) + }) customRoles := make([]rbac.RoleIdentifier, 0) // Validate that the roles being assigned are valid. for _, r := range grantedRoles { @@ -7860,6 +7869,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. Retired-name tolerance + // only covers stored grants and implied org defaults that linger until + // the cleanup migration lands; without this check the request would + // skip validation and persist a hidden grant that a binary rollback + // resolves again. + for _, role := range scopedGranted { + if rbac.IsRetiredRoleName(role.Name) { + return database.OrganizationMember{}, xerrors.Errorf("role %q is retired and cannot be assigned", role.Name) + } + } // The org's default_org_member_roles are implied at request time by // GetAuthorizationUserRoles. Include them in the implied set so @@ -7936,6 +7955,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 } @@ -8486,6 +8514,15 @@ func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRo return database.User{}, err } + // Explicitly granting a retired role is rejected. Retired-name tolerance + // only covers stored grants that linger until the cleanup migration + // lands. + for _, roleName := range arg.GrantedRoles { + if rbac.IsRetiredRoleName(roleName) { + return database.User{}, xerrors.Errorf("role %q is retired and cannot be assigned", roleName) + } + } + // The member role is always implied. impliedTypes := append(q.convertToDeploymentRoles(arg.GrantedRoles), rbac.RoleMember()) // If the changeset is nothing, less rbac checks need to be done. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b2f46bb0be4ce..22fdc81717a32 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 837dd590e045d..a4c6a79098395 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 d746f9b0bdfc8..5554f2eca9fc7 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 e1788aa8ab650..e05324cf37216 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) @@ -5835,7 +5822,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) @@ -6525,7 +6512,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) @@ -8521,7 +8508,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, @@ -8551,7 +8538,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, @@ -8606,7 +8593,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) @@ -8614,11 +8601,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{ @@ -8632,11 +8614,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) { @@ -10250,7 +10232,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, @@ -10288,7 +10270,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, @@ -11840,7 +11822,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) @@ -11949,7 +11931,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) @@ -12110,7 +12092,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, @@ -12178,7 +12160,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) @@ -12186,21 +12168,18 @@ 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", + Status: database.ChatStatusError, }) 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) @@ -12213,7 +12192,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) { @@ -12708,20 +12687,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) }) } @@ -12905,7 +12884,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) @@ -13051,7 +13030,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) @@ -13074,7 +13053,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}, @@ -13102,7 +13081,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") @@ -13477,9 +13456,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} @@ -13645,21 +13622,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, @@ -15726,7 +15695,6 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { t, adminClient.Client, firstUser.OrganizationID, - rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID), ) memberClient := codersdk.NewExperimentalClient(memberClientRaw) @@ -15853,7 +15821,6 @@ func TestCreateChatPersonalModelOverrideRoot(t *testing.T) { t, adminClient.Client, org.ID, - rbac.ScopedRoleAgentsAccess(org.ID), ) otherClient := codersdk.NewExperimentalClient(otherClientRaw) @@ -16253,7 +16220,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{ @@ -16365,7 +16332,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) @@ -17671,7 +17638,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) @@ -17683,7 +17649,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) @@ -17691,24 +17657,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) { @@ -18300,7 +18262,6 @@ func TestChatReadOnlySharedWriteHandlers(t *testing.T) { t, ownerClient.Client, owner.OrganizationID, - rbac.ScopedRoleAgentsAccess(owner.OrganizationID), ) sharedClient = codersdk.NewExperimentalClient(sharedRaw) @@ -18532,7 +18493,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/mcp.go b/coderd/mcp.go index a1b297bfd36da..d6160729f00b7 100644 --- a/coderd/mcp.go +++ b/coderd/mcp.go @@ -532,7 +532,8 @@ func (api *API) getMCPServerConfig(rw http.ResponseWriter, r *http.Request) { hasFullView := api.Authorize(r, policy.ActionUpdate, config) || api.Authorize(r, policy.ActionRead, rbac.ResourceAuditLog.InOrg(config.OrganizationID)) if !hasFullView && !config.Enabled && - !api.Authorize(r, policy.ActionDelete, config) { + !api.Authorize(r, policy.ActionDelete, config) && + !api.Authorize(r, policy.ActionShare, config) { httpapi.ResourceNotFound(rw) return } diff --git a/coderd/mcp_acl.go b/coderd/mcp_acl.go index 6b793ed04c61e..572c9ae20a00f 100644 --- a/coderd/mcp_acl.go +++ b/coderd/mcp_acl.go @@ -2,6 +2,7 @@ package coderd import ( "context" + "database/sql" "fmt" "maps" "net/http" @@ -18,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac/acl" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/searchquery" "github.com/coder/coder/v2/codersdk" ) @@ -56,6 +58,107 @@ func (api *API) mcpServerConfigACL(rw http.ResponseWriter, r *http.Request) { }) } +// @Summary Get available MCP server config ACL users and groups +// @ID get-available-mcp-server-config-acl-users-and-groups +// @Security CoderSessionToken +// @Tags MCP +// @Produce json +// @Param organization path string true "Organization name or ID" +// @Param mcpserverconfig path string true "MCP server config ID" format(uuid) +// @Param q query string false "User search query; free-text search also applies to groups" +// @Param after_id query string false "User after ID" format(uuid) +// @Param limit query int false "Page limit for users and groups, if 0 returns all candidates" +// @Param offset query int false "User page offset" +// @Success 200 {object} codersdk.ACLAvailable +// @Router /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl/available [get] +// @x-apidocgen {"skip": true} +func (api *API) mcpServerConfigACLAvailable(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + config := httpmw.MCPServerConfigParam(r) + if !api.Authorize(r, policy.ActionShare, config.RBACObject()) { + httpapi.ResourceNotFound(rw) + return + } + + userFilter, validations := searchquery.Users(r.URL.Query().Get("q")) + if len(validations) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid user search query.", + Validations: validations, + }) + return + } + pagination, ok := ParsePagination(rw, r) + if !ok { + return + } + + //nolint:gocritic // The MCP server config share permission authorizes this + // bounded organization-scoped lookup even when the caller cannot browse the + // ordinary directories. + restrictedCtx := dbauthz.AsSystemRestricted(ctx) + members, err := api.Database.PaginatedOrganizationMembers(restrictedCtx, database.PaginatedOrganizationMembersParams{ + AfterID: pagination.AfterID, + OrganizationID: config.OrganizationID, + Search: userFilter.Search, + Name: userFilter.Name, + ExactUsername: userFilter.ExactUsername, + ExactEmail: userFilter.ExactEmail, + Status: userFilter.Status, + IsServiceAccount: userFilter.IsServiceAccount, + RbacRole: userFilter.RbacRole, + LastSeenBefore: userFilter.LastSeenBefore, + LastSeenAfter: userFilter.LastSeenAfter, + CreatedAfter: userFilter.CreatedAfter, + CreatedBefore: userFilter.CreatedBefore, + GithubComUserID: userFilter.GithubComUserID, + LoginType: userFilter.LoginType, + IncludeSystem: false, + // #nosec G115 - Pagination offsets are small and fit in int32. + OffsetOpt: int32(pagination.Offset), + // #nosec G115 - Pagination limits are small and fit in int32. + LimitOpt: int32(pagination.Limit), + }) + if err != nil { + httpapi.InternalServerError(rw, xerrors.Errorf("list MCP server config ACL users: %w", err)) + return + } + + groups, err := api.Database.GetGroups(restrictedCtx, database.GetGroupsParams{ + OrganizationID: config.OrganizationID, + Search: userFilter.Search, + // #nosec G115 - Pagination limits are small and fit in int32. + LimitOpt: int32(pagination.Limit), + }) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, xerrors.Errorf("list MCP server config ACL groups: %w", err)) + return + } + + groupIDs := make([]uuid.UUID, len(groups)) + for i, group := range groups { + groupIDs[i] = group.Group.ID + } + countByGroup, ok := api.mcpServerConfigACLGroupMemberCounts(restrictedCtx, rw, groupIDs) + if !ok { + return + } + + sdkUsers := make([]codersdk.ReducedUser, 0, len(members)) + for _, member := range members { + sdkUsers = append(sdkUsers, mcpServerConfigACLReducedUser(member)) + } + sdkGroups := make([]codersdk.Group, 0, len(groups)) + for _, group := range groups { + sdkGroups = append(sdkGroups, db2sdk.Group(group, nil, int(countByGroup[group.Group.ID]))) + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ACLAvailable{ + Users: sdkUsers, + Groups: sdkGroups, + }) +} + // @Summary Update MCP server config ACL // @ID update-mcp-server-config-acl // @Security CoderSessionToken @@ -176,24 +279,14 @@ func (api *API) mcpServerConfigACLGroups(ctx context.Context, rw http.ResponseWr return nil, false } } - countByGroup := make(map[uuid.UUID]int64, len(groups)) - if len(groups) > 0 { - groupIDs := make([]uuid.UUID, 0, len(groups)) - for _, group := range groups { - groupIDs = append(groupIDs, group.Group.ID) - } - //nolint:gocritic // ACL managers may resolve group sizes after the share gate passes. - countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{ - GroupIds: groupIDs, - IncludeSystem: false, - }) - if err != nil { - httpapi.InternalServerError(rw, err) - return nil, false - } - for _, row := range countRows { - countByGroup[row.GroupID] = row.MemberCount - } + groupIDs := make([]uuid.UUID, 0, len(groups)) + for _, group := range groups { + groupIDs = append(groupIDs, group.Group.ID) + } + //nolint:gocritic // ACL managers may resolve group sizes after the share gate passes. + countByGroup, ok := api.mcpServerConfigACLGroupMemberCounts(dbauthz.AsSystemRestricted(ctx), rw, groupIDs) + if !ok { + return nil, false } result := make([]codersdk.MCPServerConfigGroup, 0, len(groups)) for _, group := range groups { @@ -205,6 +298,44 @@ func (api *API) mcpServerConfigACLGroups(ctx context.Context, rw http.ResponseWr return result, true } +func (api *API) mcpServerConfigACLGroupMemberCounts(ctx context.Context, rw http.ResponseWriter, groupIDs []uuid.UUID) (map[uuid.UUID]int64, bool) { + countByGroup := make(map[uuid.UUID]int64, len(groupIDs)) + if len(groupIDs) == 0 { + return countByGroup, true + } + + countRows, err := api.Database.GetGroupMembersCountByGroupIDs(ctx, database.GetGroupMembersCountByGroupIDsParams{ + GroupIds: groupIDs, + IncludeSystem: false, + }) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, xerrors.Errorf("count MCP server config ACL group members: %w", err)) + return nil, false + } + for _, row := range countRows { + countByGroup[row.GroupID] = row.MemberCount + } + return countByGroup, true +} + +func mcpServerConfigACLReducedUser(member database.PaginatedOrganizationMembersRow) codersdk.ReducedUser { + return codersdk.ReducedUser{ + MinimalUser: codersdk.MinimalUser{ + ID: member.OrganizationMember.UserID, + Username: member.Username, + Name: member.Name, + AvatarURL: member.AvatarURL, + }, + Email: member.Email, + CreatedAt: member.UserCreatedAt, + UpdatedAt: member.UserUpdatedAt, + LastSeenAt: member.LastSeenAt, + Status: codersdk.UserStatus(member.Status), + LoginType: codersdk.LoginType(member.LoginType), + IsServiceAccount: member.IsServiceAccount, + } +} + // canonicalMCPServerConfigACLRoles rekeys the request map by canonical // uuid.String() values so noncanonical spellings hit the same keys RBAC // reads, and rejects requests where two spellings collapse to one diff --git a/coderd/mcp_acl_available_test.go b/coderd/mcp_acl_available_test.go new file mode 100644 index 0000000000000..299d5a26fd739 --- /dev/null +++ b/coderd/mcp_acl_available_test.go @@ -0,0 +1,341 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + "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/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/rbac/policy" + "github.com/coder/coder/v2/coderd/rbac/rolestore" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestMCPServerConfigACLAvailable(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, db, _ := newMCPClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client) + config := createMCPServerConfig(t, client, firstUser.OrganizationID, testutil.GetRandomName(t), true) + + needleUser := dbgen.User(t, db, database.User{ + Username: "needle-user-" + testutil.GetRandomName(t), + Name: "Needle User", + Email: testutil.GetRandomName(t) + "@example.com", + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: firstUser.OrganizationID, + UserID: needleUser.ID, + }) + otherMember := dbgen.User(t, db, database.User{ + Username: "other-user-" + testutil.GetRandomName(t), + Email: testutil.GetRandomName(t) + "@example.com", + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: firstUser.OrganizationID, + UserID: otherMember.ID, + }) + + needleGroup := dbgen.Group(t, db, database.Group{ + OrganizationID: firstUser.OrganizationID, + Name: "needle-group-" + testutil.GetRandomName(t), + DisplayName: "Needle Group", + }) + dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: needleGroup.ID, UserID: needleUser.ID}) + dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: needleGroup.ID, UserID: otherMember.ID}) + + otherOrganization := dbgen.Organization(t, db, database.Organization{}) + foreignUser := dbgen.User(t, db, database.User{ + Username: "needle-foreign-user-" + testutil.GetRandomName(t), + Email: testutil.GetRandomName(t) + "@example.com", + }) + dbgen.OrganizationMember(t, db, database.OrganizationMember{ + OrganizationID: otherOrganization.ID, + UserID: foreignUser.ID, + }) + foreignGroup := dbgen.Group(t, db, database.Group{ + OrganizationID: otherOrganization.ID, + Name: "needle-foreign-group-" + testutil.GetRandomName(t), + }) + + available, err := client.MCPServerConfigACLAvailable(ctx, firstUser.OrganizationID, config.ID, codersdk.UsersRequest{}) + require.NoError(t, err) + usersByID := make(map[uuid.UUID]codersdk.ReducedUser, len(available.Users)) + for _, user := range available.Users { + usersByID[user.ID] = user + } + require.Equal(t, needleUser.Username, usersByID[needleUser.ID].Username) + require.Equal(t, needleUser.Name, usersByID[needleUser.ID].Name) + require.Equal(t, needleUser.Email, usersByID[needleUser.ID].Email) + require.Contains(t, usersByID, otherMember.ID) + require.NotContains(t, usersByID, database.PrebuildsSystemUserID) + require.NotContains(t, usersByID, foreignUser.ID) + + groupsByID := make(map[uuid.UUID]codersdk.Group, len(available.Groups)) + for _, group := range available.Groups { + groupsByID[group.ID] = group + require.Empty(t, group.Members) + } + require.Equal(t, needleGroup.Name, groupsByID[needleGroup.ID].Name) + require.Equal(t, needleGroup.DisplayName, groupsByID[needleGroup.ID].DisplayName) + require.Equal(t, 2, groupsByID[needleGroup.ID].TotalMemberCount) + require.Equal(t, 3, groupsByID[firstUser.OrganizationID].TotalMemberCount) + require.NotContains(t, groupsByID, foreignGroup.ID) + + filtered, err := client.MCPServerConfigACLAvailable(ctx, firstUser.OrganizationID, config.ID, codersdk.UsersRequest{ + SearchQuery: "needle", + Pagination: codersdk.Pagination{Limit: 1}, + }) + require.NoError(t, err) + require.Len(t, filtered.Users, 1) + require.Equal(t, needleUser.ID, filtered.Users[0].ID) + require.Len(t, filtered.Groups, 1) + require.Equal(t, needleGroup.ID, filtered.Groups[0].ID) + require.Equal(t, 2, filtered.Groups[0].TotalMemberCount) + require.Empty(t, filtered.Groups[0].Members) + + memberClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, err = memberClient.MCPServerConfigACLAvailable(ctx, firstUser.OrganizationID, config.ID, codersdk.UsersRequest{}) + requireSDKError(t, err, http.StatusNotFound) + + _, err = client.MCPServerConfigACLAvailable(ctx, otherOrganization.ID, config.ID, codersdk.UsersRequest{}) + requireSDKError(t, err, http.StatusNotFound) +} + +func TestMCPServerConfigDisabledSharerFetch(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, rawDB, _ := newMCPClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client) + + config := createMCPServerConfig(t, client, firstUser.OrganizationID, testutil.GetRandomName(t), false) + + sharerClient, sharer := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, err := sharerClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID) + requireSDKError(t, err, http.StatusNotFound) + + role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: testutil.GetRandomName(t), + DisplayName: "MCP Server Config Sharer", + OrganizationID: uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}, + OrgPermissions: database.CustomRolePermissions{ + { + ResourceType: rbac.ResourceMCPServerConfig.Type, + Action: policy.ActionRead, + }, + { + ResourceType: rbac.ResourceMCPServerConfig.Type, + Action: policy.ActionShare, + }, + }, + }) + require.NoError(t, err) + _, err = client.UpdateOrganizationMemberRoles(ctx, firstUser.OrganizationID, sharer.ID.String(), codersdk.UpdateRoles{ + Roles: []string{role.Name}, + }) + require.NoError(t, err) + + // The detail route requires read permission in addition to share. + fetched, err := sharerClient.MCPServerConfigByID(ctx, firstUser.OrganizationID, config.ID) + require.NoError(t, err) + require.Equal(t, config.ID, fetched.ID) + require.False(t, fetched.Enabled) + require.Empty(t, fetched.URL) + + configs, err := sharerClient.MCPServerConfigs(ctx, firstUser.OrganizationID) + require.NoError(t, err) + require.Len(t, configs, 1) + require.Equal(t, config.ID, configs[0].ID) +} + +func TestMCPServerConfigACLWorkspaceSharingModes(t *testing.T) { + t.Parallel() + + modes := []database.ShareableWorkspaceOwners{ + database.ShareableWorkspaceOwnersNone, + database.ShareableWorkspaceOwnersServiceAccounts, + database.ShareableWorkspaceOwnersEveryone, + } + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client, rawDB, api := newMCPClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, client) + systemCtx := dbauthz.AsSystemRestricted(ctx) + + organization, err := rawDB.UpdateOrganizationWorkspaceSharingSettings(ctx, database.UpdateOrganizationWorkspaceSharingSettingsParams{ + ID: firstUser.OrganizationID, + ShareableWorkspaceOwners: mode, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + _, _, err = rolestore.ReconcileSystemRole(systemCtx, api, database.CustomRole{ + Name: rbac.RoleOrgMember(), + OrganizationID: uuid.NullUUID{ + UUID: firstUser.OrganizationID, + Valid: true, + }, + }, organization) + require.NoError(t, err) + + config := createMCPServerConfig(t, client, firstUser.OrganizationID, testutil.GetRandomName(t), true) + preexistingGroup := dbgen.Group(t, rawDB, database.Group{ + OrganizationID: firstUser.OrganizationID, + Name: "preexisting-" + testutil.GetRandomName(t), + }) + candidateGroup := dbgen.Group(t, rawDB, database.Group{ + OrganizationID: firstUser.OrganizationID, + Name: "candidate-" + testutil.GetRandomName(t), + DisplayName: "Candidate Group", + }) + err = client.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{ + GroupRoles: map[string]codersdk.MCPServerConfigRole{ + preexistingGroup.ID.String(): codersdk.MCPServerConfigRoleRead, + }, + }) + require.NoError(t, err) + + sharerClient, sharer := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + _, candidateUser := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID) + dbgen.GroupMember(t, rawDB, database.GroupMemberTable{ + GroupID: candidateGroup.ID, + UserID: candidateUser.ID, + }) + + role, err := rawDB.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: testutil.GetRandomName(t), + DisplayName: "MCP Server Config Sharer", + OrganizationID: uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}, + OrgPermissions: database.CustomRolePermissions{ + { + ResourceType: rbac.ResourceMCPServerConfig.Type, + Action: policy.ActionRead, + }, + { + ResourceType: rbac.ResourceMCPServerConfig.Type, + Action: policy.ActionShare, + }, + }, + }) + require.NoError(t, err) + _, err = client.UpdateOrganizationMemberRoles(ctx, firstUser.OrganizationID, sharer.ID.String(), codersdk.UpdateRoles{ + Roles: []string{role.Name}, + }) + require.NoError(t, err) + + initialACL, err := sharerClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID) + require.NoError(t, err) + require.Empty(t, initialACL.Users) + require.Contains(t, mcpServerConfigACLGroupRoles(initialACL), firstUser.OrganizationID) + preexistingACLGroup := mcpServerConfigACLGroupByID(t, initialACL, preexistingGroup.ID) + require.Equal(t, preexistingGroup.Name, preexistingACLGroup.Name) + require.Empty(t, preexistingACLGroup.Members) + + available, err := sharerClient.MCPServerConfigACLAvailable(ctx, firstUser.OrganizationID, config.ID, codersdk.UsersRequest{}) + require.NoError(t, err) + availableUser := mcpServerConfigACLAvailableUserByID(t, available, candidateUser.ID) + require.Equal(t, candidateUser.Username, availableUser.Username) + availableGroup := mcpServerConfigACLAvailableGroupByID(t, available, candidateGroup.ID) + require.Equal(t, candidateGroup.DisplayName, availableGroup.DisplayName) + require.Equal(t, 1, availableGroup.TotalMemberCount) + require.Empty(t, availableGroup.Members) + + err = sharerClient.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{ + UserRoles: map[string]codersdk.MCPServerConfigRole{ + candidateUser.ID.String(): codersdk.MCPServerConfigRoleRead, + }, + GroupRoles: map[string]codersdk.MCPServerConfigRole{ + candidateGroup.ID.String(): codersdk.MCPServerConfigRoleRead, + preexistingGroup.ID.String(): codersdk.MCPServerConfigRoleDeleted, + }, + }) + require.NoError(t, err) + + updatedACL, err := sharerClient.MCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID) + require.NoError(t, err) + require.Equal(t, map[uuid.UUID]codersdk.MCPServerConfigRole{ + candidateUser.ID: codersdk.MCPServerConfigRoleRead, + }, mcpServerConfigACLUserRoles(updatedACL)) + require.Equal(t, map[uuid.UUID]codersdk.MCPServerConfigRole{ + firstUser.OrganizationID: codersdk.MCPServerConfigRoleRead, + candidateGroup.ID: codersdk.MCPServerConfigRoleRead, + }, mcpServerConfigACLGroupRoles(updatedACL)) + }) + } +} + +func newMCPClientWithDatabase(t testing.TB) (client *codersdk.Client, rawDB database.Store, apiDB database.Store) { + t.Helper() + + db, pubsub := dbtestutil.NewDB(t) + providerKeys := coderdtest.FakeOpenAICompatProviderAPIKeys(t) + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + DeploymentValues: mcpDeploymentValues(t), + ChatProviderAPIKeys: &providerKeys, + }) + return client, db, api.Database +} + +func mcpServerConfigACLUserRoles(acl codersdk.MCPServerConfigACL) map[uuid.UUID]codersdk.MCPServerConfigRole { + roles := make(map[uuid.UUID]codersdk.MCPServerConfigRole, len(acl.Users)) + for _, user := range acl.Users { + roles[user.ID] = user.Role + } + return roles +} + +func mcpServerConfigACLGroupRoles(acl codersdk.MCPServerConfigACL) map[uuid.UUID]codersdk.MCPServerConfigRole { + roles := make(map[uuid.UUID]codersdk.MCPServerConfigRole, len(acl.Groups)) + for _, group := range acl.Groups { + roles[group.ID] = group.Role + } + return roles +} + +func mcpServerConfigACLGroupByID(t testing.TB, acl codersdk.MCPServerConfigACL, groupID uuid.UUID) codersdk.MCPServerConfigGroup { + t.Helper() + for _, group := range acl.Groups { + if group.ID == groupID { + return group + } + } + require.FailNow(t, "MCP server config ACL group not found", "group_id=%s", groupID) + return codersdk.MCPServerConfigGroup{} +} + +func mcpServerConfigACLAvailableUserByID(t testing.TB, available codersdk.ACLAvailable, userID uuid.UUID) codersdk.ReducedUser { + t.Helper() + for _, user := range available.Users { + if user.ID == userID { + return user + } + } + require.FailNow(t, "MCP server config ACL available user not found", "user_id=%s", userID) + return codersdk.ReducedUser{} +} + +func mcpServerConfigACLAvailableGroupByID(t testing.TB, available codersdk.ACLAvailable, groupID uuid.UUID) codersdk.Group { + t.Helper() + for _, group := range available.Groups { + if group.ID == groupID { + return group + } + } + require.FailNow(t, "MCP server config ACL available group not found", "group_id=%s", groupID) + return codersdk.Group{} +} diff --git a/coderd/members.go b/coderd/members.go index 21728c065d546..4bf80b6c91795 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.IsRetiredRoleName) 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 c2bf219c1ebc2..e83990b6e9157 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -6,12 +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" @@ -50,6 +55,143 @@ 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/rbac/roles.go b/coderd/rbac/roles.go index 3aceb98323814..0e483d79172bd 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} } @@ -346,9 +340,29 @@ type RoleOptions struct { NoChatSharing bool } +// retiredRoleNames contains retired built-in role names. They stay reserved so +// a custom role cannot take a name that older binaries still resolve as a +// built-in role, which would silently shadow the custom permissions on +// rollback. 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 retiredRoleNames = map[string]struct{}{ + "agents-access": {}, +} + +// IsRetiredRoleName reports whether name is a retired built-in role name that +// may still appear in stored role arrays. +func IsRetiredRoleName(name string) bool { + _, ok := retiredRoleNames[name] + return ok +} + // ReservedRoleName exists because the database should only allow unique role // names, but some roles are built in. So these names are reserved func ReservedRoleName(name string) bool { + if _, ok := retiredRoleNames[name]; ok { + return true + } _, ok := loadBuiltinRoles()[name] return ok } @@ -729,29 +743,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 +769,6 @@ var assignRoles = map[string]map[string]bool{ userAdmin: true, customSiteRole: true, customOrganizationRole: true, - agentsAccess: true, }, owner: { owner: true, @@ -795,13 +785,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 +800,10 @@ var assignRoles = map[string]map[string]bool{ orgWorkspaceCreationBan: true, orgWorkspaceAccess: true, customOrganizationRole: true, - agentsAccess: true, }, orgUserAdmin: { orgMember: true, orgWorkspaceAccess: true, - agentsAccess: true, }, } @@ -1002,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 IsRetiredRoleName(n.Name) { + // Retired role names grant nothing and cannot be re-created as + // custom roles, so stale stored grants are dropped instead of + // failing expansion. + continue + } r, err := RoleByName(n) if err != nil { return nil, xerrors.Errorf("get role permissions: %w", err) @@ -1188,8 +1180,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 +1201,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 +1256,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 +1267,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 060ff175b0525..9029236c3ff3d 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) } @@ -1824,3 +1804,31 @@ func TestDBPurgeBoundaryLogDelete(t *testing.T) { rbac.ResourceBoundaryLog) require.Error(t, err, "DBPurge must not read boundary logs") } + +// TestRetiredRoleNames verifies retired built-in role names stay reserved and +// that stale stored grants of them expand to nothing instead of failing. +func TestRetiredRoleNames(t *testing.T) { + t.Parallel() + + const retiredName = "agents-access" + orgID := uuid.New() + + require.True(t, rbac.IsRetiredRoleName(retiredName)) + // Retired names stay reserved so custom roles cannot shadow them. + require.True(t, rbac.ReservedRoleName(retiredName)) + + // Retired names do not resolve to a role... + _, err := rbac.RoleByName(rbac.RoleIdentifier{Name: retiredName}) + require.Error(t, err) + + // ...but stored role arrays may still contain them, so expansion drops + // them instead of failing. + roles, err := rbac.RoleIdentifiers{ + {Name: retiredName}, + {Name: retiredName, OrganizationID: orgID}, + rbac.ScopedRoleOrgAuditor(orgID), + }.Expand() + require.NoError(t, err) + require.Len(t, roles, 1) + require.Equal(t, rbac.ScopedRoleOrgAuditor(orgID), roles[0].Identifier) +} diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 233df7ae527f0..145a6c27fdc06 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.IsRetiredRoleName(name.Name) { + // Retired built-in role names may linger in stored role arrays + // until a cleanup migration lands. They grant nothing and stay + // reserved, so skip them instead of falling through to the + // custom-role lookup, which could resurrect an unrelated + // pre-reservation custom role with the same name. + continue + } // Remove any built in roles expanded, err := rbac.RoleByName(name) if err == nil { diff --git a/coderd/rbac/rolestore/rolestore_test.go b/coderd/rbac/rolestore/rolestore_test.go index 78a2c3de62654..1d35b90ff0fe7 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 TestExpandRetiredRoleName(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + + org := dbgen.Organization(t, db, database.Organization{}) + + // Simulate a custom role that took the name before it became a + // reserved built-in role name. Expanding a stored grant of the + // retired name must not resurrect the custom role. + dbgen.CustomRole(t, db, database.CustomRole{ + Name: "agents-access", + OrganizationID: uuid.NullUUID{ + UUID: org.ID, + Valid: true, + }, + }) + + ctx := testutil.Context(t, testutil.WaitShort) + roles, err := rolestore.Expand(ctx, db, []rbac.RoleIdentifier{{Name: "agents-access", OrganizationID: org.ID}}) + require.NoError(t, err) + require.Empty(t, roles, "retired role names expand to nothing") +} + func TestPrefetchCustomRoles(t *testing.T) { t.Parallel() diff --git a/coderd/roles.go b/coderd/roles.go index 500ada46e46dc..db6009c53de26 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)) diff --git a/coderd/users.go b/coderd/users.go index 8dae291a5bdbd..b5dbf97686928 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) diff --git a/coderd/x/chatd/forced_mcp_test.go b/coderd/x/chatd/forced_mcp_test.go index 4f8e6d35d084d..97d131e951252 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/mcp.go b/codersdk/mcp.go index 017d518a02587..d5a0ecb5e99d0 100644 --- a/codersdk/mcp.go +++ b/codersdk/mcp.go @@ -252,6 +252,26 @@ func (c *Client) MCPServerConfigACL(ctx context.Context, organizationID, id uuid return acl, ReadBodyAsJSON(res, &acl) } +// MCPServerConfigACLAvailable returns available users and groups that can be +// assigned to an MCP server config ACL. +func (c *Client) MCPServerConfigACLAvailable(ctx context.Context, organizationID, id uuid.UUID, req UsersRequest) (ACLAvailable, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/mcp-servers/%s/acl/available", organizationID, id), + nil, + req.Pagination.asRequestOption(), + req.asRequestOption(), + ) + if err != nil { + return ACLAvailable{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ACLAvailable{}, ReadBodyAsError(res) + } + var available ACLAvailable + return available, ReadBodyAsJSON(res, &available) +} + // UpdateMCPServerConfigACL applies a sparse ACL update to an MCP server // config. func (c *Client) UpdateMCPServerConfigACL(ctx context.Context, organizationID, id uuid.UUID, req UpdateMCPServerConfigACLRequest) error { diff --git a/codersdk/rbacroles.go b/codersdk/rbacroles.go index 71b82c6340d78..ea87127a39520 100644 --- a/codersdk/rbacroles.go +++ b/codersdk/rbacroles.go @@ -7,7 +7,10 @@ const ( RoleTemplateAdmin string = "template-admin" RoleUserAdmin string = "user-admin" RoleAuditor string = "auditor" - RoleAgentsAccess string = "agents-access" + // 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/codersdk/templates.go b/codersdk/templates.go index 4c382b6ae46c5..7b256f38b4095 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -212,7 +212,7 @@ type UpdateTemplateACL struct { GroupPerms map[string]TemplateRole `json:"group_perms,omitempty" example:":admin,8bd26b20-f3e8-48be-a903-46bb920cf671:use"` } -// ACLAvailable is a list of users and groups that can be added to a template +// ACLAvailable is a list of users and groups that can be added to a resource // ACL. type ACLAvailable struct { Users []ReducedUser `json:"users"` diff --git a/docs/ai-coder/agents/getting-started.md b/docs/ai-coder/agents/getting-started.md index 514c2698caccc..317457a5cf97e 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. -- **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. + 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 @@ -60,65 +59,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/ai-coder/agents/models.md b/docs/ai-coder/agents/models.md index 62965329ae689..bdc7114c03f0d 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 let members and groups in the selected organization use the model. +Model access lists control who can use Coder Agents. +All organization members except service accounts hold chat permissions, but a member without read access to at least one model in the organization can't use the feature. +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. diff --git a/docs/ai-coder/agents/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index c74b169531b6f..39ea998abb17a 100644 --- a/docs/ai-coder/agents/platform-controls/mcp-servers.md +++ b/docs/ai-coder/agents/platform-controls/mcp-servers.md @@ -197,11 +197,13 @@ Refer to [Organization scope](./organizations.md) for the organization scope of Each server has a group and user ACL that controls which members can see and use it. New servers grant read access to the organization's **Everyone** group, -so all members have access by default. Admins can remove the Everyone entry and -grant specific groups or users instead through the API -(`GET`/`PATCH /api/v2/organizations/{organization}/mcp-servers/{id}/acl`); there is no ACL editor -in the settings page. ACL management is available in all editions and does not -require an enterprise entitlement. ACL changes are recorded in the audit log. +so all members have access by default. Members with MCP server share permission +can open **Server actions** > **Manage permissions** to remove the Everyone +entry and grant specific groups or users instead. They can also manage the ACL +through the API +(`GET`/`PATCH /api/v2/organizations/{organization}/mcp-servers/{id}/acl`). +ACL management is available in all editions and does not require an enterprise +entitlement. ACL changes are recorded in the audit log. Revoking access stops a member from newly selecting the server in any chat, but chats that already have the server selected keep using it, the same way diff --git a/docs/ai-coder/agents/platform-controls/organizations.md b/docs/ai-coder/agents/platform-controls/organizations.md index 57d5033819753..0b4012fe2ff89 100644 --- a/docs/ai-coder/agents/platform-controls/organizations.md +++ b/docs/ai-coder/agents/platform-controls/organizations.md @@ -99,8 +99,11 @@ You can add only the members and the groups of the organization that owns the mo To change a model's access list, open **Model actions** > **Manage permissions** on the **Models** page. Refer to [Manage model permissions](../models.md#manage-model-permissions) for the steps. -The **MCP servers** page has no access list editor. -Change an MCP server access list through the API instead: +To change an MCP server's access list, open **Server actions** > +**Manage permissions** on the **MCP servers** page. Refer to +[Access control](./mcp-servers.md#access-control) for details. + +You can also use the API: - `GET /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl` - `PATCH /api/v2/organizations/{organization}/mcp-servers/{mcpserverconfig}/acl` diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 29b5d6d2d2c45..63a67aed3f5f2 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 6e1b301a4cb08..791960e93e4bd 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.go b/enterprise/coderd/roles.go index 318138c0b92f3..c4ea6607bf0c6 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 e2cc4df5bb215..1def3e0846c0c 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{ @@ -506,7 +539,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 +573,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 +607,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 3aceee175e38a..476ac29e5b263 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/api.test.ts b/site/src/api/api.test.ts index b8f2bf49ecfab..7c8e8da406466 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -1,4 +1,6 @@ import { + MockMCPServerConfigACL, + MockMCPServerConfigACLAvailable, MockProvisionerJob, MockStoppedWorkspace, MockTemplate, @@ -526,6 +528,46 @@ describe("api.ts", () => { expect(axiosInstance.get).toHaveBeenCalledWith(aclPath); expect(axiosInstance.patch).toHaveBeenCalledWith(aclPath, acl); }); + + it("uses organization-nested MCP server ACL paths", async () => { + const serverId = "server/id"; + const update: TypesGen.UpdateMCPServerConfigACLRequest = { + user_roles: { "user-1": "read" }, + }; + vi.spyOn(axiosInstance, "get") + .mockResolvedValueOnce({ data: MockMCPServerConfigACL }) + .mockResolvedValueOnce({ data: MockMCPServerConfigACLAvailable }); + vi.spyOn(axiosInstance, "patch").mockResolvedValueOnce({}); + + await expect( + API.experimental.getMCPServerConfigACL(organizationId, serverId), + ).resolves.toStrictEqual(MockMCPServerConfigACL); + await expect( + API.experimental.getMCPServerConfigACLAvailable( + organizationId, + serverId, + { q: "alice@example.com", limit: 25 }, + ), + ).resolves.toStrictEqual(MockMCPServerConfigACLAvailable); + await expect( + API.experimental.updateMCPServerConfigACL( + organizationId, + serverId, + update, + ), + ).resolves.toBeUndefined(); + + const aclPath = + "/api/v2/organizations/organization%2Fid/mcp-servers/server%2Fid/acl"; + const aclAvailablePath = + "/api/v2/organizations/organization%2Fid/mcp-servers/server%2Fid/acl/available"; + expect(axiosInstance.get).toHaveBeenNthCalledWith(1, aclPath); + expect(axiosInstance.get).toHaveBeenNthCalledWith( + 2, + `${aclAvailablePath}?q=alice%40example.com&limit=25`, + ); + expect(axiosInstance.patch).toHaveBeenCalledWith(aclPath, update); + }); }); describe("user secrets endpoints", () => { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 990de4e91f7b2..c7a99ab2979dc 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -368,6 +368,10 @@ const mcpServerConfigsPath = (organization: string) => `/api/v2/organizations/${encodeURIComponent(organization)}/mcp-servers`; const mcpServerConfigPath = (organization: string, id: string) => `${mcpServerConfigsPath(organization)}/${encodeURIComponent(id)}`; +const mcpServerConfigACLPath = (organization: string, id: string) => + `${mcpServerConfigPath(organization, id)}/acl`; +const mcpServerConfigACLAvailablePath = (organization: string, id: string) => + `/api/v2/organizations/${encodeURIComponent(organization)}/mcp-servers/${encodeURIComponent(id)}/acl/available`; export const mcpServerOAuth2ConnectPath = (organization: string, id: string) => `${mcpServerConfigPath(organization, id)}/oauth2/connect`; const mcpServerOAuth2DisconnectPath = (id: string) => @@ -3962,6 +3966,38 @@ class ExperimentalApiMethods { return response.data; }; + getMCPServerConfigACL = async ( + organization: string, + id: string, + ): Promise => { + const response = await this.axios.get( + mcpServerConfigACLPath(organization, id), + ); + return response.data; + }; + + getMCPServerConfigACLAvailable = async ( + organization: string, + id: string, + options: TypesGen.UsersRequest, + ): Promise => { + const response = await this.axios.get( + getURLWithSearchParams( + mcpServerConfigACLAvailablePath(organization, id), + options, + ), + ); + return response.data; + }; + + updateMCPServerConfigACL = async ( + organization: string, + id: string, + req: TypesGen.UpdateMCPServerConfigACLRequest, + ): Promise => { + await this.axios.patch(mcpServerConfigACLPath(organization, id), req); + }; + createMCPServerConfig = async ( organization: string, req: TypesGen.CreateMCPServerConfigRequest, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 5bc443aef8dc1..2ae4ecaa9d255 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -1,15 +1,27 @@ import { QueryClient, QueryObserver } from "react-query"; import { describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; +import { authorizationKey } from "#/api/queries/authCheck"; +import { + organizations, + organizationsPermissions, +} from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import { ChatWatchEventKinds } from "#/api/typesGenerated"; import { ERROR_STATUSES, SUCCESS_STATUSES, } from "#/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils"; -import { MockChatMessage } from "#/testHelpers/chatEntities"; +import { + MockChatMessage, + MockMCPServerConfig, +} from "#/testHelpers/chatEntities"; import { MockChatModel } from "#/testHelpers/chatModels"; import { createDeferred } from "#/testHelpers/deferred"; +import { + MockMCPServerConfigACL, + MockMCPServerConfigACLAvailable, +} from "#/testHelpers/entities"; import { buildOptimisticEditedMessage } from "./chatMessageEdits"; import { addChildToParentInCache, @@ -63,6 +75,12 @@ import { invalidateChatPrompts, invalidateChatSearches, invalidateChatsByWorkspace, + mcpServerConfigACL, + mcpServerConfigACLAvailable, + mcpServerConfigACLAvailableKey, + mcpServerConfigACLKey, + mcpServerConfigKey, + mcpServerConfigsKey, mergeWatchedChatIntoCaches, mergeWatchedChatSummary, openChat, @@ -94,6 +112,7 @@ import { updateChatWorkspace, updateChildInParentCache, updateInfiniteChatsCache, + updateMCPServerConfigACL, upsertChatMessages, } from "./chats"; @@ -118,6 +137,9 @@ vi.mock("#/api/api", () => ({ getChatModel: vi.fn(), getChatModelACL: vi.fn(), updateChatModelACL: vi.fn(), + getMCPServerConfigACL: vi.fn(), + getMCPServerConfigACLAvailable: vi.fn(), + updateMCPServerConfigACL: vi.fn(), updateChatModel: vi.fn(), deleteChatModel: vi.fn(), }, @@ -337,6 +359,93 @@ describe("chat model query factories", () => { }); }); +describe("MCP server ACL query factories", () => { + const organization = "organization-1"; + const otherOrganization = "organization-2"; + const serverId = MockMCPServerConfig.id; + + it("scopes ACL candidates by organization, server, and options", async () => { + const options = { q: "alice@example.com", limit: 25 }; + const otherOptions = { q: "bob@example.com", limit: 25 }; + vi.mocked( + API.experimental.getMCPServerConfigACLAvailable, + ).mockResolvedValue(MockMCPServerConfigACLAvailable); + + const query = mcpServerConfigACLAvailable(organization, serverId, options); + + expect(query.queryKey).toEqual( + mcpServerConfigACLAvailableKey(organization, serverId, options), + ); + expect(query.queryKey).not.toEqual( + mcpServerConfigACLAvailableKey(otherOrganization, serverId, options), + ); + expect(query.queryKey).not.toEqual( + mcpServerConfigACLAvailableKey(organization, "other-server", options), + ); + expect(query.queryKey).not.toEqual( + mcpServerConfigACLAvailableKey(organization, serverId, otherOptions), + ); + await expect(query.queryFn()).resolves.toEqual( + MockMCPServerConfigACLAvailable, + ); + expect( + API.experimental.getMCPServerConfigACLAvailable, + ).toHaveBeenCalledWith(organization, serverId, options); + }); + + it("gets and sparsely updates an organization-scoped ACL", async () => { + const req: TypesGen.UpdateMCPServerConfigACLRequest = { + user_roles: { "user-1": "read" }, + }; + vi.mocked(API.experimental.getMCPServerConfigACL).mockResolvedValue( + MockMCPServerConfigACL, + ); + vi.mocked(API.experimental.updateMCPServerConfigACL).mockResolvedValue(); + + const query = mcpServerConfigACL(organization, serverId); + expect(query.queryKey).toEqual( + mcpServerConfigACLKey(organization, serverId), + ); + await expect(query.queryFn()).resolves.toEqual(MockMCPServerConfigACL); + expect(API.experimental.getMCPServerConfigACL).toHaveBeenCalledWith( + organization, + serverId, + ); + + const queryClient = createTestQueryClient(); + const keys = [ + mcpServerConfigACLKey(organization, serverId), + mcpServerConfigKey(organization, serverId), + mcpServerConfigsKey(organization), + [...authorizationKey, "mcp-servers"], + organizationsPermissions([organization]).queryKey, + ] as const; + const unaffectedKeys = [ + organizations().queryKey, + organizationsPermissions([otherOrganization]).queryKey, + ] as const; + for (const key of [...keys, ...unaffectedKeys]) { + queryClient.setQueryData(key, {}); + } + const variables = { organization, id: serverId, req }; + const mutation = updateMCPServerConfigACL(queryClient); + + await expect(mutation.mutationFn(variables)).resolves.toBeUndefined(); + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + organization, + serverId, + req, + ); + await mutation.onSuccess(undefined, variables); + for (const key of keys) { + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + } + for (const key of unaffectedKeys) { + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false); + } + }); +}); + describe("advisor config query factories", () => { it("builds the advisor config query and delegates to the API", async () => { const advisorConfig: TypesGen.AdvisorConfig = { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index ab8a21d0b2bea..e6ab1f04cd37e 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -17,6 +17,7 @@ import { projectEditedConversationIntoCache, reconcileEditedMessageInCache, } from "./chatMessageEdits"; +import { organizationsPermissions } from "./organizations"; const chatCollectionsKey = ["chats", "collections"] as const; @@ -2449,6 +2450,34 @@ export const mcpServerConfig = (organization: string, id: string) => ({ API.experimental.getMCPServerConfig(organization, id), }); +export const mcpServerConfigACLKey = (organization: string, id: string) => + [...mcpServerConfigKey(organization, id), "acl"] as const; + +export const mcpServerConfigACL = (organization: string, id: string) => ({ + queryKey: mcpServerConfigACLKey(organization, id), + queryFn: (): Promise => + API.experimental.getMCPServerConfigACL(organization, id), + enabled: organization !== "" && id !== "", +}); + +export const mcpServerConfigACLAvailableKey = ( + organization: string, + id: string, + options: TypesGen.UsersRequest, +) => + [...mcpServerConfigACLKey(organization, id), "available", options] as const; + +export const mcpServerConfigACLAvailable = ( + organization: string, + id: string, + options: TypesGen.UsersRequest, +) => ({ + queryKey: mcpServerConfigACLAvailableKey(organization, id, options), + queryFn: (): Promise => + API.experimental.getMCPServerConfigACLAvailable(organization, id, options), + enabled: organization !== "" && id !== "", +}); + const invalidateMCPServerConfigQueries = async (queryClient: QueryClient) => { await queryClient.invalidateQueries({ queryKey: mcpServersKey }); }; @@ -2491,6 +2520,52 @@ export const deleteMCPServerConfig = ( }, }); +type UpdateMCPServerConfigACLMutationArgs = { + organization: string; + id: string; + req: TypesGen.UpdateMCPServerConfigACLRequest; +}; + +export const updateMCPServerConfigACL = (queryClient: QueryClient) => ({ + mutationFn: ({ + organization, + id, + req, + }: UpdateMCPServerConfigACLMutationArgs) => + API.experimental.updateMCPServerConfigACL(organization, id, req), + onSuccess: async ( + _data: unknown, + variables: UpdateMCPServerConfigACLMutationArgs, + ) => { + const { organization, id } = variables; + const permissionsQueryKey = organizationsPermissions([ + organization, + ]).queryKey; + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: mcpServerConfigACLKey(organization, id), + exact: true, + }), + queryClient.invalidateQueries({ + queryKey: mcpServerConfigKey(organization, id), + exact: true, + }), + queryClient.invalidateQueries({ + queryKey: mcpServerConfigsKey(organization), + exact: true, + }), + queryClient.invalidateQueries({ queryKey: authorizationKey }), + queryClient.invalidateQueries({ + predicate: ({ queryKey }) => + queryKey[0] === permissionsQueryKey[0] && + queryKey[2] === permissionsQueryKey[2] && + Array.isArray(queryKey[1]) && + queryKey[1].includes(organization), + }), + ]); + }, +}); + export const disconnectMCPServerOAuth2 = (queryClient: QueryClient) => ({ mutationFn: (id: string) => API.experimental.disconnectMCPServerOAuth2(id), onSuccess: async () => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 78eb71490e81c..6c0cf3b6c64f6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2,7 +2,7 @@ // From codersdk/templates.go /** - * ACLAvailable is a list of users and groups that can be added to a template + * ACLAvailable is a list of users and groups that can be added to a resource * ACL. */ export interface ACLAvailable { @@ -8313,6 +8313,9 @@ export interface Role { // From codersdk/rbacroles.go /** * Ideally these roles would be generated from the rbac/roles.go package. + * @deprecated the agents-access role was removed. Coder Agents chat + * access is part of the organization-member permission floor, and + * servers without this built-in role reject assigning it. */ export const RoleAgentsAccess = "agents-access"; diff --git a/site/src/components/IconField/IconField.stories.tsx b/site/src/components/IconField/IconField.stories.tsx index 7def38e2fe3e6..0773c78ae8347 100644 --- a/site/src/components/IconField/IconField.stories.tsx +++ b/site/src/components/IconField/IconField.stories.tsx @@ -50,9 +50,12 @@ export const OpenPicker: Story = { }); await userEvent.click(button); await expect(button).toHaveAttribute("aria-expanded", "true"); - const popover = await screen.findByRole("dialog"); - await waitFor(() => - expect(popover.querySelector("em-emoji-picker")).toBeInTheDocument(), - ); + const dialog = await screen.findByRole("dialog"); + await waitFor(() => { + expect( + within(dialog).queryByRole("status", { name: "Loading" }), + ).not.toBeInTheDocument(); + }); + await expect(dialog).toBeVisible(); }, }; diff --git a/site/src/modules/dashboard/DashboardLayout.stories.tsx b/site/src/modules/dashboard/DashboardLayout.stories.tsx index 94f6017251c44..d57b63f83f655 100644 --- a/site/src/modules/dashboard/DashboardLayout.stories.tsx +++ b/site/src/modules/dashboard/DashboardLayout.stories.tsx @@ -25,7 +25,7 @@ import { MockUserMember, MockUserOwner, } from "#/testHelpers/entities"; -import { pixelWithTablet } from "#/testHelpers/pixel"; +import { pixelWithDesktop, pixelWithTablet } from "#/testHelpers/pixel"; import { withAuthProvider, withDashboardProvider, @@ -47,6 +47,14 @@ const pageContent = ( ); +const mcpServersRouter = reactRouterParameters({ + location: { path: "/" }, + routing: [ + { path: "/", useStoryElement: true }, + { path: "/ai/settings", element:

AI settings for sharers

}, + ], +}); + const modelSettingsRouter = reactRouterParameters({ location: { path: "/" }, routing: [ @@ -118,6 +126,7 @@ export const ForMember: Story = { export const CustomOrganizationRoleCanOpenModels: Story = { parameters: { + pixel: { matrix: pixelWithDesktop }, user: MockUserMember, permissions: MockNoPermissions, reactRouter: modelSettingsRouter, @@ -141,6 +150,7 @@ export const CustomOrganizationRoleCanOpenModels: Story = { export const ACLReadableMemberCanOpenModels: Story = { parameters: { + pixel: { matrix: pixelWithDesktop }, user: MockUserMember, permissions: MockNoPermissions, reactRouter: modelSettingsRouter, @@ -165,6 +175,42 @@ export const ACLReadableMemberCanOpenModels: Story = { play: openModels, }; +export const CustomOrganizationRoleCanOpenMCPServers: Story = { + parameters: { + pixel: { matrix: pixelWithDesktop }, + user: MockUserMember, + permissions: MockNoPermissions, + reactRouter: mcpServersRouter, + queries: [ + { key: buildInfoKey, data: MockBuildInfo }, + { key: updateCheckQueryKey, data: MockUpdateCheck }, + { key: deploymentStatsQueryKey, data: MockDeploymentStats }, + { + key: organizationsPermissions([MockDefaultOrganization.id]).queryKey, + data: { + [MockDefaultOrganization.id]: { + ...MockNoOrganizationPermissions, + shareMCPServerConfig: true, + }, + }, + }, + ], + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + await user.click( + await canvas.findByRole("button", { name: "Admin settings" }), + ); + await user.click(await screen.findByRole("menuitem", { name: "AI" })); + await expect( + await canvas.findByRole("heading", { + name: "AI settings for sharers", + }), + ).toBeInTheDocument(); + }, +}; + export const UpdateAvailable: Story = { parameters: { queries: [ diff --git a/site/src/modules/dashboard/Navbar/Navbar.tsx b/site/src/modules/dashboard/Navbar/Navbar.tsx index c1e6af8c9c2e1..cb6ceccb1a19a 100644 --- a/site/src/modules/dashboard/Navbar/Navbar.tsx +++ b/site/src/modules/dashboard/Navbar/Navbar.tsx @@ -9,6 +9,7 @@ import { canAccessAnyChatModelConfig, canViewDeploymentSettings, } from "#/modules/permissions"; +import { useCanShareOrganizationMCPServers } from "#/pages/AISettingsPage/MCPServersPage/organizationSharing"; import { useAccessibleModelOrganizations } from "#/pages/AISettingsPage/ModelsPage/organizationModels"; import { useFeatureVisibility } from "../useFeatureVisibility"; import { NavbarView } from "./NavbarView"; @@ -34,7 +35,7 @@ export const Navbar: React.FC = () => { featureVisibility.connection_log && permissions.viewAnyConnectionLog; const canViewAIBridge = featureVisibility.aibridge && permissions.viewAnyAIBridgeInterception; - const canViewAISettings = + const canViewSiteWideAISettings = permissions.viewAnyAIProvider || permissions.viewAIGatewayKeys || permissions.editDeploymentConfig || @@ -43,6 +44,12 @@ export const Navbar: React.FC = () => { permissions.updateAnyMCPServerConfig || permissions.deleteAnyMCPServerConfig || canAccessAnyModel; + const organizationMCPSharing = useCanShareOrganizationMCPServers( + organizations, + { enabled: !canViewSiteWideAISettings }, + ); + const canViewAISettings = + canViewSiteWideAISettings || organizationMCPSharing.canShare; const canViewModels = !canViewAISettings && accessibleModelOrgsQuery.organizations.length > 0; const canCreateChat = permissions.createChat; diff --git a/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx b/site/src/modules/dashboard/Navbar/NavbarView.stories.tsx index a958675c7097b..02c58753df948 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: {}, @@ -284,6 +284,7 @@ export const ForMember: Story = { export const ForMemberWithModelAccess: Story = { parameters: { + pixel: { matrix: pixelWithDesktop }, reactRouter: reactRouterParameters({ location: { path: "/" }, routing: [ @@ -314,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/management/AISettingsSidebar.tsx b/site/src/modules/management/AISettingsSidebar.tsx index 30228b3667fc0..72829a69b9a41 100644 --- a/site/src/modules/management/AISettingsSidebar.tsx +++ b/site/src/modules/management/AISettingsSidebar.tsx @@ -2,6 +2,7 @@ import type { FC } from "react"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import AISettingsSidebarView from "#/modules/management/AISettingsSidebarView"; +import { useCanShareOrganizationMCPServers } from "#/pages/AISettingsPage/MCPServersPage/organizationSharing"; import { useAccessibleModelOrganizations } from "#/pages/AISettingsPage/ModelsPage/organizationModels"; /** @@ -11,6 +12,10 @@ export const AISettingsSidebar: FC = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const accessibleOrgsQuery = useAccessibleModelOrganizations(organizations); + const organizationMCPSharing = useCanShareOrganizationMCPServers( + organizations, + { enabled: !permissions.editDeploymentConfig }, + ); return ( { canAccessOrganizationModels={ (accessibleOrgsQuery.organizations.length ?? 0) > 0 } + canShareOrganizationMCPServers={organizationMCPSharing.canShare} /> ); }; diff --git a/site/src/modules/management/AISettingsSidebarView.stories.tsx b/site/src/modules/management/AISettingsSidebarView.stories.tsx index ea871982da26e..8b024d4840bde 100644 --- a/site/src/modules/management/AISettingsSidebarView.stories.tsx +++ b/site/src/modules/management/AISettingsSidebarView.stories.tsx @@ -244,6 +244,19 @@ export const MCPServersForDeleteOnlyAdmin: Story = { }, }; +export const MCPServersForOrganizationShareOnlyAdmin: Story = { + args: { + permissions: MockNoPermissions, + canShareOrganizationMCPServers: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("link", { name: "MCP servers" }), + ).toHaveAttribute("href", "/ai/settings/mcp-servers"); + }, +}; + export const MCPServersForCreateOnlyAdmin: Story = { args: { permissions: { diff --git a/site/src/modules/management/AISettingsSidebarView.tsx b/site/src/modules/management/AISettingsSidebarView.tsx index 2a636f797348a..cccec41148e77 100644 --- a/site/src/modules/management/AISettingsSidebarView.tsx +++ b/site/src/modules/management/AISettingsSidebarView.tsx @@ -14,6 +14,7 @@ interface AISettingsSidebarViewProps { /** Site-wide permissions. */ permissions: Permissions; canAccessOrganizationModels?: boolean; + canShareOrganizationMCPServers?: boolean; } const SubNavItem: FC<{ href: string; children?: ReactNode }> = ({ @@ -59,6 +60,7 @@ const ModelsSidebarNavItem: FC = () => { const AISettingsSidebarView: FC = ({ permissions, canAccessOrganizationModels = false, + canShareOrganizationMCPServers = false, }) => { return ( @@ -101,13 +103,15 @@ const AISettingsSidebarView: FC = ({ (permissions.viewAnyMCPServerConfigs || permissions.createAnyMCPServerConfig || permissions.updateAnyMCPServerConfig || - permissions.deleteAnyMCPServerConfig) && ( + permissions.deleteAnyMCPServerConfig || + canShareOrganizationMCPServers) && (
{ const canvas = within(canvasElement); - await expect(canvas.getByRole("link", { name: "Premium" })).toHaveAttribute( - "href", - "/deployment/premium", - ); + await expect( + canvas.getByRole("link", { name: "Trial Upgrade" }), + ).toHaveAttribute("href", "/deployment/premium"); }, }; @@ -87,7 +86,7 @@ export const PremiumTabHidden: Story = { const canvas = within(canvasElement); await expect( - canvas.queryByRole("link", { name: "Premium" }), + canvas.queryByRole("link", { name: "Trial Upgrade" }), ).not.toBeInTheDocument(); // A neighbouring item must survive the change. await expect( diff --git a/site/src/modules/permissions/organizations.ts b/site/src/modules/permissions/organizations.ts index c21275f7e0c6d..570a00b678d13 100644 --- a/site/src/modules/permissions/organizations.ts +++ b/site/src/modules/permissions/organizations.ts @@ -143,6 +143,13 @@ export const organizationPermissionChecks = (organizationId: string) => }, action: "delete", }, + shareMCPServerConfig: { + object: { + resource_type: "mcp_server_config", + organization_id: organizationId, + }, + action: "share", + }, viewChatModelConfigs: { object: { resource_type: "chat_model_config", diff --git a/site/src/modules/roles/RoleSelector.stories.tsx b/site/src/modules/roles/RoleSelector.stories.tsx index 3f14e7fb0a9d9..417a8a846db82 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 222ecee8002c9..55246db38539f 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/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index a1f96888b0917..a5b140cfa6e05 100644 --- a/site/src/modules/terminal/WorkspaceTerminal.tsx +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -331,8 +331,9 @@ export const WorkspaceTerminal = ({ window.removeEventListener("resize", refit); resizeObserver.disconnect(); fitAddonRef.current = undefined; - nextTerminal.dispose(); setTerminal(undefined); + // xterm queues its initial viewport synchronization in a timer. + window.setTimeout(() => nextTerminal.dispose(), 0); }; }, [ isVisible, diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx new file mode 100644 index 0000000000000..3b6f9ba68696a --- /dev/null +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, spyOn } from "storybook/test"; +import { reactRouterParameters } from "storybook-addon-remix-react-router"; +import { API } from "#/api/api"; +import { chatModels } from "#/api/queries/chats"; +import { organizationsPermissions } from "#/api/queries/organizations"; +import { + MockDefaultOrganization, + MockNoOrganizationPermissions, + MockNoPermissions, + MockUserMember, +} from "#/testHelpers/entities"; +import { + withAuthProvider, + withDashboardProvider, +} from "#/testHelpers/storybook"; +import { AISettingsIndexRedirect } from "./AISettingsIndexRedirect"; + +const meta: Meta = { + title: "pages/AISettingsPage/AISettingsIndexRedirect", + component: AISettingsIndexRedirect, + decorators: [withAuthProvider, withDashboardProvider], + parameters: { + user: MockUserMember, + permissions: MockNoPermissions, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings" }, + routing: [ + { path: "/ai/settings", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers", + element:

Organization MCP servers

, + }, + { path: "/ai/settings/providers", element:

AI providers

}, + ], + }), + }, +}; + +export default meta; +type Story = StoryObj; + +export const OrganizationMCPSharerRedirectsToMCPServers: Story = { + parameters: { + queries: [ + { + key: chatModels(MockDefaultOrganization.id).queryKey, + data: { models: [], providers: [] }, + }, + { + key: organizationsPermissions([MockDefaultOrganization.id]).queryKey, + data: { + [MockDefaultOrganization.id]: { + ...MockNoOrganizationPermissions, + shareMCPServerConfig: true, + }, + }, + }, + ], + }, + play: async () => { + await expect( + await screen.findByRole("heading", { + name: "Organization MCP servers", + }), + ).toBeInTheDocument(); + }, +}; + +export const MemberWithoutMCPSharingFallsBack: Story = { + parameters: { + queries: [ + { + key: chatModels(MockDefaultOrganization.id).queryKey, + data: { models: [], providers: [] }, + }, + { + key: organizationsPermissions([MockDefaultOrganization.id]).queryKey, + data: { + [MockDefaultOrganization.id]: MockNoOrganizationPermissions, + }, + }, + ], + }, + play: async () => { + await expect( + await screen.findByRole("heading", { name: "AI providers" }), + ).toBeInTheDocument(); + }, +}; + +export const MCPSharingPermissionLookupFailureShowsError: Story = { + beforeEach: () => { + spyOn(API, "checkAuthorization").mockRejectedValue( + new Error("Unable to load organization permissions"), + ); + }, + parameters: { + queries: [ + { + key: chatModels(MockDefaultOrganization.id).queryKey, + data: { models: [], providers: [] }, + }, + ], + }, + play: async () => { + await expect( + await screen.findByText("Unable to load organization permissions"), + ).toBeInTheDocument(); + expect( + screen.queryByRole("heading", { name: "AI providers" }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx index 320f28ed2dc56..c9d45e1dc719d 100644 --- a/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx +++ b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.tsx @@ -4,12 +4,17 @@ import { Loader } from "#/components/Loader/Loader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { canAccessAnyChatModelConfig } from "#/modules/permissions"; +import { useCanShareOrganizationMCPServers } from "./MCPServersPage/organizationSharing"; import { useAccessibleModelOrganizations } from "./ModelsPage/organizationModels"; export const AISettingsIndexRedirect = () => { const { permissions } = useAuthenticated(); const { organizations } = useDashboard(); const accessibleOrgsQuery = useAccessibleModelOrganizations(organizations); + const organizationMCPSharing = useCanShareOrganizationMCPServers( + organizations, + { enabled: !permissions.editDeploymentConfig }, + ); if (permissions.viewAnyAIProvider) { return ; @@ -47,6 +52,18 @@ export const AISettingsIndexRedirect = () => { return ; } + if (organizationMCPSharing.isLoading) { + return ; + } + + if (organizationMCPSharing.error !== null) { + return ; + } + + if (organizationMCPSharing.canShare) { + return ; + } + if (permissions.editDeploymentConfig) { return ; } diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 32579b03ece6d..2c04d277017e2 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -45,6 +45,7 @@ type MCPOrganizationStoryPermissions = Readonly<{ create?: boolean; update?: boolean; delete?: boolean; + share?: boolean; }>; const organizationPermissionsResponse = ( @@ -68,7 +69,9 @@ const organizationPermissionsResponse = ( ? permissions?.update : permission === "deleteMCPServerConfig" ? permissions?.delete - : false; + : permission === "shareMCPServerConfig" + ? permissions?.share + : false; return [key, Boolean(allowed)]; }), ); @@ -325,6 +328,45 @@ export const DeleteOnlyOrgAdminCanOpenMCPServer: Story = { }, }; +export const ShareOnlyOrgAdminCanOpenMCPServer: Story = { + parameters: { + permissions: { + editDeploymentConfig: false, + viewAnyMCPServerConfigs: false, + createAnyMCPServerConfig: false, + updateAnyMCPServerConfig: false, + deleteAnyMCPServerConfig: false, + }, + reactRouter: reactRouterParameters({ + location: { path: "/ai/settings/mcp-servers" }, + routing: [ + { path: "/ai/settings/mcp-servers", useStoryElement: true }, + { + path: "/ai/settings/mcp-servers/:serverId", + element: , + }, + ], + }), + }, + beforeEach: () => { + mockOrganizationPermissions({ + [MockDefaultOrganization.id]: { share: true }, + }); + spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([ + MockCoderMCPServer, + ]); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole("button", { name: /Coder/ })); + await expect( + await canvas.findByRole("heading", { + name: `detail-org:${MockDefaultOrganization.name}`, + }), + ).toBeVisible(); + }, +}; + export const UpdateOnlyOrgAdminUsesAuthorizedOrganization: Story = { parameters: { permissions: { diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx index d96f46a303400..504a512a9c141 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.tsx @@ -30,7 +30,8 @@ const MCPServersPage: FC = () => { return Boolean( organizationPermissions?.viewMCPServerConfigs || organizationPermissions?.updateMCPServerConfig || - organizationPermissions?.deleteMCPServerConfig, + organizationPermissions?.deleteMCPServerConfig || + organizationPermissions?.shareMCPServerConfig, ); }); const creatableOrganizations = permissions.editDeploymentConfig @@ -59,13 +60,15 @@ const MCPServersPage: FC = () => { Boolean( organizationPermissions?.viewMCPServerConfigs || organizationPermissions?.updateMCPServerConfig || - organizationPermissions?.deleteMCPServerConfig, + organizationPermissions?.deleteMCPServerConfig || + organizationPermissions?.shareMCPServerConfig, ); const canOpenServer = permissions.editDeploymentConfig || Boolean( organizationPermissions?.updateMCPServerConfig || - organizationPermissions?.deleteMCPServerConfig, + organizationPermissions?.deleteMCPServerConfig || + organizationPermissions?.shareMCPServerConfig, ); const serversQuery = useQuery({ ...mcpServerConfigs(organization?.id ?? ""), @@ -81,7 +84,9 @@ const MCPServersPage: FC = () => { permissions.editDeploymentConfig || permissions.viewAnyMCPServerConfigs || permissions.updateAnyMCPServerConfig || - permissions.deleteAnyMCPServerConfig + permissions.deleteAnyMCPServerConfig || + organizationPermissionsQuery.data === undefined || + authorizedOrganizations.length > 0 } > Codestin Search App diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx index e26bd6dc37ce2..8e29456babdff 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPage.tsx @@ -45,7 +45,8 @@ const UpdateMCPServerPage: FC = () => { organizationPermissionsQuery.data?.[organization.id]; return Boolean( organizationPermissions?.updateMCPServerConfig || - organizationPermissions?.deleteMCPServerConfig, + organizationPermissions?.deleteMCPServerConfig || + organizationPermissions?.shareMCPServerConfig, ); }); const requestedOrganizationName = searchParams.get(orgSearchParam); @@ -66,7 +67,10 @@ const UpdateMCPServerPage: FC = () => { const canDelete = permissions.editDeploymentConfig || Boolean(organizationPermissions?.deleteMCPServerConfig); - const canManage = canUpdate || canDelete; + const canShare = + permissions.editDeploymentConfig || + Boolean(organizationPermissions?.shareMCPServerConfig); + const canManage = canUpdate || canDelete || canShare; const queryClient = useQueryClient(); const navigate = useNavigate(); const serverQuery = useQuery({ @@ -100,7 +104,9 @@ const UpdateMCPServerPage: FC = () => { isFeatureVisible={ permissions.editDeploymentConfig || permissions.updateAnyMCPServerConfig || - permissions.deleteAnyMCPServerConfig + permissions.deleteAnyMCPServerConfig || + organizationPermissionsQuery.data === undefined || + manageableOrganizations.length > 0 } > {organizationPermissionsQuery.isLoadingError ? ( @@ -148,6 +154,7 @@ const UpdateMCPServerPage: FC = () => { isSaving={updateMutation.isPending} isDeleting={deleteMutation.isPending} canSelectUserOIDC={permissions.editDeploymentConfig} + canShareServer={canShare} onCancel={() => void navigate(listPath)} onUpdateServer={ canUpdateServer diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx index 7fb53b57bbdb8..b0b22b32d790f 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx @@ -129,3 +129,42 @@ export const DeleteOnly: Story = { await expect(canvas.getByRole("button", { name: "Delete" })).toBeEnabled(); }, }; + +export const ShareOnlyAccess: Story = { + args: { + canShareServer: true, + onUpdateServer: undefined, + onDeleteServer: undefined, + onToggleEnabled: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + await expect(canvas.getByLabelText(/display name/i)).toBeDisabled(); + expect( + canvas.queryByRole("button", { name: "Delete" }), + ).not.toBeInTheDocument(); + await userEvent.click( + canvas.getByRole("button", { name: "Manage permissions" }), + ); + await expect( + await body.findByRole("dialog", { name: "Server permissions" }), + ).toHaveAttribute("data-state", "open"); + }, +}; + +export const NoShareReadOnlyAccess: Story = { + args: { + canShareServer: false, + onUpdateServer: undefined, + onDeleteServer: undefined, + onToggleEnabled: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByLabelText(/display name/i)).toBeDisabled(); + expect( + canvas.queryByRole("button", { name: "Manage permissions" }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx index 287f9975748cc..8745e559046e0 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx @@ -12,6 +12,7 @@ interface UpdateMCPServerPageViewProps { isSaving: boolean; isDeleting: boolean; canSelectUserOIDC: boolean; + canShareServer?: boolean; onUpdateServer?: ( serverId: string, req: TypesGen.UpdateMCPServerConfigRequest, @@ -29,6 +30,7 @@ const UpdateMCPServerPageView: FC = ({ isSaving, isDeleting, canSelectUserOIDC, + canShareServer, onUpdateServer, onDeleteServer, onToggleEnabled, @@ -44,6 +46,7 @@ const UpdateMCPServerPageView: FC = ({ isSaving={isSaving} isDeleting={isDeleting} canSelectUserOIDC={canSelectUserOIDC} + canShareServer={canShareServer} organizationPicker={ Promise; @@ -38,6 +40,7 @@ type MCPServerFormEditProps = { isDeleting: boolean; canSelectUserOIDC: boolean; organizationPicker?: ReactNode; + canShareServer?: boolean; onCreateServer?: undefined; onUpdateServer?: ( serverId: string, @@ -57,6 +60,7 @@ export const MCPServerForm: FC = ({ isDeleting = false, canSelectUserOIDC, organizationPicker, + canShareServer = false, onCreateServer, onUpdateServer, onDeleteServer, @@ -69,6 +73,7 @@ export const MCPServerForm: FC = ({ const [showAuth, setShowAuth] = useState(false); const [showBehavior, setShowBehavior] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); + const [sharingOpen, setSharingOpen] = useState(false); const form = useFormik({ initialValues: buildInitialMCPServerFormValues(server), @@ -116,6 +121,7 @@ export const MCPServerForm: FC = ({ onRequestDelete={ onDeleteServer ? () => setConfirmingDelete(true) : undefined } + onShareServer={canShareServer ? () => setSharingOpen(true) : undefined} onToggleEnabled={onToggleEnabled} />
@@ -136,6 +142,15 @@ export const MCPServerForm: FC = ({ setShowBehavior={setShowBehavior} />
+ {server && canShareServer && ( + + )} void; + onShareServer?: () => void; onToggleEnabled?: (enabled: boolean) => void; } @@ -44,6 +45,7 @@ export const MCPServerFormHeader: FC = ({ isEditing, isDisabled, onRequestDelete, + onShareServer, onToggleEnabled, }) => { const disabledReasonId = useId(); @@ -53,16 +55,29 @@ export const MCPServerFormHeader: FC = ({ <>
{listPath && } - {isEditing && server && onRequestDelete && ( + {isEditing && server && (onShareServer || onRequestDelete) && (
- + {onShareServer && ( + + )} + {onRequestDelete && ( + + )}
)}
diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerPrincipalAutocomplete.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerPrincipalAutocomplete.tsx new file mode 100644 index 0000000000000..59f5cb0c15c70 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerPrincipalAutocomplete.tsx @@ -0,0 +1,109 @@ +import { CheckIcon } from "lucide-react"; +import { type FC, useId, useState } from "react"; +import { keepPreviousData, useQuery } from "react-query"; +import { mcpServerConfigACLAvailable } from "#/api/queries/chats"; +import type { Group, ReducedUser } from "#/api/typesGenerated"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Autocomplete } from "#/components/Autocomplete/Autocomplete"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { getGroupSubtitle, isGroup } from "#/modules/groups"; +import { prepareQuery } from "#/utils/filters"; + +export type MCPServerPrincipalAutocompleteValue = ReducedUser | Group | null; +type AutocompleteOption = Exclude; + +type MCPServerPrincipalAutocompleteProps = { + value: MCPServerPrincipalAutocompleteValue; + onChange: (value: MCPServerPrincipalAutocompleteValue) => void; + organizationId: string; + serverId: string; + excludedPrincipalIds: readonly string[]; + className?: string; +}; + +export const MCPServerPrincipalAutocomplete: FC< + MCPServerPrincipalAutocompleteProps +> = ({ + value, + onChange, + organizationId, + serverId, + excludedPrincipalIds, + className, +}) => { + const [inputValue, setInputValue] = useState(""); + const [open, setOpen] = useState(false); + const autocompleteId = useId(); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + setInputValue(""); + } + }; + + const aclAvailableQuery = useQuery({ + ...mcpServerConfigACLAvailable(organizationId, serverId, { + q: prepareQuery(inputValue), + limit: 25, + }), + enabled: open, + placeholderData: keepPreviousData, + }); + + const options: AutocompleteOption[] = aclAvailableQuery.data + ? [ + ...aclAvailableQuery.data.groups, + ...aclAvailableQuery.data.users, + ].filter((principal) => !excludedPrincipalIds.includes(principal.id)) + : []; + + return ( +
+ option.id} + getOptionLabel={(option) => + isGroup(option) ? option.display_name || option.name : option.email + } + isOptionEqualToValue={(option, optionValue) => + option.id === optionValue.id + } + renderOption={(option, isSelected) => ( +
+ + {isSelected && } +
+ )} + open={open} + onOpenChange={handleOpenChange} + inputValue={inputValue} + onInputChange={setInputValue} + loading={aclAvailableQuery.isFetching} + placeholder="Search for user or group" + noOptionsText={ + aclAvailableQuery.error + ? "Unable to load users or groups" + : "No users or groups found" + } + className={className} + id={autocompleteId} + /> + {aclAvailableQuery.error && ( + + )} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.stories.tsx new file mode 100644 index 0000000000000..21b39047c2a6d --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.stories.tsx @@ -0,0 +1,477 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { API } from "#/api/api"; +import type * as TypesGen from "#/api/typesGenerated"; +import { + MockDefaultOrganization, + MockEveryoneGroup, + MockGroup, + MockGroup2, + MockMCPServerConfigACLAvailable, + MockUserMember, + MockUserOwner, +} from "#/testHelpers/entities"; +import { withDashboardProvider, withToaster } from "#/testHelpers/storybook"; +import { MockCoderMCPServer } from "../testFixtures"; +import { MCPServerSharingDialog } from "./MCPServerSharingDialog"; + +type MockACL = TypesGen.MCPServerConfigACL; + +const emptyACL: MockACL = { users: [], groups: [] }; +const populatedACL: MockACL = { + users: [{ ...MockUserMember, role: "read" }], + groups: [{ ...MockGroup, role: "read" }], +}; +const everyoneACL: MockACL = { + users: [], + groups: [{ ...MockEveryoneGroup, role: "read" }], +}; +const refreshedACL: MockACL = { + users: [{ ...MockUserOwner, role: "read" }], + groups: [{ ...MockGroup2, role: "read" }], +}; + +const mockLegacyPrincipalRequests = () => { + spyOn(API, "getOrganizationPaginatedMembers").mockRejectedValue( + new Error("Legacy organization member discovery must not be called"), + ); + spyOn(API, "getGroupsByOrganization").mockRejectedValue( + new Error("Legacy organization group discovery must not be called"), + ); +}; + +const mockRequests = ({ + acl = emptyACL, + aclError, + aclPending = false, + availableError, + updateError, +}: { + acl?: MockACL; + aclError?: Error; + aclPending?: boolean; + availableError?: Error; + updateError?: Error; +} = {}) => { + mockLegacyPrincipalRequests(); + if (aclPending) { + spyOn(API.experimental, "getMCPServerConfigACL").mockReturnValue( + new Promise(() => undefined), + ); + } else if (aclError) { + spyOn(API.experimental, "getMCPServerConfigACL").mockRejectedValue( + aclError, + ); + } else { + spyOn(API.experimental, "getMCPServerConfigACL").mockResolvedValue(acl); + } + if (availableError) { + spyOn(API.experimental, "getMCPServerConfigACLAvailable").mockRejectedValue( + availableError, + ); + } else { + spyOn(API.experimental, "getMCPServerConfigACLAvailable").mockResolvedValue( + MockMCPServerConfigACLAvailable, + ); + } + if (updateError) { + spyOn(API.experimental, "updateMCPServerConfigACL").mockRejectedValue( + updateError, + ); + } else { + spyOn(API.experimental, "updateMCPServerConfigACL").mockResolvedValue(); + } +}; + +const addAutocompleteOption = async ( + body: ReturnType, + query: string, + option: string | RegExp, +) => { + await userEvent.click( + await body.findByRole("button", { name: "Search for user or group" }), + ); + await userEvent.type( + body.getByPlaceholderText("Search for user or group"), + query, + ); + await userEvent.click(await body.findByRole("option", { name: option })); + await userEvent.click(body.getByRole("button", { name: "Add member" })); +}; + +let currentServerACL = populatedACL; + +const ReopenableSharingDialog = () => { + const [open, setOpen] = useState(true); + return ( + <> + + + + ); +}; + +const meta: Meta = { + title: "pages/AISettingsPage/MCPServersPage/MCPServerSharingDialog", + component: MCPServerSharingDialog, + decorators: [withDashboardProvider, withToaster], + args: { + open: true, + onOpenChange: fn(), + organizationId: MockDefaultOrganization.id, + serverId: MockCoderMCPServer.id, + serverName: MockCoderMCPServer.display_name, + }, +}; + +export default meta; +type Story = StoryObj; + +export const EmptyACL: Story = { + beforeEach: () => mockRequests(), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect( + await body.findByText("No members or groups have permission yet"), + ).toBeInTheDocument(); + expect( + body.getByRole("button", { name: "Save permissions" }), + ).toBeDisabled(); + expect(API.getOrganizationPaginatedMembers).not.toHaveBeenCalled(); + expect(API.getGroupsByOrganization).not.toHaveBeenCalled(); + }, +}; + +export const Loading: Story = { + beforeEach: () => mockRequests({ aclPending: true }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect(await body.findByRole("status")).toHaveTextContent( + "Loading server permissions", + ); + }, +}; + +export const InitialACLFailureIsBlocking: Story = { + beforeEach: () => mockRequests({ aclError: new Error("Unable to load ACL") }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect(await body.findByText("Unable to load ACL")).toBeInTheDocument(); + expect( + body.getByRole("button", { name: "Save permissions" }), + ).toBeDisabled(); + expect( + body.queryByRole("button", { name: "Search for user or group" }), + ).not.toBeInTheDocument(); + expect( + body.queryByRole("table", { + name: "Server permissions for members and groups", + }), + ).not.toBeInTheDocument(); + }, +}; + +export const HydratedPrincipalsRenderWithoutIDs: Story = { + beforeEach: () => mockRequests({ acl: populatedACL }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect( + await body.findByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }), + ).toBeInTheDocument(); + expect( + body.getByRole("row", { + name: new RegExp(MockGroup.display_name || MockGroup.name, "i"), + }), + ).toBeInTheDocument(); + expect(body.queryByText(MockUserMember.id)).not.toBeInTheDocument(); + expect(body.queryByText(MockGroup.id)).not.toBeInTheDocument(); + expect(API.getOrganizationPaginatedMembers).not.toHaveBeenCalled(); + expect(API.getGroupsByOrganization).not.toHaveBeenCalled(); + }, +}; + +export const EveryoneGroup: Story = { + beforeEach: () => mockRequests({ acl: everyoneACL }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const everyoneRow = await body.findByRole("row", { name: /Everyone/i }); + expect(everyoneRow).toHaveTextContent("Everyone"); + expect(everyoneRow).toHaveTextContent("All users"); + }, +}; + +export const AddUser: Story = { + beforeEach: () => mockRequests(), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await addAutocompleteOption( + body, + MockUserMember.email, + new RegExp(MockUserMember.email, "i"), + ); + + expect( + body.getByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }), + ).toBeVisible(); + await userEvent.click( + body.getByRole("button", { name: "Save permissions" }), + ); + + await waitFor(() => + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledTimes( + 1, + ), + ); + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + { user_roles: { [MockUserMember.id]: "read" } }, + ); + expect(args.onOpenChange).toHaveBeenCalledWith(false); + }, +}; + +export const AddGroup: Story = { + beforeEach: () => mockRequests(), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await addAutocompleteOption( + body, + MockGroup2.name, + new RegExp(MockGroup2.display_name || MockGroup2.name, "i"), + ); + + expect( + body.getByRole("row", { + name: new RegExp(MockGroup2.display_name || MockGroup2.name, "i"), + }), + ).toBeVisible(); + await userEvent.click( + body.getByRole("button", { name: "Save permissions" }), + ); + + await waitFor(() => + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledTimes( + 1, + ), + ); + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + { group_roles: { [MockGroup2.id]: "read" } }, + ); + expect(args.onOpenChange).toHaveBeenCalledWith(false); + }, +}; + +export const SelectedPrincipalsExcludedFromAutocomplete: Story = { + beforeEach: () => mockRequests({ acl: populatedACL }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { + name: "Search for user or group", + }), + ); + + expect( + body.queryByRole("option", { + name: new RegExp(MockUserMember.email, "i"), + }), + ).not.toBeInTheDocument(); + expect( + body.queryByRole("option", { + name: new RegExp(MockGroup.display_name || MockGroup.name, "i"), + }), + ).not.toBeInTheDocument(); + expect( + await body.findByRole("option", { + name: new RegExp(MockUserOwner.email, "i"), + }), + ).toBeInTheDocument(); + expect( + body.getByRole("option", { + name: new RegExp(MockGroup2.display_name || MockGroup2.name, "i"), + }), + ).toBeInTheDocument(); + }, +}; + +export const SaveRemovalsAsSparseDelta: Story = { + beforeEach: () => mockRequests({ acl: populatedACL }), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { + name: `Remove ${MockGroup.display_name}`, + }), + ); + await userEvent.click( + body.getByRole("button", { + name: `Remove ${MockUserMember.username}`, + }), + ); + await userEvent.click( + body.getByRole("button", { name: "Save permissions" }), + ); + + await waitFor(() => + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + { + user_roles: { [MockUserMember.id]: "" }, + group_roles: { [MockGroup.id]: "" }, + }, + ), + ); + expect(args.onOpenChange).toHaveBeenCalledWith(false); + }, +}; + +export const CandidateDiscoveryFailureKeepsACLUsable: Story = { + beforeEach: () => + mockRequests({ + acl: populatedACL, + availableError: new Error("Unable to discover principals"), + }), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const userRow = await body.findByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }); + await userEvent.click( + body.getByRole("button", { name: "Search for user or group" }), + ); + expect(await body.findByRole("alert")).toHaveTextContent( + "Unable to discover principals", + ); + expect(userRow).toBeInTheDocument(); + await userEvent.click( + body.getByRole("button", { name: `Remove ${MockGroup.display_name}` }), + ); + await userEvent.click( + body.getByRole("button", { name: "Save permissions" }), + ); + + await waitFor(() => + expect(API.experimental.updateMCPServerConfigACL).toHaveBeenCalledWith( + MockDefaultOrganization.id, + MockCoderMCPServer.id, + { group_roles: { [MockGroup.id]: "" } }, + ), + ); + }, +}; + +export const ReopenUsesFreshACL: Story = { + render: () => , + beforeEach: () => { + mockLegacyPrincipalRequests(); + currentServerACL = populatedACL; + spyOn(API.experimental, "getMCPServerConfigACL").mockImplementation( + async () => currentServerACL, + ); + spyOn(API.experimental, "getMCPServerConfigACLAvailable").mockResolvedValue( + MockMCPServerConfigACLAvailable, + ); + spyOn(API.experimental, "updateMCPServerConfigACL").mockResolvedValue(); + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + expect( + await body.findByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }), + ).toBeInTheDocument(); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect( + body.queryByRole("dialog", { name: "Server permissions" }), + ).not.toBeInTheDocument(), + ); + + currentServerACL = refreshedACL; + await userEvent.click(body.getByRole("button", { name: "Open sharing" })); + + expect( + await body.findByRole("row", { + name: new RegExp(MockUserOwner.username, "i"), + }), + ).toBeInTheDocument(); + expect( + body.getByRole("row", { + name: new RegExp(MockGroup2.display_name || MockGroup2.name, "i"), + }), + ).toBeInTheDocument(); + expect( + body.queryByRole("row", { + name: new RegExp(MockUserMember.username, "i"), + }), + ).not.toBeInTheDocument(); + expect(API.experimental.getMCPServerConfigACL).toHaveBeenCalledTimes(2); + }, +}; + +export const SaveErrorKeepsEditorOpen: Story = { + beforeEach: () => + mockRequests({ + acl: populatedACL, + updateError: new Error("Unable to save ACL"), + }), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { + name: `Remove ${MockGroup.display_name}`, + }), + ); + await userEvent.click( + body.getByRole("button", { name: "Save permissions" }), + ); + + const alert = await body.findByRole("alert"); + expect(alert).toHaveTextContent("Unable to save ACL"); + expect( + body.getByRole("dialog", { name: "Server permissions" }), + ).toHaveAttribute("data-state", "open"); + expect( + body.queryByRole("row", { + name: new RegExp(MockGroup.display_name || MockGroup.name, "i"), + }), + ).not.toBeInTheDocument(); + expect( + body.getByRole("button", { name: "Save permissions" }), + ).toBeEnabled(); + expect(args.onOpenChange).not.toHaveBeenCalledWith(false); + }, +}; + +export const CancelDiscardsDraft: Story = { + beforeEach: () => mockRequests({ acl: populatedACL }), + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + await userEvent.click( + await body.findByRole("button", { + name: `Remove ${MockGroup.display_name}`, + }), + ); + await userEvent.click(body.getByRole("button", { name: "Cancel" })); + + expect(API.experimental.updateMCPServerConfigACL).not.toHaveBeenCalled(); + expect(args.onOpenChange).toHaveBeenCalledWith(false); + }, +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx new file mode 100644 index 0000000000000..713c9ce7a6357 --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerSharingDialog.tsx @@ -0,0 +1,142 @@ +import type { FC } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { toast } from "sonner"; +import { + mcpServerConfigACL, + updateMCPServerConfigACL, +} from "#/api/queries/chats"; +import type * as TypesGen from "#/api/typesGenerated"; +import { getGroupSubtitle, isGroup } from "#/modules/groups"; +import { + ResourceSharingDialog, + type SharingDialogData, + type SharingPrincipal, + type SharingPrincipalSelection, +} from "../../components/ResourceSharingDialog"; +import { + MCPServerPrincipalAutocomplete, + type MCPServerPrincipalAutocompleteValue, +} from "./MCPServerPrincipalAutocomplete"; + +type MCPServerSharingDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + serverId: string; + serverName: string; +}; + +type MCPServerPrincipal = Exclude; + +const groupPrincipal = (group: TypesGen.Group): SharingPrincipal => ({ + id: group.id, + name: group.display_name || group.name, + subtitle: getGroupSubtitle(group), + avatarUrl: group.avatar_url, +}); + +const userPrincipal = (user: TypesGen.MinimalUser): SharingPrincipal => ({ + id: user.id, + name: user.username, + subtitle: user.name || "User", + avatarUrl: user.avatar_url, +}); + +const sharingDialogData = ( + acl: TypesGen.MCPServerConfigACL, +): SharingDialogData => ({ + acl: { + user_roles: Object.fromEntries( + acl.users.map((user) => [user.id, user.role]), + ), + group_roles: Object.fromEntries( + acl.groups.map((group) => [group.id, group.role]), + ), + }, + principals: { + users: Object.fromEntries( + acl.users.map((user) => [user.id, userPrincipal(user)]), + ), + groups: Object.fromEntries( + acl.groups.map((group) => [group.id, groupPrincipal(group)]), + ), + }, +}); + +const selectedPrincipal = ( + option: MCPServerPrincipal, +): SharingPrincipalSelection => + isGroup(option) + ? { kind: "group", principal: groupPrincipal(option) } + : { kind: "user", principal: userPrincipal(option) }; + +type OpenMCPServerSharingDialogProps = Omit< + MCPServerSharingDialogProps, + "open" +>; + +const OpenMCPServerSharingDialog: FC = ({ + onOpenChange, + organizationId, + serverId, + serverName, +}) => { + const queryClient = useQueryClient(); + const aclOptions = mcpServerConfigACL(organizationId, serverId); + const aclQuery = useQuery({ ...aclOptions, refetchOnMount: "always" }); + const updateMutation = useMutation(updateMCPServerConfigACL(queryClient)); + const data = aclQuery.data ? sharingDialogData(aclQuery.data) : undefined; + + const close = () => { + onOpenChange(false); + queryClient.removeQueries({ queryKey: aclOptions.queryKey, exact: true }); + }; + + return ( + Manage which organization members and groups can use {serverName}. + } + loadingLabel="Loading server permissions" + emptyTitle="No members or groups have permission yet" + tableLabel="Server permissions for members and groups" + roleLabel="Read" + confirmText="Save permissions" + data={data} + loadError={data ? null : aclQuery.error} + refetchError={data ? aclQuery.error : null} + saveError={updateMutation.error} + isSaving={updateMutation.isPending} + readRole="read" + deletedRole="" + renderAutocomplete={({ value, onChange, excludedPrincipalIds }) => ( + + )} + getPrincipal={selectedPrincipal} + onClose={close} + onSave={(req) => + updateMutation.mutate( + { organization: organizationId, id: serverId, req }, + { + onSuccess: () => { + toast.success(`Permissions for "${serverName}" updated.`); + close(); + }, + }, + ) + } + /> + ); +}; + +export const MCPServerSharingDialog: FC = ( + props, +) => (props.open ? : null); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/organizationSharing.ts b/site/src/pages/AISettingsPage/MCPServersPage/organizationSharing.ts new file mode 100644 index 0000000000000..cbd937fc99a6b --- /dev/null +++ b/site/src/pages/AISettingsPage/MCPServersPage/organizationSharing.ts @@ -0,0 +1,31 @@ +import { useQuery } from "react-query"; +import { organizationsPermissions } from "#/api/queries/organizations"; +import type { Organization } from "#/api/typesGenerated"; + +type UseCanShareOrganizationMCPServersOptions = { + enabled?: boolean; +}; + +// Top-level navigation must admit organization-scoped sharers without +// site-wide MCP permissions. +export const useCanShareOrganizationMCPServers = ( + organizations: readonly Organization[], + options: UseCanShareOrganizationMCPServersOptions = {}, +) => { + const enabled = (options.enabled ?? true) && organizations.length > 0; + const organizationsPermissionsQuery = useQuery({ + ...organizationsPermissions( + organizations.map((organization) => organization.id), + ), + enabled, + }); + return { + canShare: organizations.some( + (organization) => + organizationsPermissionsQuery.data?.[organization.id] + ?.shareMCPServerConfig, + ), + isLoading: enabled && organizationsPermissionsQuery.isLoading, + error: organizationsPermissionsQuery.error, + }; +}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx index dd410688ff53b..28fcd7c9177c8 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/OrganizationModelsLayout.stories.tsx @@ -84,6 +84,16 @@ const meta: Meta = { key: organizationsPermissions([MockOrganization2.id]).queryKey, data: { [MockOrganization2.id]: MockOrganizationPermissions }, }, + { + key: organizationsPermissions([ + MockDefaultOrganization.id, + MockOrganization2.id, + ]).queryKey, + data: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + [MockOrganization2.id]: MockOrganizationPermissions, + }, + }, ], }, }; @@ -206,6 +216,15 @@ export const InvalidRequestedOrganizationDeniesAdd: Story = { [MockDefaultOrganization.id]: MockOrganizationPermissions, }, }, + { + key: organizationsPermissions([ + MockDefaultOrganization.id, + MockOrganization2.id, + ]).queryKey, + data: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + }, + }, ], }, play: async ({ canvasElement }) => { @@ -281,6 +300,16 @@ export const DuplicateDisplayNamesAreDisambiguated: Story = { [MockDefaultOrganization.id]: MockOrganizationPermissions, }, }, + { + key: organizationsPermissions([ + MockDefaultOrganization.id, + duplicateNameOrganization.id, + ]).queryKey, + data: { + [MockDefaultOrganization.id]: MockOrganizationPermissions, + [duplicateNameOrganization.id]: MockOrganizationPermissions, + }, + }, ], }, play: async ({ canvasElement }) => { @@ -389,6 +418,7 @@ export const NoReadableOrganizationIsNotFound: Story = { isAxiosError: true, response: { status: 403 }, }); + spyOn(API, "checkAuthorization").mockResolvedValue({}); }, parameters: { queries: [] }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx index f69b265b01b5e..4239a0e41fb77 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx @@ -1,38 +1,21 @@ -import isEqual from "lodash/isEqual"; -import { Trash2Icon, UserPlusIcon } from "lucide-react"; -import { type FC, useEffect, useState } from "react"; +import type { FC } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { toast } from "sonner"; import { chatModelACL, updateChatModelACL } from "#/api/queries/chats"; import { groupsByOrganization } from "#/api/queries/groups"; import { organizationMembers } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { AvatarData } from "#/components/Avatar/AvatarData"; -import { Button } from "#/components/Button/Button"; -import { - Dialog, - DialogActions, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "#/components/Dialog/Dialog"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "#/components/Table/Table"; import { getGroupSubtitle, isGroup } from "#/modules/groups"; import { UserOrGroupAutocomplete, type UserOrGroupAutocompleteValue, } from "#/modules/workspaces/WorkspaceSharingForm/UserOrGroupAutocomplete"; +import { + ResourceSharingDialog, + type SharingDialogData, + type SharingPrincipal, + type SharingPrincipalSelection, +} from "../../components/ResourceSharingDialog"; type ChatModelSharingDialogProps = { open: boolean; @@ -42,327 +25,161 @@ type ChatModelSharingDialogProps = { modelName: string; }; -type DraftACL = { - user_roles: Record; - group_roles: Record; -}; - -const emptyDraft = (): DraftACL => ({ user_roles: {}, group_roles: {} }); - -const buildRoleDelta = ( - initial: Record, - current: Record, -): Record => { - const delta: Record = {}; - for (const [principalId, role] of Object.entries(current)) { - if (initial[principalId] !== role) { - delta[principalId] = role; - } - } - for (const principalId of Object.keys(initial)) { - if (current[principalId] === undefined) { - delta[principalId] = ""; - } - } - return delta; -}; - -const buildACLDelta = ( - initial: DraftACL, - current: DraftACL, -): TypesGen.UpdateChatModelACLRequest => { - const userRoles = buildRoleDelta(initial.user_roles, current.user_roles); - const groupRoles = buildRoleDelta(initial.group_roles, current.group_roles); - return { - ...(Object.keys(userRoles).length > 0 ? { user_roles: userRoles } : {}), - ...(Object.keys(groupRoles).length > 0 ? { group_roles: groupRoles } : {}), - }; -}; - -export const ChatModelSharingDialog: FC = ({ - open, +type ChatModelPrincipal = Exclude; + +const groupPrincipal = (group: TypesGen.Group): SharingPrincipal => ({ + id: group.id, + name: group.display_name || group.name, + subtitle: getGroupSubtitle(group), + avatarUrl: group.avatar_url, +}); + +const memberPrincipal = ( + member: TypesGen.OrganizationMemberWithUserData, +): SharingPrincipal => ({ + id: member.user_id, + name: member.username, + subtitle: member.name || member.email || "User", + avatarUrl: member.avatar_url, +}); + +const sharingDialogData = ( + acl: TypesGen.ChatModelACL, + members: readonly TypesGen.OrganizationMemberWithUserData[], + groups: readonly TypesGen.Group[], +): SharingDialogData => ({ + acl: { + user_roles: { ...acl.user_roles }, + group_roles: { ...acl.group_roles }, + }, + principals: { + users: Object.fromEntries( + Object.keys(acl.user_roles).map((userId) => { + const member = members.find((item) => item.user_id === userId); + return [ + userId, + member + ? memberPrincipal(member) + : { id: userId, name: userId, subtitle: "User" }, + ]; + }), + ), + groups: Object.fromEntries( + Object.keys(acl.group_roles).map((groupId) => { + const group = groups.find((item) => item.id === groupId); + return [ + groupId, + group + ? groupPrincipal(group) + : { id: groupId, name: groupId, subtitle: "Group" }, + ]; + }), + ), + }, +}); + +const selectedPrincipal = ( + option: ChatModelPrincipal, +): SharingPrincipalSelection => + isGroup(option) + ? { kind: "group", principal: groupPrincipal(option) } + : { kind: "user", principal: memberPrincipal(option) }; + +type OpenChatModelSharingDialogProps = Omit< + ChatModelSharingDialogProps, + "open" +>; + +const OpenChatModelSharingDialog: FC = ({ onOpenChange, organizationId, modelId, modelName, }) => { const queryClient = useQueryClient(); - const [draft, setDraft] = useState(emptyDraft); - const [initialACL, setInitialACL] = useState(null); - const [initialized, setInitialized] = useState(false); - const [selectedOption, setSelectedOption] = - useState(null); - - const aclQuery = useQuery({ - ...chatModelACL(organizationId, modelId), - enabled: open && initialized, - }); + const aclOptions = chatModelACL(organizationId, modelId); + const membersOptions = organizationMembers(organizationId, { limit: 0 }); + const groupsOptions = groupsByOrganization(organizationId); + const aclQuery = useQuery({ ...aclOptions, refetchOnMount: "always" }); const membersQuery = useQuery({ - ...organizationMembers(organizationId, { limit: 0 }), - enabled: open, - }); - const groupsQuery = useQuery({ - ...groupsByOrganization(organizationId), - enabled: open, + ...membersOptions, + refetchOnMount: "always", }); + const groupsQuery = useQuery({ ...groupsOptions, refetchOnMount: "always" }); const updateMutation = useMutation(updateChatModelACL(queryClient)); - useEffect(() => { - if (!open || initialized) { - return; - } - let cancelled = false; - void aclQuery.refetch().then(({ data }) => { - if (cancelled || !data) { - return; - } - const snapshot = { - user_roles: { ...data.user_roles }, - group_roles: { ...data.group_roles }, - }; - setInitialACL(snapshot); - setDraft(snapshot); - setInitialized(true); - }); - return () => { - cancelled = true; - }; - }, [aclQuery.refetch, initialized, open]); + const members = membersQuery.data?.members; + const groups = groupsQuery.data; + const data = + aclQuery.data && members && groups + ? sharingDialogData(aclQuery.data, members, groups) + : undefined; + const loadError = data + ? null + : (aclQuery.error ?? + (members === undefined ? membersQuery.error : null) ?? + (groups === undefined ? groupsQuery.error : null)); + const refetchError = data + ? (aclQuery.error ?? membersQuery.error ?? groupsQuery.error) + : null; - const reset = () => { - setDraft(emptyDraft()); - setInitialACL(null); - setInitialized(false); - setSelectedOption(null); - updateMutation.reset(); - }; const close = () => { - reset(); onOpenChange(false); - }; - - const members = membersQuery.data?.members ?? []; - const groups = groupsQuery.data ?? []; - const userIds = Object.keys(draft.user_roles); - const groupIds = Object.keys(draft.group_roles); - const excludedPrincipals = [ - ...members - .filter((member) => userIds.includes(member.user_id)) - .map((member) => ({ id: member.user_id })), - ...groups.filter((group) => groupIds.includes(group.id)), - ]; - const loadError = - (!initialized ? aclQuery.error : null) ?? - (membersQuery.data === undefined ? membersQuery.error : null) ?? - (groupsQuery.data === undefined ? groupsQuery.error : null); - const refetchError = loadError - ? null - : (aclQuery.error ?? membersQuery.error ?? groupsQuery.error); - const isLoading = - !loadError && - ((open && !initialized) || membersQuery.isLoading || groupsQuery.isLoading); - const isEmpty = userIds.length === 0 && groupIds.length === 0; - const isDirty = - initialized && initialACL !== null && !isEqual(draft, initialACL); - - const addSelectedPrincipal = () => { - if (!selectedOption) { - return; - } - if (isGroup(selectedOption)) { - setDraft((current) => ({ - ...current, - group_roles: { ...current.group_roles, [selectedOption.id]: "read" }, - })); - } else { - setDraft((current) => ({ - ...current, - user_roles: { ...current.user_roles, [selectedOption.id]: "read" }, - })); - } - setSelectedOption(null); - }; - - const removeUser = (userId: string) => { - setDraft((current) => { - const userRoles = { ...current.user_roles }; - delete userRoles[userId]; - return { ...current, user_roles: userRoles }; + queryClient.removeQueries({ queryKey: aclOptions.queryKey, exact: true }); + queryClient.removeQueries({ + queryKey: membersOptions.queryKey, + exact: true, }); - }; - const removeGroup = (groupId: string) => { - setDraft((current) => { - const groupRoles = { ...current.group_roles }; - delete groupRoles[groupId]; - return { ...current, group_roles: groupRoles }; + queryClient.removeQueries({ + queryKey: groupsOptions.queryKey, + exact: true, }); }; - const save = () => { - if (!initialACL) { - return; - } - updateMutation.mutate( - { - organizationId, - modelId, - req: buildACLDelta(initialACL, draft), - }, - { - onSuccess: () => { - toast.success(`Permissions for "${modelName}" updated.`); - close(); - }, - }, - ); - }; - return ( - { - if (!nextOpen && !updateMutation.isPending) { - close(); - } - }} - > - - - Model permissions - - Manage which organization members and groups can use {modelName}. - - - - {updateMutation.error && } - {refetchError && } - {loadError && } - - {loadError ? null : isLoading ? ( -
- - Loading model permissions -
- ) : !initialized ? null : ( -
-
-
- -
- -
- - {isEmpty ? ( -
-

- No members or groups have permission yet -

-

- Add a member or group using the controls above. -

-
- ) : ( - - - - Member - Role - - - - - {groupIds.map((groupId) => { - const group = groups.find((item) => item.id === groupId); - const name = group?.display_name || group?.name || groupId; - return ( - - - - - Use - - - - - ); - })} - {userIds.map((userId) => { - const member = members.find( - (item) => item.user_id === userId, - ); - const name = member?.username || userId; - return ( - - - - - Use - - - - - ); - })} - -
- )} -
- )} - - - - -
-
+ Manage which organization members and groups can use {modelName}. + } + loadingLabel="Loading model permissions" + emptyTitle="No members or groups have permission yet" + tableLabel="Model permissions for members and groups" + roleLabel="Use" + confirmText="Save permissions" + data={data} + loadError={loadError} + refetchError={refetchError} + saveError={updateMutation.error} + isSaving={updateMutation.isPending} + readRole="read" + deletedRole="" + renderAutocomplete={({ value, onChange, excludedPrincipalIds }) => ( + ({ id }))} + className="w-full" + /> + )} + getPrincipal={selectedPrincipal} + onClose={close} + onSave={(req) => + updateMutation.mutate( + { organizationId, modelId, req }, + { + onSuccess: () => { + toast.success(`Permissions for "${modelName}" updated.`); + close(); + }, + }, + ) + } + /> ); }; + +export const ChatModelSharingDialog: FC = ( + props, +) => (props.open ? : null); diff --git a/site/src/pages/AISettingsPage/components/ResourceSharingDialog.tsx b/site/src/pages/AISettingsPage/components/ResourceSharingDialog.tsx new file mode 100644 index 0000000000000..4a264c41a1f5f --- /dev/null +++ b/site/src/pages/AISettingsPage/components/ResourceSharingDialog.tsx @@ -0,0 +1,457 @@ +import isEqual from "lodash/isEqual"; +import { Trash2Icon, UserPlusIcon } from "lucide-react"; +import { type FC, type ReactNode, useState } from "react"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { AvatarData } from "#/components/Avatar/AvatarData"; +import { Button } from "#/components/Button/Button"; +import { + Dialog, + DialogActions, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "#/components/Dialog/Dialog"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "#/components/Table/Table"; + +type SharingACL = { + user_roles: Record; + group_roles: Record; +}; + +type SharingACLUpdate = { + user_roles?: Record; + group_roles?: Record; +}; + +export type SharingPrincipal = { + id: string; + name: string; + subtitle: string; + avatarUrl?: string; +}; + +type SharingPrincipals = { + users: Record; + groups: Record; +}; + +export type SharingDialogData = { + acl: SharingACL; + principals: SharingPrincipals; +}; + +export type SharingPrincipalSelection = { + kind: "user" | "group"; + principal: SharingPrincipal; +}; + +type SharingAutocompleteProps