Ctrl K
ComunidadGuías…

coderd/chat_project_acl.go

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

Archivo original ↗Preguntar a KiBot
package coderd

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"net/http"
	"slices"

	"github.com/google/uuid"
	"golang.org/x/xerrors"

	"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/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 project ACL
// @ID get-chat-project-acl
// @Security CoderSessionToken
// @Tags Chats
// @Produce json
// @Param organization path string true "Organization ID" format(uuid)
// @Param project path string true "Chat project ID" format(uuid)
// @Success 200 {object} codersdk.ChatProjectACL
// @Router /api/experimental/organizations/{organization}/chats/projects/{project}/acl [get]
// @x-apidocgen {"skip": true}
//
//nolint:revive // HTTP handler writes to ResponseWriter.
func (api *API) getChatProjectACL(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	project := httpmw.ChatProjectParam(r)

	// Read check first so the disabled response does not reveal the project.
	if !api.Authorize(r, policy.ActionRead, project.RBACObject()) {
		httpapi.ResourceNotFound(rw)
		return
	}
	if !api.allowChatSharing(ctx, rw) {
		return
	}

	//nolint:gocritic // Project readers see the owner without user read permission.
	owner, err := api.Database.GetUserByID(dbauthz.AsSystemRestricted(ctx), project.OwnerID)
	if err != nil {
		httpapi.InternalServerError(rw, xerrors.Errorf("get project owner: %w", err))
		return
	}
	users, err := api.chatProjectACLUsers(ctx, project)
	if err != nil {
		httpapi.InternalServerError(rw, err)
		return
	}
	// Groups from other organizations stay listed because their members in
	// this organization still get access.
	groups, err := resolveACLGroups(ctx, api.Database, uuid.Nil, project.GroupACL, func(group codersdk.Group, entry database.ChatACLEntry) codersdk.ChatProjectGroup {
		return codersdk.ChatProjectGroup{Group: group, Role: convertToChatProjectRole(entry.Permissions)}
	})
	if err != nil {
		httpapi.InternalServerError(rw, err)
		return
	}
	httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChatProjectACL{
		Owner:  db2sdk.MinimalUser(owner),
		Users:  users,
		Groups: groups,
	})
}

// @Summary Update chat project ACL
// @ID update-chat-project-acl
// @Security CoderSessionToken
// @Tags Chats
// @Accept json
// @Param organization path string true "Organization ID" format(uuid)
// @Param project path string true "Chat project ID" format(uuid)
// @Param request body codersdk.UpdateChatProjectACL true "Update chat project ACL request"
// @Success 204
// @Router /api/experimental/organizations/{organization}/chats/projects/{project}/acl [patch]
// @x-apidocgen {"skip": true}
func (api *API) patchChatProjectACL(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	project := httpmw.ChatProjectParam(r)
	apiKey := httpmw.APIKey(r)

	aReq, commitAudit := audit.InitRequest[database.ChatProject](rw, &audit.RequestParams{
		Audit:          *api.Auditor.Load(),
		Log:            api.Logger,
		Request:        r,
		Action:         database.AuditActionWrite,
		OrganizationID: project.OrganizationID,
	})
	defer commitAudit()
	aReq.Old = project

	if !api.Authorize(r, policy.ActionRead, project.RBACObject()) {
		httpapi.ResourceNotFound(rw)
		return
	}
	if !api.allowChatSharing(ctx, rw) {
		return
	}
	if !api.Authorize(r, policy.ActionShare, project.RBACObject()) {
		httpapi.Forbidden(rw)
		return
	}

	var req codersdk.UpdateChatProjectACL
	if !httpapi.Read(ctx, rw, r, &req) {
		return
	}

	validErrs := acl.Validate(ctx, api.Database, ChatProjectACLUpdateValidator(req))
	if len(validErrs) > 0 {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message:     "Invalid request to update chat project ACL.",
			Validations: validErrs,
		})
		return
	}
	// RBAC matches ACL keys as strings, so only canonical UUIDs work.
	userRoles, userKeyErrs := canonicalChatProjectRoles(req.UserRoles, "user_roles")
	groupRoles, groupKeyErrs := canonicalChatProjectRoles(req.GroupRoles, "group_roles")
	if len(userKeyErrs)+len(groupKeyErrs) > 0 {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message:     "Invalid request to update chat project ACL.",
			Validations: slices.Concat(userKeyErrs, groupKeyErrs),
		})
		return
	}

	if _, ok := userRoles[project.OwnerID]; ok {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "The project owner cannot be added to the project's sharing list.",
		})
		return
	}
	if _, ok := userRoles[apiKey.UserID]; ok {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Cannot change your own project sharing role.",
		})
		return
	}

	err := api.Database.InTx(func(tx database.Store) error {
		// Lock granted users' memberships before the project row. A membership
		// delete locks its row and then, through the 000629 trigger, the project
		// row, so the reverse order could deadlock. Holding the membership lock
		// also keeps a delete from missing the grant written below.
		if userIDs := grantedChatProjectIDs(userRoles); len(userIDs) > 0 {
			//nolint:gocritic // Validation below reports users that are not members.
			_, err := tx.LockOrganizationMembersByUserIDsForShare(dbauthz.AsSystemRestricted(ctx), database.LockOrganizationMembersByUserIDsForShareParams{
				OrganizationID: project.OrganizationID,
				UserIds:        userIDs,
			})
			if err != nil {
				return xerrors.Errorf("lock organization members: %w", err)
			}
		}
		current, err := tx.GetChatProjectByIDForUpdate(ctx, project.ID)
		if err != nil {
			return xerrors.Errorf("get chat project for update: %w", err)
		}
		aReq.Old = current
		validErrs, err := validateChatProjectACLOrganization(ctx, tx, project, userRoles, groupRoles)
		if err != nil {
			return err
		}
		if len(validErrs) > 0 {
			return &chatProjectACLValidationError{validations: validErrs}
		}
		userACL := applyChatProjectRoles(current.UserACL, userRoles)
		groupACL := applyChatProjectRoles(current.GroupACL, groupRoles)
		if err := tx.UpdateChatProjectACLByID(ctx, database.UpdateChatProjectACLByIDParams{
			ID:       project.ID,
			UserACL:  userACL,
			GroupACL: groupACL,
		}); err != nil {
			return xerrors.Errorf("update chat project ACL: %w", err)
		}
		updated, err := tx.GetChatProjectByID(ctx, project.ID)
		if err != nil {
			return xerrors.Errorf("get updated chat project: %w", err)
		}
		aReq.New = updated
		return nil
	}, nil)
	if err != nil {
		if validationErr, ok := errors.AsType[*chatProjectACLValidationError](err); ok {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message:     "Invalid request to update chat project ACL.",
				Validations: validationErr.validations,
			})
			return
		}
		if errors.Is(err, sql.ErrNoRows) {
			httpapi.ResourceNotFound(rw)
			return
		}
		if dbauthz.IsNotAuthorizedError(err) {
			httpapi.Forbidden(rw)
			return
		}
		httpapi.InternalServerError(rw, err)
		return
	}

	rw.WriteHeader(http.StatusNoContent)
}

func canonicalChatProjectRoles(roles map[string]codersdk.ChatProjectRole, field string) (map[uuid.UUID]codersdk.ChatProjectRole, []codersdk.ValidationError) {
	canonical := make(map[uuid.UUID]codersdk.ChatProjectRole, len(roles))
	var validErrs []codersdk.ValidationError
	for rawID, role := range roles {
		id, err := uuid.Parse(rawID)
		if err != nil {
			validErrs = append(validErrs, codersdk.ValidationError{
				Field:  field,
				Detail: fmt.Sprintf("%q is not a valid UUID", rawID),
			})
			continue
		}
		if _, ok := canonical[id]; ok {
			validErrs = append(validErrs, codersdk.ValidationError{
				Field:  field,
				Detail: fmt.Sprintf("ID %v is listed more than once under different spellings", id),
			})
			continue
		}
		canonical[id] = role
	}
	return canonical, validErrs
}

func applyChatProjectRoles(current database.ChatACL, roles map[uuid.UUID]codersdk.ChatProjectRole) database.ChatACL {
	next := make(database.ChatACL, len(current)+len(roles))
	for id, entry := range current {
		next[id] = entry
	}
	for id, role := range roles {
		if role == codersdk.ChatProjectRoleDeleted {
			delete(next, id.String())
			continue
		}
		next[id.String()] = database.ChatACLEntry{Permissions: db2sdk.ChatProjectRoleActions(role)}
	}
	return next
}

type chatProjectACLValidationError struct {
	validations []codersdk.ValidationError
}

func (*chatProjectACLValidationError) Error() string {
	return "invalid chat project ACL"
}

func validateChatProjectACLOrganization(
	ctx context.Context,
	db database.Store,
	project database.ChatProject,
	userRoles, groupRoles map[uuid.UUID]codersdk.ChatProjectRole,
) ([]codersdk.ValidationError, error) {
	//nolint:gocritic // Validation needs every requested user and group, even ones the caller cannot read.
	sysCtx := dbauthz.AsSystemRestricted(ctx)
	var validErrs []codersdk.ValidationError

	userIDs := grantedChatProjectIDs(userRoles)
	if len(userIDs) > 0 {
		memberships, err := db.GetOrganizationIDsByMemberIDs(sysCtx, userIDs)
		if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
			return nil, xerrors.Errorf("get organization memberships: %w", err)
		}
		inOrg := make(map[uuid.UUID]bool, len(memberships))
		for _, membership := range memberships {
			inOrg[membership.UserID] = slices.Contains(membership.OrganizationIDs, project.OrganizationID)
		}
		for _, id := range userIDs {
			if !inOrg[id] {
				validErrs = append(validErrs, codersdk.ValidationError{
					Field:  "user_roles",
					Detail: fmt.Sprintf("user with ID %v is not a member of the project's organization", id),
				})
			}
		}
	}

	groupIDs := grantedChatProjectIDs(groupRoles)
	if len(groupIDs) > 0 {
		groups, err := db.GetGroups(sysCtx, database.GetGroupsParams{GroupIds: groupIDs})
		if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
			return nil, xerrors.Errorf("get groups: %w", err)
		}
		for _, group := range groups {
			if group.Group.OrganizationID != project.OrganizationID {
				validErrs = append(validErrs, codersdk.ValidationError{
					Field:  "group_roles",
					Detail: fmt.Sprintf("group with ID %v is not in the project's organization", group.Group.ID),
				})
			}
		}
	}
	return validErrs, nil
}

func grantedChatProjectIDs(roles map[uuid.UUID]codersdk.ChatProjectRole) []uuid.UUID {
	ids := make([]uuid.UUID, 0, len(roles))
	for id, role := range roles {
		if role != codersdk.ChatProjectRoleDeleted {
			ids = append(ids, id)
		}
	}
	return ids
}

func (api *API) chatProjectACLUsers(ctx context.Context, project database.ChatProject) ([]codersdk.ChatProjectUser, error) {
	// Entries for users outside the organization grant nothing, because access
	// requires membership, so hide them. The 000629 trigger and the membership
	// lock in patchChatProjectACL keep such entries from being written.
	//nolint:gocritic // Project readers see who it is shared with without user read permission.
	memberships, err := api.Database.GetOrganizationIDsByMemberIDs(dbauthz.AsSystemRestricted(ctx), parseACLIDs(project.UserACL))
	if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
		return nil, xerrors.Errorf("get organization memberships: %w", err)
	}
	members := make(database.ChatACL, len(memberships))
	for _, membership := range memberships {
		if slices.Contains(membership.OrganizationIDs, project.OrganizationID) {
			members[membership.UserID.String()] = project.UserACL[membership.UserID.String()]
		}
	}
	return resolveACLUsers(ctx, api.Database, members, func(user codersdk.MinimalUser, entry database.ChatACLEntry) codersdk.ChatProjectUser {
		return codersdk.ChatProjectUser{MinimalUser: user, Role: convertToChatProjectRole(entry.Permissions)}
	})
}

type ChatProjectACLUpdateValidator codersdk.UpdateChatProjectACL

var _ acl.UpdateValidator[codersdk.ChatProjectRole] = ChatProjectACLUpdateValidator{}

func (v ChatProjectACLUpdateValidator) Users() (map[string]codersdk.ChatProjectRole, string) {
	return v.UserRoles, "user_roles"
}

func (v ChatProjectACLUpdateValidator) Groups() (map[string]codersdk.ChatProjectRole, string) {
	return v.GroupRoles, "group_roles"
}

func (ChatProjectACLUpdateValidator) ValidateRole(role codersdk.ChatProjectRole) error {
	switch role {
	case codersdk.ChatProjectRoleDeleted, codersdk.ChatProjectRoleUse, codersdk.ChatProjectRoleAdmin:
		return nil
	}
	return xerrors.Errorf("role %q is not a valid chat project role", role)
}

func convertToChatProjectRole(actions []policy.Action) codersdk.ChatProjectRole {
	for _, role := range []codersdk.ChatProjectRole{codersdk.ChatProjectRoleAdmin, codersdk.ChatProjectRoleUse} {
		if slice.SameElements(actions, db2sdk.ChatProjectRoleActions(role)) {
			return role
		}
	}
	return codersdk.ChatProjectRoleDeleted
}