diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5a9b2bb4f8b..b68b80c1cd0 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 0fb2c6f0e9d..7b2b4ff27e5 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 70a942fc3a4..70dc896262d 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 5b54c307884..764e3850303 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 ce04b0cb94c..ae228b1a416 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/mcp.go b/coderd/mcp.go index a1b297bfd36..d6160729f00 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 6b793ed04c6..572c9ae20a0 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 00000000000..299d5a26fd7 --- /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/codersdk/mcp.go b/codersdk/mcp.go index edb70ca9971..3214900a887 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/templates.go b/codersdk/templates.go index a5610747825..83bf570995d 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -217,7 +217,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/platform-controls/mcp-servers.md b/docs/ai-coder/agents/platform-controls/mcp-servers.md index c74b169531b..39ea998abb1 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 57d50338197..0b4012fe2ff 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/site/src/api/api.test.ts b/site/src/api/api.test.ts index 4530ec72bd0..b64cc5cad78 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, @@ -527,6 +529,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/experimental/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 03b2a0e7c63..826b4e91c72 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -368,6 +368,10 @@ const mcpServerConfigsPath = (organization: string) => `/api/experimental/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) => @@ -3986,6 +3990,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 5bc443aef8d..2ae4ecaa9d2 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 ab8a21d0b2b..e6ab1f04cd3 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 6ebd55fb523..df2c45d2ea1 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 { diff --git a/site/src/modules/dashboard/DashboardLayout.stories.tsx b/site/src/modules/dashboard/DashboardLayout.stories.tsx index c23c84aab63..d57b63f83f6 100644 --- a/site/src/modules/dashboard/DashboardLayout.stories.tsx +++ b/site/src/modules/dashboard/DashboardLayout.stories.tsx @@ -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: [ @@ -167,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 c1e6af8c9c2..cb6ceccb1a1 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/management/AISettingsSidebar.tsx b/site/src/modules/management/AISettingsSidebar.tsx index 30228b3667f..72829a69b9a 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 ea871982da2..8b024d4840b 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 2a636f79734..cccec41148e 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) && (
}, 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/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx b/site/src/pages/AISettingsPage/AISettingsIndexRedirect.stories.tsx new file mode 100644 index 00000000000..3b6f9ba6869 --- /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 320f28ed2dc..c9d45e1dc71 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 409526712a5..eaba3e1e841 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 b7fb6fe0f58..5c5b8eb1fed 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 e26bd6dc37c..8e29456babd 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 be3535f06a6..0ae03860b4d 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.stories.tsx @@ -133,3 +133,46 @@ export const DeleteOnly: Story = { ).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(); + const serverActions = canvas.getByRole("button", { + name: "Server actions", + }); + await userEvent.click(serverActions); + expect( + body.queryByRole("menuitem", { name: "Remove" }), + ).not.toBeInTheDocument(); + await userEvent.click( + await body.findByRole("menuitem", { 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: "Server actions" }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/UpdateMCPServerPage/UpdateMCPServerPageView.tsx index dda7efcdc0d..a8e4b30351b 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, @@ -51,6 +53,7 @@ const UpdateMCPServerPageView: FC = ({ isSaving={isSaving} isDeleting={isDeleting} canSelectUserOIDC={canSelectUserOIDC} + canShareServer={canShareServer} onUpdateServer={onUpdateServer} onDeleteServer={onDeleteServer} onToggleEnabled={onToggleEnabled} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx index f7066458779..9863c5d2d17 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -5,6 +5,7 @@ import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; import { MCPServerFormDialogs } from "./MCPServerFormDialogs"; import { MCPServerFormFields } from "./MCPServerFormFields"; import { MCPServerFormHeader } from "./MCPServerFormHeader"; +import { MCPServerSharingDialog } from "./MCPServerSharingDialog"; import { buildCreateMCPServerConfigRequest, buildInitialMCPServerFormValues, @@ -21,6 +22,7 @@ type MCPServerFormCreateProps = { isSaving: boolean; isDeleting?: false; canSelectUserOIDC: boolean; + canShareServer?: false; onCreateServer: ( req: TypesGen.CreateMCPServerConfigRequest, ) => Promise; @@ -36,6 +38,7 @@ type MCPServerFormEditProps = { isSaving: boolean; isDeleting: boolean; canSelectUserOIDC: boolean; + canShareServer?: boolean; onCreateServer?: undefined; onUpdateServer?: ( serverId: string, @@ -54,6 +57,7 @@ export const MCPServerForm: FC = ({ isSaving, isDeleting = false, canSelectUserOIDC, + canShareServer = false, onCreateServer, onUpdateServer, onDeleteServer, @@ -66,6 +70,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), @@ -110,6 +115,7 @@ export const MCPServerForm: FC = ({ onRequestDelete={ onDeleteServer ? () => setConfirmingDelete(true) : undefined } + onShareServer={canShareServer ? () => setSharingOpen(true) : undefined} onToggleEnabled={onToggleEnabled} />
@@ -129,6 +135,15 @@ export const MCPServerForm: FC = ({ setShowBehavior={setShowBehavior} />
+ {server && canShareServer && ( + + )} void; + onShareServer?: () => void; onToggleEnabled?: (enabled: boolean) => void; } @@ -50,6 +57,7 @@ export const MCPServerFormHeader: FC = ({ isEditing, isDisabled, onRequestDelete, + onShareServer, onToggleEnabled, }) => { const disabledReasonId = useId(); @@ -59,7 +67,7 @@ export const MCPServerFormHeader: FC = ({ <>
{listPath && } - {isEditing && server && onRequestDelete && ( + {isEditing && server && (onRequestDelete || onShareServer) && ( - - - Remove - + {onShareServer && ( + + + Manage permissions + + )} + {onShareServer && onRequestDelete && } + {onRequestDelete && ( + + + Remove + + )} )} 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 00000000000..59f5cb0c15c --- /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 00000000000..21b39047c2a --- /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 00000000000..713c9ce7a63 --- /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 00000000000..cbd937fc99a --- /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/components/ChatModelSharingDialog.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx index f69b265b01b..4239a0e41fb 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 00000000000..4a264c41a1f --- /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