From 83d81b298ceefc1f2ebce29169c9616a94ab709c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 10:41:14 +0000 Subject: [PATCH 1/6] feat: hydrate chat model ACL and add ACL available endpoint The chat model sharing dialog previously required org member and group directory reads that a delegated sharer role does not have, blanking the dialog under restrictive workspace sharing modes and rendering group grants as raw UUIDs. Hydrate GET .../chats/models/{model}/acl with resolved users and groups, and add GET .../chats/models/{model}/acl/available returning assignable principals, both authorized solely via chat model config share permission, mirroring templateAvailablePermissions. --- coderd/apidoc/docs.go | 86 +++- coderd/apidoc/swagger.json | 82 +++- coderd/chat_routes.go | 1 + coderd/exp_chats_model_acl.go | 240 +++++++++- coderd/exp_chats_model_acl_test.go | 418 ++++++++++++++++-- codersdk/chats.go | 32 +- codersdk/chats_model_acl_test.go | 15 +- docs/reference/api/schemas.md | 58 ++- site/src/api/api.test.ts | 16 +- site/src/api/api.ts | 13 + site/src/api/queries/chats.test.ts | 41 +- site/src/api/queries/chats.ts | 18 + site/src/api/typesGenerated.ts | 7 +- .../ChatModelPrincipalAutocomplete.tsx | 109 +++++ .../ChatModelSharingDialog.stories.tsx | 196 ++++++-- .../components/ChatModelSharingDialog.tsx | 96 ++-- .../components/ModelForm.stories.tsx | 4 +- site/src/testHelpers/entities.ts | 13 + site/src/testHelpers/handlers.ts | 13 + 19 files changed, 1250 insertions(+), 208 deletions(-) create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b68b80c1cd0..de44ae61f0e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6487,6 +6487,76 @@ const docTemplate = `{ } } }, + "/api/v2/organizations/{organization}/chats/models/{model}/acl/available": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Chats" + ], + "summary": "Get available AI model ACL users and groups", + "operationId": "get-available-ai-model-acl-users-groups", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Model ID", + "name": "model", + "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}/groups": { "get": { "produces": [ @@ -20247,16 +20317,16 @@ const docTemplate = `{ "codersdk.ChatModelACL": { "type": "object", "properties": { - "group_roles": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/codersdk.ChatRole" + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatGroup" } }, - "user_roles": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/codersdk.ChatRole" + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatUser" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7b2b4ff27e5..348002dbab0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5736,6 +5736,72 @@ } } }, + "/api/v2/organizations/{organization}/chats/models/{model}/acl/available": { + "get": { + "produces": ["application/json"], + "tags": ["Chats"], + "summary": "Get available AI model ACL users and groups", + "operationId": "get-available-ai-model-acl-users-groups", + "parameters": [ + { + "type": "string", + "description": "Organization name or ID", + "name": "organization", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "uuid", + "description": "Model ID", + "name": "model", + "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}/groups": { "get": { "produces": ["application/json"], @@ -18265,16 +18331,16 @@ "codersdk.ChatModelACL": { "type": "object", "properties": { - "group_roles": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/codersdk.ChatRole" + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatGroup" } }, - "user_roles": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/codersdk.ChatRole" + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.ChatUser" } } } diff --git a/coderd/chat_routes.go b/coderd/chat_routes.go index 70dc896262d..72e068c5f7f 100644 --- a/coderd/chat_routes.go +++ b/coderd/chat_routes.go @@ -265,6 +265,7 @@ func (api *API) registerOrganizationChatRoutes(r chi.Router, prefix chatAPIPrefi r.Route("/acl", func(r chi.Router) { r.Get("/", api.chatModelConfigACLHandler) r.Patch("/", api.updateChatModelConfigACL) + r.Get("/available", api.chatModelConfigACLAvailable) }) }) }) diff --git a/coderd/exp_chats_model_acl.go b/coderd/exp_chats_model_acl.go index 6a63757d6de..1d52e9326e6 100644 --- a/coderd/exp_chats_model_acl.go +++ b/coderd/exp_chats_model_acl.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + slog "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -21,6 +22,7 @@ import ( "github.com/coder/coder/v2/coderd/pubsub" "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" ) @@ -42,7 +44,120 @@ func (api *API) chatModelConfigACLHandler(rw http.ResponseWriter, r *http.Reques return } - httpapi.Write(ctx, rw, http.StatusOK, chatModelConfigACL(config)) + users, ok := api.chatModelACLUsers(ctx, rw, config, config.UserACL) + if !ok { + return + } + groups, ok := api.chatModelACLGroups(ctx, rw, config, config.GroupACL) + if !ok { + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatModelACL{ + Users: users, + Groups: groups, + }) +} + +// @Summary Get available AI model ACL users and groups +// @ID get-available-ai-model-acl-users-groups +// @Security CoderSessionToken +// @Tags Chats +// @Produce json +// @Param organization path string true "Organization name or ID" +// @Param model path string true "Model 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}/chats/models/{model}/acl/available [get] +// @x-apidocgen {"skip": true} +func (api *API) chatModelConfigACLAvailable(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + config := httpmw.ChatModelConfigParam(r) + if !api.Authorize(r, policy.ActionShare, chatModelConfigRBACObject(config)) { + 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 model share permission authorizes this bounded + // organization-scoped lookup even when the caller cannot browse the + // ordinary directories. + restrictedCtx := dbauthz.AsSystemRestricted(ctx) + memberRows, 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 chat model 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 chat model ACL groups: %w", err)) + return + } + + groupIDs := make([]uuid.UUID, len(groups)) + for i, group := range groups { + groupIDs[i] = group.Group.ID + } + countByGroup, ok := api.chatModelACLGroupMemberCounts(restrictedCtx, rw, groupIDs) + if !ok { + return + } + + sdkUsers := make([]codersdk.ReducedUser, 0, len(memberRows)) + for _, member := range memberRows { + sdkUsers = append(sdkUsers, reducedUserFromPaginatedOrganizationMember(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, + }) } type chatModelACLValidationError struct { @@ -166,19 +281,124 @@ func (api *API) updateChatModelConfigACL(rw http.ResponseWriter, r *http.Request rw.WriteHeader(http.StatusNoContent) } -func chatModelConfigACL(config database.ChatModelConfig) codersdk.ChatModelACL { - return codersdk.ChatModelACL{ - UserRoles: chatModelACLRoles(config.UserACL), - GroupRoles: chatModelACLRoles(config.GroupACL), +func (api *API) chatModelACLUsers(ctx context.Context, rw http.ResponseWriter, config database.ChatModelConfig, entries database.ChatACL) ([]codersdk.ChatUser, bool) { + userIDs := make([]uuid.UUID, 0, len(entries)) + entriesByID := make(map[uuid.UUID]database.ChatACLEntry, len(entries)) + for rawUserID, entry := range entries { + userID, err := uuid.Parse(rawUserID) + if err != nil { + api.Logger.Warn(ctx, "found invalid user uuid in chat model acl", slog.Error(err), slog.F("model_id", config.ID), slog.F("user_id", rawUserID)) + continue + } + userIDs = append(userIDs, userID) + entriesByID[userID] = entry + } + if len(userIDs) == 0 { + return []codersdk.ChatUser{}, true + } + + //nolint:gocritic // The model share permission authorizes identity hydration + // for ACL entries even when the caller cannot browse the ordinary user + // directory. + dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, xerrors.Errorf("hydrate chat model ACL users: %w", err)) + return nil, false + } + + users := make([]codersdk.ChatUser, 0, len(dbUsers)) + for _, user := range dbUsers { + users = append(users, codersdk.ChatUser{ + MinimalUser: db2sdk.MinimalUser(user), + Role: convertToChatRole(entriesByID[user.ID].Permissions), + }) + } + return users, true +} + +func (api *API) chatModelACLGroups(ctx context.Context, rw http.ResponseWriter, config database.ChatModelConfig, entries database.ChatACL) ([]codersdk.ChatGroup, bool) { + groupIDs := make([]uuid.UUID, 0, len(entries)) + entriesByID := make(map[uuid.UUID]database.ChatACLEntry, len(entries)) + for rawGroupID, entry := range entries { + groupID, err := uuid.Parse(rawGroupID) + if err != nil { + api.Logger.Warn(ctx, "found invalid group uuid in chat model acl", slog.Error(err), slog.F("model_id", config.ID), slog.F("group_id", rawGroupID)) + continue + } + groupIDs = append(groupIDs, groupID) + entriesByID[groupID] = entry + } + if len(groupIDs) == 0 { + return []codersdk.ChatGroup{}, true + } + + //nolint:gocritic // The model share permission authorizes identity hydration + // for ACL entries even when the caller cannot browse the ordinary group + // directory. + restrictedCtx := dbauthz.AsSystemRestricted(ctx) + dbGroups, err := api.Database.GetGroups(restrictedCtx, database.GetGroupsParams{ + OrganizationID: config.OrganizationID, + GroupIds: groupIDs, + }) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, xerrors.Errorf("hydrate chat model ACL groups: %w", err)) + return nil, false + } + hydratedGroupIDs := make([]uuid.UUID, len(dbGroups)) + for i, group := range dbGroups { + hydratedGroupIDs[i] = group.Group.ID + } + countByGroup, ok := api.chatModelACLGroupMemberCounts(restrictedCtx, rw, hydratedGroupIDs) + if !ok { + return nil, false + } + + groups := make([]codersdk.ChatGroup, 0, len(dbGroups)) + for _, group := range dbGroups { + groups = append(groups, codersdk.ChatGroup{ + Group: db2sdk.Group(group, nil, int(countByGroup[group.Group.ID])), + Role: convertToChatRole(entriesByID[group.Group.ID].Permissions), + }) + } + return groups, true +} + +func (api *API) chatModelACLGroupMemberCounts(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 chat model ACL group members: %w", err)) + return nil, false + } + for _, row := range countRows { + countByGroup[row.GroupID] = row.MemberCount } + return countByGroup, true } -func chatModelACLRoles(entries database.ChatACL) map[string]codersdk.ChatRole { - roles := make(map[string]codersdk.ChatRole, len(entries)) - for id, entry := range entries { - roles[id] = convertToChatRole(entry.Permissions) +func reducedUserFromPaginatedOrganizationMember(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, } - return roles } func applyChatModelACLRoles(entries database.ChatACL, roles map[string]codersdk.ChatRole) { diff --git a/coderd/exp_chats_model_acl_test.go b/coderd/exp_chats_model_acl_test.go index 93bf3d20ec5..c53e9fb83f4 100644 --- a/coderd/exp_chats_model_acl_test.go +++ b/coderd/exp_chats_model_acl_test.go @@ -16,6 +16,10 @@ import ( "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" ) @@ -25,8 +29,13 @@ func TestChatModelACL(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) mAudit := audit.NewMock() + // Keep a raw store handle so the test can seed a stale + // cross-organization ACL entry that no authorized caller could create. + rawDB, pubsub := dbtestutil.NewDB(t) adminClient, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) { opts.Auditor = mAudit + opts.Database = rawDB + opts.Pubsub = pubsub }) firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) model := createChatModel(t, adminClient) @@ -39,10 +48,15 @@ func TestChatModelACL(t *testing.T) { initialACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) require.NoError(t, err) - require.Equal(t, map[string]codersdk.ChatRole{}, initialACL.UserRoles) - require.Equal(t, map[string]codersdk.ChatRole{ - firstUser.OrganizationID.String(): codersdk.ChatRoleRead, - }, initialACL.GroupRoles) + require.Empty(t, initialACL.Users) + require.Len(t, initialACL.Groups, 1) + everyone := initialACL.Groups[0] + require.True(t, everyone.IsEveryone()) + require.Equal(t, firstUser.OrganizationID, everyone.ID) + require.Equal(t, firstUser.OrganizationID, everyone.OrganizationID) + require.Equal(t, codersdk.ChatRoleRead, everyone.Role) + require.Equal(t, 3, everyone.TotalMemberCount) + require.Empty(t, everyone.Members) mAudit.ResetLogs() err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ @@ -67,12 +81,20 @@ func TestChatModelACL(t *testing.T) { updatedACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) require.NoError(t, err) - require.Equal(t, map[string]codersdk.ChatRole{ - member.ID.String(): codersdk.ChatRoleRead, - }, updatedACL.UserRoles) - require.Equal(t, map[string]codersdk.ChatRole{ - group.ID.String(): codersdk.ChatRoleRead, - }, updatedACL.GroupRoles) + require.Len(t, updatedACL.Users, 1) + require.Equal(t, member.ID, updatedACL.Users[0].ID) + require.Equal(t, member.Username, updatedACL.Users[0].Username) + require.Equal(t, member.Name, updatedACL.Users[0].Name) + require.Equal(t, member.AvatarURL, updatedACL.Users[0].AvatarURL) + require.Equal(t, codersdk.ChatRoleRead, updatedACL.Users[0].Role) + require.Len(t, updatedACL.Groups, 1) + require.Equal(t, group.ID, updatedACL.Groups[0].ID) + require.Equal(t, group.Name, updatedACL.Groups[0].Name) + require.Equal(t, group.DisplayName, updatedACL.Groups[0].DisplayName) + require.Equal(t, firstUser.OrganizationID, updatedACL.Groups[0].OrganizationID) + require.Equal(t, 1, updatedACL.Groups[0].TotalMemberCount) + require.Empty(t, updatedACL.Groups[0].Members) + require.Equal(t, codersdk.ChatRoleRead, updatedACL.Groups[0].Role) _, err = memberClient.ChatModel(ctx, firstUser.OrganizationID, model.ID) require.NoError(t, err) @@ -96,8 +118,25 @@ func TestChatModelACL(t *testing.T) { emptyACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) require.NoError(t, err) - require.Empty(t, emptyACL.UserRoles) - require.Empty(t, emptyACL.GroupRoles) + require.Empty(t, emptyACL.Users) + require.Empty(t, emptyACL.Groups) + + foreignOrganization := dbgen.Organization(t, db, database.Organization{}) + foreignGroup := dbgen.Group(t, db, database.Group{OrganizationID: foreignOrganization.ID}) + // Seed through the raw store: a stale cross-organization ACL entry + // cannot be created through the authorized API surface. + _, err = rawDB.UpdateChatModelConfigACLByID(ctx, database.UpdateChatModelConfigACLByIDParams{ + GroupACL: database.ChatACL{ + foreignGroup.ID.String(): {Permissions: []policy.Action{policy.ActionRead}}, + }, + UserACL: database.ChatACL{}, + ID: model.ID, + }) + require.NoError(t, err) + + scopedACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Empty(t, scopedACL.Groups) _, err = memberClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) requireSDKError(t, err, http.StatusNotFound) @@ -111,6 +150,267 @@ func TestChatModelACL(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) } +func TestChatModelACLAvailable(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + adminClient, db := newChatClientWithDatabase(t) + firstUser := coderdtest.CreateFirstUser(t, adminClient.Client) + model := createChatModel(t, adminClient) + + 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 := adminClient.ChatModelACLAvailable(ctx, firstUser.OrganizationID, model.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 := adminClient.ChatModelACLAvailable(ctx, firstUser.OrganizationID, model.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) + + _, err = adminClient.ChatModelACLAvailable(ctx, otherOrganization.ID, model.ID, codersdk.UsersRequest{}) + requireSDKError(t, err, http.StatusNotFound) +} + +// TestChatModelACLWorkspaceSharingModes covers the restrictive workspace +// sharing modes where org members lose directory read permissions. The +// default "everyone" mode is exercised by the other tests in this file. +func TestChatModelACLWorkspaceSharingModes(t *testing.T) { + t.Parallel() + + tests := []struct { + mode database.ShareableWorkspaceOwners + memberDirectoryVisible bool + }{ + { + mode: database.ShareableWorkspaceOwnersServiceAccounts, + memberDirectoryVisible: true, + }, + { + mode: database.ShareableWorkspaceOwnersNone, + memberDirectoryVisible: false, + }, + } + for _, test := range tests { + t.Run(string(test.mode), func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, pubsub := dbtestutil.NewDB(t) + opts := newChatTestOptions(t, coderdtest.DeploymentValues(t)) + opts.Database = rawDB + opts.Pubsub = pubsub + client, _, api := coderdtest.NewWithAPI(t, opts) + firstUser := coderdtest.CreateFirstUser(t, client) + adminClient := codersdk.NewExperimentalClient(client) + systemCtx := dbauthz.AsSystemRestricted(ctx) + + organization, err := rawDB.UpdateOrganizationWorkspaceSharingSettings(ctx, database.UpdateOrganizationWorkspaceSharingSettingsParams{ + ID: firstUser.OrganizationID, + ShareableWorkspaceOwners: test.mode, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + _, _, err = rolestore.ReconcileSystemRole(systemCtx, api.Database, database.CustomRole{ + Name: rbac.RoleOrgMember(), + OrganizationID: uuid.NullUUID{ + UUID: firstUser.OrganizationID, + Valid: true, + }, + }, organization) + require.NoError(t, err) + + model := createChatModel(t, adminClient) + 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 = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + GroupRoles: map[string]codersdk.ChatRole{ + preexistingGroup.ID.String(): codersdk.ChatRoleRead, + }, + }) + 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: "Chat Model Sharer", + OrganizationID: uuid.NullUUID{UUID: firstUser.OrganizationID, Valid: true}, + OrgPermissions: database.CustomRolePermissions{ + { + ResourceType: rbac.ResourceChatModelConfig.Type, + Action: policy.ActionRead, + }, + { + ResourceType: rbac.ResourceChatModelConfig.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) + sharerExperimentalClient := codersdk.NewExperimentalClient(sharerClient) + + members, memberDirectoryErr := sharerClient.OrganizationMembersPaginated(ctx, firstUser.OrganizationID, codersdk.UsersRequest{}) + if test.memberDirectoryVisible { + require.NoError(t, memberDirectoryErr) + require.True(t, paginatedMembersContain(members, candidateUser.ID)) + } else { + require.Error(t, memberDirectoryErr) + } + + // Neither restrictive mode grants org members group directory reads. + _, groupDirectoryErr := sharerClient.OrganizationGroupsPaginated(ctx, firstUser.OrganizationID, codersdk.PaginatedGroupsRequest{}) + require.Error(t, groupDirectoryErr) + + initialACL, err := sharerExperimentalClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Empty(t, initialACL.Users) + require.Equal(t, map[uuid.UUID]codersdk.ChatRole{ + firstUser.OrganizationID: codersdk.ChatRoleRead, + preexistingGroup.ID: codersdk.ChatRoleRead, + }, chatModelACLGroupRoles(initialACL)) + preexistingACLGroup := chatModelACLGroupByID(t, initialACL, preexistingGroup.ID) + require.Equal(t, preexistingGroup.Name, preexistingACLGroup.Name) + require.Empty(t, preexistingACLGroup.Members) + + available, err := sharerExperimentalClient.ChatModelACLAvailable(ctx, firstUser.OrganizationID, model.ID, codersdk.UsersRequest{}) + require.NoError(t, err) + availableUser := aclAvailableUserByID(t, available, candidateUser.ID) + require.Equal(t, candidateUser.Username, availableUser.Username) + require.Equal(t, candidateUser.Name, availableUser.Name) + availableGroup := aclAvailableGroupByID(t, available, candidateGroup.ID) + require.Equal(t, candidateGroup.Name, availableGroup.Name) + require.Equal(t, candidateGroup.DisplayName, availableGroup.DisplayName) + require.Equal(t, 1, availableGroup.TotalMemberCount) + require.Empty(t, availableGroup.Members) + + err = sharerExperimentalClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{ + candidateUser.ID.String(): codersdk.ChatRoleRead, + }, + GroupRoles: map[string]codersdk.ChatRole{ + candidateGroup.ID.String(): codersdk.ChatRoleRead, + preexistingGroup.ID.String(): codersdk.ChatRoleDeleted, + }, + }) + require.NoError(t, err) + + updatedACL, err := sharerExperimentalClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Equal(t, map[uuid.UUID]codersdk.ChatRole{ + candidateUser.ID: codersdk.ChatRoleRead, + }, chatModelACLUserRoles(updatedACL)) + require.Equal(t, map[uuid.UUID]codersdk.ChatRole{ + firstUser.OrganizationID: codersdk.ChatRoleRead, + candidateGroup.ID: codersdk.ChatRoleRead, + }, chatModelACLGroupRoles(updatedACL)) + require.Equal(t, candidateUser.Username, updatedACL.Users[0].Username) + updatedGroup := chatModelACLGroupByID(t, updatedACL, candidateGroup.ID) + require.Equal(t, 1, updatedGroup.TotalMemberCount) + require.Empty(t, updatedGroup.Members) + + err = sharerExperimentalClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ + UserRoles: map[string]codersdk.ChatRole{ + candidateUser.ID.String(): codersdk.ChatRoleDeleted, + }, + GroupRoles: map[string]codersdk.ChatRole{ + candidateGroup.ID.String(): codersdk.ChatRoleDeleted, + }, + }) + require.NoError(t, err) + + finalACL, err := sharerExperimentalClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) + require.NoError(t, err) + require.Empty(t, finalACL.Users) + require.Equal(t, map[uuid.UUID]codersdk.ChatRole{ + firstUser.OrganizationID: codersdk.ChatRoleRead, + }, chatModelACLGroupRoles(finalACL)) + }) + } +} + //nolint:tparallel,paralleltest // Subtests share one model ACL and run sequentially. func TestChatModelACLSparseUpdate(t *testing.T) { t.Parallel() @@ -134,19 +434,18 @@ func TestChatModelACLSparseUpdate(t *testing.T) { }) require.NoError(t, err) - expected := codersdk.ChatModelACL{ - UserRoles: map[string]codersdk.ChatRole{ - firstMember.ID.String(): codersdk.ChatRoleRead, - secondMember.ID.String(): codersdk.ChatRoleRead, - }, - GroupRoles: map[string]codersdk.ChatRole{ - firstUser.OrganizationID.String(): codersdk.ChatRoleRead, - group.ID.String(): codersdk.ChatRoleRead, - }, + expectedUserRoles := map[uuid.UUID]codersdk.ChatRole{ + firstMember.ID: codersdk.ChatRoleRead, + secondMember.ID: codersdk.ChatRoleRead, + } + expectedGroupRoles := map[uuid.UUID]codersdk.ChatRole{ + firstUser.OrganizationID: codersdk.ChatRoleRead, + group.ID: codersdk.ChatRoleRead, } modelACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) require.NoError(t, err) - require.Equal(t, expected, modelACL) + require.Equal(t, expectedUserRoles, chatModelACLUserRoles(modelACL)) + require.Equal(t, expectedGroupRoles, chatModelACLGroupRoles(modelACL)) path := fmt.Sprintf( "/api/experimental/organizations/%s/chats/models/%s/acl", @@ -169,7 +468,8 @@ func TestChatModelACLSparseUpdate(t *testing.T) { modelACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) require.NoError(t, err) - require.Equal(t, expected, modelACL) + require.Equal(t, expectedUserRoles, chatModelACLUserRoles(modelACL)) + require.Equal(t, expectedGroupRoles, chatModelACLGroupRoles(modelACL)) }) } @@ -186,12 +486,12 @@ func TestChatModelACLSparseUpdate(t *testing.T) { modelACL, err = adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) require.NoError(t, err) - require.Equal(t, map[string]codersdk.ChatRole{ - secondMember.ID.String(): codersdk.ChatRoleRead, - }, modelACL.UserRoles) - require.Equal(t, map[string]codersdk.ChatRole{ - firstUser.OrganizationID.String(): codersdk.ChatRoleRead, - }, modelACL.GroupRoles) + require.Equal(t, map[uuid.UUID]codersdk.ChatRole{ + secondMember.ID: codersdk.ChatRoleRead, + }, chatModelACLUserRoles(modelACL)) + require.Equal(t, map[uuid.UUID]codersdk.ChatRole{ + firstUser.OrganizationID: codersdk.ChatRoleRead, + }, chatModelACLGroupRoles(modelACL)) err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{ UserRoles: map[string]codersdk.ChatRole{firstMember.ID.String(): codersdk.ChatRoleRead}, @@ -399,10 +699,70 @@ func TestChatModelACLShareDenied(t *testing.T) { require.NoError(t, err) _, err = memberClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID) requireSDKError(t, err, http.StatusNotFound) + _, err = memberClient.ChatModelACLAvailable(ctx, firstUser.OrganizationID, model.ID, codersdk.UsersRequest{}) + requireSDKError(t, err, http.StatusNotFound) err = memberClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{}) requireSDKError(t, err, http.StatusNotFound) } +func paginatedMembersContain(members codersdk.PaginatedMembersResponse, userID uuid.UUID) bool { + for _, member := range members.Members { + if member.UserID == userID { + return true + } + } + return false +} + +func chatModelACLGroupByID(t *testing.T, modelACL codersdk.ChatModelACL, groupID uuid.UUID) codersdk.ChatGroup { + t.Helper() + for _, group := range modelACL.Groups { + if group.ID == groupID { + return group + } + } + require.FailNow(t, "chat model ACL group not found", groupID.String()) + return codersdk.ChatGroup{} +} + +func aclAvailableUserByID(t *testing.T, available codersdk.ACLAvailable, userID uuid.UUID) codersdk.ReducedUser { + t.Helper() + for _, user := range available.Users { + if user.ID == userID { + return user + } + } + require.FailNow(t, "available ACL user not found", userID.String()) + return codersdk.ReducedUser{} +} + +func aclAvailableGroupByID(t *testing.T, available codersdk.ACLAvailable, groupID uuid.UUID) codersdk.Group { + t.Helper() + for _, group := range available.Groups { + if group.ID == groupID { + return group + } + } + require.FailNow(t, "available ACL group not found", groupID.String()) + return codersdk.Group{} +} + +func chatModelACLUserRoles(modelACL codersdk.ChatModelACL) map[uuid.UUID]codersdk.ChatRole { + roles := make(map[uuid.UUID]codersdk.ChatRole, len(modelACL.Users)) + for _, user := range modelACL.Users { + roles[user.ID] = user.Role + } + return roles +} + +func chatModelACLGroupRoles(modelACL codersdk.ChatModelACL) map[uuid.UUID]codersdk.ChatRole { + roles := make(map[uuid.UUID]codersdk.ChatRole, len(modelACL.Groups)) + for _, group := range modelACL.Groups { + roles[group.ID] = group.Role + } + return roles +} + func TestCreateChatModelRejectsACLKeys(t *testing.T) { t.Parallel() diff --git a/codersdk/chats.go b/codersdk/chats.go index 81e94662cd3..fc960dbadc5 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1316,10 +1316,11 @@ type ChatModel struct { } // ChatModelACL is the access control list for an organization-scoped chat -// model. Each principal is mapped to its effective model role. +// model. Each principal includes the identity details needed to display and +// manage the ACL without separate directory lookups. type ChatModelACL struct { - UserRoles map[string]ChatRole `json:"user_roles"` - GroupRoles map[string]ChatRole `json:"group_roles"` + Users []ChatUser `json:"users"` + Groups []ChatGroup `json:"groups"` } // UpdateChatModelACLRequest is a sparse update of a chat model ACL. Only the @@ -2245,6 +2246,31 @@ func (c *Client) ChatModelACL(ctx context.Context, organizationID, modelID uuid. return modelACL, ReadBodyAsJSON(res, &modelACL) } +// ChatModelACLAvailable returns available users and groups that can be assigned +// chat model permissions. The optional request applies q/limit/offset/after_id +// to users. Groups reuse the user search query and q/limit semantics. Pass +// codersdk.UsersRequest{} when no filtering is desired. +func (c *Client) ChatModelACLAvailable(ctx context.Context, organizationID, modelID uuid.UUID, req UsersRequest) (ACLAvailable, error) { + res, err := c.Request( + ctx, + http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/chats/models/%s/acl/available", organizationID, modelID), + 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 acl ACLAvailable + return acl, ReadBodyAsJSON(res, &acl) +} + // UpdateChatModelACL applies a sparse access control list update to a chat // model in an organization. func (c *Client) UpdateChatModelACL(ctx context.Context, organizationID, modelID uuid.UUID, req UpdateChatModelACLRequest) error { diff --git a/codersdk/chats_model_acl_test.go b/codersdk/chats_model_acl_test.go index be6aaa4db34..175b7919501 100644 --- a/codersdk/chats_model_acl_test.go +++ b/codersdk/chats_model_acl_test.go @@ -26,7 +26,7 @@ func TestClientChatModelACL(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodGet, r.Method) require.Equal(t, "/api/v2/organizations/"+organizationID.String()+"/chats/models/"+modelID.String()+"/acl", r.URL.Path) - http.Error(rw, `{"user_roles":{"`+userID.String()+`":"read"},"group_roles":{"`+groupID.String()+`":"read"}}`, http.StatusOK) + http.Error(rw, `{"users":[{"id":"`+userID.String()+`","username":"alice","name":"Alice","role":"read"}],"groups":[{"id":"`+groupID.String()+`","organization_id":"`+organizationID.String()+`","name":"developers","display_name":"Developers","members":[],"total_member_count":2,"role":"read"}]}`, http.StatusOK) })) defer server.Close() @@ -36,8 +36,17 @@ func TestClientChatModelACL(t *testing.T) { modelACL, err := client.ChatModelACL(context.Background(), organizationID, modelID) require.NoError(t, err) - require.Equal(t, map[string]codersdk.ChatRole{userID.String(): codersdk.ChatRoleRead}, modelACL.UserRoles) - require.Equal(t, map[string]codersdk.ChatRole{groupID.String(): codersdk.ChatRoleRead}, modelACL.GroupRoles) + require.Equal(t, []codersdk.ChatUser{{ + MinimalUser: codersdk.MinimalUser{ID: userID, Username: "alice", Name: "Alice"}, + Role: codersdk.ChatRoleRead, + }}, modelACL.Users) + require.Equal(t, groupID, modelACL.Groups[0].ID) + require.Equal(t, organizationID, modelACL.Groups[0].OrganizationID) + require.Equal(t, "developers", modelACL.Groups[0].Name) + require.Equal(t, "Developers", modelACL.Groups[0].DisplayName) + require.Equal(t, 2, modelACL.Groups[0].TotalMemberCount) + require.Empty(t, modelACL.Groups[0].Members) + require.Equal(t, codersdk.ChatRoleRead, modelACL.Groups[0].Role) } func TestClientUpdateChatModelACL(t *testing.T) { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 23985941a7b..f6244ff3e8f 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -3696,25 +3696,55 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { - "group_roles": { - "property1": "read", - "property2": "read" - }, - "user_roles": { - "property1": "read", - "property2": "read" - } + "groups": [ + { + "avatar_url": "http://example.com", + "display_name": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "members": [ + { + "avatar_url": "http://example.com", + "created_at": "2019-08-24T14:15:22Z", + "email": "user@example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "is_service_account": true, + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", + "name": "string", + "status": "active", + "theme_preference": "string", + "updated_at": "2019-08-24T14:15:22Z", + "username": "string" + } + ], + "name": "string", + "organization_display_name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "organization_name": "string", + "quota_allowance": 0, + "role": "read", + "source": "user", + "total_member_count": 0 + } + ], + "users": [ + { + "avatar_url": "http://example.com", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "role": "read", + "username": "string" + } + ] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|----------------------------------------|----------|--------------|-------------| -| `group_roles` | object | false | | | -| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | | -| `user_roles` | object | false | | | -| » `[any property]` | [codersdk.ChatRole](#codersdkchatrole) | false | | | +| Name | Type | Required | Restrictions | Description | +|----------|---------------------------------------------------|----------|--------------|-------------| +| `groups` | array of [codersdk.ChatGroup](#codersdkchatgroup) | false | | | +| `users` | array of [codersdk.ChatUser](#codersdkchatuser) | false | | | ## codersdk.ChatModelAnthropicProviderOptions diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index e366101ef67..cb609566a36 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -1,4 +1,5 @@ import { + MockChatModelACL, MockMCPServerConfigACL, MockMCPServerConfigACLAvailable, MockProvisionerJob, @@ -513,21 +514,26 @@ describe("api.ts", () => { it("uses organization-nested chat model ACL paths", async () => { const modelId = "model/id"; - const acl = { user_roles: {}, group_roles: {} }; - vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({ data: acl }); + const update: TypesGen.UpdateChatModelACLRequest = { + user_roles: { "user-1": "read" }, + group_roles: { "group-1": "" }, + }; + vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({ + data: MockChatModelACL, + }); vi.spyOn(axiosInstance, "patch").mockResolvedValueOnce({}); await expect( API.experimental.getChatModelACL(organizationId, modelId), - ).resolves.toStrictEqual(acl); + ).resolves.toStrictEqual(MockChatModelACL); await expect( - API.experimental.updateChatModelACL(organizationId, modelId, acl), + API.experimental.updateChatModelACL(organizationId, modelId, update), ).resolves.toBeUndefined(); const aclPath = "/api/v2/organizations/organization%2Fid/chats/models/model%2Fid/acl"; expect(axiosInstance.get).toHaveBeenCalledWith(aclPath); - expect(axiosInstance.patch).toHaveBeenCalledWith(aclPath, acl); + expect(axiosInstance.patch).toHaveBeenCalledWith(aclPath, update); }); it("uses organization-nested MCP server ACL paths", async () => { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c7a99ab2979..2c98a77dcdb 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3932,6 +3932,19 @@ class ExperimentalApiMethods { return response.data; }; + getChatModelACLAvailable = async ( + organizationId: string, + modelId: string, + options: TypesGen.UsersRequest, + ): Promise => { + const url = getURLWithSearchParams( + `${chatModelACLPath(organizationId, modelId)}/available`, + options, + ); + const response = await this.axios.get(url); + return response.data; + }; + updateChatModelACL = async ( organizationId: string, modelId: string, diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 2ae4ecaa9d2..1c428af7347 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -19,6 +19,8 @@ import { import { MockChatModel } from "#/testHelpers/chatModels"; import { createDeferred } from "#/testHelpers/deferred"; import { + MockChatModelACL, + MockChatModelACLAvailable, MockMCPServerConfigACL, MockMCPServerConfigACLAvailable, } from "#/testHelpers/entities"; @@ -51,6 +53,8 @@ import { chatMessagesKey, chatModel, chatModelACL, + chatModelACLAvailable, + chatModelACLAvailableKey, chatModelACLKey, chatModelKey, chatPromptsKey, @@ -136,6 +140,7 @@ vi.mock("#/api/api", () => ({ updateChatACL: vi.fn(), getChatModel: vi.fn(), getChatModelACL: vi.fn(), + getChatModelACLAvailable: vi.fn(), updateChatModelACL: vi.fn(), getMCPServerConfigACL: vi.fn(), getMCPServerConfigACLAvailable: vi.fn(), @@ -262,15 +267,45 @@ describe("chat model query factories", () => { ); }); + it("scopes model ACL candidates by organization, model, and options", async () => { + const options = { q: "alice@example.com", limit: 25 }; + const otherOptions = { q: "bob@example.com", limit: 25 }; + vi.mocked(API.experimental.getChatModelACLAvailable).mockResolvedValue( + MockChatModelACLAvailable, + ); + + const query = chatModelACLAvailable(organizationId, modelId, options); + + expect(query.queryKey).toEqual( + chatModelACLAvailableKey(organizationId, modelId, options), + ); + expect(query.queryKey).not.toEqual( + chatModelACLAvailableKey(otherOrganizationId, modelId, options), + ); + expect(query.queryKey).not.toEqual( + chatModelACLAvailableKey(organizationId, "other-model", options), + ); + expect(query.queryKey).not.toEqual( + chatModelACLAvailableKey(organizationId, modelId, otherOptions), + ); + await expect(query.queryFn()).resolves.toEqual(MockChatModelACLAvailable); + expect(API.experimental.getChatModelACLAvailable).toHaveBeenCalledWith( + organizationId, + modelId, + options, + ); + }); + it("gets and sparsely updates an organization-scoped model ACL", async () => { - const acl = { user_roles: {}, group_roles: {} }; const req = { user_roles: { "user-1": "read" as const } }; - vi.mocked(API.experimental.getChatModelACL).mockResolvedValue(acl); + vi.mocked(API.experimental.getChatModelACL).mockResolvedValue( + MockChatModelACL, + ); vi.mocked(API.experimental.updateChatModelACL).mockResolvedValue(); const query = chatModelACL(organizationId, modelId); expect(query.queryKey).toEqual(chatModelACLKey(organizationId, modelId)); - await expect(query.queryFn()).resolves.toEqual(acl); + await expect(query.queryFn()).resolves.toEqual(MockChatModelACL); expect(API.experimental.getChatModelACL).toHaveBeenCalledWith( organizationId, modelId, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index e6ab1f04cd3..dd08258e3f4 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -2328,6 +2328,24 @@ export const chatModelACL = (organizationId: string, modelId: string) => ({ enabled: organizationId !== "" && modelId !== "", }); +export const chatModelACLAvailableKey = ( + organizationId: string, + modelId: string, + options: TypesGen.UsersRequest, +) => + [...chatModelACLKey(organizationId, modelId), "available", options] as const; + +export const chatModelACLAvailable = ( + organizationId: string, + modelId: string, + options: TypesGen.UsersRequest, +) => ({ + queryKey: chatModelACLAvailableKey(organizationId, modelId, options), + queryFn: (): Promise => + API.experimental.getChatModelACLAvailable(organizationId, modelId, options), + enabled: organizationId !== "" && modelId !== "", +}); + type UpdateChatModelACLMutationArgs = { organizationId: string; modelId: string; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4cb01e0189a..4f26df5168e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2855,11 +2855,12 @@ export interface ChatModel { // From codersdk/chats.go /** * ChatModelACL is the access control list for an organization-scoped chat - * model. Each principal is mapped to its effective model role. + * model. Each principal includes the identity details needed to display and + * manage the ACL without separate directory lookups. */ export interface ChatModelACL { - readonly user_roles: Record; - readonly group_roles: Record; + readonly users: readonly ChatUser[]; + readonly groups: readonly ChatGroup[]; } // From codersdk/chats.go diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx new file mode 100644 index 00000000000..8d044f8c854 --- /dev/null +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx @@ -0,0 +1,109 @@ +import { CheckIcon } from "lucide-react"; +import { type FC, useId, useState } from "react"; +import { keepPreviousData, useQuery } from "react-query"; +import { chatModelACLAvailable } 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 ChatModelPrincipalAutocompleteValue = ReducedUser | Group | null; +type AutocompleteOption = Exclude; + +type ChatModelPrincipalAutocompleteProps = { + value: ChatModelPrincipalAutocompleteValue; + onChange: (value: ChatModelPrincipalAutocompleteValue) => void; + organizationId: string; + modelId: string; + excludedPrincipalIds: readonly string[]; + className?: string; +}; + +export const ChatModelPrincipalAutocomplete: FC< + ChatModelPrincipalAutocompleteProps +> = ({ + value, + onChange, + organizationId, + modelId, + 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({ + ...chatModelACLAvailable(organizationId, modelId, { + q: prepareQuery(encodeURI(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/ModelsPage/components/ChatModelSharingDialog.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.stories.tsx index 568052bcf0d..ca88c89622d 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.stories.tsx @@ -2,51 +2,59 @@ 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 { + MockChatModelACLAvailable, MockDefaultOrganization, + MockEveryoneGroup, MockGroup, MockGroup2, - MockOrganizationMember, - MockOrganizationMember2, + MockUserMember, + MockUserOwner, } from "#/testHelpers/entities"; import { withDashboardProvider, withToaster } from "#/testHelpers/storybook"; import { mockGPT5 } from "../testFixtures"; import { ChatModelSharingDialog } from "./ChatModelSharingDialog"; -type MockACL = { - user_roles: Record; - group_roles: Record; -}; +type MockACL = TypesGen.ChatModelACL; -const emptyACL: MockACL = { user_roles: {}, group_roles: {} }; +const emptyACL: MockACL = { users: [], groups: [] }; const populatedACL: MockACL = { - user_roles: { [MockOrganizationMember2.user_id]: "read" as const }, - group_roles: { [MockGroup.id]: "read" as const }, + 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 mockPrincipalRequests = () => { - spyOn(API, "getOrganizationPaginatedMembers").mockResolvedValue({ - members: [MockOrganizationMember, MockOrganizationMember2], - count: 2, - }); - spyOn(API, "getGroupsByOrganization").mockResolvedValue([ - MockGroup, - MockGroup2, - ]); +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; } = {}) => { - mockPrincipalRequests(); + mockLegacyPrincipalRequests(); if (aclPending) { spyOn(API.experimental, "getChatModelACL").mockReturnValue( new Promise(() => undefined), @@ -56,6 +64,15 @@ const mockRequests = ({ } else { spyOn(API.experimental, "getChatModelACL").mockResolvedValue(acl); } + if (availableError) { + spyOn(API.experimental, "getChatModelACLAvailable").mockRejectedValue( + availableError, + ); + } else { + spyOn(API.experimental, "getChatModelACLAvailable").mockResolvedValue( + MockChatModelACLAvailable, + ); + } if (updateError) { spyOn(API.experimental, "updateChatModelACL").mockRejectedValue( updateError, @@ -81,10 +98,6 @@ const addAutocompleteOption = async ( await userEvent.click(body.getByRole("button", { name: "Add member" })); }; -const refreshedACL: MockACL = { - user_roles: { [MockOrganizationMember.user_id]: "read" }, - group_roles: { [MockGroup2.id]: "read" }, -}; let currentServerACL = populatedACL; const ReopenableSharingDialog = () => { @@ -139,6 +152,8 @@ export const EmptyACL: Story = { expect( body.getByRole("button", { name: "Save permissions" }), ).toBeDisabled(); + expect(API.getOrganizationPaginatedMembers).not.toHaveBeenCalled(); + expect(API.getGroupsByOrganization).not.toHaveBeenCalled(); }, }; @@ -152,7 +167,7 @@ export const Loading: Story = { }, }; -export const LoadError: Story = { +export const InitialACLFailureIsBlocking: Story = { beforeEach: () => mockRequests({ aclError: new Error("Unable to load ACL") }), play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); @@ -160,6 +175,45 @@ export const LoadError: Story = { 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: "Model 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"); }, }; @@ -169,12 +223,12 @@ export const AddUser: Story = { const body = within(canvasElement.ownerDocument.body); await addAutocompleteOption( body, - MockOrganizationMember2.email, - new RegExp(MockOrganizationMember2.email, "i"), + MockUserMember.email, + new RegExp(MockUserMember.email, "i"), ); const userRow = body.getByRole("row", { - name: new RegExp(MockOrganizationMember2.username, "i"), + name: new RegExp(MockUserMember.username, "i"), }); expect(userRow).toBeVisible(); expect(userRow).toHaveTextContent("Use"); @@ -188,7 +242,7 @@ export const AddUser: Story = { expect(API.experimental.updateChatModelACL).toHaveBeenCalledWith( MockDefaultOrganization.id, mockGPT5.id, - { user_roles: { [MockOrganizationMember2.user_id]: "read" } }, + { user_roles: { [MockUserMember.id]: "read" } }, ); expect(args.onOpenChange).toHaveBeenCalledWith(false); }, @@ -237,7 +291,7 @@ export const SelectedPrincipalsExcludedFromAutocomplete: Story = { expect( body.queryByRole("option", { - name: new RegExp(MockOrganizationMember2.email, "i"), + name: new RegExp(MockUserMember.email, "i"), }), ).not.toBeInTheDocument(); expect( @@ -245,18 +299,16 @@ export const SelectedPrincipalsExcludedFromAutocomplete: Story = { name: new RegExp(MockGroup.display_name || MockGroup.name, "i"), }), ).not.toBeInTheDocument(); - await waitFor(() => { - expect( - body.getByRole("option", { - name: new RegExp(MockOrganizationMember.email, "i"), - }), - ).toBeVisible(); - expect( - body.getByRole("option", { - name: new RegExp(MockGroup2.display_name || MockGroup2.name, "i"), - }), - ).toBeVisible(); - }); + 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(); }, }; @@ -271,7 +323,7 @@ export const SaveRemovalsAsSparseDelta: Story = { ); await userEvent.click( body.getByRole("button", { - name: `Remove ${MockOrganizationMember2.username}`, + name: `Remove ${MockUserMember.username}`, }), ); await userEvent.click( @@ -283,7 +335,7 @@ export const SaveRemovalsAsSparseDelta: Story = { MockDefaultOrganization.id, mockGPT5.id, { - user_roles: { [MockOrganizationMember2.user_id]: "" }, + user_roles: { [MockUserMember.id]: "" }, group_roles: { [MockGroup.id]: "" }, }, ), @@ -292,21 +344,59 @@ export const SaveRemovalsAsSparseDelta: Story = { }, }; +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.updateChatModelACL).toHaveBeenCalledWith( + MockDefaultOrganization.id, + mockGPT5.id, + { group_roles: { [MockGroup.id]: "" } }, + ), + ); + }, +}; + export const ReopenUsesFreshACL: Story = { render: () => , beforeEach: () => { - mockPrincipalRequests(); + mockLegacyPrincipalRequests(); currentServerACL = populatedACL; spyOn(API.experimental, "getChatModelACL").mockImplementation( async () => currentServerACL, ); + spyOn(API.experimental, "getChatModelACLAvailable").mockResolvedValue( + MockChatModelACLAvailable, + ); spyOn(API.experimental, "updateChatModelACL").mockResolvedValue(); }, play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); expect( await body.findByRole("row", { - name: new RegExp(MockOrganizationMember2.username, "i"), + name: new RegExp(MockUserMember.username, "i"), }), ).toBeInTheDocument(); await userEvent.click(body.getByRole("button", { name: "Cancel" })); @@ -323,7 +413,7 @@ export const ReopenUsesFreshACL: Story = { expect( await body.findByRole("row", { - name: new RegExp(MockOrganizationMember.username, "i"), + name: new RegExp(MockUserOwner.username, "i"), }), ).toBeInTheDocument(); expect( @@ -333,14 +423,14 @@ export const ReopenUsesFreshACL: Story = { ).toBeInTheDocument(); expect( body.queryByRole("row", { - name: new RegExp(MockOrganizationMember2.username, "i"), + name: new RegExp(MockUserMember.username, "i"), }), ).not.toBeInTheDocument(); - expect(API.experimental.getChatModelACL).toHaveBeenCalled(); + expect(API.experimental.getChatModelACL).toHaveBeenCalledTimes(2); }, }; -export const SaveErrorKeepsEditorOpen: Story = { +export const SaveErrorPreservesDraft: Story = { beforeEach: () => mockRequests({ acl: populatedACL, @@ -362,6 +452,14 @@ export const SaveErrorKeepsEditorOpen: Story = { expect( body.getByRole("dialog", { name: "Model 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); }, }; diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx index 4239a0e41fb..5b06f3398c5 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.tsx @@ -2,20 +2,18 @@ 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 { 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"; +import { + ChatModelPrincipalAutocomplete, + type ChatModelPrincipalAutocompleteValue, +} from "./ChatModelPrincipalAutocomplete"; type ChatModelSharingDialogProps = { open: boolean; @@ -25,7 +23,7 @@ type ChatModelSharingDialogProps = { modelName: string; }; -type ChatModelPrincipal = Exclude; +type ChatModelPrincipal = Exclude; const groupPrincipal = (group: TypesGen.Group): SharingPrincipal => ({ id: group.id, @@ -34,46 +32,30 @@ const groupPrincipal = (group: TypesGen.Group): SharingPrincipal => ({ 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 userPrincipal = (user: TypesGen.MinimalUser): SharingPrincipal => ({ + id: user.id, + name: user.username, + subtitle: user.name || "User", + avatarUrl: user.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 }, + 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( - 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" }, - ]; - }), + acl.users.map((user) => [user.id, userPrincipal(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" }, - ]; - }), + acl.groups.map((group) => [group.id, groupPrincipal(group)]), ), }, }); @@ -83,7 +65,7 @@ const selectedPrincipal = ( ): SharingPrincipalSelection => isGroup(option) ? { kind: "group", principal: groupPrincipal(option) } - : { kind: "user", principal: memberPrincipal(option) }; + : { kind: "user", principal: userPrincipal(option) }; type OpenChatModelSharingDialogProps = Omit< ChatModelSharingDialogProps, @@ -98,42 +80,13 @@ const OpenChatModelSharingDialog: FC = ({ }) => { const queryClient = useQueryClient(); const aclOptions = chatModelACL(organizationId, modelId); - const membersOptions = organizationMembers(organizationId, { limit: 0 }); - const groupsOptions = groupsByOrganization(organizationId); const aclQuery = useQuery({ ...aclOptions, refetchOnMount: "always" }); - const membersQuery = useQuery({ - ...membersOptions, - refetchOnMount: "always", - }); - const groupsQuery = useQuery({ ...groupsOptions, refetchOnMount: "always" }); const updateMutation = useMutation(updateChatModelACL(queryClient)); - - 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 data = aclQuery.data ? sharingDialogData(aclQuery.data) : undefined; const close = () => { onOpenChange(false); queryClient.removeQueries({ queryKey: aclOptions.queryKey, exact: true }); - queryClient.removeQueries({ - queryKey: membersOptions.queryKey, - exact: true, - }); - queryClient.removeQueries({ - queryKey: groupsOptions.queryKey, - exact: true, - }); }; return ( @@ -148,18 +101,19 @@ const OpenChatModelSharingDialog: FC = ({ roleLabel="Use" confirmText="Save permissions" data={data} - loadError={loadError} - refetchError={refetchError} + loadError={data ? null : aclQuery.error} + refetchError={data ? aclQuery.error : null} saveError={updateMutation.error} isSaving={updateMutation.isPending} readRole="read" deletedRole="" renderAutocomplete={({ value, onChange, excludedPrincipalIds }) => ( - ({ id }))} + modelId={modelId} + excludedPrincipalIds={excludedPrincipalIds} className="w-full" /> )} diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx index e9c18211708..cc3cdb88a2f 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.stories.tsx @@ -299,8 +299,8 @@ export const ShareOnlyAccess: Story = { }, beforeEach: () => { spyOn(API.experimental, "getChatModelACL").mockResolvedValue({ - user_roles: {}, - group_roles: {}, + users: [], + groups: [], }); spyOn(API.experimental, "updateChatModelACL").mockResolvedValue(); spyOn(API, "getOrganizationPaginatedMembers").mockResolvedValue({ diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index ced8aa93e19..4ae390bca1c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3310,6 +3310,14 @@ export const MockEveryoneGroup: TypesGen.Group = { total_member_count: 0, }; +export const MockChatModelACL: TypesGen.ChatModelACL = { + users: [{ ...MockUserMember, role: "read" }], + groups: [ + { ...MockGroup, role: "read" }, + { ...MockEveryoneGroup, role: "read" }, + ], +}; + export const MockMCPServerConfigACL: TypesGen.MCPServerConfigACL = { users: [{ ...MockUserMember, role: "read" }], groups: [ @@ -3318,6 +3326,11 @@ export const MockMCPServerConfigACL: TypesGen.MCPServerConfigACL = { ], }; +export const MockChatModelACLAvailable: TypesGen.ACLAvailable = { + users: [MockUserOwner, MockUserMember], + groups: [MockGroup, MockGroup2, MockEveryoneGroup], +}; + export const MockMCPServerConfigACLAvailable: TypesGen.ACLAvailable = { users: [MockUserOwner, MockUserMember], groups: [MockGroup, MockGroup2, MockEveryoneGroup], diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 207840e6893..a3bec9f85e4 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -82,6 +82,19 @@ export const handlers = [ }, ), + // chat models + http.get( + "/api/v2/organizations/:organizationId/chats/models/:modelId/acl/available", + () => HttpResponse.json(M.MockChatModelACLAvailable), + ), + http.get( + "/api/v2/organizations/:organizationId/chats/models/:modelId/acl", + () => HttpResponse.json(M.MockChatModelACL), + ), + http.patch( + "/api/v2/organizations/:organizationId/chats/models/:modelId/acl", + () => new HttpResponse(null, { status: 204 }), + ), http.get( "/api/v2/organizations/:organizationId/mcp-servers/:serverId/acl/available", () => HttpResponse.json(M.MockMCPServerConfigACLAvailable), From 583c13e3bbbe5a90364497df8e676ed7e6060873 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 11:54:58 +0000 Subject: [PATCH 2/6] fix(coderd): match acl available swagger operation id to summary --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/exp_chats_model_acl.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index de44ae61f0e..213533889fa 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6496,7 +6496,7 @@ const docTemplate = `{ "Chats" ], "summary": "Get available AI model ACL users and groups", - "operationId": "get-available-ai-model-acl-users-groups", + "operationId": "get-available-ai-model-acl-users-and-groups", "parameters": [ { "type": "string", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 348002dbab0..e61e55c2b33 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5741,7 +5741,7 @@ "produces": ["application/json"], "tags": ["Chats"], "summary": "Get available AI model ACL users and groups", - "operationId": "get-available-ai-model-acl-users-groups", + "operationId": "get-available-ai-model-acl-users-and-groups", "parameters": [ { "type": "string", diff --git a/coderd/exp_chats_model_acl.go b/coderd/exp_chats_model_acl.go index 1d52e9326e6..ec84cfd51e6 100644 --- a/coderd/exp_chats_model_acl.go +++ b/coderd/exp_chats_model_acl.go @@ -60,7 +60,7 @@ func (api *API) chatModelConfigACLHandler(rw http.ResponseWriter, r *http.Reques } // @Summary Get available AI model ACL users and groups -// @ID get-available-ai-model-acl-users-groups +// @ID get-available-ai-model-acl-users-and-groups // @Security CoderSessionToken // @Tags Chats // @Produce json From b14cb57550205d62c456856fcfc402d59e617782 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 12:38:45 +0000 Subject: [PATCH 3/6] fix(site/src/pages/AISettingsPage): do not pre-encode principal search query --- .../ModelsPage/components/ChatModelPrincipalAutocomplete.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx index 8d044f8c854..ff3513abca5 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx @@ -44,7 +44,9 @@ export const ChatModelPrincipalAutocomplete: FC< const aclAvailableQuery = useQuery({ ...chatModelACLAvailable(organizationId, modelId, { - q: prepareQuery(encodeURI(inputValue)), + // URLSearchParams in the API client handles encoding; pre-encoding + // here would double-encode (e.g. "a b" becomes q=a%2520b). + q: prepareQuery(inputValue), limit: 25, }), enabled: open, From b36256a6733408f2dc73a5ecb1b793d32fde45a0 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Aug 2026 12:43:35 +0000 Subject: [PATCH 4/6] chore(site/src/pages/AISettingsPage): drop encoding comment --- .../ModelsPage/components/ChatModelPrincipalAutocomplete.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx index ff3513abca5..0a6f2023234 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelPrincipalAutocomplete.tsx @@ -44,8 +44,6 @@ export const ChatModelPrincipalAutocomplete: FC< const aclAvailableQuery = useQuery({ ...chatModelACLAvailable(organizationId, modelId, { - // URLSearchParams in the API client handles encoding; pre-encoding - // here would double-encode (e.g. "a b" becomes q=a%2520b). q: prepareQuery(inputValue), limit: 25, }), From 1de02c8485c13bafa075f2658c8be5ba4a80c996 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:59:07 +0000 Subject: [PATCH 5/6] fix(site/src/pages/AISettingsPage): await empty ACL state in sharing dialog story --- .../ModelsPage/components/ChatModelSharingDialog.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.stories.tsx index ca88c89622d..e0c0d89176c 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ChatModelSharingDialog.stories.tsx @@ -147,7 +147,7 @@ export const EmptyACL: Story = { ), ).toBeInTheDocument(); expect( - body.getByText("No members or groups have permission yet"), + await body.findByText("No members or groups have permission yet"), ).toBeInTheDocument(); expect( body.getByRole("button", { name: "Save permissions" }), From 92597c2baf87709bac35c10b9bc8fa8073defa7c Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Thu, 27 Aug 2026 09:26:01 +0000 Subject: [PATCH 6/6] fix(site): remove unused MCPConnectSummaryViewModel export The interface is only referenced inside debugPanelUtils.ts, so exporting it causes knip to report an unused export. Match the adjacent file-local view-model types. --- .../components/RightPanel/DebugPanel/debugPanelUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts index da532e32841..b836562675e 100644 --- a/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts +++ b/site/src/pages/AgentsPage/components/RightPanel/DebugPanel/debugPanelUtils.ts @@ -436,7 +436,7 @@ export const compactDuration = (ms: number): string => { // View-model types for coerced debug payloads. // --------------------------------------------------------------------------- -export interface MCPConnectSummaryViewModel { +interface MCPConnectSummaryViewModel { slug: string; outcome: string; durationMs: number | undefined;