Skip to content

Commit c013515

Browse files
tofikwestclaude
andauthored
feat(isms): add Roles, Responsibilities & Authorities document (CS-698) (#3391)
* feat(isms): add Roles, Responsibilities & Authorities document (CS-698) Add a new ISMS > Roles sub-page (ISO 27001 Clause 5.3): four seeded governance roles (Top Management, SPO, Deputy SPO, Internal Auditor) with pre-filled text, member assignments with per-member competence evidence (Clause 7.2), an Internal Auditor route picker with an independence soft-warning, 1-3 / 4+ team-size bands, and generate-time validation. The Clause 5.3 document renders to branded PDF + DOCX through the existing ISMS export pipeline, including the two auto-generated governance rows and an operational-responsibilities summary read from per-artifact owners. Mirrors the existing ISMS register pattern; reuses the evidence permission resource and the is-isms-enabled flag gate. Role seeding is idempotent (never destructive) so member assignments survive a regenerate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015bx4urA8n2E6zzBAjEuxwR * fix(isms): address CS-698 review — validation, concurrency, UI hardening - Enforce Clause 5.3 completeness server-side on submit-for-approval (not just the client), so an incomplete Roles doc can't be published by calling the API directly. Validation now iterates the required seeded roles, catching an entirely-missing row, not only present-but-unassigned ones (client + server). - Add a unique (documentId, roleKey) index + skipDuplicates so concurrent first-load provisioning can't double-seed governance roles. - Make the role-assignment idempotency check run inside the per-document lock, so concurrent duplicate adds return the existing row instead of hitting the unique index. - Extract a shared parseOptionalDate util (was duplicated in both role services). - Neutralise the small-team note so it no longer asserts an external audit route that can contradict the customer's chosen route. - Audit-route member pickers: drop the free-text fallback (a member id is required); disable the select when no members exist. - Swallow already-handled mutation rejections in the add-member and delete-role handlers to avoid unhandled promise rejections. - Fix stale "all six ISMS documents" JSDoc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015bx4urA8n2E6zzBAjEuxwR * fix(isms): harden roles submit gate + strict date parsing (CS-698 review 2) - Server-side clause-5.3 gate now counts only assignments that resolve to an active member, so a required role assigned solely to a deactivated/removed member no longer passes submit-for-approval. - parseOptionalDate now validates a strict YYYY-MM-DD format and round-trips the parsed value, rejecting ambiguous/rolled-over input (e.g. 2026-02-30) instead of relying on permissive Date parsing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015bx4urA8n2E6zzBAjEuxwR * fix(isms): close CS-698 review round 3 — submit race, active-member + route validation, doc-type scoping - Serialize submit-for-approval: validate completeness and flip status inside one transaction under the per-document lock, and take that same lock on role/ assignment update+remove, so an edit can't invalidate the doc between the check and the needs_review transition (TOCTOU). - Enforce active (non-deactivated) members everywhere: role audit-route member, assignment member, and the completeness gate count only active-member assignments; the client validation mirrors it via the active-member set so the Submit button and the server agree. - Route-specific Internal Auditor validation (client + server): external requires firm/person + evidence reference; in-house requires a member; training-planned requires member + course + due date. - Scope role + assignment register lookups to roles_and_responsibilities documents so role rows can't attach to unrelated ISMS documents. - Generated governance table renders only the four seeded roles + two auto rows (custom roles are managed on the page but not part of the standardized 5.3 table). - AuditRoutePicker: swallow the already-handled save rejection (no unhandled promise rejection). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015bx4urA8n2E6zzBAjEuxwR * fix(isms): audit-route validation counts active members + trims required text (CS-698 review 4) - The Internal Auditor's in-house/training-planned member must now resolve to an ACTIVE member (same active-member set used for assignments), on both the client gate and the server completeness check — a deactivated auditor no longer passes. - Required audit text is trimmed before the check: external firm/person + evidence and the training-planned course can't be satisfied by whitespace via the API. - Unify the active-member logic in roleValidationMessages (assignments + audit member) so client and server agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015bx4urA8n2E6zzBAjEuxwR --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b6033ef commit c013515

58 files changed

Lines changed: 4376 additions & 27 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/api/src/isms/documents/generate.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { deriveContextOfOrganization } from './context';
44
import { deriveInterestedParties } from './interested-parties';
55
import { deriveRequirements } from './requirements';
66
import { deriveObjectives } from './objectives';
7+
import { seedRolesIfMissing } from './roles';
78
import { deriveNarrativeForType, isNarrativeType } from './registry';
89
import type { IsmsPlatformData } from './types';
910

@@ -248,6 +249,12 @@ export async function runDerivation({
248249
await generateObjectives({ tx, documentId, data });
249250
return;
250251
}
252+
if (type === 'roles_and_responsibilities') {
253+
// Idempotent seed only — never a destructive replace, so member assignments
254+
// (IsmsRoleAssignment) and customer edits survive every regenerate.
255+
await seedRolesIfMissing({ tx, documentId, memberCount: data.memberCount });
256+
return;
257+
}
251258
if (isNarrativeType(type)) {
252259
await generateNarrative({ tx, documentId, type, data });
253260
return;

‎apps/api/src/isms/documents/registry.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { buildContextSections } from './context';
55
import { buildInterestedPartiesSections } from './interested-parties';
66
import { buildRequirementsSections } from './requirements';
77
import { buildObjectivesSections } from './objectives';
8+
import { buildRolesSections } from './roles';
89
import {
910
buildScopeSections,
1011
deriveScopeNarrative,
@@ -31,6 +32,7 @@ const EXPORT_SECTION_BUILDERS: Record<
3132
interested_parties_register: buildInterestedPartiesSections,
3233
interested_parties_requirements: buildRequirementsSections,
3334
objectives_plan: buildObjectivesSections,
35+
roles_and_responsibilities: buildRolesSections,
3436
isms_scope: buildScopeSections,
3537
leadership_commitment: buildLeadershipSections,
3638
};
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import type { SeedRoleDefinition } from './types';
2+
3+
/**
4+
* The four seeded ISMS governance roles (clause 5.3) and their pre-filled
5+
* default text. Drafted from the reference document
6+
* ("05a - ISMS Roles, Responsibilities & Authorities") so a customer with no ISO
7+
* expertise gets a workable baseline; every field is editable in the app.
8+
*
9+
* Order is the render/seed order. `roleKey` is the idempotency key for seeding.
10+
*/
11+
export const SEED_ROLE_DEFINITIONS: SeedRoleDefinition[] = [
12+
{
13+
roleKey: 'top_management',
14+
name: 'Top Management',
15+
description:
16+
'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.',
17+
responsibilities:
18+
'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.',
19+
authorities:
20+
'Approve the ISMS and accept residual risk on behalf of the organisation.',
21+
authorityGrantedBy: 'The executive office and the Board of Directors',
22+
requiredCompetence:
23+
'Executive understanding of the organisation, its risk appetite, and its legal and regulatory obligations, sufficient to direct the ISMS and be accountable for it.',
24+
},
25+
{
26+
roleKey: 'spo',
27+
name: 'Security & Privacy Owner (SPO)',
28+
description:
29+
'The person who owns and operates the ISMS day to day and is the focal point for information security and privacy across the organisation.',
30+
responsibilities:
31+
'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.',
32+
authorities:
33+
'Direct the security and privacy programme; assign control, risk, and policy owners; and approve exceptions within the organisation’s risk appetite.',
34+
authorityGrantedBy: 'Top Management',
35+
requiredCompetence:
36+
'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.',
37+
},
38+
{
39+
roleKey: 'deputy_spo',
40+
name: 'Deputy Security & Privacy Owner',
41+
description:
42+
'The documented backup for the Security & Privacy Owner, ensuring the ISMS remains covered during the SPO’s absence.',
43+
responsibilities:
44+
'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.',
45+
authorities: 'Act with the SPO’s authority during the SPO’s absence.',
46+
authorityGrantedBy: 'Top Management',
47+
requiredCompetence:
48+
'Sufficient familiarity with the ISMS and the organisation’s controls to stand in for the SPO and make interim risk-based decisions.',
49+
},
50+
{
51+
roleKey: 'internal_auditor',
52+
name: 'Internal Auditor',
53+
description:
54+
'The person or party responsible for independently auditing the ISMS to confirm it conforms to ISO/IEC 27001 and is effectively implemented and maintained.',
55+
responsibilities:
56+
'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.',
57+
authorities:
58+
'Access the information, systems, and personnel needed to conduct the audit, and report findings directly to top management.',
59+
authorityGrantedBy: 'Top Management',
60+
requiredCompetence:
61+
'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.',
62+
},
63+
];
64+
65+
/** roleKey values of the seeded roles, for validation and lookups. */
66+
export const SEED_ROLE_KEYS: string[] = SEED_ROLE_DEFINITIONS.map(
67+
(role) => role.roleKey,
68+
);
69+
70+
/**
71+
* The two governance rows that appear only in the generated document (never as
72+
* editable role cards): the per-artifact operational owners and the whole
73+
* workforce. Holders/text are fixed.
74+
*/
75+
export const AUTO_DOC_ROLE_ROWS = [
76+
{
77+
name: 'Control / asset / risk / policy owners',
78+
holders: 'Identified in the platform per policy, control, risk, task, and vendor.',
79+
responsibilities:
80+
'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.',
81+
authority: 'Make operational decisions for their assigned item. Authority granted by the SPO.',
82+
},
83+
{
84+
name: 'All personnel and contractors',
85+
holders: 'All workforce members.',
86+
responsibilities:
87+
'Comply with the policy set; complete required training; protect company data on personal devices; and report incidents and near-misses.',
88+
authority: 'Use approved tools and identity flows. Granted on engagement.',
89+
},
90+
] as const;
91+
92+
/**
93+
* Non-removable note reproduced verbatim in the generated document (§2). Clarifies
94+
* that Comp AI application-access levels are not ISMS governance roles.
95+
*/
96+
export const APPLICATION_ACCESS_NOTE =
97+
'Comp AI application-access levels (Owner / Admin / Auditor / Employee / Contractor) are not ISMS governance roles. The ISMS governance roles are defined below.';
98+
99+
/** The application-access levels and what they grant (generated document §2). */
100+
export const APPLICATION_ACCESS_LEVELS: string[] = [
101+
'Owner — the account creator.',
102+
'Admin — administrative access to the GRC platform.',
103+
'Auditor — read access provided to external auditors.',
104+
'Employee — access to the employee portal.',
105+
'Contractor — limited access for engaged contractors.',
106+
];
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { db } from '@db';
2+
import type { Prisma } from '@db';
3+
import type { IsmsTeamSizeBand, OperationalOwnershipRow } from './types';
4+
import { teamSizeBand } from './roles';
5+
6+
/**
7+
* Extra data the Roles document (5.3) needs at export time but that isn't on the
8+
* document's own rows: display names for assigned members (assignments store a
9+
* plain memberId, no FK), the live per-artifact operational owners, and the
10+
* team-size band. Resolved once and frozen into the version snapshot so a
11+
* historical export re-renders byte-faithfully.
12+
*/
13+
export interface RolesExtras {
14+
/** memberId → display name (name, else email, else a placeholder). */
15+
memberNames: Record<string, string>;
16+
operationalOwnership: OperationalOwnershipRow[];
17+
band: IsmsTeamSizeBand;
18+
}
19+
20+
type Client = Prisma.TransactionClient | typeof db;
21+
22+
const OWNER_DISPLAY_CAP = 12;
23+
24+
type NamedAssignee = {
25+
assignee: { user: { name: string | null; email: string | null } | null } | null;
26+
};
27+
28+
function memberDisplayName(user: { name: string | null; email: string | null } | null): string {
29+
return user?.name?.trim() || user?.email?.trim() || 'Unknown member';
30+
}
31+
32+
function dedupeOwners(rows: NamedAssignee[]): string[] {
33+
const seen = new Set<string>();
34+
const names: string[] = [];
35+
for (const row of rows) {
36+
if (!row.assignee?.user) continue;
37+
const name = memberDisplayName(row.assignee.user);
38+
if (seen.has(name)) continue;
39+
seen.add(name);
40+
names.push(name);
41+
}
42+
names.sort((a, b) => a.localeCompare(b));
43+
if (names.length <= OWNER_DISPLAY_CAP) return names;
44+
return [
45+
...names.slice(0, OWNER_DISPLAY_CAP),
46+
`and ${names.length - OWNER_DISPLAY_CAP} more`,
47+
];
48+
}
49+
50+
/** Load the Roles document's export extras for an organization. */
51+
export async function loadRolesExtras({
52+
organizationId,
53+
client,
54+
}: {
55+
organizationId: string;
56+
client?: Client;
57+
}): Promise<RolesExtras> {
58+
const prisma = client ?? db;
59+
const assigneeSelect = {
60+
assignee: { select: { user: { select: { name: true, email: true } } } },
61+
} as const;
62+
63+
const [members, memberCount, policies, risks, tasks, vendors] =
64+
await Promise.all([
65+
prisma.member.findMany({
66+
where: { organizationId },
67+
select: { id: true, user: { select: { name: true, email: true } } },
68+
}),
69+
prisma.member.count({ where: { organizationId, deactivated: false } }),
70+
prisma.policy.findMany({
71+
where: { organizationId, assigneeId: { not: null } },
72+
select: assigneeSelect,
73+
}),
74+
prisma.risk.findMany({
75+
where: { organizationId, assigneeId: { not: null } },
76+
select: assigneeSelect,
77+
}),
78+
prisma.task.findMany({
79+
where: { organizationId, assigneeId: { not: null } },
80+
select: assigneeSelect,
81+
}),
82+
prisma.vendor.findMany({
83+
where: { organizationId, assigneeId: { not: null } },
84+
select: assigneeSelect,
85+
}),
86+
]);
87+
88+
const memberNames: Record<string, string> = {};
89+
for (const member of members) {
90+
memberNames[member.id] = memberDisplayName(member.user);
91+
}
92+
93+
// Controls have no owner field in the platform; their ownership is descriptive
94+
// (assigned per control via their linked tasks). Kept in the matrix per the
95+
// reference document, without enumerating names.
96+
const operationalOwnership: OperationalOwnershipRow[] = [
97+
{
98+
artifact: 'Policies',
99+
assignedWhere: 'Policy assignee / approver in Comp AI',
100+
ownerResponsibility:
101+
'Keep the policy current and accurate; ensure required acknowledgement.',
102+
owners: dedupeOwners(policies),
103+
},
104+
{
105+
artifact: 'Controls',
106+
assignedWhere: 'Control owner in Comp AI',
107+
ownerResponsibility: 'Implement, operate, and evidence the control.',
108+
owners: [],
109+
},
110+
{
111+
artifact: 'Risks',
112+
assignedWhere: 'Risk owner in Comp AI',
113+
ownerResponsibility:
114+
'Assess, treat, and monitor the risk within the risk appetite.',
115+
owners: dedupeOwners(risks),
116+
},
117+
{
118+
artifact: 'Evidence tasks',
119+
assignedWhere: 'Task assignee in Comp AI',
120+
ownerResponsibility: 'Complete and evidence the task by its due date.',
121+
owners: dedupeOwners(tasks),
122+
},
123+
{
124+
artifact: 'Vendors / sub-processors',
125+
assignedWhere: 'Vendor owner in Comp AI',
126+
ownerResponsibility:
127+
'Perform due diligence and ongoing security review; maintain the DPA.',
128+
owners: dedupeOwners(vendors),
129+
},
130+
];
131+
132+
return {
133+
memberNames,
134+
operationalOwnership,
135+
band: teamSizeBand(memberCount),
136+
};
137+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { buildExportSections } from './registry';
2+
import { generateIsmsExportFile } from '../utils/export-generator';
3+
import { buildExportMetadata } from '../utils/export-metadata';
4+
import type { DocumentExportInput, RoleExportRow } from './types';
5+
6+
/**
7+
* End-to-end render check for the Roles document (5.3): the section builder +
8+
* both real renderers (jsPDF, docx) must produce non-empty files. Guards the
9+
* whole export pipeline for the new type without needing a live org.
10+
*/
11+
function role(overrides: Partial<RoleExportRow>): RoleExportRow {
12+
return {
13+
roleKey: 'spo',
14+
name: 'Security & Privacy Owner (SPO)',
15+
description: 'Owns the ISMS.',
16+
responsibilities: 'Operate the ISMS.',
17+
authorities: 'Direct the programme.',
18+
authorityGrantedBy: 'Top Management',
19+
requiredCompetence: 'ISO 27001 knowledge.',
20+
holders: ['Alex Petrisor'],
21+
auditRoute: null,
22+
auditRouteHolderName: null,
23+
auditFirmName: null,
24+
auditEvidenceRef: null,
25+
auditCourse: null,
26+
auditDueDate: null,
27+
...overrides,
28+
};
29+
}
30+
31+
const INPUT: DocumentExportInput = {
32+
contextIssues: [],
33+
interestedParties: [],
34+
requirements: [],
35+
objectives: [],
36+
narrative: null,
37+
roles: [
38+
role({ roleKey: 'top_management', name: 'Top Management', holders: ['Raoul'] }),
39+
role({}),
40+
role({
41+
roleKey: 'internal_auditor',
42+
name: 'Internal Auditor',
43+
auditRoute: 'external',
44+
auditFirmName: 'Acme Audit LLP',
45+
holders: ['External auditor'],
46+
}),
47+
],
48+
operationalOwnership: [
49+
{
50+
artifact: 'Policies',
51+
assignedWhere: 'Policy assignee in Comp AI',
52+
ownerResponsibility: 'Keep it current.',
53+
owners: ['Alice'],
54+
},
55+
],
56+
band: 'standard',
57+
};
58+
59+
function metadata() {
60+
return buildExportMetadata({
61+
type: 'roles_and_responsibilities',
62+
title: 'Roles, Responsibilities and Authorities',
63+
frameworkName: 'ISO 27001',
64+
version: 1,
65+
status: 'approved',
66+
preparedBy: 'Comp AI',
67+
owner: null,
68+
approverName: 'Raoul Plickat',
69+
approvedAt: new Date('2026-05-26T00:00:00.000Z'),
70+
declinedAt: null,
71+
organizationName: 'Pressmaster AI Inc.',
72+
primaryColor: '#004D3D',
73+
});
74+
}
75+
76+
describe('Roles document export', () => {
77+
const sections = buildExportSections({
78+
type: 'roles_and_responsibilities',
79+
input: INPUT,
80+
});
81+
82+
it('renders a non-empty PDF', async () => {
83+
const result = await generateIsmsExportFile({
84+
sections,
85+
metadata: metadata(),
86+
format: 'pdf',
87+
});
88+
expect(result.fileBuffer.length).toBeGreaterThan(0);
89+
expect(result.mimeType).toBe('application/pdf');
90+
});
91+
92+
it('renders a non-empty DOCX', async () => {
93+
const result = await generateIsmsExportFile({
94+
sections,
95+
metadata: metadata(),
96+
format: 'docx',
97+
});
98+
expect(result.fileBuffer.length).toBeGreaterThan(0);
99+
});
100+
});

0 commit comments

Comments
 (0)