chore: Rename GroupPermission -> GroupMembership (#7214)

* GroupPermission -> GroupMembership

* Add group membership source

* wip
This commit is contained in:
Tom Moor
2024-07-17 19:31:20 -04:00
committed by GitHub
parent e52719c38e
commit f675a04735
18 changed files with 396 additions and 123 deletions

View File

@@ -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",

View File

@@ -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<Props> {
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<Props> {
subscription: WebhookSubscription,
event: CollectionGroupEvent
): Promise<void> {
const model = await GroupPermission.scope([
const model = await GroupMembership.scope([
"withGroup",
"withCollection",
]).findOne({
@@ -581,6 +587,36 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
});
}
private async handleDocumentGroupEvent(
subscription: WebhookSubscription,
event: DocumentGroupEvent
): Promise<void> {
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

View File

@@ -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");
},
};

View File

@@ -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")

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<GroupMembership>,
Partial<InferCreationAttributes<GroupMembership>>
> {
@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<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)
: membership
)
);
return rootMemberships.filter(Boolean) as GroupMembership[];
}
@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: {
sourceId: model.id,
},
transaction,
}
);
}
}
@AfterCreate
static async createSourcedMemberships(
model: GroupMembership,
options: SaveOptions<GroupMembership>
) {
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<GroupMembership>
) {
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;

View File

@@ -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<GroupPermission>,
Partial<InferCreationAttributes<GroupPermission>>
> {
@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;

View File

@@ -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;

View File

@@ -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";

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<GroupPermission> = {
let where: WhereOptions<GroupMembership> = {
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,

View File

@@ -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,

View File

@@ -35,7 +35,7 @@ import type {
View,
Notification,
Share,
GroupPermission,
GroupMembership,
} from "./models";
export enum AuthenticationType {
@@ -246,7 +246,7 @@ export type CollectionUserEvent = BaseEvent<UserMembership> & {
};
};
export type CollectionGroupEvent = BaseEvent<GroupPermission> & {
export type CollectionGroupEvent = BaseEvent<GroupMembership> & {
name: "collections.add_group" | "collections.remove_group";
collectionId: string;
modelId: string;
@@ -265,6 +265,13 @@ export type DocumentUserEvent = BaseEvent<UserMembership> & {
};
};
export type DocumentGroupEvent = BaseEvent<GroupMembership> & {
name: "documents.add_group" | "documents.remove_group";
documentId: string;
modelId: string;
data: { name: string };
};
export type CollectionEvent = BaseEvent<Collection> &
(
| {
@@ -428,6 +435,7 @@ export type Event =
| AuthenticationProviderEvent
| DocumentEvent
| DocumentUserEvent
| DocumentGroupEvent
| PinEvent
| CommentEvent
| StarEvent

View File

@@ -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",