diff --git a/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx b/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx index 1315887e0c..d19a62442b 100644 --- a/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx +++ b/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx @@ -41,6 +41,8 @@ const WEBHOOK_EVENTS = { "documents.title_change", "documents.add_user", "documents.remove_user", + "documents.add_group", + "documents.remove_group", ], collections: [ "collections.create", diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index 405423b567..d3e6eb4450 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -20,7 +20,7 @@ import { View, Share, UserMembership, - GroupPermission, + GroupMembership, GroupUser, Comment, } from "@server/models"; @@ -42,6 +42,7 @@ import { presentCollectionGroupMembership, presentComment, } from "@server/presenters"; +import presentDocumentGroupMembership from "@server/presenters/documentGroupMembership"; import BaseTask from "@server/queues/tasks/BaseTask"; import { CollectionEvent, @@ -50,6 +51,7 @@ import { CommentEvent, DocumentEvent, DocumentUserEvent, + DocumentGroupEvent, Event, FileOperationEvent, GroupEvent, @@ -138,6 +140,10 @@ export default class DeliverWebhookTask extends BaseTask { case "documents.remove_user": await this.handleDocumentUserEvent(subscription, event); return; + case "documents.add_group": + case "documents.remove_group": + await this.handleDocumentGroupEvent(subscription, event); + return; case "documents.update.delayed": case "documents.update.debounced": // Ignored @@ -478,7 +484,7 @@ export default class DeliverWebhookTask extends BaseTask { subscription: WebhookSubscription, event: CollectionGroupEvent ): Promise { - const model = await GroupPermission.scope([ + const model = await GroupMembership.scope([ "withGroup", "withCollection", ]).findOne({ @@ -581,6 +587,36 @@ export default class DeliverWebhookTask extends BaseTask { }); } + private async handleDocumentGroupEvent( + subscription: WebhookSubscription, + event: DocumentGroupEvent + ): Promise { + const model = await GroupMembership.scope([ + "withGroup", + "withDocument", + ]).findOne({ + where: { + documentId: event.documentId, + groupId: event.modelId, + }, + paranoid: false, + }); + + const document = + model && (await presentDocument(undefined, model.document!)); + + await this.sendWebhook({ + event, + subscription, + payload: { + id: event.modelId, + model: model && presentDocumentGroupMembership(model), + document, + group: model && presentGroup(model.group), + }, + }); + } + private async handleRevisionEvent( subscription: WebhookSubscription, event: RevisionEvent diff --git a/server/migrations/20240709031512-group-permission-source-id.js b/server/migrations/20240709031512-group-permission-source-id.js new file mode 100644 index 0000000000..010f1b5b7a --- /dev/null +++ b/server/migrations/20240709031512-group-permission-source-id.js @@ -0,0 +1,33 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("group_permissions", "sourceId", { + type: Sequelize.UUID, + onDelete: "cascade", + references: { + model: "group_permissions", + }, + allowNull: true, + }); + + await queryInterface.removeConstraint("group_permissions", "group_permissions_documentId_fkey") + await queryInterface.changeColumn("group_permissions", "documentId", { + type: Sequelize.UUID, + onDelete: "cascade", + references: { + model: "documents", + }, + }); + }, + async down(queryInterface) { + await queryInterface.removeConstraint("group_permissions", "group_permissions_documentId_fkey") + await queryInterface.changeColumn("group_permissions", "documentId", { + type: Sequelize.UUID, + references: { + model: "documents", + }, + }); + await queryInterface.removeColumn("group_permissions", "sourceId"); + }, +}; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index e67d5c38e2..c8a604a5d2 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -42,7 +42,7 @@ import { ValidationError } from "@server/errors"; import Document from "./Document"; import FileOperation from "./FileOperation"; import Group from "./Group"; -import GroupPermission from "./GroupPermission"; +import GroupMembership from "./GroupMembership"; import GroupUser from "./GroupUser"; import Team from "./Team"; import User from "./User"; @@ -63,7 +63,7 @@ import NotContainsUrl from "./validators/NotContainsUrl"; required: false, }, { - model: GroupPermission, + model: GroupMembership, as: "collectionGroupMemberships", required: false, // use of "separate" property: sequelize breaks when there are @@ -110,7 +110,7 @@ import NotContainsUrl from "./validators/NotContainsUrl"; required: false, }, { - model: GroupPermission, + model: GroupMembership, as: "collectionGroupMemberships", required: false, // use of "separate" property: sequelize breaks when there are @@ -322,13 +322,13 @@ class Collection extends ParanoidModel< @HasMany(() => UserMembership, "collectionId") memberships: UserMembership[]; - @HasMany(() => GroupPermission, "collectionId") - collectionGroupMemberships: GroupPermission[]; + @HasMany(() => GroupMembership, "collectionId") + collectionGroupMemberships: GroupMembership[]; @BelongsToMany(() => User, () => UserMembership) users: User[]; - @BelongsToMany(() => Group, () => GroupPermission) + @BelongsToMany(() => Group, () => GroupMembership) groups: Group[]; @BelongsTo(() => User, "createdById") diff --git a/server/models/Group.ts b/server/models/Group.ts index 76f564c277..c321e72f1b 100644 --- a/server/models/Group.ts +++ b/server/models/Group.ts @@ -11,7 +11,7 @@ import { DataType, Scopes, } from "sequelize-typescript"; -import GroupPermission from "./GroupPermission"; +import GroupMembership from "./GroupMembership"; import GroupUser from "./GroupUser"; import Team from "./Team"; import User from "./User"; @@ -90,7 +90,7 @@ class Group extends ParanoidModel< groupId: model.id, }, }); - await GroupPermission.destroy({ + await GroupMembership.destroy({ where: { groupId: model.id, }, @@ -109,8 +109,8 @@ class Group extends ParanoidModel< @HasMany(() => GroupUser, { as: "members", foreignKey: "groupId" }) groupUsers: GroupUser[]; - @HasMany(() => GroupPermission, "groupId") - collectionGroupMemberships: GroupPermission[]; + @HasMany(() => GroupMembership, "groupId") + collectionGroupMemberships: GroupMembership[]; @BelongsTo(() => Team, "teamId") team: Team; diff --git a/server/models/GroupPermission.test.ts b/server/models/GroupMembership.test.ts similarity index 79% rename from server/models/GroupPermission.test.ts rename to server/models/GroupMembership.test.ts index d6d5f26fdf..79d0ec0e4a 100644 --- a/server/models/GroupPermission.test.ts +++ b/server/models/GroupMembership.test.ts @@ -1,20 +1,20 @@ import { buildCollection, buildGroup, buildUser } from "@server/test/factories"; -import GroupPermission from "./GroupPermission"; +import GroupMembership from "./GroupMembership"; -describe("GroupPermission", () => { +describe("GroupMembership", () => { describe("withCollection scope", () => { it("should return the collection", async () => { const collection = await buildCollection(); const group = await buildGroup(); const user = await buildUser({ teamId: group.teamId }); - await GroupPermission.create({ + await GroupMembership.create({ createdById: user.id, groupId: group.id, collectionId: collection.id, }); - const permission = await GroupPermission.scope("withCollection").findOne({ + const permission = await GroupMembership.scope("withCollection").findOne({ where: { groupId: group.id, collectionId: collection.id, diff --git a/server/models/GroupMembership.ts b/server/models/GroupMembership.ts new file mode 100644 index 0000000000..1d87e9c16e --- /dev/null +++ b/server/models/GroupMembership.ts @@ -0,0 +1,251 @@ +import { + InferAttributes, + InferCreationAttributes, + Op, + type SaveOptions, + type FindOptions, +} from "sequelize"; +import { + BelongsTo, + Column, + Default, + ForeignKey, + IsIn, + Table, + DataType, + Scopes, + AfterCreate, + AfterUpdate, +} from "sequelize-typescript"; +import { CollectionPermission, DocumentPermission } from "@shared/types"; +import Collection from "./Collection"; +import Document from "./Document"; +import Group from "./Group"; +import User from "./User"; +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> +> { + @Default(CollectionPermission.ReadWrite) + @IsIn([Object.values(CollectionPermission)]) + @Column(DataType.STRING) + permission: CollectionPermission | DocumentPermission; + + // associations + + /** The collection that this permission grants the group access to. */ + @BelongsTo(() => Collection, "collectionId") + collection?: Collection | null; + + /** The collection ID that this permission grants the group access to. */ + @ForeignKey(() => Collection) + @Column(DataType.UUID) + collectionId?: string | null; + + /** The document that this permission grants the group access to. */ + @BelongsTo(() => Document, "documentId") + document?: Document | null; + + /** The document ID that this permission grants the group access to. */ + @ForeignKey(() => Document) + @Column(DataType.UUID) + documentId?: string | null; + + /** If this represents the permission on a child then this points to the permission on the root */ + @BelongsTo(() => GroupMembership, "sourceId") + source?: GroupMembership | null; + + /** If this represents the permission on a child then this points to the permission on the root */ + @ForeignKey(() => GroupMembership) + @Column(DataType.UUID) + sourceId?: string | null; + + /** The group that this permission is granted to. */ + @BelongsTo(() => Group, "groupId") + group: Group; + + /** The group ID that this permission is granted to. */ + @ForeignKey(() => Group) + @Column(DataType.UUID) + groupId: string; + + /** The user that created this permission. */ + @BelongsTo(() => User, "createdById") + createdBy: User; + + /** The user ID that created this permission. */ + @ForeignKey(() => User) + @Column(DataType.UUID) + createdById: string; + + /** + * 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. + */ + 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) + : membership + ) + ); + + return rootMemberships.filter(Boolean) as GroupMembership[]; + } + + @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: { + sourceId: model.id, + }, + transaction, + } + ); + } + } + + @AfterCreate + static async createSourcedMemberships( + model: GroupMembership, + options: SaveOptions + ) { + if (model.sourceId || !model.documentId) { + return; + } + + return this.recreateSourcedMemberships(model, options); + } + + /** + * Recreate all sourced permissions for a given permission. + */ + static async recreateSourcedMemberships( + model: GroupMembership, + options: SaveOptions + ) { + if (!model.documentId) { + return; + } + const { transaction } = options; + + await this.destroy({ + where: { + sourceId: model.id, + }, + transaction, + }); + + const document = await Document.unscoped().findOne({ + attributes: ["id"], + where: { + id: model.documentId, + }, + transaction, + }); + if (!document) { + return; + } + + const childDocumentIds = await document.findAllChildDocumentIds( + { + publishedAt: { + [Op.ne]: null, + }, + }, + { + 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, + } + ); + } + } +} + +export default GroupMembership; diff --git a/server/models/GroupPermission.ts b/server/models/GroupPermission.ts deleted file mode 100644 index 802eadd884..0000000000 --- a/server/models/GroupPermission.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { InferAttributes, InferCreationAttributes, Op } from "sequelize"; -import { - BelongsTo, - Column, - Default, - ForeignKey, - IsIn, - Table, - DataType, - Scopes, -} from "sequelize-typescript"; -import { CollectionPermission } from "@shared/types"; -import Collection from "./Collection"; -import Document from "./Document"; -import Group from "./Group"; -import User from "./User"; -import ParanoidModel from "./base/ParanoidModel"; -import Fix from "./decorators/Fix"; - -@Scopes(() => ({ - withGroup: { - include: [ - { - association: "group", - }, - ], - }, - withCollection: { - where: { - collectionId: { - [Op.ne]: null, - }, - }, - include: [ - { - association: "collection", - }, - ], - }, -})) -@Table({ tableName: "group_permissions", modelName: "group_permission" }) -@Fix -class GroupPermission extends ParanoidModel< - InferAttributes, - Partial> -> { - @Default(CollectionPermission.ReadWrite) - @IsIn([Object.values(CollectionPermission)]) - @Column(DataType.STRING) - permission: CollectionPermission; - - // associations - - @BelongsTo(() => Collection, "collectionId") - collection?: Collection | null; - - @ForeignKey(() => Collection) - @Column(DataType.UUID) - collectionId?: string | null; - - @BelongsTo(() => Document, "documentId") - document?: Document | null; - - @ForeignKey(() => Document) - @Column(DataType.UUID) - documentId?: string | null; - - @BelongsTo(() => Group, "groupId") - group: Group; - - @ForeignKey(() => Group) - @Column(DataType.UUID) - groupId: string; - - @BelongsTo(() => User, "createdById") - createdBy: User; - - @ForeignKey(() => User) - @Column(DataType.UUID) - createdById: string; -} - -export default GroupPermission; diff --git a/server/models/UserMembership.ts b/server/models/UserMembership.ts index f5fc435716..d97f23b920 100644 --- a/server/models/UserMembership.ts +++ b/server/models/UserMembership.ts @@ -25,6 +25,9 @@ import User from "./User"; import IdModel from "./base/IdModel"; import Fix from "./decorators/Fix"; +/** + * Represents a users's permission to access a collection or document. + */ @Scopes(() => ({ withUser: { include: [ @@ -69,6 +72,7 @@ class UserMembership extends IdModel< @Column(DataType.STRING) permission: CollectionPermission | DocumentPermission; + /** The visible sort order in "shared with me" */ @AllowNull @Column index: string | null; diff --git a/server/models/index.ts b/server/models/index.ts index 7ec3fc696c..6679ebe796 100644 --- a/server/models/index.ts +++ b/server/models/index.ts @@ -8,7 +8,7 @@ export { default as Backlink } from "./Backlink"; export { default as Collection } from "./Collection"; -export { default as GroupPermission } from "./GroupPermission"; +export { default as GroupMembership } from "./GroupMembership"; export { default as UserMembership } from "./UserMembership"; diff --git a/server/presenters/collectionGroupMembership.ts b/server/presenters/collectionGroupMembership.ts index 065d2d41e1..20831a551c 100644 --- a/server/presenters/collectionGroupMembership.ts +++ b/server/presenters/collectionGroupMembership.ts @@ -1,5 +1,5 @@ import { CollectionPermission } from "@shared/types"; -import { GroupPermission } from "@server/models"; +import { GroupMembership } from "@server/models"; type Membership = { id: string; @@ -9,12 +9,12 @@ type Membership = { }; export default function presentCollectionGroupMembership( - membership: GroupPermission + membership: GroupMembership ): Membership { return { id: membership.id, groupId: membership.groupId, collectionId: membership.collectionId, - permission: membership.permission, + permission: membership.permission as CollectionPermission, }; } diff --git a/server/presenters/documentGroupMembership.ts b/server/presenters/documentGroupMembership.ts new file mode 100644 index 0000000000..f062d1309f --- /dev/null +++ b/server/presenters/documentGroupMembership.ts @@ -0,0 +1,20 @@ +import { DocumentPermission } from "@shared/types"; +import { GroupMembership } from "@server/models"; + +type Membership = { + id: string; + groupId: string; + documentId?: string | null; + permission: DocumentPermission; +}; + +export default function presentDocumentGroupMembership( + membership: GroupMembership +): Membership { + return { + id: membership.id, + groupId: membership.groupId, + documentId: membership.documentId, + permission: membership.permission as DocumentPermission, + }; +} diff --git a/server/queues/processors/WebsocketsProcessor.ts b/server/queues/processors/WebsocketsProcessor.ts index 4c304f99f3..02284f7bcc 100644 --- a/server/queues/processors/WebsocketsProcessor.ts +++ b/server/queues/processors/WebsocketsProcessor.ts @@ -7,7 +7,7 @@ import { Collection, FileOperation, Group, - GroupPermission, + GroupMembership, GroupUser, Pin, Star, @@ -487,7 +487,7 @@ export default class WebsocketsProcessor { case "groups.add_user": { // do an add user for every collection that the group is a part of - const collectionGroupMemberships = await GroupPermission.scope( + const collectionGroupMemberships = await GroupMembership.scope( "withCollection" ).findAll({ where: { @@ -522,7 +522,7 @@ export default class WebsocketsProcessor { } case "groups.remove_user": { - const collectionGroupMemberships = await GroupPermission.scope( + const collectionGroupMemberships = await GroupMembership.scope( "withCollection" ).findAll({ where: { @@ -593,7 +593,7 @@ export default class WebsocketsProcessor { }, }, }); - const collectionGroupMemberships = await GroupPermission.scope( + const collectionGroupMemberships = await GroupMembership.scope( "withCollection" ).findAll({ paranoid: false, diff --git a/server/routes/api/collections/collections.test.ts b/server/routes/api/collections/collections.test.ts index 58dc0841bd..a7a531ead8 100644 --- a/server/routes/api/collections/collections.test.ts +++ b/server/routes/api/collections/collections.test.ts @@ -1,5 +1,5 @@ import { CollectionPermission } from "@shared/types"; -import { Document, UserMembership, GroupPermission } from "@server/models"; +import { Document, UserMembership, GroupMembership } from "@server/models"; import { buildUser, buildAdmin, @@ -809,7 +809,7 @@ describe("#collections.group_memberships", () => { userId: user.id, permission: CollectionPermission.ReadWrite, }); - await GroupPermission.create({ + await GroupMembership.create({ createdById: user.id, collectionId: collection.id, groupId: group.id, @@ -853,13 +853,13 @@ describe("#collections.group_memberships", () => { userId: user.id, permission: CollectionPermission.ReadWrite, }); - await GroupPermission.create({ + await GroupMembership.create({ createdById: user.id, collectionId: collection.id, groupId: group.id, permission: CollectionPermission.ReadWrite, }); - await GroupPermission.create({ + await GroupMembership.create({ createdById: user.id, collectionId: collection.id, groupId: group2.id, @@ -896,13 +896,13 @@ describe("#collections.group_memberships", () => { userId: user.id, permission: CollectionPermission.ReadWrite, }); - await GroupPermission.create({ + await GroupMembership.create({ createdById: user.id, collectionId: collection.id, groupId: group.id, permission: CollectionPermission.ReadWrite, }); - await GroupPermission.create({ + await GroupMembership.create({ createdById: user.id, collectionId: collection.id, groupId: group2.id, diff --git a/server/routes/api/collections/collections.ts b/server/routes/api/collections/collections.ts index c160aa48e7..2230ebb2fa 100644 --- a/server/routes/api/collections/collections.ts +++ b/server/routes/api/collections/collections.ts @@ -18,7 +18,7 @@ import validate from "@server/middlewares/validate"; import { Collection, UserMembership, - GroupPermission, + GroupMembership, Team, Event, User, @@ -242,7 +242,7 @@ router.post( const group = await Group.findByPk(groupId); authorize(user, "read", group); - let membership = await GroupPermission.findOne({ + let membership = await GroupMembership.findOne({ where: { collectionId: id, groupId, @@ -250,7 +250,7 @@ router.post( }); if (!membership) { - membership = await GroupPermission.create({ + membership = await GroupMembership.create({ collectionId: id, groupId, permission, @@ -343,7 +343,7 @@ router.post( }).findByPk(id); authorize(user, "read", collection); - let where: WhereOptions = { + let where: WhereOptions = { collectionId: id, }; let groupWhere; @@ -373,8 +373,8 @@ router.post( }; const [total, memberships] = await Promise.all([ - GroupPermission.count(options), - GroupPermission.findAll({ + GroupMembership.count(options), + GroupMembership.findAll({ ...options, order: [["createdAt", "DESC"]], offset: ctx.state.pagination.offset, diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index d738cc1d79..5955df194d 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -15,7 +15,7 @@ import { SearchQuery, Event, User, - GroupPermission, + GroupMembership, } from "@server/models"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; import { @@ -4096,7 +4096,7 @@ describe("#documents.users", () => { permission: CollectionPermission.Read, createdById: user.id, }), - GroupPermission.create({ + GroupMembership.create({ collectionId: collection.id, groupId: group.id, permission: CollectionPermission.ReadWrite, diff --git a/server/types.ts b/server/types.ts index bdecbc31c8..63bd26c351 100644 --- a/server/types.ts +++ b/server/types.ts @@ -35,7 +35,7 @@ import type { View, Notification, Share, - GroupPermission, + GroupMembership, } from "./models"; export enum AuthenticationType { @@ -246,7 +246,7 @@ export type CollectionUserEvent = BaseEvent & { }; }; -export type CollectionGroupEvent = BaseEvent & { +export type CollectionGroupEvent = BaseEvent & { name: "collections.add_group" | "collections.remove_group"; collectionId: string; modelId: string; @@ -265,6 +265,13 @@ export type DocumentUserEvent = BaseEvent & { }; }; +export type DocumentGroupEvent = BaseEvent & { + name: "documents.add_group" | "documents.remove_group"; + documentId: string; + modelId: string; + data: { name: string }; +}; + export type CollectionEvent = BaseEvent & ( | { @@ -428,6 +435,7 @@ export type Event = | AuthenticationProviderEvent | DocumentEvent | DocumentUserEvent + | DocumentGroupEvent | PinEvent | CommentEvent | StarEvent diff --git a/shared/utils/EventHelper.ts b/shared/utils/EventHelper.ts index 0bc05e15dd..ce488355ec 100644 --- a/shared/utils/EventHelper.ts +++ b/shared/utils/EventHelper.ts @@ -46,6 +46,8 @@ export class EventHelper { "documents.restore", "documents.add_user", "documents.remove_user", + "documents.add_group", + "documents.remove_group", "groups.create", "groups.update", "groups.delete",