diff --git a/apps/api/src/isms/documents/generate.ts b/apps/api/src/isms/documents/generate.ts index 4497aa66f1..14d71f8354 100644 --- a/apps/api/src/isms/documents/generate.ts +++ b/apps/api/src/isms/documents/generate.ts @@ -4,6 +4,7 @@ import { deriveContextOfOrganization } from './context'; import { deriveInterestedParties } from './interested-parties'; import { deriveRequirements } from './requirements'; import { deriveObjectives } from './objectives'; +import { seedRolesIfMissing } from './roles'; import { deriveNarrativeForType, isNarrativeType } from './registry'; import type { IsmsPlatformData } from './types'; @@ -248,6 +249,12 @@ export async function runDerivation({ await generateObjectives({ tx, documentId, data }); return; } + if (type === 'roles_and_responsibilities') { + // Idempotent seed only — never a destructive replace, so member assignments + // (IsmsRoleAssignment) and customer edits survive every regenerate. + await seedRolesIfMissing({ tx, documentId, memberCount: data.memberCount }); + return; + } if (isNarrativeType(type)) { await generateNarrative({ tx, documentId, type, data }); return; diff --git a/apps/api/src/isms/documents/registry.ts b/apps/api/src/isms/documents/registry.ts index 84a8ee8d24..d18b64c8b3 100644 --- a/apps/api/src/isms/documents/registry.ts +++ b/apps/api/src/isms/documents/registry.ts @@ -5,6 +5,7 @@ import { buildContextSections } from './context'; import { buildInterestedPartiesSections } from './interested-parties'; import { buildRequirementsSections } from './requirements'; import { buildObjectivesSections } from './objectives'; +import { buildRolesSections } from './roles'; import { buildScopeSections, deriveScopeNarrative, @@ -31,6 +32,7 @@ const EXPORT_SECTION_BUILDERS: Record< interested_parties_register: buildInterestedPartiesSections, interested_parties_requirements: buildRequirementsSections, objectives_plan: buildObjectivesSections, + roles_and_responsibilities: buildRolesSections, isms_scope: buildScopeSections, leadership_commitment: buildLeadershipSections, }; diff --git a/apps/api/src/isms/documents/roles-defaults.ts b/apps/api/src/isms/documents/roles-defaults.ts new file mode 100644 index 0000000000..1987398a31 --- /dev/null +++ b/apps/api/src/isms/documents/roles-defaults.ts @@ -0,0 +1,106 @@ +import type { SeedRoleDefinition } from './types'; + +/** + * The four seeded ISMS governance roles (clause 5.3) and their pre-filled + * default text. Drafted from the reference document + * ("05a - ISMS Roles, Responsibilities & Authorities") so a customer with no ISO + * expertise gets a workable baseline; every field is editable in the app. + * + * Order is the render/seed order. `roleKey` is the idempotency key for seeding. + */ +export const SEED_ROLE_DEFINITIONS: SeedRoleDefinition[] = [ + { + roleKey: 'top_management', + name: 'Top Management', + description: + 'The executive leadership accountable for the ISMS. Top management sets the direction of the information security programme and provides the mandate and resources for it to operate.', + responsibilities: + 'Approve the ISMS scope, information security policy, and objectives; provide the resources the ISMS needs; direct and support the people contributing to the ISMS; promote continual improvement; and accept or escalate residual risk.', + authorities: + 'Approve the ISMS and accept residual risk on behalf of the organisation.', + authorityGrantedBy: 'The executive office and the Board of Directors', + requiredCompetence: + 'Executive understanding of the organisation, its risk appetite, and its legal and regulatory obligations, sufficient to direct the ISMS and be accountable for it.', + }, + { + roleKey: 'spo', + name: 'Security & Privacy Owner (SPO)', + description: + 'The person who owns and operates the ISMS day to day and is the focal point for information security and privacy across the organisation.', + responsibilities: + 'Operate the ISMS day to day — scope, Statement of Applicability, risk, vendor, policy, training, incident, and audit programmes; ensure the ISMS conforms to ISO/IEC 27001; report ISMS performance to top management; and assign control, risk, and policy owners.', + authorities: + 'Direct the security and privacy programme; assign control, risk, and policy owners; and approve exceptions within the organisation’s risk appetite.', + authorityGrantedBy: 'Top Management', + requiredCompetence: + 'Working knowledge of ISO/IEC 27001 and the organisation’s control environment, with the experience to operate an ISMS and make risk-based decisions within the agreed risk appetite.', + }, + { + roleKey: 'deputy_spo', + name: 'Deputy Security & Privacy Owner', + description: + 'The documented backup for the Security & Privacy Owner, ensuring the ISMS remains covered during the SPO’s absence.', + responsibilities: + 'Provide documented backup for the SPO; act with the SPO’s authority during the SPO’s absence; and participate in incident response and management reviews as required.', + authorities: 'Act with the SPO’s authority during the SPO’s absence.', + authorityGrantedBy: 'Top Management', + requiredCompetence: + 'Sufficient familiarity with the ISMS and the organisation’s controls to stand in for the SPO and make interim risk-based decisions.', + }, + { + roleKey: 'internal_auditor', + name: 'Internal Auditor', + description: + 'The person or party responsible for independently auditing the ISMS to confirm it conforms to ISO/IEC 27001 and is effectively implemented and maintained.', + responsibilities: + 'Plan and conduct internal audits of the ISMS at the intervals defined in the audit programme; report findings and nonconformities; and maintain independence and impartiality from the areas audited.', + authorities: + 'Access the information, systems, and personnel needed to conduct the audit, and report findings directly to top management.', + authorityGrantedBy: 'Top Management', + requiredCompetence: + 'Knowledge of ISO/IEC 27001 auditing (e.g. ISO 19011 principles) and the independence to audit the ISMS objectively; where in-house audit competence is not held, it is engaged externally.', + }, +]; + +/** roleKey values of the seeded roles, for validation and lookups. */ +export const SEED_ROLE_KEYS: string[] = SEED_ROLE_DEFINITIONS.map( + (role) => role.roleKey, +); + +/** + * The two governance rows that appear only in the generated document (never as + * editable role cards): the per-artifact operational owners and the whole + * workforce. Holders/text are fixed. + */ +export const AUTO_DOC_ROLE_ROWS = [ + { + name: 'Control / asset / risk / policy owners', + holders: 'Identified in the platform per policy, control, risk, task, and vendor.', + responsibilities: + 'Implement, operate, and evidence the specific controls, policies, risks, and evidence tasks assigned to them. Operational ownership is assigned at the artifact level in Comp AI.', + authority: 'Make operational decisions for their assigned item. Authority granted by the SPO.', + }, + { + name: 'All personnel and contractors', + holders: 'All workforce members.', + responsibilities: + 'Comply with the policy set; complete required training; protect company data on personal devices; and report incidents and near-misses.', + authority: 'Use approved tools and identity flows. Granted on engagement.', + }, +] as const; + +/** + * Non-removable note reproduced verbatim in the generated document (§2). Clarifies + * that Comp AI application-access levels are not ISMS governance roles. + */ +export const APPLICATION_ACCESS_NOTE = + 'Comp AI application-access levels (Owner / Admin / Auditor / Employee / Contractor) are not ISMS governance roles. The ISMS governance roles are defined below.'; + +/** The application-access levels and what they grant (generated document §2). */ +export const APPLICATION_ACCESS_LEVELS: string[] = [ + 'Owner — the account creator.', + 'Admin — administrative access to the GRC platform.', + 'Auditor — read access provided to external auditors.', + 'Employee — access to the employee portal.', + 'Contractor — limited access for engaged contractors.', +]; diff --git a/apps/api/src/isms/documents/roles-export-data.ts b/apps/api/src/isms/documents/roles-export-data.ts new file mode 100644 index 0000000000..4f4b9887b3 --- /dev/null +++ b/apps/api/src/isms/documents/roles-export-data.ts @@ -0,0 +1,137 @@ +import { db } from '@db'; +import type { Prisma } from '@db'; +import type { IsmsTeamSizeBand, OperationalOwnershipRow } from './types'; +import { teamSizeBand } from './roles'; + +/** + * Extra data the Roles document (5.3) needs at export time but that isn't on the + * document's own rows: display names for assigned members (assignments store a + * plain memberId, no FK), the live per-artifact operational owners, and the + * team-size band. Resolved once and frozen into the version snapshot so a + * historical export re-renders byte-faithfully. + */ +export interface RolesExtras { + /** memberId → display name (name, else email, else a placeholder). */ + memberNames: Record; + operationalOwnership: OperationalOwnershipRow[]; + band: IsmsTeamSizeBand; +} + +type Client = Prisma.TransactionClient | typeof db; + +const OWNER_DISPLAY_CAP = 12; + +type NamedAssignee = { + assignee: { user: { name: string | null; email: string | null } | null } | null; +}; + +function memberDisplayName(user: { name: string | null; email: string | null } | null): string { + return user?.name?.trim() || user?.email?.trim() || 'Unknown member'; +} + +function dedupeOwners(rows: NamedAssignee[]): string[] { + const seen = new Set(); + const names: string[] = []; + for (const row of rows) { + if (!row.assignee?.user) continue; + const name = memberDisplayName(row.assignee.user); + if (seen.has(name)) continue; + seen.add(name); + names.push(name); + } + names.sort((a, b) => a.localeCompare(b)); + if (names.length <= OWNER_DISPLAY_CAP) return names; + return [ + ...names.slice(0, OWNER_DISPLAY_CAP), + `and ${names.length - OWNER_DISPLAY_CAP} more`, + ]; +} + +/** Load the Roles document's export extras for an organization. */ +export async function loadRolesExtras({ + organizationId, + client, +}: { + organizationId: string; + client?: Client; +}): Promise { + const prisma = client ?? db; + const assigneeSelect = { + assignee: { select: { user: { select: { name: true, email: true } } } }, + } as const; + + const [members, memberCount, policies, risks, tasks, vendors] = + await Promise.all([ + prisma.member.findMany({ + where: { organizationId }, + select: { id: true, user: { select: { name: true, email: true } } }, + }), + prisma.member.count({ where: { organizationId, deactivated: false } }), + prisma.policy.findMany({ + where: { organizationId, assigneeId: { not: null } }, + select: assigneeSelect, + }), + prisma.risk.findMany({ + where: { organizationId, assigneeId: { not: null } }, + select: assigneeSelect, + }), + prisma.task.findMany({ + where: { organizationId, assigneeId: { not: null } }, + select: assigneeSelect, + }), + prisma.vendor.findMany({ + where: { organizationId, assigneeId: { not: null } }, + select: assigneeSelect, + }), + ]); + + const memberNames: Record = {}; + for (const member of members) { + memberNames[member.id] = memberDisplayName(member.user); + } + + // Controls have no owner field in the platform; their ownership is descriptive + // (assigned per control via their linked tasks). Kept in the matrix per the + // reference document, without enumerating names. + const operationalOwnership: OperationalOwnershipRow[] = [ + { + artifact: 'Policies', + assignedWhere: 'Policy assignee / approver in Comp AI', + ownerResponsibility: + 'Keep the policy current and accurate; ensure required acknowledgement.', + owners: dedupeOwners(policies), + }, + { + artifact: 'Controls', + assignedWhere: 'Control owner in Comp AI', + ownerResponsibility: 'Implement, operate, and evidence the control.', + owners: [], + }, + { + artifact: 'Risks', + assignedWhere: 'Risk owner in Comp AI', + ownerResponsibility: + 'Assess, treat, and monitor the risk within the risk appetite.', + owners: dedupeOwners(risks), + }, + { + artifact: 'Evidence tasks', + assignedWhere: 'Task assignee in Comp AI', + ownerResponsibility: 'Complete and evidence the task by its due date.', + owners: dedupeOwners(tasks), + }, + { + artifact: 'Vendors / sub-processors', + assignedWhere: 'Vendor owner in Comp AI', + ownerResponsibility: + 'Perform due diligence and ongoing security review; maintain the DPA.', + owners: dedupeOwners(vendors), + }, + ]; + + return { + memberNames, + operationalOwnership, + band: teamSizeBand(memberCount), + }; +} diff --git a/apps/api/src/isms/documents/roles-export.spec.ts b/apps/api/src/isms/documents/roles-export.spec.ts new file mode 100644 index 0000000000..10658900b9 --- /dev/null +++ b/apps/api/src/isms/documents/roles-export.spec.ts @@ -0,0 +1,100 @@ +import { buildExportSections } from './registry'; +import { generateIsmsExportFile } from '../utils/export-generator'; +import { buildExportMetadata } from '../utils/export-metadata'; +import type { DocumentExportInput, RoleExportRow } from './types'; + +/** + * End-to-end render check for the Roles document (5.3): the section builder + + * both real renderers (jsPDF, docx) must produce non-empty files. Guards the + * whole export pipeline for the new type without needing a live org. + */ +function role(overrides: Partial): RoleExportRow { + return { + roleKey: 'spo', + name: 'Security & Privacy Owner (SPO)', + description: 'Owns the ISMS.', + responsibilities: 'Operate the ISMS.', + authorities: 'Direct the programme.', + authorityGrantedBy: 'Top Management', + requiredCompetence: 'ISO 27001 knowledge.', + holders: ['Alex Petrisor'], + auditRoute: null, + auditRouteHolderName: null, + auditFirmName: null, + auditEvidenceRef: null, + auditCourse: null, + auditDueDate: null, + ...overrides, + }; +} + +const INPUT: DocumentExportInput = { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + roles: [ + role({ roleKey: 'top_management', name: 'Top Management', holders: ['Raoul'] }), + role({}), + role({ + roleKey: 'internal_auditor', + name: 'Internal Auditor', + auditRoute: 'external', + auditFirmName: 'Acme Audit LLP', + holders: ['External auditor'], + }), + ], + operationalOwnership: [ + { + artifact: 'Policies', + assignedWhere: 'Policy assignee in Comp AI', + ownerResponsibility: 'Keep it current.', + owners: ['Alice'], + }, + ], + band: 'standard', +}; + +function metadata() { + return buildExportMetadata({ + type: 'roles_and_responsibilities', + title: 'Roles, Responsibilities and Authorities', + frameworkName: 'ISO 27001', + version: 1, + status: 'approved', + preparedBy: 'Comp AI', + owner: null, + approverName: 'Raoul Plickat', + approvedAt: new Date('2026-05-26T00:00:00.000Z'), + declinedAt: null, + organizationName: 'Pressmaster AI Inc.', + primaryColor: '#004D3D', + }); +} + +describe('Roles document export', () => { + const sections = buildExportSections({ + type: 'roles_and_responsibilities', + input: INPUT, + }); + + it('renders a non-empty PDF', async () => { + const result = await generateIsmsExportFile({ + sections, + metadata: metadata(), + format: 'pdf', + }); + expect(result.fileBuffer.length).toBeGreaterThan(0); + expect(result.mimeType).toBe('application/pdf'); + }); + + it('renders a non-empty DOCX', async () => { + const result = await generateIsmsExportFile({ + sections, + metadata: metadata(), + format: 'docx', + }); + expect(result.fileBuffer.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/isms/documents/roles.spec.ts b/apps/api/src/isms/documents/roles.spec.ts new file mode 100644 index 0000000000..786c6e8242 --- /dev/null +++ b/apps/api/src/isms/documents/roles.spec.ts @@ -0,0 +1,340 @@ +import { + buildRolesSections, + roleValidationMessages, + seedRolesIfMissing, + teamSizeBand, + type RoleValidationRow, +} from './roles'; +import type { + DocumentExportInput, + RoleExportRow, + OperationalOwnershipRow, +} from './types'; +import type { IsmsExportSection } from '../utils/export-shared'; + +function role(overrides: Partial): RoleExportRow { + return { + roleKey: null, + name: 'Custom role', + description: 'desc', + responsibilities: 'resp', + authorities: 'auth', + authorityGrantedBy: 'Top Management', + requiredCompetence: 'comp', + holders: [], + auditRoute: null, + auditRouteHolderName: null, + auditFirmName: null, + auditEvidenceRef: null, + auditCourse: null, + auditDueDate: null, + ...overrides, + }; +} + +const OWNERSHIP: OperationalOwnershipRow[] = [ + { + artifact: 'Policies', + assignedWhere: 'Policy assignee / approver in Comp AI', + ownerResponsibility: 'Keep the policy current.', + owners: ['Alice'], + }, + { + artifact: 'Controls', + assignedWhere: 'Control owner in Comp AI', + ownerResponsibility: 'Operate the control.', + owners: [], + }, +]; + +function input(overrides: Partial): DocumentExportInput { + return { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + roles: [], + operationalOwnership: OWNERSHIP, + band: 'standard', + ...overrides, + }; +} + +function findSection( + sections: IsmsExportSection[], + heading: string, +): IsmsExportSection | undefined { + return sections.find((section) => section.heading === heading); +} + +describe('teamSizeBand', () => { + it.each([ + [0, 'small'], + [1, 'small'], + [3, 'small'], + [4, 'standard'], + [50, 'standard'], + ])('maps %i members to %s', (count, band) => { + expect(teamSizeBand(count)).toBe(band); + }); +}); + +describe('buildRolesSections', () => { + it('renders a 6-row governance table (roles + 2 auto rows) with holders', () => { + const sections = buildRolesSections( + input({ + roles: [ + role({ roleKey: 'top_management', name: 'Top Management', holders: ['Raoul'] }), + role({ roleKey: 'spo', name: 'SPO', holders: ['Alex'] }), + ], + }), + ); + const table = findSection(sections, 'ISMS governance roles')?.table; + expect(table?.headers).toHaveLength(4); + // 2 provided roles + 2 auto-generated rows + expect(table?.rows).toHaveLength(4); + expect(table?.rows[0][1]).toBe('Raoul'); // holder column + expect(table?.rows.some((r) => r[0] === 'Control / asset / risk / policy owners')).toBe(true); + expect(table?.rows.some((r) => r[0] === 'All personnel and contractors')).toBe(true); + }); + + it('shows [To be named] when a role has no holders', () => { + const sections = buildRolesSections( + input({ roles: [role({ roleKey: 'spo', name: 'SPO', holders: [] })] }), + ); + const table = findSection(sections, 'ISMS governance roles')?.table; + expect(table?.rows[0][1]).toBe('[To be named]'); + }); + + it('assigns 5.3(a) and (b) to the SPO holder in prose', () => { + const sections = buildRolesSections( + input({ roles: [role({ roleKey: 'spo', name: 'SPO', holders: ['Alex Petrisor'] })] }), + ); + const assignments = findSection(sections, 'Specific assignments required by Clause 5.3'); + const paragraphs = assignments?.paragraphs ?? []; + expect(paragraphs.find((p) => p.text.startsWith('(a)'))?.text).toContain('Alex Petrisor'); + expect(paragraphs.find((p) => p.text.startsWith('(b)'))?.text).toContain('Alex Petrisor'); + }); + + it('describes the chosen internal audit route (external)', () => { + const sections = buildRolesSections( + input({ + roles: [ + role({ + roleKey: 'internal_auditor', + name: 'Internal Auditor', + auditRoute: 'external', + auditFirmName: 'Acme Audit LLP', + }), + ], + }), + ); + const text = findSection(sections, 'Internal audit route')?.paragraphs?.[0].text ?? ''; + expect(text).toContain('external independent auditor'); + expect(text).toContain('Acme Audit LLP'); + }); + + it('renders the team-size note and a summary ONLY for the small band', () => { + const small = buildRolesSections(input({ band: 'small' })); + expect(findSection(small, 'Note on team size')).toBeDefined(); + // Operational responsibilities are a bulleted summary in the small band. + const smallOps = findSection(small, 'Operational responsibilities'); + expect(smallOps?.bullets).toBeDefined(); + expect(smallOps?.table).toBeUndefined(); + + const standard = buildRolesSections(input({ band: 'standard' })); + expect(findSection(standard, 'Note on team size')).toBeUndefined(); + // ...and a 4-column table in the standard band, surfacing live owners. + const stdOps = findSection(standard, 'Operational responsibilities'); + expect(stdOps?.table?.headers).toHaveLength(4); + expect(stdOps?.table?.rows[0]).toContain('Alice'); + }); +}); + +describe('roleValidationMessages (server gate)', () => { + const ACTIVE = new Set(['m1']); + const assigned = { memberId: 'm1' }; + const check = (roles: RoleValidationRow[], memberCount = 20) => + roleValidationMessages({ roles, memberCount, activeMemberIds: ACTIVE }); + const externalAuditor: RoleValidationRow = { + roleKey: 'internal_auditor', + name: 'Internal Auditor', + auditRoute: 'external', + auditFirmName: 'Acme Audit LLP', + auditEvidenceRef: 'LA cert on file', + assignments: [assigned], + }; + const complete = (): RoleValidationRow[] => [ + { roleKey: 'top_management', name: 'Top Management', auditRoute: null, assignments: [assigned] }, + { roleKey: 'spo', name: 'SPO', auditRoute: null, assignments: [assigned] }, + { roleKey: 'deputy_spo', name: 'Deputy SPO', auditRoute: null, assignments: [assigned] }, + { ...externalAuditor }, + ]; + + it('passes when every seeded role is present + assigned + routed', () => { + expect(check(complete())).toEqual([]); + }); + + it('does not count an assignment to a non-active member', () => { + const roles = complete(); + roles[0] = { + roleKey: 'top_management', + name: 'Top Management', + auditRoute: null, + assignments: [{ memberId: 'deactivated' }], + }; + expect(check(roles)).toContain('Top Management needs at least one assigned member.'); + }); + + it('requires firm + evidence for the external audit route (whitespace does not count)', () => { + const roles = complete(); + roles[3] = { + roleKey: 'internal_auditor', + name: 'Internal Auditor', + auditRoute: 'external', + auditFirmName: ' ', + auditEvidenceRef: '', + assignments: [assigned], + }; + expect(check(roles)).toContain( + 'The external Internal Auditor needs a firm/person name and an evidence reference.', + ); + }); + + it('requires an ACTIVE member for the in-house audit route', () => { + const roles = complete(); + roles[3] = { + roleKey: 'internal_auditor', + name: 'Internal Auditor', + auditRoute: 'in_house', + auditRouteMemberId: 'deactivated', // set, but not in the active set + assignments: [assigned], + }; + expect(check(roles)).toContain( + 'The in-house Internal Auditor needs an active member selected.', + ); + }); + + it('requires active member + course + due date for the training-planned route', () => { + const roles = complete(); + roles[3] = { + roleKey: 'internal_auditor', + name: 'Internal Auditor', + auditRoute: 'training_planned', + auditRouteMemberId: 'm1', // active + auditCourse: ' ', // whitespace → missing + auditDueDate: '2026-05-26', + assignments: [assigned], + }; + expect(check(roles)).toContain( + 'The training-planned Internal Auditor needs an active member, a course, and a due date.', + ); + }); + + it('flags an entirely-missing required seeded role', () => { + const roles = complete().filter((r) => r.roleKey !== 'top_management'); + expect(check(roles)).toContain('Top Management is missing from the document.'); + }); + + it('flags a present-but-unassigned seeded role', () => { + const roles = complete(); + roles[1] = { roleKey: 'spo', name: 'SPO', auditRoute: null, assignments: [] }; + expect(check(roles)).toContain('SPO needs at least one assigned member.'); + }); + + it('treats Deputy SPO as optional only in the small band', () => { + const roles = complete().filter((r) => r.roleKey !== 'deputy_spo'); + expect(check(roles, 2)).toEqual([]); + expect(check(roles, 10)).toContain( + 'Deputy Security & Privacy Owner is missing from the document.', + ); + }); + + it('requires the Internal Auditor route', () => { + const roles = complete(); + roles[3] = { + roleKey: 'internal_auditor', + name: 'Internal Auditor', + auditRoute: null, + assignments: [assigned], + }; + expect(check(roles)).toContain('The Internal Auditor needs an audit route selected.'); + }); +}); + +describe('seedRolesIfMissing', () => { + function makeTx(existingRoleKeys: Array) { + return { + ismsRole: { + findMany: jest + .fn() + .mockResolvedValue( + existingRoleKeys.map((roleKey, i) => ({ roleKey, position: i })), + ), + createMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }; + } + + it('creates all four seeded roles on an empty document', async () => { + const tx = makeTx([]); + await seedRolesIfMissing({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: tx as any, + documentId: 'doc_1', + memberCount: 10, + }); + const created = tx.ismsRole.createMany.mock.calls[0][0].data; + expect(created).toHaveLength(4); + expect(created.map((r: { roleKey: string }) => r.roleKey)).toEqual([ + 'top_management', + 'spo', + 'deputy_spo', + 'internal_auditor', + ]); + }); + + it('is idempotent: creates nothing when all seeded roles exist', async () => { + const tx = makeTx(['top_management', 'spo', 'deputy_spo', 'internal_auditor']); + await seedRolesIfMissing({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: tx as any, + documentId: 'doc_1', + memberCount: 10, + }); + expect(tx.ismsRole.createMany).not.toHaveBeenCalled(); + }); + + it('defaults the Internal Auditor route to external for a small team', async () => { + const tx = makeTx([]); + await seedRolesIfMissing({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: tx as any, + documentId: 'doc_1', + memberCount: 2, + }); + const created = tx.ismsRole.createMany.mock.calls[0][0].data as Array<{ + roleKey: string; + auditRoute: string | null; + }>; + const auditor = created.find((r) => r.roleKey === 'internal_auditor'); + expect(auditor?.auditRoute).toBe('external'); + }); + + it('leaves the Internal Auditor route unset for a standard team', async () => { + const tx = makeTx([]); + await seedRolesIfMissing({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tx: tx as any, + documentId: 'doc_1', + memberCount: 20, + }); + const created = tx.ismsRole.createMany.mock.calls[0][0].data as Array<{ + roleKey: string; + auditRoute: string | null; + }>; + const auditor = created.find((r) => r.roleKey === 'internal_auditor'); + expect(auditor?.auditRoute).toBeNull(); + }); +}); diff --git a/apps/api/src/isms/documents/roles.ts b/apps/api/src/isms/documents/roles.ts new file mode 100644 index 0000000000..bdc1490d04 --- /dev/null +++ b/apps/api/src/isms/documents/roles.ts @@ -0,0 +1,391 @@ +import type { Prisma } from '@db'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { + DocumentExportInput, + IsmsTeamSizeBand, + OperationalOwnershipRow, + RoleExportRow, +} from './types'; +import { + APPLICATION_ACCESS_LEVELS, + APPLICATION_ACCESS_NOTE, + AUTO_DOC_ROLE_ROWS, + SEED_ROLE_DEFINITIONS, + SEED_ROLE_KEYS, +} from './roles-defaults'; + +type Tx = Prisma.TransactionClient; + +/** 1-3 people → 'small', 4+ → 'standard'. Drives the Roles document's copy/defaults. */ +export function teamSizeBand(memberCount: number): IsmsTeamSizeBand { + return memberCount <= 3 ? 'small' : 'standard'; +} + +/** Seeded roles that must be present + assigned before the 5.3 doc can be published. */ +const REQUIRED_SEED_ROLE_KEYS = SEED_ROLE_DEFINITIONS.map((role) => role.roleKey); + +/** The subset of role fields the completeness check needs (server + client share it). */ +export interface RoleValidationRow { + roleKey: string | null; + name: string; + auditRoute: string | null; + auditRouteMemberId?: string | null; + auditFirmName?: string | null; + auditEvidenceRef?: string | null; + auditCourse?: string | null; + auditDueDate?: Date | string | null; + assignments: Array<{ memberId: string }>; +} + +/** True when the audit-route member is set AND is an active organization member. */ +function hasActiveAuditMember( + role: RoleValidationRow, + activeMemberIds: Set, +): boolean { + return ( + !!role.auditRouteMemberId && activeMemberIds.has(role.auditRouteMemberId) + ); +} + +/** + * Route-specific required fields for the Internal Auditor (CS-698). The in-house + * and training-planned routes need an ACTIVE selected member; external needs a + * firm/person and evidence reference. Text is trimmed so whitespace can't satisfy + * a required field via the API. + */ +function auditRouteMessages( + role: RoleValidationRow, + activeMemberIds: Set, +): string[] { + const route = role.auditRoute; + if (!route) return ['The Internal Auditor needs an audit route selected.']; + if (route === 'in_house') { + return hasActiveAuditMember(role, activeMemberIds) + ? [] + : ['The in-house Internal Auditor needs an active member selected.']; + } + if (route === 'external') { + return role.auditFirmName?.trim() && role.auditEvidenceRef?.trim() + ? [] + : [ + 'The external Internal Auditor needs a firm/person name and an evidence reference.', + ]; + } + // training_planned + const complete = + hasActiveAuditMember(role, activeMemberIds) && + !!role.auditCourse?.trim() && + !!role.auditDueDate; + return complete + ? [] + : [ + 'The training-planned Internal Auditor needs an active member, a course, and a due date.', + ]; +} + +/** + * Clause-5.3 completeness check, shared by the submit-for-approval server gate. + * Every seeded role must exist and have at least one assigned member (except the + * Deputy SPO in the 1-3 band); the Internal Auditor must have a route AND its + * route-specific fields. Only assignments (and audit-route members) that resolve + * to an ACTIVE member count. Missing rows are reported too. Returns the unmet + * requirements; empty = ready. + */ +export function roleValidationMessages({ + roles, + memberCount, + activeMemberIds, +}: { + roles: RoleValidationRow[]; + memberCount: number; + activeMemberIds: Set; +}): string[] { + const band = teamSizeBand(memberCount); + const byKey = new Map( + roles + .filter((role) => role.roleKey) + .map((role) => [role.roleKey as string, role]), + ); + const messages: string[] = []; + for (const key of REQUIRED_SEED_ROLE_KEYS) { + const role = byKey.get(key); + const name = + role?.name ?? + SEED_ROLE_DEFINITIONS.find((seed) => seed.roleKey === key)?.name ?? + key; + const optional = key === 'deputy_spo' && band === 'small'; + if (!role) { + if (!optional) messages.push(`${name} is missing from the document.`); + continue; + } + const activeAssignments = role.assignments.filter((assignment) => + activeMemberIds.has(assignment.memberId), + ); + if (!optional && activeAssignments.length === 0) { + messages.push(`${name} needs at least one assigned member.`); + } + if (key === 'internal_auditor') { + messages.push(...auditRouteMessages(role, activeMemberIds)); + } + } + return messages; +} + +/** + * Seed the four governance roles for a Roles document, idempotently by `roleKey`. + * Only creates seed roles that are missing — it NEVER deletes or overwrites, so a + * regenerate can never clobber the customer's edits or member assignments (unlike + * the derived-row replace used by the other registers, which would cascade-delete + * IsmsRoleAssignment rows). Safe to call at document creation and on every generate. + * + * The Internal Auditor route defaults to `external` for small teams (1-3), the + * standard route at that size; larger teams choose their own route. + */ +export async function seedRolesIfMissing({ + tx, + documentId, + memberCount, +}: { + tx: Tx; + documentId: string; + memberCount: number; +}): Promise { + const existing = await tx.ismsRole.findMany({ + where: { documentId }, + select: { roleKey: true, position: true }, + }); + const existingKeys = new Set( + existing.map((role) => role.roleKey).filter((key): key is string => !!key), + ); + const missing = SEED_ROLE_DEFINITIONS.filter( + (role) => !existingKeys.has(role.roleKey), + ); + if (missing.length === 0) return; + + const maxPosition = existing.reduce( + (max, role) => Math.max(max, role.position), + -1, + ); + const band = teamSizeBand(memberCount); + + await tx.ismsRole.createMany({ + data: missing.map((role, index) => ({ + documentId, + roleKey: role.roleKey, + name: role.name, + description: role.description, + responsibilities: role.responsibilities, + authorities: role.authorities, + authorityGrantedBy: role.authorityGrantedBy, + requiredCompetence: role.requiredCompetence, + auditRoute: + role.roleKey === 'internal_auditor' && band === 'small' + ? 'external' + : null, + source: 'derived', + derivedFrom: `seed:${role.roleKey}`, + position: maxPosition + 1 + index, + })), + // Belt-and-braces with the @@unique([documentId, roleKey]) constraint: a + // concurrent provision/generate that races this seed is absorbed silently + // instead of throwing, so parallel first-loads can't double-seed. + skipDuplicates: true, + }); +} + +// ---- Export section builder ------------------------------------------------- + +function holderText(holders: string[]): string { + return holders.length > 0 ? holders.join(', ') : '[To be named]'; +} + +/** Combine the authority text and its source into the reference doc's column. */ +function authorityText(role: RoleExportRow): string { + const granted = role.authorityGrantedBy.trim(); + if (!granted) return role.authorities; + return `${role.authorities} Authority granted by ${granted}.`; +} + +function internalAuditParagraph(role: RoleExportRow | undefined): string { + if (!role || !role.auditRoute) { + return 'The internal audit route has not yet been selected.'; + } + if (role.auditRoute === 'in_house') { + const who = role.auditRouteHolderName + ? `, performed by ${role.auditRouteHolderName}` + : ''; + return `The organisation conducts its internal ISMS audit in-house${who}. The internal auditor maintains independence and impartiality from the areas they audit.`; + } + if (role.auditRoute === 'external') { + const firm = role.auditFirmName ? `: ${role.auditFirmName}` : ''; + const evidence = role.auditEvidenceRef + ? ` Supporting evidence: ${role.auditEvidenceRef}.` + : ''; + return `The organisation engages an external independent auditor to conduct its internal ISMS audit${firm}.${evidence}`; + } + // training_planned + const who = role.auditRouteHolderName ? ` by ${role.auditRouteHolderName}` : ''; + const course = role.auditCourse ? ` through ${role.auditCourse}` : ''; + const due = role.auditDueDate ? `, due ${role.auditDueDate}` : ''; + return `Internal audit competence is being developed${who}${course}${due}. Until it is in place, an external independent auditor is the recommended interim route.`; +} + +function buildRoleTable(roles: RoleExportRow[]): IsmsExportSection['table'] { + // Per CS-698 the governance table is exactly the four seeded roles + the two + // auto-generated rows. Custom roles are managed on the page but are not part of + // this standardized Clause 5.3 table, so they're excluded here. + const seededRoles = roles.filter( + (role) => role.roleKey !== null && SEED_ROLE_KEYS.includes(role.roleKey), + ); + return { + headers: ['Role', 'Holder', 'Responsibility', 'Authority — and granted by'], + rows: [ + ...seededRoles.map((role) => [ + role.name, + holderText(role.holders), + role.responsibilities, + authorityText(role), + ]), + ...AUTO_DOC_ROLE_ROWS.map((row) => [ + row.name, + row.holders, + row.responsibilities, + row.authority, + ]), + ], + }; +} + +function buildOperationalSection( + ownership: OperationalOwnershipRow[], + band: IsmsTeamSizeBand, +): IsmsExportSection { + const intro = + 'Operational responsibility for the ISMS is assigned at the level of individual artifacts. Comp AI records a named owner or assignee for every policy, control, risk, evidence task, and vendor; this constitutes the live, auditable responsibilities matrix.'; + + if (band === 'small') { + return { + heading: 'Operational responsibilities', + intro, + bullets: ownership.map((row) => { + const owners = + row.owners.length > 0 + ? row.owners.join(', ') + : 'assigned per item in Comp AI'; + return `${row.artifact}: ${owners}.`; + }), + }; + } + + return { + heading: 'Operational responsibilities', + intro, + table: { + headers: [ + 'ISMS artifact', + 'Where responsibility is assigned', + 'Owner’s responsibility', + 'Current owner(s)', + ], + rows: ownership.map((row) => [ + row.artifact, + row.assignedWhere, + row.ownerResponsibility, + row.owners.length > 0 ? row.owners.join(', ') : '—', + ]), + }, + }; +} + +/** + * Build the ISMS Roles, Responsibilities & Authorities document (clause 5.3). + * Structure follows the reference document merged with the ticket's ordered + * contents. `roles`, `operationalOwnership` and `band` are populated by + * loadRolesExtras at export-input assembly (see roles-export-data.ts). + */ +export function buildRolesSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const roles = input.roles ?? []; + const ownership = input.operationalOwnership ?? []; + const band: IsmsTeamSizeBand = input.band ?? 'standard'; + const spo = roles.find((role) => role.roleKey === 'spo'); + const spoHolders = spo && spo.holders.length > 0 ? ` (${spo.holders.join(', ')})` : ''; + const auditor = roles.find((role) => role.roleKey === 'internal_auditor'); + + const sections: IsmsExportSection[] = [ + { + heading: 'Purpose', + paragraphs: [ + { + text: 'This document defines the responsibilities and authorities for the roles relevant to information security in the organisation, and the authority from which each is derived, in accordance with ISO/IEC 27001:2022, Clause 5.3. It also supports the authorities-and-responsibilities matrix referenced in Clause 5.2.', + }, + ], + }, + { + heading: 'Relationship to Comp AI application-access roles', + intro: + 'The Comp AI platform assigns application-access levels that determine access to the software only:', + bullets: APPLICATION_ACCESS_LEVELS, + paragraphs: [{ text: APPLICATION_ACCESS_NOTE }], + }, + { + heading: 'ISMS governance roles', + intro: + 'The following named roles carry the accountability and decision authority for the ISMS.', + table: buildRoleTable(roles), + }, + { + heading: 'Specific assignments required by Clause 5.3', + intro: 'Top management has assigned the responsibility and authority for:', + paragraphs: [ + { + text: `(a) Ensuring that the ISMS conforms to the requirements of ISO/IEC 27001 — assigned to the Security & Privacy Owner${spoHolders}.`, + }, + { + text: `(b) Reporting on the performance of the ISMS to top management — assigned to the Security & Privacy Owner${spoHolders}, delivered at each management review and on material incidents or risk changes.`, + }, + { + text: 'The authority to assign tasks and make decisions within the ISMS rests with top management, who assign it to the Security & Privacy Owner; the SPO in turn assigns operational ownership of individual controls, policies, risks, and tasks through Comp AI.', + }, + ], + }, + { + heading: 'Internal audit route', + paragraphs: [{ text: internalAuditParagraph(auditor) }], + }, + ]; + + if (band === 'small') { + sections.push({ + heading: 'Note on team size', + paragraphs: [ + { + text: 'The organisation currently operates with a small team (three or fewer people). Some ISMS roles are necessarily held by the same individuals, and the Deputy Security & Privacy Owner may be unfilled. This is a pragmatic and accepted arrangement at this size; the internal audit route selected for this ISMS is stated in the "Internal audit route" section above. Be prepared to explain this structure at audit.', + }, + ], + }); + } + + sections.push(buildOperationalSection(ownership, band)); + + sections.push({ + heading: 'Communication of roles', + paragraphs: [ + { + text: 'Responsibilities and authorities are made known throughout the organisation through policy acknowledgement at onboarding and annually, the per-item owner assignments recorded in Comp AI, and the leadership communications captured in management reviews.', + }, + ], + }); + + sections.push({ + heading: 'Review', + paragraphs: [ + { + text: 'This document is owned by the Security & Privacy Owner and is reviewed at least annually and on material change to governance, organisational structure, or key personnel. The approver and approval date are recorded in the document control table above.', + }, + ], + }); + + return sections; +} diff --git a/apps/api/src/isms/documents/snapshot.ts b/apps/api/src/isms/documents/snapshot.ts index d37dfec93c..eb04c6d8a1 100644 --- a/apps/api/src/isms/documents/snapshot.ts +++ b/apps/api/src/isms/documents/snapshot.ts @@ -86,6 +86,10 @@ const TYPE_DRIFT_SOURCES: Record> = { 'members', 'wizardAnswers', ], + // Roles text is static defaults; the only platform input the 5.3 document + // consumes is headcount, which sets the team-size band (small vs standard) and + // so changes the team-size note + operational-responsibilities rendering. + roles_and_responsibilities: ['members'], isms_scope: [ 'frameworks', 'vendors', diff --git a/apps/api/src/isms/documents/types.ts b/apps/api/src/isms/documents/types.ts index a902387a16..f5c58add49 100644 --- a/apps/api/src/isms/documents/types.ts +++ b/apps/api/src/isms/documents/types.ts @@ -77,6 +77,48 @@ export interface DerivedObjective extends DerivedRegisterRow { measurementMethod: string | null; } +/** Team-size band that drives the Roles document's copy and defaults (5.3). */ +export type IsmsTeamSizeBand = 'small' | 'standard'; // small = 1-3 people, standard = 4+ + +/** The four seeded ISMS governance roles and their pre-filled default text. */ +export interface SeedRoleDefinition { + roleKey: 'top_management' | 'spo' | 'deputy_spo' | 'internal_auditor'; + name: string; + description: string; + responsibilities: string; + authorities: string; + authorityGrantedBy: string; + requiredCompetence: string; +} + +/** A role, resolved for export: fields + named holders + internal-audit route. */ +export interface RoleExportRow { + roleKey: string | null; + name: string; + description: string; + responsibilities: string; + authorities: string; + authorityGrantedBy: string; + requiredCompetence: string; + /** Display names of the assigned members (resolved + frozen at build time). */ + holders: string[]; + auditRoute: string | null; + auditRouteHolderName: string | null; + auditFirmName: string | null; + auditEvidenceRef: string | null; + auditCourse: string | null; + auditDueDate: string | null; +} + +/** One row of the operational-responsibilities summary (5.3 §5). */ +export interface OperationalOwnershipRow { + artifact: string; + assignedWhere: string; + ownerResponsibility: string; + /** Distinct owner display names read from the platform (may be empty). */ + owners: string[]; +} + /** * The organization profile that fills the narrative parts of the Context of the * Organization document (clause 4.1) — overview table, mission, intended @@ -120,4 +162,10 @@ export interface DocumentExportInput { narrative: unknown; /** Org overview/mission/outcomes — only populated for the Context document. */ orgProfile?: IsmsOrgProfile; + /** Governance roles with resolved holders — only populated for the Roles document (5.3). */ + roles?: RoleExportRow[]; + /** Operational per-artifact ownership — only populated for the Roles document (5.3). */ + operationalOwnership?: OperationalOwnershipRow[]; + /** Team-size band — only populated for the Roles document (5.3). */ + band?: IsmsTeamSizeBand; } diff --git a/apps/api/src/isms/isms-registers.controller.spec.ts b/apps/api/src/isms/isms-registers.controller.spec.ts index fe99b08ae0..548b2eb604 100644 --- a/apps/api/src/isms/isms-registers.controller.spec.ts +++ b/apps/api/src/isms/isms-registers.controller.spec.ts @@ -10,6 +10,8 @@ import { IsmsContextIssueService } from './isms-context-issue.service'; import { IsmsInterestedPartyService } from './isms-interested-party.service'; import { IsmsRequirementService } from './isms-requirement.service'; import { IsmsObjectiveService } from './isms-objective.service'; +import { IsmsRoleService } from './isms-role.service'; +import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; import { IsmsNarrativeService } from './isms-narrative.service'; jest.mock('../auth/auth.server', () => ({ @@ -38,6 +40,12 @@ jest.mock('./isms-requirement.service', () => ({ jest.mock('./isms-objective.service', () => ({ IsmsObjectiveService: class {}, })); +jest.mock('./isms-role.service', () => ({ + IsmsRoleService: class {}, +})); +jest.mock('./isms-role-assignment.service', () => ({ + IsmsRoleAssignmentService: class {}, +})); jest.mock('./isms-narrative.service', () => ({ IsmsNarrativeService: class {}, })); @@ -68,6 +76,16 @@ describe('IsmsRegistersController', () => { update: jest.fn(), remove: jest.fn(), }; + const roleService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const roleAssignmentService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; const narrativeService = { save: jest.fn() }; const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; @@ -83,6 +101,8 @@ describe('IsmsRegistersController', () => { }, { provide: IsmsRequirementService, useValue: requirementService }, { provide: IsmsObjectiveService, useValue: objectiveService }, + { provide: IsmsRoleService, useValue: roleService }, + { provide: IsmsRoleAssignmentService, useValue: roleAssignmentService }, { provide: IsmsNarrativeService, useValue: narrativeService }, ], }) diff --git a/apps/api/src/isms/isms-registers.controller.ts b/apps/api/src/isms/isms-registers.controller.ts index 6528372300..957e6da398 100644 --- a/apps/api/src/isms/isms-registers.controller.ts +++ b/apps/api/src/isms/isms-registers.controller.ts @@ -27,6 +27,8 @@ import { IsmsContextIssueService } from './isms-context-issue.service'; import { IsmsInterestedPartyService } from './isms-interested-party.service'; import { IsmsRequirementService } from './isms-requirement.service'; import { IsmsObjectiveService } from './isms-objective.service'; +import { IsmsRoleService } from './isms-role.service'; +import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; import { IsmsNarrativeService } from './isms-narrative.service'; import { createRegisterRegistry, @@ -67,6 +69,32 @@ const REGISTER_ROW_BODY = { type: 'string', enum: ['not_started', 'on_track', 'at_risk', 'met'], }, + // Roles register (5.3) + role assignments (7.2 competence) + responsibilities: { type: 'string' }, + authorities: { type: 'string' }, + authorityGrantedBy: { type: 'string' }, + requiredCompetence: { type: 'string' }, + auditRoute: { + type: 'string', + enum: ['in_house', 'external', 'training_planned'], + nullable: true, + }, + auditRouteMemberId: { type: 'string', nullable: true }, + auditFirmName: { type: 'string', nullable: true }, + auditEvidenceRef: { type: 'string', nullable: true }, + auditCourse: { type: 'string', nullable: true }, + auditDueDate: { type: 'string', nullable: true }, + roleId: { type: 'string' }, + memberId: { type: 'string' }, + basisOfCompetence: { + type: 'string', + enum: ['education', 'training', 'experience', 'combination'], + nullable: true, + }, + evidenceRetained: { type: 'string', nullable: true }, + gap: { type: 'string', nullable: true }, + remediationAction: { type: 'string', nullable: true }, + remediationDueDate: { type: 'string', nullable: true }, position: { type: 'integer', minimum: 0 }, }, }, @@ -106,6 +134,8 @@ export class IsmsRegistersController { interestedPartyService: IsmsInterestedPartyService, requirementService: IsmsRequirementService, objectiveService: IsmsObjectiveService, + roleService: IsmsRoleService, + roleAssignmentService: IsmsRoleAssignmentService, private readonly narrativeService: IsmsNarrativeService, ) { this.registry = createRegisterRegistry({ @@ -113,6 +143,8 @@ export class IsmsRegistersController { interestedParties: interestedPartyService, requirements: requirementService, objectives: objectiveService, + roles: roleService, + roleAssignments: roleAssignmentService, }); } diff --git a/apps/api/src/isms/isms-role-assignment.service.spec.ts b/apps/api/src/isms/isms-role-assignment.service.spec.ts new file mode 100644 index 0000000000..18419b70f5 --- /dev/null +++ b/apps/api/src/isms/isms-role-assignment.service.spec.ts @@ -0,0 +1,127 @@ +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + ismsRole: { findFirst: jest.fn() }, + member: { findFirst: jest.fn() }, + ismsRoleAssignment: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsRoleAssignmentService', () => { + let service: IsmsRoleAssignmentService; + + beforeEach(() => { + jest.clearAllMocks(); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ status: 'draft' }); + service = new IsmsRoleAssignmentService(); + }); + + const createArgs = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { roleId: 'role_1', memberId: 'mem_1' }, + }; + + function stubCreatePreconditions() { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ id: 'doc_1' }); + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue({ id: 'role_1' }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + } + + it('rejects a role that is not in the document', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ id: 'doc_1' }); + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(createArgs)).rejects.toThrow(NotFoundException); + }); + + it('rejects a member who is not in the org', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ id: 'doc_1' }); + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue({ id: 'role_1' }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(createArgs)).rejects.toThrow(NotFoundException); + }); + + it('creates an assignment carrying the denormalized documentId', async () => { + stubCreatePreconditions(); + (mockDb.ismsRoleAssignment.findFirst as jest.Mock) + .mockResolvedValueOnce(null) // no existing (roleId, memberId) + .mockResolvedValueOnce({ position: 0 }); // nextPosition + (mockDb.ismsRoleAssignment.create as jest.Mock).mockResolvedValue({ id: 'ra_1' }); + + await service.create(createArgs); + + expect(mockDb.ismsRoleAssignment.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + roleId: 'role_1', + memberId: 'mem_1', + documentId: 'doc_1', + position: 1, + }), + }); + }); + + it('is idempotent: returns the existing assignment instead of duplicating', async () => { + stubCreatePreconditions(); + (mockDb.ismsRoleAssignment.findFirst as jest.Mock).mockResolvedValue({ + id: 'ra_existing', + }); + + const result = await service.create(createArgs); + + expect(result).toEqual({ id: 'ra_existing' }); + expect(mockDb.ismsRoleAssignment.create).not.toHaveBeenCalled(); + }); + + it('updates competence fields', async () => { + (mockDb.ismsRoleAssignment.findFirst as jest.Mock).mockResolvedValue({ + id: 'ra_1', + documentId: 'doc_1', + }); + (mockDb.ismsRoleAssignment.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + assignmentId: 'ra_1', + organizationId: 'org_1', + dto: { basisOfCompetence: 'training', gap: 'Needs ISO course' }, + }); + + expect(mockDb.ismsRoleAssignment.update).toHaveBeenCalledWith({ + where: { id: 'ra_1' }, + data: expect.objectContaining({ + basisOfCompetence: 'training', + gap: 'Needs ISO course', + }), + }); + }); + + it('throws when updating an assignment outside the org', async () => { + (mockDb.ismsRoleAssignment.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.update({ assignmentId: 'ra_x', organizationId: 'org_1', dto: {} }), + ).rejects.toThrow(NotFoundException); + }); + + it('removes an assignment', async () => { + (mockDb.ismsRoleAssignment.findFirst as jest.Mock).mockResolvedValue({ + id: 'ra_1', + documentId: 'doc_1', + }); + (mockDb.ismsRoleAssignment.delete as jest.Mock).mockResolvedValue({}); + const result = await service.remove({ assignmentId: 'ra_1', organizationId: 'org_1' }); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/apps/api/src/isms/isms-role-assignment.service.ts b/apps/api/src/isms/isms-role-assignment.service.ts new file mode 100644 index 0000000000..1ecc08414c --- /dev/null +++ b/apps/api/src/isms/isms-role-assignment.service.ts @@ -0,0 +1,210 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocument } from './utils/document-lock'; +import { parseOptionalDate } from './utils/parse-optional-date'; +import type { + CreateRoleAssignmentInput, + UpdateRoleAssignmentInput, +} from './registers/register-registry'; + +/** + * CRUD for role member assignments + their competence evidence (clauses 5.3/7.2). + * Each row is one member assigned to one role. Creating is idempotent per + * (role, member) so re-adding a member in the picker never duplicates. Assignment + * changes revert an approved document to draft, like every other register edit. + */ +@Injectable() +export class IsmsRoleAssignmentService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateRoleAssignmentInput; + }) { + await this.requireDocument({ documentId, organizationId }); + await this.requireRoleInDocument({ roleId: dto.roleId, documentId }); + await this.requireMember({ memberId: dto.memberId, organizationId }); + + return db.$transaction(async (tx) => { + await lockDocument(tx, documentId); + // Idempotency inside the per-document lock: concurrent duplicate adds for + // the same (role, member) serialize here, so the second caller returns the + // first's row instead of hitting the (roleId, memberId) unique index. + const existing = await tx.ismsRoleAssignment.findFirst({ + where: { roleId: dto.roleId, memberId: dto.memberId }, + }); + if (existing) return existing; + + const position = + dto.position ?? (await this.nextPosition({ tx, roleId: dto.roleId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsRoleAssignment.create({ + data: { + roleId: dto.roleId, + documentId, + memberId: dto.memberId, + basisOfCompetence: dto.basisOfCompetence ?? null, + evidenceRetained: dto.evidenceRetained ?? null, + gap: dto.gap ?? null, + remediationAction: dto.remediationAction ?? null, + remediationDueDate: parseOptionalDate(dto.remediationDueDate) ?? null, + position, + }, + }); + }); + } + + async update({ + assignmentId, + organizationId, + dto, + }: { + assignmentId: string; + organizationId: string; + dto: UpdateRoleAssignmentInput; + }) { + const assignment = await this.requireAssignment({ + assignmentId, + organizationId, + }); + + return db.$transaction(async (tx) => { + await lockDocument(tx, assignment.documentId); + await invalidateApprovalIfNeeded({ + tx, + documentId: assignment.documentId, + }); + return tx.ismsRoleAssignment.update({ + where: { id: assignmentId }, + data: { + basisOfCompetence: + dto.basisOfCompetence === undefined + ? undefined + : dto.basisOfCompetence, + evidenceRetained: + dto.evidenceRetained === undefined + ? undefined + : dto.evidenceRetained, + gap: dto.gap === undefined ? undefined : dto.gap, + remediationAction: + dto.remediationAction === undefined + ? undefined + : dto.remediationAction, + remediationDueDate: parseOptionalDate(dto.remediationDueDate), + position: dto.position ?? undefined, + }, + }); + }); + } + + async remove({ + assignmentId, + organizationId, + }: { + assignmentId: string; + organizationId: string; + }) { + const assignment = await this.requireAssignment({ + assignmentId, + organizationId, + }); + await db.$transaction(async (tx) => { + await lockDocument(tx, assignment.documentId); + await invalidateApprovalIfNeeded({ + tx, + documentId: assignment.documentId, + }); + await tx.ismsRoleAssignment.delete({ where: { id: assignmentId } }); + }); + return { success: true }; + } + + private async nextPosition({ + tx, + roleId, + }: { + tx: Prisma.TransactionClient; + roleId: string; + }) { + const last = await tx.ismsRoleAssignment.findFirst({ + where: { roleId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { + id: documentId, + organizationId, + type: 'roles_and_responsibilities', + }, + }); + if (!document) { + throw new NotFoundException('ISMS roles document not found'); + } + return document; + } + + private async requireRoleInDocument({ + roleId, + documentId, + }: { + roleId: string; + documentId: string; + }) { + const role = await db.ismsRole.findFirst({ + where: { id: roleId, documentId }, + }); + if (!role) { + throw new NotFoundException('Role not found in document'); + } + return role; + } + + private async requireMember({ + memberId, + organizationId, + }: { + memberId: string; + organizationId: string; + }) { + // Assignments must reference active People members (CS-698). + const member = await db.member.findFirst({ + where: { id: memberId, organizationId, deactivated: false }, + }); + if (!member) { + throw new NotFoundException('Active member not found in organization'); + } + return member; + } + + private async requireAssignment({ + assignmentId, + organizationId, + }: { + assignmentId: string; + organizationId: string; + }) { + const assignment = await db.ismsRoleAssignment.findFirst({ + where: { id: assignmentId, role: { document: { organizationId } } }, + }); + if (!assignment) { + throw new NotFoundException('Role assignment not found'); + } + return assignment; + } +} diff --git a/apps/api/src/isms/isms-role.service.spec.ts b/apps/api/src/isms/isms-role.service.spec.ts new file mode 100644 index 0000000000..dc0cc7514e --- /dev/null +++ b/apps/api/src/isms/isms-role.service.spec.ts @@ -0,0 +1,139 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsRoleService } from './isms-role.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, + member: { findFirst: jest.fn() }, + ismsRole: { + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsRoleService', () => { + let service: IsmsRoleService; + + beforeEach(() => { + jest.clearAllMocks(); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ status: 'draft' }); + service = new IsmsRoleService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { name: 'Data Protection Officer' }, + }; + + it('throws NotFoundException when the document is missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + }); + + it('creates a custom (manual, roleKey null) role', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ id: 'doc_1' }); + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue({ position: 1 }); + (mockDb.ismsRole.create as jest.Mock).mockResolvedValue({ id: 'role_1' }); + + await service.create(args); + + expect(mockDb.ismsRole.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + roleKey: null, + source: 'manual', + position: 2, + name: 'Data Protection Officer', + authorityGrantedBy: 'Top Management', + }), + }); + }); + }); + + describe('update', () => { + it('throws NotFoundException when the role is not in the org', async () => { + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.update({ roleId: 'role_1', organizationId: 'org_1', dto: {} }), + ).rejects.toThrow(NotFoundException); + }); + + it('saves the audit route and flips source to manual', async () => { + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue({ + id: 'role_1', + documentId: 'doc_1', + source: 'derived', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsRole.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + roleId: 'role_1', + organizationId: 'org_1', + dto: { auditRoute: 'in_house', auditRouteMemberId: 'mem_1' }, + }); + + expect(mockDb.ismsRole.update).toHaveBeenCalledWith({ + where: { id: 'role_1' }, + data: expect.objectContaining({ + auditRoute: 'in_house', + auditRouteMemberId: 'mem_1', + source: 'manual', + }), + }); + }); + + it('rejects an audit-route member who is not in the org', async () => { + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue({ + id: 'role_1', + documentId: 'doc_1', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.update({ + roleId: 'role_1', + organizationId: 'org_1', + dto: { auditRouteMemberId: 'mem_other' }, + }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.ismsRole.update).not.toHaveBeenCalled(); + }); + }); + + describe('remove', () => { + it('blocks deleting a seeded role', async () => { + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue({ + id: 'role_1', + roleKey: 'spo', + documentId: 'doc_1', + }); + await expect( + service.remove({ roleId: 'role_1', organizationId: 'org_1' }), + ).rejects.toThrow(BadRequestException); + expect(mockDb.ismsRole.delete).not.toHaveBeenCalled(); + }); + + it('deletes a custom role', async () => { + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue({ + id: 'role_1', + roleKey: null, + documentId: 'doc_1', + }); + (mockDb.ismsRole.delete as jest.Mock).mockResolvedValue({}); + const result = await service.remove({ roleId: 'role_1', organizationId: 'org_1' }); + expect(result).toEqual({ success: true }); + expect(mockDb.ismsRole.delete).toHaveBeenCalledWith({ where: { id: 'role_1' } }); + }); + }); +}); diff --git a/apps/api/src/isms/isms-role.service.ts b/apps/api/src/isms/isms-role.service.ts new file mode 100644 index 0000000000..028222299a --- /dev/null +++ b/apps/api/src/isms/isms-role.service.ts @@ -0,0 +1,199 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocument } from './utils/document-lock'; +import { parseOptionalDate } from './utils/parse-optional-date'; +import type { + CreateRoleInput, + UpdateRoleInput, +} from './registers/register-registry'; + +/** + * CRUD for the ISMS Governance Roles register (clause 5.3). The four seeded roles + * are created idempotently by seedRolesIfMissing; this service handles custom-role + * creation, edits to any role's text, and the Internal Auditor route selection. + * Editing a row flips its source to 'manual' (records the override); seeded roles + * cannot be removed. + */ +@Injectable() +export class IsmsRoleService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateRoleInput; + }) { + await this.requireDocument({ documentId, organizationId }); + + return db.$transaction(async (tx) => { + await lockDocument(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsRole.create({ + data: { + documentId, + roleKey: null, // customer-added custom role + name: dto.name, + description: dto.description ?? '', + responsibilities: dto.responsibilities ?? '', + authorities: dto.authorities ?? '', + authorityGrantedBy: dto.authorityGrantedBy ?? 'Top Management', + requiredCompetence: dto.requiredCompetence ?? '', + source: 'manual', + position, + }, + }); + }); + } + + async update({ + roleId, + organizationId, + dto, + }: { + roleId: string; + organizationId: string; + dto: UpdateRoleInput; + }) { + const role = await this.requireRole({ roleId, organizationId }); + const auditRouteMemberId = await this.resolveMember({ + memberId: dto.auditRouteMemberId, + organizationId, + }); + + return db.$transaction(async (tx) => { + // Same per-document lock submit/approve take, so an edit can't race the + // submission's completeness check. + await lockDocument(tx, role.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: role.documentId }); + return tx.ismsRole.update({ + where: { id: roleId }, + data: { + name: dto.name ?? undefined, + description: dto.description ?? undefined, + responsibilities: dto.responsibilities ?? undefined, + authorities: dto.authorities ?? undefined, + authorityGrantedBy: dto.authorityGrantedBy ?? undefined, + requiredCompetence: dto.requiredCompetence ?? undefined, + auditRoute: dto.auditRoute === undefined ? undefined : dto.auditRoute, + auditRouteMemberId: + dto.auditRouteMemberId === undefined ? undefined : auditRouteMemberId, + auditFirmName: + dto.auditFirmName === undefined ? undefined : dto.auditFirmName, + auditEvidenceRef: + dto.auditEvidenceRef === undefined + ? undefined + : dto.auditEvidenceRef, + auditCourse: + dto.auditCourse === undefined ? undefined : dto.auditCourse, + auditDueDate: parseOptionalDate(dto.auditDueDate), + position: dto.position ?? undefined, + source: 'manual', + }, + }); + }); + } + + async remove({ + roleId, + organizationId, + }: { + roleId: string; + organizationId: string; + }) { + const role = await this.requireRole({ roleId, organizationId }); + if (role.roleKey) { + throw new BadRequestException( + 'Seeded governance roles cannot be removed; edit their text instead.', + ); + } + await db.$transaction(async (tx) => { + await lockDocument(tx, role.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: role.documentId }); + await tx.ismsRole.delete({ where: { id: roleId } }); + }); + return { success: true }; + } + + private async resolveMember({ + memberId, + organizationId, + }: { + memberId: string | null | undefined; + organizationId: string; + }): Promise { + if (memberId === undefined) return undefined; + const trimmed = memberId?.trim(); + if (!trimmed) return null; + // Role holders (incl. the audit-route member) must be active People members. + const member = await db.member.findFirst({ + where: { id: trimmed, organizationId, deactivated: false }, + }); + if (!member) { + throw new NotFoundException('Active member not found in organization'); + } + return trimmed; + } + + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsRole.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + // Scope to the Roles document type: role rows must never attach to another + // ISMS document (they'd be invisible to the Clause 5.3 export). + const document = await db.ismsDocument.findFirst({ + where: { + id: documentId, + organizationId, + type: 'roles_and_responsibilities', + }, + }); + if (!document) { + throw new NotFoundException('ISMS roles document not found'); + } + return document; + } + + private async requireRole({ + roleId, + organizationId, + }: { + roleId: string; + organizationId: string; + }) { + const role = await db.ismsRole.findFirst({ + where: { id: roleId, document: { organizationId } }, + }); + if (!role) { + throw new NotFoundException('Role not found'); + } + return role; + } +} diff --git a/apps/api/src/isms/isms-version.service.spec.ts b/apps/api/src/isms/isms-version.service.spec.ts index ceff4d5764..8a29625c43 100644 --- a/apps/api/src/isms/isms-version.service.spec.ts +++ b/apps/api/src/isms/isms-version.service.spec.ts @@ -24,6 +24,7 @@ jest.mock('@db', () => ({ jest.mock('./utils/export-payload', () => ({ buildExportInput: jest.fn(() => ({ rows: [] })), resolveOrgProfile: jest.fn(), + resolveRolesExtras: 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 d54a0a9926..d22055f92f 100644 --- a/apps/api/src/isms/isms-version.service.ts +++ b/apps/api/src/isms/isms-version.service.ts @@ -16,6 +16,7 @@ import { parseExportSnapshot, renderSnapshot, resolveOrgProfile, + resolveRolesExtras, type IsmsExportSnapshot, type LoadedExportDocument, } from './utils/export-payload'; @@ -69,7 +70,8 @@ export class IsmsVersionService { // Read the org profile through the same transaction so the snapshot's profile // and the register rows written in this transaction share one point in time. const orgProfile = await resolveOrgProfile(document, tx); - const input = buildExportInput({ document, orgProfile }); + const rolesExtras = await resolveRolesExtras(document, tx); + const input = buildExportInput({ document, orgProfile, rolesExtras }); const metadata = buildExportMetadata({ type: document.type, title: document.title, diff --git a/apps/api/src/isms/isms.module.ts b/apps/api/src/isms/isms.module.ts index 9d894b737a..68cb5e841f 100644 --- a/apps/api/src/isms/isms.module.ts +++ b/apps/api/src/isms/isms.module.ts @@ -9,6 +9,8 @@ import { IsmsDocumentControlService } from './isms-document-control.service'; import { IsmsInterestedPartyService } from './isms-interested-party.service'; import { IsmsRequirementService } from './isms-requirement.service'; import { IsmsObjectiveService } from './isms-objective.service'; +import { IsmsRoleService } from './isms-role.service'; +import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; import { IsmsNarrativeService } from './isms-narrative.service'; import { IsmsProfileController } from './wizard/isms-profile.controller'; import { IsmsProfileService } from './wizard/isms-profile.service'; @@ -32,6 +34,8 @@ import { AttachmentsModule } from '../attachments/attachments.module'; IsmsInterestedPartyService, IsmsRequirementService, IsmsObjectiveService, + IsmsRoleService, + IsmsRoleAssignmentService, IsmsNarrativeService, IsmsProfileService, ], @@ -44,6 +48,8 @@ import { AttachmentsModule } from '../attachments/attachments.module'; IsmsInterestedPartyService, IsmsRequirementService, IsmsObjectiveService, + IsmsRoleService, + IsmsRoleAssignmentService, IsmsNarrativeService, IsmsProfileService, ], diff --git a/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts index 318f0adfe4..8326f12b8c 100644 --- a/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts +++ b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts @@ -72,7 +72,7 @@ describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no template const result = await service.ensureSetup(dto); expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); - expect(createManyData()).toHaveLength(5); + expect(createManyData()).toHaveLength(6); // Definition-derived docs carry no templateId. expect(createManyData()[0].templateId).toBeNull(); expect(result.success).toBe(true); @@ -96,7 +96,7 @@ describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no template await service.ensureSetup(dto); - expect(createManyData()).toHaveLength(6); + expect(createManyData()).toHaveLength(7); expect(createManyData()[0].requirementId).toBeNull(); }); }); diff --git a/apps/api/src/isms/isms.service.lifecycle.spec.ts b/apps/api/src/isms/isms.service.lifecycle.spec.ts index 2defb6ba8f..5184ab0772 100644 --- a/apps/api/src/isms/isms.service.lifecycle.spec.ts +++ b/apps/api/src/isms/isms.service.lifecycle.spec.ts @@ -9,17 +9,21 @@ import type { IsmsVersionService } from './isms-version.service'; // approve() (the CS-701 freeze/version flow) is covered in // isms.service.approve.spec.ts. -jest.mock('@db', () => ({ - db: { +jest.mock('@db', () => { + const db = { ismsDocument: { findFirst: jest.fn(), update: jest.fn(), updateMany: jest.fn(), }, - member: { findFirst: jest.fn() }, - $transaction: jest.fn(), - }, -})); + member: { findFirst: jest.fn(), findMany: jest.fn() }, + ismsRole: { findMany: jest.fn() }, + // lockDocument runs $executeRaw; submitForApproval wraps validate+update in a tx. + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); const mockDb = jest.mocked(db); @@ -118,6 +122,77 @@ describe('IsmsService document lifecycle', () => { }), }); }); + + it('blocks a Roles doc when a required role is only assigned to a deactivated member', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'roles_and_responsibilities', + }); + // Active members exclude 'mem_dead'. + (mockDb.member.findMany as jest.Mock).mockResolvedValue([ + { id: 'mem_active' }, + { id: 'b' }, + { id: 'c' }, + { id: 'd' }, + ]); + (mockDb.ismsRole.findMany as jest.Mock).mockResolvedValue([ + { + roleKey: 'top_management', + name: 'Top Management', + auditRoute: null, + assignments: [{ memberId: 'mem_dead' }], // only a deactivated member + }, + { roleKey: 'spo', name: 'SPO', auditRoute: null, assignments: [{ memberId: 'b' }] }, + { roleKey: 'deputy_spo', name: 'Deputy SPO', auditRoute: null, assignments: [{ memberId: 'c' }] }, + { + roleKey: 'internal_auditor', + name: 'Internal Auditor', + auditRoute: 'external', + auditFirmName: 'Acme', + auditEvidenceRef: 'cert', + assignments: [{ memberId: 'd' }], + }, + ]); + + await expect(service.submitForApproval(args)).rejects.toThrow(BadRequestException); + expect(mockDb.ismsDocument.update).not.toHaveBeenCalled(); + }); + + it('allows a Roles doc when every seeded role has an active assignment + route', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ id: 'mem_1' }); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + type: 'roles_and_responsibilities', + }); + (mockDb.member.findMany as jest.Mock).mockResolvedValue([ + { id: 'a' }, + { id: 'b' }, + { id: 'c' }, + { id: 'd' }, + { id: 'e' }, + ]); + (mockDb.ismsRole.findMany as jest.Mock).mockResolvedValue([ + { roleKey: 'top_management', name: 'Top Management', auditRoute: null, assignments: [{ memberId: 'a' }] }, + { roleKey: 'spo', name: 'SPO', auditRoute: null, assignments: [{ memberId: 'b' }] }, + { roleKey: 'deputy_spo', name: 'Deputy SPO', auditRoute: null, assignments: [{ memberId: 'c' }] }, + { + roleKey: 'internal_auditor', + name: 'Internal Auditor', + auditRoute: 'external', + auditFirmName: 'Acme Audit LLP', + auditEvidenceRef: 'LA cert on file', + assignments: [{ memberId: 'd' }], + }, + ]); + (mockDb.ismsDocument.update as jest.Mock).mockResolvedValue({ + id: 'doc_1', + status: 'needs_review', + }); + + await service.submitForApproval(args); + expect(mockDb.ismsDocument.update).toHaveBeenCalled(); + }); }); describe('decline', () => { diff --git a/apps/api/src/isms/isms.service.ts b/apps/api/src/isms/isms.service.ts index 39777c115c..c1b2110ada 100644 --- a/apps/api/src/isms/isms.service.ts +++ b/apps/api/src/isms/isms.service.ts @@ -5,10 +5,12 @@ import { NotFoundException, } from '@nestjs/common'; import { db } from '@db'; +import type { Prisma } from '@db'; import { SubmitIsmsForApprovalDto } from './dto/submit-isms-for-approval.dto'; import { deriveControlLinks, resolveDocumentPlans } from './utils/ensure-setup-plan'; import { collectPlatformData } from './documents/data-source'; import { runDerivation } from './documents/generate'; +import { roleValidationMessages, seedRolesIfMissing } from './documents/roles'; import { updateDraftSnapshot } from './utils/draft-snapshot'; import { EXPORT_DOCUMENT_INCLUDE } from './utils/export-payload'; import { lockDocument } from './utils/document-lock'; @@ -135,6 +137,21 @@ export class IsmsService { controlTemplateIds: controlTemplatesByType.get(doc.type) ?? [], }); } + + // Seed the four governance roles for a newly-created Roles document so they + // are visible with default text on first load, without a Generate click. + // Idempotent by roleKey, so concurrent provisioning calls can't duplicate. + const rolesDoc = created.find( + (doc) => doc.type === 'roles_and_responsibilities', + ); + if (rolesDoc) { + const memberCount = await db.member.count({ + where: { organizationId, deactivated: false }, + }); + await db.$transaction((tx) => + seedRolesIfMissing({ tx, documentId: rolesDoc.id, memberCount }), + ); + } } async getDocument({ @@ -156,6 +173,10 @@ export class IsmsService { interestedParties: { orderBy: { position: 'asc' } }, interestedPartyRequirements: { orderBy: { position: 'asc' } }, objectives: { orderBy: { position: 'asc' } }, + roles: { + orderBy: { position: 'asc' }, + include: { assignments: { orderBy: { position: 'asc' } } }, + }, controlLinks: { select: { id: true, @@ -189,16 +210,30 @@ export class IsmsService { throw new NotFoundException('Approver not found in organization'); } - await this.requireDocument({ documentId, organizationId }); + const document = await this.requireDocument({ documentId, organizationId }); - return db.ismsDocument.update({ - where: { id: documentId }, - data: { - approverId: dto.approverId, - status: 'needs_review', - approvedAt: null, - declinedAt: null, - }, + return db.$transaction(async (tx) => { + // Serialize with concurrent register edits (which take the same per-document + // lock) so the completeness check and the status flip are atomic — an edit + // can't invalidate the document between validation and the transition to + // needs_review (TOCTOU). Validate inside the lock, then transition. + await lockDocument(tx, documentId); + + // Clause 5.3 generate-time validation, enforced server-side so it can't be + // bypassed by calling the API directly (the client disables Submit too). + if (document.type === 'roles_and_responsibilities') { + await this.assertRolesComplete({ tx, documentId, organizationId }); + } + + return tx.ismsDocument.update({ + where: { id: documentId }, + data: { + approverId: dto.approverId, + status: 'needs_review', + approvedAt: null, + declinedAt: null, + }, + }); }); } @@ -343,6 +378,57 @@ export class IsmsService { } } + /** + * Enforce clause-5.3 completeness before the Roles document can be submitted for + * approval (each seeded role assigned — except Deputy SPO in the 1-3 band — and + * the Internal Auditor route chosen). Mirrors the client-side gate. + */ + private async assertRolesComplete({ + tx, + documentId, + organizationId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + organizationId: string; + }) { + const [roles, activeMembers] = await Promise.all([ + tx.ismsRole.findMany({ + where: { documentId }, + select: { + roleKey: true, + name: true, + auditRoute: true, + auditRouteMemberId: true, + auditFirmName: true, + auditEvidenceRef: true, + auditCourse: true, + auditDueDate: true, + assignments: { select: { memberId: true } }, + }, + }), + tx.member.findMany({ + where: { organizationId, deactivated: false }, + select: { id: true }, + }), + ]); + // A role "assigned" only to a deactivated/removed member is not really + // covered — count only assignments that resolve to an active member. + // roleValidationMessages counts only assignments (and audit-route members) + // that resolve to an active member, so pass the raw rows + the active set. + const activeMemberIds = new Set(activeMembers.map((member) => member.id)); + const messages = roleValidationMessages({ + roles, + memberCount: activeMemberIds.size, + activeMemberIds, + }); + if (messages.length > 0) { + throw new BadRequestException( + `This Clause 5.3 document is not ready to submit. ${messages.join(' ')}`, + ); + } + } + private async requireDocument({ documentId, organizationId, diff --git a/apps/api/src/isms/registers/register-registry.ts b/apps/api/src/isms/registers/register-registry.ts index d331ef4786..67b0c711bb 100644 --- a/apps/api/src/isms/registers/register-registry.ts +++ b/apps/api/src/isms/registers/register-registry.ts @@ -4,6 +4,8 @@ import type { IsmsContextIssueService } from '../isms-context-issue.service'; import type { IsmsInterestedPartyService } from '../isms-interested-party.service'; import type { IsmsObjectiveService } from '../isms-objective.service'; import type { IsmsRequirementService } from '../isms-requirement.service'; +import type { IsmsRoleService } from '../isms-role.service'; +import type { IsmsRoleAssignmentService } from '../isms-role-assignment.service'; /** * One generic dispatch for every ISMS register row (context issues, interested @@ -16,6 +18,13 @@ import type { IsmsRequirementService } from '../isms-requirement.service'; const position = z.number().int().min(0).optional(); const OBJECTIVE_STATUS = ['not_started', 'on_track', 'at_risk', 'met'] as const; +const AUDIT_ROUTE = ['in_house', 'external', 'training_planned'] as const; +const COMPETENCE_BASIS = [ + 'education', + 'training', + 'experience', + 'combination', +] as const; const schemas = { contextIssueCreate: z.object({ @@ -82,6 +91,49 @@ const schemas = { status: z.enum(OBJECTIVE_STATUS).optional(), position, }), + roleCreate: z.object({ + name: z.string().min(1), + description: z.string().optional(), + responsibilities: z.string().optional(), + authorities: z.string().optional(), + authorityGrantedBy: z.string().optional(), + requiredCompetence: z.string().optional(), + position, + }), + roleUpdate: z.object({ + name: z.string().min(1).optional(), + description: z.string().optional(), + responsibilities: z.string().optional(), + authorities: z.string().optional(), + authorityGrantedBy: z.string().optional(), + requiredCompetence: z.string().optional(), + // Internal Auditor route. Nullish: undefined = leave as-is, null = clear. + auditRoute: z.enum(AUDIT_ROUTE).nullish(), + auditRouteMemberId: z.string().nullish(), + auditFirmName: z.string().nullish(), + auditEvidenceRef: z.string().nullish(), + auditCourse: z.string().nullish(), + auditDueDate: z.string().nullish(), // ISO date string + position, + }), + roleAssignmentCreate: z.object({ + roleId: z.string(), + memberId: z.string(), + basisOfCompetence: z.enum(COMPETENCE_BASIS).nullish(), + evidenceRetained: z.string().nullish(), + gap: z.string().nullish(), + remediationAction: z.string().nullish(), + remediationDueDate: z.string().nullish(), + position, + }), + roleAssignmentUpdate: z.object({ + basisOfCompetence: z.enum(COMPETENCE_BASIS).nullish(), + evidenceRetained: z.string().nullish(), + gap: z.string().nullish(), + remediationAction: z.string().nullish(), + remediationDueDate: z.string().nullish(), + position, + }), } as const; // Inferred input types — the single source of truth for register row shapes. @@ -99,12 +151,22 @@ export type CreateRequirementInput = z.infer; export type UpdateRequirementInput = z.infer; export type CreateObjectiveInput = z.infer; export type UpdateObjectiveInput = z.infer; +export type CreateRoleInput = z.infer; +export type UpdateRoleInput = z.infer; +export type CreateRoleAssignmentInput = z.infer< + typeof schemas.roleAssignmentCreate +>; +export type UpdateRoleAssignmentInput = z.infer< + typeof schemas.roleAssignmentUpdate +>; export const ISMS_REGISTER_KEYS = [ 'context-issues', 'interested-parties', 'requirements', 'objectives', + 'roles', + 'role-assignments', ] as const; export type IsmsRegisterKey = (typeof ISMS_REGISTER_KEYS)[number]; @@ -139,6 +201,8 @@ export interface RegisterServices { interestedParties: IsmsInterestedPartyService; requirements: IsmsRequirementService; objectives: IsmsObjectiveService; + roles: IsmsRoleService; + roleAssignments: IsmsRoleAssignmentService; } /** Build the register → handler map from the injected per-register services. */ @@ -210,5 +274,40 @@ export function createRegisterRegistry( remove: ({ rowId, organizationId }) => services.objectives.remove({ objectiveId: rowId, organizationId }), }, + roles: { + create: ({ documentId, organizationId, data }) => + services.roles.create({ + documentId, + organizationId, + dto: parse(schemas.roleCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.roles.update({ + roleId: rowId, + organizationId, + dto: parse(schemas.roleUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.roles.remove({ roleId: rowId, organizationId }), + }, + 'role-assignments': { + create: ({ documentId, organizationId, data }) => + services.roleAssignments.create({ + documentId, + organizationId, + dto: parse(schemas.roleAssignmentCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.roleAssignments.update({ + assignmentId: rowId, + organizationId, + dto: parse(schemas.roleAssignmentUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.roleAssignments.remove({ + assignmentId: rowId, + organizationId, + }), + }, }; } diff --git a/apps/api/src/isms/utils/document-types.spec.ts b/apps/api/src/isms/utils/document-types.spec.ts index dd479691ec..2e7aff6e9d 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 six foundational document types with clauses', () => { - expect(ISMS_TYPE_DEFINITIONS).toHaveLength(6); + it('defines all seven foundational document types with clauses', () => { + expect(ISMS_TYPE_DEFINITIONS).toHaveLength(7); const types = ISMS_TYPE_DEFINITIONS.map((d) => d.type); expect(types).toEqual( expect.arrayContaining([ @@ -11,6 +11,7 @@ describe('ISMS_TYPE_DEFINITIONS', () => { 'interested_parties_requirements', 'isms_scope', 'leadership_commitment', + 'roles_and_responsibilities', 'objectives_plan', ]), ); diff --git a/apps/api/src/isms/utils/document-types.ts b/apps/api/src/isms/utils/document-types.ts index a4179bdbd5..7fe6c94643 100644 --- a/apps/api/src/isms/utils/document-types.ts +++ b/apps/api/src/isms/utils/document-types.ts @@ -52,6 +52,13 @@ export const ISMS_TYPE_DEFINITIONS: IsmsTypeDefinition[] = [ description: 'Evidence of top management leadership and commitment to the ISMS (ISO 27001 clause 5.1).', }, + { + type: 'roles_and_responsibilities', + clause: '5.3', + title: 'Roles, Responsibilities and Authorities', + description: + 'The ISMS governance roles, their responsibilities and authorities, and the members who hold them (ISO 27001 clause 5.3).', + }, { type: 'objectives_plan', clause: '6.2', diff --git a/apps/api/src/isms/utils/export-payload.ts b/apps/api/src/isms/utils/export-payload.ts index efd9346c33..e534ec8a5a 100644 --- a/apps/api/src/isms/utils/export-payload.ts +++ b/apps/api/src/isms/utils/export-payload.ts @@ -3,7 +3,15 @@ import { db } from '@db'; import type { IsmsDocumentType, Prisma } from '@db'; import { buildExportSections } from '../documents/registry'; import { loadOrgProfile } from '../documents/org-profile'; -import type { DocumentExportInput, IsmsOrgProfile } from '../documents/types'; +import { + loadRolesExtras, + type RolesExtras, +} from '../documents/roles-export-data'; +import type { + DocumentExportInput, + IsmsOrgProfile, + RoleExportRow, +} from '../documents/types'; import { buildExportMetadata } from './export-metadata'; import { generateIsmsExportFile, @@ -34,6 +42,10 @@ export const EXPORT_DOCUMENT_INCLUDE = { interestedParties: { orderBy: { position: 'asc' } }, interestedPartyRequirements: { orderBy: { position: 'asc' } }, objectives: { orderBy: { position: 'asc' } }, + roles: { + orderBy: { position: 'asc' }, + include: { assignments: { orderBy: { position: 'asc' } } }, + }, } satisfies Prisma.IsmsDocumentInclude; export type LoadedExportDocument = Prisma.IsmsDocumentGetPayload<{ @@ -64,13 +76,55 @@ export async function resolveOrgProfile( }); } +/** The Roles document (5.3) resolves member names + ownership; other types don't. */ +export async function resolveRolesExtras( + document: LoadedExportDocument, + client?: Prisma.TransactionClient, +): Promise { + if (document.type !== 'roles_and_responsibilities') return undefined; + return loadRolesExtras({ organizationId: document.organizationId, client }); +} + +function formatDateYmd(date: Date | null): string | null { + return date ? date.toISOString().slice(0, 10) : null; +} + +/** Map role rows + assignments into export rows, resolving holder names. */ +function mapRoles( + document: LoadedExportDocument, + extras: RolesExtras, +): RoleExportRow[] { + return document.roles.map((role) => ({ + roleKey: role.roleKey, + name: role.name, + description: role.description, + responsibilities: role.responsibilities, + authorities: role.authorities, + authorityGrantedBy: role.authorityGrantedBy, + requiredCompetence: role.requiredCompetence, + holders: role.assignments + .map((assignment) => extras.memberNames[assignment.memberId]) + .filter((name): name is string => !!name), + auditRoute: role.auditRoute, + auditRouteHolderName: role.auditRouteMemberId + ? (extras.memberNames[role.auditRouteMemberId] ?? null) + : null, + auditFirmName: role.auditFirmName, + auditEvidenceRef: role.auditEvidenceRef, + auditCourse: role.auditCourse, + auditDueDate: formatDateYmd(role.auditDueDate), + })); +} + /** Map the loaded document's live rows + draft narrative into export input. */ export function buildExportInput({ document, orgProfile, + rolesExtras, }: { document: LoadedExportDocument; orgProfile?: IsmsOrgProfile; + rolesExtras?: RolesExtras; }): DocumentExportInput { return { contextIssues: document.contextIssues.map((issue) => ({ @@ -99,6 +153,9 @@ export function buildExportInput({ })), narrative: document.draftNarrative ?? null, orgProfile, + roles: rolesExtras ? mapRoles(document, rolesExtras) : undefined, + operationalOwnership: rolesExtras?.operationalOwnership, + band: rolesExtras?.band, }; } @@ -118,7 +175,8 @@ export async function buildDraftSnapshot( document: LoadedExportDocument, ): Promise { const orgProfile = await resolveOrgProfile(document); - const input = buildExportInput({ document, orgProfile }); + const rolesExtras = await resolveRolesExtras(document); + const input = buildExportInput({ document, orgProfile, rolesExtras }); const metadata = buildExportMetadata({ type: document.type, title: document.title, diff --git a/apps/api/src/isms/utils/parse-optional-date.spec.ts b/apps/api/src/isms/utils/parse-optional-date.spec.ts new file mode 100644 index 0000000000..1d681a5f42 --- /dev/null +++ b/apps/api/src/isms/utils/parse-optional-date.spec.ts @@ -0,0 +1,31 @@ +import { BadRequestException } from '@nestjs/common'; +import { parseOptionalDate } from './parse-optional-date'; + +describe('parseOptionalDate', () => { + it('passes undefined through (leave column as-is)', () => { + expect(parseOptionalDate(undefined)).toBeUndefined(); + }); + + it('clears on null / empty / whitespace', () => { + expect(parseOptionalDate(null)).toBeNull(); + expect(parseOptionalDate('')).toBeNull(); + expect(parseOptionalDate(' ')).toBeNull(); + }); + + it('parses a valid YYYY-MM-DD to UTC midnight', () => { + const date = parseOptionalDate('2026-05-26'); + expect(date).toBeInstanceOf(Date); + expect((date as Date).toISOString()).toBe('2026-05-26T00:00:00.000Z'); + }); + + it('rejects non-ISO / ambiguous formats', () => { + for (const bad of ['26-05-2026', '05/26/2026', '2026-5-6', '2026', 'garbage']) { + expect(() => parseOptionalDate(bad)).toThrow(BadRequestException); + } + }); + + it('rejects a well-formatted but non-existent calendar date (no silent rollover)', () => { + expect(() => parseOptionalDate('2026-02-30')).toThrow(BadRequestException); + expect(() => parseOptionalDate('2026-13-01')).toThrow(BadRequestException); + }); +}); diff --git a/apps/api/src/isms/utils/parse-optional-date.ts b/apps/api/src/isms/utils/parse-optional-date.ts new file mode 100644 index 0000000000..3ad3531939 --- /dev/null +++ b/apps/api/src/isms/utils/parse-optional-date.ts @@ -0,0 +1,33 @@ +import { BadRequestException } from '@nestjs/common'; + +/** Date-only inputs from the UI's — strict YYYY-MM-DD. */ +const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Parse an optional date-only string (YYYY-MM-DD) from a register DTO into a + * Prisma-friendly value, using the three-state convention shared by the ISMS + * services: + * undefined → leave the column as-is (field omitted) + * null / empty / whitespace → clear the column + * a valid YYYY-MM-DD string → the parsed Date (UTC midnight) + * + * Validation is strict: the format must be YYYY-MM-DD AND the parsed value must + * round-trip to the same string, so `new Date`'s permissive parsing can't accept + * ambiguous input (e.g. "2026-02-30" rolling to March, or partial strings). + * Throws BadRequestException otherwise. + */ +export function parseOptionalDate( + value: string | null | undefined, +): Date | null | undefined { + if (value === undefined) return undefined; + const trimmed = value?.trim(); + if (!trimmed) return null; + if (!DATE_ONLY.test(trimmed)) { + throw new BadRequestException('Invalid date; expected format YYYY-MM-DD'); + } + const date = new Date(`${trimmed}T00:00:00.000Z`); + if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== trimmed) { + throw new BadRequestException('Invalid date; expected a real calendar date'); + } + return date; +} diff --git a/apps/api/src/isms/wizard/isms-profile.service.ts b/apps/api/src/isms/wizard/isms-profile.service.ts index e296572061..c50d1ffb3c 100644 --- a/apps/api/src/isms/wizard/isms-profile.service.ts +++ b/apps/api/src/isms/wizard/isms-profile.service.ts @@ -31,7 +31,8 @@ const GENERATION_ORDER: Record = { interested_parties_requirements: 2, isms_scope: 3, leadership_commitment: 4, - objectives_plan: 5, + roles_and_responsibilities: 5, + objectives_plan: 6, }; const GENERATION_ORDER_DEFAULT = Object.keys(GENERATION_ORDER).length; @@ -125,7 +126,7 @@ export class IsmsProfileService { } /** - * Ensure all six ISMS documents exist, then regenerate each from the latest + * Ensure every ISMS document exists, then regenerate each from the latest * profile + platform data. Called by the wizard on completion so every document * reflects the answers just saved. Returns the regenerated documents. */ 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 cfa51758d0..4c11a6be30 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 @@ -12,6 +12,7 @@ import type { ApproverOption } from '../components/IsmsApprovalSection'; import { LeadershipClient } from '../components/LeadershipClient'; import { ObjectivesClient } from '../components/ObjectivesClient'; import { RequirementsClient } from '../components/RequirementsClient'; +import { RolesClient } from '../components/RolesClient'; import { ScopeClient } from '../components/ScopeClient'; import { ISMS_SLUG_TO_TYPE, @@ -29,6 +30,8 @@ interface IsmsDetailClientProps { fallbackData: IsmsDocumentData | null; currentMemberId: string | null; approverOptions: ApproverOption[]; + /** All active members (for the Roles member pickers); superset of approvers. */ + memberOptions: ApproverOption[]; } const ISMS_DETAIL_CLIENTS: Record< @@ -39,6 +42,7 @@ const ISMS_DETAIL_CLIENTS: Record< interested_parties_register: InterestedPartiesClient, interested_parties_requirements: RequirementsClient, objectives_plan: ObjectivesClient, + roles_and_responsibilities: RolesClient, isms_scope: ScopeClient, leadership_commitment: LeadershipClient, }; @@ -151,6 +155,12 @@ export default async function IsmsDocumentPage({ .map((p) => ({ id: p.id, name: p.user?.name ?? p.user?.email ?? 'Unknown' })) .sort((a, b) => a.name.localeCompare(b.name)); + // All active members — the Roles document assigns to the whole workforce, not + // just approvers, so it needs the full list plus the headcount for the band. + const memberOptions: ApproverOption[] = activeMembers + .map((p) => ({ id: p.id, name: p.user?.name ?? p.user?.email ?? 'Unknown' })) + .sort((a, b) => a.name.localeCompare(b.name)); + const DetailClient = ISMS_DETAIL_CLIENTS[documentType]; return ( @@ -162,6 +172,7 @@ export default async function IsmsDocumentPage({ fallbackData={fallbackData} currentMemberId={currentMember?.id ?? null} approverOptions={approverOptions} + memberOptions={memberOptions} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditRoutePicker.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditRoutePicker.test.tsx new file mode 100644 index 0000000000..2c2c4f97b8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditRoutePicker.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IsmsRole } from '../isms-types'; +import type { ApproverOption } from './IsmsApprovalSection'; +import { ismsDesignSystemMock, ismsIconsMock, ismsSharedMock } from './__test-helpers__/dsMocks'; + +vi.mock('@trycompai/design-system', () => ismsDesignSystemMock()); +vi.mock('@trycompai/design-system/icons', () => ismsIconsMock()); +vi.mock('./shared', () => ismsSharedMock()); + +import { AuditRoutePicker } from './AuditRoutePicker'; + +const MEMBERS: ApproverOption[] = [ + { id: 'mem_1', name: 'Alex Petrisor' }, + { id: 'mem_2', name: 'Jordan Lee' }, +]; + +function auditorRole(overrides: Partial = {}): IsmsRole { + return { + id: 'role_ia', + roleKey: 'internal_auditor', + name: 'Internal Auditor', + description: '', + responsibilities: '', + authorities: '', + authorityGrantedBy: 'Top Management', + requiredCompetence: '', + auditRoute: null, + auditRouteMemberId: null, + auditFirmName: null, + auditEvidenceRef: null, + auditCourse: null, + auditDueDate: null, + source: 'derived', + derivedFrom: null, + position: 3, + assignments: [], + ...overrides, + }; +} + +describe('AuditRoutePicker', () => { + beforeEach(() => vi.clearAllMocks()); + + it('warns when the in-house auditor is also the SPO', () => { + render( + , + ); + expect(screen.getByText(/is also assigned as the Security/)).toBeInTheDocument(); + }); + + it('does not warn when the in-house auditor is not the SPO', () => { + render( + , + ); + expect(screen.queryByText(/is also assigned as the Security/)).not.toBeInTheDocument(); + }); + + it('saves an external route, clearing in-house/training fields', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + // Make the form dirty so Save enables, then submit. + fireEvent.change(screen.getByLabelText('External auditor evidence reference'), { + target: { value: 'LA cert #123' }, + }); + fireEvent.click(screen.getByText('Save audit route')); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + auditRoute: 'external', + auditFirmName: 'Acme', + auditEvidenceRef: 'LA cert #123', + auditRouteMemberId: null, + auditCourse: null, + auditDueDate: null, + }), + ); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditRoutePicker.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditRoutePicker.tsx new file mode 100644 index 0000000000..29b01006cd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/AuditRoutePicker.tsx @@ -0,0 +1,318 @@ +'use client'; + +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Field, + Grid, + HStack, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, +} from '@trycompai/design-system'; +import { WarningAlt } from '@trycompai/design-system/icons'; +import { useEffect } from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; +import type { IsmsRole } from '../isms-types'; +import type { ApproverOption } from './IsmsApprovalSection'; +import { AUDIT_ROUTE_OPTIONS } from './roles-constants'; +import { IsmsFieldLabel } from './shared'; + +/** The audit-route payload sent to the role update endpoint. */ +export interface AuditRouteUpdate { + auditRoute: string | null; + auditRouteMemberId: string | null; + auditFirmName: string | null; + auditEvidenceRef: string | null; + auditCourse: string | null; + auditDueDate: string | null; +} + +interface AuditRouteFormValues { + auditRoute: string; + auditRouteMemberId: string; + auditFirmName: string; + auditEvidenceRef: string; + auditCourse: string; + auditDueDate: string; +} + +interface AuditRoutePickerProps { + role: IsmsRole; + canEdit: boolean; + memberOptions: ApproverOption[]; + /** Member ids assigned to the SPO role, for the conflict-of-interest warning. */ + spoMemberIds: string[]; + onSave: (update: AuditRouteUpdate) => Promise; +} + +function toFormValues(role: IsmsRole): AuditRouteFormValues { + return { + auditRoute: role.auditRoute ?? '', + auditRouteMemberId: role.auditRouteMemberId ?? '', + auditFirmName: role.auditFirmName ?? '', + auditEvidenceRef: role.auditEvidenceRef ?? '', + auditCourse: role.auditCourse ?? '', + auditDueDate: role.auditDueDate?.slice(0, 10) ?? '', + }; +} + +function emptyToNull(value: string): string | null { + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +export function AuditRoutePicker({ + role, + canEdit, + memberOptions, + spoMemberIds, + onSave, +}: AuditRoutePickerProps) { + const { + control, + handleSubmit, + reset, + formState: { isDirty, isSubmitting }, + } = useForm({ defaultValues: toFormValues(role) }); + + useEffect(() => { + reset(toFormValues(role)); + }, [role, reset]); + + const route = useWatch({ control, name: 'auditRoute' }); + const selectedMemberId = useWatch({ control, name: 'auditRouteMemberId' }); + + const conflictMember = + route === 'in_house' && selectedMemberId && spoMemberIds.includes(selectedMemberId) + ? (memberOptions.find((option) => option.id === selectedMemberId)?.name ?? + 'This member') + : null; + + const handleSave = handleSubmit(async (values) => { + const inHouseOrTraining = + values.auditRoute === 'in_house' || values.auditRoute === 'training_planned'; + try { + await onSave({ + auditRoute: values.auditRoute || null, + auditRouteMemberId: inHouseOrTraining + ? emptyToNull(values.auditRouteMemberId) + : null, + auditFirmName: + values.auditRoute === 'external' ? emptyToNull(values.auditFirmName) : null, + auditEvidenceRef: + values.auditRoute === 'external' + ? emptyToNull(values.auditEvidenceRef) + : null, + auditCourse: + values.auditRoute === 'training_planned' + ? emptyToNull(values.auditCourse) + : null, + auditDueDate: + values.auditRoute === 'training_planned' + ? emptyToNull(values.auditDueDate) + : null, + }); + } catch { + // The caller surfaces the failure via toast and re-throws; swallow here so a + // failed save keeps the form dirty for retry without an unhandled rejection. + } + }); + + const hasMemberOptions = memberOptions.length > 0; + + return ( + + + ( + + )} + /> + + + {route === 'in_house' ? ( + + ( + // A member id is required (it must resolve to a real person), so we + // never offer editable free text — the select disables when there + // are no members to pick. + + )} + /> + + ) : null} + + {route === 'external' ? ( + + + + ( + + )} + /> + + + + + ( + + )} + /> + + + + ) : null} + + {route === 'training_planned' ? ( + + + ( + + )} + /> + + + + ( + + )} + /> + + + + + ( + + )} + /> + + + + ) : null} + + {conflictMember ? ( + }> + Independence conflict + + {conflictMember} is also assigned as the Security & Privacy Owner. ISO 27001 + requires the internal auditor to be objective and impartial — a person auditing an + ISMS they also run creates a conflict. For teams of your size, outsourcing the + internal audit to an external auditor is the standard route and usually the most + cost-effective way to satisfy the independence requirement. If you keep this as-is, be + prepared to justify the arrangement at Stage 2. + + + ) : null} + + {canEdit ? ( + + + + ) : null} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx index c4dd38c70e..83402dd4d9 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx @@ -109,6 +109,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedParties: [], interestedPartyRequirements: [], objectives: [], + roles: [], controlLinks: [], draftNarrative: null, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx index 7618d876e2..245a2d6738 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx @@ -132,6 +132,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedParties: PARTIES, interestedPartyRequirements: [], objectives: [], + roles: [], controlLinks: [], draftNarrative: null, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx index 9e1cccdbe3..addc53c6ad 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx @@ -104,6 +104,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedParties: [], interestedPartyRequirements: [], objectives: [], + roles: [], controlLinks: [], draftNarrative: null, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.tsx index 37ba6d724a..b013b955ec 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.tsx @@ -33,6 +33,11 @@ interface IsmsApprovalSectionProps { canManage: boolean; currentMemberId: string | null; approverOptions: ApproverOption[]; + /** + * When set, "Submit for approval" is disabled and this reason is shown — used + * by documents with generate-time validation (e.g. Roles, clause 5.3). + */ + submitBlockedReason?: string | null; onSubmitForApproval: (approverId: string) => Promise; onApprove: () => Promise; onDecline: () => Promise; @@ -55,6 +60,7 @@ export function IsmsApprovalSection({ canManage, currentMemberId, approverOptions, + submitBlockedReason, onSubmitForApproval, onApprove, onDecline, @@ -174,10 +180,20 @@ export function IsmsApprovalSection({ )} {(showSubmitButton || showResubmitButton) && ( -
- + {submitBlockedReason ? ( + + {submitBlockedReason} + + ) : null}
)} diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx index 269a667b86..c4483f018f 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx @@ -191,6 +191,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedParties: [], interestedPartyRequirements: [], objectives: [], + roles: [], controlLinks: LINKS, draftNarrative: null, currentVersionId: null, 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 6e50a82601..f1c4494ae4 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 @@ -55,6 +55,13 @@ export interface IsmsDocumentShellProps { sectionDescription: string; /** Toast shown after a successful generate, e.g. "Generated issues from platform data". */ generateSuccessMessage: string; + /** + * Optional per-document validation gate. When it returns a non-null reason for + * the current document, "Submit for approval" is disabled and the reason is + * shown (used by the Roles document to enforce clause-5.3 completeness). Other + * documents omit it and are never gated. + */ + getSubmitBlockedReason?: (document: IsmsDocumentData) => string | null; /** Renders the register-specific body once a document is loaded. */ children: (args: IsmsDocumentBodyArgs) => ReactNode; } @@ -78,6 +85,7 @@ export function IsmsDocumentShell({ sectionTitle, sectionDescription, generateSuccessMessage, + getSubmitBlockedReason, children, }: IsmsDocumentShellProps) { const { hasPermission } = usePermissions(); @@ -203,6 +211,7 @@ export function IsmsDocumentShell({ canManage={canManage} currentMemberId={currentMemberId} approverOptions={approverOptions} + submitBlockedReason={getSubmitBlockedReason?.(document) ?? null} onSubmitForApproval={handleSubmit} onApprove={handleApprove} onDecline={handleDecline} diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx index e0dd17d495..e55d19fba5 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx @@ -172,6 +172,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedParties: [], interestedPartyRequirements: [], objectives: [], + roles: [], controlLinks: [], draftNarrative: NARRATIVE, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.test.tsx index 424590c6f0..81325a7e8e 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ObjectivesClient.test.tsx @@ -115,6 +115,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedParties: [], interestedPartyRequirements: [], objectives: OBJECTIVES, + roles: [], controlLinks: [], draftNarrative: null, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.test.tsx index 258f1c7920..a6a95596f5 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RequirementsClient.test.tsx @@ -113,6 +113,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedParties: [], interestedPartyRequirements: REQUIREMENTS, objectives: [], + roles: [], controlLinks: [], draftNarrative: null, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/RoleAssignmentRow.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RoleAssignmentRow.test.tsx new file mode 100644 index 0000000000..7102e6d07f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RoleAssignmentRow.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IsmsRoleAssignment } from '../isms-types'; +import { ismsDesignSystemMock, ismsIconsMock, ismsSharedMock } from './__test-helpers__/dsMocks'; + +vi.mock('@trycompai/design-system', () => ismsDesignSystemMock()); +vi.mock('@trycompai/design-system/icons', () => ismsIconsMock()); +vi.mock('./shared', () => ismsSharedMock()); + +import { RoleAssignmentRow } from './RoleAssignmentRow'; + +function makeAssignment(overrides: Partial = {}): IsmsRoleAssignment { + return { + id: 'ra_1', + roleId: 'role_1', + memberId: 'mem_1', + basisOfCompetence: 'training', + evidenceRetained: 'Training record', + gap: null, + remediationAction: null, + remediationDueDate: null, + position: 0, + ...overrides, + }; +} + +describe('RoleAssignmentRow', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows the member name and competence basis in read view', () => { + render( + , + ); + expect(screen.getByText('Alex Petrisor')).toBeInTheDocument(); + expect(screen.getByText('Training')).toBeInTheDocument(); + }); + + it('flags a gap and reveals remediation fields when a gap is present', () => { + render( + , + ); + // Read view surfaces the gap text + remediation ('Gap' appears as both a + // badge and a field label, so assert on the unique values instead). + expect(screen.getByText('Needs ISO 27001 course')).toBeInTheDocument(); + expect(screen.getByText('Enrol')).toBeInTheDocument(); + }); + + it('saves edited competence via onUpdate', async () => { + const onUpdate = vi.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.click(screen.getByLabelText('Edit competence for Alex')); + // Remediation fields are visible because a gap is set. + expect(screen.getByLabelText('Remediation action')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('Evidence retained'), { + target: { value: 'Updated evidence' }, + }); + fireEvent.click(screen.getByText('Save')); + + await waitFor(() => expect(onUpdate).toHaveBeenCalled()); + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ evidenceRetained: 'Updated evidence', gap: 'Needs course' }), + ); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/RoleAssignmentRow.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RoleAssignmentRow.tsx new file mode 100644 index 0000000000..518ad77942 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/RoleAssignmentRow.tsx @@ -0,0 +1,277 @@ +'use client'; + +import { + Badge, + Field, + Grid, + HStack, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, + Text, + Textarea, +} from '@trycompai/design-system'; +import { useEffect, useState } from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; +import type { IsmsRoleAssignment } from '../isms-types'; +import { + COMPETENCE_BASIS_LABELS, + COMPETENCE_BASIS_OPTIONS, +} from './roles-constants'; +import { IsmsCardActions, IsmsFieldLabel, IsmsRegisterField } from './shared'; + +/** The competence payload sent to the assignment update endpoint. */ +export interface AssignmentCompetenceUpdate { + basisOfCompetence: string | null; + evidenceRetained: string | null; + gap: string | null; + remediationAction: string | null; + remediationDueDate: string | null; +} + +interface CompetenceFormValues { + basisOfCompetence: string; + evidenceRetained: string; + gap: string; + remediationAction: string; + remediationDueDate: string; +} + +interface RoleAssignmentRowProps { + assignment: IsmsRoleAssignment; + memberName: string; + canEdit: boolean; + onUpdate: (update: AssignmentCompetenceUpdate) => Promise; + onRemove: () => Promise; +} + +function toFormValues(assignment: IsmsRoleAssignment): CompetenceFormValues { + return { + basisOfCompetence: assignment.basisOfCompetence ?? '', + evidenceRetained: assignment.evidenceRetained ?? '', + gap: assignment.gap ?? '', + remediationAction: assignment.remediationAction ?? '', + remediationDueDate: assignment.remediationDueDate?.slice(0, 10) ?? '', + }; +} + +function emptyToNull(value: string): string | null { + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +export function RoleAssignmentRow({ + assignment, + memberName, + canEdit, + onUpdate, + onRemove, +}: RoleAssignmentRowProps) { + const [isEditing, setIsEditing] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const { + control, + handleSubmit, + reset, + formState: { isDirty, isSubmitting }, + } = useForm({ + defaultValues: toFormValues(assignment), + }); + + useEffect(() => { + if (!isEditing) reset(toFormValues(assignment)); + }, [assignment, isEditing, reset]); + + const gapValue = useWatch({ control, name: 'gap' }); + const hasGap = !!gapValue?.trim(); + + const handleSave = handleSubmit(async (values) => { + try { + await onUpdate({ + basisOfCompetence: values.basisOfCompetence || null, + evidenceRetained: emptyToNull(values.evidenceRetained), + gap: emptyToNull(values.gap), + // Remediation only applies when a gap is recorded. + remediationAction: values.gap.trim() + ? emptyToNull(values.remediationAction) + : null, + remediationDueDate: values.gap.trim() + ? emptyToNull(values.remediationDueDate) + : null, + }); + } catch { + return; + } + setIsEditing(false); + }); + + const handleDelete = async () => { + setIsDeleting(true); + try { + await onRemove(); + } finally { + setIsDeleting(false); + } + }; + + const actions = canEdit ? ( + { + reset(toFormValues(assignment)); + setIsEditing(true); + }} + onSave={handleSave} + onCancel={() => { + reset(toFormValues(assignment)); + setIsEditing(false); + }} + onDelete={handleDelete} + isDirty={isDirty} + isSaving={isSubmitting} + isDeleting={isDeleting} + editLabel={`Edit competence for ${memberName}`} + deleteLabel={`Remove ${memberName}`} + /> + ) : undefined; + + const header = ( + + + {memberName} + + {assignment.gap ? Gap : null} + + ); + + return ( +
+ + {header} + {actions ?
{actions}
: null} +
+ + {isEditing ? ( + + + + ( + + )} + /> + + + + ( + + )} + /> + + + + + + ( +