Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -5,6 +5,7 @@ import { deriveInterestedParties } from './interested-parties';
import { deriveRequirements } from './requirements';
import { deriveObjectives } from './objectives';
import { seedRolesIfMissing } from './roles';
import { seedMetricsIfMissing } from './monitoring';
import { deriveNarrativeForType, isNarrativeType } from './registry';
import type { IsmsPlatformData } from './types';

Expand Down Expand Up @@ -255,6 +256,12 @@ export async function runDerivation({
await seedRolesIfMissing({ tx, documentId, memberCount: data.memberCount });
return;
}
if (type === 'monitoring') {
// Idempotent seed only (same guarantee as roles): a regenerate can never
// clobber metric edits, deactivations, or measurement history.
await seedMetricsIfMissing({ tx, documentId });
return;
}
if (isNarrativeType(type)) {
await generateNarrative({ tx, documentId, type, data });
return;
Expand Down
105 changes: 105 additions & 0 deletions apps/api/src/isms/documents/monitoring-defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { SeedMetricDefinition } from './types';

/**
* The nine seeded monitoring metrics (CS-723), active from day one. Names,
* measured text, methods, cadences and targets follow the ticket and the
* clause-9.1 reference document; the customer edits any field, deactivates a
* metric, or adds custom ones. Seeding is idempotent by metricKey
* (seedMetricsIfMissing) and never overwrites customer edits.
*/
export const SEED_METRIC_DEFINITIONS: SeedMetricDefinition[] = [
{
metricKey: 'training_completion',
name: 'Security awareness training completion',
whatIsMeasured:
'Percentage of workforce members who have completed their assigned security-awareness training.',
method:
'Comp AI training tracker: completed assignments over total assigned, per campaign with monthly follow-up.',
cadence: 'monthly',
target: '95% completion or above',
},
{
metricKey: 'vendor_review_currency',
name: 'Vendor review currency',
whatIsMeasured:
'Percentage of critical vendors with an in-date security review and a signed DPA.',
method:
'Comp AI vendor register: critical vendors with a review inside its window and a signed DPA, over all critical vendors.',
cadence: 'quarterly',
target: '100% of critical vendors current',
},
{
metricKey: 'finding_ageing',
name: 'Open audit findings and corrective-action ageing',
whatIsMeasured:
'Count of open audit findings and the age of each open corrective action.',
method:
'Corrective-action records in Comp AI: open findings and days since each was raised, against its due date.',
cadence: 'monthly',
target: 'No corrective action open beyond its due date',
},
{
metricKey: 'risk_treatment_status',
name: 'Risk treatment status',
whatIsMeasured:
'Progress of risk treatment plans across the risk register.',
method:
'Comp AI risk register: risks by treatment status, highlighting high and critical risks without an active plan.',
cadence: 'quarterly',
target: 'All high and critical risks under active treatment',
},
{
metricKey: 'policy_acknowledgement',
name: 'Policy acknowledgement coverage',
whatIsMeasured:
'Percentage of workforce members who have acknowledged all policies assigned to them.',
method:
'Comp AI policy tracker: members with all assigned policies acknowledged, over all members with assignments.',
cadence: 'quarterly',
target: '95% acknowledgement coverage or above',
},
{
metricKey: 'uptime',
name: 'Production availability / uptime',
whatIsMeasured: 'Availability of production services over the period.',
method:
'Cloud monitoring uptime reports (e.g. AWS / GCP / status page) for production services.',
cadence: 'monthly',
target: '99.9% availability or above',
},
{
metricKey: 'vulnerability_remediation',
name: 'Critical and High vulnerability remediation time',
whatIsMeasured:
'Time from detection to remediation for Critical and High severity vulnerabilities.',
method:
'SAST / SCA findings and tracker SLA timers: days from detection to closure per Critical/High vulnerability.',
cadence: 'monthly',
target: 'Critical within 7 days; High within 30 days',
},
{
metricKey: 'unauthorised_access',
name: 'Confirmed unauthorised-access incidents',
whatIsMeasured:
'Confirmed incidents of unauthorised access to customer or company data.',
method:
'Access logs, alerting and incident records: count of confirmed unauthorised-access incidents in the period.',
cadence: 'monthly',
target: '0 confirmed incidents',
},
{
metricKey: 'incident_response_times',
name: 'Incident time-to-acknowledge and time-to-contain',
whatIsMeasured:
'Time from detection to acknowledgement and to containment for security incidents.',
method:
'Incident records measured against the severity SLA: acknowledgement and containment timestamps per incident.',
cadence: 'monthly',
target: 'Within the severity SLA for every incident',
},
];

/** Stable keys of the nine seeded metrics. */
export const SEED_METRIC_KEYS: string[] = SEED_METRIC_DEFINITIONS.map(
(metric) => metric.metricKey,
);
147 changes: 147 additions & 0 deletions apps/api/src/isms/documents/monitoring-export-data.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { db } from '@db';
import {
loadMonitoringExtras,
mapMetrics,
type MetricWithExportIncludes,
} from './monitoring-export-data';

jest.mock('@db', () => ({
db: {
member: { findMany: jest.fn() },
ismsRole: { findFirst: jest.fn() },
},
}));

const mockDb = jest.mocked(db);

const baseMetric = {
id: 'met_1',
documentId: 'doc_1',
metricKey: 'uptime',
name: 'Production availability / uptime',
whatIsMeasured: 'Availability of production services.',
method: 'Cloud monitoring uptime reports.',
cadence: 'monthly',
monitorMemberId: null,
analyzeMemberId: null,
target: '≥ 99.9% availability',
objectiveId: null,
objective: null,
dataSource: 'manual',
isActive: true,
source: 'derived',
derivedFrom: 'seed:uptime',
position: 0,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
measurements: [],
} as unknown as MetricWithExportIncludes;

describe('loadMonitoringExtras', () => {
beforeEach(() => jest.clearAllMocks());

it('resolves member names and the SPO fallback display', async () => {
(mockDb.member.findMany as jest.Mock).mockResolvedValue([
{
id: 'mem_1',
deactivated: false,
user: { name: 'Jane Doe', email: 'jane@x.io' },
},
{
id: 'mem_2',
deactivated: true,
user: { name: null, email: 'gone@x.io' },
},
]);
(mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue({
assignments: [{ memberId: 'mem_1' }, { memberId: 'mem_2' }],
});

const extras = await loadMonitoringExtras({ organizationId: 'org_1' });

expect(extras.memberNames).toEqual({
mem_1: 'Jane Doe',
mem_2: 'gone@x.io',
});
// Deactivated SPO assignee is excluded from the fallback display.
expect(extras.spoDisplay).toBe('Security & Privacy Owner (Jane Doe)');
});

it('falls back to the plain role label when the SPO is unassigned', async () => {
(mockDb.member.findMany as jest.Mock).mockResolvedValue([]);
(mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue(null);

const extras = await loadMonitoringExtras({ organizationId: 'org_1' });
expect(extras.spoDisplay).toBe('Security & Privacy Owner (SPO)');
});
});

describe('mapMetrics', () => {
const extras = {
memberNames: { mem_1: 'Jane Doe' },
spoDisplay: 'Security & Privacy Owner (SPO)',
};

it('excludes deactivated metrics from the published framework table', () => {
const rows = mapMetrics(
[baseMetric, { ...baseMetric, id: 'met_2', isActive: false }],
extras,
);
expect(rows).toHaveLength(1);
});

it('resolves people: explicit member name, SPO fallback for null', () => {
const rows = mapMetrics(
[{ ...baseMetric, monitorMemberId: 'mem_1', analyzeMemberId: null }],
extras,
);
expect(rows[0].monitorName).toBe('Jane Doe');
expect(rows[0].analyzeName).toBe('Security & Privacy Owner (SPO)');
});

it('renders the latest value with its period label', () => {
const rows = mapMetrics(
[
{
...baseMetric,
measurements: [
{
periodStart: new Date('2026-07-01T00:00:00Z'),
value: '99.95%',
},
] as MetricWithExportIncludes['measurements'],
},
],
extras,
);
expect(rows[0].currentValue).toBe('99.95% (July 2026)');
});

it('renders an em-dash when the metric has no measurements', () => {
expect(mapMetrics([baseMetric], extras)[0].currentValue).toBe('—');
});

it('prefers explicit target text, else the linked objective target', () => {
const withObjective = {
...baseMetric,
target: null,
objective: { objective: 'Maintain uptime', target: '99.9% SLA' },
} as unknown as MetricWithExportIncludes;
expect(mapMetrics([withObjective], extras)[0].target).toBe('99.9% SLA');

const explicitWins = {
...withObjective,
target: 'Explicit target',
} as unknown as MetricWithExportIncludes;
expect(mapMetrics([explicitWins], extras)[0].target).toBe(
'Explicit target',
);
});

it('humanizes the cadence', () => {
expect(mapMetrics([baseMetric], extras)[0].cadence).toBe('Monthly');
expect(
mapMetrics([{ ...baseMetric, cadence: null }], extras)[0].cadence,
).toBeNull();
});
});
Loading
Loading