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