fix: make chat model sharing work for sharers without directory access (#28542)

## Problem

This is a bug fix. Chat model sharing UAT (main @ 3c32408) found two
bugs when a delegated "Model Sharer" role holds only
`chat_model_config:read` + `chat_model_config:share`:

This state is reachable entirely through the UI in a normal
least-privilege setup:

1. An org admin restricts **Organization Settings → Workspace Sharing**
to `none` or `service_accounts`.
2. Under **Organization → Roles**, they create a custom role with only
the advanced permissions `chat_model_config:read` and
`chat_model_config:share`.
3. Under **Organization → Members**, they assign that role to the person
responsible for managing model access.
4. An admin creates a chat model and may initially share it with a
group.
5. The delegated sharer signs in, opens **AI Settings → Models → [model]
→ Share model**, where the failures below occur.

- **UAT-05 / UAT-08:** with workspace sharing set to `none`, the sharing
dialog errored out entirely ("Resource not found or you do not have
access to this resource"). The sharer couldn't view, add, or even remove
grants.
- **UAT-04:** with workspace sharing set to `service_accounts`, existing
group grants rendered as raw UUIDs, and groups didn't appear in the
add-principal search.

Both have the same cause. The ACL GET returned bare UUID→role maps, so
to display names (and to power its add-principal search) the dialog
called the generic org directory APIs: list organization members and
list groups. Those APIs require `organization_member:read` and
`group:read`, which the sharer role doesn't have. Ordinary members only
get them as a side effect of a *different* feature:
`OrgMemberPermissions` grants member read when the org's workspace
sharing mode isn't `none` (so users can pick coworkers to share a
workspace with), and group read only when it's `everyone`. The chat
dialog was silently borrowing those workspace-sharing permissions, so it
only worked when that unrelated org setting happened to grant them:

- `none`: no member read → dbauthz rejects the member listing → the
dialog's query chain 404s and everything blanks (UAT-05/08).
- `service_accounts`: member read but no group read → the group listing
silently filters to empty → group grants can't be resolved past their
UUIDs (UAT-04).

In short: an org setting about who can share workspaces was deciding
whether a delegated sharer could manage a chat model ACL.

## Fix

Stop borrowing directory permissions entirely; authorize everything
through the model's own share permission, using the two patterns the
repo already has for ACL editors:

- **Hydrate the ACL GET** to return resolved users and groups instead of
UUID maps, like `workspaceACL` and the per-chat ACL already do. Fixes
the raw UUIDs.
- **Add `GET .../acl/available`** returning assignable org members and
groups for the autocomplete, modeled directly on
`templateAvailablePermissions` (same query semantics, same
share-gate-then-system-context lookup that every sibling ACL endpoint
uses). Fixes candidate discovery. The new endpoint exists only because
the dialog has no permission-safe way to enumerate principals today; it
is plumbing for the fix, not a new feature.

This is exactly how template sharing already solves the same problem:
`templateACL` returns hydrated principals, and
`templateAvailablePermissions` gates on the template then does the
lookups under a system context because, per its own comment, "the caller
might not have permission to read all users". We differ from the
template version only where newer conventions exist:

| | Template | This PR |
|---|---|---|
| Gate | `ActionUpdate` on the template (predates `share`) |
`ActionShare` on the model, matching the workspace/chat/MCP ACL
endpoints |
| User candidates | Site-wide `GetUsers` | Non-system members of the
model's org only |
| Group hydration | Full member rosters | Member counts, batched in one
query |

The dialog now runs off the hydrated ACL plus the new endpoint, so all
three sharing modes take the same code path, and a discovery failure no
longer blanks existing grants. `OrgMemberPermissions`, the directory
APIs, and the PATCH (still a sparse UUID→role delta) are untouched.

## Diff breakdown

+1274/−191, but only about a third is product code:

- **Product (~450):** the two endpoints and hydration helpers
(`exp_chats_model_acl.go`, ~240), the dialog rework plus a new colocated
autocomplete component (~200), and SDK/site API plumbing (~60).
- **Tests (~625):** backend tests updated to hydrated shapes plus new
tests that reproduce the two failing UAT sharing modes end-to-end
(+418), rewritten and new story play tests (+198), and query/mock
updates.
- **Generated (~200):** swagger, apidocs, `typesGenerated.ts`, API
reference docs.

Depends on #28498 (stacked on the `/api/v2` chat API promotion; the new
endpoint is registered under both prefixes like its siblings).

---------

Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
This commit is contained in:
Ethan
2026-08-27 10:49:13 +01:00
committed by GitHub
co-authored by Michael Suchacz
parent 26b9c8764f
commit d0e22343f0
20 changed files with 1252 additions and 210 deletions
+78 -8
View File
@@ -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-and-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"
}
}
}
+74 -8
View File
@@ -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-and-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"
}
}
}
+1
View File
@@ -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)
})
})
})
+230 -10
View File
@@ -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-and-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 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 (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 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) {
+389 -29
View File
@@ -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()
+29 -3
View File
@@ -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 {
+12 -3
View File
@@ -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) {
+44 -14
View File
@@ -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
+11 -5
View File
@@ -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 () => {
+13
View File
@@ -3932,6 +3932,19 @@ class ExperimentalApiMethods {
return response.data;
};
getChatModelACLAvailable = async (
organizationId: string,
modelId: string,
options: TypesGen.UsersRequest,
): Promise<TypesGen.ACLAvailable> => {
const url = getURLWithSearchParams(
`${chatModelACLPath(organizationId, modelId)}/available`,
options,
);
const response = await this.axios.get<TypesGen.ACLAvailable>(url);
return response.data;
};
updateChatModelACL = async (
organizationId: string,
modelId: string,
+38 -3
View File
@@ -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,
+18
View File
@@ -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<TypesGen.ACLAvailable> =>
API.experimental.getChatModelACLAvailable(organizationId, modelId, options),
enabled: organizationId !== "" && modelId !== "",
});
type UpdateChatModelACLMutationArgs = {
organizationId: string;
modelId: string;
+4 -3
View File
@@ -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<string, ChatRole>;
readonly group_roles: Record<string, ChatRole>;
readonly users: readonly ChatUser[];
readonly groups: readonly ChatGroup[];
}
// From codersdk/chats.go
@@ -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<ChatModelPrincipalAutocompleteValue, null>;
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(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 (
<div className="flex flex-col gap-2">
<Autocomplete
value={value}
onChange={onChange}
options={options}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
isGroup(option) ? option.display_name || option.name : option.email
}
isOptionEqualToValue={(option, optionValue) =>
option.id === optionValue.id
}
renderOption={(option, isSelected) => (
<div className="flex w-full items-center justify-between">
<AvatarData
title={
isGroup(option)
? option.display_name || option.name
: option.username
}
subtitle={
isGroup(option) ? getGroupSubtitle(option) : option.email
}
src={option.avatar_url}
/>
{isSelected && <CheckIcon className="size-4 shrink-0" />}
</div>
)}
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 && (
<ErrorAlert error={aclAvailableQuery.error} />
)}
</div>
);
};
@@ -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<string, "read">;
group_roles: Record<string, "read">;
};
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 = () => {
@@ -134,11 +147,13 @@ 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" }),
).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: () => <ReopenableSharingDialog />,
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);
},
};
@@ -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<UserOrGroupAutocompleteValue, null>;
type ChatModelPrincipal = Exclude<ChatModelPrincipalAutocompleteValue, null>;
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<TypesGen.ChatRole> => ({
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<OpenChatModelSharingDialogProps> = ({
}) => {
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<OpenChatModelSharingDialogProps> = ({
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 }) => (
<UserOrGroupAutocomplete
<ChatModelPrincipalAutocomplete
organizationId={organizationId}
value={value}
onChange={onChange}
exclude={excludedPrincipalIds.map((id) => ({ id }))}
modelId={modelId}
excludedPrincipalIds={excludedPrincipalIds}
className="w-full"
/>
)}
@@ -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({
@@ -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;
+13
View File
@@ -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],
+13
View File
@@ -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),