Ctrl K
ComunidadGuías…

server/models/GroupMembership.ts

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

Archivo original ↗Preguntar a KiBot
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import {
  Op,
  type SaveOptions,
  type FindOptions,
  type DestroyOptions,
  type WhereOptions,
  type Transaction,
} from "sequelize";
import {
  BelongsTo,
  Column,
  Default,
  ForeignKey,
  IsIn,
  Table,
  DataType,
  Scopes,
  AfterCreate,
  AfterUpdate,
  AfterDestroy,
  BeforeDestroy,
  BeforeUpdate,
} from "sequelize-typescript";
import type { DocumentPermission } from "@shared/types";
import { CollectionPermission } from "@shared/types";
import { ValidationError } from "@server/errors";
import { LockHelper } from "@server/storage/LockHelper";
import type { APIContext } from "@server/types";
import Collection from "./Collection";
import Document from "./Document";
import Group from "./Group";
import GroupUser from "./GroupUser";
import User from "./User";
import UserMembership from "./UserMembership";
import { type HookContext } from "./base/Model";
import ParanoidModel from "./base/ParanoidModel";

/**
 * Represents a group's permission to access a collection or document.
 */
@Scopes(() => ({
  withGroup: {
    include: [
      {
        association: "group",
      },
    ],
  },
  withCollection: {
    where: {
      collectionId: {
        [Op.ne]: null,
      },
    },
    include: [
      {
        association: "collection",
      },
    ],
  },
  withDocument: {
    where: {
      documentId: {
        [Op.ne]: null,
      },
    },
    include: [
      {
        association: "document",
      },
    ],
  },
}))
@Table({ tableName: "group_permissions", modelName: "group_permission" })
class GroupMembership extends ParanoidModel<
  InferAttributes<GroupMembership>,
  Partial<InferCreationAttributes<GroupMembership>>
> {
  /** The permission granted to the group. */
  @Default(CollectionPermission.ReadWrite)
  @IsIn([Object.values(CollectionPermission)])
  @Column(DataType.STRING)
  permission: CollectionPermission | DocumentPermission;

  // associations

  /** The collection that this membership grants the group access to. */
  @BelongsTo(() => Collection, "collectionId")
  collection?: Collection | null;

  /** The collection ID that this membership grants the group access to. */
  @ForeignKey(() => Collection)
  @Column(DataType.UUID)
  collectionId?: string | null;

  /** The document that this membership grants the group access to. */
  @BelongsTo(() => Document, "documentId")
  document?: Document | null;

  /** The document ID that this membership grants the group access to. */
  @ForeignKey(() => Document)
  @Column(DataType.UUID)
  documentId?: string | null;

  /** If this represents the membership on a child then this points to the membership on the root */
  @BelongsTo(() => GroupMembership, "sourceId")
  source?: GroupMembership | null;

  /** If this represents the membership on a child then this points to the membership on the root */
  @ForeignKey(() => GroupMembership)
  @Column(DataType.UUID)
  sourceId?: string | null;

  /** The group that this membership is granted to. */
  @BelongsTo(() => Group, "groupId")
  group: Group;

  /** The group ID that this membership is granted to. */
  @ForeignKey(() => Group)
  @Column(DataType.UUID)
  groupId: string;

  /** The user that created this membership. */
  @BelongsTo(() => User, "createdById")
  createdBy: User;

  /** The user ID that created this membership. */
  @ForeignKey(() => User)
  @Column(DataType.UUID)
  createdById: string;

  get modelId() {
    return this.groupId;
  }

  // static methods

  /**
   * Find the membership of a group on a collection or document and lock it for
   * the remainder of the transaction. The lock is held even when no membership
   * exists, so that concurrent grants cannot each create a membership.
   *
   * @param where the group and the collection or document of the membership.
   * @param options additional options to pass to the query, with a transaction.
   * @returns a promise that resolves to the membership, or null.
   */
  public static async findOneForUpdate(
    where: {
      groupId: string;
      collectionId?: string | null;
      documentId?: string | null;
    },
    options: FindOptions<GroupMembership> & { transaction: Transaction }
  ) {
    const { groupId, collectionId, documentId } = where;
    const targetId = collectionId ?? documentId;
    await LockHelper.acquire(
      this.sequelize!,
      `groupMembership:${targetId}:${groupId}`.toLowerCase(),
      options.transaction
    );

    return this.findOne({
      ...options,
      where: collectionId ? { groupId, collectionId } : { groupId, documentId },
      lock: options.transaction.LOCK.UPDATE,
    });
  }

  /**
   * Copy group memberships from one document to another.
   *
   * @param where The where clause to find the group memberships to copy.
   * @param document The document to copy the group memberships to.
   * @param options Additional options to pass to the query.
   */
  public static async copy(
    where: WhereOptions<GroupMembership>,
    document: Document,
    options: SaveOptions
  ) {
    const { transaction } = options;
    const groupMemberships = await this.findAll({
      where,
      transaction,
    });
    await Promise.all(
      groupMemberships.map((membership) =>
        this.create(
          {
            documentId: document.id,
            groupId: membership.groupId,
            sourceId: membership.sourceId ?? membership.id,
            permission: membership.permission,
            createdById: membership.createdById,
          },
          { transaction, hooks: false }
        )
      )
    );
  }

  /**
   * Find the root membership for a document and (optionally) group.
   *
   * @param documentId The document ID to find the membership for.
   * @param groupId The group ID to find the membership for.
   * @param options Additional options to pass to the query.
   * @returns A promise that resolves to the root memberships for the document and group, or null.
   */
  public static async findRootMembershipsForDocument(
    documentId: string,
    groupId?: string,
    options?: FindOptions<GroupMembership>
  ): Promise<GroupMembership[]> {
    const memberships = await this.findAll({
      where: {
        documentId,
        ...(groupId ? { groupId } : {}),
      },
    });

    const rootMemberships = await Promise.all(
      memberships.map((membership) =>
        membership?.sourceId
          ? this.findByPk(membership.sourceId, options)
          : Promise.resolve(membership)
      )
    );

    return rootMemberships.filter(Boolean) as GroupMembership[];
  }

  // hooks

  @AfterCreate
  static async createSourcedMemberships(
    model: GroupMembership,
    options: SaveOptions<GroupMembership> & {
      documentId?: string;
      childDocumentIds?: string[];
    }
  ) {
    if (model.sourceId || !model.documentId) {
      return;
    }

    return this.recreateSourcedMemberships(model, options);
  }

  @AfterCreate
  static async publishAddGroupEventAfterCreate(
    model: GroupMembership,
    context: APIContext["context"]
  ) {
    await model.insertEvent(context, "add_group", {
      membershipId: model.id,
      isNew: true,
    });
  }

  @AfterCreate
  static async invalidateDocumentIdsAfterCreate(model: GroupMembership) {
    await model.invalidateMembershipDocumentIds();
  }

  @AfterUpdate
  static async updateSourcedMemberships(
    model: GroupMembership,
    options: SaveOptions<GroupMembership>
  ) {
    if (model.sourceId || !model.documentId) {
      return;
    }

    const { transaction } = options;

    if (model.changed("permission")) {
      await this.update(
        {
          permission: model.permission,
        },
        {
          where: {
            groupId: model.groupId,
            sourceId: model.id,
          },
          transaction,
        }
      );
    }
  }

  @BeforeUpdate
  static async checkLastAdminBeforeUpdate(
    model: GroupMembership,
    ctx: APIContext["context"]
  ) {
    if (
      model.permission === CollectionPermission.Admin ||
      model.previous("permission") !== CollectionPermission.Admin ||
      !model.collectionId
    ) {
      return;
    }
    await this.validateLastAdminPermission(model, ctx);
  }

  @BeforeDestroy
  static async checkLastAdminBeforeDestroy(
    model: GroupMembership,
    ctx: APIContext["context"]
  ) {
    // Only check for last admin permission if this permission is admin
    if (
      model.permission !== CollectionPermission.Admin ||
      !model.collectionId
    ) {
      return;
    }
    await this.validateLastAdminPermission(model, ctx);
  }

  @AfterUpdate
  static async publishAddGroupEventAfterUpdate(
    model: GroupMembership,
    context: APIContext["context"]
  ) {
    await model.insertEvent(context, "add_group", {
      membershipId: model.id,
      isNew: false,
    });
  }

  @AfterDestroy
  static async destroySourcedMemberships(
    model: GroupMembership,
    options: DestroyOptions<GroupMembership>
  ) {
    if (model.sourceId || !model.documentId) {
      return;
    }

    const { transaction } = options;
    await this.destroy({
      where: {
        groupId: model.groupId,
        sourceId: model.id,
      },
      transaction,
    });
  }

  @AfterDestroy
  static async publishRemoveGroupEvent(
    model: GroupMembership,
    context: APIContext["context"]
  ) {
    await model.insertEvent(context, "remove_group", {
      membershipId: model.id,
    });
  }

  @AfterDestroy
  static async invalidateDocumentIdsAfterDestroy(model: GroupMembership) {
    await model.invalidateMembershipDocumentIds();
  }

  /**
   * Recreate all sourced permissions for a given permission.
   *
   * @param model the source membership.
   * @param options the save options, with an optional target document id and
   * precomputed published child document ids of the target document.
   */
  static async recreateSourcedMemberships(
    model: GroupMembership,
    options: SaveOptions<GroupMembership> & {
      documentId?: string;
      childDocumentIds?: string[];
    }
  ) {
    if (!model.documentId) {
      return;
    }
    const { transaction, documentId } = options;
    let { childDocumentIds } = options;

    if (!childDocumentIds) {
      const document = await Document.unscoped()
        .scope("withoutState")
        .findOne({
          attributes: ["id"],
          where: {
            id: documentId ?? model.documentId,
          },
          transaction,
        });
      if (!document) {
        return;
      }

      childDocumentIds = await document.findAllChildDocumentIds(
        {
          publishedAt: {
            [Op.ne]: null,
          },
        },
        {
          transaction,
        }
      );
    }

    const documentIds = [
      ...(documentId ? [documentId] : []),
      ...childDocumentIds,
    ];
    if (!documentIds.length) {
      return;
    }

    await this.destroy({
      where: {
        groupId: model.groupId,
        sourceId: model.id,
        documentId: {
          [Op.in]: documentIds,
        },
      },
      transaction,
    });

    await this.bulkCreate(
      documentIds.map((childDocumentId) => ({
        documentId: childDocumentId,
        groupId: model.groupId,
        permission: model.permission,
        sourceId: model.id,
        createdById: model.createdById,
        createdAt: model.createdAt,
        updatedAt: model.updatedAt,
      })),
      {
        transaction,
        hooks: false,
      }
    );
  }

  /**
   * Invalidates the cached document membership IDs of every user in the group,
   * as a document granted to a group is reachable by all of its members.
   */
  private async invalidateMembershipDocumentIds() {
    if (!this.documentId) {
      return;
    }

    const groupUsers = await GroupUser.findAll({
      attributes: ["userId"],
      where: { groupId: this.groupId },
    });

    await Document.invalidateMembershipDocumentIds(
      groupUsers.map((groupUser) => groupUser.userId)
    );
  }

  private async insertEvent(
    ctx: APIContext["context"],
    name: string,
    data: Record<string, unknown>
  ) {
    const hookContext = {
      ...ctx,
      event: { name, data, publish: true },
    } as HookContext;

    if (this.collectionId) {
      await Collection.insertEvent(name, this, hookContext);
    } else {
      await Document.insertEvent(name, this, hookContext);
    }
  }

  private static async validateLastAdminPermission(
    model: GroupMembership,
    { transaction }: APIContext["context"]
  ) {
    // Both models guard the same invariant, so they share one lock name,
    // otherwise the last user manager and the last group manager can be
    // removed at the same time.
    await LockHelper.acquire(
      model.sequelize,
      `collectionAdmins:${model.collectionId}`,
      transaction
    );

    const [userMemberships, groupMemberships] = await Promise.all([
      UserMembership.count({
        where: {
          collectionId: model.collectionId,
          permission: CollectionPermission.Admin,
        },
        transaction,
      }),
      this.count({
        where: {
          collectionId: model.collectionId,
          permission: CollectionPermission.Admin,
        },
        transaction,
      }),
    ]);

    if (userMemberships === 0 && groupMemberships === 1) {
      throw ValidationError(
        "At least one user or group must have manage permissions"
      );
    }
  }
}

export default GroupMembership;