Ctrl K
ComunidadGuías…

coderd/exp_chats_acl.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}