package coderd import ( "context" "errors" "maps" "net/http" "slices" "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/notifications" "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" ) // @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/v2/chats/{chat}/acl [get] // @x-apidocgen {"skip": true} // //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, err := resolveACLUsers(ctx, api.Database, chatACL.Users, chatACLUser) if err != nil { httpapi.InternalServerError(rw, err) return } groups, err := resolveACLGroups(ctx, api.Database, uuid.Nil, chatACL.Groups, chatACLGroup) if err != nil { httpapi.InternalServerError(rw, err) return } httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatACL{ Users: users, Groups: groups, }) } // @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/v2/chats/{chat}/acl [patch] // @x-apidocgen {"skip": true} 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)) var dupErrs []codersdk.ValidationError req.UserRoles, dupErrs = canonicalACLRoles("user_roles", req.UserRoles) validErrs = append(validErrs, dupErrs...) req.GroupRoles, dupErrs = canonicalACLRoles("group_roles", req.GroupRoles) validErrs = append(validErrs, dupErrs...) if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid request to update chat ACL.", Validations: validErrs, }) return } var oldChat database.Chat 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{} } deleteNoncanonicalChatACLKeys(current.UserACL, req.UserRoles) deleteNoncanonicalChatACLKeys(current.GroupACL, req.GroupRoles) oldChat = current oldChat.UserACL = maps.Clone(current.UserACL) applyACLReadRoles(current.UserACL, req.UserRoles) applyACLReadRoles(current.GroupACL, req.GroupRoles) 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 } initiator, err := api.Database.GetUserByID(ctx, apiKey.UserID) if err != nil { api.Logger.Warn(ctx, "failed to load chat share initiator", slog.Error(err), slog.F("chat_id", chat.ID)) } else { newChat := aReq.New go func() { if count, err := api.notifyChatShared(oldChat, newChat, initiator); err != nil { api.Logger.Warn(api.ctx, "failed to enqueue one or more chat shared notifications", slog.Error(err), slog.F("chat_id", newChat.ID), slog.F("attempted_recipients", count)) } }() } rw.WriteHeader(http.StatusNoContent) } // deleteNoncanonicalChatACLKeys deletes entries whose keys spell the ID of a // principal in roles in a noncanonical form. Older servers stored request keys // as sent. RBAC matches keys as strings, so these entries grant nothing, and // the canonical keys in roles would never remove them. func deleteNoncanonicalChatACLKeys(entries database.ChatACL, roles map[string]codersdk.ChatRole) { for rawID := range entries { id, err := uuid.Parse(rawID) if err != nil || id.String() == rawID { continue } if _, ok := roles[id.String()]; ok { delete(entries, rawID) } } } func (api *API) notifyChatShared(oldChat database.Chat, newChat database.Chat, initiator database.User) (int, error) { oldReaders := api.directChatReaders(oldChat) newReaders := api.directChatReaders(newChat) added, _ := slice.SymmetricDifference(oldReaders, newReaders) recipientIDs := make([]uuid.UUID, 0, len(added)) for _, userID := range added { if userID == initiator.ID { continue } recipientIDs = append(recipientIDs, userID) } if len(recipientIDs) == 0 { return 0, nil } labels := map[string]string{ "chat_id": newChat.ID.String(), "chat_title": newChat.Title, "initiator": initiator.Username, } //nolint:gocritic // Notifier actor is required to enqueue notifications. notifierCtx := dbauthz.AsNotifier(api.ctx) var errs []error for _, userID := range recipientIDs { if _, err := api.NotificationsEnqueuer.Enqueue(notifierCtx, userID, notifications.TemplateChatShared, labels, initiator.ID.String(), newChat.ID); err != nil { errs = append(errs, xerrors.Errorf("enqueue chat shared notification: %w", err)) } } return len(recipientIDs), errors.Join(errs...) } func (api *API) directChatReaders(chat database.Chat) []uuid.UUID { readers := []uuid.UUID{chat.OwnerID} for rawUserID, entry := range chat.UserACL { if !slices.Contains(entry.Permissions, policy.ActionRead) { continue } userID, err := uuid.Parse(rawUserID) if err != nil { api.Logger.Warn(api.ctx, "skip chat ACL entry with invalid user UUID", slog.F("chat_id", chat.ID), slog.F("user_id", rawUserID), slog.Error(err)) continue } readers = append(readers, userID) } return slice.Unique(readers) } 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 chatACLUser(user codersdk.MinimalUser, entry database.ChatACLEntry) codersdk.ChatUser { return codersdk.ChatUser{MinimalUser: user, Role: convertToChatRole(entry.Permissions)} } func chatACLGroup(group codersdk.Group, entry database.ChatACLEntry) codersdk.ChatGroup { return codersdk.ChatGroup{Group: group, Role: convertToChatRole(entry.Permissions)} } func convertToChatRole(actions []policy.Action) codersdk.ChatRole { if slice.SameElements(actions, db2sdk.ChatRoleActions(codersdk.ChatRoleRead)) { return codersdk.ChatRoleRead } return codersdk.ChatRoleDeleted }