Files
outline/server/policies/document.ts
T
Tom Moor 5a4db980af Fix authorization gaps for restricted documents
- Tighten Document.findByIds so isPrivate filtering fails closed when
  the attribute is not loaded, and include isPrivate in the projection
  used by Relationship.findSourceDocumentIdsForUser so backlinks from
  restricted docs are no longer leaked to collection-only members.
- Add !isPrivate gate to the unpublish policy so collection writers
  without direct membership cannot unpublish restricted documents.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 09:00:31 -04:00

329 lines
8.0 KiB
TypeScript

import invariant from "invariant";
import { DocumentPermission, TeamPreference } from "@shared/types";
import { Document, Revision, User, Team } from "@server/models";
import { allow, can } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createDocument", Team, (actor, document) =>
and(
//
!actor.isGuest,
!actor.isViewer,
isTeamModel(actor, document),
isTeamMutable(actor)
)
);
allow(User, "read", Document, (actor, document) =>
and(
isTeamModel(actor, document),
or(
includesMembership(document, [
DocumentPermission.Read,
DocumentPermission.ReadWrite,
DocumentPermission.Admin,
]),
and(!!document?.isDraft, actor.id === document?.createdById),
isTeamAdmin(actor, document),
and(
!document?.isPrivate,
can(actor, "readDocument", document?.collection)
)
)
)
);
allow(User, ["listRevisions", "listViews"], Document, (actor, document) =>
or(
and(!actor.isGuest, can(actor, "read", document)),
and(actor.isGuest, can(actor, "update", document))
)
);
allow(User, "download", Document, (actor, document) =>
and(
can(actor, "read", document),
or(
and(!actor.isGuest, !actor.isViewer),
!!actor.team.getPreference(TeamPreference.ViewersCanExport)
)
)
);
allow(User, "comment", Document, (actor, document) =>
and(
!!document?.isActive,
isTeamMutable(actor),
// TODO: We'll introduce a separate permission for commenting
or(
and(!actor.isGuest, can(actor, "read", document)),
and(actor.isGuest, can(actor, "update", document))
),
or(!document?.collection, document?.collection?.commenting !== false)
)
);
allow(
User,
["star", "unstar", "subscribe", "unsubscribe"],
Document,
(actor, document) =>
and(
//
can(actor, "read", document)
)
);
allow(User, "share", Document, (actor, document) =>
and(
!!document?.isActive,
isTeamMutable(actor),
can(actor, "read", document),
or(!document?.collection, can(actor, "share", document?.collection))
)
);
allow(User, "update", Document, (actor, document) =>
and(
!!document?.isActive,
isTeamMutable(actor),
can(actor, "read", document),
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
DocumentPermission.Admin,
]),
and(isTeamAdmin(actor, document), can(actor, "read", document)),
and(!!document?.isDraft && actor.id === document?.createdById),
and(
!document?.isPrivate,
can(actor, "updateDocument", document?.collection)
)
)
)
);
allow(User, "publish", Document, (actor, document) =>
and(
//
!!document?.isDraft,
can(actor, "update", document)
)
);
allow(User, "manageUsers", Document, (actor, document) =>
and(
can(actor, "update", document),
or(
includesMembership(document, [DocumentPermission.Admin]),
and(isTeamAdmin(actor, document), can(actor, "read", document)),
and(
!document?.isPrivate,
can(actor, "updateDocument", document?.collection)
),
!!document?.isDraft && actor.id === document?.createdById
)
)
);
allow(User, "duplicate", Document, (actor, document) =>
and(
can(actor, "update", document),
or(
includesMembership(document, [DocumentPermission.Admin]),
and(isTeamAdmin(actor, document), can(actor, "read", document)),
and(
!document?.isPrivate,
can(actor, "updateDocument", document?.collection)
),
!!document?.isDraft && actor.id === document?.createdById
)
)
);
allow(User, "move", Document, (actor, document) =>
and(
can(actor, "update", document),
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
DocumentPermission.Admin,
]),
and(
!document?.isPrivate,
can(actor, "updateDocument", document?.collection)
),
and(!!document?.isDraft && actor.id === document?.createdById),
and(!!document?.isDraft && !document?.collection)
)
)
);
allow(User, "createChildDocument", Document, (actor, document) =>
and(
//
!document?.isDraft,
can(actor, "update", document)
)
);
allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
and(
!document?.isDraft,
!actor.isGuest,
can(actor, "update", document),
can(actor, "update", document?.collection)
)
);
allow(User, "pinToHome", Document, (actor, document) =>
and(
//
!document?.isDraft,
!!document?.isActive,
isTeamAdmin(actor, document),
isTeamMutable(actor)
)
);
allow(User, "delete", Document, (actor, document) =>
and(
!document?.isDeleted,
isTeamModel(actor, document),
isTeamMutable(actor),
or(
can(actor, "unarchive", document),
can(actor, "update", document),
and(!document?.collection, actor.id === document?.createdById)
)
)
);
allow(User, "restore", Document, (actor, document) =>
and(
!actor.isGuest,
!!document?.isDeleted,
isTeamModel(actor, document),
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
DocumentPermission.Admin,
]),
and(
!document?.isPrivate,
can(actor, "updateDocument", document?.collection)
),
and(!!document?.isDraft && actor.id === document?.createdById)
)
)
);
allow(User, "permanentDelete", Document, (actor, document) =>
and(
!actor.isGuest,
!!document?.isDeleted,
isTeamModel(actor, document),
isTeamAdmin(actor, document)
)
);
allow(User, "archive", Document, (actor, document) =>
and(
!document?.isDraft,
!!document?.isActive,
can(actor, "update", document),
or(
includesMembership(document, [DocumentPermission.Admin]),
and(isTeamAdmin(actor, document), can(actor, "read", document)),
and(
!document?.isPrivate,
can(actor, "updateDocument", document?.collection)
)
)
)
);
allow(User, "unarchive", Document, (actor, document) =>
and(
!document?.isDraft,
!document?.isDeleted,
!!document?.archivedAt,
can(actor, "read", document),
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
DocumentPermission.Admin,
]),
and(
!document?.isPrivate,
can(actor, "updateDocument", document?.collection)
),
and(!!document?.isDraft && actor.id === document?.createdById)
)
)
);
allow(
Document,
"restore",
Revision,
(document, revision) => document.id === revision?.documentId
);
allow(User, "unpublish", Document, (user, document) =>
and(
!!document,
!user.isGuest,
!user.isViewer,
!!document?.isActive,
!document?.isDraft,
user.teamId === document?.teamId,
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
DocumentPermission.Admin,
]),
and(isTeamAdmin(user, document), can(user, "read", document)),
and(
!document?.isPrivate,
can(user, "updateDocument", document?.collection)
)
)
)
);
function includesMembership(
document: Document | null,
permissions: DocumentPermission[]
) {
if (!document) {
return false;
}
invariant(
document.memberships,
"Development: document memberships should be preloaded, did you forget withMembership scope?"
);
invariant(
document.groupMemberships,
"Development: document groupMemberships should be preloaded, did you forget withMembership scope?"
);
const permissionSet = new Set(permissions);
const membershipIds: string[] = [];
for (const membership of document.memberships) {
if (permissionSet.has(membership.permission as DocumentPermission)) {
membershipIds.push(membership.id);
}
}
for (const membership of document.groupMemberships) {
if (permissionSet.has(membership.permission as DocumentPermission)) {
membershipIds.push(membership.id);
}
}
return membershipIds.length > 0 ? membershipIds : false;
}