feat: add chat sharing API (#24968)

This commit is contained in:
Danielle Maywood
2026-05-20 10:46:35 +01:00
committed by GitHub
parent 70ab2b9940
commit 96e3c49670
22 changed files with 1583 additions and 3 deletions
+221
View File
@@ -467,6 +467,88 @@ const docTemplate = `{
]
}
},
"/api/experimental/chats/{chat}/acl": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
"produces": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "Get chat ACLs",
"operationId": "get-chat-acls",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatACL"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"patch": {
"description": "Experimental: this endpoint is subject to change.",
"consumes": [
"application/json"
],
"tags": [
"Chats"
],
"summary": "Update chat ACL",
"operationId": "update-chat-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
},
{
"description": "Update chat ACL request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateChatACL"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/chats/{chat}/diff": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -16211,6 +16293,12 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"owner_name": {
"type": "string"
},
"owner_username": {
"type": "string"
},
"parent_chat_id": {
"type": "string",
"format": "uuid"
@@ -16247,6 +16335,23 @@ const docTemplate = `{
}
}
},
"codersdk.ChatACL": {
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatGroup"
}
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatUser"
}
}
}
},
"codersdk.ChatBusyBehavior": {
"type": "string",
"enum": [
@@ -16452,6 +16557,61 @@ const docTemplate = `{
}
}
},
"codersdk.ChatGroup": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"display_name": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"members": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ReducedUser"
}
},
"name": {
"type": "string"
},
"organization_display_name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"organization_name": {
"type": "string"
},
"quota_allowance": {
"type": "integer"
},
"role": {
"enum": [
"read"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.ChatRole"
}
]
},
"source": {
"$ref": "#/definitions/codersdk.GroupSource"
},
"total_member_count": {
"description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than ` + "`" + `len(Group.Members)` + "`" + `.",
"type": "integer"
}
}
},
"codersdk.ChatInputPart": {
"type": "object",
"properties": {
@@ -16905,6 +17065,17 @@ const docTemplate = `{
}
}
},
"codersdk.ChatRole": {
"type": "string",
"enum": [
"read",
""
],
"x-enum-varnames": [
"ChatRoleRead",
"ChatRoleDeleted"
]
},
"codersdk.ChatStatus": {
"type": "string",
"enum": [
@@ -17065,6 +17236,39 @@ const docTemplate = `{
}
}
},
"codersdk.ChatUser": {
"type": "object",
"required": [
"id",
"username"
],
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"role": {
"enum": [
"read"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.ChatRole"
}
]
},
"username": {
"type": "string"
}
}
},
"codersdk.ChatWatchEvent": {
"type": "object",
"properties": {
@@ -23994,6 +24198,23 @@ const docTemplate = `{
}
}
},
"codersdk.UpdateChatACL": {
"type": "object",
"properties": {
"group_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.ChatRole"
}
},
"user_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.ChatRole"
}
}
}
},
"codersdk.UpdateChatRequest": {
"type": "object",
"properties": {
+200
View File
@@ -408,6 +408,80 @@
]
}
},
"/api/experimental/chats/{chat}/acl": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
"produces": ["application/json"],
"tags": ["Chats"],
"summary": "Get chat ACLs",
"operationId": "get-chat-acls",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChatACL"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
},
"patch": {
"description": "Experimental: this endpoint is subject to change.",
"consumes": ["application/json"],
"tags": ["Chats"],
"summary": "Update chat ACL",
"operationId": "update-chat-acl",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Chat ID",
"name": "chat",
"in": "path",
"required": true
},
{
"description": "Update chat ACL request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.UpdateChatACL"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/chats/{chat}/diff": {
"get": {
"description": "Experimental: this endpoint is subject to change.",
@@ -14585,6 +14659,12 @@
"type": "string",
"format": "uuid"
},
"owner_name": {
"type": "string"
},
"owner_username": {
"type": "string"
},
"parent_chat_id": {
"type": "string",
"format": "uuid"
@@ -14621,6 +14701,23 @@
}
}
},
"codersdk.ChatACL": {
"type": "object",
"properties": {
"groups": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatGroup"
}
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChatUser"
}
}
}
},
"codersdk.ChatBusyBehavior": {
"type": "string",
"enum": ["queue", "interrupt"],
@@ -14814,6 +14911,59 @@
}
}
},
"codersdk.ChatGroup": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"display_name": {
"type": "string"
},
"id": {
"type": "string",
"format": "uuid"
},
"members": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ReducedUser"
}
},
"name": {
"type": "string"
},
"organization_display_name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"organization_name": {
"type": "string"
},
"quota_allowance": {
"type": "integer"
},
"role": {
"enum": ["read"],
"allOf": [
{
"$ref": "#/definitions/codersdk.ChatRole"
}
]
},
"source": {
"$ref": "#/definitions/codersdk.GroupSource"
},
"total_member_count": {
"description": "How many members are in this group. Shows the total count,\neven if the user is not authorized to read group member details.\nMay be greater than `len(Group.Members)`.",
"type": "integer"
}
}
},
"codersdk.ChatInputPart": {
"type": "object",
"properties": {
@@ -15250,6 +15400,11 @@
}
}
},
"codersdk.ChatRole": {
"type": "string",
"enum": ["read", ""],
"x-enum-varnames": ["ChatRoleRead", "ChatRoleDeleted"]
},
"codersdk.ChatStatus": {
"type": "string",
"enum": [
@@ -15410,6 +15565,34 @@
}
}
},
"codersdk.ChatUser": {
"type": "object",
"required": ["id", "username"],
"properties": {
"avatar_url": {
"type": "string",
"format": "uri"
},
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"role": {
"enum": ["read"],
"allOf": [
{
"$ref": "#/definitions/codersdk.ChatRole"
}
]
},
"username": {
"type": "string"
}
}
},
"codersdk.ChatWatchEvent": {
"type": "object",
"properties": {
@@ -22068,6 +22251,23 @@
}
}
},
"codersdk.UpdateChatACL": {
"type": "object",
"properties": {
"group_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.ChatRole"
}
},
"user_roles": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/codersdk.ChatRole"
}
}
}
},
"codersdk.UpdateChatRequest": {
"type": "object",
"properties": {
+4
View File
@@ -1298,6 +1298,10 @@ func New(options *Options) *API {
})
r.Route("/{chat}", func(r chi.Router) {
r.Use(httpmw.ExtractChatParam(options.Database))
r.Route("/acl", func(r chi.Router) {
r.Get("/", api.getChatACL)
r.Patch("/", api.patchChatACL)
})
r.Get("/", api.getChat)
r.Patch("/", api.patchChat)
r.Get("/messages", api.getChatMessages)
+9
View File
@@ -976,6 +976,13 @@ func WorkspaceRoleActions(role codersdk.WorkspaceRole) []policy.Action {
return []policy.Action{}
}
func ChatRoleActions(role codersdk.ChatRole) []policy.Action {
if role == codersdk.ChatRoleRead {
return []policy.Action{policy.ActionRead}
}
return []policy.Action{}
}
func ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ agentproto.Connection_Type) (database.ConnectionType, error) {
switch typ {
case agentproto.Connection_SSH:
@@ -1739,6 +1746,8 @@ func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database
ID: c.ID,
OrganizationID: c.OrganizationID,
OwnerID: c.OwnerID,
OwnerUsername: c.OwnerUsername,
OwnerName: c.OwnerName,
LastModelConfigID: c.LastModelConfigID,
Title: c.Title,
Status: codersdk.ChatStatus(c.Status),
+2
View File
@@ -930,6 +930,8 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
input := database.Chat{
ID: uuid.New(),
OwnerID: uuid.New(),
OwnerUsername: "owner-username",
OwnerName: "Owner Name",
OrganizationID: uuid.New(),
WorkspaceID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
BuildID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
+315
View File
@@ -0,0 +1,315 @@
package coderd
import (
"context"
"database/sql"
"net/http"
"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"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/acl"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Get chat ACLs
// @ID get-chat-acls
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param chat path string true "Chat ID" format(uuid)
// @Success 200 {object} codersdk.ChatACL
// @Router /api/experimental/chats/{chat}/acl [get]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
//
//nolint:revive // get-return: revive assumes get* must be a getter, but this is an HTTP handler.
func (api *API) getChatACL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
if !api.allowChatSharing(ctx, rw) {
return
}
if chat.IsSubChat() {
resp := codersdk.Response{Message: "Chat ACLs can only be set on root chats."}
if chat.RootChatID.Valid {
resp.Detail = "Target the root chat (id: " + chat.RootChatID.UUID.String() + ") instead."
}
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
return
}
chatACL, err := api.Database.GetChatACLByID(ctx, chat.ID)
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.ResourceNotFound(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
users, ok := api.chatACLUsers(ctx, rw, chat, chatACL.Users)
if !ok {
return
}
groups, ok := api.chatACLGroups(ctx, rw, chat, chatACL.Groups)
if !ok {
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatACL{
Users: users,
Groups: groups,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Update chat ACL
// @ID update-chat-acl
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param chat path string true "Chat ID" format(uuid)
// @Param request body codersdk.UpdateChatACL true "Update chat ACL request"
// @Success 204
// @Router /api/experimental/chats/{chat}/acl [patch]
// @x-apidocgen {"skip": true}
// @Description Experimental: this endpoint is subject to change.
func (api *API) patchChatACL(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
chat := httpmw.ChatParam(r)
auditor := api.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.Chat](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
OrganizationID: chat.OrganizationID,
})
defer commitAudit()
aReq.Old = chat
if !api.allowChatSharing(ctx, rw) {
return
}
if chat.IsSubChat() {
resp := codersdk.Response{Message: "Chat ACLs can only be set on root chats."}
if chat.RootChatID.Valid {
resp.Detail = "Target the root chat (id: " + chat.RootChatID.UUID.String() + ") instead."
}
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
return
}
if !api.Authorize(r, policy.ActionShare, chat.RBACObject()) {
httpapi.Forbidden(rw)
return
}
var req codersdk.UpdateChatACL
if !httpapi.Read(ctx, rw, r, &req) {
return
}
apiKey := httpmw.APIKey(r)
for userID := range req.UserRoles {
parsed, err := uuid.Parse(userID)
if err == nil && parsed == apiKey.UserID {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Cannot change your own chat sharing role.",
})
return
}
}
validErrs := acl.Validate(ctx, api.Database, ChatACLUpdateValidator(req))
if len(validErrs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request to update chat ACL.",
Validations: validErrs,
})
return
}
err := api.Database.InTx(func(tx database.Store) error {
current, err := tx.GetChatByIDForUpdate(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("get chat by ID: %w", err)
}
if current.UserACL == nil {
current.UserACL = database.ChatACL{}
}
if current.GroupACL == nil {
current.GroupACL = database.ChatACL{}
}
for id, role := range req.UserRoles {
if role == codersdk.ChatRoleDeleted {
delete(current.UserACL, id)
continue
}
current.UserACL[id] = database.ChatACLEntry{
Permissions: db2sdk.ChatRoleActions(role),
}
}
for id, role := range req.GroupRoles {
if role == codersdk.ChatRoleDeleted {
delete(current.GroupACL, id)
continue
}
current.GroupACL[id] = database.ChatACLEntry{
Permissions: db2sdk.ChatRoleActions(role),
}
}
if err := tx.UpdateChatACLByID(ctx, database.UpdateChatACLByIDParams{
ID: chat.ID,
UserACL: current.UserACL,
GroupACL: current.GroupACL,
}); err != nil {
return xerrors.Errorf("update chat ACL: %w", err)
}
updatedChat, err := tx.GetChatByID(ctx, chat.ID)
if err != nil {
return xerrors.Errorf("get updated chat by ID: %w", err)
}
aReq.New = updatedChat
return nil
}, nil)
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
rw.WriteHeader(http.StatusNoContent)
}
func (api *API) chatACLUsers(ctx context.Context, rw http.ResponseWriter, chat database.Chat, entries database.ChatACL) ([]codersdk.ChatUser, bool) {
userIDs := make([]uuid.UUID, 0, len(entries))
for userID := range entries {
id, err := uuid.Parse(userID)
if err != nil {
api.Logger.Warn(ctx, "found invalid user uuid in chat acl", slog.Error(err), slog.F("chat_id", chat.ID))
continue
}
userIDs = append(userIDs, id)
}
//nolint:gocritic // Users who can read the chat ACL should see shared users even without user read permission.
dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return nil, false
}
users := make([]codersdk.ChatUser, 0, len(dbUsers))
for _, user := range dbUsers {
entry := entries[user.ID.String()]
users = append(users, codersdk.ChatUser{
MinimalUser: db2sdk.MinimalUser(user),
Role: convertToChatRole(entry.Permissions),
})
}
return users, true
}
func (api *API) chatACLGroups(ctx context.Context, rw http.ResponseWriter, chat database.Chat, entries database.ChatACL) ([]codersdk.ChatGroup, bool) {
groupIDs := make([]uuid.UUID, 0, len(entries))
for groupID := range entries {
id, err := uuid.Parse(groupID)
if err != nil {
api.Logger.Warn(ctx, "found invalid group uuid in chat acl", slog.Error(err), slog.F("chat_id", chat.ID))
continue
}
groupIDs = append(groupIDs, id)
}
dbGroups := make([]database.GetGroupsRow, 0)
if len(groupIDs) > 0 {
var err error
//nolint:gocritic // Users who can read the chat ACL should see shared groups even without group read permission.
dbGroups, err = api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs})
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return nil, false
}
}
groups := make([]codersdk.ChatGroup, 0, len(dbGroups))
for _, group := range dbGroups {
//nolint:gocritic // Users who can read the chat ACL should see shared group sizes even without group read permission.
memberCount, err := api.Database.GetGroupMembersCountByGroupID(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDParams{
GroupID: group.Group.ID,
IncludeSystem: false,
})
if err != nil {
httpapi.InternalServerError(rw, err)
return nil, false
}
entry := entries[group.Group.ID.String()]
groups = append(groups, codersdk.ChatGroup{
Group: db2sdk.Group(group, nil, int(memberCount)),
Role: convertToChatRole(entry.Permissions),
})
}
return groups, true
}
func (api *API) allowChatSharing(ctx context.Context, rw http.ResponseWriter) bool {
if !api.chatSharingDisabled() {
return true
}
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Chat sharing is disabled for this deployment.",
})
return false
}
func (api *API) chatSharingDisabled() bool {
return rbac.ChatACLDisabled() || (api.DeploymentValues != nil && bool(api.DeploymentValues.DisableChatSharing))
}
type ChatACLUpdateValidator codersdk.UpdateChatACL
var _ acl.UpdateValidator[codersdk.ChatRole] = ChatACLUpdateValidator{}
func (c ChatACLUpdateValidator) Users() (map[string]codersdk.ChatRole, string) {
return c.UserRoles, "user_roles"
}
func (c ChatACLUpdateValidator) Groups() (map[string]codersdk.ChatRole, string) {
return c.GroupRoles, "group_roles"
}
func (ChatACLUpdateValidator) ValidateRole(role codersdk.ChatRole) error {
if role == codersdk.ChatRoleDeleted || role == codersdk.ChatRoleRead {
return nil
}
return xerrors.Errorf("role %q is not a valid chat role", role)
}
func convertToChatRole(actions []policy.Action) codersdk.ChatRole {
if slice.SameElements(actions, db2sdk.ChatRoleActions(codersdk.ChatRoleRead)) {
return codersdk.ChatRoleRead
}
return codersdk.ChatRoleDeleted
}
+517
View File
@@ -0,0 +1,517 @@
package coderd_test
import (
"bytes"
"context"
"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/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestChatACLSharingLifecycle(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
mAudit := audit.NewMock()
client, db := newChatClientWithDatabase(t, func(opts *coderdtest.Options) {
opts.Auditor = mAudit
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
sharedClient, sharedUser := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
sharedClientExp := codersdk.NewExperimentalClient(sharedClient)
nonSharedClient, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
nonSharedClientExp := codersdk.NewExperimentalClient(nonSharedClient)
groupMemberClient, groupMember := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
groupMemberClientExp := codersdk.NewExperimentalClient(groupMemberClient)
sharedGroup := dbgen.Group(t, db, database.Group{OrganizationID: firstUser.OrganizationID})
dbgen.GroupMember(t, db, database.GroupMemberTable{GroupID: sharedGroup.ID, UserID: groupMember.ID})
data := []byte("chat sharing file")
uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "text/plain", "shared.txt", bytes.NewReader(data))
require.NoError(t, err)
chat := createChatForSharing(ctx, t, client, firstUser.OrganizationID, "shared chat", uploaded.ID)
_, err = sharedClientExp.GetChat(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
_, _, err = nonSharedClientExp.GetChatFile(ctx, uploaded.ID)
requireSDKError(t, err, http.StatusNotFound)
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
sharedUser.ID.String(): codersdk.ChatRoleRead,
},
GroupRoles: map[string]codersdk.ChatRole{
sharedGroup.ID.String(): codersdk.ChatRoleRead,
},
})
require.NoError(t, err)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
acl, err := client.GetChatACL(ctx, chat.ID)
require.NoError(t, err)
require.Len(t, acl.Users, 1)
require.Equal(t, sharedUser.ID.String(), acl.Users[0].ID.String())
require.Equal(t, map[uuid.UUID]codersdk.ChatRole{
sharedUser.ID: codersdk.ChatRoleRead,
}, chatUserRoles(acl.Users))
require.Equal(t, map[uuid.UUID]codersdk.ChatRole{
sharedGroup.ID: codersdk.ChatRoleRead,
}, chatGroupRoles(acl.Groups))
require.Len(t, acl.Groups, 1)
require.Equal(t, sharedGroup.ID.String(), acl.Groups[0].ID.String())
require.Empty(t, acl.Groups[0].Members)
require.Equal(t, 1, acl.Groups[0].TotalMemberCount)
sharedACL, err := sharedClientExp.GetChatACL(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, chatUserRoles(acl.Users), chatUserRoles(sharedACL.Users))
require.Equal(t, chatGroupRoles(acl.Groups), chatGroupRoles(sharedACL.Groups))
require.Len(t, sharedACL.Groups, 1)
require.Empty(t, sharedACL.Groups[0].Members)
require.Equal(t, 1, sharedACL.Groups[0].TotalMemberCount)
sharedChat, err := sharedClientExp.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, chat.ID, sharedChat.ID)
require.Equal(t, coderdtest.FirstUserParams.Username, sharedChat.OwnerUsername)
require.Equal(t, coderdtest.FirstUserParams.Name, sharedChat.OwnerName)
require.Len(t, sharedChat.Files, 1)
require.Equal(t, uploaded.ID, sharedChat.Files[0].ID)
messages, err := sharedClientExp.GetChatMessages(ctx, chat.ID, nil)
require.NoError(t, err)
require.NotEmpty(t, messages.Messages)
got, contentType, err := sharedClientExp.GetChatFile(ctx, uploaded.ID)
require.NoError(t, err)
require.Contains(t, contentType, "text/plain")
require.Equal(t, data, got)
_, _, err = nonSharedClientExp.GetChatFile(ctx, uploaded.ID)
requireSDKError(t, err, http.StatusNotFound)
groupChat, err := groupMemberClientExp.GetChat(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, chat.ID, groupChat.ID)
_, err = sharedClientExp.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{
Content: []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: "should not send",
}},
})
requireSDKError(t, err, http.StatusNotFound)
err = sharedClientExp.UpdateChat(ctx, chat.ID, codersdk.UpdateChatRequest{
Title: ptr.Ref("should not rename"),
})
requireSDKError(t, err, http.StatusNotFound)
err = sharedClientExp.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
groupMember.ID.String(): codersdk.ChatRoleRead,
},
})
requireSDKError(t, err, http.StatusForbidden)
err = sharedClientExp.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
uuid.NewString(): codersdk.ChatRoleRead,
},
})
requireSDKError(t, err, http.StatusForbidden)
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
strings.ToUpper(firstUser.UserID.String()): codersdk.ChatRoleRead,
},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Cannot change your own chat sharing role.", sdkErr.Message)
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
sharedUser.ID.String(): codersdk.ChatRoleDeleted,
},
})
require.NoError(t, err)
_, err = sharedClientExp.GetChat(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
_, err = groupMemberClientExp.GetChat(ctx, chat.ID)
require.NoError(t, err)
mAudit.ResetLogs()
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
GroupRoles: map[string]codersdk.ChatRole{
sharedGroup.ID.String(): codersdk.ChatRoleDeleted,
},
})
require.NoError(t, err)
require.True(t, mAudit.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeChat,
ResourceID: chat.ID,
UserID: firstUser.UserID,
}))
_, err = groupMemberClientExp.GetChat(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
}
func TestChatACLSubChatInheritance(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
sharedClient, sharedUser := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
sharedClientExp := codersdk.NewExperimentalClient(sharedClient)
root := createChatForSharing(ctx, t, client, firstUser.OrganizationID, "root chat")
child := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
ParentChatID: uuid.NullUUID{UUID: root.ID, Valid: true},
LastModelConfigID: modelConfig.ID,
Title: "child chat",
})
err := client.UpdateChatACL(ctx, root.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
sharedUser.ID.String(): codersdk.ChatRoleRead,
},
})
require.NoError(t, err)
sharedChild, err := sharedClientExp.GetChat(ctx, child.ID)
require.NoError(t, err)
require.Equal(t, child.ID, sharedChild.ID)
require.NotNil(t, sharedChild.RootChatID)
require.Equal(t, root.ID, *sharedChild.RootChatID)
_, err = sharedClientExp.GetChat(ctx, root.ID)
require.NoError(t, err)
err = client.UpdateChatACL(ctx, child.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
sharedUser.ID.String(): codersdk.ChatRoleDeleted,
},
})
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Chat ACLs can only be set on root chats.", sdkErr.Message)
_, err = client.GetChatACL(ctx, child.ID)
sdkErr = requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Chat ACLs can only be set on root chats.", sdkErr.Message)
}
func TestChatACLValidation(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := newChatClient(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
_ = createChatModelConfig(t, client)
chat := createChatForSharing(ctx, t, client, firstUser.OrganizationID, "validation chat")
missingUserID := uuid.New()
missingGroupID := uuid.New()
tests := []struct {
name string
req codersdk.UpdateChatACL
wantValidation codersdk.ValidationError
}{
{
name: "InvalidRole",
req: codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
uuid.NewString(): codersdk.ChatRole("write"),
},
},
wantValidation: codersdk.ValidationError{
Field: "user_roles",
Detail: `role "write" is not a valid chat role`,
},
},
{
name: "InvalidUserUUID",
req: codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
"not-a-uuid": codersdk.ChatRoleRead,
},
},
wantValidation: codersdk.ValidationError{
Field: "user_roles",
Detail: "not-a-uuid is not a valid UUID.",
},
},
{
name: "InvalidGroupUUID",
req: codersdk.UpdateChatACL{
GroupRoles: map[string]codersdk.ChatRole{
"not-a-uuid": codersdk.ChatRoleRead,
},
},
wantValidation: codersdk.ValidationError{
Field: "group_roles",
Detail: "not-a-uuid is not a valid UUID.",
},
},
{
name: "MissingUser",
req: codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
missingUserID.String(): codersdk.ChatRoleRead,
},
},
wantValidation: codersdk.ValidationError{
Field: "user_roles",
Detail: "user with ID " + missingUserID.String() + " does not exist",
},
},
{
name: "MissingGroup",
req: codersdk.UpdateChatACL{
GroupRoles: map[string]codersdk.ChatRole{
missingGroupID.String(): codersdk.ChatRoleRead,
},
},
wantValidation: codersdk.ValidationError{
Field: "group_roles",
Detail: "group with ID " + missingGroupID.String() + " does not exist",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
err := client.UpdateChatACL(ctx, chat.ID, tt.req)
sdkErr := requireSDKError(t, err, http.StatusBadRequest)
require.Equal(t, "Invalid request to update chat ACL.", sdkErr.Message)
require.Contains(t, sdkErr.Validations, tt.wantValidation)
})
}
}
func TestSharedReaderStreamChat(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
sharedClient, sharedUser := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID)
sharedClientExp := codersdk.NewExperimentalClient(sharedClient)
chat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "shared stream chat",
})
insertAssistantCostMessage(t, db, chat.ID, modelConfig.ID, 0)
err := client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
sharedUser.ID.String(): codersdk.ChatRoleRead,
},
})
require.NoError(t, err)
events, closer, err := sharedClientExp.StreamChat(ctx, chat.ID, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = closer.Close() })
foundAssistantMessage := false
for !foundAssistantMessage {
select {
case <-ctx.Done():
require.FailNow(t, "timed out waiting for shared stream chat event")
case event, ok := <-events:
require.True(t, ok, "stream closed before expected event")
require.Equal(t, chat.ID, event.ChatID)
require.NotEqual(t, codersdk.ChatStreamEventTypeError, event.Type)
if event.Type == codersdk.ChatStreamEventTypeMessage &&
event.Message != nil &&
event.Message.Role == codersdk.ChatMessageRoleAssistant {
foundAssistantMessage = true
}
}
}
require.NoError(t, closer.Close())
persisted, err := db.GetChatByID(dbauthz.AsSystemRestricted(ctx), chat.ID)
require.NoError(t, err)
require.False(t, persisted.LastReadMessageID.Valid)
}
func TestListChatsExcludesSharedChats(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client, db := newChatClientWithDatabase(t)
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
viewerClient, viewer := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
viewerClientExp := codersdk.NewExperimentalClient(viewerClient)
sharedChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "shared with viewer",
})
viewerChat := dbgen.Chat(t, db, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: viewer.ID,
LastModelConfigID: modelConfig.ID,
Title: "viewer owned",
})
err := client.UpdateChatACL(ctx, sharedChat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
viewer.ID.String(): codersdk.ChatRoleRead,
},
})
require.NoError(t, err)
ownedOnly, err := viewerClientExp.ListChats(ctx, nil)
require.NoError(t, err)
require.Equal(t, map[uuid.UUID]struct{}{viewerChat.ID: {}}, chatIDSet(ownedOnly))
}
//nolint:paralleltest // This test verifies a process-wide RBAC kill switch.
func TestChatSharingDisabled(t *testing.T) {
previous := rbac.ChatACLDisabled()
rbac.SetChatACLDisabled(false)
rbac.ReloadBuiltinRoles(nil)
t.Cleanup(func() {
rbac.ReloadBuiltinRoles(nil)
rbac.SetChatACLDisabled(previous)
})
ctx := testutil.Context(t, testutil.WaitLong)
values := chatDeploymentValues(t)
values.DisableChatSharing = true
store, pubsub := dbtestutil.NewDB(t)
client := newChatClient(t, func(opts *coderdtest.Options) {
opts.DeploymentValues = values
opts.Database = store
opts.Pubsub = pubsub
})
firstUser := coderdtest.CreateFirstUser(t, client.Client)
modelConfig := createChatModelConfig(t, client)
viewerClient, viewer := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID))
viewerClientExp := codersdk.NewExperimentalClient(viewerClient)
chat := dbgen.Chat(t, store, database.Chat{
OrganizationID: firstUser.OrganizationID,
OwnerID: firstUser.UserID,
LastModelConfigID: modelConfig.ID,
Title: "disabled sharing",
})
err := store.UpdateChatACLByID(ctx, database.UpdateChatACLByIDParams{
ID: chat.ID,
UserACL: database.ChatACL{
viewer.ID.String(): database.ChatACLEntry{Permissions: []policy.Action{policy.ActionRead}},
},
GroupACL: database.ChatACL{},
})
require.NoError(t, err)
_, err = viewerClientExp.GetChat(ctx, chat.ID)
requireSDKError(t, err, http.StatusNotFound)
_, err = client.GetChatACL(ctx, chat.ID)
sdkErr := requireSDKError(t, err, http.StatusForbidden)
require.Equal(t, "Chat sharing is disabled for this deployment.", sdkErr.Message)
err = client.UpdateChatACL(ctx, chat.ID, codersdk.UpdateChatACL{
UserRoles: map[string]codersdk.ChatRole{
viewer.ID.String(): codersdk.ChatRoleRead,
},
})
requireSDKError(t, err, http.StatusForbidden)
ownerChats, err := client.ListChats(ctx, nil)
require.NoError(t, err)
require.Equal(t, map[uuid.UUID]struct{}{chat.ID: {}}, chatIDSet(ownerChats))
viewerChats, err := viewerClientExp.ListChats(ctx, nil)
require.NoError(t, err)
require.Empty(t, viewerChats)
}
func createChatForSharing(
ctx context.Context,
t *testing.T,
client *codersdk.ExperimentalClient,
organizationID uuid.UUID,
text string,
fileIDs ...uuid.UUID,
) codersdk.Chat {
t.Helper()
content := []codersdk.ChatInputPart{{
Type: codersdk.ChatInputPartTypeText,
Text: text,
}}
for _, fileID := range fileIDs {
content = append(content, codersdk.ChatInputPart{
Type: codersdk.ChatInputPartTypeFile,
FileID: fileID,
})
}
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
OrganizationID: organizationID,
Content: content,
})
require.NoError(t, err)
return chat
}
func chatUserRoles(users []codersdk.ChatUser) map[uuid.UUID]codersdk.ChatRole {
roles := make(map[uuid.UUID]codersdk.ChatRole, len(users))
for _, user := range users {
roles[user.ID] = user.Role
}
return roles
}
func chatGroupRoles(groups []codersdk.ChatGroup) map[uuid.UUID]codersdk.ChatRole {
roles := make(map[uuid.UUID]codersdk.ChatRole, len(groups))
for _, group := range groups {
roles[group.ID] = group.Role
}
return roles
}
func chatIDSet(chats []codersdk.Chat) map[uuid.UUID]struct{} {
ids := make(map[uuid.UUID]struct{}, len(chats))
for _, chat := range chats {
ids[chat.ID] = struct{}{}
}
return ids
}
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"github.com/coder/coder/v2/codersdk"
)
type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole] interface {
type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole | codersdk.ChatRole] interface {
// Users should return a map from user UUIDs (as strings) to the role they
// are being assigned. Additionally, it should return a string that will be
// used as the field name for the ValidationErrors returned from Validate.
@@ -25,7 +25,7 @@ type UpdateValidator[Role codersdk.WorkspaceRole | codersdk.TemplateRole] interf
ValidateRole(role Role) error
}
func Validate[Role codersdk.WorkspaceRole | codersdk.TemplateRole](
func Validate[Role codersdk.WorkspaceRole | codersdk.TemplateRole | codersdk.ChatRole](
ctx context.Context,
db database.Store,
v UpdateValidator[Role],
+10
View File
@@ -227,6 +227,16 @@ func TestRegoQueries(t *testing.T) {
" OR (chats_expanded.user_acl#>array['d5389ccc-57a4-4b13-8c3f-31747bcdc9f1', 'permissions'] ? '*'))",
VariableConverter: regosql.ChatConverter(),
},
{
Name: "ChatAllowList",
Queries: []string{
`input.object.id != ""`,
`input.object.id in ["9046b041-58ed-47a3-9c3a-de302577875a"]`,
},
ExpectedSQL: p(`(chats_expanded.id :: text != '') OR ` +
`(chats_expanded.id :: text = ANY(ARRAY ['9046b041-58ed-47a3-9c3a-de302577875a']))`),
VariableConverter: regosql.ChatConverter(),
},
{
Name: "NoACLConfig",
Queries: []string{
+5 -1
View File
@@ -6,6 +6,10 @@ func resourceIDMatcher() sqltypes.VariableMatcher {
return sqltypes.StringVarMatcher("id :: text", []string{"input", "object", "id"})
}
func chatResourceIDMatcher() sqltypes.VariableMatcher {
return sqltypes.StringVarMatcher("chats_expanded.id :: text", []string{"input", "object", "id"})
}
func organizationOwnerMatcher() sqltypes.VariableMatcher {
return sqltypes.StringVarMatcher("organization_id :: text", []string{"input", "object", "org_owner"})
}
@@ -72,7 +76,7 @@ func ChatNoACLConverter() *sqltypes.VariableConverter {
func chatBaseConverter() *sqltypes.VariableConverter {
return sqltypes.NewVariableConverter().RegisterMatcher(
resourceIDMatcher(),
chatResourceIDMatcher(),
sqltypes.StringVarMatcher("chats_expanded.organization_id :: text", []string{"input", "object", "org_owner"}),
userOwnerMatcher(),
)