@tantainnovative/create-ndpr 0.1.0 → 0.5.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 +75 -56
- package/bin/index.mjs +369 -394
- package/package.json +14 -4
- package/templates/drizzle-client.ts +44 -0
- package/templates/drizzle-schema.ts +224 -117
- package/templates/env-example +6 -3
- package/templates/express-breach-route.ts +98 -0
- package/templates/express-consent-route.ts +336 -79
- package/templates/express-cross-border-route.ts +152 -0
- package/templates/express-dpia-route.ts +332 -0
- package/templates/express-dsr-route.ts +76 -0
- package/templates/express-lawful-basis-route.ts +146 -0
- package/templates/express-request-context.ts +130 -0
- package/templates/github-ndpr-audit.yml +31 -0
- package/templates/ndpr-audit.json +14 -0
- package/templates/nextjs-breach-route.ts +145 -79
- package/templates/nextjs-consent-route.ts +343 -70
- package/templates/nextjs-cross-border-route.ts +314 -0
- package/templates/nextjs-dpia-route.ts +510 -0
- package/templates/nextjs-dsr-route.ts +93 -58
- package/templates/nextjs-lawful-basis-route.ts +283 -0
- package/templates/nextjs-layout.tsx +122 -50
- package/templates/nextjs-request-context.ts +137 -0
- package/templates/prisma-schema.prisma +121 -51
- package/templates/express-setup.ts +0 -250
package/package.json
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tantainnovative/create-ndpr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI scaffolder for NDPA compliance with @tantainnovative/ndpr-toolkit",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "CLI scaffolder for Nigeria NDPA 2023 / NDPC GAID 2025 compliance with @tantainnovative/ndpr-toolkit",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Abraham Esandayinze Tanta",
|
|
8
8
|
"url": "https://linkedin.com/in/mr-tanta"
|
|
9
9
|
},
|
|
10
|
+
"homepage": "https://ndprtoolkit.com.ng",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/mr-tanta/ndpr-toolkit.git",
|
|
14
|
+
"directory": "packages/create-ndpr"
|
|
15
|
+
},
|
|
10
16
|
"bin": {
|
|
11
|
-
"create-ndpr": "
|
|
17
|
+
"create-ndpr": "bin/index.mjs"
|
|
12
18
|
},
|
|
13
19
|
"files": [
|
|
14
20
|
"bin/",
|
|
15
|
-
"templates/"
|
|
21
|
+
"templates/",
|
|
22
|
+
"README.md"
|
|
16
23
|
],
|
|
17
24
|
"keywords": [
|
|
18
25
|
"ndpa",
|
|
19
26
|
"ndpr",
|
|
27
|
+
"gaid-2025",
|
|
28
|
+
"dcpmi",
|
|
29
|
+
"compliance-audit-return",
|
|
20
30
|
"cli",
|
|
21
31
|
"scaffold",
|
|
22
32
|
"nigeria",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NDPR Drizzle integration seam.
|
|
3
|
+
*
|
|
4
|
+
* Call configureNDPRDatabase(yourDrizzleInstance) once during server startup.
|
|
5
|
+
* This package intentionally does not choose a PostgreSQL driver or open a
|
|
6
|
+
* connection for the host application.
|
|
7
|
+
*/
|
|
8
|
+
export interface NDPRDrizzleTransactionConfig {
|
|
9
|
+
isolationLevel?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable';
|
|
10
|
+
accessMode?: 'read only' | 'read write';
|
|
11
|
+
deferrable?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface NDPRDrizzleDatabase {
|
|
15
|
+
select(...args: any[]): any;
|
|
16
|
+
insert(...args: any[]): any;
|
|
17
|
+
update(...args: any[]): any;
|
|
18
|
+
delete(...args: any[]): any;
|
|
19
|
+
transaction<T>(
|
|
20
|
+
callback: (tx: NDPRDrizzleDatabase) => Promise<T>,
|
|
21
|
+
config?: NDPRDrizzleTransactionConfig,
|
|
22
|
+
): Promise<T>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let configuredDatabase: NDPRDrizzleDatabase | null = null;
|
|
26
|
+
|
|
27
|
+
export function configureNDPRDatabase(database: unknown): void {
|
|
28
|
+
if (!database || typeof database !== 'object') {
|
|
29
|
+
throw new TypeError('configureNDPRDatabase requires a Drizzle database instance');
|
|
30
|
+
}
|
|
31
|
+
configuredDatabase = database as NDPRDrizzleDatabase;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const db = new Proxy({} as NDPRDrizzleDatabase, {
|
|
35
|
+
get(_target, property) {
|
|
36
|
+
if (!configuredDatabase) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'NDPR Drizzle database is not configured. Call configureNDPRDatabase(db) during server startup.',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const value = Reflect.get(configuredDatabase, property);
|
|
42
|
+
return typeof value === 'function' ? value.bind(configuredDatabase) : value;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -1,19 +1,3 @@
|
|
|
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
1
|
import {
|
|
18
2
|
pgTable,
|
|
19
3
|
text,
|
|
@@ -22,142 +6,265 @@ import {
|
|
|
22
6
|
boolean,
|
|
23
7
|
integer,
|
|
24
8
|
index,
|
|
9
|
+
uniqueIndex,
|
|
25
10
|
} from 'drizzle-orm/pg-core';
|
|
26
11
|
import { createId } from '@paralleldrive/cuid2';
|
|
27
12
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
13
|
+
/**
|
|
14
|
+
* NDPA persistence schema for {{ORG_NAME_COMMENT}}.
|
|
15
|
+
* Every operational and audit row carries a required server-resolved tenantId.
|
|
16
|
+
*/
|
|
31
17
|
|
|
32
18
|
export const consentRecords = pgTable(
|
|
33
19
|
'ndpr_consent_records',
|
|
34
20
|
{
|
|
35
|
-
id:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
21
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
22
|
+
tenantId: text('tenant_id').notNull(),
|
|
23
|
+
subjectId: text('subject_id').notNull(),
|
|
24
|
+
activeSubjectKey: text('active_subject_key'),
|
|
25
|
+
consents: json('consents').notNull(),
|
|
26
|
+
version: text('version').notNull(),
|
|
27
|
+
method: text('method').notNull(),
|
|
28
|
+
hasInteracted: boolean('has_interacted').notNull(),
|
|
29
|
+
lawfulBasis: text('lawful_basis'),
|
|
30
|
+
ipAddress: text('ip_address'),
|
|
31
|
+
userAgent: text('user_agent'),
|
|
32
|
+
clientTimestamp: timestamp('client_timestamp').notNull(),
|
|
33
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
34
|
+
revokedAt: timestamp('revoked_at'),
|
|
45
35
|
},
|
|
46
|
-
(
|
|
36
|
+
(table) => ({
|
|
37
|
+
activeSubjectKeyUnique: uniqueIndex('consent_active_subject_key').on(
|
|
38
|
+
table.activeSubjectKey,
|
|
39
|
+
),
|
|
40
|
+
tenantSubjectIdx: index('consent_tenant_subject_idx').on(
|
|
41
|
+
table.tenantId,
|
|
42
|
+
table.subjectId,
|
|
43
|
+
table.revokedAt,
|
|
44
|
+
),
|
|
45
|
+
replayIdx: index('consent_replay_idx').on(
|
|
46
|
+
table.tenantId,
|
|
47
|
+
table.subjectId,
|
|
48
|
+
table.clientTimestamp,
|
|
49
|
+
table.version,
|
|
50
|
+
table.method,
|
|
51
|
+
),
|
|
52
|
+
}),
|
|
47
53
|
);
|
|
48
54
|
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
// ndpr_dsr_requests — NDPA §34–38
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
|
|
53
55
|
export const dsrRequests = pgTable(
|
|
54
56
|
'ndpr_dsr_requests',
|
|
55
57
|
{
|
|
56
|
-
id:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
59
|
+
tenantId: text('tenant_id').notNull(),
|
|
60
|
+
subjectId: text('subject_id').notNull(),
|
|
61
|
+
type: text('type').notNull(),
|
|
62
|
+
status: text('status').notNull().default('pending'),
|
|
63
|
+
subjectName: text('subject_name').notNull(),
|
|
64
|
+
subjectEmail: text('subject_email').notNull(),
|
|
65
|
+
subjectPhone: text('subject_phone'),
|
|
66
|
+
identifierType: text('identifier_type').notNull(),
|
|
63
67
|
identifierValue: text('identifier_value').notNull(),
|
|
64
|
-
description:
|
|
65
|
-
internalNotes:
|
|
66
|
-
assignedTo:
|
|
67
|
-
submittedAt:
|
|
68
|
-
acknowledgedAt:
|
|
69
|
-
completedAt:
|
|
70
|
-
dueAt:
|
|
68
|
+
description: text('description'),
|
|
69
|
+
internalNotes: text('internal_notes'),
|
|
70
|
+
assignedTo: text('assigned_to'),
|
|
71
|
+
submittedAt: timestamp('submitted_at').defaultNow().notNull(),
|
|
72
|
+
acknowledgedAt: timestamp('acknowledged_at'),
|
|
73
|
+
completedAt: timestamp('completed_at'),
|
|
74
|
+
dueAt: timestamp('due_at').notNull(),
|
|
71
75
|
},
|
|
72
|
-
(
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
(table) => ({
|
|
77
|
+
tenantStatusIdx: index('dsr_tenant_status_idx').on(table.tenantId, table.status),
|
|
78
|
+
tenantSubjectIdx: index('dsr_tenant_subject_idx').on(table.tenantId, table.subjectId),
|
|
79
|
+
tenantEmailIdx: index('dsr_tenant_email_idx').on(table.tenantId, table.subjectEmail),
|
|
75
80
|
}),
|
|
76
81
|
);
|
|
77
82
|
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// ndpr_breach_reports — NDPA §40
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
83
|
export const breachReports = pgTable(
|
|
83
84
|
'ndpr_breach_reports',
|
|
84
85
|
{
|
|
85
|
-
id:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
86
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
87
|
+
tenantId: text('tenant_id').notNull(),
|
|
88
|
+
title: text('title').notNull(),
|
|
89
|
+
description: text('description').notNull(),
|
|
90
|
+
category: text('category').notNull(),
|
|
91
|
+
severity: text('severity').notNull(),
|
|
92
|
+
status: text('status').notNull().default('ongoing'),
|
|
93
|
+
discoveredAt: timestamp('discovered_at').notNull(),
|
|
94
|
+
occurredAt: timestamp('occurred_at'),
|
|
95
|
+
reportedAt: timestamp('reported_at').defaultNow().notNull(),
|
|
96
|
+
ndpcNotifiedAt: timestamp('ndpc_notified_at'),
|
|
97
|
+
reporterName: text('reporter_name').notNull(),
|
|
98
|
+
reporterEmail: text('reporter_email').notNull(),
|
|
99
|
+
reporterDepartment: text('reporter_department'),
|
|
100
|
+
affectedSystems: json('affected_systems').notNull(),
|
|
101
|
+
dataTypes: json('data_types').notNull(),
|
|
102
|
+
involvesSensitiveData: boolean('involves_sensitive_data').notNull().default(false),
|
|
103
|
+
estimatedAffected: integer('estimated_affected'),
|
|
104
|
+
approximateRecordCount: integer('approximate_record_count'),
|
|
105
|
+
dataSubjectCategories: json('data_subject_categories').notNull(),
|
|
106
|
+
likelyConsequences: text('likely_consequences'),
|
|
107
|
+
mitigationMeasures: text('mitigation_measures'),
|
|
108
|
+
isPhasedReport: boolean('is_phased_report').notNull().default(false),
|
|
109
|
+
supplementsReportId: text('supplements_report_id'),
|
|
110
|
+
initialActions: text('initial_actions'),
|
|
102
111
|
ndpcNotificationSent: boolean('ndpc_notification_sent').notNull().default(false),
|
|
103
112
|
},
|
|
104
|
-
(
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
(table) => ({
|
|
114
|
+
tenantStatusIdx: index('breach_tenant_status_idx').on(table.tenantId, table.status),
|
|
115
|
+
tenantSeverityIdx: index('breach_tenant_severity_idx').on(table.tenantId, table.severity),
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
export const processingRecords = pgTable(
|
|
120
|
+
'ndpr_processing_records',
|
|
121
|
+
{
|
|
122
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
123
|
+
tenantId: text('tenant_id').notNull(),
|
|
124
|
+
purpose: text('purpose').notNull(),
|
|
125
|
+
lawfulBasis: text('lawful_basis').notNull(),
|
|
126
|
+
dataCategories: json('data_categories').notNull(),
|
|
127
|
+
dataSubjects: json('data_subjects').notNull(),
|
|
128
|
+
recipients: json('recipients').notNull(),
|
|
129
|
+
retentionPeriod: text('retention_period').notNull(),
|
|
130
|
+
securityMeasures: json('security_measures').notNull(),
|
|
131
|
+
transferCountries: json('transfer_countries'),
|
|
132
|
+
transferMechanism: text('transfer_mechanism'),
|
|
133
|
+
dpiaConducted: boolean('dpia_conducted').notNull().default(false),
|
|
134
|
+
status: text('status').notNull().default('active'),
|
|
135
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
136
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
137
|
+
},
|
|
138
|
+
(table) => ({
|
|
139
|
+
tenantStatusIdx: index('processing_tenant_status_idx').on(table.tenantId, table.status),
|
|
107
140
|
}),
|
|
108
141
|
);
|
|
109
142
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
143
|
+
export const dpiaRecords = pgTable(
|
|
144
|
+
'ndpr_dpia_records',
|
|
145
|
+
{
|
|
146
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
147
|
+
tenantId: text('tenant_id').notNull(),
|
|
148
|
+
projectName: text('project_name').notNull(),
|
|
149
|
+
description: text('description').notNull(),
|
|
150
|
+
dpiaData: json('dpia_data').notNull(),
|
|
151
|
+
overallRisk: text('overall_risk').notNull(),
|
|
152
|
+
score: integer('score').notNull(),
|
|
153
|
+
status: text('status').notNull().default('draft'),
|
|
154
|
+
conductedBy: text('conducted_by').notNull(),
|
|
155
|
+
approvedBy: text('approved_by'),
|
|
156
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
157
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
158
|
+
removedAt: timestamp('removed_at'),
|
|
159
|
+
},
|
|
160
|
+
(table) => ({
|
|
161
|
+
tenantStatusIdx: index('dpia_tenant_status_idx').on(
|
|
162
|
+
table.tenantId,
|
|
163
|
+
table.removedAt,
|
|
164
|
+
table.status,
|
|
165
|
+
),
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
113
168
|
|
|
114
|
-
export const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
169
|
+
export const lawfulBasisRecords = pgTable(
|
|
170
|
+
'ndpr_lawful_basis_records',
|
|
171
|
+
{
|
|
172
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
173
|
+
tenantId: text('tenant_id').notNull(),
|
|
174
|
+
activityName: text('activity_name').notNull(),
|
|
175
|
+
lawfulBasis: text('lawful_basis').notNull(),
|
|
176
|
+
justification: text('justification').notNull(),
|
|
177
|
+
dataCategories: json('data_categories').notNull(),
|
|
178
|
+
purposes: json('purposes').notNull(),
|
|
179
|
+
assessedBy: text('assessed_by').notNull(),
|
|
180
|
+
assessedAt: timestamp('assessed_at').defaultNow().notNull(),
|
|
181
|
+
reviewDate: timestamp('review_date'),
|
|
182
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
183
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
184
|
+
removedAt: timestamp('removed_at'),
|
|
185
|
+
},
|
|
186
|
+
(table) => ({
|
|
187
|
+
tenantBasisIdx: index('lawful_basis_tenant_idx').on(
|
|
188
|
+
table.tenantId,
|
|
189
|
+
table.removedAt,
|
|
190
|
+
table.lawfulBasis,
|
|
191
|
+
),
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
130
194
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
195
|
+
export const crossBorderTransferRecords = pgTable(
|
|
196
|
+
'ndpr_cross_border_transfer_records',
|
|
197
|
+
{
|
|
198
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
199
|
+
tenantId: text('tenant_id').notNull(),
|
|
200
|
+
destinationCountry: text('destination_country').notNull(),
|
|
201
|
+
recipientName: text('recipient_name').notNull(),
|
|
202
|
+
transferMechanism: text('transfer_mechanism').notNull(),
|
|
203
|
+
safeguards: text('safeguards').notNull(),
|
|
204
|
+
dataCategories: json('data_categories').notNull(),
|
|
205
|
+
adequacyStatus: text('adequacy_status').notNull(),
|
|
206
|
+
ndpcApprovalRequired: boolean('ndpc_approval_required').notNull().default(false),
|
|
207
|
+
ndpcApprovalReference: text('ndpc_approval_reference'),
|
|
208
|
+
riskLevel: text('risk_level').notNull(),
|
|
209
|
+
status: text('status').notNull().default('active'),
|
|
210
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
211
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
212
|
+
removedAt: timestamp('removed_at'),
|
|
213
|
+
},
|
|
214
|
+
(table) => ({
|
|
215
|
+
tenantDestinationIdx: index('cross_border_tenant_destination_idx').on(
|
|
216
|
+
table.tenantId,
|
|
217
|
+
table.removedAt,
|
|
218
|
+
table.destinationCountry,
|
|
219
|
+
),
|
|
220
|
+
tenantRiskIdx: index('cross_border_tenant_risk_idx').on(
|
|
221
|
+
table.tenantId,
|
|
222
|
+
table.removedAt,
|
|
223
|
+
table.riskLevel,
|
|
224
|
+
),
|
|
225
|
+
tenantStatusIdx: index('cross_border_tenant_status_idx').on(
|
|
226
|
+
table.tenantId,
|
|
227
|
+
table.removedAt,
|
|
228
|
+
table.status,
|
|
229
|
+
),
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
134
232
|
|
|
135
|
-
|
|
233
|
+
/** Append-oriented application audit rows; enforce immutability in DB permissions. */
|
|
234
|
+
export const complianceAuditLog = pgTable(
|
|
136
235
|
'ndpr_audit_log',
|
|
137
236
|
{
|
|
138
|
-
id:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
237
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
238
|
+
tenantId: text('tenant_id').notNull(),
|
|
239
|
+
module: text('module').notNull(),
|
|
240
|
+
action: text('action').notNull(),
|
|
241
|
+
entityId: text('entity_id').notNull(),
|
|
242
|
+
entityType: text('entity_type').notNull(),
|
|
243
|
+
changes: json('changes'),
|
|
144
244
|
performedBy: text('performed_by'),
|
|
145
|
-
createdAt:
|
|
245
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
146
246
|
},
|
|
147
|
-
(
|
|
148
|
-
|
|
247
|
+
(table) => ({
|
|
248
|
+
tenantEntityIdx: index('audit_tenant_module_entity_idx').on(
|
|
249
|
+
table.tenantId,
|
|
250
|
+
table.module,
|
|
251
|
+
table.entityId,
|
|
252
|
+
),
|
|
149
253
|
}),
|
|
150
254
|
);
|
|
151
255
|
|
|
152
|
-
|
|
153
|
-
// Inferred types
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
|
|
156
|
-
export type ConsentRecord = typeof consentRecords.$inferSelect;
|
|
256
|
+
export type ConsentRecord = typeof consentRecords.$inferSelect;
|
|
157
257
|
export type NewConsentRecord = typeof consentRecords.$inferInsert;
|
|
158
|
-
export type DSRRequest
|
|
159
|
-
export type NewDSRRequest
|
|
160
|
-
export type BreachReport
|
|
161
|
-
export type NewBreachReport
|
|
258
|
+
export type DSRRequest = typeof dsrRequests.$inferSelect;
|
|
259
|
+
export type NewDSRRequest = typeof dsrRequests.$inferInsert;
|
|
260
|
+
export type BreachReport = typeof breachReports.$inferSelect;
|
|
261
|
+
export type NewBreachReport = typeof breachReports.$inferInsert;
|
|
162
262
|
export type ProcessingRecord = typeof processingRecords.$inferSelect;
|
|
163
|
-
export type
|
|
263
|
+
export type NewProcessingRecord = typeof processingRecords.$inferInsert;
|
|
264
|
+
export type DPIARecord = typeof dpiaRecords.$inferSelect;
|
|
265
|
+
export type NewDPIARecord = typeof dpiaRecords.$inferInsert;
|
|
266
|
+
export type LawfulBasisRecord = typeof lawfulBasisRecords.$inferSelect;
|
|
267
|
+
export type NewLawfulBasisRecord = typeof lawfulBasisRecords.$inferInsert;
|
|
268
|
+
export type CrossBorderTransfer = typeof crossBorderTransferRecords.$inferSelect;
|
|
269
|
+
export type NewCrossBorderTransfer = typeof crossBorderTransferRecords.$inferInsert;
|
|
270
|
+
export type ComplianceAuditLogEntry = typeof complianceAuditLog.$inferSelect;
|
package/templates/env-example
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
# NDPA
|
|
2
|
-
# Organisation: {{ORG_NAME}}
|
|
3
|
-
# DPO: {{DPO_EMAIL}}
|
|
1
|
+
# NDPA compliance persistence — generated for {{ORG_NAME_COMMENT}}
|
|
4
2
|
DATABASE_URL="postgresql://user:password@localhost:5432/myapp_dev"
|
|
3
|
+
|
|
4
|
+
# Server-controlled tenant boundary. Never accept this value from clients.
|
|
5
|
+
# Multi-tenant applications should replace the generated request-context
|
|
6
|
+
# resolver with one that derives tenantId from a verified session.
|
|
7
|
+
NDPR_TENANT_ID="{{TENANT_ID}}"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Express — staff-only, tenant-scoped breach register for {{ORG_NAME_COMMENT}}. */
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { assessBreachNotification } from '@tantainnovative/ndpr-toolkit/server';
|
|
4
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
5
|
+
// {{#if ORM=prisma}}
|
|
6
|
+
import { PrismaClient } from '@prisma/client';
|
|
7
|
+
const prisma = new PrismaClient();
|
|
8
|
+
// {{/if}}
|
|
9
|
+
// {{#if ORM=drizzle}}
|
|
10
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
11
|
+
import { breachReports, complianceAuditLog } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
12
|
+
import { and, desc, eq } from 'drizzle-orm';
|
|
13
|
+
// {{/if}}
|
|
14
|
+
// {{#if ORM=none}}
|
|
15
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
16
|
+
type Severity = 'critical' | 'high' | 'medium' | 'low';
|
|
17
|
+
interface BreachRow { id: string; tenantId: string; title: string; description: string; category: string; severity: Severity; status: string; discoveredAt: Date; occurredAt: Date | null; reportedAt: Date; reporterName: string; reporterEmail: string; reporterDepartment: string | null; affectedSystems: string[]; dataTypes: string[]; estimatedAffected: number | null; approximateRecordCount: number | null; dataSubjectCategories: string[]; likelyConsequences: string | null; mitigationMeasures: string | null; involvesSensitiveData: boolean; isPhasedReport: boolean; supplementsReportId: string | null; initialActions: string | null }
|
|
18
|
+
const breachStore = new Map<string, BreachRow>();
|
|
19
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; at: Date }> = [];
|
|
20
|
+
// {{/if}}
|
|
21
|
+
|
|
22
|
+
type BreachSeverity = 'critical' | 'high' | 'medium' | 'low';
|
|
23
|
+
function severityFor(category: string, affected: number | null): BreachSeverity {
|
|
24
|
+
const highRisk = new Set(['unauthorized_access', 'ransomware', 'data_exfiltration', 'identity_theft']);
|
|
25
|
+
if (highRisk.has(category)) return (affected ?? 0) > 1000 ? 'critical' : 'high';
|
|
26
|
+
if ((affected ?? 0) > 500) return 'high';
|
|
27
|
+
if ((affected ?? 0) > 50) return 'medium';
|
|
28
|
+
return 'low';
|
|
29
|
+
}
|
|
30
|
+
function count(value: unknown): number | null { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; }
|
|
31
|
+
export const breachRouter = Router();
|
|
32
|
+
|
|
33
|
+
breachRouter.get('/', async (req, res) => {
|
|
34
|
+
const context = await resolveNDPRRequestContext(req);
|
|
35
|
+
const problem = getNDPRContextProblem(context, 'staff');
|
|
36
|
+
if (problem) return res.status(problem.status).json({ error: problem.error });
|
|
37
|
+
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
|
|
38
|
+
// {{#if ORM=prisma}}
|
|
39
|
+
const reports = await prisma.breachReport.findMany({ where: { tenantId: context.tenantId, ...(status ? { status } : {}) }, orderBy: { reportedAt: 'desc' } });
|
|
40
|
+
// {{/if}}
|
|
41
|
+
// {{#if ORM=drizzle}}
|
|
42
|
+
const reports = status
|
|
43
|
+
? await db.select().from(breachReports).where(and(eq(breachReports.tenantId, context.tenantId), eq(breachReports.status, status))).orderBy(desc(breachReports.reportedAt))
|
|
44
|
+
: await db.select().from(breachReports).where(eq(breachReports.tenantId, context.tenantId)).orderBy(desc(breachReports.reportedAt));
|
|
45
|
+
// {{/if}}
|
|
46
|
+
// {{#if ORM=none}}
|
|
47
|
+
const reports = [...breachStore.values()].filter((row) => row.tenantId === context.tenantId && (!status || row.status === status)).sort((a, b) => b.reportedAt.getTime() - a.reportedAt.getTime());
|
|
48
|
+
// {{/if}}
|
|
49
|
+
return res.json(reports);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
breachRouter.post('/', async (req, res) => {
|
|
53
|
+
const context = await resolveNDPRRequestContext(req);
|
|
54
|
+
const problem = getNDPRContextProblem(context, 'staff');
|
|
55
|
+
if (problem) return res.status(problem.status).json({ error: problem.error });
|
|
56
|
+
const actor = context.actor;
|
|
57
|
+
if (!actor) return res.status(401).json({ error: 'Verified staff profile is required' });
|
|
58
|
+
const actorId = actor.id;
|
|
59
|
+
if (!req.body || typeof req.body !== 'object') {
|
|
60
|
+
return res.status(400).json({ error: 'A JSON object is required' });
|
|
61
|
+
}
|
|
62
|
+
const input = req.body as Record<string, unknown>;
|
|
63
|
+
const required = ['title', 'description', 'category', 'discoveredAt'] as const;
|
|
64
|
+
if (required.some((field) => typeof input[field] !== 'string' || !(input[field] as string).trim()) || !Array.isArray(input.affectedSystems) || !input.affectedSystems.every((v) => typeof v === 'string') || !Array.isArray(input.dataTypes) || !input.dataTypes.every((v) => typeof v === 'string')) {
|
|
65
|
+
return res.status(400).json({ error: `${required.join(', ')}, affectedSystems, and dataTypes are required` });
|
|
66
|
+
}
|
|
67
|
+
const discoveredAt = new Date(input.discoveredAt as string);
|
|
68
|
+
const occurredAt = typeof input.occurredAt === 'string' ? new Date(input.occurredAt) : null;
|
|
69
|
+
if (!Number.isFinite(discoveredAt.getTime()) || discoveredAt.getTime() > Date.now() || occurredAt && (!Number.isFinite(occurredAt.getTime()) || occurredAt > discoveredAt)) return res.status(400).json({ error: 'Breach dates are invalid or inconsistent' });
|
|
70
|
+
const estimatedAffected = input.estimatedAffected == null ? null : count(input.estimatedAffected);
|
|
71
|
+
const approximateRecordCount = input.approximateRecordCount == null ? null : count(input.approximateRecordCount);
|
|
72
|
+
if (input.estimatedAffected != null && estimatedAffected === null || input.approximateRecordCount != null && approximateRecordCount === null) return res.status(400).json({ error: 'Counts must be finite non-negative numbers' });
|
|
73
|
+
const dataSubjectCategories = Array.isArray(input.dataSubjectCategories) && input.dataSubjectCategories.every((v) => typeof v === 'string') ? input.dataSubjectCategories as string[] : [];
|
|
74
|
+
const severity = severityFor(input.category as string, estimatedAffected);
|
|
75
|
+
const reportedAt = new Date();
|
|
76
|
+
const data = { tenantId: context.tenantId, title: input.title as string, description: input.description as string, category: input.category as string, severity, status: 'ongoing', discoveredAt, occurredAt, reporterName: actor.displayName, reporterEmail: actor.email, reporterDepartment: actor.department ?? null, affectedSystems: input.affectedSystems as string[], dataTypes: input.dataTypes as string[], estimatedAffected, approximateRecordCount, dataSubjectCategories, likelyConsequences: typeof input.likelyConsequences === 'string' ? input.likelyConsequences : null, mitigationMeasures: typeof input.mitigationMeasures === 'string' ? input.mitigationMeasures : null, involvesSensitiveData: input.involvesSensitiveData === true, isPhasedReport: input.isPhasedReport === true, supplementsReportId: typeof input.supplementsReportId === 'string' ? input.supplementsReportId : null, initialActions: typeof input.initialActions === 'string' ? input.initialActions : null };
|
|
77
|
+
// {{#if ORM=prisma}}
|
|
78
|
+
const report = await prisma.$transaction(async (tx) => {
|
|
79
|
+
const created = await tx.breachReport.create({ data });
|
|
80
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'breach', action: 'reported', entityId: created.id, entityType: 'BreachReport', performedBy: actorId, changes: { title: data.title, category: data.category, severity, status: 'ongoing' } } });
|
|
81
|
+
return created;
|
|
82
|
+
});
|
|
83
|
+
// {{/if}}
|
|
84
|
+
// {{#if ORM=drizzle}}
|
|
85
|
+
const report = await db.transaction(async (tx) => {
|
|
86
|
+
const [created] = await tx.insert(breachReports).values(data).returning();
|
|
87
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'breach', action: 'reported', entityId: created.id, entityType: 'BreachReport', performedBy: actorId, changes: { title: data.title, category: data.category, severity, status: 'ongoing' } });
|
|
88
|
+
return created;
|
|
89
|
+
});
|
|
90
|
+
// {{/if}}
|
|
91
|
+
// {{#if ORM=none}}
|
|
92
|
+
const report: BreachRow = { id: crypto.randomUUID(), ...data, reportedAt };
|
|
93
|
+
breachStore.set(report.id, report);
|
|
94
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'reported', entityId: report.id, performedBy: actorId, at: reportedAt });
|
|
95
|
+
// {{/if}}
|
|
96
|
+
const readiness = assessBreachNotification({ id: report.id, title: data.title, description: data.description, category: data.category, discoveredAt: discoveredAt.getTime(), occurredAt: occurredAt?.getTime(), reportedAt: reportedAt.getTime(), reporter: { name: data.reporterName, email: data.reporterEmail, department: data.reporterDepartment ?? '' }, affectedSystems: data.affectedSystems, dataTypes: data.dataTypes, involvesSensitiveData: data.involvesSensitiveData, estimatedAffectedSubjects: estimatedAffected ?? undefined, approximateRecordCount: approximateRecordCount ?? undefined, dataSubjectCategories, likelyConsequences: data.likelyConsequences ?? undefined, mitigationMeasures: data.mitigationMeasures ?? undefined, isPhasedReport: data.isPhasedReport, supplementsReportId: data.supplementsReportId ?? undefined, initialActions: data.initialActions ?? undefined, dpoContact: { name: `{{ORG_NAME_TEMPLATE}} DPO`, email: `{{DPO_EMAIL_TEMPLATE}}` }, status: 'ongoing' });
|
|
97
|
+
return res.status(201).json({ ...report, ndpcReadiness: { complete: readiness.complete, ready: readiness.ready, valid: readiness.valid, completeness: readiness.completeness, missing: readiness.missing, validationErrors: readiness.validationErrors, notificationRequired: readiness.notificationRequired, hoursRemaining: readiness.timing.hoursRemaining, evidenceRecorded: readiness.evidence.notificationProvided } });
|
|
98
|
+
});
|