Skip to content
Merged
7 changes: 7 additions & 0 deletions apps/api/src/isms/documents/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/isms/documents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
Expand Down
106 changes: 106 additions & 0 deletions apps/api/src/isms/documents/roles-defaults.ts
Original file line number Diff line number Diff line change
@@ -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.',
];
137 changes: 137 additions & 0 deletions apps/api/src/isms/documents/roles-export-data.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<string>();
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<RolesExtras> {
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<string, string> = {};
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: [],
Comment thread
tofikwest marked this conversation as resolved.
},
{
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),
};
}
100 changes: 100 additions & 0 deletions apps/api/src/isms/documents/roles-export.spec.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
});
Loading
Loading