Files
coder/coderd/exp_chats_model_acl_test.go
T
EthanandMichael Suchacz d0e22343f0 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>
2026-08-27 10:49:13 +01:00

797 lines
31 KiB
Go

package coderd_test
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestChatModelACL(t *testing.T) {
t.Parallel()
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)
memberClientRaw, member := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
groupMemberClientRaw, groupMember := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
groupMemberClient := codersdk.NewExperimentalClient(groupMemberClientRaw)
group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID})
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: group.ID, UserID: groupMember.ID})
initialACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
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{
UserRoles: map[string]codersdk.ChatRole{
member.ID.String(): codersdk.ChatRoleRead,
},
GroupRoles: map[string]codersdk.ChatRole{
firstUser.OrganizationID.String(): codersdk.ChatRoleDeleted,
group.ID.String(): codersdk.ChatRoleRead,
},
})
require.NoError(t, err)
logs := mAudit.AuditLogs()
require.Len(t, logs, 1)
require.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Equal(t, database.ResourceTypeChatModelConfig, logs[0].ResourceType)
require.Equal(t, model.ID, logs[0].ResourceID)
require.Equal(t, firstUser.UserID, logs[0].UserID)
require.Equal(t, firstUser.OrganizationID, logs[0].OrganizationID)
require.EqualValues(t, http.StatusNoContent, logs[0].StatusCode)
updatedACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
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)
_, err = groupMemberClient.ChatModel(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
_, err = memberClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
requireSDKError(t, err, http.StatusNotFound)
err = memberClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{})
requireSDKError(t, err, http.StatusNotFound)
err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{
member.ID.String(): codersdk.ChatRoleDeleted,
},
GroupRoles: map[string]codersdk.ChatRole{
group.ID.String(): codersdk.ChatRoleDeleted,
},
})
require.NoError(t, err)
emptyACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
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)
err = memberClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{})
requireSDKError(t, err, http.StatusNotFound)
_, err = groupMemberClient.ChatModel(ctx, firstUser.OrganizationID, model.ID)
requireSDKError(t, err, http.StatusNotFound)
otherOrganization := dbgen.Organization(t, db, database.Organization{})
_, err = adminClient.ChatModelACL(ctx, otherOrganization.ID, model.ID)
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()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
model := createChatModel(t, adminClient)
_, firstMember := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
_, secondMember := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
group := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID})
err := adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{firstMember.ID.String(): codersdk.ChatRoleRead},
GroupRoles: map[string]codersdk.ChatRole{group.ID.String(): codersdk.ChatRoleRead},
})
require.NoError(t, err)
err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{secondMember.ID.String(): codersdk.ChatRoleRead},
})
require.NoError(t, err)
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, expectedUserRoles, chatModelACLUserRoles(modelACL))
require.Equal(t, expectedGroupRoles, chatModelACLGroupRoles(modelACL))
path := fmt.Sprintf(
"/api/experimental/organizations/%s/chats/models/%s/acl",
firstUser.OrganizationID,
model.ID,
)
for _, test := range []struct {
name string
body json.RawMessage
}{
{name: "omitted maps", body: json.RawMessage(`{}`)},
{name: "empty maps", body: json.RawMessage(`{"user_roles":{},"group_roles":{}}`)},
{name: "null maps", body: json.RawMessage(`{"user_roles":null,"group_roles":null}`)},
} {
t.Run(test.name, func(t *testing.T) {
res, err := adminClient.Request(ctx, http.MethodPatch, path, test.body)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusNoContent, res.StatusCode)
modelACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
require.Equal(t, expectedUserRoles, chatModelACLUserRoles(modelACL))
require.Equal(t, expectedGroupRoles, chatModelACLGroupRoles(modelACL))
})
}
err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{
firstMember.ID.String(): codersdk.ChatRoleDeleted,
uuid.NewString(): codersdk.ChatRoleDeleted,
},
GroupRoles: map[string]codersdk.ChatRole{
group.ID.String(): codersdk.ChatRoleDeleted,
},
})
require.NoError(t, err)
modelACL, err = adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
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},
})
require.NoError(t, err)
//nolint:gocritic // Seeding a former member requires system access.
err = db.DeleteOrganizationMember(dbauthz.AsSystemRestricted(ctx), database.DeleteOrganizationMemberParams{
OrganizationID: firstUser.OrganizationID,
UserID: firstMember.ID,
})
require.NoError(t, err)
err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{firstMember.ID.String(): codersdk.ChatRoleDeleted},
})
require.NoError(t, err)
otherOrganization := dbgen.Organization(t, db, database.Organization{})
foreignUser := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: otherOrganization.ID,
UserID: foreignUser.ID,
})
foreignGroup := dbgen.Group(t, db, database.Group{OrganizationID: otherOrganization.ID})
err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{foreignUser.ID.String(): codersdk.ChatRoleDeleted},
GroupRoles: map[string]codersdk.ChatRole{foreignGroup.ID.String(): codersdk.ChatRoleDeleted},
})
require.NoError(t, err)
}
//nolint:tparallel,paralleltest // Subtests verify the same ACL remains unchanged.
func TestChatModelACLValidationIsAtomic(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)
_, member := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
err := adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{member.ID.String(): codersdk.ChatRoleRead},
})
require.NoError(t, err)
initialACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
otherOrganization := dbgen.Organization(t, db, database.Organization{})
foreignUser := dbgen.User(t, db, database.User{})
dbgen.OrganizationMember(t, db, database.OrganizationMember{
OrganizationID: otherOrganization.ID,
UserID: foreignUser.ID,
})
foreignGroup := dbgen.Group(t, db, database.Group{OrganizationID: otherOrganization.ID})
duplicateBody := json.RawMessage(fmt.Sprintf(
`{"user_roles":{%q:"read",%q:""}}`,
member.ID.String(),
strings.ToUpper(member.ID.String()),
))
tests := []struct {
name string
body any
}{
{
name: "foreign user",
body: codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{foreignUser.ID.String(): codersdk.ChatRoleRead},
},
},
{
name: "foreign group",
body: codersdk.UpdateChatModelACLRequest{
GroupRoles: map[string]codersdk.ChatRole{foreignGroup.ID.String(): codersdk.ChatRoleRead},
},
},
{
name: "missing user",
body: codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{uuid.NewString(): codersdk.ChatRoleRead},
},
},
{
name: "missing group",
body: codersdk.UpdateChatModelACLRequest{
GroupRoles: map[string]codersdk.ChatRole{uuid.NewString(): codersdk.ChatRoleRead},
},
},
{
name: "invalid user permission",
body: codersdk.UpdateChatModelACLRequest{
UserRoles: map[string]codersdk.ChatRole{member.ID.String(): "write"},
},
},
{
name: "invalid group permission",
body: codersdk.UpdateChatModelACLRequest{
GroupRoles: map[string]codersdk.ChatRole{firstUser.OrganizationID.String(): "share"},
},
},
{name: "duplicate canonical user ID", body: duplicateBody},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
res, err := adminClient.Request(ctx, http.MethodPatch, fmt.Sprintf(
"/api/experimental/organizations/%s/chats/models/%s/acl",
firstUser.OrganizationID,
model.ID,
), test.body)
require.NoError(t, err)
defer res.Body.Close()
err = codersdk.ReadBodyAsError(res)
requireSDKError(t, err, http.StatusBadRequest)
modelACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
require.Equal(t, initialACL, modelACL)
})
}
}
func TestChatModelACLLockFailure(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
rawDB, pubsub := dbtestutil.NewDB(t)
store := newFailNextAcquireLockStore(rawDB, 0)
adminClient := newChatClient(t, func(opts *coderdtest.Options) {
opts.Database = store
opts.Pubsub = pubsub
})
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
model := createChatModel(t, adminClient)
initialACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
store.lockID = database.GenLockID("chat_model_config_writes:" + firstUser.OrganizationID.String())
store.failNextAcquireLock.Store(true)
err = adminClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{})
requireSDKError(t, err, http.StatusInternalServerError)
modelACL, err := adminClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
require.Equal(t, initialACL, modelACL)
}
//nolint:tparallel,paralleltest // Subtests share one model and run sequentially.
func TestChatModelActionScopesWithOrgRead(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)
newScopedClient := func(t *testing.T, scopes ...database.APIKeyScope) *codersdk.ExperimentalClient {
t.Helper()
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: firstUser.UserID,
Scopes: append(database.APIKeyScopes{"organization:read"}, scopes...),
})
client := codersdk.New(
adminClient.URL,
codersdk.WithSessionToken(token),
codersdk.WithHTTPClient(coderdtest.NewIsolatedHTTPClient(adminClient.URL)),
)
t.Cleanup(client.HTTPClient.CloseIdleConnections)
return codersdk.NewExperimentalClient(client)
}
t.Run("UpdateWithoutModelRead", func(t *testing.T) {
updateClient := newScopedClient(t, database.ApiKeyScopeChatModelConfigUpdate)
updated, err := updateClient.UpdateChatModel(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelRequest{
DisplayName: "Updated without model read scope",
})
require.NoError(t, err)
require.Equal(t, "Updated without model read scope", updated.DisplayName)
_, err = updateClient.ChatModel(ctx, firstUser.OrganizationID, model.ID)
requireSDKError(t, err, http.StatusNotFound)
})
t.Run("ShareWithoutModelRead", func(t *testing.T) {
shareClient := newScopedClient(t, database.ApiKeyScopeChatModelConfigShare)
_, err := shareClient.ChatModelACL(ctx, firstUser.OrganizationID, model.ID)
require.NoError(t, err)
err = shareClient.UpdateChatModelACL(ctx, firstUser.OrganizationID, model.ID, codersdk.UpdateChatModelACLRequest{})
require.NoError(t, err)
_, err = shareClient.ChatModel(ctx, firstUser.OrganizationID, model.ID)
requireSDKError(t, err, http.StatusNotFound)
})
}
func TestChatModelACLShareDenied(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
adminClient := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, adminClient.Client)
model := createChatModel(t, adminClient)
memberClientRaw, _ := coderdtest.CreateAnotherUser(t, adminClient.Client, firstUser.OrganizationID)
memberClient := codersdk.NewExperimentalClient(memberClientRaw)
_, err := memberClient.ChatModel(ctx, firstUser.OrganizationID, model.ID)
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()
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
for _, test := range []struct {
name string
key string
value any
}{
{name: "group_acl/object", key: "group_acl", value: map[string]any{}},
{name: "group_acl/null", key: "group_acl", value: nil},
{name: "user_acl/object", key: "user_acl", value: map[string]any{}},
{name: "user_acl/null", key: "user_acl", value: nil},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
res, err := client.Request(ctx, http.MethodPost, fmt.Sprintf(
"/api/experimental/organizations/%s/chats/models",
firstUser.OrganizationID,
), map[string]any{test.key: test.value})
require.NoError(t, err)
defer res.Body.Close()
err = codersdk.ReadBodyAsError(res)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Contains(t, sdkErr.Message, "nested /acl endpoint")
})
}
}