Apply parent-doc membership escape to filters DSL

Resolve the targeted parentDocumentId from either the legacy top-level
param or the new filters DSL so the membership escape applies in both
cases. Adds a regression test covering a private-collection parent with
direct UserMembership.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-04-28 07:13:35 -04:00
parent a337429a1f
commit fcf4a24444
2 changed files with 53 additions and 4 deletions
@@ -1341,6 +1341,44 @@ describe("#documents.list", () => {
});
expect(res.status).toEqual(400);
});
it("should apply parent-doc membership escape for parentDocumentId in filters", async () => {
const user = await buildUser();
const otherUser = await buildUser({ teamId: user.teamId });
const privateCollection = await buildCollection({
teamId: user.teamId,
userId: otherUser.id,
permission: null,
});
const parent = await buildDocument({
teamId: user.teamId,
userId: otherUser.id,
collectionId: privateCollection.id,
});
const child = await buildDocument({
teamId: user.teamId,
userId: otherUser.id,
collectionId: privateCollection.id,
parentDocumentId: parent.id,
});
await UserMembership.create({
createdById: otherUser.id,
documentId: parent.id,
userId: user.id,
permission: DocumentPermission.Read,
});
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
filters: [
{ field: "parentDocumentId", operator: "eq", value: parent.id },
],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.map((d: { id: string }) => d.id)).toEqual([child.id]);
});
});
it("should require authentication", async () => {
+15 -4
View File
@@ -144,15 +144,26 @@ router.post(
],
};
// Resolve the parent document being targeted from either the legacy
// top-level param or the filters DSL, so the membership escape below
// applies in both cases. `isNull` leaves resolve to undefined here
// (no specific parent to authorize against).
const normalizedFilter = combineFilters(rawFilters);
const parentDocumentId =
legacyParentDocumentId ??
(normalizedFilter
? extractTopLevelEqValue(normalizedFilter, "parentDocumentId")
: undefined);
// Membership escape: if the caller is filtering by a parent document they
// are a direct member of (or have group membership to), bypass the default
// collection access check. Mirrors the prior behavior of pushing then
// removing the legacy collectionId predicate.
let collectionScopeDropped = false;
if (legacyParentDocumentId) {
if (parentDocumentId) {
const [groupMembership, membership] = await Promise.all([
GroupMembership.findOne({
where: { documentId: legacyParentDocumentId },
where: { documentId: parentDocumentId },
include: [
{
model: Group,
@@ -168,7 +179,7 @@ router.post(
],
}),
UserMembership.findOne({
where: { userId: user.id, documentId: legacyParentDocumentId },
where: { userId: user.id, documentId: parentDocumentId },
}),
]);
@@ -181,7 +192,7 @@ router.post(
// The schema rejects callers that combine `filters` with the deprecated
// top-level params, so exactly one of these is set.
const filter =
combineFilters(rawFilters) ??
normalizedFilter ??
legacyParamsToFilter({
userId: legacyUserId,
collectionId: legacyCollectionId,