Ctrl K
ComunidadGuías…

server/models/UserMembership.ts

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

Archivo original ↗Preguntar a KiBot
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import {
  Op,
  type CreateOptions,
  type FindOptions,
  type InstanceDestroyOptions,
  type SaveOptions,
  type Transaction,
} from "sequelize";
import type { WhereOptions } from "sequelize";
import {
  Column,
  ForeignKey,
  BelongsTo,
  Default,
  IsIn,
  Table,
  DataType,
  Scopes,
  AfterCreate,
  AfterUpdate,
  Length,
  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 { afterCommit } from "@server/storage/transaction";
import type { APIContext } from "@server/types";
import { CacheHelper } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import Collection from "./Collection";
import Document from "./Document";
import GroupMembership from "./GroupMembership";
import User from "./User";
import IdModel from "./base/IdModel";
import type { HookContext } from "./base/Model";

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

  /** The visible sort order in "shared with me" */
  @Length({
    max: 256,
    msg: `index must be 256 characters or less`,
  })
  @Column(DataType.STRING)
  index: string | null;

  // associations

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

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

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

  /** The document ID that this membership grants the user 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(() => UserMembership, "sourceId")
  source?: UserMembership | null;

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

  /** The user that this membership is granted to. */
  @BelongsTo(() => User, "userId")
  user: User;

  /** The user ID that this membership is granted to. */
  @ForeignKey(() => User)
  @Column(DataType.UUID)
  userId: 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;

  // static methods

  /**
   * Find the membership of a user 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 user 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: {
      userId: string;
      collectionId?: string | null;
      documentId?: string | null;
    },
    options: FindOptions<UserMembership> & { transaction: Transaction }
  ) {
    const { userId, collectionId, documentId } = where;
    const targetId = collectionId ?? documentId;
    await LockHelper.acquire(
      this.sequelize!,
      `userMembership:${targetId}:${userId}`.toLowerCase(),
      options.transaction
    );

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

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

  /**
   * Find the root membership for a document and (optionally) user.
   *
   * @param documentId The document ID to find the membership for.
   * @param userId The user 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 user, or null.
   */
  static async findRootMembershipsForDocument(
    documentId: string,
    userId?: string,
    options?: FindOptions<UserMembership>
  ): Promise<UserMembership[]> {
    const memberships = await this.findAll({
      where: {
        documentId,
        ...(userId ? { userId } : {}),
      },
    });

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

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

  // hooks

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

    return this.recreateSourcedMemberships(model, options);
  }

  @AfterCreate
  static async publishAddUserEventAfterCreate(
    model: UserMembership,
    context: APIContext["context"]
  ) {
    await model.insertEvent(context, "add_user", {
      isNew: true,
    });
  }

  @AfterCreate
  static async invalidateCollectionIdsAfterCreate(
    model: UserMembership,
    options: CreateOptions<UserMembership>
  ) {
    await this.invalidateCollectionIds(model, options);
  }

  @AfterCreate
  static async invalidateDocumentIdsAfterCreate(model: UserMembership) {
    if (model.documentId) {
      await Document.invalidateMembershipDocumentIds([model.userId]);
    }
  }

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

    const { transaction } = options;

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

  @BeforeUpdate
  static async checkLastAdminBeforeUpdate(
    model: UserMembership,
    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: UserMembership,
    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 publishAddUserEventAfterUpdate(
    model: UserMembership,
    context: APIContext["context"]
  ) {
    await model.insertEvent(context, "add_user", {
      isNew: false,
    });
  }

  @AfterDestroy
  static async publishRemoveUserEvent(
    model: UserMembership,
    context: APIContext["context"]
  ) {
    await model.insertEvent(context, "remove_user");
  }

  @AfterDestroy
  static async invalidateCollectionIdsAfterDestroy(
    model: UserMembership,
    options: InstanceDestroyOptions
  ) {
    await this.invalidateCollectionIds(model, options);
  }

  @AfterDestroy
  static async invalidateDocumentIdsAfterDestroy(model: UserMembership) {
    if (model.documentId) {
      await Document.invalidateMembershipDocumentIds([model.userId]);
    }
  }

  /**
   * 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: UserMembership,
    options: SaveOptions<UserMembership> & {
      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: {
        userId: model.userId,
        sourceId: model.id,
        documentId: {
          [Op.in]: documentIds,
        },
      },
      transaction,
    });

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

  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 invalidateCollectionIds(
    model: UserMembership,
    options: { transaction?: Transaction | null }
  ) {
    if (!model.collectionId) {
      return;
    }

    await afterCommit(options.transaction, () =>
      CacheHelper.removeData(
        RedisPrefixHelper.getUserCollectionIdsKey(model.userId)
      )
    );
  }

  private static async validateLastAdminPermission(
    model: UserMembership,
    { 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([
      this.count({
        where: {
          collectionId: model.collectionId,
          permission: CollectionPermission.Admin,
        },
        transaction,
      }),
      GroupMembership.count({
        where: {
          collectionId: model.collectionId,
          permission: CollectionPermission.Admin,
        },
        transaction,
      }),
    ]);

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

export default UserMembership;