@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.
- package/README.md +408 -0
- package/package.json +25 -0
- package/prisma/schema.prisma +104 -0
- package/src/adapters/drizzle-consent.ts +135 -0
- package/src/adapters/drizzle-dsr.ts +190 -0
- package/src/adapters/index.ts +39 -0
- package/src/adapters/prisma-breach.ts +181 -0
- package/src/adapters/prisma-consent.ts +129 -0
- package/src/adapters/prisma-dsr.ts +163 -0
- package/src/adapters/prisma-ropa.ts +205 -0
- package/src/drizzle/schema.ts +484 -0
- package/src/express/index.ts +82 -0
- package/src/express/middleware/consent-check.ts +133 -0
- package/src/express/routes/breach.ts +259 -0
- package/src/express/routes/compliance.ts +130 -0
- package/src/express/routes/consent.ts +163 -0
- package/src/express/routes/dsr.ts +203 -0
- package/src/express/routes/ropa.ts +225 -0
- package/src/nextjs/app-router/api/breach/[id]/route.ts +121 -0
- package/src/nextjs/app-router/api/breach/route.ts +182 -0
- package/src/nextjs/app-router/api/compliance/route.ts +162 -0
- package/src/nextjs/app-router/api/consent/route.ts +161 -0
- package/src/nextjs/app-router/api/dsr/[id]/route.ts +115 -0
- package/src/nextjs/app-router/api/dsr/route.ts +128 -0
- package/src/nextjs/app-router/api/ropa/route.ts +234 -0
- package/src/nextjs/app-router/layout-example.tsx +178 -0
- package/src/nextjs/app-router/middleware.ts +113 -0
|
@@ -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
|
+
});
|