@tantainnovative/create-ndpr 0.4.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 +64 -68
- package/bin/index.mjs +366 -526
- package/package.json +1 -1
- package/templates/drizzle-client.ts +44 -0
- package/templates/drizzle-schema.ts +200 -178
- package/templates/env-example +6 -3
- package/templates/express-breach-route.ts +98 -0
- package/templates/express-consent-route.ts +304 -153
- package/templates/express-cross-border-route.ts +94 -259
- package/templates/express-dpia-route.ts +267 -222
- package/templates/express-dsr-route.ts +76 -0
- package/templates/express-lawful-basis-route.ts +88 -234
- package/templates/express-request-context.ts +130 -0
- package/templates/github-ndpr-audit.yml +3 -3
- package/templates/nextjs-breach-route.ts +117 -183
- package/templates/nextjs-consent-route.ts +312 -104
- package/templates/nextjs-cross-border-route.ts +239 -250
- package/templates/nextjs-dpia-route.ts +434 -214
- package/templates/nextjs-dsr-route.ts +70 -116
- package/templates/nextjs-lawful-basis-route.ts +207 -221
- package/templates/nextjs-layout.tsx +120 -57
- package/templates/nextjs-request-context.ts +137 -0
- package/templates/prisma-schema.prisma +89 -77
- package/templates/express-setup.ts +0 -509
package/package.json
CHANGED
|
@@ -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,22 +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
|
-
* - dpiaRecords — NDPA §34(3) (impact assessments)
|
|
12
|
-
* - lawfulBasisRecords — NDPA §25 (lawful basis register)
|
|
13
|
-
* - crossBorderTransferRecords — NDPA §41–42 (cross-border transfers)
|
|
14
|
-
* - auditLog — §44 audit trail
|
|
15
|
-
*
|
|
16
|
-
* Prerequisites: drizzle-orm, @paralleldrive/cuid2
|
|
17
|
-
* Run `drizzle-kit push` to apply.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
1
|
import {
|
|
21
2
|
pgTable,
|
|
22
3
|
text,
|
|
@@ -25,224 +6,265 @@ import {
|
|
|
25
6
|
boolean,
|
|
26
7
|
integer,
|
|
27
8
|
index,
|
|
9
|
+
uniqueIndex,
|
|
28
10
|
} from 'drizzle-orm/pg-core';
|
|
29
11
|
import { createId } from '@paralleldrive/cuid2';
|
|
30
12
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
13
|
+
/**
|
|
14
|
+
* NDPA persistence schema for {{ORG_NAME_COMMENT}}.
|
|
15
|
+
* Every operational and audit row carries a required server-resolved tenantId.
|
|
16
|
+
*/
|
|
34
17
|
|
|
35
18
|
export const consentRecords = pgTable(
|
|
36
19
|
'ndpr_consent_records',
|
|
37
20
|
{
|
|
38
|
-
id:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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'),
|
|
48
35
|
},
|
|
49
|
-
(
|
|
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
|
+
}),
|
|
50
53
|
);
|
|
51
54
|
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
// ndpr_dsr_requests — NDPA §34–38
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
|
|
56
55
|
export const dsrRequests = pgTable(
|
|
57
56
|
'ndpr_dsr_requests',
|
|
58
57
|
{
|
|
59
|
-
id:
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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(),
|
|
66
67
|
identifierValue: text('identifier_value').notNull(),
|
|
67
|
-
description:
|
|
68
|
-
internalNotes:
|
|
69
|
-
assignedTo:
|
|
70
|
-
submittedAt:
|
|
71
|
-
acknowledgedAt:
|
|
72
|
-
completedAt:
|
|
73
|
-
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(),
|
|
74
75
|
},
|
|
75
|
-
(
|
|
76
|
-
|
|
77
|
-
|
|
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),
|
|
78
80
|
}),
|
|
79
81
|
);
|
|
80
82
|
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
// ndpr_breach_reports — NDPA §40
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
|
|
85
83
|
export const breachReports = pgTable(
|
|
86
84
|
'ndpr_breach_reports',
|
|
87
85
|
{
|
|
88
|
-
id:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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'),
|
|
105
111
|
ndpcNotificationSent: boolean('ndpc_notification_sent').notNull().default(false),
|
|
106
112
|
},
|
|
107
|
-
(
|
|
108
|
-
|
|
109
|
-
|
|
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),
|
|
110
116
|
}),
|
|
111
117
|
);
|
|
112
118
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// ---------------------------------------------------------------------------
|
|
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),
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
137
142
|
|
|
138
143
|
export const dpiaRecords = pgTable(
|
|
139
144
|
'ndpr_dpia_records',
|
|
140
145
|
{
|
|
141
|
-
id:
|
|
146
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
147
|
+
tenantId: text('tenant_id').notNull(),
|
|
142
148
|
projectName: text('project_name').notNull(),
|
|
143
149
|
description: text('description').notNull(),
|
|
144
|
-
dpiaData:
|
|
150
|
+
dpiaData: json('dpia_data').notNull(),
|
|
145
151
|
overallRisk: text('overall_risk').notNull(),
|
|
146
|
-
score:
|
|
147
|
-
status:
|
|
152
|
+
score: integer('score').notNull(),
|
|
153
|
+
status: text('status').notNull().default('draft'),
|
|
148
154
|
conductedBy: text('conducted_by').notNull(),
|
|
149
|
-
approvedBy:
|
|
150
|
-
createdAt:
|
|
151
|
-
updatedAt:
|
|
155
|
+
approvedBy: text('approved_by'),
|
|
156
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
157
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
158
|
+
removedAt: timestamp('removed_at'),
|
|
152
159
|
},
|
|
153
|
-
(
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
(table) => ({
|
|
161
|
+
tenantStatusIdx: index('dpia_tenant_status_idx').on(
|
|
162
|
+
table.tenantId,
|
|
163
|
+
table.removedAt,
|
|
164
|
+
table.status,
|
|
165
|
+
),
|
|
156
166
|
}),
|
|
157
167
|
);
|
|
158
168
|
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
// ndpr_lawful_basis_records — NDPA §25
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
|
|
163
169
|
export const lawfulBasisRecords = pgTable(
|
|
164
170
|
'ndpr_lawful_basis_records',
|
|
165
171
|
{
|
|
166
|
-
id:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(),
|
|
170
177
|
dataCategories: json('data_categories').notNull(),
|
|
171
|
-
purposes:
|
|
172
|
-
assessedBy:
|
|
173
|
-
assessedAt:
|
|
174
|
-
reviewDate:
|
|
175
|
-
createdAt:
|
|
176
|
-
updatedAt:
|
|
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'),
|
|
177
185
|
},
|
|
178
|
-
(
|
|
179
|
-
|
|
180
|
-
|
|
186
|
+
(table) => ({
|
|
187
|
+
tenantBasisIdx: index('lawful_basis_tenant_idx').on(
|
|
188
|
+
table.tenantId,
|
|
189
|
+
table.removedAt,
|
|
190
|
+
table.lawfulBasis,
|
|
191
|
+
),
|
|
181
192
|
}),
|
|
182
193
|
);
|
|
183
194
|
|
|
184
|
-
// ---------------------------------------------------------------------------
|
|
185
|
-
// ndpr_cross_border_transfer_records — NDPA §41–42
|
|
186
|
-
// ---------------------------------------------------------------------------
|
|
187
|
-
|
|
188
195
|
export const crossBorderTransferRecords = pgTable(
|
|
189
196
|
'ndpr_cross_border_transfer_records',
|
|
190
197
|
{
|
|
191
|
-
id:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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),
|
|
199
207
|
ndpcApprovalReference: text('ndpc_approval_reference'),
|
|
200
|
-
riskLevel:
|
|
201
|
-
|
|
202
|
-
|
|
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'),
|
|
203
213
|
},
|
|
204
|
-
(
|
|
205
|
-
|
|
206
|
-
|
|
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
|
+
),
|
|
207
230
|
}),
|
|
208
231
|
);
|
|
209
232
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// ---------------------------------------------------------------------------
|
|
213
|
-
|
|
214
|
-
export const auditLog = pgTable(
|
|
233
|
+
/** Append-oriented application audit rows; enforce immutability in DB permissions. */
|
|
234
|
+
export const complianceAuditLog = pgTable(
|
|
215
235
|
'ndpr_audit_log',
|
|
216
236
|
{
|
|
217
|
-
id:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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'),
|
|
223
244
|
performedBy: text('performed_by'),
|
|
224
|
-
createdAt:
|
|
245
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
225
246
|
},
|
|
226
|
-
(
|
|
227
|
-
|
|
247
|
+
(table) => ({
|
|
248
|
+
tenantEntityIdx: index('audit_tenant_module_entity_idx').on(
|
|
249
|
+
table.tenantId,
|
|
250
|
+
table.module,
|
|
251
|
+
table.entityId,
|
|
252
|
+
),
|
|
228
253
|
}),
|
|
229
254
|
);
|
|
230
255
|
|
|
231
|
-
|
|
232
|
-
// Inferred types
|
|
233
|
-
// ---------------------------------------------------------------------------
|
|
234
|
-
|
|
235
|
-
export type ConsentRecord = typeof consentRecords.$inferSelect;
|
|
256
|
+
export type ConsentRecord = typeof consentRecords.$inferSelect;
|
|
236
257
|
export type NewConsentRecord = typeof consentRecords.$inferInsert;
|
|
237
|
-
export type DSRRequest
|
|
238
|
-
export type NewDSRRequest
|
|
239
|
-
export type BreachReport
|
|
240
|
-
export type NewBreachReport
|
|
241
|
-
export type ProcessingRecord
|
|
242
|
-
export type
|
|
243
|
-
export type
|
|
244
|
-
export type
|
|
245
|
-
export type
|
|
246
|
-
export type
|
|
247
|
-
export type
|
|
248
|
-
export type
|
|
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;
|
|
262
|
+
export type ProcessingRecord = typeof processingRecords.$inferSelect;
|
|
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
|
+
});
|