server/models/UserMembership.ts
Rama main · réplica de lectura del repositorio original.
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;