@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
|
@@ -1,292 +1,146 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Express — Lawful Basis Router
|
|
3
|
-
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
|
-
* DPO: {{DPO_EMAIL}}
|
|
5
|
-
*
|
|
6
|
-
* NDPA §25 — data processing must have a lawful basis. This router
|
|
7
|
-
* manages a register of processing activities and their lawful bases.
|
|
8
|
-
*
|
|
9
|
-
* Routes:
|
|
10
|
-
* GET /lawful-basis — List records (optional ?lawfulBasis= filter)
|
|
11
|
-
* GET /lawful-basis/:id — Get a single record
|
|
12
|
-
* POST /lawful-basis — Create a new lawful basis record
|
|
13
|
-
* PUT /lawful-basis/:id — Update an existing record
|
|
14
|
-
* DELETE /lawful-basis/:id — Delete a record
|
|
15
|
-
*
|
|
16
|
-
* Usage:
|
|
17
|
-
* import { lawfulBasisRouter } from './routes/lawful-basis';
|
|
18
|
-
* app.use('/api/lawful-basis', lawfulBasisRouter);
|
|
19
|
-
*/
|
|
20
|
-
|
|
1
|
+
/** Express — staff-only, tenant-scoped lawful-basis register for {{ORG_NAME_COMMENT}}. */
|
|
21
2
|
import { Router } from 'express';
|
|
3
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
22
4
|
// {{#if ORM=prisma}}
|
|
23
5
|
import { PrismaClient } from '@prisma/client';
|
|
24
|
-
|
|
25
6
|
const prisma = new PrismaClient();
|
|
26
7
|
// {{/if}}
|
|
27
8
|
// {{#if ORM=drizzle}}
|
|
28
|
-
import { db } from '
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
9
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
10
|
+
import { complianceAuditLog, lawfulBasisRecords } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
11
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
31
12
|
// {{/if}}
|
|
13
|
+
|
|
14
|
+
const LAWFUL_BASES = ['consent', 'contract', 'legal_obligation', 'vital_interests', 'public_interest', 'legitimate_interests'] as const;
|
|
15
|
+
type LawfulBasis = (typeof LAWFUL_BASES)[number];
|
|
16
|
+
type Validation<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
17
|
+
interface BasisExisting { activityName: string; lawfulBasis: string; justification: string; dataCategories: unknown; purposes: unknown; reviewDate: Date | null }
|
|
18
|
+
interface BasisMutation { activityName: string; lawfulBasis: LawfulBasis; justification: string; dataCategories: string[]; purposes: string[]; assessedBy: string; assessedAt: Date; reviewDate: Date | null; updatedAt: Date }
|
|
19
|
+
|
|
32
20
|
// {{#if ORM=none}}
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
interface LawfulBasisRecord {
|
|
38
|
-
id: string;
|
|
39
|
-
activityName: string;
|
|
40
|
-
lawfulBasis: string;
|
|
41
|
-
justification: string;
|
|
42
|
-
dataCategories: string[];
|
|
43
|
-
purposes: string[];
|
|
44
|
-
assessedBy: string;
|
|
45
|
-
reviewDate: Date | null;
|
|
46
|
-
createdAt: Date;
|
|
47
|
-
updatedAt: Date;
|
|
48
|
-
}
|
|
49
|
-
const lawfulBasisStore = new Map<string, LawfulBasisRecord>();
|
|
50
|
-
const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
|
|
51
|
-
function newId() { return crypto.randomUUID(); }
|
|
21
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
22
|
+
interface BasisRow extends BasisMutation { id: string; tenantId: string; createdAt: Date; removedAt: Date | null }
|
|
23
|
+
const basisStore = new Map<string, BasisRow>();
|
|
24
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; changes?: unknown; at: Date }> = [];
|
|
52
25
|
// {{/if}}
|
|
53
26
|
|
|
27
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
28
|
+
function hasOwn(value: Record<string, unknown>, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); }
|
|
29
|
+
function rejectUnknownFields(value: Record<string, unknown>, allowed: readonly string[]): string | null {
|
|
30
|
+
const allowedSet = new Set(allowed); const unknown = Object.keys(value).filter((key) => !allowedSet.has(key)); return unknown.length > 0 ? `Unsupported field(s): ${unknown.join(', ')}` : null;
|
|
31
|
+
}
|
|
32
|
+
function nonEmptyText(value: unknown, field: string, maxLength = 10_000): Validation<string> {
|
|
33
|
+
if (typeof value !== 'string' || !value.trim() || value.trim().length > maxLength) return { ok: false, error: `${field} must be non-empty text of at most ${maxLength} characters` };
|
|
34
|
+
return { ok: true, value: value.trim() };
|
|
35
|
+
}
|
|
36
|
+
function nonEmptyStringArray(value: unknown, field: string): Validation<string[]> {
|
|
37
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) return { ok: false, error: `${field} must be a non-empty array with at most 100 items` };
|
|
38
|
+
const result: string[] = [];
|
|
39
|
+
for (const item of value) { if (typeof item !== 'string' || !item.trim()) return { ok: false, error: `${field} must contain only non-empty strings` }; if (!result.includes(item.trim())) result.push(item.trim()); }
|
|
40
|
+
return { ok: true, value: result };
|
|
41
|
+
}
|
|
42
|
+
function isLawfulBasis(value: unknown): value is LawfulBasis { return typeof value === 'string' && (LAWFUL_BASES as readonly string[]).includes(value); }
|
|
43
|
+
function parseReviewDate(value: unknown): Date | null | undefined {
|
|
44
|
+
if (value === null || value === '') return null;
|
|
45
|
+
if (value instanceof Date) return Number.isFinite(value.getTime()) ? new Date(value.getTime()) : undefined;
|
|
46
|
+
if (typeof value !== 'string') return undefined;
|
|
47
|
+
const input = value.trim(); const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input); const dateTime = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.exec(input);
|
|
48
|
+
if (!dateOnly && !dateTime) return undefined;
|
|
49
|
+
const timestamp = Date.parse(input); if (!Number.isFinite(timestamp)) return undefined;
|
|
50
|
+
const parts = dateOnly ?? dateTime; const check = new Date(Date.UTC(Number(parts![1]), Number(parts![2]) - 1, Number(parts![3])));
|
|
51
|
+
if (check.getUTCFullYear() !== Number(parts![1]) || check.getUTCMonth() + 1 !== Number(parts![2]) || check.getUTCDate() !== Number(parts![3])) return undefined;
|
|
52
|
+
return new Date(timestamp);
|
|
53
|
+
}
|
|
54
|
+
function buildBasisMutation(input: Record<string, unknown>, actorId: string, existing?: BasisExisting, now = new Date()): Validation<BasisMutation> {
|
|
55
|
+
const activityName = hasOwn(input, 'activityName') ? nonEmptyText(input.activityName, 'activityName', 500) : existing ? nonEmptyText(existing.activityName, 'stored activityName', 500) : { ok: false as const, error: 'activityName is required' }; if (!activityName.ok) return activityName;
|
|
56
|
+
const basisValue = hasOwn(input, 'lawfulBasis') ? input.lawfulBasis : existing?.lawfulBasis; if (!isLawfulBasis(basisValue)) return { ok: false, error: 'lawfulBasis is not one of the six supported NDPA bases' };
|
|
57
|
+
const justification = hasOwn(input, 'justification') ? nonEmptyText(input.justification, 'justification') : existing ? nonEmptyText(existing.justification, 'stored justification') : { ok: false as const, error: 'justification is required' }; if (!justification.ok) return justification;
|
|
58
|
+
if (basisValue === 'legitimate_interests' && justification.value.length < 20) return { ok: false, error: 'legitimate_interests requires a detailed justification covering the interest, necessity, and balancing assessment' };
|
|
59
|
+
const categories = nonEmptyStringArray(hasOwn(input, 'dataCategories') ? input.dataCategories : existing?.dataCategories, 'dataCategories'); if (!categories.ok) return categories;
|
|
60
|
+
const purposes = nonEmptyStringArray(hasOwn(input, 'purposes') ? input.purposes : existing?.purposes, 'purposes'); if (!purposes.ok) return purposes;
|
|
61
|
+
let reviewDate: Date | null;
|
|
62
|
+
if (hasOwn(input, 'reviewDate')) { const parsed = parseReviewDate(input.reviewDate); if (parsed === undefined) return { ok: false, error: 'reviewDate must be null, YYYY-MM-DD, or an ISO timestamp with a timezone' }; if (parsed !== null && parsed.getTime() <= now.getTime()) return { ok: false, error: 'reviewDate must be in the future' }; reviewDate = parsed; }
|
|
63
|
+
else { const parsed = parseReviewDate(existing?.reviewDate ?? null); if (parsed === undefined) return { ok: false, error: 'Stored reviewDate is invalid' }; reviewDate = parsed; }
|
|
64
|
+
return { ok: true, value: { activityName: activityName.value, lawfulBasis: basisValue, justification: justification.value, dataCategories: categories.value, purposes: purposes.value, assessedBy: actorId, assessedAt: now, reviewDate, updatedAt: now } };
|
|
65
|
+
}
|
|
66
|
+
function auditChanges(data: BasisMutation) { return { activityName: data.activityName, lawfulBasis: data.lawfulBasis, assessedBy: data.assessedBy, reviewDate: data.reviewDate?.toISOString() ?? null }; }
|
|
67
|
+
|
|
54
68
|
export const lawfulBasisRouter = Router();
|
|
55
69
|
|
|
56
|
-
// GET /lawful-basis?lawfulBasis=consent
|
|
57
70
|
lawfulBasisRouter.get('/', async (req, res) => {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
71
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
|
|
72
|
+
const basis = typeof req.query.lawfulBasis === 'string' ? req.query.lawfulBasis : undefined; if (basis !== undefined && !isLawfulBasis(basis)) return res.status(400).json({ error: 'lawfulBasis filter is not supported' });
|
|
61
73
|
// {{#if ORM=prisma}}
|
|
62
|
-
const records = await prisma.lawfulBasisRecord.findMany({
|
|
63
|
-
where: basisFilter ? { lawfulBasis: basisFilter } : undefined,
|
|
64
|
-
orderBy: { createdAt: 'desc' },
|
|
65
|
-
});
|
|
74
|
+
const records = await prisma.lawfulBasisRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(basis ? { lawfulBasis: basis } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
66
75
|
// {{/if}}
|
|
67
76
|
// {{#if ORM=drizzle}}
|
|
68
|
-
const records =
|
|
69
|
-
? await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.lawfulBasis, basisFilter)).orderBy(desc(lawfulBasisRecords.createdAt))
|
|
70
|
-
: await db.select().from(lawfulBasisRecords).orderBy(desc(lawfulBasisRecords.createdAt));
|
|
77
|
+
const records = basis ? await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt), eq(lawfulBasisRecords.lawfulBasis, basis))).orderBy(desc(lawfulBasisRecords.createdAt)) : await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).orderBy(desc(lawfulBasisRecords.createdAt));
|
|
71
78
|
// {{/if}}
|
|
72
79
|
// {{#if ORM=none}}
|
|
73
|
-
const records = [...
|
|
74
|
-
.filter((r) => (basisFilter ? r.lawfulBasis === basisFilter : true))
|
|
75
|
-
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
80
|
+
const records = [...basisStore.values()].filter((row) => row.tenantId === context.tenantId && row.removedAt === null && (!basis || row.lawfulBasis === basis)).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
76
81
|
// {{/if}}
|
|
77
|
-
|
|
78
82
|
return res.json(records);
|
|
79
83
|
});
|
|
80
84
|
|
|
81
|
-
// GET /lawful-basis/:id
|
|
82
85
|
lawfulBasisRouter.get('/:id', async (req, res) => {
|
|
86
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
|
|
83
87
|
// {{#if ORM=prisma}}
|
|
84
|
-
const record = await prisma.lawfulBasisRecord.
|
|
88
|
+
const record = await prisma.lawfulBasisRecord.findFirst({ where: { id: req.params.id, tenantId: context.tenantId, removedAt: null } });
|
|
85
89
|
// {{/if}}
|
|
86
90
|
// {{#if ORM=drizzle}}
|
|
87
|
-
const [record] = await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, req.params.id)).limit(1);
|
|
91
|
+
const [record] = await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, req.params.id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1);
|
|
88
92
|
// {{/if}}
|
|
89
93
|
// {{#if ORM=none}}
|
|
90
|
-
const
|
|
94
|
+
const candidate = basisStore.get(req.params.id); const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
91
95
|
// {{/if}}
|
|
92
|
-
|
|
93
|
-
if (!record) {
|
|
94
|
-
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return res.json(record);
|
|
96
|
+
return record ? res.json(record) : res.status(404).json({ error: 'Lawful-basis record not found' });
|
|
98
97
|
});
|
|
99
98
|
|
|
100
|
-
// POST /lawful-basis
|
|
101
99
|
lawfulBasisRouter.post('/', async (req, res) => {
|
|
102
|
-
const {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
dataCategories,
|
|
107
|
-
purposes,
|
|
108
|
-
assessedBy,
|
|
109
|
-
reviewDate,
|
|
110
|
-
} = req.body;
|
|
111
|
-
|
|
112
|
-
if (!activityName || !lawfulBasis || !justification || !dataCategories || !purposes || !assessedBy) {
|
|
113
|
-
return res.status(400).json({
|
|
114
|
-
error: 'activityName, lawfulBasis, justification, dataCategories, purposes, and assessedBy are required',
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (!Array.isArray(dataCategories) || !Array.isArray(purposes)) {
|
|
119
|
-
return res.status(400).json({ error: 'dataCategories and purposes must be arrays' });
|
|
120
|
-
}
|
|
121
|
-
|
|
100
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string;
|
|
101
|
+
if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
|
|
102
|
+
const unknown = rejectUnknownFields(req.body, ['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate']); if (unknown) return res.status(400).json({ error: unknown });
|
|
103
|
+
const normalized = buildBasisMutation(req.body, actorId); if (!normalized.ok) return res.status(400).json({ error: normalized.error }); const data = normalized.value;
|
|
122
104
|
// {{#if ORM=prisma}}
|
|
123
|
-
const record = await prisma.lawfulBasisRecord.create({
|
|
124
|
-
data: {
|
|
125
|
-
activityName,
|
|
126
|
-
lawfulBasis,
|
|
127
|
-
justification,
|
|
128
|
-
dataCategories,
|
|
129
|
-
purposes,
|
|
130
|
-
assessedBy,
|
|
131
|
-
reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
132
|
-
},
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
await prisma.complianceAuditLog.create({
|
|
136
|
-
data: {
|
|
137
|
-
module: 'lawful-basis',
|
|
138
|
-
action: 'created',
|
|
139
|
-
entityId: record.id,
|
|
140
|
-
entityType: 'LawfulBasisRecord',
|
|
141
|
-
changes: { activityName, lawfulBasis, assessedBy },
|
|
142
|
-
},
|
|
143
|
-
});
|
|
105
|
+
const record = await prisma.$transaction(async (tx) => { const created = await tx.lawfulBasisRecord.create({ data: { ...data, tenantId: context.tenantId, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'created', entityId: created.id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) } }); return created; });
|
|
144
106
|
// {{/if}}
|
|
145
107
|
// {{#if ORM=drizzle}}
|
|
146
|
-
const
|
|
147
|
-
activityName,
|
|
148
|
-
lawfulBasis,
|
|
149
|
-
justification,
|
|
150
|
-
dataCategories,
|
|
151
|
-
purposes,
|
|
152
|
-
assessedBy,
|
|
153
|
-
reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
154
|
-
}).returning();
|
|
155
|
-
|
|
156
|
-
await db.insert(complianceAuditLog).values({
|
|
157
|
-
module: 'lawful-basis',
|
|
158
|
-
action: 'created',
|
|
159
|
-
entityId: record.id,
|
|
160
|
-
entityType: 'LawfulBasisRecord',
|
|
161
|
-
changes: { activityName, lawfulBasis, assessedBy },
|
|
162
|
-
});
|
|
108
|
+
const record = await db.transaction(async (tx) => { const [created] = await tx.insert(lawfulBasisRecords).values({ ...data, tenantId: context.tenantId, removedAt: null }).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'created', entityId: created.id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) }); return created; });
|
|
163
109
|
// {{/if}}
|
|
164
110
|
// {{#if ORM=none}}
|
|
165
|
-
const
|
|
166
|
-
const record: LawfulBasisRecord = {
|
|
167
|
-
id: newId(),
|
|
168
|
-
activityName,
|
|
169
|
-
lawfulBasis,
|
|
170
|
-
justification,
|
|
171
|
-
dataCategories,
|
|
172
|
-
purposes,
|
|
173
|
-
assessedBy,
|
|
174
|
-
reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
175
|
-
createdAt: now,
|
|
176
|
-
updatedAt: now,
|
|
177
|
-
};
|
|
178
|
-
lawfulBasisStore.set(record.id, record);
|
|
179
|
-
auditLog.push({ id: newId(), module: 'lawful-basis', action: 'created', entityId: record.id, at: new Date() });
|
|
111
|
+
const record: BasisRow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: data.updatedAt, removedAt: null }; basisStore.set(record.id, record); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: data.updatedAt });
|
|
180
112
|
// {{/if}}
|
|
181
|
-
|
|
182
113
|
return res.status(201).json(record);
|
|
183
114
|
});
|
|
184
115
|
|
|
185
|
-
// PUT /lawful-basis/:id
|
|
186
116
|
lawfulBasisRouter.put('/:id', async (req, res) => {
|
|
187
|
-
const {
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
117
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string;
|
|
118
|
+
if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
|
|
119
|
+
const unknown = rejectUnknownFields(req.body, ['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate']); if (unknown) return res.status(400).json({ error: unknown });
|
|
120
|
+
if (!['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate'].some((field) => hasOwn(req.body, field))) return res.status(400).json({ error: 'Provide at least one allowlisted lawful-basis field to update' });
|
|
121
|
+
const id = req.params.id; let validationError: string | null = null;
|
|
194
122
|
// {{#if ORM=prisma}}
|
|
195
|
-
const existing = await
|
|
196
|
-
if (!existing) {
|
|
197
|
-
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const record = await prisma.lawfulBasisRecord.update({
|
|
201
|
-
where: { id },
|
|
202
|
-
data,
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
await prisma.complianceAuditLog.create({
|
|
206
|
-
data: {
|
|
207
|
-
module: 'lawful-basis',
|
|
208
|
-
action: 'updated',
|
|
209
|
-
entityId: record.id,
|
|
210
|
-
entityType: 'LawfulBasisRecord',
|
|
211
|
-
changes: data,
|
|
212
|
-
},
|
|
213
|
-
});
|
|
123
|
+
const record = await prisma.$transaction(async (tx) => { const existing = await tx.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return null; const normalized = buildBasisMutation(req.body, actorId, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; await tx.lawfulBasisRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data }); const updated = await tx.lawfulBasisRecord.findFirstOrThrow({ where: { id, tenantId: context.tenantId, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'updated', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) } }); return updated; });
|
|
214
124
|
// {{/if}}
|
|
215
125
|
// {{#if ORM=drizzle}}
|
|
216
|
-
const [existing] = await
|
|
217
|
-
if (!existing) {
|
|
218
|
-
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const [record] = await db.update(lawfulBasisRecords).set(data).where(eq(lawfulBasisRecords.id, id)).returning();
|
|
222
|
-
|
|
223
|
-
await db.insert(complianceAuditLog).values({
|
|
224
|
-
module: 'lawful-basis',
|
|
225
|
-
action: 'updated',
|
|
226
|
-
entityId: record.id,
|
|
227
|
-
entityType: 'LawfulBasisRecord',
|
|
228
|
-
changes: data,
|
|
229
|
-
});
|
|
126
|
+
const record = await db.transaction(async (tx) => { const [existing] = await tx.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1); if (!existing) return null; const normalized = buildBasisMutation(req.body, actorId, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; const [updated] = await tx.update(lawfulBasisRecords).set(data).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'updated', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) }); return updated; });
|
|
230
127
|
// {{/if}}
|
|
231
128
|
// {{#if ORM=none}}
|
|
232
|
-
const existing =
|
|
233
|
-
if (!existing) {
|
|
234
|
-
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
235
|
-
}
|
|
236
|
-
const record: LawfulBasisRecord = { ...existing, ...data, id, updatedAt: new Date() };
|
|
237
|
-
lawfulBasisStore.set(id, record);
|
|
238
|
-
auditLog.push({ id: newId(), module: 'lawful-basis', action: 'updated', entityId: record.id, at: new Date() });
|
|
129
|
+
const existing = basisStore.get(id); let record: BasisRow | null = null; if (existing?.tenantId === context.tenantId && existing.removedAt === null) { const normalized = buildBasisMutation(req.body, actorId, existing); if (!normalized.ok) validationError = normalized.error; else { record = { ...existing, ...normalized.value, id }; basisStore.set(id, record); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'updated', entityId: id, performedBy: actorId, changes: auditChanges(normalized.value), at: normalized.value.updatedAt }); } }
|
|
239
130
|
// {{/if}}
|
|
240
|
-
|
|
241
|
-
return res.json(record);
|
|
131
|
+
if (validationError) return res.status(400).json({ error: validationError }); return record ? res.json(record) : res.status(404).json({ error: 'Lawful-basis record not found' });
|
|
242
132
|
});
|
|
243
133
|
|
|
244
|
-
// DELETE /lawful-basis/:id
|
|
245
134
|
lawfulBasisRouter.delete('/:id', async (req, res) => {
|
|
246
|
-
const {
|
|
247
|
-
|
|
135
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string; const id = req.params.id; const archivedAt = new Date();
|
|
248
136
|
// {{#if ORM=prisma}}
|
|
249
|
-
const existing = await
|
|
250
|
-
if (!existing) {
|
|
251
|
-
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
await prisma.lawfulBasisRecord.delete({ where: { id } });
|
|
255
|
-
|
|
256
|
-
await prisma.complianceAuditLog.create({
|
|
257
|
-
data: {
|
|
258
|
-
module: 'lawful-basis',
|
|
259
|
-
action: 'deleted',
|
|
260
|
-
entityId: id,
|
|
261
|
-
entityType: 'LawfulBasisRecord',
|
|
262
|
-
changes: { activityName: existing.activityName },
|
|
263
|
-
},
|
|
264
|
-
});
|
|
137
|
+
const archived = await prisma.$transaction(async (tx) => { const existing = await tx.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return false; const result = await tx.lawfulBasisRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { removedAt: archivedAt, updatedAt: archivedAt } }); if (result.count !== 1) return false; await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'archived', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() } } }); return true; });
|
|
265
138
|
// {{/if}}
|
|
266
139
|
// {{#if ORM=drizzle}}
|
|
267
|
-
const [existing] = await
|
|
268
|
-
if (!existing) {
|
|
269
|
-
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
await db.delete(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, id));
|
|
273
|
-
|
|
274
|
-
await db.insert(complianceAuditLog).values({
|
|
275
|
-
module: 'lawful-basis',
|
|
276
|
-
action: 'deleted',
|
|
277
|
-
entityId: id,
|
|
278
|
-
entityType: 'LawfulBasisRecord',
|
|
279
|
-
changes: { activityName: existing.activityName },
|
|
280
|
-
});
|
|
140
|
+
const archived = await db.transaction(async (tx) => { const [existing] = await tx.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1); if (!existing) return false; const [updated] = await tx.update(lawfulBasisRecords).set({ removedAt: archivedAt, updatedAt: archivedAt }).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).returning(); if (!updated) return false; await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'archived', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() } }); return true; });
|
|
281
141
|
// {{/if}}
|
|
282
142
|
// {{#if ORM=none}}
|
|
283
|
-
const existing =
|
|
284
|
-
if (!existing) {
|
|
285
|
-
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
286
|
-
}
|
|
287
|
-
lawfulBasisStore.delete(id);
|
|
288
|
-
auditLog.push({ id: newId(), module: 'lawful-basis', action: 'deleted', entityId: id, at: new Date() });
|
|
143
|
+
const existing = basisStore.get(id); const archived = existing?.tenantId === context.tenantId && existing.removedAt === null; if (archived && existing) { basisStore.set(id, { ...existing, removedAt: archivedAt, updatedAt: archivedAt }); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'archived', entityId: id, performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() }, at: archivedAt }); }
|
|
289
144
|
// {{/if}}
|
|
290
|
-
|
|
291
|
-
return res.json({ success: true });
|
|
145
|
+
return archived ? res.json({ success: true, archived: true }) : res.status(404).json({ error: 'Lawful-basis record not found' });
|
|
292
146
|
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
|
|
3
|
+
export interface NDPRVerifiedActor {
|
|
4
|
+
id: string;
|
|
5
|
+
displayName: string;
|
|
6
|
+
email: string;
|
|
7
|
+
department?: string;
|
|
8
|
+
subjectId?: string;
|
|
9
|
+
roles: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface NDPRRequestContext {
|
|
13
|
+
tenantId: string;
|
|
14
|
+
actor: NDPRVerifiedActor | null;
|
|
15
|
+
actorId: string | null;
|
|
16
|
+
subjectId: string | null;
|
|
17
|
+
subjectSource: 'verified-account-subject' | 'anonymous-uuid-capability' | null;
|
|
18
|
+
roles: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type NDPRContextRequirement = 'tenant' | 'subject' | 'staff';
|
|
22
|
+
export interface NDPRContextProblem { status: 401 | 403 | 503; error: string }
|
|
23
|
+
export type NDPRVerifiedActorResolver = (
|
|
24
|
+
request: Request,
|
|
25
|
+
) => Promise<NDPRVerifiedActor | null>;
|
|
26
|
+
|
|
27
|
+
const ANONYMOUS_SUBJECT_PATTERN =
|
|
28
|
+
/^anon_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
29
|
+
const STAFF_ROLES = new Set(['ndpr:staff', 'ndpr:admin']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Replace this safe default with a verified Express auth/session integration.
|
|
33
|
+
* Never trust actor, account subject, profile, role, or tenant fields from
|
|
34
|
+
* request-controlled input.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveVerifiedNDPRActor(
|
|
37
|
+
_request: Request,
|
|
38
|
+
): Promise<NDPRVerifiedActor | null> {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The tenant comes only from server configuration. The only client-provided
|
|
44
|
+
* identity accepted by default is a random anon_<UUID> subject capability;
|
|
45
|
+
* it never grants staff access.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveNDPRRequestContext(
|
|
48
|
+
request: Request,
|
|
49
|
+
actorResolver: NDPRVerifiedActorResolver = resolveVerifiedNDPRActor,
|
|
50
|
+
): Promise<NDPRRequestContext> {
|
|
51
|
+
const tenantId = process.env.NDPR_TENANT_ID?.trim() ?? '';
|
|
52
|
+
const candidate = request.get('x-ndpr-subject-id')?.trim() ?? '';
|
|
53
|
+
const anonymousSubjectId = ANONYMOUS_SUBJECT_PATTERN.test(candidate)
|
|
54
|
+
? candidate
|
|
55
|
+
: null;
|
|
56
|
+
const actor = normalizeVerifiedActor(await actorResolver(request));
|
|
57
|
+
const subjectId = actor?.subjectId ?? anonymousSubjectId;
|
|
58
|
+
const subjectSource = actor?.subjectId
|
|
59
|
+
? 'verified-account-subject' as const
|
|
60
|
+
: anonymousSubjectId
|
|
61
|
+
? 'anonymous-uuid-capability' as const
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
tenantId,
|
|
66
|
+
actor,
|
|
67
|
+
actorId: actor?.id ?? null,
|
|
68
|
+
subjectId,
|
|
69
|
+
subjectSource,
|
|
70
|
+
roles: actor?.roles ?? [],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getNDPRContextProblem(
|
|
75
|
+
context: NDPRRequestContext,
|
|
76
|
+
requirement: NDPRContextRequirement,
|
|
77
|
+
): NDPRContextProblem | null {
|
|
78
|
+
if (!context.tenantId) {
|
|
79
|
+
return {
|
|
80
|
+
status: 503,
|
|
81
|
+
error: 'NDPR_TENANT_ID is not configured on the server',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (requirement === 'subject' && !context.subjectId) {
|
|
85
|
+
return { status: 401, error: 'A verified data-subject identity is required' };
|
|
86
|
+
}
|
|
87
|
+
if (requirement === 'staff' && !context.actorId) {
|
|
88
|
+
return {
|
|
89
|
+
status: 401,
|
|
90
|
+
error: 'Connect resolveVerifiedNDPRActor to verified staff authentication',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (requirement === 'staff'
|
|
94
|
+
&& !context.roles.some((role) => STAFF_ROLES.has(role))) {
|
|
95
|
+
return { status: 403, error: 'NDPR staff or administrator role required' };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeVerifiedActor(
|
|
101
|
+
actor: NDPRVerifiedActor | null,
|
|
102
|
+
): NDPRVerifiedActor | null {
|
|
103
|
+
if (!actor
|
|
104
|
+
|| typeof actor.id !== 'string'
|
|
105
|
+
|| typeof actor.displayName !== 'string'
|
|
106
|
+
|| typeof actor.email !== 'string'
|
|
107
|
+
|| !Array.isArray(actor.roles)) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const id = actor.id.trim();
|
|
111
|
+
const displayName = actor.displayName.trim();
|
|
112
|
+
const email = actor.email.trim();
|
|
113
|
+
if (!id || !displayName || !email) return null;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
displayName,
|
|
118
|
+
email,
|
|
119
|
+
department: typeof actor.department === 'string'
|
|
120
|
+
? actor.department.trim() || undefined
|
|
121
|
+
: undefined,
|
|
122
|
+
subjectId: typeof actor.subjectId === 'string'
|
|
123
|
+
? actor.subjectId.trim() || undefined
|
|
124
|
+
: undefined,
|
|
125
|
+
roles: actor.roles
|
|
126
|
+
.filter((role): role is string => typeof role === 'string')
|
|
127
|
+
.map((role) => role.trim())
|
|
128
|
+
.filter(Boolean),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# NDPA 2023 / NDPC GAID 2025 compliance gate — generated by create-ndpr for {{
|
|
1
|
+
# NDPA 2023 / NDPC GAID 2025 compliance gate — generated by create-ndpr for {{ORG_NAME_COMMENT}}.
|
|
2
2
|
#
|
|
3
3
|
# Runs `ndpr audit` on every push and pull request. The audit scores your
|
|
4
4
|
# compliance config (consent, DSR, DPIA, breach, policy, lawful basis,
|
|
5
|
-
# cross-border, RoPA) plus your GAID 2025 DCPMI
|
|
5
|
+
# cross-border, RoPA) plus your GAID 2025 DCPMI designation inputs, and exits
|
|
6
6
|
# non-zero when the overall score drops below the threshold — so a regression
|
|
7
7
|
# fails CI the same way a broken test would.
|
|
8
8
|
#
|
|
@@ -26,6 +26,6 @@ jobs:
|
|
|
26
26
|
# The `ndpr` CLI ships with the toolkit. --no-save keeps it out of your
|
|
27
27
|
# package.json if you don't already depend on it directly.
|
|
28
28
|
- name: Install the NDPR toolkit (provides the `ndpr` CLI)
|
|
29
|
-
run: npm install --no-save @tantainnovative/ndpr-toolkit
|
|
29
|
+
run: npm install --no-save @tantainnovative/ndpr-toolkit@{{TOOLKIT_VERSION}}
|
|
30
30
|
- name: Run the NDPA compliance audit
|
|
31
31
|
run: npx ndpr audit --min-score 70
|