Files
outline/server/models/Relationship.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

110 lines
2.8 KiB
TypeScript

import type { InferAttributes, InferCreationAttributes } from "sequelize";
import {
DataType,
BelongsTo,
ForeignKey,
Column,
Table,
} from "sequelize-typescript";
import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
export enum RelationshipType {
Backlink = "backlink",
Similar = "similar",
}
@Table({ tableName: "relationships", modelName: "relationship" })
@Fix
class Relationship extends IdModel<
InferAttributes<Relationship>,
Partial<InferCreationAttributes<Relationship>>
> {
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => Document, "reverseDocumentId")
reverseDocument: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
reverseDocumentId: string;
@Column({
type: DataType.ENUM(...Object.values(RelationshipType)),
allowNull: false,
defaultValue: RelationshipType.Backlink,
})
type: RelationshipType;
/**
* Find all backlinks for a document that the user has access to.
*
* @param documentId The document ID to find backlinks for.
* @param user The user to check access for.
* @deprecated
*/
public static async findSourceDocumentIdsForUser(
documentId: string,
user: User
) {
const relationships = await this.findAll({
attributes: ["reverseDocumentId"],
where: {
documentId,
type: RelationshipType.Backlink,
},
});
const documents = await Document.findByIds(
relationships.map((relationship) => relationship.reverseDocumentId),
{
attributes: ["id", "isPrivate", "collectionId"],
userId: user.id,
includeState: false,
includeViews: false,
}
);
return documents.map((doc) => doc.id);
}
/**
* Find all backlinks for a document that are within a shared tree.
*
* @param documentId The document ID to find backlinks for.
* @param allowedDocumentIds Array of document IDs that are accessible in the shared tree.
* @returns Array of document IDs that link to the target document and are within the shared tree.
*/
public static async findSourceDocumentIdsInSharedTree(
documentId: string,
allowedDocumentIds: string[]
) {
const relationships = await this.findAll({
attributes: ["reverseDocumentId"],
where: {
documentId,
type: RelationshipType.Backlink,
reverseDocumentId: allowedDocumentIds,
},
});
return relationships.map((relationship) => relationship.reverseDocumentId);
}
}
export default Relationship;