diff --git a/apps/api/src/device-agent/device-agent-auth.service.spec.ts b/apps/api/src/device-agent/device-agent-auth.service.spec.ts index c5e3c4e484..b7487c4698 100644 --- a/apps/api/src/device-agent/device-agent-auth.service.spec.ts +++ b/apps/api/src/device-agent/device-agent-auth.service.spec.ts @@ -519,6 +519,49 @@ describe('DeviceAgentAuthService', () => { }); }); + it('stamps source="agent" on check-in so an integration-imported row adopted by the agent is counted in People (CS-770)', async () => { + // CS-770: the endpoint agent had adopted a device first imported via an + // integration, but the row stayed source='integration' — so the People + // tab (which only rolls up agent devices) showed the compliant device as + // "Missing". A check-in means the agent is managing the device, so it + // must assert agent ownership to heal the record. + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev-1', + agentSessionId: 'ses-1', + diskEncryptionEnabled: false, + antivirusEnabled: false, + passwordPolicySet: false, + screenLockEnabled: false, + checkDetails: {}, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + isCompliant: true, + }); + + await service.checkIn({ + userId: 'user-1', + sessionId: 'ses-1', + sessionDeviceAgent: true, + dto: { + deviceId: 'dev-1', + checks: [ + { + checkType: 'disk_encryption', + passed: true, + checkedAt: new Date().toISOString(), + }, + ], + }, + }); + + expect(mockDb.device.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev-1' }, + data: expect.objectContaining({ source: 'agent' }), + }), + ); + }); + it('should throw NotFoundException if device not found', async () => { (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); diff --git a/apps/api/src/device-agent/device-agent-auth.service.ts b/apps/api/src/device-agent/device-agent-auth.service.ts index 4af0026a3b..785808676e 100644 --- a/apps/api/src/device-agent/device-agent-auth.service.ts +++ b/apps/api/src/device-agent/device-agent-auth.service.ts @@ -225,6 +225,11 @@ export class DeviceAgentAuthService { checkDetails: checkDetails as Prisma.InputJsonValue, isCompliant, lastCheckIn: new Date(), + // A check-in means the endpoint agent is actively managing this device, + // so assert agent ownership. Heals a row that was imported by an + // integration and adopted by the agent but left source='integration' + // (which hid the compliant device as "Missing" in the People tab). + source: 'agent', ...(dto.agentVersion ? { agentVersion: dto.agentVersion } : {}), ...(sessionIdToLink !== undefined ? { agentSessionId: sessionIdToLink } : {}), }, diff --git a/apps/api/src/device-agent/device-registration.helpers.spec.ts b/apps/api/src/device-agent/device-registration.helpers.spec.ts index 2e2f6a8862..ca5d4c0cc7 100644 --- a/apps/api/src/device-agent/device-registration.helpers.spec.ts +++ b/apps/api/src/device-agent/device-registration.helpers.spec.ts @@ -134,6 +134,33 @@ describe('registerWithSerial — orphan adoption', () => { }); }); +describe('registerWithSerial — claims agent ownership (CS-770)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('re-stamps an adopted serial-match row as source="agent" so it is not left stuck as an integration import', async () => { + // CS-770: a device imported via an integration (source='integration') then + // adopted by the endpoint agent — matched here by serial for the same + // member — must be re-stamped source='agent'. Otherwise the People tab + // skips it (it only rolls up agent devices) and the compliant device reads + // "Missing" there even though the Device tab still shows it. + (mockDb.device.findUnique as jest.Mock).mockResolvedValue({ + id: 'dev_intune', + memberId: member.id, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev_intune' }); + + const dto = makeDto(); + await registerWithSerial({ member, dto }); + + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_intune' }, + data: expect.objectContaining({ source: 'agent' }), + }); + }); +}); + describe('registerWithoutSerial — unchanged behavior', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/apps/api/src/device-agent/device-registration.helpers.ts b/apps/api/src/device-agent/device-registration.helpers.ts index 57c6e5ac57..102b14f295 100644 --- a/apps/api/src/device-agent/device-registration.helpers.ts +++ b/apps/api/src/device-agent/device-registration.helpers.ts @@ -13,6 +13,13 @@ function buildUpdateData(dto: RegisterDeviceDto) { osVersion: dto.osVersion, hardwareModel: dto.hardwareModel, agentVersion: dto.agentVersion, + // The endpoint agent is the managing source once it registers a device. + // When it adopts a row previously created by an integration import (matched + // here by serial for the same member), re-stamp it as an agent device. + // Otherwise the row stays source='integration', the People tab skips it + // (it only rolls up agent devices) and a compliant device reads "Missing" + // there while still showing in the Device tab. + source: 'agent' as const, }; } diff --git a/apps/api/src/isms/documents/data-source.spec.ts b/apps/api/src/isms/documents/data-source.spec.ts index 279dd5420d..7dd722c872 100644 --- a/apps/api/src/isms/documents/data-source.spec.ts +++ b/apps/api/src/isms/documents/data-source.spec.ts @@ -11,6 +11,7 @@ const mockDb = { frameworkEditorFramework: { findUnique: jest.fn() }, ismsProfile: { findUnique: jest.fn() }, ismsInterestedParty: { findMany: jest.fn() }, + riskAcceptance: { findMany: jest.fn() }, }; jest.mock('@db', () => ({ db: mockDb })); @@ -55,6 +56,7 @@ function seedDb({ }); mockDb.ismsProfile.findUnique.mockResolvedValue({ answers: {} }); mockDb.ismsInterestedParty.findMany.mockResolvedValue(parties); + mockDb.riskAcceptance.findMany.mockResolvedValue([]); } beforeEach(() => { diff --git a/apps/api/src/isms/documents/data-source.ts b/apps/api/src/isms/documents/data-source.ts index f8db007e40..e76efba692 100644 --- a/apps/api/src/isms/documents/data-source.ts +++ b/apps/api/src/isms/documents/data-source.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { db } from '@db'; +import type { Prisma } from '@db'; import { parseStoredAnswers } from '../wizard/wizard-schema'; import type { IsmsPlatformData } from './types'; @@ -16,10 +17,19 @@ const HIGH_IMPACT = ['major', 'severe']; export async function collectPlatformData({ organizationId, frameworkId, + client, }: { organizationId: string; frameworkId: string; + /** + * Optional transaction client. The approval flow passes its transaction so + * the drift baseline is read at the SAME point in time as the rows frozen + * into the published version — otherwise a concurrent edit between the two + * reads makes a just-approved document immediately show as stale. + */ + client?: Prisma.TransactionClient; }): Promise { + const dbc = client ?? db; const [ organization, frameworkInstances, @@ -32,42 +42,72 @@ export async function collectPlatformData({ ownFramework, profile, partiesRows, + acceptanceRows, ] = await Promise.all([ - db.organization.findUnique({ + dbc.organization.findUnique({ where: { id: organizationId }, select: { name: true }, }), - db.frameworkInstance.findMany({ + dbc.frameworkInstance.findMany({ where: { organizationId }, select: { framework: { select: { name: true } } }, }), - db.vendor.findMany({ + dbc.vendor.findMany({ where: { organizationId }, - select: { name: true, category: true, isSubProcessor: true }, + select: { + id: true, + name: true, + category: true, + isSubProcessor: true, + // Risk fields feed the Risk Treatment Plan fingerprint (6.1.3). + status: true, + inherentProbability: true, + inherentImpact: true, + residualProbability: true, + residualImpact: true, + treatmentStrategy: true, + treatmentStrategyDescription: true, + assigneeId: true, + assignee: { select: { user: { select: { name: true, email: true } } } }, + }, }), - db.member.count({ where: { organizationId, deactivated: false } }), - db.member.groupBy({ + dbc.member.count({ where: { organizationId, deactivated: false } }), + dbc.member.groupBy({ by: ['department'], where: { organizationId, deactivated: false }, _count: { _all: true }, }), - db.device.count({ where: { organizationId } }), - db.risk.findMany({ + dbc.device.count({ where: { organizationId } }), + dbc.risk.findMany({ where: { organizationId }, - select: { residualLikelihood: true, residualImpact: true }, + select: { + id: true, + residualLikelihood: true, + residualImpact: true, + // The remaining fields feed the Risk Treatment Plan fingerprint (6.1.3). + title: true, + category: true, + status: true, + likelihood: true, + impact: true, + treatmentStrategy: true, + treatmentStrategyDescription: true, + assigneeId: true, + assignee: { select: { user: { select: { name: true, email: true } } } }, + }, }), - db.employeeTrainingVideoCompletion.count({ + dbc.employeeTrainingVideoCompletion.count({ where: { member: { organizationId } }, }), - db.frameworkEditorFramework.findUnique({ + dbc.frameworkEditorFramework.findUnique({ where: { id: frameworkId }, select: { name: true }, }), - db.ismsProfile.findUnique({ + dbc.ismsProfile.findUnique({ where: { organizationId_frameworkId: { organizationId, frameworkId } }, select: { answers: true }, }), - db.ismsInterestedParty.findMany({ + dbc.ismsInterestedParty.findMany({ where: { document: { organizationId, @@ -77,6 +117,10 @@ export async function collectPlatformData({ }, select: { id: true, name: true, category: true }, }), + dbc.riskAcceptance.findMany({ + where: { organizationId }, + select: { id: true, riskId: true, vendorId: true }, + }), ]); const frameworkNames = new Set(); @@ -124,6 +168,11 @@ export async function collectPlatformData({ hasTrainingProgram: trainingCompletionCount > 0, wizardAnswers: parseStoredAnswers(profile?.answers), partiesFingerprint: fingerprintParties(partiesRows), + riskTreatmentFingerprint: fingerprintRiskTreatment({ + risks, + vendors, + acceptances: acceptanceRows, + }), }; } @@ -145,3 +194,114 @@ function fingerprintParties( .join(''); return createHash('sha256').update(canonical).digest('hex'); } + +/** + * Stable, order-insensitive SHA-256 over everything the Risk Treatment Plan + * (6.1.3) renders: non-archived Risk Register rows, vendor risk fields, and + * acceptance events (append-only, so their ids alone capture "a new acceptance + * was recorded"). Same canonicalization as fingerprintParties: JSON-encoded + * rows, sorted, so field boundaries can't collide and row order is irrelevant. + * Two subtleties: (1) archived risks leave the plan, so archiving changes the + * row set (= drift) while later edits to an archived risk stay invisible — + * acceptance rows are filtered to the RENDERED subjects for the same reason; + * (2) the fingerprint carries the rendered owner DISPLAY value (not the id), + * so a member rename that changes the exported owner cell also drifts. + */ +function fingerprintRiskTreatment({ + risks, + vendors, + acceptances, +}: { + risks: Array<{ + id: string; + title: string; + category: string; + status: string; + likelihood: string; + impact: string; + residualLikelihood: string; + residualImpact: string; + treatmentStrategy: string; + treatmentStrategyDescription: string | null; + assigneeId: string | null; + assignee: { user: { name: string | null; email: string } } | null; + }>; + vendors: Array<{ + id: string; + name: string; + category: string; + status: string; + inherentProbability: string; + inherentImpact: string; + residualProbability: string; + residualImpact: string; + treatmentStrategy: string; + treatmentStrategyDescription: string | null; + assigneeId: string | null; + assignee: { user: { name: string | null; email: string } } | null; + }>; + acceptances: Array<{ + id: string; + riskId: string | null; + vendorId: string | null; + }>; +}): string { + const ownerDisplay = ( + assignee: { user: { name: string | null; email: string } } | null, + ): string => (assignee ? assignee.user.name?.trim() || assignee.user.email : ''); + const renderedRisks = risks.filter((risk) => risk.status !== 'archived'); + const renderedSubjectIds = new Set([ + ...renderedRisks.map((risk) => risk.id), + ...vendors.map((vendor) => vendor.id), + ]); + const rows = [ + ...renderedRisks.map((risk) => + JSON.stringify([ + 'risk', + risk.id, + risk.title, + risk.category, + risk.status, + risk.likelihood, + risk.impact, + risk.residualLikelihood, + risk.residualImpact, + risk.treatmentStrategy, + risk.treatmentStrategyDescription ?? '', + risk.assigneeId ?? '', + ownerDisplay(risk.assignee), + ]), + ), + ...vendors.map((vendor) => + JSON.stringify([ + 'vendor', + vendor.id, + vendor.name, + vendor.category, + vendor.status, + vendor.inherentProbability, + vendor.inherentImpact, + vendor.residualProbability, + vendor.residualImpact, + vendor.treatmentStrategy, + vendor.treatmentStrategyDescription ?? '', + vendor.assigneeId ?? '', + ownerDisplay(vendor.assignee), + ]), + ), + ...acceptances + .filter((acceptance) => + renderedSubjectIds.has(acceptance.riskId ?? acceptance.vendorId ?? ''), + ) + .map((acceptance) => + JSON.stringify([ + 'acceptance', + acceptance.id, + acceptance.riskId ?? '', + acceptance.vendorId ?? '', + ]), + ), + ]; + if (rows.length === 0) return ''; + return createHash('sha256').update(rows.sort().join('')).digest('hex'); +} diff --git a/apps/api/src/isms/documents/generate.spec.ts b/apps/api/src/isms/documents/generate.spec.ts index 163aba2e44..3eb24a87b5 100644 --- a/apps/api/src/isms/documents/generate.spec.ts +++ b/apps/api/src/isms/documents/generate.spec.ts @@ -23,6 +23,7 @@ const data: IsmsPlatformData = { hasTrainingProgram: true, wizardAnswers: {}, partiesFingerprint: '', + riskTreatmentFingerprint: '', }; function registerTable() { diff --git a/apps/api/src/isms/documents/generate.ts b/apps/api/src/isms/documents/generate.ts index 98d2861920..9bd296234e 100644 --- a/apps/api/src/isms/documents/generate.ts +++ b/apps/api/src/isms/documents/generate.ts @@ -276,6 +276,11 @@ export async function runDerivation({ await generateNarrative({ tx, documentId, type, data }); return; } + if (type === 'risk_treatment_plan') { + // Nothing is stored on generate: the plan renders live from the Risk + // Register + Vendors at export/snapshot time (loadRiskTreatmentExtras). + return; + } if (isNarrativeType(type)) { await generateNarrative({ tx, documentId, type, data }); return; diff --git a/apps/api/src/isms/documents/interested-parties.spec.ts b/apps/api/src/isms/documents/interested-parties.spec.ts index aa46fea46a..47b7afe405 100644 --- a/apps/api/src/isms/documents/interested-parties.spec.ts +++ b/apps/api/src/isms/documents/interested-parties.spec.ts @@ -20,6 +20,7 @@ const data: IsmsPlatformData = { hasTrainingProgram: true, wizardAnswers: {}, partiesFingerprint: '', + riskTreatmentFingerprint: '', }; describe('deriveInterestedParties', () => { diff --git a/apps/api/src/isms/documents/narrative.spec.ts b/apps/api/src/isms/documents/narrative.spec.ts index bfafca0dcc..21e5b37991 100644 --- a/apps/api/src/isms/documents/narrative.spec.ts +++ b/apps/api/src/isms/documents/narrative.spec.ts @@ -26,6 +26,7 @@ const data: IsmsPlatformData = { hasTrainingProgram: true, wizardAnswers: {}, partiesFingerprint: '', + riskTreatmentFingerprint: '', }; const emptyInput = (narrative: unknown): DocumentExportInput => ({ diff --git a/apps/api/src/isms/documents/objectives.spec.ts b/apps/api/src/isms/documents/objectives.spec.ts index 74405b818d..4ad34df231 100644 --- a/apps/api/src/isms/documents/objectives.spec.ts +++ b/apps/api/src/isms/documents/objectives.spec.ts @@ -17,6 +17,7 @@ const data: IsmsPlatformData = { hasTrainingProgram: true, wizardAnswers: {}, partiesFingerprint: '', + riskTreatmentFingerprint: '', }; describe('deriveObjectives', () => { diff --git a/apps/api/src/isms/documents/registry.ts b/apps/api/src/isms/documents/registry.ts index 16932b2f5b..d119640930 100644 --- a/apps/api/src/isms/documents/registry.ts +++ b/apps/api/src/isms/documents/registry.ts @@ -27,12 +27,19 @@ import { deriveLeadershipNarrative, leadershipNarrativeSchema, } from './leadership'; +import { + buildRiskMethodologySections, + deriveRiskMethodologyNarrative, + riskMethodologyNarrativeSchema, +} from './risk-methodology'; +import { buildRiskTreatmentPlanSections } from './risk-treatment-plan'; import type { DocumentExportInput, IsmsPlatformData } from './types'; /** Document types whose content is a singleton narrative stored in version.narrative. */ const NARRATIVE_TYPES: IsmsDocumentType[] = [ 'isms_scope', 'leadership_commitment', + 'risk_assessment_methodology', ]; const EXPORT_SECTION_BUILDERS: Record< @@ -49,6 +56,8 @@ const EXPORT_SECTION_BUILDERS: Record< management_review: buildManagementReviewSections, isms_scope: buildScopeSections, leadership_commitment: buildLeadershipSections, + risk_assessment_methodology: buildRiskMethodologySections, + risk_treatment_plan: buildRiskTreatmentPlanSections, }; export function buildExportSections({ @@ -75,6 +84,9 @@ export function narrativeSchemaForType( if (type === 'leadership_commitment') return leadershipNarrativeSchema; if (type === 'internal_audit') return internalAuditNarrativeSchema; if (type === 'management_review') return managementReviewNarrativeSchema; + if (type === 'risk_assessment_methodology') { + return riskMethodologyNarrativeSchema; + } return null; } @@ -90,6 +102,9 @@ export function deriveNarrativeForType({ if (type === 'leadership_commitment') return deriveLeadershipNarrative(data); if (type === 'internal_audit') return deriveInternalAuditNarrative(data); if (type === 'management_review') return deriveManagementReviewNarrative(data); + if (type === 'risk_assessment_methodology') { + return deriveRiskMethodologyNarrative(data); + } return null; } diff --git a/apps/api/src/isms/documents/requirements.spec.ts b/apps/api/src/isms/documents/requirements.spec.ts index 1ac105db0d..686727be45 100644 --- a/apps/api/src/isms/documents/requirements.spec.ts +++ b/apps/api/src/isms/documents/requirements.spec.ts @@ -17,6 +17,7 @@ const data: IsmsPlatformData = { hasTrainingProgram: true, wizardAnswers: {}, partiesFingerprint: '', + riskTreatmentFingerprint: '', }; describe('deriveRequirements', () => { diff --git a/apps/api/src/isms/documents/risk-methodology-defaults.ts b/apps/api/src/isms/documents/risk-methodology-defaults.ts new file mode 100644 index 0000000000..380fce36b0 --- /dev/null +++ b/apps/api/src/isms/documents/risk-methodology-defaults.ts @@ -0,0 +1,102 @@ +// Seeded default text for the Risk Assessment Methodology document (6.1.2). +// Adapted from the CS-727 reference document, aligned to the platform's REAL +// behavior: the five-band risk level scale (very-low .. very-high, computed as +// likelihood x impact in apps/api/src/risks/risk-level.ts) and the platform's +// four treatment strategies (mitigate/avoid/transfer/accept) with their ISO +// 27001 option names (Modify/Avoid/Share/Retain). Keep text ASCII-safe: the +// PDF renderer's standard fonts cannot render glyphs like ">=" ligatures. + +/** Platform likelihood levels, in ascending order (matches the Likelihood enum). */ +export const METHODOLOGY_LIKELIHOOD_LABELS = [ + '1 - Very unlikely', + '2 - Unlikely', + '3 - Possible', + '4 - Likely', + '5 - Very likely', +] as const; + +/** Platform impact levels, in ascending order (matches the Impact enum). */ +export const METHODOLOGY_IMPACT_LABELS = [ + '1 - Insignificant', + '2 - Minor', + '3 - Moderate', + '4 - Major', + '5 - Severe', +] as const; + +/** Platform risk-level bands, in ascending order (matches risk-level.ts). */ +export const METHODOLOGY_LEVEL_LABELS = [ + 'Very low', + 'Low', + 'Medium', + 'High', + 'Very high', +] as const; + +/** + * Platform treatment strategies in the reference document's display order, + * with the equivalent ISO 27001 treatment option named alongside. + */ +export const METHODOLOGY_TREATMENT_LABELS = [ + 'Mitigate (ISO 27001: Modify)', + 'Avoid (ISO 27001: Avoid)', + 'Transfer (ISO 27001: Share)', + 'Accept (ISO 27001: Retain)', +] as const; + +export const DEFAULT_LIKELIHOOD_DESCRIPTIONS: string[] = [ + 'Very unlikely to occur; may occur only in exceptional circumstances (e.g. once every 5+ years).', + 'Not expected to occur but possible (e.g. once every 2-5 years).', + 'Could occur under normal circumstances (e.g. once per year).', + 'Will probably occur in most circumstances (e.g. multiple times per year).', + 'Expected to occur in most circumstances (e.g. monthly or more often).', +]; + +export const DEFAULT_IMPACT_DESCRIPTIONS: string[] = [ + 'Negligible operational, financial, legal, or reputational impact. Resolvable within hours.', + 'Limited impact on a single function; short-term inconvenience; no external notification required.', + 'Noticeable disruption to one or more functions; possible customer impact; short-term reputational effect.', + 'Significant disruption to the service; customer-visible impact; regulator notification may be triggered; measurable financial loss.', + 'Existential impact on the organisation; extended service outage; multi-jurisdiction regulator engagement; significant financial or reputational loss.', +]; + +/** Acceptance requirement per risk-level band (very-low .. very-high). */ +export const DEFAULT_ACCEPTANCE_THRESHOLDS: string[] = [ + 'Accepted by default. Recorded in the register; no formal owner acceptance event required.', + 'Accepted by default. Recorded in the register; a risk-owner acceptance event is recommended.', + 'Accepted with risk-owner sign-off. An owner acceptance event is recorded in Comp AI.', + 'Must be treated. If retained on business grounds, requires top-management approval and a documented acceptance event.', + 'Must be treated. Retention only in exceptional circumstances with top-management approval; escalated to the next Management Review.', +]; + +/** Description per treatment strategy (order matches METHODOLOGY_TREATMENT_LABELS). */ +export const DEFAULT_TREATMENT_OPTIONS: string[] = [ + 'Implement or strengthen controls to reduce the likelihood or impact of the risk.', + 'Discontinue or redesign the activity giving rise to the risk.', + 'Transfer or share the risk, for example via insurance or a supplier contract clause.', + 'Accept the risk at its current level. Requires acceptance per the thresholds above.', +]; + +export function defaultMethodologyPurpose(organizationName: string): string { + return `This document describes the methodology by which ${organizationName} identifies, analyses, evaluates, and treats information-security risks, in accordance with ISO/IEC 27001:2022, Clause 6.1.2. It is the reference document for the Risk Register held in Comp AI and for the Risk Treatment Plan (Clause 6.1.3).`; +} + +export function defaultMethodologyScope(): string { + return "This methodology applies to all information-security risks within the ISMS scope, including risks arising from the organisation's own operations, its people, its information assets, and its third-party suppliers and sub-processors. It covers organisational risks recorded in the Risk Register and supplier risks recorded in the Vendors module."; +} + +export function defaultMethodologyApproach(organizationName: string): string { + return `${organizationName} uses an asset-based risk assessment approach. Risks are identified against the information assets, systems, and processes within the ISMS scope. For each asset or process, threats and vulnerabilities are considered and translated into risk statements; supplier risks are assessed separately against each vendor. Every risk is assessed for its inherent level (before treatment) and its residual level (after treatment), and both are recorded in Comp AI.`; +} + +export function defaultMethodologyResponsibilities(): string { + return 'Every risk in the register has a named risk owner. Risk owners are responsible for: (a) understanding the risk and the treatment applied; (b) approving the treatment plan; (c) formally accepting the residual risk via the acceptance event recorded in Comp AI; and (d) escalating to the Security & Privacy Owner or top management if the risk changes materially.'; +} + +export function defaultMethodologyFrequency(): string { + return 'Risks are reviewed at least quarterly by the Security & Privacy Owner for changes in likelihood, impact, or treatment status; formally annually at the Management Review (Clause 9.3), where trends and outstanding acceptances are considered; and ad hoc on any material change to the ISMS scope, the vendor register, or the regulatory environment, or after a security incident.'; +} + +export function defaultMethodologyDocumentation(): string { + return 'All risks, their treatments, and their acceptance events are held in Comp AI. Two rendered documents derive from that data: this Risk Assessment Methodology (Clause 6.1.2) and the Risk Treatment Plan (Clause 6.1.3), which is rendered from the Risk Register and Vendor Risks and shows the current treatment and residual state for every risk in scope. Acceptance events are timestamped and immutable; superseded acceptances are retained, so the risk position at any prior point in time can be evidenced.'; +} diff --git a/apps/api/src/isms/documents/risk-methodology.spec.ts b/apps/api/src/isms/documents/risk-methodology.spec.ts new file mode 100644 index 0000000000..0c2b38ab84 --- /dev/null +++ b/apps/api/src/isms/documents/risk-methodology.spec.ts @@ -0,0 +1,131 @@ +import type { IsmsPlatformData } from './types'; +import { + buildRiskMethodologySections, + defaultRiskMethodologyNarrative, + deriveRiskMethodologyNarrative, + riskMethodologyNarrativeSchema, +} from './risk-methodology'; + +const data: IsmsPlatformData = { + organizationName: 'Acme Corp', + frameworkNames: ['ISO 27001'], + vendorCount: 0, + subProcessorCount: 0, + vendorsByCategory: {}, + subProcessorNames: [], + infraVendorNames: [], + memberCount: 3, + membersByDepartment: {}, + deviceCount: 0, + riskCount: 0, + highRiskCount: 0, + hasTrainingProgram: false, + wizardAnswers: {}, + partiesFingerprint: '', + riskTreatmentFingerprint: '', +}; + +describe('deriveRiskMethodologyNarrative', () => { + it('produces a schema-valid, org-name-aware default narrative', () => { + const narrative = deriveRiskMethodologyNarrative(data); + + expect(riskMethodologyNarrativeSchema.safeParse(narrative).success).toBe( + true, + ); + expect(narrative.purpose).toContain('Acme Corp'); + expect(narrative.approach).toContain('Acme Corp'); + expect(narrative.likelihoodDescriptions).toHaveLength(5); + expect(narrative.impactDescriptions).toHaveLength(5); + expect(narrative.acceptanceThresholds).toHaveLength(5); + expect(narrative.treatmentOptions).toHaveLength(4); + }); + + it('matches the seed-heal default used by ensure-setup', () => { + expect(deriveRiskMethodologyNarrative(data)).toEqual( + defaultRiskMethodologyNarrative('Acme Corp'), + ); + }); +}); + +describe('buildRiskMethodologySections', () => { + const input = { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: defaultRiskMethodologyNarrative('Acme Corp'), + }; + + it('renders the twelve reference-document sections in order', () => { + const sections = buildRiskMethodologySections(input); + + expect(sections.map((section) => section.heading)).toEqual([ + 'Purpose', + 'Scope', + 'Risk assessment approach', + 'Likelihood scale', + 'Impact scale', + 'Risk level matrix', + 'Acceptance thresholds', + 'Treatment options', + 'Risk-owner responsibilities', + 'Frequency of assessment', + 'Documentation approach', + 'Sign-off', + ]); + }); + + it('renders the 5x5 matrix with the platform banding and aligned cell fills', () => { + const sections = buildRiskMethodologySections(input); + const matrix = sections.find( + (section) => section.heading === 'Risk level matrix', + ); + + expect(matrix?.table?.headers).toEqual([ + '', + 'Impact 1', + 'Impact 2', + 'Impact 3', + 'Impact 4', + 'Impact 5', + ]); + expect(matrix?.table?.rows).toHaveLength(5); + // Rows run likelihood 5 -> 1; banding = score bands (matches + // RiskScoreBadge / TreatmentHero, not the raw getRiskLevel bands). + expect(matrix?.table?.rows?.[0]?.[0]).toBe('Likelihood 5'); + expect(matrix?.table?.rows?.[0]?.[5]).toBe('Very high'); // 25 -> score 10 + expect(matrix?.table?.rows?.[4]?.[1]).toBe('Very low'); // 1 -> score 1 + expect(matrix?.table?.rows?.[2]?.[3]).toBe('Low'); // 9 -> score 4 + expect(matrix?.table?.rows?.[1]?.[3]).toBe('Medium'); // 12 -> score 5 + expect(matrix?.table?.rows?.[0]?.[4]).toBe('High'); // 20 -> score 8 + // cellFills align with rows; the label column carries no fill. + expect(matrix?.table?.cellFills).toHaveLength(5); + expect(matrix?.table?.cellFills?.[0]?.[0]).toBeNull(); + expect(matrix?.table?.cellFills?.[0]?.[5]).toMatch(/^[0-9A-F]{6}$/); + }); + + it('pairs the fixed level labels with the editable threshold text', () => { + const sections = buildRiskMethodologySections(input); + const thresholds = sections.find( + (section) => section.heading === 'Acceptance thresholds', + ); + + expect(thresholds?.table?.rows?.map((row) => row[0])).toEqual([ + 'Very low', + 'Low', + 'Medium', + 'High', + 'Very high', + ]); + }); + + it('falls back to emptyText when the narrative is missing or invalid', () => { + const sections = buildRiskMethodologySections({ + ...input, + narrative: null, + }); + + expect(sections).toHaveLength(1); + expect(sections[0].emptyText).toBe('No methodology content saved.'); + }); +}); diff --git a/apps/api/src/isms/documents/risk-methodology.ts b/apps/api/src/isms/documents/risk-methodology.ts new file mode 100644 index 0000000000..2ff5abd58c --- /dev/null +++ b/apps/api/src/isms/documents/risk-methodology.ts @@ -0,0 +1,214 @@ +import { z } from 'zod'; +import { + getRiskLevelFromScore, + LEVEL_LABEL, + type RiskLevel, +} from '../../risks/risk-level'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { DocumentExportInput, IsmsPlatformData } from './types'; +import { + DEFAULT_ACCEPTANCE_THRESHOLDS, + DEFAULT_IMPACT_DESCRIPTIONS, + DEFAULT_LIKELIHOOD_DESCRIPTIONS, + DEFAULT_TREATMENT_OPTIONS, + defaultMethodologyApproach, + defaultMethodologyDocumentation, + defaultMethodologyFrequency, + defaultMethodologyPurpose, + defaultMethodologyResponsibilities, + defaultMethodologyScope, + METHODOLOGY_IMPACT_LABELS, + METHODOLOGY_LEVEL_LABELS, + METHODOLOGY_LIKELIHOOD_LABELS, + METHODOLOGY_TREATMENT_LABELS, +} from './risk-methodology-defaults'; + +/** + * Narrative shape persisted in IsmsDocument.draftNarrative for + * risk_assessment_methodology (6.1.2). Every field is customer-editable + * seeded text. The level/option LABELS and the 5x5 risk matrix are NOT part + * of the narrative: they describe how the platform actually computes risk + * levels (risk-level.ts), so they render from fixed constants — an editable + * copy could contradict the product's real behavior. + */ +export const riskMethodologyNarrativeSchema = z.object({ + purpose: z.string(), + scope: z.string(), + approach: z.string(), + /** One description per likelihood level, ascending (very_unlikely .. very_likely). */ + likelihoodDescriptions: z.array(z.string()).length(5), + /** One description per impact level, ascending (insignificant .. severe). */ + impactDescriptions: z.array(z.string()).length(5), + /** One acceptance requirement per risk-level band, ascending (very-low .. very-high). */ + acceptanceThresholds: z.array(z.string()).length(5), + /** One description per treatment option (mitigate, avoid, transfer, accept). */ + treatmentOptions: z.array(z.string()).length(4), + responsibilities: z.string(), + frequency: z.string(), + documentation: z.string(), +}); + +export type RiskMethodologyNarrative = z.infer< + typeof riskMethodologyNarrativeSchema +>; + +/** Derive the default methodology narrative (all templated, org-name aware). */ +export function deriveRiskMethodologyNarrative( + data: IsmsPlatformData, +): RiskMethodologyNarrative { + return defaultRiskMethodologyNarrative(data.organizationName); +} + +export function defaultRiskMethodologyNarrative( + organizationName: string, +): RiskMethodologyNarrative { + return { + purpose: defaultMethodologyPurpose(organizationName), + scope: defaultMethodologyScope(), + approach: defaultMethodologyApproach(organizationName), + likelihoodDescriptions: [...DEFAULT_LIKELIHOOD_DESCRIPTIONS], + impactDescriptions: [...DEFAULT_IMPACT_DESCRIPTIONS], + acceptanceThresholds: [...DEFAULT_ACCEPTANCE_THRESHOLDS], + treatmentOptions: [...DEFAULT_TREATMENT_OPTIONS], + responsibilities: defaultMethodologyResponsibilities(), + frequency: defaultMethodologyFrequency(), + documentation: defaultMethodologyDocumentation(), + }; +} + +/** Soft fill per risk level for the matrix cells (hex, no '#'; black text stays legible). */ +export const LEVEL_FILL_HEX: Record = { + 'very-low': 'DCFCE7', + low: 'ECFCCB', + medium: 'FEF9C3', + high: 'FFEDD5', + 'very-high': 'FEE2E2', +}; + +/** + * The 5x5 risk level matrix, computed from the SAME banding the product's + * badges and treatment-plan hero use (risk-level.ts getRiskLevelFromScore over + * the normalized 1-10 score). Rows run likelihood 5 -> 1 (reference-document + * orientation); the first column is the row label. + */ +function riskMatrixTable(): NonNullable { + const headers = [ + '', + ...METHODOLOGY_IMPACT_LABELS.map((_, index) => `Impact ${index + 1}`), + ]; + const rows: string[][] = []; + const cellFills: (string | null)[][] = []; + for (let likelihood = 5; likelihood >= 1; likelihood -= 1) { + const row: string[] = [`Likelihood ${likelihood}`]; + const fills: (string | null)[] = [null]; + for (let impact = 1; impact <= 5; impact += 1) { + const level = getRiskLevelFromScore( + Math.max(1, Math.ceil((likelihood * impact) / 2.5)), + ); + row.push(LEVEL_LABEL[level]); + fills.push(LEVEL_FILL_HEX[level]); + } + rows.push(row); + cellFills.push(fills); + } + return { headers, rows, cellFills }; +} + +/** Pair fixed level/option labels with the narrative's editable descriptions. */ +function labelledRows( + labels: readonly string[], + descriptions: string[], +): string[][] { + return labels.map((label, index) => [label, descriptions[index] ?? '']); +} + +export function buildRiskMethodologySections( + input: DocumentExportInput, +): IsmsExportSection[] { + const parsed = riskMethodologyNarrativeSchema.safeParse(input.narrative); + if (!parsed.success) { + return [ + { + heading: 'Risk Assessment Methodology', + emptyText: 'No methodology content saved.', + }, + ]; + } + const narrative = parsed.data; + + return [ + { heading: 'Purpose', paragraphs: [{ text: narrative.purpose }] }, + { heading: 'Scope', paragraphs: [{ text: narrative.scope }] }, + { + heading: 'Risk assessment approach', + paragraphs: [{ text: narrative.approach }], + }, + { + heading: 'Likelihood scale', + intro: 'Each risk is assessed on a five-point likelihood scale:', + table: { + headers: ['Level', 'Description'], + rows: labelledRows( + METHODOLOGY_LIKELIHOOD_LABELS, + narrative.likelihoodDescriptions, + ), + }, + }, + { + heading: 'Impact scale', + intro: 'Each risk is assessed on a five-point impact scale:', + table: { + headers: ['Level', 'Description'], + rows: labelledRows( + METHODOLOGY_IMPACT_LABELS, + narrative.impactDescriptions, + ), + }, + }, + { + heading: 'Risk level matrix', + intro: + 'The risk level is derived from the product of likelihood and impact (1-25), normalized to a 1-10 score (the product divided by 2.5, rounded up) and banded by score: Very low (score 1-2), Low (3-4), Medium (5-6), High (7-8), Very high (9-10). Expressed as the raw product, the bands are: Very low 1-5, Low 6-10, Medium 11-15, High 16-20, Very high 21-25. Each risk in the register carries its calculated level for both its inherent and residual states.', + table: riskMatrixTable(), + }, + { + heading: 'Acceptance thresholds', + intro: 'Risk levels trigger different acceptance requirements:', + table: { + headers: ['Risk level', 'Acceptance requirement'], + rows: labelledRows( + METHODOLOGY_LEVEL_LABELS, + narrative.acceptanceThresholds, + ), + }, + }, + { + heading: 'Treatment options', + intro: 'For each risk, one of four treatment options is selected:', + paragraphs: METHODOLOGY_TREATMENT_LABELS.map((label, index) => ({ + label: `${label} - `, + text: narrative.treatmentOptions[index] ?? '', + })), + }, + { + heading: 'Risk-owner responsibilities', + paragraphs: [{ text: narrative.responsibilities }], + }, + { + heading: 'Frequency of assessment', + paragraphs: [{ text: narrative.frequency }], + }, + { + heading: 'Documentation approach', + paragraphs: [{ text: narrative.documentation }], + }, + { + heading: 'Sign-off', + paragraphs: [ + { + text: 'This methodology is prepared by the Security & Privacy Owner and approved by top management; the approver and approval date are recorded in the document control table above. It is reviewed at least annually and when the risk landscape or the assessment approach materially changes.', + }, + ], + }, + ]; +} diff --git a/apps/api/src/isms/documents/risk-treatment-export-data.spec.ts b/apps/api/src/isms/documents/risk-treatment-export-data.spec.ts new file mode 100644 index 0000000000..7e7a40a91e --- /dev/null +++ b/apps/api/src/isms/documents/risk-treatment-export-data.spec.ts @@ -0,0 +1,171 @@ +const mockDb = { + risk: { findMany: jest.fn() }, + vendor: { findMany: jest.fn() }, + riskAcceptance: { findMany: jest.fn() }, +}; + +jest.mock('@db', () => ({ db: mockDb })); + +import { loadRiskTreatmentExtras } from './risk-treatment-export-data'; + +const ORG = 'org_1'; + +const baseRisk = { + id: 'rsk_1', + title: 'Cloud misconfiguration', + category: 'technology', + status: 'open', + likelihood: 'possible', + impact: 'major', + residualLikelihood: 'unlikely', + residualImpact: 'minor', + treatmentStrategy: 'mitigate', + treatmentStrategyDescription: 'IaC review; drift detection.', + assignee: { user: { name: 'Jane Doe', email: 'jane@acme.com' } }, +}; + +const baseVendor = { + id: 'vnd_1', + name: 'AWS', + category: 'cloud', + status: 'assessed', + inherentProbability: 'likely', + inherentImpact: 'major', + residualProbability: 'possible', + residualImpact: 'moderate', + treatmentStrategy: 'mitigate', + treatmentStrategyDescription: 'Shared-responsibility model.', + assignee: null, +}; + +function seed({ + risks = [baseRisk], + vendors = [baseVendor], + acceptances = [] as Array>, +} = {}) { + mockDb.risk.findMany.mockResolvedValue(risks); + mockDb.vendor.findMany.mockResolvedValue(vendors); + mockDb.riskAcceptance.findMany.mockResolvedValue(acceptances); +} + +describe('loadRiskTreatmentExtras', () => { + beforeEach(() => jest.clearAllMocks()); + + it('resolves levels, humanized labels, references, and owner names', async () => { + seed(); + + const extras = await loadRiskTreatmentExtras({ organizationId: ORG }); + + expect(extras.risks).toHaveLength(1); + expect(extras.risks[0]).toMatchObject({ + reference: 'R-01', + title: 'Cloud misconfiguration', + category: 'Technology', + inherentLevel: 'Medium', // possible(3) x major(4) = raw 12 -> score 5 + treatment: 'Mitigate', + controls: 'IaC review; drift detection.', + ownerName: 'Jane Doe', + residualLevel: 'Very low', // unlikely(2) x minor(2) = raw 4 -> score 2 + acceptance: 'Awaiting acceptance', + acceptanceState: 'awaiting', + status: 'Open', + }); + expect(extras.vendors[0]).toMatchObject({ + name: 'AWS', + inherentLevel: 'High', // likely(4) x major(4) = raw 16 -> score 7 + residualLevel: 'Low', // possible(3) x moderate(3) = raw 9 -> score 4 + ownerName: '—', + status: 'Assessed', + }); + }); + + it('excludes archived risks from the plan', async () => { + seed(); + await loadRiskTreatmentExtras({ organizationId: ORG }); + + expect(mockDb.risk.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizationId: ORG, status: { not: 'archived' } }, + }), + ); + }); + + it('marks the latest acceptance stale when the residual rating moved', async () => { + seed({ + acceptances: [ + { + riskId: 'rsk_1', + vendorId: null, + acceptedByName: 'Jane Doe', + // Frozen at a different rating than the risk's current residual. + residualLikelihood: 'possible', + residualImpact: 'moderate', + createdAt: new Date('2026-04-15T00:00:00Z'), + }, + ], + }); + + const extras = await loadRiskTreatmentExtras({ organizationId: ORG }); + + expect(extras.risks[0].acceptanceState).toBe('stale'); + expect(extras.risks[0].acceptance).toContain('Stale'); + expect(extras.risks[0].acceptance).toContain('2026-04-15'); + }); + + it('uses the newest acceptance per subject (rows arrive newest first)', async () => { + seed({ + acceptances: [ + { + riskId: 'rsk_1', + vendorId: null, + acceptedByName: 'Jane Doe', + residualLikelihood: 'unlikely', + residualImpact: 'minor', + createdAt: new Date('2026-05-01T00:00:00Z'), + }, + { + riskId: 'rsk_1', + vendorId: null, + acceptedByName: 'Old Owner', + residualLikelihood: 'possible', + residualImpact: 'moderate', + createdAt: new Date('2026-01-01T00:00:00Z'), + }, + ], + }); + + const extras = await loadRiskTreatmentExtras({ organizationId: ORG }); + + expect(extras.risks[0].acceptanceState).toBe('accepted'); + expect(extras.risks[0].acceptance).toBe('Accepted 2026-05-01 (Jane Doe)'); + }); + + it('resolves vendor acceptances against the vendor residual fields', async () => { + seed({ + acceptances: [ + { + riskId: null, + vendorId: 'vnd_1', + acceptedByName: 'John Smith', + residualLikelihood: 'possible', + residualImpact: 'moderate', + createdAt: new Date('2026-04-15T00:00:00Z'), + }, + ], + }); + + const extras = await loadRiskTreatmentExtras({ organizationId: ORG }); + + expect(extras.vendors[0].acceptanceState).toBe('accepted'); + }); + + it('renders a dash when no treatment description is recorded', async () => { + seed({ + risks: [{ ...baseRisk, treatmentStrategyDescription: ' ' }], + }); + + const extras = await loadRiskTreatmentExtras({ organizationId: ORG }); + + expect(extras.risks[0].controls).toBe('—'); + }); +}); diff --git a/apps/api/src/isms/documents/risk-treatment-export-data.ts b/apps/api/src/isms/documents/risk-treatment-export-data.ts new file mode 100644 index 0000000000..71e61c39fc --- /dev/null +++ b/apps/api/src/isms/documents/risk-treatment-export-data.ts @@ -0,0 +1,185 @@ +import { db } from '@db'; +import type { Impact, Likelihood, Prisma } from '@db'; +import { LEVEL_LABEL, ratingLevel } from '../../risks/risk-level'; +import { formatExportDate } from '../utils/export-shared'; +import type { + AcceptanceExportState, + RiskTreatmentExportRow, + VendorTreatmentExportRow, +} from './types'; + +/** + * Live platform data the Risk Treatment Plan (6.1.3) renders from: the Risk + * Register (all non-archived risks), every vendor's risk fields, and the + * latest acceptance event per subject. Loaded at export-input assembly for + * BOTH snapshot sites (buildDraftSnapshot + createPublishedVersion), so a + * published version freezes the rows as they stood at approval. + */ +export interface RiskTreatmentExtras { + risks: RiskTreatmentExportRow[]; + vendors: VendorTreatmentExportRow[]; +} + +interface LatestAcceptance { + acceptedByName: string; + residualLikelihood: Likelihood; + residualImpact: Impact; + createdAt: Date; +} + +/** "vendor_management" -> "Vendor management" (shared enum humanizer). */ +function humanizeEnum(value: string): string { + const spaced = value.replace(/_/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +function levelLabel(likelihood: Likelihood, impact: Impact): string { + return LEVEL_LABEL[ratingLevel(likelihood, impact)]; +} + +function ownerName( + assignee: { user: { name: string | null; email: string } } | null, +): string { + if (!assignee) return '—'; + return assignee.user.name?.trim() || assignee.user.email; +} + +/** + * Resolve the acceptance cell + state for a subject. An acceptance is stale + * the moment the live residual rating differs from the rating frozen on the + * latest acceptance event (6.1.3(f) — re-acceptance required). + */ +function resolveAcceptance({ + latest, + residualLikelihood, + residualImpact, +}: { + latest: LatestAcceptance | undefined; + residualLikelihood: Likelihood; + residualImpact: Impact; +}): { acceptance: string; acceptanceState: AcceptanceExportState } { + if (!latest) { + return { acceptance: 'Awaiting acceptance', acceptanceState: 'awaiting' }; + } + const stale = + latest.residualLikelihood !== residualLikelihood || + latest.residualImpact !== residualImpact; + if (stale) { + return { + acceptance: `Stale — accepted ${formatExportDate(latest.createdAt)} (${latest.acceptedByName}); residual has changed since`, + acceptanceState: 'stale', + }; + } + return { + acceptance: `Accepted ${formatExportDate(latest.createdAt)} (${latest.acceptedByName})`, + acceptanceState: 'accepted', + }; +} + +export async function loadRiskTreatmentExtras({ + organizationId, + client, +}: { + organizationId: string; + client?: Prisma.TransactionClient; +}): Promise { + const dbc = client ?? db; + const [risks, vendors, acceptances] = await Promise.all([ + // Archived risks are retired from the register and stay out of the plan. + dbc.risk.findMany({ + where: { organizationId, status: { not: 'archived' } }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + select: { + id: true, + title: true, + category: true, + status: true, + likelihood: true, + impact: true, + residualLikelihood: true, + residualImpact: true, + treatmentStrategy: true, + treatmentStrategyDescription: true, + assignee: { select: { user: { select: { name: true, email: true } } } }, + }, + }), + dbc.vendor.findMany({ + where: { organizationId }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + select: { + id: true, + name: true, + category: true, + status: true, + inherentProbability: true, + inherentImpact: true, + residualProbability: true, + residualImpact: true, + treatmentStrategy: true, + treatmentStrategyDescription: true, + assignee: { select: { user: { select: { name: true, email: true } } } }, + }, + }), + dbc.riskAcceptance.findMany({ + where: { organizationId }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + select: { + riskId: true, + vendorId: true, + acceptedByName: true, + residualLikelihood: true, + residualImpact: true, + createdAt: true, + }, + }), + ]); + + // Newest-first order means the first acceptance seen per subject is the latest. + const latestByRisk = new Map(); + const latestByVendor = new Map(); + for (const acceptance of acceptances) { + if (acceptance.riskId && !latestByRisk.has(acceptance.riskId)) { + latestByRisk.set(acceptance.riskId, acceptance); + } + if (acceptance.vendorId && !latestByVendor.has(acceptance.vendorId)) { + latestByVendor.set(acceptance.vendorId, acceptance); + } + } + + return { + risks: risks.map((risk, index) => ({ + reference: `R-${String(index + 1).padStart(2, '0')}`, + title: risk.title, + category: humanizeEnum(risk.category), + inherentLevel: levelLabel(risk.likelihood, risk.impact), + treatment: humanizeEnum(risk.treatmentStrategy), + controls: risk.treatmentStrategyDescription?.trim() || '—', + ownerName: ownerName(risk.assignee), + residualLevel: levelLabel(risk.residualLikelihood, risk.residualImpact), + ...resolveAcceptance({ + latest: latestByRisk.get(risk.id), + residualLikelihood: risk.residualLikelihood, + residualImpact: risk.residualImpact, + }), + status: humanizeEnum(risk.status), + })), + vendors: vendors.map((vendor) => ({ + name: vendor.name, + category: humanizeEnum(vendor.category), + inherentLevel: levelLabel(vendor.inherentProbability, vendor.inherentImpact), + treatment: humanizeEnum(vendor.treatmentStrategy), + controls: vendor.treatmentStrategyDescription?.trim() || '—', + ownerName: ownerName(vendor.assignee), + residualLevel: levelLabel( + vendor.residualProbability, + vendor.residualImpact, + ), + ...resolveAcceptance({ + latest: latestByVendor.get(vendor.id), + residualLikelihood: vendor.residualProbability, + residualImpact: vendor.residualImpact, + }), + status: humanizeEnum(vendor.status), + })), + }; +} diff --git a/apps/api/src/isms/documents/risk-treatment-plan.spec.ts b/apps/api/src/isms/documents/risk-treatment-plan.spec.ts new file mode 100644 index 0000000000..c8f3080bc7 --- /dev/null +++ b/apps/api/src/isms/documents/risk-treatment-plan.spec.ts @@ -0,0 +1,206 @@ +import type { + RiskTreatmentExportRow, + VendorTreatmentExportRow, +} from './types'; +import { + buildRiskTreatmentPlanSections, + riskTreatmentValidationMessages, +} from './risk-treatment-plan'; + +const riskRow = ( + overrides: Partial = {}, +): RiskTreatmentExportRow => ({ + reference: 'R-01', + title: 'Unauthorized data sharing', + category: 'Governance', + inherentLevel: 'Medium', + treatment: 'Mitigate', + controls: 'DLP settings; awareness training.', + ownerName: 'Jane Doe', + residualLevel: 'Low', + acceptance: 'Accepted 2026-04-15 (Jane Doe)', + acceptanceState: 'accepted', + status: 'Open', + ...overrides, +}); + +const vendorRow = ( + overrides: Partial = {}, +): VendorTreatmentExportRow => ({ + name: 'AWS', + category: 'Cloud', + inherentLevel: 'High', + treatment: 'Mitigate', + controls: 'Shared-responsibility model; DPA in place.', + ownerName: 'John Smith', + residualLevel: 'Medium', + acceptance: 'Accepted 2026-04-15 (John Smith)', + acceptanceState: 'accepted', + status: 'Assessed', + ...overrides, +}); + +describe('riskTreatmentValidationMessages', () => { + it('is empty when every risk and vendor has an owner', () => { + expect( + riskTreatmentValidationMessages({ + riskCount: 3, + risksWithoutOwner: 0, + vendorsWithoutOwner: 0, + }), + ).toEqual([]); + }); + + it('requires at least one risk in the register', () => { + expect( + riskTreatmentValidationMessages({ + riskCount: 0, + risksWithoutOwner: 0, + vendorsWithoutOwner: 0, + }), + ).toEqual(['Record at least one risk in the Risk Register.']); + }); + + it('pluralizes owner requirements correctly', () => { + expect( + riskTreatmentValidationMessages({ + riskCount: 5, + risksWithoutOwner: 1, + vendorsWithoutOwner: 2, + }), + ).toEqual([ + '1 risk in the Risk Register needs an owner assigned.', + '2 vendors need an owner assigned.', + ]); + }); +}); + +describe('buildRiskTreatmentPlanSections', () => { + const input = { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + riskTreatment: { + risks: [ + riskRow(), + riskRow({ + reference: 'R-02', + title: 'EU privacy compliance', + acceptance: 'Stale — accepted 2026-04-15 (Jane Doe); residual has changed since', + acceptanceState: 'stale', + residualLevel: 'Medium', + }), + ], + vendors: [ + vendorRow(), + vendorRow({ + name: 'Google Workspace', + acceptance: 'Awaiting acceptance', + acceptanceState: 'awaiting', + }), + ], + }, + }; + + it('renders the six reference-document sections in order', () => { + const sections = buildRiskTreatmentPlanSections(input); + + expect(sections.map((section) => section.heading)).toEqual([ + 'Purpose', + 'Reference', + 'Organisational risks', + 'Supplier risks', + 'Outstanding acceptances', + 'Sign-off', + ]); + }); + + it('renders one 10-column row per risk with acceptance and status cells', () => { + const sections = buildRiskTreatmentPlanSections(input); + const risks = sections.find( + (section) => section.heading === 'Organisational risks', + ); + + expect(risks?.table?.headers).toEqual([ + 'Ref', + 'Description', + 'Cat.', + 'Inh', + 'Treat.', + 'Controls / actions', + 'Owner', + 'Res', + 'Acceptance', + 'Status', + ]); + expect(risks?.table?.rows).toHaveLength(2); + expect(risks?.table?.rows?.[0]).toEqual([ + 'R-01', + 'Unauthorized data sharing', + 'Governance', + 'Medium', + 'Mitigate', + 'DLP settings; awareness training.', + 'Jane Doe', + 'Low', + 'Accepted 2026-04-15 (Jane Doe)', + 'Open', + ]); + expect(risks?.intro).toContain('2 organisational risks are recorded'); + }); + + it('renders per-row Status in the supplier table too', () => { + const sections = buildRiskTreatmentPlanSections(input); + const suppliers = sections.find( + (section) => section.heading === 'Supplier risks', + ); + + expect(suppliers?.table?.headers?.slice(-1)).toEqual(['Status']); + expect(suppliers?.table?.rows?.[0]?.slice(-1)).toEqual(['Assessed']); + }); + + it('lists awaiting and stale rows under Outstanding acceptances', () => { + const sections = buildRiskTreatmentPlanSections(input); + const outstanding = sections.find( + (section) => section.heading === 'Outstanding acceptances', + ); + + expect(outstanding?.bullets).toHaveLength(2); + expect(outstanding?.bullets?.[0]).toContain('R-02'); + expect(outstanding?.bullets?.[0]).toContain('stale'); + expect(outstanding?.bullets?.[1]).toContain('Google Workspace'); + expect(outstanding?.bullets?.[1]).toContain('not yet recorded'); + }); + + it('reports a clean state when every acceptance is current', () => { + const sections = buildRiskTreatmentPlanSections({ + ...input, + riskTreatment: { risks: [riskRow()], vendors: [vendorRow()] }, + }); + const outstanding = sections.find( + (section) => section.heading === 'Outstanding acceptances', + ); + + expect(outstanding?.bullets).toBeUndefined(); + expect(outstanding?.paragraphs?.[0]?.text).toContain('None'); + }); + + it('renders emptyText tables when nothing is recorded yet', () => { + const sections = buildRiskTreatmentPlanSections({ + ...input, + riskTreatment: { risks: [], vendors: [] }, + }); + const risks = sections.find( + (section) => section.heading === 'Organisational risks', + ); + const vendors = sections.find( + (section) => section.heading === 'Supplier risks', + ); + + expect(risks?.table?.rows).toHaveLength(0); + expect(risks?.emptyText).toBe('No risks recorded in the Risk Register.'); + expect(vendors?.emptyText).toBe('No vendors recorded in the Vendors module.'); + }); +}); diff --git a/apps/api/src/isms/documents/risk-treatment-plan.ts b/apps/api/src/isms/documents/risk-treatment-plan.ts new file mode 100644 index 0000000000..3a2161ae29 --- /dev/null +++ b/apps/api/src/isms/documents/risk-treatment-plan.ts @@ -0,0 +1,237 @@ +import type { IsmsExportSection } from '../utils/export-shared'; +import type { + DocumentExportInput, + RiskTreatmentExportRow, + VendorTreatmentExportRow, +} from './types'; + +/** + * Clause-6.1.3 readiness check, shared by the submit-for-approval server gate + * and the client Submit button (risk-treatment-constants.ts mirrors it). The + * ticket requires an owner on every risk and every in-scope vendor risk before + * the plan is generated/approved. Per-risk acceptance events are strongly + * recommended but NOT blocking — rows without one render "Awaiting acceptance". + * (Residual likelihood/impact always carry a value in the platform schema, so + * the ticket's "residual set" requirement is inherently satisfied.) + * Returns unmet requirements; empty = ready. + */ +export function riskTreatmentValidationMessages({ + riskCount, + risksWithoutOwner, + vendorsWithoutOwner, +}: { + riskCount: number; + risksWithoutOwner: number; + vendorsWithoutOwner: number; +}): string[] { + const messages: string[] = []; + if (riskCount === 0) { + messages.push('Record at least one risk in the Risk Register.'); + } + if (risksWithoutOwner === 1) { + messages.push('1 risk in the Risk Register needs an owner assigned.'); + } else if (risksWithoutOwner > 1) { + messages.push( + `${risksWithoutOwner} risks in the Risk Register need an owner assigned.`, + ); + } + if (vendorsWithoutOwner === 1) { + messages.push('1 vendor needs an owner assigned.'); + } else if (vendorsWithoutOwner > 1) { + messages.push(`${vendorsWithoutOwner} vendors need an owner assigned.`); + } + return messages; +} + +function countByState( + rows: Array<{ acceptanceState: string }>, + state: string, +): number { + return rows.filter((row) => row.acceptanceState === state).length; +} + +/** "3 accepted, 1 awaiting acceptance, 1 stale" — acceptance summary for an intro. */ +function acceptanceSummary(rows: Array<{ acceptanceState: string }>): string { + const parts = [ + `${countByState(rows, 'accepted')} with owner acceptance recorded`, + `${countByState(rows, 'awaiting')} awaiting acceptance`, + ]; + const stale = countByState(rows, 'stale'); + if (stale > 0) { + parts.push(`${stale} with a stale acceptance (re-acceptance required)`); + } + return parts.join(', '); +} + +function organisationalRisksIntro(risks: RiskTreatmentExportRow[]): string { + const plural = risks.length === 1 ? 'risk is' : 'risks are'; + return `${risks.length} organisational ${plural} recorded in the Risk Register: ${acceptanceSummary(risks)}.`; +} + +function supplierRisksIntro(vendors: VendorTreatmentExportRow[]): string { + const plural = vendors.length === 1 ? 'supplier is' : 'suppliers are'; + return `${vendors.length} ${plural} recorded in the Vendors module: ${acceptanceSummary(vendors)}.`; +} + +/** Bullet lines for every row whose acceptance is outstanding (awaiting/stale). */ +function outstandingBullets({ + risks, + vendors, +}: { + risks: RiskTreatmentExportRow[]; + vendors: VendorTreatmentExportRow[]; +}): string[] { + const bullets: string[] = []; + for (const risk of risks) { + if (risk.acceptanceState === 'awaiting') { + bullets.push( + `${risk.reference} (${risk.title}) — residual level ${risk.residualLevel}; owner acceptance not yet recorded. Owner: ${risk.ownerName}.`, + ); + } + if (risk.acceptanceState === 'stale') { + bullets.push( + `${risk.reference} (${risk.title}) — residual level changed since the last acceptance; previous acceptance marked stale. Owner (${risk.ownerName}) to record a fresh acceptance.`, + ); + } + } + for (const vendor of vendors) { + if (vendor.acceptanceState === 'awaiting') { + bullets.push( + `${vendor.name} (vendor) — residual level ${vendor.residualLevel}; owner acceptance not yet recorded. Owner: ${vendor.ownerName}.`, + ); + } + if (vendor.acceptanceState === 'stale') { + bullets.push( + `${vendor.name} (vendor) — residual level changed since the last acceptance; previous acceptance marked stale. Owner (${vendor.ownerName}) to record a fresh acceptance.`, + ); + } + } + return bullets; +} + +/** + * Build the Risk Treatment Plan document (clause 6.1.3). Contents and order + * follow the CS-727 reference document: Purpose, Reference (methodology + * cross-reference + cell key), Organisational risks table, Supplier risks + * table, Outstanding acceptances, Sign-off. The tables render from + * input.riskTreatment (loaded live from the Risk Register + Vendors by + * loadRiskTreatmentExtras). Headers use the reference document's + * abbreviations (Cat./Inh/Treat./Res, defined in the cell key) so the + * ticket's full column set — including per-row Status — fits the page. + */ +export function buildRiskTreatmentPlanSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const risks = input.riskTreatment?.risks ?? []; + const vendors = input.riskTreatment?.vendors ?? []; + const outstanding = outstandingBullets({ risks, vendors }); + + return [ + { + heading: 'Purpose', + paragraphs: [ + { + text: 'This document is the Risk Treatment Plan required by ISO/IEC 27001:2022, Clause 6.1.3. It records, for each identified risk, the selected treatment option, the controls and actions applied to implement it, the risk owner, and the residual risk state, with the risk-owner acceptance of residual risk where recorded (Clause 6.1.3(f)).', + }, + { + text: 'The plan is rendered from the Risk Register and the Vendors module in Comp AI and reflects the state of the register at the time of rendering. It is regenerated on demand; any material change to the register after this document was approved is flagged in Comp AI so the plan can be refreshed.', + }, + ], + }, + { + heading: 'Reference', + paragraphs: [ + { + text: 'The scales, risk level matrix, treatment options, and acceptance thresholds used in this plan are defined in the Risk Assessment Methodology (Clause 6.1.2) document.', + }, + { + text: 'Cell key: Cat. = category; Inh = risk level before treatment (inherent); Treat. = treatment option; Res = risk level after treatment (residual). The Acceptance column shows the recorded owner acceptance with its date, "Awaiting acceptance" when none is recorded, or "Stale" when the residual level has changed since the last acceptance (re-acceptance required). Status is the register status of the row.', + }, + ], + }, + { + heading: 'Organisational risks', + intro: risks.length > 0 ? organisationalRisksIntro(risks) : undefined, + emptyText: 'No risks recorded in the Risk Register.', + table: { + headers: [ + 'Ref', + 'Description', + 'Cat.', + 'Inh', + 'Treat.', + 'Controls / actions', + 'Owner', + 'Res', + 'Acceptance', + 'Status', + ], + rows: risks.map((risk) => [ + risk.reference, + risk.title, + risk.category, + risk.inherentLevel, + risk.treatment, + risk.controls, + risk.ownerName, + risk.residualLevel, + risk.acceptance, + risk.status, + ]), + }, + }, + { + heading: 'Supplier risks', + intro: vendors.length > 0 ? supplierRisksIntro(vendors) : undefined, + emptyText: 'No vendors recorded in the Vendors module.', + table: { + headers: [ + 'Vendor', + 'Cat.', + 'Inh', + 'Treat.', + 'Controls / actions', + 'Owner', + 'Res', + 'Acceptance', + 'Status', + ], + rows: vendors.map((vendor) => [ + vendor.name, + vendor.category, + vendor.inherentLevel, + vendor.treatment, + vendor.controls, + vendor.ownerName, + vendor.residualLevel, + vendor.acceptance, + vendor.status, + ]), + }, + }, + { + heading: 'Outstanding acceptances', + ...(outstanding.length > 0 + ? { + intro: + 'The following risks require attention before the next Management Review:', + bullets: outstanding, + } + : { + paragraphs: [ + { + text: 'None — every recorded risk and supplier risk has a current owner acceptance.', + }, + ], + }), + }, + { + heading: 'Sign-off', + paragraphs: [ + { + text: 'This Risk Treatment Plan is approved by top management; the approver and approval date are recorded in the document control table above. Individual risk-owner acceptances of residual risk are recorded per row in the tables above — this sign-off is the overall approval of the plan required by Clause 6.1.3.', + }, + ], + }, + ]; +} diff --git a/apps/api/src/isms/documents/risk-treatment-readiness.ts b/apps/api/src/isms/documents/risk-treatment-readiness.ts new file mode 100644 index 0000000000..0cb7dfca89 --- /dev/null +++ b/apps/api/src/isms/documents/risk-treatment-readiness.ts @@ -0,0 +1,34 @@ +import { db } from '@db'; +import type { Prisma } from '@db'; +import { riskTreatmentValidationMessages } from './risk-treatment-plan'; + +/** + * Clause-6.1.3 readiness: the RTP renders from the platform Risk Register + + * Vendors (org-scoped), not from register rows of its own — so readiness reads + * those tables. Archived risks are out of the plan (see + * loadRiskTreatmentExtras). Shared by the submit gate and the page payload. + */ +export async function loadRiskTreatmentReadinessMessages({ + organizationId, + client, +}: { + organizationId: string; + client?: Prisma.TransactionClient; +}): Promise { + const dbc = client ?? db; + const [risks, vendors] = await Promise.all([ + dbc.risk.findMany({ + where: { organizationId, status: { not: 'archived' } }, + select: { assigneeId: true }, + }), + dbc.vendor.findMany({ + where: { organizationId }, + select: { assigneeId: true }, + }), + ]); + return riskTreatmentValidationMessages({ + riskCount: risks.length, + risksWithoutOwner: risks.filter((risk) => !risk.assigneeId).length, + vendorsWithoutOwner: vendors.filter((vendor) => !vendor.assigneeId).length, + }); +} diff --git a/apps/api/src/isms/documents/snapshot.spec.ts b/apps/api/src/isms/documents/snapshot.spec.ts index d0c52e4b28..727e4582cb 100644 --- a/apps/api/src/isms/documents/snapshot.spec.ts +++ b/apps/api/src/isms/documents/snapshot.spec.ts @@ -17,6 +17,7 @@ const base: IsmsPlatformData = { hasTrainingProgram: true, wizardAnswers: {}, partiesFingerprint: 'fp-base', + riskTreatmentFingerprint: 'rtfp-base', }; describe('diffPlatformSnapshots', () => { @@ -164,6 +165,39 @@ describe('diffPlatformSnapshots', () => { expect(result.changedSources).not.toContain('parties'); }); + it('flags RTP drift when the risk treatment fingerprint changes', () => { + const result = diffPlatformSnapshots({ + type: 'risk_treatment_plan', + previous: base, + current: { ...base, riskTreatmentFingerprint: 'rtfp-edited' }, + }); + expect(result.isStale).toBe(true); + expect(result.changedSources).toEqual(['riskTreatment']); + }); + + it('the RTP ignores every other platform signal', () => { + const result = diffPlatformSnapshots({ + type: 'risk_treatment_plan', + previous: base, + current: { ...base, vendorCount: 99, memberCount: 42 }, + }); + expect(result.isStale).toBe(false); + }); + + it('the methodology (6.1.2) never goes platform-drift stale', () => { + const result = diffPlatformSnapshots({ + type: 'risk_assessment_methodology', + previous: base, + current: { + ...base, + organizationName: 'Renamed Corp', + riskTreatmentFingerprint: 'rtfp-edited', + vendorCount: 99, + }, + }); + expect(result.isStale).toBe(false); + }); + it('only the requirements doc treats parties edits as drift', () => { for (const type of [ 'context_of_organization', diff --git a/apps/api/src/isms/documents/snapshot.ts b/apps/api/src/isms/documents/snapshot.ts index 161bc359e5..fc6066a127 100644 --- a/apps/api/src/isms/documents/snapshot.ts +++ b/apps/api/src/isms/documents/snapshot.ts @@ -109,6 +109,13 @@ const TYPE_DRIFT_SOURCES: Record> = { 'wizardAnswers', ], leadership_commitment: ['organizationName', 'wizardAnswers'], + // The methodology (6.1.2) is customer-owned templated text once seeded; its + // scales/matrix render from fixed platform constants. Never platform-stale. + risk_assessment_methodology: [], + // The RTP (6.1.3) renders straight from the Risk Register + vendor risk + // fields + acceptance events; any of those changing means the approved plan + // "may be out of date" (the ticket's drift signal). + risk_treatment_plan: ['riskTreatment'], }; interface DiffMap { @@ -124,6 +131,7 @@ interface DiffMap { organizationName: boolean; wizardAnswers: boolean; parties: boolean; + riskTreatment: boolean; } function computeChanges({ @@ -157,6 +165,8 @@ function computeChanges({ current.wizardAnswers, ), parties: previous.partiesFingerprint !== current.partiesFingerprint, + riskTreatment: + previous.riskTreatmentFingerprint !== current.riskTreatmentFingerprint, }; } @@ -210,6 +220,7 @@ export function parsePlatformSnapshot( hasTrainingProgram: record.hasTrainingProgram === true, wizardAnswers: parseStoredAnswers(record.wizardAnswers), partiesFingerprint: toStr(record.partiesFingerprint, ''), + riskTreatmentFingerprint: toStr(record.riskTreatmentFingerprint, ''), }; } diff --git a/apps/api/src/isms/documents/types.ts b/apps/api/src/isms/documents/types.ts index e1bfd2d880..3d59e389f1 100644 --- a/apps/api/src/isms/documents/types.ts +++ b/apps/api/src/isms/documents/types.ts @@ -47,6 +47,14 @@ export interface IsmsPlatformData { * register has no rows yet. */ partiesFingerprint: string; + /** + * Stable fingerprint of everything the Risk Treatment Plan (6.1.3) renders: + * Risk Register rows, vendor risk fields, and acceptance events. Any change + * to a risk's rating/treatment/owner, a vendor's risk fields, or a recorded + * acceptance must flag RTP drift ("may be out of date"). Empty string when + * there is nothing to render yet. + */ + riskTreatmentFingerprint: string; } /** A row destined for one of the ISMS registers (interested parties, etc.). */ @@ -262,6 +270,47 @@ export interface ReviewExportRow { carriedForward: ReviewActionExportRow[]; } +/** How a risk's / vendor's latest acceptance stands relative to its live residual rating. */ +export type AcceptanceExportState = 'accepted' | 'awaiting' | 'stale'; + +/** One Risk Register row, resolved for the Risk Treatment Plan (6.1.3). */ +export interface RiskTreatmentExportRow { + /** Display reference generated by register order at build time, e.g. "R-01". */ + reference: string; + title: string; + /** Humanized category label ("Vendor management"). */ + category: string; + /** Level label of the inherent rating ("Medium"). */ + inherentLevel: string; + /** Humanized treatment strategy ("Mitigate"). */ + treatment: string; + /** The active treatment/controls description; "—" when none recorded. */ + controls: string; + /** Owner display name (frozen at build time); "—" when unassigned. */ + ownerName: string; + /** Level label of the residual rating ("Low"). */ + residualLevel: string; + /** Rendered acceptance cell ("Accepted 2026-04-15 (Jane Doe)" / "Awaiting acceptance" / "Stale — ..."). */ + acceptance: string; + acceptanceState: AcceptanceExportState; + /** Humanized register status ("Open"); summarized in the section intro. */ + status: string; +} + +/** One vendor row, resolved for the Risk Treatment Plan's supplier table. */ +export interface VendorTreatmentExportRow { + name: string; + category: string; + inherentLevel: string; + treatment: string; + controls: string; + ownerName: string; + residualLevel: string; + acceptance: string; + acceptanceState: AcceptanceExportState; + status: string; +} + /** * The organization profile that fills the narrative parts of the Context of the * Organization document (clause 4.1) — overview table, mission, intended @@ -317,4 +366,9 @@ export interface DocumentExportInput { audits?: AuditExportRow[]; /** Review instances with resolved names — only populated for the Management Review document (9.3). */ reviews?: ReviewExportRow[]; + /** Risk Register + vendor rows — only populated for the Risk Treatment Plan (6.1.3). */ + riskTreatment?: { + risks: RiskTreatmentExportRow[]; + vendors: VendorTreatmentExportRow[]; + }; } diff --git a/apps/api/src/isms/isms-context.service.spec.ts b/apps/api/src/isms/isms-context.service.spec.ts index b7d46147c3..10bde90025 100644 --- a/apps/api/src/isms/isms-context.service.spec.ts +++ b/apps/api/src/isms/isms-context.service.spec.ts @@ -63,6 +63,7 @@ const snapshot = { hasTrainingProgram: true, wizardAnswers: {}, partiesFingerprint: '', + riskTreatmentFingerprint: '', }; const versionService = { diff --git a/apps/api/src/isms/isms-version.service.spec.ts b/apps/api/src/isms/isms-version.service.spec.ts index a4f5a7b8f6..f8440bf0fc 100644 --- a/apps/api/src/isms/isms-version.service.spec.ts +++ b/apps/api/src/isms/isms-version.service.spec.ts @@ -28,6 +28,7 @@ jest.mock('./utils/export-payload', () => ({ resolveMonitoringExtras: jest.fn(), resolveInternalAuditExtras: jest.fn(), resolveManagementReviewExtras: jest.fn(), + resolveRiskTreatmentExtras: jest.fn(), parseExportSnapshot: jest.fn(() => null), })); jest.mock('./utils/export-metadata', () => ({ diff --git a/apps/api/src/isms/isms-version.service.ts b/apps/api/src/isms/isms-version.service.ts index 2bd67513aa..530342136a 100644 --- a/apps/api/src/isms/isms-version.service.ts +++ b/apps/api/src/isms/isms-version.service.ts @@ -19,6 +19,7 @@ import { resolveManagementReviewExtras, resolveMonitoringExtras, resolveOrgProfile, + resolveRiskTreatmentExtras, resolveRolesExtras, type IsmsExportSnapshot, type LoadedExportDocument, @@ -80,6 +81,7 @@ export class IsmsVersionService { document, tx, ); + const riskTreatmentExtras = await resolveRiskTreatmentExtras(document, tx); const input = buildExportInput({ document, orgProfile, @@ -87,6 +89,7 @@ export class IsmsVersionService { monitoringExtras, internalAuditExtras, managementReviewExtras, + riskTreatmentExtras, }); const metadata = buildExportMetadata({ type: document.type, diff --git a/apps/api/src/isms/isms.controller.permissions.spec.ts b/apps/api/src/isms/isms.controller.permissions.spec.ts index 7a9820255a..13dc141c3d 100644 --- a/apps/api/src/isms/isms.controller.permissions.spec.ts +++ b/apps/api/src/isms/isms.controller.permissions.spec.ts @@ -54,6 +54,9 @@ describe('IsmsController permission metadata', () => { expect(permissionsFor('drift')).toEqual([ { resource: 'evidence', actions: ['read'] }, ]); + expect(permissionsFor('riskTreatment')).toEqual([ + { resource: 'evidence', actions: ['read'] }, + ]); expect(permissionsFor('getVersions')).toEqual([ { resource: 'evidence', actions: ['read'] }, ]); diff --git a/apps/api/src/isms/isms.controller.spec.ts b/apps/api/src/isms/isms.controller.spec.ts index f01ce15224..b62e47f3b9 100644 --- a/apps/api/src/isms/isms.controller.spec.ts +++ b/apps/api/src/isms/isms.controller.spec.ts @@ -68,6 +68,7 @@ describe('IsmsController', () => { const mockIsmsService = { ensureSetup: jest.fn(), getDocument: jest.fn(), + getRiskTreatmentData: jest.fn(), submitForApproval: jest.fn(), approve: jest.fn(), decline: jest.fn(), @@ -305,6 +306,21 @@ describe('IsmsController', () => { }); }); + it('riskTreatment delegates to the isms service', async () => { + mockIsmsService.getRiskTreatmentData.mockResolvedValue({ + risks: [], + vendors: [], + validationMessages: [], + }); + + await controller.riskTreatment('doc_1', 'org_1'); + + expect(mockIsmsService.getRiskTreatmentData).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + }); + }); + it('drift delegates to the context service', async () => { mockContextService.drift.mockResolvedValue({ isStale: false, diff --git a/apps/api/src/isms/isms.controller.ts b/apps/api/src/isms/isms.controller.ts index 00934ff575..91dd8f5a95 100644 --- a/apps/api/src/isms/isms.controller.ts +++ b/apps/api/src/isms/isms.controller.ts @@ -224,6 +224,26 @@ export class IsmsController { return this.contextService.drift({ documentId: id, organizationId }); } + @Get('documents/:id/risk-treatment') + @RequirePermission('evidence', 'read') + @ApiOperation({ + summary: "The Risk Treatment Plan's rows (Clause 6.1.3)", + description: + 'Returns the organisational and supplier risk rows the Risk Treatment Plan renders (from the Risk Register and Vendors, with acceptance state per row) plus its submit-readiness messages.', + }) + @ApiOkResponse({ + description: 'Risk + vendor treatment rows and readiness messages', + }) + async riskTreatment( + @Param('id') id: string, + @OrganizationId() organizationId: string, + ) { + return this.ismsService.getRiskTreatmentData({ + documentId: id, + organizationId, + }); + } + @Get('documents/:id/versions') @RequirePermission('evidence', 'read') @ApiOperation({ diff --git a/apps/api/src/isms/isms.service.approve.spec.ts b/apps/api/src/isms/isms.service.approve.spec.ts index fc6e203815..b76fe11da2 100644 --- a/apps/api/src/isms/isms.service.approve.spec.ts +++ b/apps/api/src/isms/isms.service.approve.spec.ts @@ -16,6 +16,16 @@ jest.mock('@db', () => ({ member: { findFirst: jest.fn() }, $transaction: jest.fn(), }, + Prisma: { + TransactionIsolationLevel: { RepeatableRead: 'RepeatableRead' }, + PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, { code }: { code: string }) { + super(message); + this.code = code; + } + }, + }, })); jest.mock('./documents/data-source', () => ({ collectPlatformData: jest.fn(), @@ -55,6 +65,7 @@ const platformData = { hasTrainingProgram: false, wizardAnswers: {}, partiesFingerprint: '', + riskTreatmentFingerprint: '', }; describe('IsmsService.approve (CS-701 versioning)', () => { @@ -139,9 +150,12 @@ describe('IsmsService.approve (CS-701 versioning)', () => { await service.approve(args); + // The baseline is collected INSIDE the approval transaction (client + // threaded) so it reads the same point in time as the frozen rows. expect(mockCollect).toHaveBeenCalledWith({ organizationId: 'org_1', frameworkId: 'fw_1', + client: expect.anything(), }); // Re-derives in-tx from the same snapshot: persisted rows and the frozen // version come from one pass. diff --git a/apps/api/src/isms/isms.service.spec.ts b/apps/api/src/isms/isms.service.spec.ts index 35acc92c8b..69d0ad6fb5 100644 --- a/apps/api/src/isms/isms.service.spec.ts +++ b/apps/api/src/isms/isms.service.spec.ts @@ -187,6 +187,8 @@ describe('IsmsService ensureSetup', () => { { type: 'monitoring' }, { type: 'internal_audit' }, { type: 'management_review' }, + { type: 'risk_assessment_methodology' }, + { type: 'risk_treatment_plan' }, ]) .mockResolvedValueOnce([ { @@ -334,9 +336,9 @@ describe('IsmsService ensureSetup', () => { await service.ensureSetup(dto); expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); - // 1 template-driven + 9 definition fallbacks: a type shipped before its + // 1 template-driven + 11 definition fallbacks: a type shipped before its // template seed re-runs (e.g. monitoring, CS-723) still provisions. - expect(createManyData()).toHaveLength(10); + expect(createManyData()).toHaveLength(12); expect(createManyData()[0]).toMatchObject({ type: 'context_of_organization', title: 'Context of the Organization', @@ -420,9 +422,9 @@ describe('IsmsService ensureSetup', () => { await service.ensureSetup(dto); - // objectives (template) + 8 definition fallbacks; the existing + // objectives (template) + 10 definition fallbacks; the existing // context_of_organization is skipped. - expect(createManyData()).toHaveLength(9); + expect(createManyData()).toHaveLength(11); expect(createManyData()[0].type).toBe('objectives_plan'); expect( createManyData().map((doc: { type: string }) => doc.type), @@ -523,6 +525,8 @@ describe('IsmsService ensureSetup', () => { { type: 'monitoring' }, { type: 'internal_audit' }, { type: 'management_review' }, + { type: 'risk_assessment_methodology' }, + { type: 'risk_treatment_plan' }, ]) .mockResolvedValueOnce([]); diff --git a/apps/api/src/isms/isms.service.ts b/apps/api/src/isms/isms.service.ts index 25cd90a1c0..a306b54d14 100644 --- a/apps/api/src/isms/isms.service.ts +++ b/apps/api/src/isms/isms.service.ts @@ -24,6 +24,9 @@ import { reviewValidationMessages, } from './documents/management-review'; import { defaultProcedureText } from './documents/management-review-defaults'; +import { defaultRiskMethodologyNarrative } from './documents/risk-methodology'; +import { loadRiskTreatmentExtras } from './documents/risk-treatment-export-data'; +import { loadRiskTreatmentReadinessMessages } from './documents/risk-treatment-readiness'; import { updateDraftSnapshot } from './utils/draft-snapshot'; import { EXPORT_DOCUMENT_INCLUDE } from './utils/export-payload'; import { lockDocument } from './utils/document-lock'; @@ -266,7 +269,9 @@ export class IsmsService { const targets = documents.filter( (doc) => - (doc.type === 'internal_audit' || doc.type === 'management_review') && + (doc.type === 'internal_audit' || + doc.type === 'management_review' || + doc.type === 'risk_assessment_methodology') && isEmptyNarrative(doc.draftNarrative), ); if (targets.length === 0) return; @@ -282,15 +287,22 @@ export class IsmsService { { draftNarrative: { equals: {} } }, ], }; + const defaultNarrativeFor = ( + type: IsmsDocumentType, + ): Prisma.InputJsonObject => { + if (type === 'internal_audit') { + return { programme: defaultProgrammeText(organizationName) }; + } + if (type === 'management_review') { + return { procedure: defaultProcedureText(organizationName) }; + } + // risk_assessment_methodology (6.1.2): the fully templated default text. + return { ...defaultRiskMethodologyNarrative(organizationName) }; + }; for (const doc of targets) { await db.ismsDocument.updateMany({ where: { id: doc.id, ...whileNarrativeEmpty }, - data: { - draftNarrative: - doc.type === 'internal_audit' - ? { programme: defaultProgrammeText(organizationName) } - : { procedure: defaultProcedureText(organizationName) }, - }, + data: { draftNarrative: defaultNarrativeFor(doc.type) }, }); } } @@ -407,6 +419,11 @@ export class IsmsService { if (document.type === 'management_review') { await this.assertManagementReviewComplete({ tx, documentId }); } + // Clause 6.1.3: at least one risk recorded and an owner on every risk + // and vendor; per-risk acceptance is recommended, never blocking (CS-727). + if (document.type === 'risk_treatment_plan') { + await this.assertRiskTreatmentPlanComplete({ tx, organizationId }); + } return tx.ismsDocument.update({ where: { id: documentId }, @@ -433,20 +450,28 @@ export class IsmsService { const document = await this.requireDocument({ documentId, organizationId }); this.assertPendingApprovalBy({ document, member }); - const snapshot = await collectPlatformData({ - organizationId, - frameworkId: document.frameworkId, - }); const now = new Date(); // Freeze the draft into a new immutable published version and promote it to // currentVersion. Editing afterwards reverts status to draft but leaves this // published version live and exportable (CS-701). - const published = await db.$transaction(async (tx) => { + const publish = async (tx: Prisma.TransactionClient) => { // Serialize concurrent approvals (and register-row creates, which take the // same lock) on this document so they can't interleave and double-publish. await lockDocument(tx, documentId); + // Collect the drift baseline INSIDE the transaction so it reads the same + // point in time as the rows frozen into the published version below. + // The transaction runs REPEATABLE READ (one MVCC snapshot for every + // statement), so this baseline and the rows loaded further down can + // never observe different data — a concurrent platform edit commits + // entirely before or entirely after this approval. + const snapshot = await collectPlatformData({ + organizationId, + frameworkId: document.frameworkId, + client: tx, + }); + // Atomically claim the approval: the check-then-act guard above runs before // the transaction, so under READ COMMITTED a racing approve/decline could // read the same stale `needs_review`. This conditional update only matches @@ -495,6 +520,24 @@ export class IsmsService { data: { currentVersionId: result.versionId }, }); return result; + }; + + // REPEATABLE READ can abort with a write conflict (P2034) when a racing + // lifecycle write commits between our snapshot and our claim update — + // retry once; the rerun takes a fresh snapshot and either wins cleanly or + // fails the conditional claim with the friendly BadRequestException. + const runPublish = () => + db.$transaction(publish, { + isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, + }); + const published = await runPublish().catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034' + ) { + return runPublish(); + } + throw error; }); // Render + upload the frozen exports outside the transaction (Policies @@ -700,6 +743,50 @@ export class IsmsService { } } + private async assertRiskTreatmentPlanComplete({ + tx, + organizationId, + }: { + tx: Prisma.TransactionClient; + organizationId: string; + }) { + const messages = await loadRiskTreatmentReadinessMessages({ + organizationId, + client: tx, + }); + if (messages.length > 0) { + throw new BadRequestException( + `This Clause 6.1.3 document is not ready to submit. ${messages.join(' ')}`, + ); + } + } + + /** + * The Risk Treatment Plan page payload: the same rows the export renders + * (from the Risk Register + Vendors) plus the submit-readiness messages. + * Gated at evidence:read — consistent with every other ISMS read — so the + * preview never depends on the viewer holding risk/vendor permissions. + */ + async getRiskTreatmentData({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await this.requireDocument({ documentId, organizationId }); + if (document.type !== 'risk_treatment_plan') { + throw new BadRequestException( + 'This document is not a Risk Treatment Plan', + ); + } + const [extras, validationMessages] = await Promise.all([ + loadRiskTreatmentExtras({ organizationId }), + loadRiskTreatmentReadinessMessages({ organizationId }), + ]); + return { ...extras, validationMessages }; + } + private async requireDocument({ documentId, organizationId, diff --git a/apps/api/src/isms/utils/document-types.spec.ts b/apps/api/src/isms/utils/document-types.spec.ts index 06a42ca7d3..547d68ae52 100644 --- a/apps/api/src/isms/utils/document-types.spec.ts +++ b/apps/api/src/isms/utils/document-types.spec.ts @@ -1,8 +1,8 @@ import { ISMS_TYPE_DEFINITIONS, matchRequirementId } from './document-types'; describe('ISMS_TYPE_DEFINITIONS', () => { - it('defines all ten foundational document types with clauses', () => { - expect(ISMS_TYPE_DEFINITIONS).toHaveLength(10); + it('defines all twelve foundational document types with clauses', () => { + expect(ISMS_TYPE_DEFINITIONS).toHaveLength(12); const types = ISMS_TYPE_DEFINITIONS.map((d) => d.type); expect(types).toEqual( expect.arrayContaining([ @@ -12,6 +12,8 @@ describe('ISMS_TYPE_DEFINITIONS', () => { 'isms_scope', 'leadership_commitment', 'roles_and_responsibilities', + 'risk_assessment_methodology', + 'risk_treatment_plan', 'objectives_plan', 'monitoring', 'internal_audit', diff --git a/apps/api/src/isms/utils/document-types.ts b/apps/api/src/isms/utils/document-types.ts index 935dd725a2..6263035969 100644 --- a/apps/api/src/isms/utils/document-types.ts +++ b/apps/api/src/isms/utils/document-types.ts @@ -59,6 +59,20 @@ export const ISMS_TYPE_DEFINITIONS: IsmsTypeDefinition[] = [ description: 'The ISMS governance roles, their responsibilities and authorities, and the members who hold them (ISO 27001 clause 5.3).', }, + { + type: 'risk_assessment_methodology', + clause: '6.1.2', + title: 'Risk Assessment Methodology', + description: + 'How information-security risks are identified, analysed and evaluated — the scales, risk level matrix, acceptance thresholds and treatment options used (ISO 27001 clause 6.1.2).', + }, + { + type: 'risk_treatment_plan', + clause: '6.1.3', + title: 'Risk Treatment Plan', + description: + 'The treatment, controls, owner, residual risk state and owner acceptance for every risk in the Risk Register and every vendor risk (ISO 27001 clause 6.1.3).', + }, { type: 'objectives_plan', clause: '6.2', diff --git a/apps/api/src/isms/utils/docx-renderer.ts b/apps/api/src/isms/utils/docx-renderer.ts index 7e4a361772..84d82c768d 100644 --- a/apps/api/src/isms/utils/docx-renderer.ts +++ b/apps/api/src/isms/utils/docx-renderer.ts @@ -125,13 +125,15 @@ function dataTable({ ), }); const bodyRows = table.rows.map( - (row) => + (row, rowIndex) => new TableRow({ children: row.map((value, index) => cell({ text: value, bold: widths ? index === 0 : false, width: widths ? widths[index] : undefined, + // Per-cell background fills (the 6.1.2 risk level matrix). + fill: table.cellFills?.[rowIndex]?.[index] ?? undefined, }), ), }), diff --git a/apps/api/src/isms/utils/export-payload.ts b/apps/api/src/isms/utils/export-payload.ts index f5753b1194..1d3ade87e4 100644 --- a/apps/api/src/isms/utils/export-payload.ts +++ b/apps/api/src/isms/utils/export-payload.ts @@ -22,6 +22,10 @@ import { mapReviews, type ManagementReviewExtras, } from '../documents/management-review-export-data'; +import { + loadRiskTreatmentExtras, + type RiskTreatmentExtras, +} from '../documents/risk-treatment-export-data'; import type { DocumentExportInput, IsmsOrgProfile, @@ -165,6 +169,18 @@ export async function resolveManagementReviewExtras( }); } +/** The Risk Treatment Plan (6.1.3) reads the Risk Register + vendors; other types don't. */ +export async function resolveRiskTreatmentExtras( + document: LoadedExportDocument, + client?: Prisma.TransactionClient, +): Promise { + if (document.type !== 'risk_treatment_plan') return undefined; + return loadRiskTreatmentExtras({ + organizationId: document.organizationId, + client, + }); +} + function formatDateYmd(date: Date | null): string | null { return date ? date.toISOString().slice(0, 10) : null; } @@ -204,6 +220,7 @@ export function buildExportInput({ monitoringExtras, internalAuditExtras, managementReviewExtras, + riskTreatmentExtras, }: { document: LoadedExportDocument; orgProfile?: IsmsOrgProfile; @@ -211,6 +228,7 @@ export function buildExportInput({ monitoringExtras?: MonitoringExtras; internalAuditExtras?: InternalAuditExtras; managementReviewExtras?: ManagementReviewExtras; + riskTreatmentExtras?: RiskTreatmentExtras; }): DocumentExportInput { return { contextIssues: document.contextIssues.map((issue) => ({ @@ -251,6 +269,7 @@ export function buildExportInput({ reviews: managementReviewExtras ? mapReviews(document.reviews, managementReviewExtras) : undefined, + riskTreatment: riskTreatmentExtras, }; } @@ -275,6 +294,7 @@ export async function buildDraftSnapshot( const internalAuditExtras = await resolveInternalAuditExtras(document); const managementReviewExtras = await resolveManagementReviewExtras(document); + const riskTreatmentExtras = await resolveRiskTreatmentExtras(document); const input = buildExportInput({ document, orgProfile, @@ -282,6 +302,7 @@ export async function buildDraftSnapshot( monitoringExtras, internalAuditExtras, managementReviewExtras, + riskTreatmentExtras, }); const metadata = buildExportMetadata({ type: document.type, diff --git a/apps/api/src/isms/utils/export-shared.ts b/apps/api/src/isms/utils/export-shared.ts index 9f9c2b2ced..7aabeb9a02 100644 --- a/apps/api/src/isms/utils/export-shared.ts +++ b/apps/api/src/isms/utils/export-shared.ts @@ -79,6 +79,12 @@ export interface IsmsExportParagraph { export interface IsmsExportTable { headers: string[]; rows: string[][]; + /** + * Optional per-cell background fills, aligned with `rows` (hex without '#', + * null = default). Used by the 6.1.2 risk level matrix; both renderers keep + * the default ink color, so fills must stay light enough for black text. + */ + cellFills?: (string | null)[][]; } export const DOCX_MIME_TYPE = diff --git a/apps/api/src/isms/utils/pdf-renderer.ts b/apps/api/src/isms/utils/pdf-renderer.ts index b7bc8cc64f..e920794315 100644 --- a/apps/api/src/isms/utils/pdf-renderer.ts +++ b/apps/api/src/isms/utils/pdf-renderer.ts @@ -113,6 +113,20 @@ export function renderIsmsPdf({ const renderTable = (table: IsmsExportTable) => { const threeCol = table.headers.length === 3; + const cellFills = table.cellFills; + // Never let a header word break mid-word ("Acceptanc/e"): floor each + // column at its header's longest word. Wide tables (the 6.1.3 plan has 10 + // columns) otherwise get squeezed below the header width by autotable's + // content-proportional sizing. + pdf.setFont('helvetica', 'bold'); + pdf.setFontSize(9); + const headerMinWidths: Record = {}; + table.headers.forEach((header, index) => { + const longestWord = header + .split(/\s+/) + .reduce((max, word) => Math.max(max, pdf.getTextWidth(word)), 0); + headerMinWidths[index] = { minCellWidth: longestWord + 2.2 * 2 + 0.5 }; + }); autoTable(pdf, { startY: y, margin: { left: margin, right: margin, bottom: 16 }, @@ -140,7 +154,16 @@ export function renderIsmsPdf({ 1: { cellWidth: 56 }, 2: { cellWidth: contentWidth - 88 }, } - : {}, + : headerMinWidths, + // Per-cell background fills (the 6.1.2 risk level matrix). Fills are + // light pastels, so the default ink text stays legible. + didParseCell: cellFills + ? (data) => { + if (data.section !== 'body') return; + const fill = cellFills[data.row.index]?.[data.column.index]; + if (fill) data.cell.styles.fillColor = accentColor(fill); + } + : undefined, }); y = finalY() + 4; }; diff --git a/apps/api/src/isms/wizard/isms-profile.service.spec.ts b/apps/api/src/isms/wizard/isms-profile.service.spec.ts index b93ba7df88..1cc8f6dd04 100644 --- a/apps/api/src/isms/wizard/isms-profile.service.spec.ts +++ b/apps/api/src/isms/wizard/isms-profile.service.spec.ts @@ -69,6 +69,7 @@ const platformData = { hasTrainingProgram: false, wizardAnswers: {}, partiesFingerprint: '', + riskTreatmentFingerprint: '', }; const args = { organizationId: 'org_1', frameworkId: 'fw_1' }; diff --git a/apps/api/src/isms/wizard/isms-profile.service.ts b/apps/api/src/isms/wizard/isms-profile.service.ts index 4d81cf4684..57f5ae94b8 100644 --- a/apps/api/src/isms/wizard/isms-profile.service.ts +++ b/apps/api/src/isms/wizard/isms-profile.service.ts @@ -32,10 +32,12 @@ const GENERATION_ORDER: Record = { isms_scope: 3, leadership_commitment: 4, roles_and_responsibilities: 5, - objectives_plan: 6, - monitoring: 7, - internal_audit: 8, - management_review: 9, + risk_assessment_methodology: 6, + risk_treatment_plan: 7, + objectives_plan: 8, + monitoring: 9, + internal_audit: 10, + management_review: 11, }; const GENERATION_ORDER_DEFAULT = Object.keys(GENERATION_ORDER).length; diff --git a/apps/api/src/isms/wizard/wizard-defaults.spec.ts b/apps/api/src/isms/wizard/wizard-defaults.spec.ts index 5f01627702..79f3f00191 100644 --- a/apps/api/src/isms/wizard/wizard-defaults.spec.ts +++ b/apps/api/src/isms/wizard/wizard-defaults.spec.ts @@ -34,6 +34,7 @@ const platformData: IsmsPlatformData = { hasTrainingProgram: true, wizardAnswers: {}, partiesFingerprint: '', + riskTreatmentFingerprint: '', }; const args = { organizationId: 'org_1', frameworkId: 'fw_1' }; diff --git a/apps/api/src/risks/dto/create-risk-acceptance.dto.ts b/apps/api/src/risks/dto/create-risk-acceptance.dto.ts new file mode 100644 index 0000000000..f2776e0611 --- /dev/null +++ b/apps/api/src/risks/dto/create-risk-acceptance.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateRiskAcceptanceDto { + @ApiPropertyOptional({ + description: + 'Member ID of the acceptor. Defaults to the risk owner (assignee) when omitted.', + example: 'mem_abc123def456', + }) + @IsOptional() + @IsString() + acceptedById?: string; + + @ApiPropertyOptional({ + description: 'Optional notes recorded with the acceptance.', + example: 'Residual risk reviewed at the Q2 risk review.', + }) + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; +} diff --git a/apps/api/src/risks/risk-acceptances.controller.spec.ts b/apps/api/src/risks/risk-acceptances.controller.spec.ts new file mode 100644 index 0000000000..9c2e347aef --- /dev/null +++ b/apps/api/src/risks/risk-acceptances.controller.spec.ts @@ -0,0 +1,163 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ForbiddenException } from '@nestjs/common'; +import type { AuthContext } from '../auth/types'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RiskAcceptancesController } from './risk-acceptances.controller'; +import { RiskAcceptancesService } from './risk-acceptances.service'; + +jest.mock('@db', () => ({ + ...jest.requireActual('@prisma/client'), + db: {}, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { risk: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +jest.mock('../utils/assignment-filter', () => ({ + hasRiskAccess: jest.fn().mockReturnValue(true), +})); + +import { hasRiskAccess } from '../utils/assignment-filter'; + +const mockHasRiskAccess = hasRiskAccess as jest.MockedFunction< + typeof hasRiskAccess +>; + +describe('RiskAcceptancesController', () => { + let controller: RiskAcceptancesController; + let acceptancesService: { + listForRisk: jest.Mock; + createForRisk: jest.Mock; + }; + + const orgId = 'org_test123'; + + const authContext: AuthContext = { + organizationId: orgId, + authType: 'session', + isApiKey: false, + isPlatformAdmin: false, + userRoles: ['admin'], + userId: 'usr_123', + userEmail: 'admin@example.com', + memberId: 'mem_123', + }; + + const acceptanceView = { + id: 'rska_1', + acceptedById: 'mem_123', + acceptedByName: 'Jane Doe', + notes: null, + residualLikelihood: 'unlikely', + residualImpact: 'minor', + level: 'very-low', + levelLabel: 'Very low', + stale: false, + createdAt: new Date('2026-04-15T00:00:00Z'), + }; + + beforeEach(async () => { + acceptancesService = { + listForRisk: jest.fn(), + createForRisk: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [RiskAcceptancesController], + providers: [ + { provide: RiskAcceptancesService, useValue: acceptancesService }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(RiskAcceptancesController); + jest.clearAllMocks(); + mockHasRiskAccess.mockReturnValue(true); + }); + + describe('listRiskAcceptances', () => { + it('lists acceptance events with auth info', async () => { + acceptancesService.listForRisk.mockResolvedValue({ + risk: { id: 'risk_1', assigneeId: 'mem_123' }, + acceptances: [acceptanceView], + }); + + const result = await controller.listRiskAcceptances( + 'risk_1', + orgId, + authContext, + ); + + expect(acceptancesService.listForRisk).toHaveBeenCalledWith( + 'risk_1', + orgId, + ); + expect(result.data).toEqual([acceptanceView]); + expect(result.authType).toBe('session'); + expect(result.authenticatedUser).toEqual({ + id: 'usr_123', + email: 'admin@example.com', + }); + }); + + it('denies the list to restricted roles without assignment access', async () => { + acceptancesService.listForRisk.mockResolvedValue({ + risk: { id: 'risk_1', assigneeId: 'mem_other' }, + acceptances: [], + }); + mockHasRiskAccess.mockReturnValue(false); + + await expect( + controller.listRiskAcceptances('risk_1', orgId, authContext), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('recordRiskAcceptance', () => { + it('records an acceptance and returns the created event', async () => { + acceptancesService.createForRisk.mockResolvedValue(acceptanceView); + + const result = await controller.recordRiskAcceptance( + 'risk_1', + { notes: 'Reviewed at Q2' }, + orgId, + authContext, + ); + + expect(acceptancesService.createForRisk).toHaveBeenCalledWith( + 'risk_1', + orgId, + { notes: 'Reviewed at Q2' }, + expect.any(Function), + ); + expect(result.id).toBe('rska_1'); + expect(result.authType).toBe('session'); + }); + + it('passes an access gate that rejects restricted roles without assignment', async () => { + acceptancesService.createForRisk.mockImplementation( + async (_riskId, _orgId, _dto, assertAccess) => { + // Simulate the service invoking the gate with the loaded risk. + assertAccess?.({ assigneeId: 'mem_other' }); + return acceptanceView; + }, + ); + mockHasRiskAccess.mockReturnValue(false); + + await expect( + controller.recordRiskAcceptance('risk_1', {}, orgId, authContext), + ).rejects.toThrow(ForbiddenException); + }); + }); +}); diff --git a/apps/api/src/risks/risk-acceptances.controller.ts b/apps/api/src/risks/risk-acceptances.controller.ts new file mode 100644 index 0000000000..f9a43b0294 --- /dev/null +++ b/apps/api/src/risks/risk-acceptances.controller.ts @@ -0,0 +1,135 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { hasRiskAccess } from '../utils/assignment-filter'; +import { CreateRiskAcceptanceDto } from './dto/create-risk-acceptance.dto'; +import { RiskAcceptancesService } from './risk-acceptances.service'; +import { RISK_OPERATIONS } from './schemas/risk-operations'; +import { RISK_PARAMS } from './schemas/risk-params'; +import { RISK_BODIES } from './schemas/risk-bodies'; +import { + LIST_RISK_ACCEPTANCES_RESPONSES, + RECORD_RISK_ACCEPTANCE_RESPONSES, +} from './schemas/risk-acceptances.responses'; + +/** + * Residual-risk acceptance events for risks (ISO 27001 6.1.3(f), CS-727). + * Append-only: list + record, no update/delete. Restricted roles get the same + * assignment-access rule as GET /risks/:id on BOTH endpoints — an acceptance + * is the risk owner's formal statement, so members who cannot see a risk must + * not be able to read or write its acceptance trail. + */ +@ApiTags('Risks') +@Controller({ path: 'risks', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +export class RiskAcceptancesController { + constructor( + private readonly riskAcceptancesService: RiskAcceptancesService, + ) {} + + @Get(':id/acceptances') + @UseGuards(PermissionGuard) + @RequirePermission('risk', 'read') + @ApiOperation(RISK_OPERATIONS.listRiskAcceptances) + @ApiParam(RISK_PARAMS.riskId) + @ApiResponse(LIST_RISK_ACCEPTANCES_RESPONSES[200]) + @ApiResponse(LIST_RISK_ACCEPTANCES_RESPONSES[401]) + @ApiResponse(LIST_RISK_ACCEPTANCES_RESPONSES[403]) + @ApiResponse(LIST_RISK_ACCEPTANCES_RESPONSES[404]) + @ApiResponse(LIST_RISK_ACCEPTANCES_RESPONSES[500]) + async listRiskAcceptances( + @Param('id') riskId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const { risk, acceptances } = await this.riskAcceptancesService.listForRisk( + riskId, + organizationId, + ); + this.assertRiskAccess(risk, authContext); + + return { + data: acceptances, + authType: authContext.authType, + ...this.authenticatedUser(authContext), + }; + } + + @Post(':id/acceptances') + @UseGuards(PermissionGuard) + @RequirePermission('risk', 'update') + @ApiOperation(RISK_OPERATIONS.recordRiskAcceptance) + @ApiParam(RISK_PARAMS.riskId) + @ApiBody(RISK_BODIES.recordRiskAcceptance) + @ApiResponse(RECORD_RISK_ACCEPTANCE_RESPONSES[201]) + @ApiResponse(RECORD_RISK_ACCEPTANCE_RESPONSES[400]) + @ApiResponse(RECORD_RISK_ACCEPTANCE_RESPONSES[401]) + @ApiResponse(RECORD_RISK_ACCEPTANCE_RESPONSES[403]) + @ApiResponse(RECORD_RISK_ACCEPTANCE_RESPONSES[404]) + @ApiResponse(RECORD_RISK_ACCEPTANCE_RESPONSES[500]) + async recordRiskAcceptance( + @Param('id') riskId: string, + @Body() dto: CreateRiskAcceptanceDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const acceptance = await this.riskAcceptancesService.createForRisk( + riskId, + organizationId, + dto, + // Assignment gate for restricted roles, applied inside the create load + // so an unassigned restricted member can neither read nor record. + (risk) => this.assertRiskAccess(risk, authContext), + ); + + return { + ...acceptance, + authType: authContext.authType, + ...this.authenticatedUser(authContext), + }; + } + + private assertRiskAccess( + risk: { assigneeId: string | null }, + authContext: AuthContextType, + ) { + if ( + !hasRiskAccess(risk, authContext.memberId, authContext.userRoles, { + isApiKey: authContext.isApiKey, + }) + ) { + throw new ForbiddenException('You do not have access to this risk'); + } + } + + private authenticatedUser(authContext: AuthContextType) { + return authContext.userId && authContext.userEmail + ? { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + } + : {}; + } +} diff --git a/apps/api/src/risks/risk-acceptances.service.spec.ts b/apps/api/src/risks/risk-acceptances.service.spec.ts new file mode 100644 index 0000000000..5d2ccbeefb --- /dev/null +++ b/apps/api/src/risks/risk-acceptances.service.spec.ts @@ -0,0 +1,266 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +const mockDb = { + risk: { findFirst: jest.fn() }, + vendor: { findFirst: jest.fn() }, + member: { findFirst: jest.fn() }, + riskAcceptance: { findMany: jest.fn(), create: jest.fn() }, + // The create paths run inside a transaction with a subject row-lock; the + // callback receives this same mock as the transaction client. + $transaction: jest.fn( + (fn: (tx: typeof mockDb) => unknown): unknown => fn(mockDb), + ), + $queryRaw: jest.fn().mockResolvedValue([]), +}; + +jest.mock('@db', () => ({ db: mockDb })); + +import { RiskAcceptancesService } from './risk-acceptances.service'; + +const ORG = 'org_1'; + +const baseRisk = { + assigneeId: 'mem_owner', + residualLikelihood: 'unlikely', + residualImpact: 'minor', +}; + +const activeMember = { + deactivated: false, + user: { name: 'Jane Doe', email: 'jane@acme.com' }, +}; + +const storedRow = { + id: 'rska_1', + acceptedById: 'mem_owner', + acceptedByName: 'Jane Doe', + notes: null, + residualLikelihood: 'unlikely', + residualImpact: 'minor', + createdAt: new Date('2026-04-15T00:00:00Z'), +}; + +describe('RiskAcceptancesService', () => { + let service: RiskAcceptancesService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new RiskAcceptancesService(); + }); + + describe('createForRisk', () => { + it('freezes the current residual rating and the acceptor name', async () => { + mockDb.risk.findFirst.mockResolvedValue(baseRisk); + mockDb.member.findFirst.mockResolvedValue(activeMember); + mockDb.riskAcceptance.create.mockResolvedValue(storedRow); + + const view = await service.createForRisk('rsk_1', ORG, {}); + + expect(mockDb.riskAcceptance.create).toHaveBeenCalledWith({ + data: { + organizationId: ORG, + riskId: 'rsk_1', + acceptedById: 'mem_owner', + acceptedByName: 'Jane Doe', + notes: null, + residualLikelihood: 'unlikely', + residualImpact: 'minor', + }, + }); + expect(view.stale).toBe(false); + // unlikely(2) x minor(2) = raw 4 -> score 2 -> very-low (score bands, + // matching RiskScoreBadge / TreatmentHero) + expect(view.levelLabel).toBe('Very low'); + }); + + it('row-locks the risk and writes through the same transaction', async () => { + mockDb.risk.findFirst.mockResolvedValue(baseRisk); + mockDb.member.findFirst.mockResolvedValue(activeMember); + mockDb.riskAcceptance.create.mockResolvedValue(storedRow); + + await service.createForRisk('rsk_1', ORG, {}); + + // The subject lock serializes concurrent residual edits with the + // read-freeze-insert sequence, so a fresh acceptance can never be + // recorded against an already-superseded rating. + expect(mockDb.$transaction).toHaveBeenCalledTimes(1); + expect(mockDb.$queryRaw).toHaveBeenCalledTimes(1); + const rawQuery = mockDb.$queryRaw.mock.calls[0][0].join('?'); + expect(rawQuery).toContain('FOR UPDATE'); + }); + + it('defaults the acceptor to the risk owner (assignee)', async () => { + mockDb.risk.findFirst.mockResolvedValue(baseRisk); + mockDb.member.findFirst.mockResolvedValue(activeMember); + mockDb.riskAcceptance.create.mockResolvedValue(storedRow); + + await service.createForRisk('rsk_1', ORG, {}); + + expect(mockDb.member.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'mem_owner', organizationId: ORG }, + }), + ); + }); + + it('uses the explicit acceptor over the owner when provided', async () => { + mockDb.risk.findFirst.mockResolvedValue(baseRisk); + mockDb.member.findFirst.mockResolvedValue(activeMember); + mockDb.riskAcceptance.create.mockResolvedValue(storedRow); + + await service.createForRisk('rsk_1', ORG, { acceptedById: 'mem_other' }); + + expect(mockDb.member.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'mem_other', organizationId: ORG }, + }), + ); + }); + + it('rejects when the risk has no owner and no acceptor was chosen', async () => { + mockDb.risk.findFirst.mockResolvedValue({ ...baseRisk, assigneeId: null }); + + await expect(service.createForRisk('rsk_1', ORG, {})).rejects.toThrow( + BadRequestException, + ); + expect(mockDb.riskAcceptance.create).not.toHaveBeenCalled(); + }); + + it('rejects an acceptor outside the organization', async () => { + mockDb.risk.findFirst.mockResolvedValue(baseRisk); + mockDb.member.findFirst.mockResolvedValue(null); + + await expect( + service.createForRisk('rsk_1', ORG, { acceptedById: 'mem_foreign' }), + ).rejects.toThrow('Acceptor is not a member of this organization'); + }); + + it('rejects a deactivated acceptor', async () => { + mockDb.risk.findFirst.mockResolvedValue(baseRisk); + mockDb.member.findFirst.mockResolvedValue({ + ...activeMember, + deactivated: true, + }); + + await expect(service.createForRisk('rsk_1', ORG, {})).rejects.toThrow( + 'deactivated', + ); + }); + + it('falls back to the email when the acceptor has no name', async () => { + mockDb.risk.findFirst.mockResolvedValue(baseRisk); + mockDb.member.findFirst.mockResolvedValue({ + deactivated: false, + user: { name: ' ', email: 'jane@acme.com' }, + }); + mockDb.riskAcceptance.create.mockResolvedValue(storedRow); + + await service.createForRisk('rsk_1', ORG, {}); + + expect(mockDb.riskAcceptance.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ acceptedByName: 'jane@acme.com' }), + }); + }); + + it('404s for a risk outside the organization', async () => { + mockDb.risk.findFirst.mockResolvedValue(null); + + await expect(service.createForRisk('rsk_x', ORG, {})).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('listForRisk', () => { + it('computes stale per event against the live residual rating', async () => { + mockDb.risk.findFirst.mockResolvedValue({ + ...baseRisk, + // Residual has moved since the older acceptance was recorded. + residualLikelihood: 'possible', + residualImpact: 'moderate', + }); + mockDb.riskAcceptance.findMany.mockResolvedValue([ + { + ...storedRow, + id: 'rska_2', + residualLikelihood: 'possible', + residualImpact: 'moderate', + }, + { ...storedRow, id: 'rska_1' }, + ]); + + const { acceptances } = await service.listForRisk('rsk_1', ORG); + + expect(acceptances.map((a) => a.stale)).toEqual([false, true]); + expect(mockDb.riskAcceptance.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { riskId: 'rsk_1', organizationId: ORG }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }), + ); + }); + + it('404s for a risk outside the organization', async () => { + mockDb.risk.findFirst.mockResolvedValue(null); + + await expect(service.listForRisk('rsk_x', ORG)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('vendor variants', () => { + it('normalizes residualProbability into the shared rating shape', async () => { + mockDb.vendor.findFirst.mockResolvedValue({ + assigneeId: 'mem_owner', + residualProbability: 'likely', + residualImpact: 'major', + }); + mockDb.member.findFirst.mockResolvedValue(activeMember); + mockDb.riskAcceptance.create.mockResolvedValue({ + ...storedRow, + residualLikelihood: 'likely', + residualImpact: 'major', + }); + + const view = await service.createForVendor('vnd_1', ORG, {}); + + expect(mockDb.riskAcceptance.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + vendorId: 'vnd_1', + residualLikelihood: 'likely', + residualImpact: 'major', + }), + }); + expect(view.levelLabel).toBe('High'); // likely(4) x major(4) = raw 16 -> score 7 -> high + }); + + it('lists vendor acceptances by vendorId', async () => { + mockDb.vendor.findFirst.mockResolvedValue({ + assigneeId: null, + residualProbability: 'unlikely', + residualImpact: 'minor', + }); + mockDb.riskAcceptance.findMany.mockResolvedValue([storedRow]); + + const { acceptances } = await service.listForVendor('vnd_1', ORG); + + expect(acceptances).toHaveLength(1); + expect(acceptances[0].stale).toBe(false); + expect(mockDb.riskAcceptance.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { vendorId: 'vnd_1', organizationId: ORG }, + }), + ); + }); + }); + + it('exposes no update or delete — acceptances are append-only', () => { + const surface = Object.getOwnPropertyNames( + Object.getPrototypeOf(service), + ).filter((name) => name !== 'constructor'); + expect(surface.some((name) => /update|delete|remove/i.test(name))).toBe( + false, + ); + }); +}); diff --git a/apps/api/src/risks/risk-acceptances.service.ts b/apps/api/src/risks/risk-acceptances.service.ts new file mode 100644 index 0000000000..336f3f83fe --- /dev/null +++ b/apps/api/src/risks/risk-acceptances.service.ts @@ -0,0 +1,257 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + db, + type Impact, + type Likelihood, + type Prisma, + type RiskAcceptance, +} from '@db'; +import { CreateRiskAcceptanceDto } from './dto/create-risk-acceptance.dto'; +import { LEVEL_LABEL, ratingLevel, type RiskLevel } from './risk-level'; + +// Residual-risk acceptance events (ISO 27001 Clause 6.1.3(f), CS-727). +// +// Acceptances are append-only: this service intentionally has no update or +// delete method. "Stale" is computed on read by comparing the residual rating +// frozen on the event against the parent's live residual rating — the moment +// the residual score changes, the acceptance needs re-recording; older events +// stay in the history as the audit trail. + +export interface RiskAcceptanceView { + id: string; + acceptedById: string | null; + acceptedByName: string; + notes: string | null; + residualLikelihood: Likelihood; + residualImpact: Impact; + level: RiskLevel; + levelLabel: string; + stale: boolean; + createdAt: Date; +} + +interface ResidualRating { + residualLikelihood: Likelihood; + residualImpact: Impact; +} + +@Injectable() +export class RiskAcceptancesService { + async listForRisk(riskId: string, organizationId: string) { + const risk = await db.risk.findFirst({ + where: { id: riskId, organizationId }, + select: { + id: true, + assigneeId: true, + residualLikelihood: true, + residualImpact: true, + }, + }); + if (!risk) { + throw new NotFoundException( + `Risk with ID ${riskId} not found in organization ${organizationId}`, + ); + } + + const acceptances = await this.listBySubject( + { riskId }, + organizationId, + risk, + ); + return { risk, acceptances }; + } + + async createForRisk( + riskId: string, + organizationId: string, + dto: CreateRiskAcceptanceDto, + /** + * Caller-supplied access gate (e.g. hasRiskAccess for restricted roles), + * run against the loaded risk BEFORE anything is written. Throwing here + * aborts the create. + */ + assertAccess?: (risk: { assigneeId: string | null }) => void, + ): Promise { + // Row-lock the risk for the whole read-freeze-insert sequence: a + // concurrent residual PATCH blocks until this commits, so the frozen + // rating is always the rating at acceptance time (never instantly stale). + return db.$transaction(async (tx) => { + // Org-scoped so a foreign-tenant id can never acquire (even briefly) + // another organization's row lock. + await tx.$queryRaw`SELECT id FROM "Risk" WHERE id = ${riskId} AND "organizationId" = ${organizationId} FOR UPDATE`; + const risk = await tx.risk.findFirst({ + where: { id: riskId, organizationId }, + select: { + assigneeId: true, + residualLikelihood: true, + residualImpact: true, + }, + }); + if (!risk) { + throw new NotFoundException( + `Risk with ID ${riskId} not found in organization ${organizationId}`, + ); + } + assertAccess?.(risk); + + return this.createAcceptance({ + tx, + organizationId, + subject: { riskId }, + dto, + ownerMemberId: risk.assigneeId, + current: risk, + }); + }); + } + + async listForVendor(vendorId: string, organizationId: string) { + const vendor = await this.findVendorRating(vendorId, organizationId); + const acceptances = await this.listBySubject( + { vendorId }, + organizationId, + vendor.rating, + ); + return { vendor, acceptances }; + } + + async createForVendor( + vendorId: string, + organizationId: string, + dto: CreateRiskAcceptanceDto, + ): Promise { + // Same row-lock rationale as createForRisk. + return db.$transaction(async (tx) => { + // Org-scoped so a foreign-tenant id can never acquire (even briefly) + // another organization's row lock. + await tx.$queryRaw`SELECT id FROM "Vendor" WHERE id = ${vendorId} AND "organizationId" = ${organizationId} FOR UPDATE`; + const vendor = await this.findVendorRating(vendorId, organizationId, tx); + + return this.createAcceptance({ + tx, + organizationId, + subject: { vendorId }, + dto, + ownerMemberId: vendor.assigneeId, + current: vendor.rating, + }); + }); + } + + private async findVendorRating( + vendorId: string, + organizationId: string, + client?: Prisma.TransactionClient, + ) { + const vendor = await (client ?? db).vendor.findFirst({ + where: { id: vendorId, organizationId }, + select: { + assigneeId: true, + residualProbability: true, + residualImpact: true, + }, + }); + if (!vendor) { + throw new NotFoundException( + `Vendor with ID ${vendorId} not found in organization ${organizationId}`, + ); + } + // Vendor names its residual likelihood "residualProbability" — normalize + // to the shared rating shape so risks and vendors share one code path. + return { + assigneeId: vendor.assigneeId, + rating: { + residualLikelihood: vendor.residualProbability, + residualImpact: vendor.residualImpact, + }, + }; + } + + private async listBySubject( + subject: { riskId: string } | { vendorId: string }, + organizationId: string, + current: ResidualRating, + ): Promise { + const rows = await db.riskAcceptance.findMany({ + // The subject is already resolved org-scoped; organizationId is + // defense-in-depth so a malformed row could never cross tenants. + where: { ...subject, organizationId }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + return rows.map((row) => this.toView(row, current)); + } + + private async createAcceptance(params: { + tx: Prisma.TransactionClient; + organizationId: string; + subject: { riskId: string } | { vendorId: string }; + dto: CreateRiskAcceptanceDto; + ownerMemberId: string | null; + current: ResidualRating; + }): Promise { + const { tx, organizationId, subject, dto, ownerMemberId, current } = params; + + const acceptorId = dto.acceptedById ?? ownerMemberId; + if (!acceptorId) { + throw new BadRequestException( + 'No owner is assigned. Assign an owner or choose an acceptor.', + ); + } + + const member = await tx.member.findFirst({ + where: { id: acceptorId, organizationId }, + select: { + deactivated: true, + user: { select: { name: true, email: true } }, + }, + }); + if (!member) { + throw new BadRequestException( + 'Acceptor is not a member of this organization', + ); + } + if (member.deactivated) { + throw new BadRequestException( + 'Acceptor is a deactivated member of this organization', + ); + } + + const row = await tx.riskAcceptance.create({ + data: { + organizationId, + ...subject, + acceptedById: acceptorId, + // Frozen at acceptance so the historical record survives member + // removal or renaming. + acceptedByName: member.user.name?.trim() || member.user.email, + notes: dto.notes?.trim() || null, + residualLikelihood: current.residualLikelihood, + residualImpact: current.residualImpact, + }, + }); + + return this.toView(row, current); + } + + private toView(row: RiskAcceptance, current: ResidualRating) { + const level = ratingLevel(row.residualLikelihood, row.residualImpact); + return { + id: row.id, + acceptedById: row.acceptedById, + acceptedByName: row.acceptedByName, + notes: row.notes, + residualLikelihood: row.residualLikelihood, + residualImpact: row.residualImpact, + level, + levelLabel: LEVEL_LABEL[level], + stale: + row.residualLikelihood !== current.residualLikelihood || + row.residualImpact !== current.residualImpact, + createdAt: row.createdAt, + }; + } +} diff --git a/apps/api/src/risks/risk-level.ts b/apps/api/src/risks/risk-level.ts new file mode 100644 index 0000000000..ecfd15a354 --- /dev/null +++ b/apps/api/src/risks/risk-level.ts @@ -0,0 +1,84 @@ +import { Impact, Likelihood } from '@db'; + +// Server-side mirror of apps/app/src/lib/risk-score.ts (scores, level bands, +// labels). Keep the logic in the two files LITERALLY identical — a divergence +// lets the UI and the exported Risk Treatment Plan disagree about a risk's +// level. Only the UI-only LEVEL_COLOR CSS tokens are omitted here. +// +// NOTE the two banding functions disagree for mid-range raws (e.g. raw 9 is +// "medium" by getRiskLevel but "low" by the normalized-score bands). The +// surfaces users act on (RiskScoreBadge, RisksTable severity, TreatmentHero) +// classify via getRiskLevelFromScore — CS-727 (acceptances + both 6.1.x +// documents) uses the SAME score-based bands so the documents and the +// acceptance dialog can never contradict the page the user is looking at. + +export const LIKELIHOOD_SCORES: Record = { + very_unlikely: 1, + unlikely: 2, + possible: 3, + likely: 4, + very_likely: 5, +}; + +export const IMPACT_SCORES: Record = { + insignificant: 1, + minor: 2, + moderate: 3, + major: 4, + severe: 5, +}; + +export type RiskLevel = 'very-low' | 'low' | 'medium' | 'high' | 'very-high'; + +export interface RiskScore { + raw: number; + score: number; + level: RiskLevel; +} + +export function getRiskLevel(raw: number): RiskLevel { + if (raw > 16) return 'very-high'; + if (raw > 9) return 'high'; + if (raw > 4) return 'medium'; + if (raw > 1) return 'low'; + return 'very-low'; +} + +/** + * Like `getRiskLevel`, but indexed off the normalized 1-10 score so callers + * that show the score (Treatment Plan hero, RiskScale) and the level label + * stay consistent. Buckets mirror RiskScale's 5 visual segments (2 score + * units each): + * + * 1-2 → very-low, 3-4 → low, 5-6 → medium, 7-8 → high, 9-10 → very-high + */ +export function getRiskLevelFromScore(score: number): RiskLevel { + if (score >= 9) return 'very-high'; + if (score >= 7) return 'high'; + if (score >= 5) return 'medium'; + if (score >= 3) return 'low'; + return 'very-low'; +} + +/** The level label shown for a rating everywhere in CS-727 (see NOTE above). */ +export function ratingLevel(likelihood: Likelihood, impact: Impact): RiskLevel { + return getRiskLevelFromScore(getRiskScore(likelihood, impact).score); +} + +/** Human-readable level labels for exported documents and API responses. */ +export const LEVEL_LABEL: Record = { + 'very-low': 'Very low', + low: 'Low', + medium: 'Medium', + high: 'High', + 'very-high': 'Very high', +}; + +export function getRiskScore( + likelihood: Likelihood, + impact: Impact, +): RiskScore { + const raw = LIKELIHOOD_SCORES[likelihood] * IMPACT_SCORES[impact]; + const score = Math.max(1, Math.ceil(raw / 2.5)); + return { raw, score, level: getRiskLevel(raw) }; +} diff --git a/apps/api/src/risks/risks.controller.spec.ts b/apps/api/src/risks/risks.controller.spec.ts index a0d043371f..d661517074 100644 --- a/apps/api/src/risks/risks.controller.spec.ts +++ b/apps/api/src/risks/risks.controller.spec.ts @@ -369,7 +369,7 @@ describe('RisksController', () => { const createDto = { title: 'New Risk', description: 'Description', - category: RiskCategory.operational, + category: RiskCategory.operations, }; it('should call create with organizationId and dto', async () => { @@ -501,4 +501,5 @@ describe('RisksController', () => { expect(result).not.toHaveProperty('authenticatedUser'); }); }); + }); diff --git a/apps/api/src/risks/risks.module.ts b/apps/api/src/risks/risks.module.ts index e94beadccc..caf3c84cf2 100644 --- a/apps/api/src/risks/risks.module.ts +++ b/apps/api/src/risks/risks.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { RiskAcceptancesController } from './risk-acceptances.controller'; +import { RiskAcceptancesService } from './risk-acceptances.service'; import { RisksController } from './risks.controller'; import { RisksService } from './risks.service'; @Module({ imports: [AuthModule], - controllers: [RisksController], - providers: [RisksService], - exports: [RisksService], + controllers: [RisksController, RiskAcceptancesController], + providers: [RisksService, RiskAcceptancesService], + exports: [RisksService, RiskAcceptancesService], }) export class RisksModule {} diff --git a/apps/api/src/risks/schemas/risk-acceptances.responses.ts b/apps/api/src/risks/schemas/risk-acceptances.responses.ts new file mode 100644 index 0000000000..6ebcf0d769 --- /dev/null +++ b/apps/api/src/risks/schemas/risk-acceptances.responses.ts @@ -0,0 +1,217 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +// Shared shape of one acceptance event, reused by the vendor acceptance +// responses (apps/api/src/vendors/schemas/vendor-acceptances.responses.ts). +export const RISK_ACCEPTANCE_SCHEMA = { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'Acceptance event ID', + example: 'rska_abc123def456', + }, + acceptedById: { + type: 'string', + nullable: true, + description: 'Member ID of the acceptor (null if since removed)', + example: 'mem_abc123def456', + }, + acceptedByName: { + type: 'string', + description: 'Acceptor display name, frozen at acceptance', + example: 'Jane Doe', + }, + notes: { + type: 'string', + nullable: true, + example: 'Residual risk reviewed at the Q2 risk review.', + }, + residualLikelihood: { + type: 'string', + enum: ['very_unlikely', 'unlikely', 'possible', 'likely', 'very_likely'], + description: 'Residual likelihood frozen at acceptance', + example: 'unlikely', + }, + residualImpact: { + type: 'string', + enum: ['insignificant', 'minor', 'moderate', 'major', 'severe'], + description: 'Residual impact frozen at acceptance', + example: 'minor', + }, + level: { + type: 'string', + enum: ['very-low', 'low', 'medium', 'high', 'very-high'], + description: 'Risk level of the accepted residual rating', + example: 'low', + }, + levelLabel: { + type: 'string', + description: 'Human-readable level label', + example: 'Low', + }, + stale: { + type: 'boolean', + description: + 'True when the residual rating changed after this acceptance was recorded — re-acceptance is required', + example: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + description: 'When the acceptance was recorded (server-set, immutable)', + }, + }, +}; + +const NOT_FOUND = (entity: string): ApiResponseOptions => ({ + status: 404, + description: `${entity} not found`, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: `${entity} with ID abc123 not found in organization org_abc123def456`, + }, + }, + }, + }, + }, +}); + +// Auth fields the controllers spread into every response (sibling pattern: +// get-risk-by-id.responses.ts). +const AUTH_RESPONSE_PROPERTIES = { + authType: { + type: 'string', + enum: ['api-key', 'session', 'service'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + description: 'User information (only for session auth)', + properties: { + id: { type: 'string', example: 'usr_def456ghi789' }, + email: { type: 'string', example: 'user@example.com' }, + }, + }, +}; + +const INTERNAL_ERROR: ApiResponseOptions = { + status: 500, + description: 'Internal server error', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Internal server error' }, + }, + }, + }, + }, +}; + +const UNAUTHORIZED: ApiResponseOptions = { + status: 401, + description: 'Unauthorized - Invalid authentication', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Invalid or expired API key' }, + }, + }, + }, + }, +}; + +const FORBIDDEN: ApiResponseOptions = { + status: 403, + description: + 'Forbidden - User does not have permission to access this risk', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'You do not have access to this risk', + }, + }, + }, + }, + }, +}; + +export const LIST_RISK_ACCEPTANCES_RESPONSES: Record< + number, + ApiResponseOptions +> = { + 200: { + status: 200, + description: 'Acceptance history retrieved successfully (newest first)', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { type: 'array', items: RISK_ACCEPTANCE_SCHEMA }, + ...AUTH_RESPONSE_PROPERTIES, + }, + }, + }, + }, + }, + 401: UNAUTHORIZED, + 403: FORBIDDEN, + 404: NOT_FOUND('Risk'), + 500: INTERNAL_ERROR, +}; + +export const RECORD_RISK_ACCEPTANCE_RESPONSES: Record< + number, + ApiResponseOptions +> = { + 201: { + status: 201, + description: 'Acceptance recorded successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ...RISK_ACCEPTANCE_SCHEMA.properties, + ...AUTH_RESPONSE_PROPERTIES, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'No owner assigned, or the acceptor is not a valid member', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: + 'No owner is assigned. Assign an owner or choose an acceptor.', + }, + }, + }, + }, + }, + }, + 401: UNAUTHORIZED, + 403: FORBIDDEN, + 404: NOT_FOUND('Risk'), + 500: INTERNAL_ERROR, +}; diff --git a/apps/api/src/risks/schemas/risk-bodies.ts b/apps/api/src/risks/schemas/risk-bodies.ts index 791c9cfcc9..652b994dcc 100644 --- a/apps/api/src/risks/schemas/risk-bodies.ts +++ b/apps/api/src/risks/schemas/risk-bodies.ts @@ -1,4 +1,5 @@ import type { ApiBodyOptions } from '@nestjs/swagger'; +import { CreateRiskAcceptanceDto } from '../dto/create-risk-acceptance.dto'; import { CreateRiskDto } from '../dto/create-risk.dto'; import { UpdateRiskDto } from '../dto/update-risk.dto'; @@ -11,4 +12,8 @@ export const RISK_BODIES: Record = { description: 'Risk update data', type: UpdateRiskDto, }, + recordRiskAcceptance: { + description: 'Acceptance data (acceptor defaults to the risk owner)', + type: CreateRiskAcceptanceDto, + }, }; diff --git a/apps/api/src/risks/schemas/risk-operations.ts b/apps/api/src/risks/schemas/risk-operations.ts index 7bbb919c3a..50ebc8e8b8 100644 --- a/apps/api/src/risks/schemas/risk-operations.ts +++ b/apps/api/src/risks/schemas/risk-operations.ts @@ -26,4 +26,14 @@ export const RISK_OPERATIONS: Record = { description: 'Permanently removes a risk from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).', }, + listRiskAcceptances: { + summary: 'List risk-owner acceptance events', + description: + "Returns the residual-risk acceptance history for a risk, newest first. Each event freezes the residual rating at acceptance and carries a stale flag set when the risk's residual rating has changed since (ISO 27001 6.1.3(f)).", + }, + recordRiskAcceptance: { + summary: 'Record risk-owner acceptance', + description: + "Records an immutable, timestamped acceptance of a risk's current residual risk by the risk owner (or a chosen member). Re-record after the residual rating changes; prior events remain in the history.", + }, }; diff --git a/apps/api/src/vendors/schemas/vendor-acceptances.responses.ts b/apps/api/src/vendors/schemas/vendor-acceptances.responses.ts new file mode 100644 index 0000000000..8e0091cb1b --- /dev/null +++ b/apps/api/src/vendors/schemas/vendor-acceptances.responses.ts @@ -0,0 +1,61 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; +import { + LIST_RISK_ACCEPTANCES_RESPONSES, + RECORD_RISK_ACCEPTANCE_RESPONSES, +} from '../../risks/schemas/risk-acceptances.responses'; + +// Vendor risk acceptances share the exact response shape with risk +// acceptances — only the 404 subject differs. + +const vendorForbidden: ApiResponseOptions = { + status: 403, + description: + 'Forbidden - User does not have permission to access vendor risks', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Forbidden' }, + }, + }, + }, + }, +}; + +const vendorNotFound: ApiResponseOptions = { + status: 404, + description: 'Vendor not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: + 'Vendor with ID vnd_abc123def456 not found in organization org_abc123def456', + }, + }, + }, + }, + }, +}; + +export const LIST_VENDOR_ACCEPTANCES_RESPONSES: Record< + number, + ApiResponseOptions +> = { + ...LIST_RISK_ACCEPTANCES_RESPONSES, + 403: vendorForbidden, + 404: vendorNotFound, +}; + +export const RECORD_VENDOR_ACCEPTANCE_RESPONSES: Record< + number, + ApiResponseOptions +> = { + ...RECORD_RISK_ACCEPTANCE_RESPONSES, + 403: vendorForbidden, + 404: vendorNotFound, +}; diff --git a/apps/api/src/vendors/schemas/vendor-bodies.ts b/apps/api/src/vendors/schemas/vendor-bodies.ts index 6e3a6f8900..f1ab9e4824 100644 --- a/apps/api/src/vendors/schemas/vendor-bodies.ts +++ b/apps/api/src/vendors/schemas/vendor-bodies.ts @@ -1,4 +1,5 @@ import type { ApiBodyOptions } from '@nestjs/swagger'; +import { CreateRiskAcceptanceDto } from '../../risks/dto/create-risk-acceptance.dto'; import { CreateVendorDto } from '../dto/create-vendor.dto'; import { UpdateVendorDto } from '../dto/update-vendor.dto'; @@ -11,4 +12,8 @@ export const VENDOR_BODIES: Record = { description: 'Vendor update data', type: UpdateVendorDto, }, + recordVendorAcceptance: { + description: 'Acceptance data (acceptor defaults to the vendor owner)', + type: CreateRiskAcceptanceDto, + }, }; diff --git a/apps/api/src/vendors/schemas/vendor-operations.ts b/apps/api/src/vendors/schemas/vendor-operations.ts index 5ee3f6413d..5ae9d165b9 100644 --- a/apps/api/src/vendors/schemas/vendor-operations.ts +++ b/apps/api/src/vendors/schemas/vendor-operations.ts @@ -26,4 +26,14 @@ export const VENDOR_OPERATIONS: Record = { description: 'Permanently removes a vendor from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).', }, + listVendorAcceptances: { + summary: 'List vendor risk acceptance events', + description: + "Returns the residual-risk acceptance history for a vendor, newest first. Each event freezes the residual rating at acceptance and carries a stale flag set when the vendor's residual rating has changed since (ISO 27001 6.1.3(f)).", + }, + recordVendorAcceptance: { + summary: 'Record vendor risk-owner acceptance', + description: + "Records an immutable, timestamped acceptance of a vendor's current residual risk by the vendor owner (or a chosen member). Re-record after the residual rating changes; prior events remain in the history.", + }, }; diff --git a/apps/api/src/vendors/vendor-acceptances.controller.spec.ts b/apps/api/src/vendors/vendor-acceptances.controller.spec.ts new file mode 100644 index 0000000000..b7bdc4eede --- /dev/null +++ b/apps/api/src/vendors/vendor-acceptances.controller.spec.ts @@ -0,0 +1,114 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import type { AuthContext } from '../auth/types'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { VendorAcceptancesController } from './vendor-acceptances.controller'; +import { RiskAcceptancesService } from '../risks/risk-acceptances.service'; + +jest.mock('@db', () => ({ + ...jest.requireActual('@prisma/client'), + db: {}, +})); + +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { vendor: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +describe('VendorAcceptancesController', () => { + let controller: VendorAcceptancesController; + let acceptancesService: { + listForVendor: jest.Mock; + createForVendor: jest.Mock; + }; + + const orgId = 'org_test123'; + + const authContext: AuthContext = { + organizationId: orgId, + authType: 'session', + isApiKey: false, + isPlatformAdmin: false, + userRoles: ['admin'], + userId: 'usr_123', + userEmail: 'admin@example.com', + memberId: 'mem_123', + }; + + const acceptanceView = { + id: 'rska_1', + acceptedById: 'mem_123', + acceptedByName: 'John Smith', + notes: null, + residualLikelihood: 'possible', + residualImpact: 'moderate', + level: 'low', + levelLabel: 'Low', + stale: false, + createdAt: new Date('2026-04-15T00:00:00Z'), + }; + + beforeEach(async () => { + acceptancesService = { + listForVendor: jest.fn(), + createForVendor: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [VendorAcceptancesController], + providers: [ + { provide: RiskAcceptancesService, useValue: acceptancesService }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(VendorAcceptancesController); + jest.clearAllMocks(); + }); + + it('lists vendor acceptance events with auth info', async () => { + acceptancesService.listForVendor.mockResolvedValue({ + vendor: { assigneeId: 'mem_123' }, + acceptances: [acceptanceView], + }); + + const result = await controller.listVendorAcceptances( + 'vnd_1', + orgId, + authContext, + ); + + expect(acceptancesService.listForVendor).toHaveBeenCalledWith( + 'vnd_1', + orgId, + ); + expect(result.data).toEqual([acceptanceView]); + expect(result.authType).toBe('session'); + }); + + it('records a vendor acceptance and returns the created event', async () => { + acceptancesService.createForVendor.mockResolvedValue(acceptanceView); + + const result = await controller.recordVendorAcceptance( + 'vnd_1', + { acceptedById: 'mem_123' }, + orgId, + authContext, + ); + + expect(acceptancesService.createForVendor).toHaveBeenCalledWith( + 'vnd_1', + orgId, + { acceptedById: 'mem_123' }, + ); + expect(result.id).toBe('rska_1'); + }); +}); diff --git a/apps/api/src/vendors/vendor-acceptances.controller.ts b/apps/api/src/vendors/vendor-acceptances.controller.ts new file mode 100644 index 0000000000..fde34ccc93 --- /dev/null +++ b/apps/api/src/vendors/vendor-acceptances.controller.ts @@ -0,0 +1,106 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { CreateRiskAcceptanceDto } from '../risks/dto/create-risk-acceptance.dto'; +import { RiskAcceptancesService } from '../risks/risk-acceptances.service'; +import { VENDOR_OPERATIONS } from './schemas/vendor-operations'; +import { VENDOR_PARAMS } from './schemas/vendor-params'; +import { VENDOR_BODIES } from './schemas/vendor-bodies'; +import { + LIST_VENDOR_ACCEPTANCES_RESPONSES, + RECORD_VENDOR_ACCEPTANCE_RESPONSES, +} from './schemas/vendor-acceptances.responses'; + +/** + * Residual-risk acceptance events for vendor risks (ISO 27001 6.1.3(f), + * CS-727). Append-only: list + record, no update/delete. Vendors have no + * assignment-based access filtering (unlike risks), so vendor:read / + * vendor:update are the whole gate — same as the rest of VendorsController. + */ +@ApiTags('Vendors') +@Controller({ path: 'vendors', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class VendorAcceptancesController { + constructor( + private readonly riskAcceptancesService: RiskAcceptancesService, + ) {} + + @Get(':id/acceptances') + @RequirePermission('vendor', 'read') + @ApiOperation(VENDOR_OPERATIONS.listVendorAcceptances) + @ApiParam(VENDOR_PARAMS.vendorId) + @ApiResponse(LIST_VENDOR_ACCEPTANCES_RESPONSES[200]) + @ApiResponse(LIST_VENDOR_ACCEPTANCES_RESPONSES[401]) + @ApiResponse(LIST_VENDOR_ACCEPTANCES_RESPONSES[403]) + @ApiResponse(LIST_VENDOR_ACCEPTANCES_RESPONSES[404]) + @ApiResponse(LIST_VENDOR_ACCEPTANCES_RESPONSES[500]) + async listVendorAcceptances( + @Param('id') vendorId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const { acceptances } = await this.riskAcceptancesService.listForVendor( + vendorId, + organizationId, + ); + + return { + data: acceptances, + authType: authContext.authType, + ...this.authenticatedUser(authContext), + }; + } + + @Post(':id/acceptances') + @RequirePermission('vendor', 'update') + @ApiOperation(VENDOR_OPERATIONS.recordVendorAcceptance) + @ApiParam(VENDOR_PARAMS.vendorId) + @ApiBody(VENDOR_BODIES.recordVendorAcceptance) + @ApiResponse(RECORD_VENDOR_ACCEPTANCE_RESPONSES[201]) + @ApiResponse(RECORD_VENDOR_ACCEPTANCE_RESPONSES[400]) + @ApiResponse(RECORD_VENDOR_ACCEPTANCE_RESPONSES[401]) + @ApiResponse(RECORD_VENDOR_ACCEPTANCE_RESPONSES[403]) + @ApiResponse(RECORD_VENDOR_ACCEPTANCE_RESPONSES[404]) + @ApiResponse(RECORD_VENDOR_ACCEPTANCE_RESPONSES[500]) + async recordVendorAcceptance( + @Param('id') vendorId: string, + @Body() dto: CreateRiskAcceptanceDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const acceptance = await this.riskAcceptancesService.createForVendor( + vendorId, + organizationId, + dto, + ); + + return { + ...acceptance, + authType: authContext.authType, + ...this.authenticatedUser(authContext), + }; + } + + private authenticatedUser(authContext: AuthContextType) { + return authContext.userId && authContext.userEmail + ? { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + } + : {}; + } +} diff --git a/apps/api/src/vendors/vendors.module.ts b/apps/api/src/vendors/vendors.module.ts index c3dd8be61b..27f05587af 100644 --- a/apps/api/src/vendors/vendors.module.ts +++ b/apps/api/src/vendors/vendors.module.ts @@ -1,12 +1,18 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { RisksModule } from '../risks/risks.module'; import { InternalVendorAutomationController } from './internal-vendor-automation.controller'; +import { VendorAcceptancesController } from './vendor-acceptances.controller'; import { VendorsController } from './vendors.controller'; import { VendorsService } from './vendors.service'; @Module({ - imports: [AuthModule], - controllers: [VendorsController, InternalVendorAutomationController], + imports: [AuthModule, RisksModule], + controllers: [ + VendorsController, + VendorAcceptancesController, + InternalVendorAutomationController, + ], providers: [VendorsService], exports: [VendorsService], }) diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx index a550eb903a..d533d309b9 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx @@ -15,6 +15,8 @@ import { ManagementReviewClient } from '../components/ManagementReviewClient'; import { MonitoringClient } from '../components/MonitoringClient'; import { ObjectivesClient } from '../components/ObjectivesClient'; import { RequirementsClient } from '../components/RequirementsClient'; +import { RiskMethodologyClient } from '../components/RiskMethodologyClient'; +import { RiskTreatmentPlanClient } from '../components/RiskTreatmentPlanClient'; import { RolesClient } from '../components/RolesClient'; import { ScopeClient } from '../components/ScopeClient'; import { @@ -55,6 +57,8 @@ const ISMS_DETAIL_CLIENTS: Record< management_review: ManagementReviewClient, isms_scope: ScopeClient, leadership_commitment: LeadershipClient, + risk_assessment_methodology: RiskMethodologyClient, + risk_treatment_plan: RiskTreatmentPlanClient, }; interface FrameworkApiResponse { diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsDocumentShell.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsDocumentShell.tsx index f1c4494ae4..cc12472f59 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsDocumentShell.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsDocumentShell.tsx @@ -62,6 +62,12 @@ export interface IsmsDocumentShellProps { * documents omit it and are never gated. */ getSubmitBlockedReason?: (document: IsmsDocumentData) => string | null; + /** + * Optional hook run after a successful generate (before the toast). The Risk + * Treatment Plan revalidates its preview rows here so a "refresh" actually + * refreshes what the user sees. + */ + onGenerated?: () => Promise | unknown; /** Renders the register-specific body once a document is loaded. */ children: (args: IsmsDocumentBodyArgs) => ReactNode; } @@ -86,6 +92,7 @@ export function IsmsDocumentShell({ sectionDescription, generateSuccessMessage, getSubmitBlockedReason, + onGenerated, children, }: IsmsDocumentShellProps) { const { hasPermission } = usePermissions(); @@ -120,6 +127,7 @@ export function IsmsDocumentShell({ try { await generate(); await mutateDrift(); + await onGenerated?.(); toast.success(generateSuccessMessage); } catch (caught) { toast.error(caught instanceof Error ? caught.message : 'Failed to generate'); diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/MethodologyLabelledList.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/MethodologyLabelledList.tsx new file mode 100644 index 0000000000..a67f1f48c0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/MethodologyLabelledList.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { Text, Textarea } from '@trycompai/design-system'; +import type { Control, FieldValues, Path } from 'react-hook-form'; +import { Controller } from 'react-hook-form'; + +interface MethodologyLabelledListProps { + title: string; + helper: string; + /** Fixed row labels (levels/options) — not editable, mirror the export. */ + labels: readonly string[]; + /** RHF array-field name; one string per label. */ + name: Path; + control: Control; + canEdit: boolean; + /** Current values (watched) for the read-only rendering. */ + values: string[]; + /** Per-row validation messages (aligned with labels), shown under each row. */ + rowErrors?: (string | undefined)[]; +} + +/** + * One editable description per fixed label — the shape shared by the 6.1.2 + * likelihood/impact scales, acceptance thresholds, and treatment options. The + * labels are platform constants (they render into the document verbatim); + * only each row's description is customer text. + */ +export function MethodologyLabelledList({ + title, + helper, + labels, + name, + control, + canEdit, + values, + rowErrors, +}: MethodologyLabelledListProps) { + return ( +
+ {title} +
+ {helper} +
+
+ {labels.map((label, index) => ( +
+ {label} + {canEdit ? ( +
+ } + render={({ field: { ref: _ref, ...field } }) => ( +