Files
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

176 lines
4.8 KiB
TypeScript

import { compact, orderBy } from "es-toolkit/compat";
import type { WhereOptions } from "sequelize";
import { Op } from "sequelize";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import type { User } from "@server/models";
import {
Document,
Group,
GroupMembership,
UserMembership,
} from "@server/models";
import { authorize } from "@server/policies";
// Higher value takes precedence
export const CollectionPermissionPriority = {
[CollectionPermission.Admin]: 2,
[CollectionPermission.ReadWrite]: 1,
[CollectionPermission.Read]: 0,
} satisfies Record<CollectionPermission, number>;
// Higher value takes precedence
export const DocumentPermissionPriority = {
[DocumentPermission.Admin]: 2,
[DocumentPermission.ReadWrite]: 1,
[DocumentPermission.Read]: 0,
} satisfies Record<DocumentPermission, number>;
/**
* Check if the given user can access a document
*
* @param user - The user to check
* @param documentId - The document to check
* @returns Boolean whether the user can access the document
*/
export const canUserAccessDocument = async (user: User, documentId: string) => {
try {
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "read", document);
return true;
} catch (_err) {
return false;
}
};
/**
* Determines whether the user's access to a document is being elevated with the new permission.
*
* @param {Object} params Input parameters.
* @param {string} params.userId The user to check.
* @param {string} params.documentId The document to check.
* @param {DocumentPermission} params.permission The new permission given to the user.
* @param {string} params.skipMembershipId The membership to skip when comparing the existing permissions.
* @returns {boolean} Whether the user has a higher access level
*/
export const isElevatedPermission = async ({
userId,
documentId,
permission,
skipMembershipId,
}: {
userId: string;
documentId: string;
permission: DocumentPermission;
skipMembershipId?: string;
}) => {
const existingPermission = await getDocumentPermission({
userId,
documentId,
skipMembershipId,
});
if (!existingPermission) {
return true;
}
return (
DocumentPermissionPriority[existingPermission] <
DocumentPermissionPriority[permission]
);
};
/**
* Returns the user's permission to a document.
*
* @param {Object} params Input parameters.
* @param {string} params.userId The user to check.
* @param {string} params.documentId The document to check.
* @param {string} params.skipMembershipId The membership to skip when comparing the existing permissions.
* @returns {DocumentPermission | undefined} Highest permission, if it exists.
*/
export const getDocumentPermission = async ({
userId,
documentId,
skipMembershipId,
}: {
userId: string;
documentId: string;
skipMembershipId?: string;
}): Promise<DocumentPermission | undefined> => {
const document = await Document.findByPk(documentId, { userId });
const permissions: DocumentPermission[] = [];
const collection = document?.collection;
if (collection) {
const collectionPermissions = orderBy(
compact([
collection.permission,
...compact(
collection.memberships?.map(
(m) => m.permission as CollectionPermission
)
),
...compact(
collection.groupMemberships?.map(
(m) => m.permission as CollectionPermission
)
),
]),
(permission) => CollectionPermissionPriority[permission],
"desc"
);
if (collectionPermissions[0]) {
permissions.push(
collectionPermissions[0] === CollectionPermission.Read
? DocumentPermission.Read
: DocumentPermission.ReadWrite
);
}
}
const userMembershipWhere: WhereOptions<UserMembership> = {
userId,
documentId,
};
const groupMembershipWhere: WhereOptions<GroupMembership> = {
documentId,
};
if (skipMembershipId) {
userMembershipWhere.id = { [Op.ne]: skipMembershipId };
groupMembershipWhere.id = { [Op.ne]: skipMembershipId };
}
const [userMemberships, groupMemberships] = await Promise.all([
UserMembership.findAll({
where: userMembershipWhere,
}),
GroupMembership.findAll({
where: groupMembershipWhere,
include: [
{
model: Group.filterByMember(userId),
as: "group",
required: true,
},
],
}),
]);
permissions.push(
...userMemberships.map((m) => m.permission as DocumentPermission),
...groupMemberships.map((m) => m.permission as DocumentPermission)
);
const orderedPermissions = orderBy(
permissions,
(permission) => DocumentPermissionPriority[permission],
"desc"
);
return orderedPermissions[0];
};