diff --git a/src/Controller/V2/Document/DocumentController.ts b/src/Controller/V2/Document/DocumentController.ts index ad8677f4..aa33e996 100644 --- a/src/Controller/V2/Document/DocumentController.ts +++ b/src/Controller/V2/Document/DocumentController.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { ProjectPermission } from '../../..//Models/ProjectModels' import { DIContainer } from '../../../DIContainer/DIContainer' import { DocumentPermission } from '../../../DomainServices/DocumentService' @@ -105,7 +104,15 @@ export class DocumentController extends BaseController { projectID, DocumentPermission.WRITE ) - return await DIContainer.sharedContainer.authorityService.receiveSteps(manuscriptID, payload) + const permittedActions = await DIContainer.sharedContainer.authorityService.getPermittedActions( + projectID, + user.id + ) + + return await DIContainer.sharedContainer.authorityService.receiveSteps(manuscriptID, payload, { + userId: user.id, + actions: permittedActions, + }) } broadcastSteps(manuscriptID: string, result: History) { diff --git a/src/DIContainer/DIContainer.ts b/src/DIContainer/DIContainer.ts index 43392d28..8dc1aa68 100644 --- a/src/DIContainer/DIContainer.ts +++ b/src/DIContainer/DIContainer.ts @@ -32,6 +32,7 @@ import { ProjectService } from '../DomainServices/ProjectService' import { RegisterationService } from '../DomainServices/RegisterationService' import { SocketsService } from '../DomainServices/SocketsService' import { UserService } from '../DomainServices/UserService' +import { StepAccessService } from '../DomainServices/StepAccessService' import { DocumentClient, EventClient, @@ -85,6 +86,7 @@ export class DIContainer { readonly eventclient: EventClient readonly eventManager: EventManager readonly socketsService: SocketsService + readonly stepAccessService: StepAccessService /** * WARNING: internal method. @@ -118,6 +120,7 @@ export class DIContainer { this.socketsService = new SocketsService() this.documentService = new DocumentService(this.socketsService, repository.documentClient) this.oEmbedService = new OEmbedService() + this.stepAccessService = new StepAccessService() } /** diff --git a/src/DomainServices/AuthorityService.ts b/src/DomainServices/AuthorityService.ts index 8ff69d20..3e96c4cd 100644 --- a/src/DomainServices/AuthorityService.ts +++ b/src/DomainServices/AuthorityService.ts @@ -14,13 +14,19 @@ * limitations under the License. */ -import { getVersion, JSONProsemirrorNode, ManuscriptActions, schema } from '@manuscripts/transform' +import { + getVersion, + JSONProsemirrorNode, + ManuscriptActions, + AccessContext, + schema, +} from '@manuscripts/transform' import { Prisma } from '@prisma/client' import { JsonObject } from '@prisma/client/runtime/library' import { Step } from 'prosemirror-transform' import { DIContainer } from '../DIContainer/DIContainer' -import { UserRoleError, VersionMismatchError } from '../Errors' +import { StepAccessError, UserRoleError, VersionMismatchError } from '../Errors' import { History, ModifiedStep, ReceiveSteps } from '../Models/AuthorityModels' import { ProjectUserRole } from '../Models/ProjectModels' import { DB } from '../Models/RepositoryModels' @@ -28,12 +34,17 @@ import { DB } from '../Models/RepositoryModels' export class AuthorityService { constructor(private readonly repository: DB) {} - public async receiveSteps(documentID: string, receiveSteps: ReceiveSteps): Promise { + public async receiveSteps( + documentID: string, + receiveSteps: ReceiveSteps, + accessContext: AccessContext + ): Promise { const found = await this.repository.manuscriptDoc.findDocument(documentID) const { doc, modifiedSteps } = this.applyStepsToDocument( receiveSteps.steps, found.doc, - receiveSteps.clientID.toString() + receiveSteps.clientID.toString(), + accessContext ) try { await this.repository.manuscriptDoc.updateDocumentWithVersionCheck( @@ -82,7 +93,10 @@ export class AuthorityService { return history } - public async getPermittedActions(projectID: string, userID: string): Promise> { + public async getPermittedActions( + projectID: string, + userID: string + ): Promise> { const project = await DIContainer.sharedContainer.projectService.getProject(projectID) const role = DIContainer.sharedContainer.projectService.getUserRole(project, userID) @@ -123,12 +137,21 @@ export class AuthorityService { private applyStepsToDocument( jsonSteps: Prisma.JsonObject[], document: Prisma.JsonValue, - clientID: string + clientID: string, + accessContext: AccessContext ) { const steps = this.hydrateSteps(jsonSteps) const modifiedSteps: ModifiedStep[] = [] let pmDocument = schema.nodeFromJSON(document) for (let i = 0; i < steps.length; i++) { + const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( + steps[i], + pmDocument, + accessContext + ) + if (!hasAccessToStep) { + throw new StepAccessError(steps[i]) + } pmDocument = steps[i].apply(pmDocument).doc || pmDocument modifiedSteps.push({ ...jsonSteps[i], clientID }) } diff --git a/src/DomainServices/StepAccessService.ts b/src/DomainServices/StepAccessService.ts new file mode 100644 index 00000000..8eb1441e --- /dev/null +++ b/src/DomainServices/StepAccessService.ts @@ -0,0 +1,113 @@ +/*! + * © 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 { + AccessContext, + getNodeAccessPolicy, + ManuscriptNode, + ExposedSlice, +} from '@manuscripts/transform' +import { AttrStep, ReplaceAroundStep, ReplaceStep, Step } from 'prosemirror-transform' + +export class StepAccessService { + validate(step: Step, doc: ManuscriptNode, context: AccessContext) { + if (step instanceof ReplaceAroundStep) { + const gap = doc.slice(step.gapFrom, step.gapTo) + const slice = ( + step.slice as ExposedSlice + ).insertAt(step.insert, gap.content) + return this.validateReplaceStep(new ReplaceStep(step.from, step.to, slice), doc, context) + } + + if (step instanceof ReplaceStep) { + return this.validateReplaceStep(step, doc, context) + } + + if (step instanceof AttrStep) { + return this.validateAttrStep(step, doc, context) + } + return true + } + + private validateReplaceStep(step: ReplaceStep, doc: ManuscriptNode, context: AccessContext) { + if (this.isStepUpdateNodeAttr(step, doc)) { + const node = step.slice.content.firstChild! + const nodeDB = doc.slice(step.from, step.to).content.firstChild! + return !this.findDiff(nodeDB, node).find((attr) => !this.attrPolicy(nodeDB, attr, context)) + } + + let hasAccess = context.actions.editArticle + + doc.slice(step.from, step.to).content.descendants((node) => { + const deletePolicy = getNodeAccessPolicy(node.type)?.delete + if (deletePolicy && !deletePolicy(node, context)) { + hasAccess = false + return false + } + }) + + step.slice.content.descendants((node) => { + const insertPolicy = getNodeAccessPolicy(node.type)?.insert + if (insertPolicy && !insertPolicy(node, context)) { + hasAccess = false + return false + } + }) + + return hasAccess + } + + private validateAttrStep(step: AttrStep, doc: ManuscriptNode, context: AccessContext) { + const node = doc.nodeAt(step.pos) + return this.attrPolicy(node, step.attr, context) + } + + private isStepUpdateNodeAttr(step: ReplaceStep, doc: ManuscriptNode) { + const stepContent = step.slice.content + const sliceContent = doc.slice(step.from, step.to).content + return ( + stepContent.size === sliceContent.size && + stepContent.childCount === 1 && + sliceContent.childCount === 1 && + stepContent.firstChild!.content.eq(sliceContent.firstChild!.content) + ) + } + + private findDiff(nodeA: ManuscriptNode, nodeB: ManuscriptNode) { + const keys: string[] = [] + Object.entries(nodeB.attrs).map(([key, value]) => { + if (!nodeA.hasMarkup(nodeA.type, { ...nodeA.attrs, [key]: value })) { + keys.push(key) + } + }) + return keys + } + + private attrPolicy(node: ManuscriptNode | null, attr: string, context: AccessContext) { + const policy = node?.type && getNodeAccessPolicy(node.type)?.attrs + + if (policy) { + // we could have a policy that applied to all attribute changes + if (typeof policy === 'function') { + return policy(node, context) + } else { + // apply policy per-attribute + return !!policy[attr]?.(node, context) + } + } + + return context.actions.editMetadata + } +} diff --git a/src/Errors.ts b/src/Errors.ts index f13c81cc..393e109a 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -18,6 +18,7 @@ import { Prisma } from '@prisma/client' import { StatusCodes } from 'http-status-codes' import { InternalErrorCode } from './InternalErrorCodes' +import { Step } from 'prosemirror-transform' /** An error-like object that has a code. Used amongst error types to describe those error types that have their own natural HTTP status code. */ export interface StatusCoded { @@ -287,3 +288,16 @@ export class ForbiddenOriginError extends Error implements StatusCoded { Object.setPrototypeOf(this, new.target.prototype) } } + +export class StepAccessError extends Error implements StatusCoded { + readonly internalErrorCode = InternalErrorCode.StepAccessError + readonly statusCode = StatusCodes.FORBIDDEN + step: JSON + + constructor(step: Step) { + super(`User role is not permitted to apply step`) + this.name = 'StepAccessError' + this.step = step.toJSON() + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/src/InternalErrorCodes.ts b/src/InternalErrorCodes.ts index 7a754e4a..2092ca06 100644 --- a/src/InternalErrorCodes.ts +++ b/src/InternalErrorCodes.ts @@ -16,6 +16,7 @@ export enum InternalErrorCode { SyncError = 'SG_ERR', + StepAccessError = 'STEP_ACCESS_ERR', NumericalError = 'NUMERICAL_ERR', NoBucketError = 'CB_BUCKET_NOT_FOUND', InvalidBucketError = 'CB_INVALID_BUCKET', diff --git a/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts new file mode 100644 index 00000000..331cf05d --- /dev/null +++ b/test/suites/unit/DomainLayer/V2/StepAccessService.spec.ts @@ -0,0 +1,115 @@ +/*! + * © 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 { AccessContext, schema } from '@manuscripts/transform' +import { Transform } from 'prosemirror-transform' + +import { DIContainer } from '../../../../../src/DIContainer/DIContainer' +import { TEST_TIMEOUT } from '../../../../utilities/testSetup' + +jest.setTimeout(TEST_TIMEOUT) + +let accessContext: AccessContext +let tr: Transform + +beforeEach(async () => { + ;(DIContainer as any)._sharedContainer = null + await DIContainer.init() + accessContext = { + userId: 'MPUserProfile:01', + actions: { + handleSuggestion: true, + rejectOwnSuggestion: true, + handleOwnComments: true, + handleOthersComments: true, + resolveOwnComment: true, + resolveOthersComment: true, + createComment: true, + canEditFiles: true, + editArticle: true, + formatArticle: true, + editMetadata: true, + editCitationsAndRefs: true, + seeEditorToolbar: true, + seeReferencesButtons: true, + }, + } + const emptyDoc = schema.nodes.doc.createAndFill()! + tr = new Transform(emptyDoc) + tr.insert(10, schema.nodeFromJSON(comment)) +}) + +afterEach(() => { + jest.clearAllMocks() +}) + +const comment = { + type: 'comment', + attrs: { + id: 'MPCommentAnnotation:29D4335B', + contents: 'comment content', + target: 'MPParagraphElement:06D94BD3', + resolved: false, + userID: 'MPUserProfile:01', + originalText: '', + }, +} + +describe('StepAccessService', () => { + describe('validate', () => { + it('has no access to resolve other comment', () => { + accessContext.userId = 'MPUserProfile:02' + accessContext.actions.resolveOthersComment = false + tr.setNodeMarkup(10, undefined, { ...comment.attrs, resolved: true }) + const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( + tr.steps[1], + tr.docs[1], + accessContext + ) + expect(hasAccessToStep).toEqual(false) + }) + it('has no access to resolve own comment', () => { + accessContext.actions.resolveOwnComment = false + tr.setNodeMarkup(10, undefined, { ...comment.attrs, resolved: true }) + const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( + tr.steps[1], + tr.docs[1], + accessContext + ) + expect(hasAccessToStep).toEqual(false) + }) + it('has no access to create a comment', () => { + accessContext.actions.createComment = false + tr.insert(10, schema.nodeFromJSON(comment)) + const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( + tr.steps[1], + tr.docs[1], + accessContext + ) + expect(hasAccessToStep).toEqual(false) + }) + it('has no access to delete a comment', () => { + accessContext.actions.handleOwnComments = false + tr.delete(10, 11) + const hasAccessToStep = DIContainer.sharedContainer.stepAccessService.validate( + tr.steps[1], + tr.docs[1], + accessContext + ) + expect(hasAccessToStep).toEqual(false) + }) + }) +})