From e2f270486360427492366e2ed674c6cf136716fb Mon Sep 17 00:00:00 2001 From: asouqi Date: Mon, 13 Apr 2026 00:36:32 +0300 Subject: [PATCH 1/6] Prevent users from manipulating comments (LEAN-5385) --- src/schema/nodes/comment.ts | 26 ++++++++++++++++++++++++-- src/schema/types.ts | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/schema/nodes/comment.ts b/src/schema/nodes/comment.ts index a2f6a37a..874bf27c 100644 --- a/src/schema/nodes/comment.ts +++ b/src/schema/nodes/comment.ts @@ -16,7 +16,7 @@ import { NodeSpec } from 'prosemirror-model' -import { ManuscriptNode } from '../types' +import {ManuscriptNode, NodeAccessPolicy} from '../types' export interface CommentAttrs { id: string @@ -33,7 +33,7 @@ export interface CommentNode extends ManuscriptNode { attrs: CommentAttrs } -export const comment: NodeSpec = { +export const comment: NodeSpec & NodeAccessPolicy = { attrs: { id: { default: '' }, contents: { default: '' }, @@ -44,6 +44,28 @@ export const comment: NodeSpec = { timestamp: { default: 0 }, originalText: { default: '' }, }, + canInsertNode(_, context) { + return context.capabilities.createComment + }, + canDeleteNode(node, context) { + const isOwn = node.attrs.userID === context.userId + return isOwn ? context.capabilities.handleOwnComments + : context.capabilities.handleOthersComments + }, + canEditAttr(node, attr, context): boolean { + const isOwn = node.attrs.userID === context.userId + + if (attr === 'contents') { + return isOwn + ? context.capabilities.handleOwnComments + : context.capabilities.handleOthersComments + } else if (attr === 'resolved') { + return isOwn + ? context.capabilities.resolveOwnComment + : context.capabilities.resolveOthersComment + } + return false + } } export const isCommentNode = (node: ManuscriptNode): node is CommentNode => diff --git a/src/schema/types.ts b/src/schema/types.ts index 3ca07389..6a01684c 100644 --- a/src/schema/types.ts +++ b/src/schema/types.ts @@ -222,3 +222,22 @@ export function isNodeOfType( ): node is T { return node.type === type } + +export type Capabilities = { + handleOwnComments: boolean + resolveOwnComment: boolean + handleOthersComments: boolean + resolveOthersComment: boolean + createComment: boolean +} + +export interface AccessContext { + userId: string + capabilities: Capabilities +} + +export interface NodeAccessPolicy { + canEditAttr(node: ManuscriptNode, attr: string, context: AccessContext): boolean + canInsertNode(node: ManuscriptNode, context: AccessContext): boolean + canDeleteNode(node: ManuscriptNode, context: AccessContext): boolean +} From 26a3317f2857553858ee3c90f80dc0bb7e2a65da Mon Sep 17 00:00:00 2001 From: asouqi Date: Tue, 21 Apr 2026 20:19:27 +0300 Subject: [PATCH 2/6] review update --- src/lib/access-control.ts | 128 ++++++++++++++++++++++++++++++++++++ src/schema/nodes/comment.ts | 26 +------- src/schema/types.ts | 12 ++-- 3 files changed, 137 insertions(+), 29 deletions(-) create mode 100644 src/lib/access-control.ts diff --git a/src/lib/access-control.ts b/src/lib/access-control.ts new file mode 100644 index 00000000..bccb261a --- /dev/null +++ b/src/lib/access-control.ts @@ -0,0 +1,128 @@ +import {AccessContext, AccessRule, ManuscriptNode, Nodes} from "../schema" + +const hasDataTracked = (node: ManuscriptNode): boolean => { + const dataTracked = node.attrs.dataTracked + return Array.isArray(dataTracked) ? dataTracked.length > 0 : !!dataTracked +} + +const getDataTrackedAuthorID = (node: ManuscriptNode): string | null => { + // TODO:: find authorID of the change + return `dataTracked?.authorID` +} + +const canHandleTrackedNode = ( + node: ManuscriptNode, + context: AccessContext +): boolean => { + const authorID = getDataTrackedAuthorID(node) + if (!authorID) return true + + const isOwnChange = authorID === context.userId + + return isOwnChange + ? context.capabilities.handleSuggestion || + context.capabilities.rejectOwnSuggestion + : context.capabilities.handleSuggestion +} + + +/** LEVEL 1: Global Defaults **/ +const globalDefaults = { + insert: (_: ManuscriptNode | null, ctx: AccessContext) => + ctx.capabilities.editArticle, + delete: (_: ManuscriptNode, ctx: AccessContext) => + ctx.capabilities.editArticle, + attr: (_: ManuscriptNode, ctx: AccessContext) => + ctx.capabilities.editArticle, +} + +type NodeRules = { + insert?: AccessRule + delete?: AccessRule + attr?: AccessRule +} + +/** LEVEL 2: Node-level Rules **/ +const nodeRules: Partial> = { + comment: { + insert: (_, ctx) => ctx.capabilities.createComment, + delete: (node, ctx) => { + const isOwn = node.attrs.userID === ctx.userId + return isOwn + ? ctx.capabilities.handleOwnComments + : ctx.capabilities.handleOthersComments + }, + attr: () => false, + }, +} + +/** LEVEL 3: Node + Attribute Rules **/ +const nodeAttrRules: Partial>> = { + comment: { + contents: (node, ctx) => { + const isOwn = node.attrs.userID === ctx.userId + return isOwn + ? ctx.capabilities.handleOwnComments + : ctx.capabilities.handleOthersComments + }, + resolved: (node, ctx) => { + const isOwn = node.attrs.userID === ctx.userId + return isOwn + ? ctx.capabilities.resolveOwnComment + : ctx.capabilities.resolveOthersComment + }, + }, +} + +export const canInsertNode = ( + nodeType: Nodes, + context: AccessContext +): boolean => { + const rule = nodeRules[nodeType]?.insert ?? globalDefaults.insert + return rule(null as unknown as ManuscriptNode, context) +} + +export const canDeleteNode = ( + nodeType: Nodes, + node: ManuscriptNode, + context: AccessContext +): boolean => { + const rule = nodeRules[nodeType]?.delete ?? globalDefaults.delete + const hasBasePermission = rule(node, context) + + if (hasDataTracked(node)) { + return hasBasePermission && canHandleTrackedNode(node, context) + } + + return hasBasePermission +} + +export const canEditAttr = ( + nodeType: Nodes, + node: ManuscriptNode, + attr: string, + context: AccessContext +): boolean => { + let hasBasePermission: boolean + + // Level 3: Node + Attribute specific + const nodeAttrRule = nodeAttrRules[nodeType]?.[attr] + if (nodeAttrRule) { + hasBasePermission = nodeAttrRule(node, context) + } else { + // Level 2: Node default + const nodeRule = nodeRules[nodeType]?.attr + if (nodeRule) { + hasBasePermission = nodeRule(node, context) + } else { + // Level 1: Global default + hasBasePermission = globalDefaults.attr(node, context) + } + } + + if (hasDataTracked(node)) { + return hasBasePermission && canHandleTrackedNode(node, context) + } + + return hasBasePermission +} diff --git a/src/schema/nodes/comment.ts b/src/schema/nodes/comment.ts index 874bf27c..a2f6a37a 100644 --- a/src/schema/nodes/comment.ts +++ b/src/schema/nodes/comment.ts @@ -16,7 +16,7 @@ import { NodeSpec } from 'prosemirror-model' -import {ManuscriptNode, NodeAccessPolicy} from '../types' +import { ManuscriptNode } from '../types' export interface CommentAttrs { id: string @@ -33,7 +33,7 @@ export interface CommentNode extends ManuscriptNode { attrs: CommentAttrs } -export const comment: NodeSpec & NodeAccessPolicy = { +export const comment: NodeSpec = { attrs: { id: { default: '' }, contents: { default: '' }, @@ -44,28 +44,6 @@ export const comment: NodeSpec & NodeAccessPolicy = { timestamp: { default: 0 }, originalText: { default: '' }, }, - canInsertNode(_, context) { - return context.capabilities.createComment - }, - canDeleteNode(node, context) { - const isOwn = node.attrs.userID === context.userId - return isOwn ? context.capabilities.handleOwnComments - : context.capabilities.handleOthersComments - }, - canEditAttr(node, attr, context): boolean { - const isOwn = node.attrs.userID === context.userId - - if (attr === 'contents') { - return isOwn - ? context.capabilities.handleOwnComments - : context.capabilities.handleOthersComments - } else if (attr === 'resolved') { - return isOwn - ? context.capabilities.resolveOwnComment - : context.capabilities.resolveOthersComment - } - return false - } } export const isCommentNode = (node: ManuscriptNode): node is CommentNode => diff --git a/src/schema/types.ts b/src/schema/types.ts index 6a01684c..0ca940c6 100644 --- a/src/schema/types.ts +++ b/src/schema/types.ts @@ -224,11 +224,14 @@ export function isNodeOfType( } export type Capabilities = { + handleSuggestion: boolean + rejectOwnSuggestion: boolean handleOwnComments: boolean resolveOwnComment: boolean handleOthersComments: boolean resolveOthersComment: boolean createComment: boolean + editArticle: boolean } export interface AccessContext { @@ -236,8 +239,7 @@ export interface AccessContext { capabilities: Capabilities } -export interface NodeAccessPolicy { - canEditAttr(node: ManuscriptNode, attr: string, context: AccessContext): boolean - canInsertNode(node: ManuscriptNode, context: AccessContext): boolean - canDeleteNode(node: ManuscriptNode, context: AccessContext): boolean -} +export type AccessRule = ( + node: ManuscriptNode, + context: AccessContext +) => boolean From 0270054810c03341a4fa074a6d399510ac21ba1a Mon Sep 17 00:00:00 2001 From: asouqi Date: Wed, 17 Jun 2026 16:32:46 +0300 Subject: [PATCH 3/6] review update --- src/index.ts | 1 + src/lib/access-control.ts | 183 +++++++++++++++----------------------- 2 files changed, 73 insertions(+), 111 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9f59d07d..87c926e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export * from './lib/utils' export * from './lib/section-categories' export { CreditVocabTerm } from './lib/credit-roles' export * from './lib/citeproc' +export * from './lib/access-control' export * from './schema' export { migrateFor } from './schema/migration/migrate' export * from './transformer' diff --git a/src/lib/access-control.ts b/src/lib/access-control.ts index bccb261a..a407d497 100644 --- a/src/lib/access-control.ts +++ b/src/lib/access-control.ts @@ -1,128 +1,89 @@ -import {AccessContext, AccessRule, ManuscriptNode, Nodes} from "../schema" - -const hasDataTracked = (node: ManuscriptNode): boolean => { - const dataTracked = node.attrs.dataTracked - return Array.isArray(dataTracked) ? dataTracked.length > 0 : !!dataTracked -} - -const getDataTrackedAuthorID = (node: ManuscriptNode): string | null => { - // TODO:: find authorID of the change - return `dataTracked?.authorID` -} - -const canHandleTrackedNode = ( - node: ManuscriptNode, - context: AccessContext -): boolean => { - const authorID = getDataTrackedAuthorID(node) - if (!authorID) return true - - const isOwnChange = authorID === context.userId - - return isOwnChange - ? context.capabilities.handleSuggestion || - context.capabilities.rejectOwnSuggestion - : context.capabilities.handleSuggestion +/*! + * © 2026 Atypon Systems LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NodeType } from 'prosemirror-model' + +import { CommentNode, ManuscriptActions, ManuscriptNode } from '../schema' + +/** + * Attribute-Based Access Control polices for manuscript nodes. + * used by manuscript-api to evaluate incoming ProseMirror steps against these policies. + */ + +export type NodeAccessSubject = { + userId: string + actions: Record } - -/** LEVEL 1: Global Defaults **/ -const globalDefaults = { - insert: (_: ManuscriptNode | null, ctx: AccessContext) => - ctx.capabilities.editArticle, - delete: (_: ManuscriptNode, ctx: AccessContext) => - ctx.capabilities.editArticle, - attr: (_: ManuscriptNode, ctx: AccessContext) => - ctx.capabilities.editArticle, +/** Nodes with restricted access */ +type ProtectedResources = { + comment: CommentNode } -type NodeRules = { - insert?: AccessRule - delete?: AccessRule - attr?: AccessRule +/** A rule that evaluates whether a subject can perform an operation on a specific node */ +type NodeRule = ( + node: T, + subject: NodeAccessSubject +) => boolean + +/** Policy for a single node type */ +type NodePolicy = { + /** can subject add this node */ + insert?: NodeRule + /** can subject delete this node */ + delete?: NodeRule + /** can subject modify attributes: + * - As an object per-attribute rules + * - As a function one rule applied to all attribute changes*/ + attrs?: Partial>> | NodeRule } -/** LEVEL 2: Node-level Rules **/ -const nodeRules: Partial> = { - comment: { - insert: (_, ctx) => ctx.capabilities.createComment, - delete: (node, ctx) => { - const isOwn = node.attrs.userID === ctx.userId - return isOwn - ? ctx.capabilities.handleOwnComments - : ctx.capabilities.handleOthersComments - }, - attr: () => false, - }, +type NodesPolicy = { + [K in keyof ProtectedResources]?: NodePolicy } -/** LEVEL 3: Node + Attribute Rules **/ -const nodeAttrRules: Partial>> = { +const nodesPolicy: NodesPolicy = { comment: { - contents: (node, ctx) => { - const isOwn = node.attrs.userID === ctx.userId + insert: (_, subject) => subject.actions.createComment, + delete: (node, subject) => { + const isOwn = node.attrs.userID === subject.userId return isOwn - ? ctx.capabilities.handleOwnComments - : ctx.capabilities.handleOthersComments + ? subject.actions.handleOwnComments + : subject.actions.handleOthersComments }, - resolved: (node, ctx) => { - const isOwn = node.attrs.userID === ctx.userId - return isOwn - ? ctx.capabilities.resolveOwnComment - : ctx.capabilities.resolveOthersComment + attrs: { + contents: (node, subject) => { + const isOwn = node.attrs.userID === subject.userId + return isOwn + ? subject.actions.handleOwnComments + : subject.actions.handleOthersComments + }, + resolved: (node, subject) => { + const isOwn = node.attrs.userID === subject.userId + return isOwn + ? subject.actions.resolveOwnComment + : subject.actions.resolveOthersComment + }, }, }, } -export const canInsertNode = ( - nodeType: Nodes, - context: AccessContext -): boolean => { - const rule = nodeRules[nodeType]?.insert ?? globalDefaults.insert - return rule(null as unknown as ManuscriptNode, context) -} - -export const canDeleteNode = ( - nodeType: Nodes, - node: ManuscriptNode, - context: AccessContext -): boolean => { - const rule = nodeRules[nodeType]?.delete ?? globalDefaults.delete - const hasBasePermission = rule(node, context) - - if (hasDataTracked(node)) { - return hasBasePermission && canHandleTrackedNode(node, context) - } - - return hasBasePermission -} - -export const canEditAttr = ( - nodeType: Nodes, - node: ManuscriptNode, - attr: string, - context: AccessContext -): boolean => { - let hasBasePermission: boolean - - // Level 3: Node + Attribute specific - const nodeAttrRule = nodeAttrRules[nodeType]?.[attr] - if (nodeAttrRule) { - hasBasePermission = nodeAttrRule(node, context) - } else { - // Level 2: Node default - const nodeRule = nodeRules[nodeType]?.attr - if (nodeRule) { - hasBasePermission = nodeRule(node, context) - } else { - // Level 1: Global default - hasBasePermission = globalDefaults.attr(node, context) - } +export function getNodeAccessPolicy(nodeType: NodeType) { + if (nodeType.name in nodesPolicy) { + return nodesPolicy[ + nodeType.name as keyof NodesPolicy + ] as NodePolicy } - - if (hasDataTracked(node)) { - return hasBasePermission && canHandleTrackedNode(node, context) - } - - return hasBasePermission } From 51ca2a2859984b66b1b1a979edccc3be494e7582 Mon Sep 17 00:00:00 2001 From: asouqi Date: Wed, 17 Jun 2026 17:05:29 +0300 Subject: [PATCH 4/6] review update --- src/lib/access-control.ts | 32 ++++++++++++++++---------------- src/schema/types.ts | 21 --------------------- 2 files changed, 16 insertions(+), 37 deletions(-) diff --git a/src/lib/access-control.ts b/src/lib/access-control.ts index a407d497..ce0c0090 100644 --- a/src/lib/access-control.ts +++ b/src/lib/access-control.ts @@ -18,11 +18,11 @@ import { NodeType } from 'prosemirror-model' import { CommentNode, ManuscriptActions, ManuscriptNode } from '../schema' /** - * Attribute-Based Access Control polices for manuscript nodes. + * Attribute-Based Access Control (ABAC) polices for manuscript nodes. * used by manuscript-api to evaluate incoming ProseMirror steps against these policies. */ -export type NodeAccessSubject = { +export type AccessContext = { userId: string actions: Record } @@ -35,7 +35,7 @@ type ProtectedResources = { /** A rule that evaluates whether a subject can perform an operation on a specific node */ type NodeRule = ( node: T, - subject: NodeAccessSubject + context: AccessContext ) => boolean /** Policy for a single node type */ @@ -56,25 +56,25 @@ type NodesPolicy = { const nodesPolicy: NodesPolicy = { comment: { - insert: (_, subject) => subject.actions.createComment, - delete: (node, subject) => { - const isOwn = node.attrs.userID === subject.userId + insert: (_, context) => context.actions.createComment, + delete: (node, context) => { + const isOwn = node.attrs.userID === context.userId return isOwn - ? subject.actions.handleOwnComments - : subject.actions.handleOthersComments + ? context.actions.handleOwnComments + : context.actions.handleOthersComments }, attrs: { - contents: (node, subject) => { - const isOwn = node.attrs.userID === subject.userId + contents: (node, context) => { + const isOwn = node.attrs.userID === context.userId return isOwn - ? subject.actions.handleOwnComments - : subject.actions.handleOthersComments + ? context.actions.handleOwnComments + : context.actions.handleOthersComments }, - resolved: (node, subject) => { - const isOwn = node.attrs.userID === subject.userId + resolved: (node, context) => { + const isOwn = node.attrs.userID === context.userId return isOwn - ? subject.actions.resolveOwnComment - : subject.actions.resolveOthersComment + ? context.actions.resolveOwnComment + : context.actions.resolveOthersComment }, }, }, diff --git a/src/schema/types.ts b/src/schema/types.ts index 50ee001e..ce075c16 100644 --- a/src/schema/types.ts +++ b/src/schema/types.ts @@ -238,24 +238,3 @@ export function isNodeOfType( ): node is T { return node.type === type } - -export type Capabilities = { - handleSuggestion: boolean - rejectOwnSuggestion: boolean - handleOwnComments: boolean - resolveOwnComment: boolean - handleOthersComments: boolean - resolveOthersComment: boolean - createComment: boolean - editArticle: boolean -} - -export interface AccessContext { - userId: string - capabilities: Capabilities -} - -export type AccessRule = ( - node: ManuscriptNode, - context: AccessContext -) => boolean From 8245e00bd30b4268033929ec5bca9941ef254635 Mon Sep 17 00:00:00 2001 From: asouqi Date: Wed, 17 Jun 2026 17:58:12 +0300 Subject: [PATCH 5/6] Typo --- src/lib/access-control.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/access-control.ts b/src/lib/access-control.ts index ce0c0090..ca1ff23d 100644 --- a/src/lib/access-control.ts +++ b/src/lib/access-control.ts @@ -18,8 +18,8 @@ import { NodeType } from 'prosemirror-model' import { CommentNode, ManuscriptActions, ManuscriptNode } from '../schema' /** - * Attribute-Based Access Control (ABAC) polices for manuscript nodes. - * used by manuscript-api to evaluate incoming ProseMirror steps against these policies. + * Attribute-Based Access Control (ABAC) policies for manuscript nodes. + * Used by manuscript-api to evaluate incoming ProseMirror steps against these policies. */ export type AccessContext = { From ce0fce711988dbbf1f51e328ab7ada4ab7bc43fd Mon Sep 17 00:00:00 2001 From: asouqi Date: Mon, 22 Jun 2026 16:11:20 +0300 Subject: [PATCH 6/6] review update --- src/lib/access-control.ts | 9 +++++++++ src/schema/types.ts | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/src/lib/access-control.ts b/src/lib/access-control.ts index ca1ff23d..3ba36f97 100644 --- a/src/lib/access-control.ts +++ b/src/lib/access-control.ts @@ -54,6 +54,15 @@ type NodesPolicy = { [K in keyof ProtectedResources]?: NodePolicy } +/** + * Access policy to manuscript content as node-based. + * @example add access policy to citation node based on ManuscriptActions which is resolved at manuscript-api by user role + * citation: { + * insert: (_, context) => context.actions.editCitationsAndRefs, + * delete: (_, context) => context.actions.editCitationsAndRefs, + * attrs: (_, context) => context.actions.editCitationsAndRefs, + * } + */ const nodesPolicy: NodesPolicy = { comment: { insert: (_, context) => context.actions.createComment, diff --git a/src/schema/types.ts b/src/schema/types.ts index ce075c16..cf2959fc 100644 --- a/src/schema/types.ts +++ b/src/schema/types.ts @@ -238,3 +238,7 @@ export function isNodeOfType( ): node is T { return node.type === type } + +export type ExposedSlice = T & { + insertAt: (pos: number, fragment: F) => T +}