diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 38bc8348a4..a2ecc72bb7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a51fe7a59b..688778a27a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": { diff --git a/coderd/coderd.go b/coderd/coderd.go index a72f7f290b..dedd032d80 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index e324b8dd41..4b5ba6e6a4 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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), diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index ada8877a18..7dce695afc 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -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}, diff --git a/coderd/exp_chats_acl.go b/coderd/exp_chats_acl.go new file mode 100644 index 0000000000..cb9af92f3d --- /dev/null +++ b/coderd/exp_chats_acl.go @@ -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 +} diff --git a/coderd/exp_chats_acl_test.go b/coderd/exp_chats_acl_test.go new file mode 100644 index 0000000000..a41b592e9f --- /dev/null +++ b/coderd/exp_chats_acl_test.go @@ -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 +} diff --git a/coderd/rbac/acl/updatevalidator.go b/coderd/rbac/acl/updatevalidator.go index 9785609f2e..a3c0427101 100644 --- a/coderd/rbac/acl/updatevalidator.go +++ b/coderd/rbac/acl/updatevalidator.go @@ -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], diff --git a/coderd/rbac/regosql/compile_test.go b/coderd/rbac/regosql/compile_test.go index bf598fe02e..d8842f8325 100644 --- a/coderd/rbac/regosql/compile_test.go +++ b/coderd/rbac/regosql/compile_test.go @@ -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{ diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index 027f0adeb8..36a056eff2 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -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(), ) diff --git a/codersdk/chats.go b/codersdk/chats.go index 562bc0014d..70da8f5b69 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -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. diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 8c978a28e5..78d2e50a0e 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -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", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 590f274a93..d2968250d3 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 74f9c5acc5..54df9a6ef9 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -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: {}, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index df85b819c2..4f8bd486d8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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; + readonly group_roles?: Record; +} + // From codersdk/chats.go /** * UpdateChatAutoArchiveDaysRequest is a request to update the chat diff --git a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx index 7fea6ddf8b..a0e70ca84c 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.stories.tsx @@ -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: [], diff --git a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx index 0dac5029f1..f6b1c259bc 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.stories.tsx @@ -52,6 +52,7 @@ const buildChat = (overrides: Partial = {}): 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, diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index 053dfcda6e..28ab040a5c 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -144,6 +144,7 @@ const buildChat = (overrides: Partial = {}): 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, diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx index 65f8a1d452..ce72f306e1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStore.test.tsx @@ -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: {}, diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx index c93ca7c9bb..ba2dc29336 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.stories.tsx @@ -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: {}, diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx index e03f4601cf..e8d7af2867 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.stories.tsx @@ -59,6 +59,7 @@ const buildChat = (overrides: Partial = {}): 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, diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx index 33fea58feb..67869f7f12 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.test.tsx @@ -57,6 +57,7 @@ const buildChat = (overrides: Partial = {}): 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",