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(),
)
+54
View File
@@ -109,6 +109,8 @@ type Chat struct {
ID uuid.UUID `json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
OwnerUsername string `json:"owner_username,omitempty"`
OwnerName string `json:"owner_name,omitempty"`
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"`
BuildID *uuid.UUID `json:"build_id,omitempty" format:"uuid"`
AgentID *uuid.UUID `json:"agent_id,omitempty" format:"uuid"`
@@ -1967,6 +1969,33 @@ type ChatUsageLimitConfigResponse struct {
GroupOverrides []ChatUsageLimitGroupOverride `json:"group_overrides"`
}
type ChatRole string
const (
ChatRoleRead ChatRole = "read"
ChatRoleDeleted ChatRole = ""
)
type ChatUser struct {
MinimalUser
Role ChatRole `json:"role" enums:"read"`
}
type ChatGroup struct {
Group
Role ChatRole `json:"role" enums:"read"`
}
type ChatACL struct {
Users []ChatUser `json:"users"`
Groups []ChatGroup `json:"groups"`
}
type UpdateChatACL struct {
UserRoles map[string]ChatRole `json:"user_roles,omitempty"`
GroupRoles map[string]ChatRole `json:"group_roles,omitempty"`
}
// ListChatsOptions are optional parameters for ListChats.
type ListChatsOptions struct {
Query string
@@ -2942,6 +2971,31 @@ func (c *ExperimentalClient) GetChat(ctx context.Context, chatID uuid.UUID) (Cha
return chat, json.NewDecoder(res.Body).Decode(&chat)
}
func (c *ExperimentalClient) GetChatACL(ctx context.Context, chatID uuid.UUID) (ChatACL, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/experimental/chats/%s/acl", chatID), nil)
if err != nil {
return ChatACL{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChatACL{}, ReadBodyAsError(res)
}
var acl ChatACL
return acl, json.NewDecoder(res.Body).Decode(&acl)
}
func (c *ExperimentalClient) UpdateChatACL(ctx context.Context, chatID uuid.UUID, req UpdateChatACL) error {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/experimental/chats/%s/acl", chatID), req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// GetChatMessages returns the messages and queued messages for a chat.
// ChatMessagesPaginationOptions are optional pagination params for
// GetChatMessages.
+22
View File
@@ -148,6 +148,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -270,6 +272,8 @@ Status Code **200**
| `» mcp_server_ids` | array | false | | |
| `» organization_id` | string(uuid) | false | | |
| `» owner_id` | string(uuid) | false | | |
| `» owner_name` | string | false | | |
| `» owner_username` | string | false | | |
| `» parent_chat_id` | string(uuid) | false | | |
| `» pin_order` | integer | false | | |
| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | |
@@ -482,6 +486,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -608,6 +614,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -885,6 +893,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -1065,6 +1075,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -1191,6 +1203,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -1452,6 +1466,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -1578,6 +1594,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -2686,6 +2704,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -2812,6 +2832,8 @@ Experimental: this endpoint is subject to change.
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
+185
View File
@@ -2338,6 +2338,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -2464,6 +2466,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -2501,6 +2505,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `mcp_server_ids` | array of string | false | | |
| `organization_id` | string | false | | |
| `owner_id` | string | false | | |
| `owner_name` | string | false | | |
| `owner_username` | string | false | | |
| `parent_chat_id` | string | false | | |
| `pin_order` | integer | false | | |
| `plan_mode` | [codersdk.ChatPlanMode](#codersdkchatplanmode) | false | | |
@@ -2511,6 +2517,60 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `warnings` | array of string | false | | |
| `workspace_id` | string | false | | |
## codersdk.ChatACL
```json
{
"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 |
|----------|---------------------------------------------------|----------|--------------|-------------|
| `groups` | array of [codersdk.ChatGroup](#codersdkchatgroup) | false | | |
| `users` | array of [codersdk.ChatUser](#codersdkchatuser) | false | | |
## codersdk.ChatBusyBehavior
```json
@@ -2691,6 +2751,63 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `organization_id` | string | false | | |
| `owner_id` | string | false | | |
## codersdk.ChatGroup
```json
{
"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
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-----------------------------|-------------------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `avatar_url` | string | false | | |
| `display_name` | string | false | | |
| `id` | string | false | | |
| `members` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | |
| `name` | string | false | | |
| `organization_display_name` | string | false | | |
| `organization_id` | string | false | | |
| `organization_name` | string | false | | |
| `quota_allowance` | integer | false | | |
| `role` | [codersdk.ChatRole](#codersdkchatrole) | false | | |
| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | |
| `total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. |
#### Enumerated Values
| Property | Value(s) |
|----------|----------|
| `role` | `read` |
## codersdk.ChatInputPart
```json
@@ -3377,6 +3494,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|------------------|---------|----------|--------------|-------------|
| `retention_days` | integer | false | | |
## codersdk.ChatRole
```json
"read"
```
### Properties
#### Enumerated Values
| Value(s) |
|------------|
| ``, `read` |
## codersdk.ChatStatus
```json
@@ -3810,6 +3941,34 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `tool_call_id` | string | false | | |
| `tool_name` | string | false | | |
## codersdk.ChatUser
```json
{
"avatar_url": "http://example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"role": "read",
"username": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------|----------------------------------------|----------|--------------|-------------|
| `avatar_url` | string | false | | |
| `id` | string | true | | |
| `name` | string | false | | |
| `role` | [codersdk.ChatRole](#codersdkchatrole) | false | | |
| `username` | string | true | | |
#### Enumerated Values
| Property | Value(s) |
|----------|----------|
| `role` | `read` |
## codersdk.ChatWatchEvent
```json
@@ -3934,6 +4093,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
],
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"owner_username": "string",
"parent_chat_id": "c3609ee6-3b11-4a93-b9ae-e4fabcc99359",
"pin_order": 0,
"plan_mode": "plan",
@@ -12893,6 +13054,30 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
| `logo_url` | string | false | | |
| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by AnnouncementBanners. |
## codersdk.UpdateChatACL
```json
{
"group_roles": {
"property1": "read",
"property2": "read"
},
"user_roles": {
"property1": "read",
"property2": "read"
}
}
```
### 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 | | |
## codersdk.UpdateChatRequest
```json
+1
View File
@@ -101,6 +101,7 @@ const makeChat = (
id,
organization_id: "test-org-id",
owner_id: "owner-1",
owner_username: "owner",
last_model_config_id: "model-1",
mcp_server_ids: [],
labels: {},
+29
View File
@@ -1480,6 +1480,8 @@ export interface Chat {
readonly id: string;
readonly organization_id: string;
readonly owner_id: string;
readonly owner_username?: string;
readonly owner_name?: string;
readonly workspace_id?: string;
readonly build_id?: string;
readonly agent_id?: string;
@@ -1524,6 +1526,12 @@ export interface Chat {
readonly children: readonly Chat[];
}
// From codersdk/chats.go
export interface ChatACL {
readonly users: readonly ChatUser[];
readonly groups: readonly ChatGroup[];
}
// From codersdk/chats.go
export type ChatAttachmentMediaType =
| "application/json"
@@ -2049,6 +2057,11 @@ export const ChatGitWatchWorkspaceNoAgentsMessage =
*/
export const ChatGitWatchWorkspaceNotFoundMessage = "Chat workspace not found.";
// From codersdk/chats.go
export interface ChatGroup extends Group {
readonly role: ChatRole;
}
// From codersdk/chats.go
/**
* ChatInputPart is a single user input part for creating a chat.
@@ -2652,6 +2665,11 @@ export interface ChatRetentionDaysResponse {
readonly retention_days: number;
}
// From codersdk/chats.go
export type ChatRole = "" | "read";
export const ChatRoles: ChatRole[] = ["", "read"];
// From codersdk/chats.go
export interface ChatSkillPart {
readonly type: "skill";
@@ -2975,6 +2993,11 @@ export interface ChatUsageLimitStatus {
readonly period_end?: string;
}
// From codersdk/chats.go
export interface ChatUser extends MinimalUser {
readonly role: ChatRole;
}
// From codersdk/chats.go
/**
* ChatWatchEvent represents an event from the global chat watch stream.
@@ -8377,6 +8400,12 @@ export interface UpdateAppearanceConfig {
readonly announcement_banners: readonly BannerConfig[];
}
// From codersdk/chats.go
export interface UpdateChatACL {
readonly user_roles?: Record<string, ChatRole>;
readonly group_roles?: Record<string, ChatRole>;
}
// From codersdk/chats.go
/**
* UpdateChatAutoArchiveDaysRequest is a request to update the chat
@@ -128,6 +128,7 @@ const mockModelConfigs: TypesGen.ChatModelConfig[] = [
const baseChatFields = {
organization_id: "test-org-id",
owner_id: MockUserOwner.id,
owner_username: MockUserOwner.username,
workspace_id: mockWorkspace.id,
last_model_config_id: MODEL_CONFIG_ID,
mcp_server_ids: [],
@@ -52,6 +52,7 @@ const buildChat = (overrides: Partial<TypesGen.Chat> = {}): TypesGen.Chat => ({
id: AGENT_ID,
organization_id: "test-org-id",
owner_id: "owner-1",
owner_username: "owner",
title: "Help me refactor",
status: "completed",
last_model_config_id: defaultModelConfigID,
@@ -144,6 +144,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
id: "chat-default",
organization_id: "test-org-id",
owner_id: "owner-1",
owner_username: "owner",
title: "Agent",
status: "completed",
last_model_config_id: defaultModelConfigs[0].id,
@@ -207,6 +207,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({
id: chatID,
organization_id: "test-org-id",
owner_id: "owner-1",
owner_username: "owner",
last_model_config_id: "model-1",
mcp_server_ids: [],
labels: {},
@@ -60,6 +60,7 @@ export const WithParentChat: Story = {
id: "parent-chat-1",
organization_id: "test-org-id",
owner_id: "owner-id",
owner_username: "owner",
last_model_config_id: "model-config-1",
mcp_server_ids: [],
labels: {},
@@ -59,6 +59,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
id: "chat-default",
organization_id: "test-org-id",
owner_id: "owner-1",
owner_username: "owner",
title: "Agent",
status: "completed",
last_model_config_id: defaultModelConfigs[0].id,
@@ -57,6 +57,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
id: "chat-default",
organization_id: "test-org-id",
owner_id: "owner-1",
owner_username: "owner",
title: "Agent",
status: "completed",
last_model_config_id: "model-1",