@tantainnovative/create-ndpr 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 +130 -0
- package/bin/index.mjs +503 -0
- package/package.json +31 -0
- package/templates/drizzle-schema.ts +163 -0
- package/templates/env-example +4 -0
- package/templates/express-consent-route.ts +105 -0
- package/templates/express-setup.ts +250 -0
- package/templates/nextjs-breach-route.ts +107 -0
- package/templates/nextjs-consent-route.ts +100 -0
- package/templates/nextjs-dsr-route.ts +80 -0
- package/templates/nextjs-layout.tsx +64 -0
- package/templates/prisma-schema.prisma +117 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NDPA Compliance Schema — generated by create-ndpr
|
|
3
|
+
* Organisation: {{ORG_NAME}}
|
|
4
|
+
* DPO: {{DPO_EMAIL}}
|
|
5
|
+
*
|
|
6
|
+
* Covers:
|
|
7
|
+
* - consentRecords — NDPA §25–26 (consent & withdrawal)
|
|
8
|
+
* - dsrRequests — NDPA Part IV §34–38 (data subject rights)
|
|
9
|
+
* - breachReports — NDPA §40 (72-hour breach notification)
|
|
10
|
+
* - processingRecords — ROPA (accountability principle)
|
|
11
|
+
* - auditLog — §44 audit trail
|
|
12
|
+
*
|
|
13
|
+
* Prerequisites: drizzle-orm, @paralleldrive/cuid2
|
|
14
|
+
* Run `drizzle-kit push` to apply.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
pgTable,
|
|
19
|
+
text,
|
|
20
|
+
timestamp,
|
|
21
|
+
json,
|
|
22
|
+
boolean,
|
|
23
|
+
integer,
|
|
24
|
+
index,
|
|
25
|
+
} from 'drizzle-orm/pg-core';
|
|
26
|
+
import { createId } from '@paralleldrive/cuid2';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// ndpr_consent_records — NDPA §25–26
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export const consentRecords = pgTable(
|
|
33
|
+
'ndpr_consent_records',
|
|
34
|
+
{
|
|
35
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
36
|
+
subjectId: text('subject_id').notNull(),
|
|
37
|
+
consents: json('consents').notNull(),
|
|
38
|
+
version: text('version').notNull(),
|
|
39
|
+
method: text('method').notNull(),
|
|
40
|
+
lawfulBasis: text('lawful_basis'),
|
|
41
|
+
ipAddress: text('ip_address'),
|
|
42
|
+
userAgent: text('user_agent'),
|
|
43
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
44
|
+
revokedAt: timestamp('revoked_at'),
|
|
45
|
+
},
|
|
46
|
+
(t) => ({ subjectIdIdx: index('consent_subject_id_idx').on(t.subjectId) }),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// ndpr_dsr_requests — NDPA §34–38
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export const dsrRequests = pgTable(
|
|
54
|
+
'ndpr_dsr_requests',
|
|
55
|
+
{
|
|
56
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
57
|
+
type: text('type').notNull(),
|
|
58
|
+
status: text('status').notNull().default('pending'),
|
|
59
|
+
subjectName: text('subject_name').notNull(),
|
|
60
|
+
subjectEmail: text('subject_email').notNull(),
|
|
61
|
+
subjectPhone: text('subject_phone'),
|
|
62
|
+
identifierType: text('identifier_type').notNull(),
|
|
63
|
+
identifierValue: text('identifier_value').notNull(),
|
|
64
|
+
description: text('description'),
|
|
65
|
+
internalNotes: text('internal_notes'),
|
|
66
|
+
assignedTo: text('assigned_to'),
|
|
67
|
+
submittedAt: timestamp('submitted_at').defaultNow().notNull(),
|
|
68
|
+
acknowledgedAt: timestamp('acknowledged_at'),
|
|
69
|
+
completedAt: timestamp('completed_at'),
|
|
70
|
+
dueAt: timestamp('due_at').notNull(),
|
|
71
|
+
},
|
|
72
|
+
(t) => ({
|
|
73
|
+
statusIdx: index('dsr_status_idx').on(t.status),
|
|
74
|
+
subjectEmailIdx: index('dsr_subject_email_idx').on(t.subjectEmail),
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// ndpr_breach_reports — NDPA §40
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
export const breachReports = pgTable(
|
|
83
|
+
'ndpr_breach_reports',
|
|
84
|
+
{
|
|
85
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
86
|
+
title: text('title').notNull(),
|
|
87
|
+
description: text('description').notNull(),
|
|
88
|
+
category: text('category').notNull(),
|
|
89
|
+
severity: text('severity').notNull(),
|
|
90
|
+
status: text('status').notNull().default('ongoing'),
|
|
91
|
+
discoveredAt: timestamp('discovered_at').notNull(),
|
|
92
|
+
occurredAt: timestamp('occurred_at'),
|
|
93
|
+
reportedAt: timestamp('reported_at').defaultNow().notNull(),
|
|
94
|
+
ndpcNotifiedAt: timestamp('ndpc_notified_at'),
|
|
95
|
+
reporterName: text('reporter_name').notNull(),
|
|
96
|
+
reporterEmail: text('reporter_email').notNull(),
|
|
97
|
+
reporterDepartment: text('reporter_department'),
|
|
98
|
+
affectedSystems: json('affected_systems').notNull(),
|
|
99
|
+
dataTypes: json('data_types').notNull(),
|
|
100
|
+
estimatedAffected: integer('estimated_affected'),
|
|
101
|
+
initialActions: text('initial_actions'),
|
|
102
|
+
ndpcNotificationSent: boolean('ndpc_notification_sent').notNull().default(false),
|
|
103
|
+
},
|
|
104
|
+
(t) => ({
|
|
105
|
+
statusIdx: index('breach_status_idx').on(t.status),
|
|
106
|
+
severityIdx: index('breach_severity_idx').on(t.severity),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// ndpr_processing_records — ROPA
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export const processingRecords = pgTable('ndpr_processing_records', {
|
|
115
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
116
|
+
purpose: text('purpose').notNull(),
|
|
117
|
+
lawfulBasis: text('lawful_basis').notNull(),
|
|
118
|
+
dataCategories: json('data_categories').notNull(),
|
|
119
|
+
dataSubjects: json('data_subjects').notNull(),
|
|
120
|
+
recipients: json('recipients').notNull(),
|
|
121
|
+
retentionPeriod: text('retention_period').notNull(),
|
|
122
|
+
securityMeasures: json('security_measures').notNull(),
|
|
123
|
+
transferCountries: json('transfer_countries'),
|
|
124
|
+
transferMechanism: text('transfer_mechanism'),
|
|
125
|
+
dpiaConducted: boolean('dpia_conducted').notNull().default(false),
|
|
126
|
+
status: text('status').notNull().default('active'),
|
|
127
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
128
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// ndpr_audit_log — §44 accountability
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
export const auditLog = pgTable(
|
|
136
|
+
'ndpr_audit_log',
|
|
137
|
+
{
|
|
138
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
139
|
+
module: text('module').notNull(),
|
|
140
|
+
action: text('action').notNull(),
|
|
141
|
+
entityId: text('entity_id').notNull(),
|
|
142
|
+
entityType: text('entity_type').notNull(),
|
|
143
|
+
changes: json('changes'),
|
|
144
|
+
performedBy: text('performed_by'),
|
|
145
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
146
|
+
},
|
|
147
|
+
(t) => ({
|
|
148
|
+
moduleEntityIdx: index('audit_module_entity_idx').on(t.module, t.entityId),
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Inferred types
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
export type ConsentRecord = typeof consentRecords.$inferSelect;
|
|
157
|
+
export type NewConsentRecord = typeof consentRecords.$inferInsert;
|
|
158
|
+
export type DSRRequest = typeof dsrRequests.$inferSelect;
|
|
159
|
+
export type NewDSRRequest = typeof dsrRequests.$inferInsert;
|
|
160
|
+
export type BreachReport = typeof breachReports.$inferSelect;
|
|
161
|
+
export type NewBreachReport = typeof breachReports.$inferInsert;
|
|
162
|
+
export type ProcessingRecord = typeof processingRecords.$inferSelect;
|
|
163
|
+
export type AuditLogEntry = typeof auditLog.$inferSelect;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express — Consent Router
|
|
3
|
+
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
|
+
* DPO: {{DPO_EMAIL}}
|
|
5
|
+
*
|
|
6
|
+
* NDPA §25 (lawful basis) and §26 (right to withdraw consent).
|
|
7
|
+
*
|
|
8
|
+
* Routes:
|
|
9
|
+
* GET /consent?subjectId=xxx — Load the active consent record
|
|
10
|
+
* POST /consent — Save new consent (revokes previous)
|
|
11
|
+
* DELETE /consent?subjectId=xxx — Revoke all active consent
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* import { consentRouter } from './routes/consent';
|
|
15
|
+
* app.use('/api/consent', consentRouter);
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Router } from 'express';
|
|
19
|
+
import { PrismaClient } from '@prisma/client';
|
|
20
|
+
|
|
21
|
+
const prisma = new PrismaClient();
|
|
22
|
+
export const consentRouter = Router();
|
|
23
|
+
|
|
24
|
+
// GET /consent?subjectId=xxx
|
|
25
|
+
consentRouter.get('/', async (req, res) => {
|
|
26
|
+
const { subjectId } = req.query;
|
|
27
|
+
|
|
28
|
+
if (!subjectId || typeof subjectId !== 'string') {
|
|
29
|
+
return res.status(400).json({ error: 'subjectId required' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const record = await prisma.consentRecord.findFirst({
|
|
33
|
+
where: { subjectId, revokedAt: null },
|
|
34
|
+
orderBy: { createdAt: 'desc' },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return res.json(record);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// POST /consent
|
|
41
|
+
consentRouter.post('/', async (req, res) => {
|
|
42
|
+
const { subjectId, consents, version, method, lawfulBasis } = req.body;
|
|
43
|
+
|
|
44
|
+
if (!subjectId || !consents || !version) {
|
|
45
|
+
return res.status(400).json({ error: 'subjectId, consents, and version are required' });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Revoke any previously active records (immutable-audit pattern — NDPA §44).
|
|
49
|
+
await prisma.consentRecord.updateMany({
|
|
50
|
+
where: { subjectId, revokedAt: null },
|
|
51
|
+
data: { revokedAt: new Date() },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const record = await prisma.consentRecord.create({
|
|
55
|
+
data: {
|
|
56
|
+
subjectId,
|
|
57
|
+
consents,
|
|
58
|
+
version,
|
|
59
|
+
method: method ?? 'api',
|
|
60
|
+
lawfulBasis: lawfulBasis ?? null,
|
|
61
|
+
ipAddress:
|
|
62
|
+
(req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ??
|
|
63
|
+
req.socket.remoteAddress ??
|
|
64
|
+
null,
|
|
65
|
+
userAgent: req.headers['user-agent'] ?? null,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await prisma.complianceAuditLog.create({
|
|
70
|
+
data: {
|
|
71
|
+
module: 'consent',
|
|
72
|
+
action: 'created',
|
|
73
|
+
entityId: record.id,
|
|
74
|
+
entityType: 'ConsentRecord',
|
|
75
|
+
changes: { subjectId, version, consents },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return res.status(201).json(record);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// DELETE /consent?subjectId=xxx
|
|
83
|
+
consentRouter.delete('/', async (req, res) => {
|
|
84
|
+
const { subjectId } = req.query;
|
|
85
|
+
|
|
86
|
+
if (!subjectId || typeof subjectId !== 'string') {
|
|
87
|
+
return res.status(400).json({ error: 'subjectId required' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await prisma.consentRecord.updateMany({
|
|
91
|
+
where: { subjectId, revokedAt: null },
|
|
92
|
+
data: { revokedAt: new Date() },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await prisma.complianceAuditLog.create({
|
|
96
|
+
data: {
|
|
97
|
+
module: 'consent',
|
|
98
|
+
action: 'revoked',
|
|
99
|
+
entityId: subjectId,
|
|
100
|
+
entityType: 'ConsentRecord',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return res.json({ success: true });
|
|
105
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express — NDPR Compliance Router
|
|
3
|
+
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
|
+
* DPO: {{DPO_EMAIL}}
|
|
5
|
+
*
|
|
6
|
+
* Mount this router in your Express app:
|
|
7
|
+
*
|
|
8
|
+
* import express from 'express';
|
|
9
|
+
* import cookieParser from 'cookie-parser';
|
|
10
|
+
* import { createNDPRRouter } from './src/ndpr';
|
|
11
|
+
*
|
|
12
|
+
* const app = express();
|
|
13
|
+
* app.use(express.json());
|
|
14
|
+
* app.use(cookieParser());
|
|
15
|
+
* app.use('/api/ndpr', createNDPRRouter());
|
|
16
|
+
*
|
|
17
|
+
* Routes exposed:
|
|
18
|
+
* GET/POST/DELETE /api/ndpr/consent — NDPA §25–26
|
|
19
|
+
* GET/POST /api/ndpr/dsr — NDPA §34–38
|
|
20
|
+
* GET/POST /api/ndpr/breach — NDPA §40
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Router } from 'express';
|
|
24
|
+
import { PrismaClient } from '@prisma/client';
|
|
25
|
+
|
|
26
|
+
const prisma = new PrismaClient();
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Consent router — NDPA §25–26
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function consentRouter(): Router {
|
|
33
|
+
const router = Router();
|
|
34
|
+
|
|
35
|
+
// GET /consent?subjectId=xxx
|
|
36
|
+
router.get('/', async (req, res) => {
|
|
37
|
+
const { subjectId } = req.query;
|
|
38
|
+
if (!subjectId || typeof subjectId !== 'string') {
|
|
39
|
+
return res.status(400).json({ error: 'subjectId required' });
|
|
40
|
+
}
|
|
41
|
+
const record = await prisma.consentRecord.findFirst({
|
|
42
|
+
where: { subjectId, revokedAt: null },
|
|
43
|
+
orderBy: { createdAt: 'desc' },
|
|
44
|
+
});
|
|
45
|
+
return res.json(record);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// POST /consent
|
|
49
|
+
router.post('/', async (req, res) => {
|
|
50
|
+
const { subjectId, consents, version, method, lawfulBasis } = req.body;
|
|
51
|
+
if (!subjectId || !consents || !version) {
|
|
52
|
+
return res.status(400).json({ error: 'subjectId, consents, and version are required' });
|
|
53
|
+
}
|
|
54
|
+
await prisma.consentRecord.updateMany({
|
|
55
|
+
where: { subjectId, revokedAt: null },
|
|
56
|
+
data: { revokedAt: new Date() },
|
|
57
|
+
});
|
|
58
|
+
const record = await prisma.consentRecord.create({
|
|
59
|
+
data: {
|
|
60
|
+
subjectId,
|
|
61
|
+
consents,
|
|
62
|
+
version,
|
|
63
|
+
method: method ?? 'api',
|
|
64
|
+
lawfulBasis: lawfulBasis ?? null,
|
|
65
|
+
ipAddress:
|
|
66
|
+
(req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ??
|
|
67
|
+
req.socket.remoteAddress ??
|
|
68
|
+
null,
|
|
69
|
+
userAgent: req.headers['user-agent'] ?? null,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
await prisma.complianceAuditLog.create({
|
|
73
|
+
data: {
|
|
74
|
+
module: 'consent',
|
|
75
|
+
action: 'created',
|
|
76
|
+
entityId: record.id,
|
|
77
|
+
entityType: 'ConsentRecord',
|
|
78
|
+
changes: { subjectId, version, consents },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
return res.status(201).json(record);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// DELETE /consent?subjectId=xxx
|
|
85
|
+
router.delete('/', async (req, res) => {
|
|
86
|
+
const { subjectId } = req.query;
|
|
87
|
+
if (!subjectId || typeof subjectId !== 'string') {
|
|
88
|
+
return res.status(400).json({ error: 'subjectId required' });
|
|
89
|
+
}
|
|
90
|
+
await prisma.consentRecord.updateMany({
|
|
91
|
+
where: { subjectId, revokedAt: null },
|
|
92
|
+
data: { revokedAt: new Date() },
|
|
93
|
+
});
|
|
94
|
+
await prisma.complianceAuditLog.create({
|
|
95
|
+
data: {
|
|
96
|
+
module: 'consent',
|
|
97
|
+
action: 'revoked',
|
|
98
|
+
entityId: subjectId,
|
|
99
|
+
entityType: 'ConsentRecord',
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
return res.json({ success: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return router;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// DSR router — NDPA §34–38
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function dsrRouter(): Router {
|
|
113
|
+
const router = Router();
|
|
114
|
+
|
|
115
|
+
// GET /dsr?status=pending
|
|
116
|
+
router.get('/', async (req, res) => {
|
|
117
|
+
const { status } = req.query;
|
|
118
|
+
const requests = await prisma.dSRRequest.findMany({
|
|
119
|
+
where: status && typeof status === 'string' ? { status } : undefined,
|
|
120
|
+
orderBy: { submittedAt: 'desc' },
|
|
121
|
+
});
|
|
122
|
+
return res.json(requests);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// POST /dsr
|
|
126
|
+
router.post('/', async (req, res) => {
|
|
127
|
+
const { type, subjectName, subjectEmail, subjectPhone, identifierType, identifierValue, description } =
|
|
128
|
+
req.body;
|
|
129
|
+
if (!type || !subjectName || !subjectEmail || !identifierType || !identifierValue) {
|
|
130
|
+
return res
|
|
131
|
+
.status(400)
|
|
132
|
+
.json({ error: 'type, subjectName, subjectEmail, identifierType, and identifierValue are required' });
|
|
133
|
+
}
|
|
134
|
+
const dueAt = new Date();
|
|
135
|
+
dueAt.setDate(dueAt.getDate() + 30);
|
|
136
|
+
const request = await prisma.dSRRequest.create({
|
|
137
|
+
data: {
|
|
138
|
+
type,
|
|
139
|
+
subjectName,
|
|
140
|
+
subjectEmail,
|
|
141
|
+
subjectPhone: subjectPhone ?? null,
|
|
142
|
+
identifierType,
|
|
143
|
+
identifierValue,
|
|
144
|
+
description: description ?? null,
|
|
145
|
+
status: 'pending',
|
|
146
|
+
dueAt,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
await prisma.complianceAuditLog.create({
|
|
150
|
+
data: {
|
|
151
|
+
module: 'dsr',
|
|
152
|
+
action: 'submitted',
|
|
153
|
+
entityId: request.id,
|
|
154
|
+
entityType: 'DSRRequest',
|
|
155
|
+
changes: { type, subjectEmail, status: 'pending' },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
return res.status(201).json(request);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return router;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Breach router — NDPA §40
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function breachRouter(): Router {
|
|
169
|
+
const router = Router();
|
|
170
|
+
|
|
171
|
+
function calculateSeverity(category: string, estimatedAffected?: number) {
|
|
172
|
+
const highRisk = ['unauthorized_access', 'ransomware', 'data_exfiltration', 'identity_theft'];
|
|
173
|
+
if (highRisk.includes(category)) return (estimatedAffected ?? 0) > 1000 ? 'critical' : 'high';
|
|
174
|
+
if ((estimatedAffected ?? 0) > 500) return 'high';
|
|
175
|
+
if ((estimatedAffected ?? 0) > 50) return 'medium';
|
|
176
|
+
return 'low';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// GET /breach?status=ongoing
|
|
180
|
+
router.get('/', async (req, res) => {
|
|
181
|
+
const { status } = req.query;
|
|
182
|
+
const reports = await prisma.breachReport.findMany({
|
|
183
|
+
where: status && typeof status === 'string' ? { status } : undefined,
|
|
184
|
+
orderBy: { reportedAt: 'desc' },
|
|
185
|
+
});
|
|
186
|
+
return res.json(reports);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// POST /breach
|
|
190
|
+
router.post('/', async (req, res) => {
|
|
191
|
+
const {
|
|
192
|
+
title, description, category, discoveredAt, occurredAt,
|
|
193
|
+
reporterName, reporterEmail, reporterDepartment,
|
|
194
|
+
affectedSystems, dataTypes, estimatedAffected, initialActions,
|
|
195
|
+
} = req.body;
|
|
196
|
+
|
|
197
|
+
if (!title || !description || !category || !discoveredAt || !reporterName || !reporterEmail) {
|
|
198
|
+
return res.status(400).json({
|
|
199
|
+
error: 'title, description, category, discoveredAt, reporterName, and reporterEmail are required',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(affectedSystems) || !Array.isArray(dataTypes)) {
|
|
203
|
+
return res.status(400).json({ error: 'affectedSystems and dataTypes must be arrays' });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const severity = calculateSeverity(category, estimatedAffected);
|
|
207
|
+
const report = await prisma.breachReport.create({
|
|
208
|
+
data: {
|
|
209
|
+
title, description, category, severity, status: 'ongoing',
|
|
210
|
+
discoveredAt: new Date(discoveredAt),
|
|
211
|
+
occurredAt: occurredAt ? new Date(occurredAt) : null,
|
|
212
|
+
reporterName, reporterEmail,
|
|
213
|
+
reporterDepartment: reporterDepartment ?? null,
|
|
214
|
+
affectedSystems, dataTypes,
|
|
215
|
+
estimatedAffected: estimatedAffected ?? null,
|
|
216
|
+
initialActions: initialActions ?? null,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
await prisma.complianceAuditLog.create({
|
|
220
|
+
data: {
|
|
221
|
+
module: 'breach',
|
|
222
|
+
action: 'reported',
|
|
223
|
+
entityId: report.id,
|
|
224
|
+
entityType: 'BreachReport',
|
|
225
|
+
changes: { title, category, severity, status: 'ongoing' },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
return res.status(201).json(report);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return router;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Main factory
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Returns a fully configured NDPR compliance Express Router.
|
|
240
|
+
*
|
|
241
|
+
* Mount with:
|
|
242
|
+
* app.use('/api/ndpr', createNDPRRouter());
|
|
243
|
+
*/
|
|
244
|
+
export function createNDPRRouter(): Router {
|
|
245
|
+
const router = Router();
|
|
246
|
+
router.use('/consent', consentRouter());
|
|
247
|
+
router.use('/dsr', dsrRouter());
|
|
248
|
+
router.use('/breach', breachRouter());
|
|
249
|
+
return router;
|
|
250
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router — Breach Notification Route
|
|
3
|
+
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
|
+
*
|
|
5
|
+
* NDPA §40 — data controllers must notify the NDPC within 72 hours of
|
|
6
|
+
* discovering a breach that poses a risk to data subject rights and freedoms.
|
|
7
|
+
*
|
|
8
|
+
* Endpoints:
|
|
9
|
+
* GET /api/breach — List breach reports (optional ?status= filter)
|
|
10
|
+
* POST /api/breach — Create a new breach report
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
14
|
+
import { PrismaClient } from '@prisma/client';
|
|
15
|
+
|
|
16
|
+
const prisma = new PrismaClient();
|
|
17
|
+
|
|
18
|
+
function calculateSeverity(
|
|
19
|
+
category: string,
|
|
20
|
+
estimatedAffected?: number,
|
|
21
|
+
): 'critical' | 'high' | 'medium' | 'low' {
|
|
22
|
+
const highRisk = ['unauthorized_access', 'ransomware', 'data_exfiltration', 'identity_theft'];
|
|
23
|
+
if (highRisk.includes(category)) {
|
|
24
|
+
return (estimatedAffected ?? 0) > 1000 ? 'critical' : 'high';
|
|
25
|
+
}
|
|
26
|
+
if ((estimatedAffected ?? 0) > 500) return 'high';
|
|
27
|
+
if ((estimatedAffected ?? 0) > 50) return 'medium';
|
|
28
|
+
return 'low';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// GET /api/breach?status=ongoing
|
|
32
|
+
export async function GET(req: NextRequest) {
|
|
33
|
+
const status = req.nextUrl.searchParams.get('status');
|
|
34
|
+
|
|
35
|
+
const reports = await prisma.breachReport.findMany({
|
|
36
|
+
where: status ? { status } : undefined,
|
|
37
|
+
orderBy: { reportedAt: 'desc' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return NextResponse.json(reports);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// POST /api/breach
|
|
44
|
+
export async function POST(req: NextRequest) {
|
|
45
|
+
const body = await req.json();
|
|
46
|
+
const {
|
|
47
|
+
title,
|
|
48
|
+
description,
|
|
49
|
+
category,
|
|
50
|
+
discoveredAt,
|
|
51
|
+
occurredAt,
|
|
52
|
+
reporterName,
|
|
53
|
+
reporterEmail,
|
|
54
|
+
reporterDepartment,
|
|
55
|
+
affectedSystems,
|
|
56
|
+
dataTypes,
|
|
57
|
+
estimatedAffected,
|
|
58
|
+
initialActions,
|
|
59
|
+
} = body;
|
|
60
|
+
|
|
61
|
+
if (!title || !description || !category || !discoveredAt || !reporterName || !reporterEmail) {
|
|
62
|
+
return NextResponse.json(
|
|
63
|
+
{ error: 'title, description, category, discoveredAt, reporterName, and reporterEmail are required' },
|
|
64
|
+
{ status: 400 },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!Array.isArray(affectedSystems) || !Array.isArray(dataTypes)) {
|
|
69
|
+
return NextResponse.json(
|
|
70
|
+
{ error: 'affectedSystems and dataTypes must be arrays' },
|
|
71
|
+
{ status: 400 },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const severity = calculateSeverity(category, estimatedAffected);
|
|
76
|
+
|
|
77
|
+
const report = await prisma.breachReport.create({
|
|
78
|
+
data: {
|
|
79
|
+
title,
|
|
80
|
+
description,
|
|
81
|
+
category,
|
|
82
|
+
severity,
|
|
83
|
+
status: 'ongoing',
|
|
84
|
+
discoveredAt: new Date(discoveredAt),
|
|
85
|
+
occurredAt: occurredAt ? new Date(occurredAt) : null,
|
|
86
|
+
reporterName,
|
|
87
|
+
reporterEmail,
|
|
88
|
+
reporterDepartment: reporterDepartment ?? null,
|
|
89
|
+
affectedSystems,
|
|
90
|
+
dataTypes,
|
|
91
|
+
estimatedAffected: estimatedAffected ?? null,
|
|
92
|
+
initialActions: initialActions ?? null,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await prisma.complianceAuditLog.create({
|
|
97
|
+
data: {
|
|
98
|
+
module: 'breach',
|
|
99
|
+
action: 'reported',
|
|
100
|
+
entityId: report.id,
|
|
101
|
+
entityType: 'BreachReport',
|
|
102
|
+
changes: { title, category, severity, status: 'ongoing' },
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return NextResponse.json(report, { status: 201 });
|
|
107
|
+
}
|