@tantainnovative/ndpr-recipes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Express — Breach Notification Router
3
+ *
4
+ * Handles listing, creating, retrieving, and updating data breach reports as
5
+ * required by NDPA Section 40, which mandates that controllers notify the NDPC
6
+ * within 72 hours of discovering a breach that poses a risk to data subject
7
+ * rights and freedoms.
8
+ *
9
+ * Routes
10
+ * ------
11
+ * GET /breach — List breach reports (optional ?status= filter)
12
+ * POST /breach — Create a new breach report (auto-calculates severity)
13
+ * GET /breach/:id — Fetch a single breach report by ID
14
+ * PATCH /breach/:id — Update status, severity, actions, or NDPC notification flag
15
+ *
16
+ * How to use
17
+ * ----------
18
+ * This router is mounted automatically by `createNDPRRouter()` in `../index.ts`.
19
+ *
20
+ * @module express/routes/breach
21
+ */
22
+
23
+ import { Router } from 'express';
24
+ import { PrismaClient } from '@prisma/client';
25
+
26
+ const prisma = new PrismaClient();
27
+ export const breachRouter = Router();
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Severity auto-calculation helper
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Derive an initial severity rating from breach category and scale of impact.
35
+ * The DPO should review and adjust this before the NDPC notification is sent.
36
+ */
37
+ function calculateSeverity(
38
+ category: string,
39
+ estimatedAffected?: number,
40
+ ): 'critical' | 'high' | 'medium' | 'low' {
41
+ const highRiskCategories = [
42
+ 'unauthorized_access',
43
+ 'ransomware',
44
+ 'data_exfiltration',
45
+ 'identity_theft',
46
+ ];
47
+
48
+ if (highRiskCategories.includes(category)) {
49
+ if ((estimatedAffected ?? 0) > 1000) return 'critical';
50
+ return 'high';
51
+ }
52
+
53
+ if ((estimatedAffected ?? 0) > 500) return 'high';
54
+ if ((estimatedAffected ?? 0) > 50) return 'medium';
55
+ return 'low';
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // GET /breach?status=ongoing
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * List all breach reports, optionally filtered by status.
64
+ *
65
+ * Returns reports ordered by reportedAt descending so the most recent
66
+ * (likely most urgent) incidents appear first. Use ?status=ongoing to view
67
+ * only active incidents requiring attention or NDPC notification.
68
+ *
69
+ * Query params:
70
+ * status (optional) — ongoing | investigating | resolved | closed
71
+ *
72
+ * Returns 200 with an array of BreachReport rows.
73
+ */
74
+ breachRouter.get('/', async (req, res) => {
75
+ const { status } = req.query;
76
+
77
+ const reports = await prisma.breachReport.findMany({
78
+ where: typeof status === 'string' ? { status } : undefined,
79
+ orderBy: { reportedAt: 'desc' },
80
+ });
81
+
82
+ return res.json(reports);
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // POST /breach
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Create a new data breach report.
91
+ *
92
+ * The 72-hour NDPC notification clock (NDPA Section 40) starts from the
93
+ * discoveredAt timestamp. Severity is auto-calculated from category and scale
94
+ * — the DPO should review before submitting the formal NDPC notification.
95
+ *
96
+ * Body (JSON):
97
+ * title (required) — short descriptive title
98
+ * description (required) — detailed description of the incident
99
+ * category (required) — breach category (e.g. 'unauthorized_access')
100
+ * discoveredAt (required) — ISO timestamp when breach was discovered
101
+ * reporterName (required) — name of the reporting person
102
+ * reporterEmail (required) — reporter's email address
103
+ * affectedSystems (required) — array of affected system/service names
104
+ * dataTypes (required) — array of data type labels affected
105
+ * reporterDepartment (optional) — reporter's department
106
+ * occurredAt (optional) — ISO timestamp when breach occurred (if known)
107
+ * estimatedAffected (optional) — approximate number of affected data subjects
108
+ * initialActions (optional) — containment actions already taken
109
+ *
110
+ * Returns 201 with the newly created BreachReport row including auto-severity.
111
+ */
112
+ breachRouter.post('/', async (req, res) => {
113
+ const {
114
+ title,
115
+ description,
116
+ category,
117
+ discoveredAt,
118
+ occurredAt,
119
+ reporterName,
120
+ reporterEmail,
121
+ reporterDepartment,
122
+ affectedSystems,
123
+ dataTypes,
124
+ estimatedAffected,
125
+ initialActions,
126
+ } = req.body;
127
+
128
+ if (!title || !description || !category || !discoveredAt || !reporterName || !reporterEmail) {
129
+ return res.status(400).json({
130
+ error:
131
+ 'title, description, category, discoveredAt, reporterName, and reporterEmail are required',
132
+ });
133
+ }
134
+
135
+ if (!Array.isArray(affectedSystems) || !Array.isArray(dataTypes)) {
136
+ return res.status(400).json({
137
+ error: 'affectedSystems and dataTypes must be arrays',
138
+ });
139
+ }
140
+
141
+ const severity = calculateSeverity(category, estimatedAffected);
142
+
143
+ const report = await prisma.breachReport.create({
144
+ data: {
145
+ title,
146
+ description,
147
+ category,
148
+ severity,
149
+ status: 'ongoing',
150
+ discoveredAt: new Date(discoveredAt),
151
+ occurredAt: occurredAt ? new Date(occurredAt) : null,
152
+ reporterName,
153
+ reporterEmail,
154
+ reporterDepartment: reporterDepartment ?? null,
155
+ affectedSystems,
156
+ dataTypes,
157
+ estimatedAffected: estimatedAffected ?? null,
158
+ initialActions: initialActions ?? null,
159
+ },
160
+ });
161
+
162
+ await prisma.complianceAuditLog.create({
163
+ data: {
164
+ module: 'breach',
165
+ action: 'reported',
166
+ entityId: report.id,
167
+ entityType: 'BreachReport',
168
+ changes: { title, category, severity, status: 'ongoing' },
169
+ },
170
+ });
171
+
172
+ return res.status(201).json(report);
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // GET /breach/:id
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Fetch a single breach report by its ID.
181
+ *
182
+ * Returns the full report including affected systems, data types, reporter
183
+ * details, and NDPC notification status. Used by the incident detail view
184
+ * and for generating the formal NDPC notification document.
185
+ *
186
+ * Path params:
187
+ * id (required) — the BreachReport record ID
188
+ *
189
+ * Returns 200 with the BreachReport row, or 404 if not found.
190
+ */
191
+ breachRouter.get('/:id', async (req, res) => {
192
+ const { id } = req.params;
193
+
194
+ const report = await prisma.breachReport.findUnique({ where: { id } });
195
+
196
+ if (!report) {
197
+ return res.status(404).json({ error: 'Breach report not found' });
198
+ }
199
+
200
+ return res.json(report);
201
+ });
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // PATCH /breach/:id
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Update a breach report's status, severity, actions, or NDPC notification flag.
209
+ *
210
+ * Key workflow transitions tracked here:
211
+ * ongoing → investigating (DPO has begun assessment)
212
+ * investigating → resolved (threat contained, remediation complete)
213
+ * resolved → closed (post-incident review done, NDPC notified)
214
+ *
215
+ * Setting ndpcNotificationSent=true stamps ndpcNotifiedAt, fulfilling the
216
+ * 72-hour notification requirement under NDPA Section 40.
217
+ *
218
+ * Body (JSON, all fields optional):
219
+ * status — ongoing | investigating | resolved | closed
220
+ * severity — critical | high | medium | low
221
+ * initialActions — containment/remediation actions text
222
+ * ndpcNotificationSent — boolean — set true once NDPC is formally notified
223
+ *
224
+ * Returns 200 with the updated BreachReport row.
225
+ */
226
+ breachRouter.patch('/:id', async (req, res) => {
227
+ const { id } = req.params;
228
+ const { status, severity, initialActions, ndpcNotificationSent } = req.body;
229
+
230
+ const data: Record<string, unknown> = {};
231
+ if (status !== undefined) data.status = status;
232
+ if (severity !== undefined) data.severity = severity;
233
+ if (initialActions !== undefined) data.initialActions = initialActions;
234
+ if (ndpcNotificationSent === true) {
235
+ data.ndpcNotificationSent = true;
236
+ data.ndpcNotifiedAt = new Date();
237
+ }
238
+
239
+ if (Object.keys(data).length === 0) {
240
+ return res.status(400).json({
241
+ error:
242
+ 'At least one of status, severity, initialActions, or ndpcNotificationSent must be provided',
243
+ });
244
+ }
245
+
246
+ const updated = await prisma.breachReport.update({ where: { id }, data });
247
+
248
+ await prisma.complianceAuditLog.create({
249
+ data: {
250
+ module: 'breach',
251
+ action: 'updated',
252
+ entityId: id,
253
+ entityType: 'BreachReport',
254
+ changes: data as any,
255
+ },
256
+ });
257
+
258
+ return res.json(updated);
259
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Express — Compliance Score Router
3
+ *
4
+ * Returns an overall NDPA compliance score by reading the current state of
5
+ * all compliance-related tables. Designed for a DPO dashboard to surface
6
+ * gaps and track compliance posture improvement over time.
7
+ *
8
+ * The score is calculated across five pillars (each 0–100):
9
+ * - Consent (NDPA Section 25) — active consent records exist
10
+ * - DSR (NDPA Sections 34–38) — requests processed without overdue violations
11
+ * - Breach (NDPA Section 40) — incidents notified to NDPC within 72 hours
12
+ * - ROPA (NDPA accountability principle) — active processing records exist
13
+ * - Audit trail (NDPA Section 44) — recent audit activity in the last 30 days
14
+ *
15
+ * Routes
16
+ * ------
17
+ * GET /compliance — Returns the current compliance score object
18
+ *
19
+ * @module express/routes/compliance
20
+ */
21
+
22
+ import { Router } from 'express';
23
+ import { PrismaClient } from '@prisma/client';
24
+
25
+ const prisma = new PrismaClient();
26
+ export const complianceRouter = Router();
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Score calculation helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** Consent pillar: 100 if any active consent records exist; 0 otherwise. */
33
+ async function consentScore(): Promise<number> {
34
+ const total = await prisma.consentRecord.count({ where: { revokedAt: null } });
35
+ return total > 0 ? 100 : 0;
36
+ }
37
+
38
+ /**
39
+ * DSR pillar: penalises overdue requests.
40
+ * Score = (1 − overdue/total) × 100, clamped to [0, 100].
41
+ */
42
+ async function dsrScore(): Promise<number> {
43
+ const total = await prisma.dSRRequest.count();
44
+ if (total === 0) return 100;
45
+
46
+ const overdue = await prisma.dSRRequest.count({
47
+ where: {
48
+ status: { in: ['pending', 'in_progress'] },
49
+ dueAt: { lt: new Date() },
50
+ },
51
+ });
52
+
53
+ return Math.max(0, Math.round((1 - overdue / total) * 100));
54
+ }
55
+
56
+ /**
57
+ * Breach pillar: percentage of mature incidents (> 72 hours old) with NDPC notified.
58
+ * New breaches inside the 72-hour window are excluded from the calculation.
59
+ */
60
+ async function breachScore(): Promise<number> {
61
+ const cutoff = new Date(Date.now() - 72 * 60 * 60 * 1000);
62
+ const totalMature = await prisma.breachReport.count({ where: { reportedAt: { lt: cutoff } } });
63
+ if (totalMature === 0) return 100;
64
+
65
+ const notified = await prisma.breachReport.count({
66
+ where: { reportedAt: { lt: cutoff }, ndpcNotificationSent: true },
67
+ });
68
+
69
+ return Math.round((notified / totalMature) * 100);
70
+ }
71
+
72
+ /** ROPA pillar: 100 if at least one active processing record exists; 0 otherwise. */
73
+ async function ropaScore(): Promise<number> {
74
+ const active = await prisma.processingRecord.count({ where: { status: 'active' } });
75
+ return active > 0 ? 100 : 0;
76
+ }
77
+
78
+ /** Audit trail pillar: 100 if there has been audit activity in the last 30 days. */
79
+ async function auditScore(): Promise<number> {
80
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
81
+ const recent = await prisma.complianceAuditLog.count({
82
+ where: { createdAt: { gte: thirtyDaysAgo } },
83
+ });
84
+ return recent > 0 ? 100 : 0;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // GET /compliance
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Return the overall NDPA compliance score and per-pillar breakdown.
93
+ *
94
+ * The overall score is the arithmetic mean of the five pillar scores.
95
+ * A score of 100 means all five pillars are fully compliant; lower scores
96
+ * indicate specific areas that need attention.
97
+ *
98
+ * Returns 200 with:
99
+ * {
100
+ * overall: number, — aggregate score (0–100)
101
+ * pillars: {
102
+ * consent: number,
103
+ * dsr: number,
104
+ * breach: number,
105
+ * ropa: number,
106
+ * audit: number,
107
+ * },
108
+ * calculatedAt: string, — ISO timestamp
109
+ * }
110
+ */
111
+ complianceRouter.get('/', async (_req, res) => {
112
+ const [consent, dsr, breach, ropa, audit] = await Promise.all([
113
+ consentScore(),
114
+ dsrScore(),
115
+ breachScore(),
116
+ ropaScore(),
117
+ auditScore(),
118
+ ]);
119
+
120
+ const pillars = { consent, dsr, breach, ropa, audit };
121
+ const overall = Math.round(
122
+ Object.values(pillars).reduce((sum, v) => sum + v, 0) / Object.keys(pillars).length,
123
+ );
124
+
125
+ return res.json({
126
+ overall,
127
+ pillars,
128
+ calculatedAt: new Date().toISOString(),
129
+ });
130
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Express — Consent Router
3
+ *
4
+ * GET, POST, and DELETE handlers for data subject consent records as required
5
+ * by NDPA Section 25 (lawful basis of processing) and Section 26 (right to
6
+ * withdraw consent). Mirrors the Next.js App Router consent route in logic.
7
+ *
8
+ * Routes
9
+ * ------
10
+ * GET /consent?subjectId=xxx — Load the active consent record
11
+ * POST /consent — Save new consent (revokes previous)
12
+ * DELETE /consent?subjectId=xxx — Revoke all active consent
13
+ *
14
+ * How to use
15
+ * ----------
16
+ * Mount this router in your Express app (see `../index.ts`):
17
+ *
18
+ * import { createNDPRRouter } from '@tantainnovative/ndpr-recipes/express';
19
+ * app.use('/ndpr', createNDPRRouter());
20
+ *
21
+ * @module express/routes/consent
22
+ */
23
+
24
+ import { Router } from 'express';
25
+ import { PrismaClient } from '@prisma/client';
26
+
27
+ const prisma = new PrismaClient();
28
+ export const consentRouter = Router();
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // GET /consent?subjectId=xxx
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Load the most recent active (non-revoked) consent record for a data subject.
36
+ *
37
+ * NDPA Section 25 requires evidence of consent to be retained. This endpoint
38
+ * lets your application verify existing consent before processing personal data.
39
+ *
40
+ * Query params:
41
+ * subjectId (required) — stable identifier for the data subject
42
+ *
43
+ * Returns 200 with the ConsentRecord, or 200 `null` if none exists.
44
+ */
45
+ consentRouter.get('/', async (req, res) => {
46
+ const { subjectId } = req.query;
47
+
48
+ if (!subjectId || typeof subjectId !== 'string') {
49
+ return res.status(400).json({ error: 'subjectId required' });
50
+ }
51
+
52
+ const record = await prisma.consentRecord.findFirst({
53
+ where: { subjectId, revokedAt: null },
54
+ orderBy: { createdAt: 'desc' },
55
+ });
56
+
57
+ return res.json(record);
58
+ });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // POST /consent
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Persist a new consent decision for a data subject.
66
+ *
67
+ * Follows the immutable-audit pattern: any active record is soft-revoked
68
+ * before the new one is inserted, preserving full consent history as required
69
+ * by the NDPA accountability principle (Section 44).
70
+ *
71
+ * Body (JSON):
72
+ * subjectId (required) — stable subject identifier
73
+ * consents (required) — map of consent category → boolean
74
+ * version (required) — consent policy version string
75
+ * method (optional) — capture method (default: 'api')
76
+ * lawfulBasis (optional) — e.g. 'consent', 'legitimate_interests'
77
+ *
78
+ * Returns 201 with the newly created ConsentRecord.
79
+ */
80
+ consentRouter.post('/', async (req, res) => {
81
+ const { subjectId, consents, version, method, lawfulBasis } = req.body;
82
+
83
+ if (!subjectId || !consents || !version) {
84
+ return res
85
+ .status(400)
86
+ .json({ error: 'subjectId, consents, and version are required' });
87
+ }
88
+
89
+ // Revoke any previously active consent records (immutable-audit pattern).
90
+ await prisma.consentRecord.updateMany({
91
+ where: { subjectId, revokedAt: null },
92
+ data: { revokedAt: new Date() },
93
+ });
94
+
95
+ // Insert new record, capturing request metadata for compliance evidence.
96
+ const record = await prisma.consentRecord.create({
97
+ data: {
98
+ subjectId,
99
+ consents,
100
+ version,
101
+ method: method ?? 'api',
102
+ lawfulBasis: lawfulBasis ?? null,
103
+ ipAddress:
104
+ (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ??
105
+ req.socket.remoteAddress ??
106
+ null,
107
+ userAgent: req.headers['user-agent'] ?? null,
108
+ },
109
+ });
110
+
111
+ // Audit log for NDPA Section 44 accountability.
112
+ await prisma.complianceAuditLog.create({
113
+ data: {
114
+ module: 'consent',
115
+ action: 'created',
116
+ entityId: record.id,
117
+ entityType: 'ConsentRecord',
118
+ changes: { subjectId, version, consents },
119
+ },
120
+ });
121
+
122
+ return res.status(201).json(record);
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // DELETE /consent?subjectId=xxx
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Revoke all active consent records for a data subject.
131
+ *
132
+ * NDPA Section 26 grants the right to withdraw consent at any time.
133
+ * Records are soft-revoked (revokedAt set) rather than deleted to preserve
134
+ * the audit trail for regulatory inspection.
135
+ *
136
+ * Query params:
137
+ * subjectId (required) — stable identifier for the data subject
138
+ *
139
+ * Returns 200 `{ success: true }` when complete.
140
+ */
141
+ consentRouter.delete('/', async (req, res) => {
142
+ const { subjectId } = req.query;
143
+
144
+ if (!subjectId || typeof subjectId !== 'string') {
145
+ return res.status(400).json({ error: 'subjectId required' });
146
+ }
147
+
148
+ await prisma.consentRecord.updateMany({
149
+ where: { subjectId, revokedAt: null },
150
+ data: { revokedAt: new Date() },
151
+ });
152
+
153
+ await prisma.complianceAuditLog.create({
154
+ data: {
155
+ module: 'consent',
156
+ action: 'revoked',
157
+ entityId: subjectId,
158
+ entityType: 'ConsentRecord',
159
+ },
160
+ });
161
+
162
+ return res.json({ success: true });
163
+ });