mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add chat sharing API (#24968)
This commit is contained in:
Generated
+221
@@ -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": {
|
||||
|
||||
Generated
+200
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user