coderd/exp_chats_acl.go
Rama main · réplica de lectura del repositorio original.
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
}