From a29a7b5170207244343bb601f08537b9da1c7e90 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:39:47 -0400 Subject: [PATCH 1/2] improvement(soa): persist applicability and justification on the SoA answer (#3395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(soa): persist applicability and justification on the SoA answer Make Statement of Applicability answers first-class per-document records: each control's applicability (Yes/No) and justification are stored on the organization's own SOAAnswer, alongside the existing answer text and versioning. SOAFrameworkConfiguration stays a shared template (control list, columns, objectives). - add SOAAnswer.isApplicable (+ migration) - auto-fill and manual save persist applicability + justification on the answer - export and the answered-count read from the document's own answers - keep the justification for both applicable and not-applicable controls - desktop/mobile rows read from the document's answers via a shared resolveSoaDisplay helper Adds API and frontend unit tests. * fix(soa): render unknown applicability as N/A and unify the result type - resolveSoaDisplay now shows an answer that has no applicability value yet as unanswered (N/A) — consistent with the export — instead of assuming the control is applicable, so pre-existing documents don't render a value that was never set for them. - Consolidate the per-view processed-result types into a single shared SOAProcessedResult (used by the autofill hook and every SoA view) so the contract is defined once. * fix(soa): harden saveAnswer and answer hydration - preserve prior applicability/justification when a field is omitted from a save, so a partial edit can't wipe a previously set decision - validate that questionId belongs to the document's configuration before creating an answer, keeping the per-org answered-count accurate - require a justification when a control is marked not applicable - hydrate the answers map through a single mapper so the sync effect no longer sets savedIsApplicable (which would shadow in-session autofill results) * fix(soa): scope the answered-count to the document's configured questions countAnsweredAnswers now counts only latest answered rows whose questionId belongs to the document's active configuration, so stale or mismatched answer rows can't skew completion/progress metrics. Both callers (manual save and auto-fill) pass the configured question IDs. Adds a regression test. * fix(soa): read fully-remote applicability from the persisted answer Remove the display-only override that forced physical-security (7.x) controls to "not applicable" for fully-remote orgs on screen. The rule is already applied at generation time (auto-fill persists isApplicable=false with a justification and the field is edit-locked), so the persisted answer already reflects it. Reading it from there keeps the on-screen SoA and the exported PDF in agreement instead of applying a rule in one channel but not the other. * fix(soa): validate before writing and make answer updates atomic saveAnswer previously retired the prior answer (isLatestAnswer=false) before running the not-applicable-requires-justification check, so a rejected save could leave a control with no latest answer. Move all validation ahead of any write, and wrap the retire-previous + create-new steps in a single transaction so a failure can never leave a control without a latest answer. Apply the same atomic retire+create in the auto-fill save path. Adds a regression test asserting a failed validation does not retire the prior answer. --- apps/api/src/soa/soa.controller.spec.ts | 2 +- apps/api/src/soa/soa.controller.ts | 15 +- apps/api/src/soa/soa.service.spec.ts | 173 ++++++++++++++- apps/api/src/soa/soa.service.ts | 206 +++++++++--------- apps/api/src/soa/utils/soa-storage.spec.ts | 44 ++++ apps/api/src/soa/utils/soa-storage.ts | 119 ++++------ .../components/EditableSOAFields.tsx | 6 +- .../components/SOAFrameworkTable.tsx | 60 ++--- .../components/SOAMobileRow.tsx | 49 ++--- .../components/SOATable.tsx | 15 +- .../components/SOATableRow.tsx | 55 ++--- .../components/soa-display.test.ts | 88 ++++++++ .../components/soa-display.ts | 60 +++++ .../components/soa-field-types.ts | 19 +- .../hooks/useSOAAutoFill.ts | 5 +- .../hooks/useSOADocument.ts | 1 + .../migration.sql | 3 + packages/db/prisma/schema/soa.prisma | 7 +- 18 files changed, 607 insertions(+), 320 deletions(-) create mode 100644 apps/api/src/soa/utils/soa-storage.spec.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts create mode 100644 packages/db/prisma/migrations/20260713120000_add_soa_answer_is_applicable/migration.sql diff --git a/apps/api/src/soa/soa.controller.spec.ts b/apps/api/src/soa/soa.controller.spec.ts index af8735cdd3..41712e3e88 100644 --- a/apps/api/src/soa/soa.controller.spec.ts +++ b/apps/api/src/soa/soa.controller.spec.ts @@ -40,7 +40,7 @@ describe('SOAController', () => { batchSearchSOAQuestions: jest.fn(), processSOAQuestionWithContent: jest.fn(), saveAnswersToDatabase: jest.fn(), - updateConfigurationWithResults: jest.fn(), + countAnsweredAnswers: jest.fn(), updateDocumentAfterAutoFill: jest.fn(), createDocument: jest.fn(), ensureSetup: jest.fn(), diff --git a/apps/api/src/soa/soa.controller.ts b/apps/api/src/soa/soa.controller.ts index 4817d3967b..f71d0c2ee3 100644 --- a/apps/api/src/soa/soa.controller.ts +++ b/apps/api/src/soa/soa.controller.ts @@ -261,17 +261,12 @@ export class SOAController { userId, ); - // Update configuration with results - await this.soaService.updateConfigurationWithResults( - configuration.id, - questions, - successfulResults, + // Update document answered count from this org's persisted answers, + // scoped to the document's configured questions. + const answeredCount = await this.soaService.countAnsweredAnswers( + dto.documentId, + questions.map((q) => q.id), ); - - // Update document - const answeredCount = successfulResults.filter( - (r) => r.isApplicable !== null, - ).length; await this.soaService.updateDocumentAfterAutoFill( dto.documentId, questions.length, diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index ae0a199ad1..d408cfc420 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -18,6 +18,7 @@ jest.mock('@db', () => ({ findFirst: jest.fn(), findUnique: jest.fn(), create: jest.fn(), + update: jest.fn(), }, sOADocument: { findFirst: jest.fn(), @@ -28,6 +29,7 @@ jest.mock('@db', () => ({ user: { findUnique: jest.fn() }, organization: { findUnique: jest.fn() }, sOAAnswer: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() }, + $transaction: jest.fn((ops: unknown[]) => Promise.all(ops)), }, })); @@ -47,9 +49,8 @@ jest.mock('./utils/soa-answer-parser', () => ({ })); jest.mock('./utils/soa-storage', () => ({ saveAnswersToDatabase: jest.fn(), - updateConfigurationWithResults: jest.fn(), updateDocumentAfterAutoFill: jest.fn(), - getAnsweredCountFromConfiguration: jest.fn(), + countAnsweredAnswers: jest.fn(), updateDocumentAnsweredCount: jest.fn(), checkIfFullyRemote: jest.fn(), })); @@ -487,7 +488,7 @@ describe('SOAService', () => { ); }); - it('maps document data and delegates to generateSOAExportFile', async () => { + it('sources applicability + justification from this org\'s answers, not the shared configuration', async () => { const generated = { fileBuffer: Buffer.from('pdf'), mimeType: 'application/pdf', @@ -506,6 +507,8 @@ describe('SOAService', () => { version: 2, framework: { name: 'ISO 27001' }, configuration: { + // The shared configuration must NOT drive applicability/justification. + // These stale values would previously bleed into every org's export. questions: [ { id: 'q-1', @@ -515,7 +518,7 @@ describe('SOAService', () => { title: 'Control title', control_objective: 'Objective', isApplicable: true, - justification: 'Mapped justification', + justification: 'Another org shared-config justification', }, }, { @@ -531,7 +534,14 @@ describe('SOAService', () => { email: 'approver@example.com', }, }, - answers: [{ questionId: 'q-2', answer: 'Fallback answer' }], + // This org's own answers. + answers: [ + { + questionId: 'q-1', + answer: 'Our own justification', + isApplicable: false, + }, + ], }); const result = await service.exportDocument(dto); @@ -545,10 +555,11 @@ describe('SOAService', () => { closure: 'A.5', title: 'Control title', control_objective: 'Objective', - isApplicable: true, - justification: 'Mapped justification', + // From the org's own answer — NOT the shared config's `true`. + isApplicable: false, + justification: 'Our own justification', }, - answer: null, + answer: 'Our own justification', }, { id: 'q-2', @@ -557,10 +568,11 @@ describe('SOAService', () => { closure: null, title: null, control_objective: null, + // No answer for this org → blank, not another org's data. isApplicable: null, justification: null, }, - answer: 'Fallback answer', + answer: null, }, ], 'ISO 27001', @@ -579,4 +591,147 @@ describe('SOAService', () => { expect(result).toEqual(generated); }); }); + + describe('saveAnswer', () => { + const baseDto = { + organizationId: 'org-1', + documentId: 'doc-1', + questionId: 'q-1', + }; + const userId = 'user-1'; + + beforeEach(() => { + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + totalQuestions: 5, + configuration: { questions: [{ id: 'q-1' }] }, + }); + (mockDb.sOAAnswer.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.sOAAnswer.create as jest.Mock).mockResolvedValue({ id: 'ans-1' }); + }); + + it('throws NotFoundException when document not found', async () => { + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.saveAnswer( + { ...baseDto, isApplicable: true, justification: 'x' }, + userId, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('rejects a question that is not in the document configuration', async () => { + await expect( + service.saveAnswer( + { + ...baseDto, + questionId: 'q-not-in-config', + isApplicable: true, + justification: 'x', + }, + userId, + ), + ).rejects.toThrow(BadRequestException); + expect(mockDb.sOAAnswer.create).not.toHaveBeenCalled(); + }); + + it('requires a justification when a control is not applicable', async () => { + await expect( + service.saveAnswer( + { ...baseDto, isApplicable: false, justification: ' ' }, + userId, + ), + ).rejects.toThrow(BadRequestException); + expect(mockDb.sOAAnswer.create).not.toHaveBeenCalled(); + }); + + it('does not retire the previous answer when validation fails', async () => { + // A rejected save must leave the prior answer intact — validation runs + // before any write, and the retire + create happen in one transaction. + (mockDb.sOAAnswer.findFirst as jest.Mock).mockResolvedValue({ + id: 'ans-prev', + answerVersion: 1, + isApplicable: true, + answer: 'Previously applicable', + }); + + await expect( + service.saveAnswer( + { ...baseDto, isApplicable: false, justification: '' }, + userId, + ), + ).rejects.toThrow(BadRequestException); + + expect(mockDb.sOAAnswer.update).not.toHaveBeenCalled(); + expect(mockDb.$transaction).not.toHaveBeenCalled(); + expect(mockDb.sOAAnswer.create).not.toHaveBeenCalled(); + }); + + it('preserves the existing applicability when isApplicable is omitted', async () => { + (mockDb.sOAAnswer.findFirst as jest.Mock).mockResolvedValue({ + id: 'ans-prev', + answerVersion: 1, + isApplicable: false, + answer: 'Prior justification', + }); + + // A partial edit that only sends a justification must not wipe the Yes/No. + await service.saveAnswer( + { ...baseDto, justification: 'Updated justification' }, + userId, + ); + + expect(mockDb.sOAAnswer.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + isApplicable: false, + answer: 'Updated justification', + }), + }); + }); + + it('persists applicability + justification on the answer', async () => { + await service.saveAnswer( + { + ...baseDto, + isApplicable: false, + justification: 'Not applicable because reasons', + }, + userId, + ); + + expect(mockDb.sOAAnswer.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + documentId: 'doc-1', + questionId: 'q-1', + answer: 'Not applicable because reasons', + isApplicable: false, + status: 'manual', + isLatestAnswer: true, + }), + }); + }); + + it('keeps the justification for applicable (YES) answers', async () => { + await service.saveAnswer( + { ...baseDto, isApplicable: true, justification: 'We do this' }, + userId, + ); + + expect(mockDb.sOAAnswer.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + answer: 'We do this', + isApplicable: true, + }), + }); + }); + + it('never writes per-organization data to the shared configuration', async () => { + await service.saveAnswer( + { ...baseDto, isApplicable: true, justification: 'We do this' }, + userId, + ); + + expect(mockDb.sOAFrameworkConfiguration.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index 016e433b91..0434a91a48 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -39,9 +39,8 @@ import { } from './utils/soa-answer-parser'; import { saveAnswersToDatabase, - updateConfigurationWithResults, updateDocumentAfterAutoFill, - getAnsweredCountFromConfiguration, + countAnsweredAnswers, updateDocumentAnsweredCount, checkIfFullyRemote, type SOAStorageLogger, @@ -74,7 +73,19 @@ export class SOAService { throw new NotFoundException('SOA document not found'); } - // Get existing answer to determine version + // The question must belong to this document's configuration, so answers + // (and the answered-count) can't be created for arbitrary question IDs. + const configQuestions = + (document.configuration.questions as unknown as Array<{ id: string }>) ?? + []; + if (!configQuestions.some((q) => q.id === dto.questionId)) { + throw new BadRequestException( + 'Question does not belong to this SOA document', + ); + } + + // Get existing answer to determine the next version and to preserve prior + // values on partial edits. const existingAnswer = await db.sOAAnswer.findFirst({ where: { documentId: dto.documentId, @@ -86,55 +97,69 @@ export class SOAService { }, }); - const nextVersion = existingAnswer ? existingAnswer.answerVersion + 1 : 1; - - // Mark existing answer as not latest if it exists - if (existingAnswer) { - await db.sOAAnswer.update({ - where: { id: existingAnswer.id }, - data: { isLatestAnswer: false }, - }); - } - - // Determine answer value - let finalAnswer: string | null = null; - if (dto.isApplicable !== undefined) { - finalAnswer = - dto.isApplicable === false - ? dto.justification || dto.answer || null - : null; - } else { - finalAnswer = dto.answer || null; - } - - // Create or update answer - await db.sOAAnswer.create({ - data: { - documentId: dto.documentId, - questionId: dto.questionId, - answer: finalAnswer, - status: - finalAnswer && finalAnswer.trim().length > 0 ? 'manual' : 'untouched', - answerVersion: nextVersion, - isLatestAnswer: true, - createdBy: existingAnswer ? undefined : userId, - updatedBy: userId, - }, - }); - - // Update configuration's question mapping if isApplicable or justification provided - if (dto.isApplicable !== undefined || dto.justification !== undefined) { - await this.updateQuestionMapping( - document.configuration.id, - dto.questionId, - dto.isApplicable ?? undefined, - dto.justification ?? undefined, + // Applicability + justification are stored per organization on the answer. + // Omitted fields preserve the previous value, so a partial edit (e.g. one + // that sends only a justification) cannot wipe a prior applicability + // decision or justification. + const isApplicable = + dto.isApplicable === undefined + ? (existingAnswer?.isApplicable ?? null) + : dto.isApplicable; + const justification = + dto.justification !== undefined + ? dto.justification + : dto.answer !== undefined + ? dto.answer + : (existingAnswer?.answer ?? null); + + // Validate BEFORE any write, so a rejected save leaves the prior answer + // intact. ISO 27001: a not-applicable control must carry a justification. + if ( + isApplicable === false && + (!justification || justification.trim().length === 0) + ) { + throw new BadRequestException( + 'A justification is required when a control is not applicable', ); } - // Update document answered questions count - const answeredCount = await getAnsweredCountFromConfiguration( - document.configurationId, + const nextVersion = existingAnswer ? existingAnswer.answerVersion + 1 : 1; + const isAnswered = isApplicable !== null; + + // Retire the prior answer and create the new version atomically, so a + // failure can never leave the control without a latest answer. + await db.$transaction([ + ...(existingAnswer + ? [ + db.sOAAnswer.update({ + where: { id: existingAnswer.id }, + data: { isLatestAnswer: false }, + }), + ] + : []), + db.sOAAnswer.create({ + data: { + documentId: dto.documentId, + questionId: dto.questionId, + answer: justification, + isApplicable, + status: + isAnswered || (justification && justification.trim().length > 0) + ? 'manual' + : 'untouched', + answerVersion: nextVersion, + isLatestAnswer: true, + createdBy: existingAnswer ? undefined : userId, + updatedBy: userId, + }, + }), + ]); + + // Update document answered questions count from the per-org answers, + // scoped to the document's configured questions. + const answeredCount = await countAnsweredAnswers( + dto.documentId, + configQuestions.map((q) => q.id), ); await updateDocumentAnsweredCount( @@ -146,45 +171,6 @@ export class SOAService { return { success: true }; } - private async updateQuestionMapping( - configurationId: string, - questionId: string, - isApplicable: boolean | undefined, - justification: string | undefined, - ) { - const configuration = await db.sOAFrameworkConfiguration.findUnique({ - where: { id: configurationId }, - }); - - if (!configuration) return; - - const questions = configuration.questions as unknown as SOAQuestion[]; - const updatedQuestions = questions.map((q) => { - if (q.id === questionId) { - return { - ...q, - columnMapping: { - ...q.columnMapping, - isApplicable: - isApplicable !== undefined - ? isApplicable - : q.columnMapping.isApplicable, - justification: - justification !== undefined - ? justification - : q.columnMapping.justification, - }, - }; - } - return q; - }); - - await db.sOAFrameworkConfiguration.update({ - where: { id: configurationId }, - data: { questions: JSON.parse(JSON.stringify(updatedQuestions)) }, - }); - } - async createDocument(dto: CreateSOADocumentDto) { const configuration = await db.sOAFrameworkConfiguration.findFirst({ where: { @@ -521,6 +507,7 @@ export class SOAService { select: { questionId: true, answer: true, + isApplicable: true, }, }, }, @@ -532,22 +519,28 @@ export class SOAService { const questions = (document.configuration.questions as unknown as SOAQuestion[]) ?? []; + // Applicability + justification come from this organization's own answers, + // never from the shared framework configuration (which only supplies the + // control template: title, closure, objective). const answersByQuestionId = new Map( - document.answers.map((answer) => [answer.questionId, answer.answer]), + document.answers.map((answer) => [answer.questionId, answer]), ); - const exportQuestions: SOAExportQuestion[] = questions.map((question) => ({ - id: question.id, - text: question.text, - columnMapping: { - closure: question.columnMapping?.closure ?? null, - title: question.columnMapping?.title ?? null, - control_objective: question.columnMapping?.control_objective ?? null, - isApplicable: question.columnMapping?.isApplicable ?? null, - justification: question.columnMapping?.justification ?? null, - }, - answer: answersByQuestionId.get(question.id) ?? null, - })); + const exportQuestions: SOAExportQuestion[] = questions.map((question) => { + const answer = answersByQuestionId.get(question.id); + return { + id: question.id, + text: question.text, + columnMapping: { + closure: question.columnMapping?.closure ?? null, + title: question.columnMapping?.title ?? null, + control_objective: question.columnMapping?.control_objective ?? null, + isApplicable: answer?.isApplicable ?? null, + justification: answer?.answer ?? null, + }, + answer: answer?.answer ?? null, + }; + }); const exportMetadata: SOAExportMetadata = { preparedBy: (document.preparedBy as string | null) ?? null, @@ -628,12 +621,11 @@ export class SOAService { ); } - async updateConfigurationWithResults( - configurationId: string, - questions: SOAQuestion[], - results: SOAQuestionResult[], - ): Promise { - return updateConfigurationWithResults(configurationId, questions, results); + async countAnsweredAnswers( + documentId: string, + validQuestionIds: string[], + ): Promise { + return countAnsweredAnswers(documentId, validQuestionIds); } async updateDocumentAfterAutoFill( diff --git a/apps/api/src/soa/utils/soa-storage.spec.ts b/apps/api/src/soa/utils/soa-storage.spec.ts new file mode 100644 index 0000000000..1f658a88dc --- /dev/null +++ b/apps/api/src/soa/utils/soa-storage.spec.ts @@ -0,0 +1,44 @@ +import { db } from '@db'; +import { countAnsweredAnswers } from './soa-storage'; + +jest.mock('@db', () => ({ + db: { + sOAAnswer: { + count: jest.fn(), + }, + }, +})); + +const mockDb = jest.mocked(db); + +describe('countAnsweredAnswers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('counts only latest answered rows whose questionId is in the active configuration', async () => { + (mockDb.sOAAnswer.count as jest.Mock).mockResolvedValue(2); + + const result = await countAnsweredAnswers('doc-1', ['q-1', 'q-2']); + + expect(result).toBe(2); + // Scoping by questionId excludes stale/mismatched answers (e.g. for a + // control that was later removed from the configuration) so they can't + // inflate the completion count. + expect(mockDb.sOAAnswer.count).toHaveBeenCalledWith({ + where: { + documentId: 'doc-1', + isLatestAnswer: true, + isApplicable: { not: null }, + questionId: { in: ['q-1', 'q-2'] }, + }, + }); + }); + + it('returns 0 without querying when the configuration has no questions', async () => { + const result = await countAnsweredAnswers('doc-1', []); + + expect(result).toBe(0); + expect(mockDb.sOAAnswer.count).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/soa/utils/soa-storage.ts b/apps/api/src/soa/utils/soa-storage.ts index 70dd7c538c..86c7f9d4bb 100644 --- a/apps/api/src/soa/utils/soa-storage.ts +++ b/apps/api/src/soa/utils/soa-storage.ts @@ -44,31 +44,37 @@ export async function saveAnswersToDatabase( const nextVersion = existingAnswer ? existingAnswer.answerVersion + 1 : 1; - // Mark existing answer as not latest if it exists - if (existingAnswer) { - await db.sOAAnswer.update({ - where: { id: existingAnswer.id }, - data: { isLatestAnswer: false }, - }); - } - // Store justification in the answer field for both YES and NO so the // SoA always carries a justification for every control (per ISO 27001). const answerValue = result.justification ?? null; - // Create new answer - await db.sOAAnswer.create({ - data: { - documentId, - questionId: question.id, - answer: answerValue, - status: 'generated', - generatedAt: new Date(), - answerVersion: nextVersion, - isLatestAnswer: true, - createdBy: userId, - }, - }); + // Retire the prior answer and create the new version atomically, so a + // failure can never leave the control without a latest answer. + // Applicability + justification are stored per organization on the + // answer — never on the shared configuration. + await db.$transaction([ + ...(existingAnswer + ? [ + db.sOAAnswer.update({ + where: { id: existingAnswer.id }, + data: { isLatestAnswer: false }, + }), + ] + : []), + db.sOAAnswer.create({ + data: { + documentId, + questionId: question.id, + answer: answerValue, + isApplicable: result.isApplicable, + status: 'generated', + generatedAt: new Date(), + answerVersion: nextVersion, + isLatestAnswer: true, + createdBy: userId, + }, + }), + ]); } catch (error) { logger.error('Failed to save SOA answer', { questionId: question.id, @@ -78,43 +84,6 @@ export async function saveAnswersToDatabase( } } -/** - * Updates SOA configuration with auto-fill results - */ -export async function updateConfigurationWithResults( - configurationId: string, - configurationQuestions: SOAQuestion[], - results: SOAQuestionResult[], -): Promise { - const resultsMap = new Map( - results - .filter((r) => r.success && r.isApplicable !== null) - .map((r) => [r.questionId, r]), - ); - - const updatedQuestions = configurationQuestions.map((q) => { - const result = resultsMap.get(q.id); - if (result) { - return { - ...q, - columnMapping: { - ...q.columnMapping, - isApplicable: result.isApplicable, - justification: result.justification, - }, - }; - } - return q; - }); - - await db.sOAFrameworkConfiguration.update({ - where: { id: configurationId }, - data: { - questions: JSON.parse(JSON.stringify(updatedQuestions)), - }, - }); -} - /** * Updates SOA document status after auto-fill */ @@ -137,25 +106,27 @@ export async function updateDocumentAfterAutoFill( } /** - * Gets the answered questions count from configuration + * Counts answered questions for a document from its per-organization answers. + * A control is "answered" once it has an applicability decision. Scoped to the + * question IDs in the document's active configuration so stale/mismatched + * answer rows can't skew the completion count. */ -export async function getAnsweredCountFromConfiguration( - configurationId: string, +export async function countAnsweredAnswers( + documentId: string, + validQuestionIds: string[], ): Promise { - const configuration = await db.sOAFrameworkConfiguration.findUnique({ - where: { id: configurationId }, - }); - - if (!configuration) return 0; - - const questions = configuration.questions as Array<{ - id: string; - columnMapping: { - isApplicable: boolean | null; - }; - }>; + if (validQuestionIds.length === 0) { + return 0; + } - return questions.filter((q) => q.columnMapping.isApplicable !== null).length; + return db.sOAAnswer.count({ + where: { + documentId, + isLatestAnswer: true, + isApplicable: { not: null }, + questionId: { in: validQuestionIds }, + }, + }); } /** diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx index f5440d96b4..51159368ed 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/EditableSOAFields.tsx @@ -22,7 +22,11 @@ import { useSOADocument } from '../hooks/useSOADocument'; import { ApplicableReadOnlyDisplay, ApplicableSwatchRow } from './ApplicableSwatch'; import type { SOAFieldSavePayload } from './soa-field-types'; -export type { SOAFieldSavePayload, SOATableAnswerData } from './soa-field-types'; +export type { + SOAFieldSavePayload, + SOATableAnswerData, + SOAProcessedResult, +} from './soa-field-types'; interface EditableSOAFieldsProps { documentId: string; diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx index 21866e0fa7..3b477ccbe5 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAFrameworkTable.tsx @@ -53,6 +53,25 @@ type SOAQuestion = { }; }; +type SOAAnswerRecord = { + questionId: string; + answer: string | null; + isApplicable: boolean | null; + answerVersion: number; +}; + +// Map a persisted answer to row state. `isApplicable` carries the persisted +// value; `savedIsApplicable` is deliberately left unset here — it is reserved +// for a manual save this session (set in handleAnswerUpdate) so it doesn't +// shadow in-session autofill results in resolveSoaDisplay. +function toAnswerData(answer: SOAAnswerRecord): SOATableAnswerData { + return { + answer: answer.answer, + answerVersion: answer.answerVersion, + isApplicable: answer.isApplicable, + }; +} + type SOADocumentInfoDocument = Parameters[0]['document']; export function SOAFrameworkTable({ @@ -97,12 +116,14 @@ export function SOAFrameworkTable({ const columns = configuration.columns as SOAColumn[]; const questions = configuration.questions as SOAQuestion[]; - // Create answers map from document answers + // Create answers map from document answers. Applicability + justification are + // per-organization values that live on the answer, so the table reads them + // from here — never from the shared framework configuration. const [answersMap, setAnswersMap] = useState>(() => { return new Map( - (document?.answers || []).map((answer: { questionId: string; answer: string | null; answerVersion: number }) => [ + (document?.answers || []).map((answer: SOAAnswerRecord) => [ answer.questionId, - { answer: answer.answer, answerVersion: answer.answerVersion }, + toAnswerData(answer), ]) ); }); @@ -114,19 +135,20 @@ export function SOAFrameworkTable({ } setAnswersMap( new Map( - resolvedDocument.answers.map((answer: { questionId: string; answer: string | null; answerVersion: number }) => [ + resolvedDocument.answers.map((answer: SOAAnswerRecord) => [ answer.questionId, - { answer: answer.answer, answerVersion: answer.answerVersion }, + toAnswerData(answer), ]) ) ); }, [resolvedDocument?.answers]); const handleAnswerUpdate = (questionId: string, payload: SOAFieldSavePayload) => { + const existingAnswer = answersMap.get(questionId); const previousIsApplicable = - answersMap.get(questionId)?.savedIsApplicable ?? + existingAnswer?.savedIsApplicable ?? processedResults.get(questionId)?.isApplicable ?? - questions.find((q) => q.id === questionId)?.columnMapping.isApplicable ?? + existingAnswer?.isApplicable ?? null; setAnswersMap((prev) => { @@ -225,28 +247,6 @@ export function SOAFrameworkTable({ }, }); - // Merge processed results into questions for display - const questionsWithResults = useMemo(() => { - if (processedResults.size === 0) { - return questions; - } - - return questions.map((q) => { - const result = processedResults.get(q.id); - if (result && 'success' in result && result.success === true) { - return { - ...q, - columnMapping: { - ...q.columnMapping, - isApplicable: result.isApplicable, - justification: result.justification, - }, - }; - } - return q; - }); - }, [questions, processedResults]); - // Document should always exist at this point if (!document) { return ( @@ -337,7 +337,7 @@ export function SOAFrameworkTable({ {/* Table */} diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATable.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATable.tsx index 70063d3f2c..1f48d7d344 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATable.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATable.tsx @@ -3,7 +3,11 @@ import { Card } from '@trycompai/ui'; import { Button } from '@trycompai/design-system'; import { ChevronUp, ChevronDown } from '@trycompai/design-system/icons'; -import type { SOAFieldSavePayload, SOATableAnswerData } from './EditableSOAFields'; +import type { + SOAFieldSavePayload, + SOAProcessedResult, + SOATableAnswerData, +} from './EditableSOAFields'; import { SOATableRow } from './SOATableRow'; import { SOAMobileRow } from './SOAMobileRow'; @@ -26,19 +30,12 @@ type SOAQuestion = { }; }; -type ProcessedResult = { - success: boolean; - isApplicable: boolean | null; - justification?: string | null; - insufficientData?: boolean; -}; - interface SOATableProps { columns: SOAColumn[]; questions: SOAQuestion[]; answersMap: Map; questionStatuses: Map; - processedResults: Map; + processedResults: Map; isFullyRemote: boolean; isExpanded: boolean; onToggleExpand: () => void; diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx index 24924952b0..fc635468a0 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx @@ -1,8 +1,13 @@ 'use client'; import { Loader2 } from 'lucide-react'; -import type { SOAFieldSavePayload, SOATableAnswerData } from './EditableSOAFields'; +import type { + SOAFieldSavePayload, + SOAProcessedResult, + SOATableAnswerData, +} from './EditableSOAFields'; import { EditableSOAFields } from './EditableSOAFields'; +import { resolveSoaDisplay } from './soa-display'; type SOAColumn = { name: string; @@ -21,19 +26,12 @@ type SOAQuestion = { }; }; -type ProcessedResult = { - success: boolean; - isApplicable: boolean | null; - justification?: string | null; - insufficientData?: boolean; -}; - interface SOATableRowProps { question: SOAQuestion; columns: SOAColumn[]; answerData?: SOATableAnswerData; questionStatus?: string; - processedResult?: ProcessedResult; + processedResult?: SOAProcessedResult; isFullyRemote: boolean; documentId: string; isPendingApproval: boolean; @@ -61,38 +59,13 @@ export function SOATableRow({ const controlClosure = question.columnMapping.closure || ''; const isControl7 = controlClosure.startsWith('7.'); - // Determine displayIsApplicable and justificationValue based on fully remote logic - let displayIsApplicable: boolean | null; - let justificationValue: string | null; - - // If fully remote and control starts with "7.", always show NO (override any other value) - if (isFullyRemote && isControl7) { - displayIsApplicable = false; - justificationValue = - processedResult?.justification || - answerData?.answer || - question.columnMapping.justification || - 'This control is not applicable as our organization operates fully remotely.'; - } else if (answerData?.savedIsApplicable !== undefined) { - // Manual save overrides autofill processedResult so the table updates without a full reload - displayIsApplicable = answerData.savedIsApplicable; - justificationValue = - answerData.answer || question.columnMapping.justification || null; - } else { - // Normal logic: processedResult / column mapping until user saves (then branch above) - const isApplicableValue = - processedResult?.isApplicable !== null && processedResult?.isApplicable !== undefined - ? processedResult.isApplicable - : (question.columnMapping.isApplicable ?? true); - - justificationValue = - processedResult?.justification || - answerData?.answer || - question.columnMapping.justification || - null; - - displayIsApplicable = isApplicableValue; - } + // Applicability + justification are per-organization values (from this + // document's own answers or an in-session autofill result), never from the + // shared framework configuration. + const { displayIsApplicable, justificationValue } = resolveSoaDisplay({ + answerData, + processedResult, + }); return ( diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.test.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.test.ts new file mode 100644 index 0000000000..750536cff1 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSoaDisplay } from './soa-display'; + +describe('resolveSoaDisplay', () => { + it('uses the persisted per-organization answer', () => { + // The only source of applicability/justification is this org's own answer. + // (There is deliberately no shared-configuration input to bleed from.) + const result = resolveSoaDisplay({ + answerData: { + answer: 'Our own justification', + answerVersion: 1, + isApplicable: false, + }, + }); + + expect(result).toEqual({ + displayIsApplicable: false, + justificationValue: 'Our own justification', + }); + }); + + it('defaults to applicable with no justification when there is no answer at all', () => { + const result = resolveSoaDisplay({ answerData: undefined }); + + expect(result).toEqual({ + displayIsApplicable: true, + justificationValue: null, + }); + }); + + it('shows unanswered (N/A) for an answer whose applicability is unknown, without assuming applicable', () => { + // Pre-migration answers have a justification but no applicability value. + // These must not silently render as "applicable"; they read as N/A, + // consistent with the export, until the org re-runs auto-fill. + const result = resolveSoaDisplay({ + answerData: { + answer: 'Existing justification', + answerVersion: 1, + isApplicable: null, + }, + }); + + expect(result).toEqual({ + displayIsApplicable: null, + justificationValue: 'Existing justification', + }); + }); + + it('lets an in-session autofill result override a stale persisted answer', () => { + const result = resolveSoaDisplay({ + answerData: { + answer: 'Old persisted justification', + answerVersion: 1, + isApplicable: true, + }, + processedResult: { + success: true, + isApplicable: false, + justification: 'Fresh autofill justification', + }, + }); + + expect(result).toEqual({ + displayIsApplicable: false, + justificationValue: 'Fresh autofill justification', + }); + }); + + it('lets a manual save this session win over an in-flight autofill result', () => { + const result = resolveSoaDisplay({ + answerData: { + answer: 'Manually saved justification', + answerVersion: 2, + savedIsApplicable: true, + }, + processedResult: { + success: true, + isApplicable: false, + justification: 'Autofill justification', + }, + }); + + expect(result).toEqual({ + displayIsApplicable: true, + justificationValue: 'Manually saved justification', + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts new file mode 100644 index 0000000000..05db46a29a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts @@ -0,0 +1,60 @@ +import type { SOAProcessedResult, SOATableAnswerData } from './soa-field-types'; + +/** + * Resolves the applicability + justification to display for a SoA control. + * + * Applicability and justification are per-organization values, sourced only + * from this document's own answers (`answerData`) or an in-session autofill + * result (`processedResult`) — never from the shared framework configuration, + * and never from a display-only rule. This keeps the on-screen SoA and the + * exported PDF in agreement (both read the same persisted answer). + * + * Note: the "fully remote → physical-security (7.x) controls are not + * applicable" rule is applied at generation time (auto-fill persists + * `isApplicable = false` with a justification) and the field is edit-locked, so + * it is already reflected in the persisted answer read here. + */ +export function resolveSoaDisplay({ + answerData, + processedResult, +}: { + answerData?: SOATableAnswerData; + processedResult?: SOAProcessedResult; +}): { displayIsApplicable: boolean | null; justificationValue: string | null } { + // A manual save this session overrides an in-flight autofill result. + if (answerData?.savedIsApplicable !== undefined) { + return { + displayIsApplicable: answerData.savedIsApplicable, + justificationValue: answerData.answer || null, + }; + } + + // In-session autofill result (before the document is reloaded). + if ( + processedResult?.isApplicable !== null && + processedResult?.isApplicable !== undefined + ) { + return { + displayIsApplicable: processedResult.isApplicable, + justificationValue: + processedResult.justification || answerData?.answer || null, + }; + } + + // Persisted per-organization answer. A pre-migration answer may not carry an + // applicability value yet; show it as unanswered (N/A) — matching the export + // — rather than assuming the control is applicable. + if (answerData !== undefined) { + return { + displayIsApplicable: answerData.isApplicable ?? null, + justificationValue: answerData.answer || null, + }; + } + + // No answer for this control yet — default to applicable, matching the + // server default and the pre-existing UX for an ungenerated SoA. + return { + displayIsApplicable: true, + justificationValue: null, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-field-types.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-field-types.ts index 5cf1bb8413..78b4225b6c 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-field-types.ts +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-field-types.ts @@ -3,9 +3,26 @@ export type SOAFieldSavePayload = { justification: string | null; }; -/** Row-level answer state; `savedIsApplicable` is set after manual save to override autofill. */ +/** + * A single control's result from an in-session auto-fill run (streamed over + * SSE). Shared by the autofill hook and every SoA view so the contract is + * defined once. + */ +export type SOAProcessedResult = { + success: boolean; + isApplicable: boolean | null; + justification?: string | null; + insufficientData?: boolean; +}; + +/** + * Row-level answer state. `isApplicable` is the persisted per-organization + * applicability loaded from the document's answers. `savedIsApplicable` is set + * after a manual save this session to override an in-flight autofill result. + */ export type SOATableAnswerData = { answer: string | null; answerVersion: number; + isApplicable?: boolean | null; savedIsApplicable?: boolean | null; }; diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts index 63efa69619..577b9fd42b 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOAAutoFill.ts @@ -3,6 +3,7 @@ import { useState, useRef } from 'react'; import { toast } from 'sonner'; import { env } from '@/env.mjs'; +import type { SOAProcessedResult } from '../components/soa-field-types'; interface UseSOAAutoFillProps { questions: Array<{ @@ -23,7 +24,9 @@ interface UseSOAAutoFillProps { export function useSOAAutoFill({ questions, documentId, organizationId, onUpdate }: UseSOAAutoFillProps) { const [isAutoFilling, setIsAutoFilling] = useState(false); const [questionStatuses, setQuestionStatuses] = useState>(new Map()); - const [processedResults, setProcessedResults] = useState>(new Map()); + const [processedResults, setProcessedResults] = useState< + Map + >(new Map()); const isAutoFillProcessStartedRef = useRef(false); const triggerAutoFill = async () => { diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts index dacd34a9ff..b3d4544983 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/hooks/useSOADocument.ts @@ -13,6 +13,7 @@ interface SOADocumentData { answers: Array<{ questionId: string; answer: string | null; + isApplicable: boolean | null; answerVersion: number; }>; [key: string]: unknown; diff --git a/packages/db/prisma/migrations/20260713120000_add_soa_answer_is_applicable/migration.sql b/packages/db/prisma/migrations/20260713120000_add_soa_answer_is_applicable/migration.sql new file mode 100644 index 0000000000..21d31d9718 --- /dev/null +++ b/packages/db/prisma/migrations/20260713120000_add_soa_answer_is_applicable/migration.sql @@ -0,0 +1,3 @@ +-- Store the Statement of Applicability applicability decision per organization +-- (on the answer) instead of on the shared, framework-level configuration. +ALTER TABLE "SOAAnswer" ADD COLUMN "isApplicable" BOOLEAN; diff --git a/packages/db/prisma/schema/soa.prisma b/packages/db/prisma/schema/soa.prisma index bc3c1e9f48..1d8937f34e 100644 --- a/packages/db/prisma/schema/soa.prisma +++ b/packages/db/prisma/schema/soa.prisma @@ -95,7 +95,12 @@ model SOAAnswer { questionId String // Must match a question.id from SOADocument.configuration.questions // Answer data - simple text answer - answer String? // Text answer (nullable if not generated yet) + answer String? // Text answer / justification (nullable if not generated yet) + + // Applicability decision for this control, scoped per organization + document. + // Stored here — NOT on the shared SOAFrameworkConfiguration — so each org's + // Statement of Applicability reflects only its own answers. + isApplicable Boolean? // true = applicable, false = not applicable, null = not yet answered // Answer metadata status SOAAnswerStatus @default(untouched) // untouched, generated, manual From 1bde95b7cc17a74f5d360688a28f844257197bd0 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:21:43 -0400 Subject: [PATCH 2/2] fix(soa): apply the fully-remote physical-control rule consistently (#3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(soa): apply the fully-remote physical-control rule consistently A fully remote organization's physical-security (7.x) controls are Not Applicable. That rule is enforced at generation and the field is edit-locked, but the on-screen SoA and the PDF export did not apply it when no answer was persisted yet — a freshly created SoA could show those controls as Applicable/N/A and disagree between screen and export. Apply the rule identically in resolveSoaDisplay (screen) and in the export, so a fully remote org always sees Not Applicable for physical-security controls everywhere, even before auto-fill has persisted it. Adds tests for both paths. * fix(soa): always use the remote rationale for forced-remote controls For a fully remote org's edit-locked physical-security (7.x) controls, use the remote rationale unconditionally on both screen and export, rather than falling back to a persisted justification that could be stale (e.g. written before the org went remote) and contradict the Not Applicable status. Tests assert the stale persisted text is overridden on both paths. --- apps/api/src/soa/soa.service.spec.ts | 85 ++++++++++++++++++- apps/api/src/soa/soa.service.ts | 28 ++++-- .../components/SOAMobileRow.tsx | 2 + .../components/SOATableRow.tsx | 2 + .../components/soa-display.test.ts | 35 +++++++- .../components/soa-display.ts | 35 ++++++-- 6 files changed, 170 insertions(+), 17 deletions(-) diff --git a/apps/api/src/soa/soa.service.spec.ts b/apps/api/src/soa/soa.service.spec.ts index d408cfc420..8d6467a11c 100644 --- a/apps/api/src/soa/soa.service.spec.ts +++ b/apps/api/src/soa/soa.service.spec.ts @@ -7,6 +7,7 @@ import { import { db } from '@db'; import { SOAService } from './soa.service'; import { generateSOAExportFile } from './utils/export-generator'; +import { checkIfFullyRemote } from './utils/soa-storage'; jest.mock('@db', () => ({ db: { @@ -45,7 +46,9 @@ jest.mock('./utils/soa-answer-parser', () => ({ parseAndProcessSOAAnswer: jest.fn(), createDefaultYesResult: jest.fn(), createFullyRemoteResult: jest.fn(), - isPhysicalSecurityControl: jest.fn(), + isPhysicalSecurityControl: jest.fn( + (closure: string) => typeof closure === 'string' && closure.startsWith('7.'), + ), })); jest.mock('./utils/soa-storage', () => ({ saveAnswersToDatabase: jest.fn(), @@ -60,6 +63,7 @@ jest.mock('./utils/export-generator', () => ({ const mockDb = jest.mocked(db); const mockGenerateSOAExportFile = jest.mocked(generateSOAExportFile); +const mockCheckIfFullyRemote = jest.mocked(checkIfFullyRemote); describe('SOAService', () => { let service: SOAService; @@ -495,6 +499,7 @@ describe('SOAService', () => { filename: 'statement-of-applicability-iso-27001-v2.pdf', }; mockGenerateSOAExportFile.mockReturnValue(generated); + mockCheckIfFullyRemote.mockResolvedValue(false); (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ id: 'doc-1', organizationId: 'org-1', @@ -590,6 +595,84 @@ describe('SOAService', () => { ); expect(result).toEqual(generated); }); + + it('forces Not Applicable on physical-security (7.x) controls for a fully remote org', async () => { + const generated = { + fileBuffer: Buffer.from('pdf'), + mimeType: 'application/pdf', + filename: 'statement-of-applicability-iso-27001-v1.pdf', + }; + mockGenerateSOAExportFile.mockReturnValue(generated); + mockCheckIfFullyRemote.mockResolvedValue(true); + (mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc-1', + organizationId: 'org-1', + preparedBy: 'Comp AI', + answeredQuestions: 2, + totalQuestions: 2, + approvedAt: null, + declinedAt: null, + status: 'completed', + version: 1, + framework: { name: 'ISO 27001' }, + configuration: { + questions: [ + { + id: 'q-phys', + text: 'Physical entry', + columnMapping: { + closure: '7.2', + title: 'Physical entry', + control_objective: 'obj', + isApplicable: true, + justification: 'another org shared-config text', + }, + }, + { + id: 'q-other', + text: 'Policies', + columnMapping: { + closure: '5.1', + title: 'Policies', + control_objective: 'obj', + isApplicable: true, + justification: 'another org shared-config text', + }, + }, + ], + }, + approver: null, + answers: [ + // A stale justification persisted before the org went remote — must + // NOT leak into the forced Not Applicable output. + { + questionId: 'q-phys', + answer: 'We maintain physical access controls at our office', + isApplicable: true, + }, + { questionId: 'q-other', answer: 'Our own 5.1 justification', isApplicable: true }, + ], + }); + + await service.exportDocument(dto); + + const passedQuestions = mockGenerateSOAExportFile.mock.calls[0][0]; + const phys = passedQuestions.find((q) => q.id === 'q-phys'); + const other = passedQuestions.find((q) => q.id === 'q-other'); + + // Fully remote + 7.x → forced Not Applicable with the remote justification, + // overriding the stale persisted answer. + expect(phys?.columnMapping.isApplicable).toBe(false); + expect(phys?.columnMapping.justification).toBe( + 'This control is not applicable as our organization operates fully remotely.', + ); + expect(phys?.answer).toBe( + 'This control is not applicable as our organization operates fully remotely.', + ); + // Non-physical controls are unaffected and use the org's own answer. + expect(other?.columnMapping.isApplicable).toBe(true); + expect(other?.columnMapping.justification).toBe('Our own 5.1 justification'); + }); }); describe('saveAnswer', () => { diff --git a/apps/api/src/soa/soa.service.ts b/apps/api/src/soa/soa.service.ts index 0434a91a48..fc4cedfd17 100644 --- a/apps/api/src/soa/soa.service.ts +++ b/apps/api/src/soa/soa.service.ts @@ -17,7 +17,10 @@ import { SubmitSOAForApprovalDto } from './dto/submit-soa-for-approval.dto'; import { ExportSOADocumentDto } from './dto/export-soa-document.dto'; import type { SimilarContentResult } from '@/vector-store/lib'; import { loadISOConfig } from './utils/transform-iso-config'; -import { ISO27001_FRAMEWORK_NAMES } from './utils/constants'; +import { + FULLY_REMOTE_JUSTIFICATION, + ISO27001_FRAMEWORK_NAMES, +} from './utils/constants'; import { generateSOAExportFile, type SOAExportMetadata, @@ -526,19 +529,34 @@ export class SOAService { document.answers.map((answer) => [answer.questionId, answer]), ); + // Enforced rule, applied identically on screen and in this export: a fully + // remote org's physical-security (7.x) controls are Not Applicable. + const isFullyRemote = await checkIfFullyRemote( + dto.organizationId, + this.storageLogger, + ); + const exportQuestions: SOAExportQuestion[] = questions.map((question) => { const answer = answersByQuestionId.get(question.id); + const closure = question.columnMapping?.closure ?? null; + const forceNotApplicable = + isFullyRemote && isPhysicalSecurityControl(closure ?? ''); + // Forced-remote controls always use the remote rationale, never a stale + // persisted justification that could contradict the Not Applicable status. + const justification = forceNotApplicable + ? FULLY_REMOTE_JUSTIFICATION + : (answer?.answer ?? null); return { id: question.id, text: question.text, columnMapping: { - closure: question.columnMapping?.closure ?? null, + closure, title: question.columnMapping?.title ?? null, control_objective: question.columnMapping?.control_objective ?? null, - isApplicable: answer?.isApplicable ?? null, - justification: answer?.answer ?? null, + isApplicable: forceNotApplicable ? false : (answer?.isApplicable ?? null), + justification, }, - answer: answer?.answer ?? null, + answer: justification, }; }); diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx index 9a34f88e8d..1af14c8dbf 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOAMobileRow.tsx @@ -60,6 +60,8 @@ export function SOAMobileRow({ const { displayIsApplicable, justificationValue } = resolveSoaDisplay({ answerData, processedResult, + isFullyRemote, + isControl7, }); return ( diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx index fc635468a0..9ccebc1019 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/SOATableRow.tsx @@ -65,6 +65,8 @@ export function SOATableRow({ const { displayIsApplicable, justificationValue } = resolveSoaDisplay({ answerData, processedResult, + isFullyRemote, + isControl7, }); return ( diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.test.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.test.ts index 750536cff1..dab98ed56c 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.test.ts +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { resolveSoaDisplay } from './soa-display'; +import { FULLY_REMOTE_JUSTIFICATION, resolveSoaDisplay } from './soa-display'; describe('resolveSoaDisplay', () => { + const base = { isFullyRemote: false, isControl7: false }; + it('uses the persisted per-organization answer', () => { // The only source of applicability/justification is this org's own answer. // (There is deliberately no shared-configuration input to bleed from.) const result = resolveSoaDisplay({ + ...base, answerData: { answer: 'Our own justification', answerVersion: 1, @@ -20,7 +23,7 @@ describe('resolveSoaDisplay', () => { }); it('defaults to applicable with no justification when there is no answer at all', () => { - const result = resolveSoaDisplay({ answerData: undefined }); + const result = resolveSoaDisplay({ ...base, answerData: undefined }); expect(result).toEqual({ displayIsApplicable: true, @@ -33,6 +36,7 @@ describe('resolveSoaDisplay', () => { // These must not silently render as "applicable"; they read as N/A, // consistent with the export, until the org re-runs auto-fill. const result = resolveSoaDisplay({ + ...base, answerData: { answer: 'Existing justification', answerVersion: 1, @@ -48,6 +52,7 @@ describe('resolveSoaDisplay', () => { it('lets an in-session autofill result override a stale persisted answer', () => { const result = resolveSoaDisplay({ + ...base, answerData: { answer: 'Old persisted justification', answerVersion: 1, @@ -68,6 +73,7 @@ describe('resolveSoaDisplay', () => { it('lets a manual save this session win over an in-flight autofill result', () => { const result = resolveSoaDisplay({ + ...base, answerData: { answer: 'Manually saved justification', answerVersion: 2, @@ -85,4 +91,29 @@ describe('resolveSoaDisplay', () => { justificationValue: 'Manually saved justification', }); }); + + it('forces Not Applicable + the remote rationale for a fully remote org on physical-security (7.x) controls, ignoring any stale persisted justification', () => { + // Even if a stale justification is persisted (e.g. written before the org + // went remote), the locked forced-remote control must show the remote + // rationale, not the contradictory old text. + const result = resolveSoaDisplay({ + isFullyRemote: true, + isControl7: true, + answerData: { + answer: 'We maintain physical access controls at our office', + answerVersion: 1, + isApplicable: true, + }, + processedResult: { + success: true, + isApplicable: true, + justification: 'stale autofill text', + }, + }); + + expect(result).toEqual({ + displayIsApplicable: false, + justificationValue: FULLY_REMOTE_JUSTIFICATION, + }); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts index 05db46a29a..84e0fe1ce7 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts +++ b/apps/app/src/app/(app)/[orgId]/documents/statement-of-applicability/components/soa-display.ts @@ -1,26 +1,43 @@ import type { SOAProcessedResult, SOATableAnswerData } from './soa-field-types'; +export const FULLY_REMOTE_JUSTIFICATION = + 'This control is not applicable as our organization operates fully remotely.'; + /** * Resolves the applicability + justification to display for a SoA control. * - * Applicability and justification are per-organization values, sourced only - * from this document's own answers (`answerData`) or an in-session autofill - * result (`processedResult`) — never from the shared framework configuration, - * and never from a display-only rule. This keeps the on-screen SoA and the - * exported PDF in agreement (both read the same persisted answer). + * Applicability and justification are per-organization values, sourced from + * this document's own answers (`answerData`) or an in-session autofill result + * (`processedResult`) — never from the shared framework configuration. * - * Note: the "fully remote → physical-security (7.x) controls are not - * applicable" rule is applied at generation time (auto-fill persists - * `isApplicable = false` with a justification) and the field is edit-locked, so - * it is already reflected in the persisted answer read here. + * The one enforced rule applied here is "fully remote → physical-security (7.x) + * controls are Not Applicable". It is applied consistently on both the screen + * (here) and the export, and the field is edit-locked to it, so a fully remote + * org sees the same Not Applicable result everywhere even before auto-fill has + * persisted it. */ export function resolveSoaDisplay({ answerData, processedResult, + isFullyRemote, + isControl7, }: { answerData?: SOATableAnswerData; processedResult?: SOAProcessedResult; + isFullyRemote: boolean; + isControl7: boolean; }): { displayIsApplicable: boolean | null; justificationValue: string | null } { + // Enforced rule: fully remote org + physical-security control (7.x) is Not + // Applicable. The field is edit-locked to this, so use the remote rationale + // unconditionally — never a stale persisted justification that could contradict + // the Not Applicable status. The export applies the identical rule. + if (isFullyRemote && isControl7) { + return { + displayIsApplicable: false, + justificationValue: FULLY_REMOTE_JUSTIFICATION, + }; + } + // A manual save this session overrides an in-flight autofill result. if (answerData?.savedIsApplicable !== undefined) { return {