mirror of
https://github.com/coder/coder.git
synced 2026-10-16 06:37:58 +08:00
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:
Generated
+78
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+74
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+44
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+4
-3
@@ -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
|
||||
|
||||
+109
@@ -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>
|
||||
);
|
||||
};
|
||||
+148
-50
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user