import type { InferAttributes, InferCreationAttributes } from "sequelize"; import { Op, type SaveOptions, type FindOptions, type DestroyOptions, type WhereOptions, } 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 type { APIContext } from "@server/types"; import Collection from "./Collection"; import Document from "./Document"; import Group from "./Group"; import User from "./User"; import UserMembership from "./UserMembership"; import { type HookContext } from "./base/Model"; import ParanoidModel from "./base/ParanoidModel"; import Fix from "./decorators/Fix"; /** * 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" }) @Fix class GroupMembership extends ParanoidModel< InferAttributes, Partial> > { /** 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 /** * 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, 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 ): Promise { 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 & { documentId?: 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, }); } @AfterUpdate static async updateSourcedMemberships( model: GroupMembership, options: SaveOptions ) { 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 ) { 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, }); } /** * Recreate all sourced permissions for a given permission. */ static async recreateSourcedMemberships( model: GroupMembership, options: SaveOptions & { documentId?: string } ) { if (!model.documentId) { return; } const { transaction, documentId } = options; const document = await Document.unscoped() .scope("withoutState") .findOne({ attributes: ["id"], where: { id: documentId ?? model.documentId, }, transaction, }); if (!document) { return; } const childDocumentIds = [ ...(documentId ? [documentId] : []), ...(await document.findAllChildDocumentIds( { publishedAt: { [Op.ne]: null, }, }, { transaction, } )), ]; if (childDocumentIds.length) { await this.destroy({ where: { groupId: model.groupId, sourceId: model.id, documentId: { [Op.in]: childDocumentIds, }, }, transaction, }); } for (const childDocumentId of childDocumentIds) { await this.create( { documentId: childDocumentId, groupId: model.groupId, 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 ) { 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"] ) { 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;