mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user