- Retention policies (Admin -> Privacy -> Retention) applied by a nightly
`retention` job: inactive accounts anonymised after 5 years with a
30-day warning (on by default; never staff roles or people with an
upcoming registration), form answers, login history, payer details in
payment notifications, audit/access logs
- User.lastLoginAt (set on login, backfilled) and inactivityWarnedAt
- Breach register (SecurityBreach) with drafts for the people affected
and the Information Regulator
- Readiness checklist with a score
- Children's/medical flag in the access log; {retention} placeholder in
the privacy notice
- Unit tests (retention.test.js), e2e (privacy-retention.spec.ts); docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
448 lines
20 KiB
JavaScript
448 lines
20 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { ACCESS_ACTIONS } = require('../utils/accessLog');
|
|
const notice = require('../utils/privacyNotice');
|
|
|
|
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
|
|
const PAGE_SIZE = 50;
|
|
const page = req => Math.max(parseInt(req.query.page, 10) || 1, 1);
|
|
|
|
// ─── Privacy notice ───────────────────────────────────────────────────────────
|
|
|
|
// @desc The privacy notice as published (current, or ?version=N), placeholders filled in
|
|
// @route GET /api/privacy/notice
|
|
// @access Public
|
|
const getNotice = async (req, res) => {
|
|
try {
|
|
const version = req.query.version != null ? parseInt(req.query.version, 10) : null;
|
|
if (req.query.version != null && !(version >= 0)) { res.status(400); throw new Error('Invalid version'); }
|
|
const result = await notice.publicNotice(version);
|
|
if (!result) { res.status(404); throw new Error('That version of the privacy notice does not exist.'); }
|
|
res.json(result);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc Accept the current privacy notice
|
|
// @route POST /api/privacy/me/notice/accept
|
|
// @access Private
|
|
const acceptMyNotice = async (req, res) => {
|
|
try {
|
|
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
|
|
const version = await notice.acceptNotice(req.user.id, { source: 'prompt', ip: getClientIp(req) });
|
|
res.json({ version });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
async function versionList() {
|
|
const [versions, counts] = await Promise.all([
|
|
prisma.privacyNotice.findMany({
|
|
orderBy: { version: 'desc' },
|
|
select: { version: true, publishedAt: true, changeSummary: true, requiresAcceptance: true, publishedBy: { select: { name: true } } },
|
|
}),
|
|
prisma.noticeAcceptance.groupBy({ by: ['version'], _count: { _all: true } }),
|
|
]);
|
|
const byVersion = new Map(counts.map(c => [c.version, c._count._all]));
|
|
return versions.map(v => ({ ...v, publishedBy: v.publishedBy?.name || null, acceptances: byVersion.get(v.version) || 0 }));
|
|
}
|
|
|
|
// @desc The editor: the draft (or current text), how it reads, and the published versions
|
|
// @route GET /api/privacy/notice/draft
|
|
// @access Private/Admin
|
|
const getNoticeDraft = async (req, res) => {
|
|
try {
|
|
const draft = await notice.getDraft();
|
|
const ctx = await notice.noticeContext();
|
|
res.json({
|
|
...draft,
|
|
rendered: notice.renderSections(draft.sections, ctx),
|
|
placeholders: notice.PLACEHOLDERS,
|
|
versions: await versionList(),
|
|
builtIn: notice.defaultSections(),
|
|
});
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc Save the draft (body.sections)
|
|
// @route PUT /api/privacy/notice/draft
|
|
// @access Private/Admin
|
|
const saveNoticeDraft = async (req, res) => {
|
|
try {
|
|
const sections = await notice.saveDraft(req.body?.sections);
|
|
res.json({ sections, rendered: notice.renderSections(sections, await notice.noticeContext()), isDraft: true, savedAt: new Date() });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc Throw the draft away (back to the current published text)
|
|
// @route DELETE /api/privacy/notice/draft
|
|
// @access Private/Admin
|
|
const discardNoticeDraft = async (req, res) => {
|
|
try {
|
|
await notice.discardDraft();
|
|
res.json({ message: 'Draft discarded.' });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc Publish body.sections as the next version (body.changeSummary, body.requiresAcceptance)
|
|
// @route POST /api/privacy/notice/publish
|
|
// @access Private/Admin
|
|
const publishNotice = async (req, res) => {
|
|
try {
|
|
const { sections, changeSummary, requiresAcceptance } = req.body || {};
|
|
if (requiresAcceptance && !String(changeSummary || '').trim()) {
|
|
res.status(400);
|
|
throw new Error('Say what changed — people see it when they are asked to accept the new version.');
|
|
}
|
|
const published = await notice.publish({ sections, changeSummary, requiresAcceptance, publishedById: req.user.id });
|
|
logAdminAction({
|
|
actorId: req.user.id, actorRole: req.user.role, action: 'privacy_notice_published', targetType: 'privacy_notice', targetId: published.id,
|
|
metadata: { version: published.version, requiresAcceptance: published.requiresAcceptance, changeSummary: published.changeSummary }, ip: getClientIp(req),
|
|
});
|
|
res.status(201).json({ version: published.version, publishedAt: published.publishedAt, requiresAcceptance: published.requiresAcceptance });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// ─── Operators ────────────────────────────────────────────────────────────────
|
|
|
|
const OPERATOR_FIELDS = ['name', 'purpose', 'dataShared', 'location', 'website'];
|
|
|
|
function operatorData(body, { partial = false } = {}) {
|
|
const data = {};
|
|
for (const f of OPERATOR_FIELDS) {
|
|
if (body?.[f] === undefined) continue;
|
|
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'name' ? 200 : 1000);
|
|
data[f] = v || (f === 'location' || f === 'website' ? null : '');
|
|
}
|
|
if (typeof body?.active === 'boolean') data.active = body.active;
|
|
if (Number.isInteger(body?.order)) data.order = body.order;
|
|
if ((!partial || 'name' in data) && !data.name) throw new Error('Give the operator a name.');
|
|
if ((!partial || 'purpose' in data) && !data.purpose) throw new Error('Say what the operator does for you.');
|
|
if (data.website && !/^https?:\/\//i.test(data.website)) data.website = `https://${data.website}`;
|
|
return data;
|
|
}
|
|
|
|
// @desc The operator register
|
|
// @route GET /api/privacy/operators
|
|
// @access Private/Admin
|
|
const listOperators = async (req, res) => {
|
|
try {
|
|
res.json(await prisma.operator.findMany({ orderBy: [{ order: 'asc' }, { name: 'asc' }] }));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/privacy/operators
|
|
const createOperator = async (req, res) => {
|
|
try {
|
|
const data = operatorData(req.body);
|
|
if (data.order === undefined) data.order = (await prisma.operator.count()) + 1;
|
|
const op = await prisma.operator.create({ data });
|
|
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_created', targetType: 'operator', targetId: op.id, metadata: { name: op.name }, ip: getClientIp(req) });
|
|
res.status(201).json(op);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route PUT /api/privacy/operators/:id
|
|
const updateOperator = async (req, res) => {
|
|
try {
|
|
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
|
|
if (!existing) { res.status(404); throw new Error('Operator not found'); }
|
|
const op = await prisma.operator.update({ where: { id: existing.id }, data: operatorData(req.body, { partial: true }) });
|
|
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_updated', targetType: 'operator', targetId: op.id, metadata: { name: op.name, active: op.active }, ip: getClientIp(req) });
|
|
res.json(op);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route DELETE /api/privacy/operators/:id
|
|
const deleteOperator = async (req, res) => {
|
|
try {
|
|
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
|
|
if (!existing) { res.status(404); throw new Error('Operator not found'); }
|
|
await prisma.operator.delete({ where: { id: existing.id } });
|
|
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_deleted', targetType: 'operator', targetId: existing.id, metadata: { name: existing.name }, ip: getClientIp(req) });
|
|
res.json({ message: 'Operator removed.' });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// ─── Consent log ──────────────────────────────────────────────────────────────
|
|
|
|
// Filter on the person's name or email.
|
|
function personWhere(q) {
|
|
return q ? { OR: [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] } : undefined;
|
|
}
|
|
|
|
// @desc Proof of consent: ?type=news (news opt-in changes), notice (privacy notice
|
|
// acceptances) or parental (parental consent answers); ?q= name/email; ?page=
|
|
// @route GET /api/privacy/consent-log
|
|
// @access Private/Admin
|
|
const consentLog = async (req, res) => {
|
|
try {
|
|
const type = String(req.query.type || 'news');
|
|
const q = String(req.query.q || '').trim();
|
|
const skip = (page(req) - 1) * PAGE_SIZE;
|
|
const user = { select: { name: true, email: true } };
|
|
|
|
if (type === 'news') {
|
|
const where = q ? { OR: [{ user: personWhere(q) }, { address: { contains: q, mode: 'insensitive' } }] } : {};
|
|
const [rows, total] = await Promise.all([
|
|
prisma.communicationConsent.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
|
|
prisma.communicationConsent.count({ where }),
|
|
]);
|
|
return res.json({
|
|
total, pageSize: PAGE_SIZE,
|
|
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || r.address, channel: r.channel, granted: r.granted, source: r.source, ip: r.ip })),
|
|
});
|
|
}
|
|
if (type === 'notice') {
|
|
const where = q ? { user: personWhere(q) } : {};
|
|
const [rows, total] = await Promise.all([
|
|
prisma.noticeAcceptance.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
|
|
prisma.noticeAcceptance.count({ where }),
|
|
]);
|
|
return res.json({
|
|
total, pageSize: PAGE_SIZE,
|
|
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || null, version: r.version, source: r.source, ip: r.ip })),
|
|
});
|
|
}
|
|
if (type === 'parental') {
|
|
const where = {
|
|
status: { in: ['given', 'declined'] },
|
|
...(q ? { OR: [{ attendee: { name: { contains: q, mode: 'insensitive' } } }, { parentName: { contains: q, mode: 'insensitive' } }, { parentEmail: { contains: q, mode: 'insensitive' } }, { signedName: { contains: q, mode: 'insensitive' } }] } : {}),
|
|
};
|
|
const [rows, total] = await Promise.all([
|
|
prisma.consentRequest.findMany({
|
|
where, orderBy: { respondedAt: 'desc' }, skip, take: PAGE_SIZE,
|
|
select: { id: true, status: true, signedName: true, parentName: true, textVersion: true, photoConsent: true, respondedAt: true, ip: true, medicalPurgedAt: true, attendee: { select: { name: true } }, event: { select: { title: true } } },
|
|
}),
|
|
prisma.consentRequest.count({ where }),
|
|
]);
|
|
return res.json({
|
|
total, pageSize: PAGE_SIZE,
|
|
rows: rows.map(r => ({
|
|
id: r.id, at: r.respondedAt, child: r.attendee?.name, event: r.event?.title, status: r.status, signedName: r.signedName,
|
|
parentName: r.parentName, textVersion: r.textVersion, photoConsent: r.photoConsent, ip: r.ip, medicalDeleted: !!r.medicalPurgedAt,
|
|
})),
|
|
});
|
|
}
|
|
res.status(400);
|
|
throw new Error('type must be news, notice or parental');
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// ─── Access log ───────────────────────────────────────────────────────────────
|
|
|
|
// @desc Who exported or viewed personal information in bulk (?action=, ?actorId=, ?from=, ?to=, ?page=)
|
|
// @route GET /api/privacy/access-log
|
|
// @access Private/Admin
|
|
const accessLog = async (req, res) => {
|
|
try {
|
|
const where = {};
|
|
if (req.query.action) where.action = String(req.query.action);
|
|
if (req.query.sensitive === 'true') where.sensitive = true;
|
|
if (req.query.actorId) where.actorId = String(req.query.actorId);
|
|
if (req.query.from || req.query.to) {
|
|
where.createdAt = {};
|
|
if (req.query.from) where.createdAt.gte = new Date(String(req.query.from));
|
|
if (req.query.to) where.createdAt.lte = new Date(String(req.query.to));
|
|
}
|
|
const [rows, total, actors] = await Promise.all([
|
|
prisma.dataAccessLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page(req) - 1) * PAGE_SIZE, take: PAGE_SIZE, include: { actor: { select: { name: true } } } }),
|
|
prisma.dataAccessLog.count({ where }),
|
|
prisma.dataAccessLog.findMany({ where: { actorId: { not: null } }, distinct: ['actorId'], select: { actorId: true, actor: { select: { name: true } } } }),
|
|
]);
|
|
res.json({
|
|
total, pageSize: PAGE_SIZE,
|
|
actions: ACCESS_ACTIONS,
|
|
actors: actors.map(a => ({ id: a.actorId, name: a.actor?.name || 'Unknown' })),
|
|
rows: rows.map(r => ({ id: r.id, at: r.createdAt, actor: r.actor?.name || 'Deleted user', actorRole: r.actorRole, action: r.action, what: r.what, records: r.records, eventId: r.eventId, ip: r.ip, sensitive: r.sensitive })),
|
|
});
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// ─── Readiness ────────────────────────────────────────────────────────────────
|
|
|
|
// @desc POPIA readiness checklist with a score
|
|
// @route GET /api/privacy/readiness
|
|
// @access Private/Admin
|
|
const getReadiness = async (req, res) => {
|
|
try {
|
|
res.json(await require('../utils/readiness').readiness());
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// ─── Retention ────────────────────────────────────────────────────────────────
|
|
|
|
async function retentionState() {
|
|
const retention = require('../utils/retention');
|
|
const { getSetting } = require('../utils/settingsCache');
|
|
const current = await retention.getPolicies();
|
|
const [preview, lastRun, medicalDays, backupCount] = await Promise.all([
|
|
retention.preview(current.policies),
|
|
retention.lastRun(),
|
|
getSetting('consent_medical_retention_days', ''),
|
|
getSetting('backup_retain_count', ''),
|
|
]);
|
|
return {
|
|
...current,
|
|
definitions: Object.fromEntries(Object.entries(retention.POLICIES).map(([k, d]) => [k, { label: d.label, description: d.description, minMonths: d.minMonths, defaults: d.defaults }])),
|
|
preview,
|
|
lastRun,
|
|
related: { medicalDays: parseInt(medicalDays, 10) || 30, backupCount: parseInt(backupCount, 10) || 14, analyticsDays: 90 },
|
|
};
|
|
}
|
|
|
|
// @desc Retention policies, what they'd affect now, and the last run
|
|
// @route GET /api/privacy/retention
|
|
// @access Private/Admin
|
|
const getRetention = async (req, res) => {
|
|
try {
|
|
res.json(await retentionState());
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc Save retention policies (body.policies: { key: { enabled, months } })
|
|
// @route PUT /api/privacy/retention
|
|
// @access Private/Admin
|
|
const saveRetention = async (req, res) => {
|
|
try {
|
|
const saved = await require('../utils/retention').savePolicies(req.body?.policies, { reviewedBy: req.user.name });
|
|
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'retention_policies_updated', targetType: 'retention', metadata: saved.policies, ip: getClientIp(req) });
|
|
res.json(await retentionState());
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc Run the retention job now
|
|
// @route POST /api/privacy/retention/run
|
|
// @access Private/Admin
|
|
const runRetentionNow = async (req, res) => {
|
|
try {
|
|
const result = await require('../utils/retention').runRetention(new Date());
|
|
res.json({ result, state: await retentionState() });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// ─── Breach register ──────────────────────────────────────────────────────────
|
|
|
|
const BREACH_TEXT = ['title', 'description', 'dataAffected', 'whoAffected', 'consequences', 'actionsTaken', 'adviceToPeople', 'causedBy'];
|
|
const BREACH_DATES = ['discoveredAt', 'occurredAt', 'regulatorNotifiedAt', 'peopleNotifiedAt'];
|
|
|
|
function breachData(body, { partial = false } = {}) {
|
|
const data = {};
|
|
for (const f of BREACH_TEXT) {
|
|
if (body?.[f] === undefined) continue;
|
|
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'title' ? 200 : 10000);
|
|
data[f] = v || (['title', 'description', 'dataAffected'].includes(f) ? '' : null);
|
|
}
|
|
for (const f of BREACH_DATES) {
|
|
if (body?.[f] === undefined) continue;
|
|
if (body[f] === null || body[f] === '') { data[f] = null; continue; }
|
|
const d = new Date(body[f]);
|
|
if (Number.isNaN(d.getTime())) throw new Error(`${f} is not a valid date.`);
|
|
data[f] = d;
|
|
}
|
|
if (body?.peopleAffected !== undefined) {
|
|
const n = body.peopleAffected === null || body.peopleAffected === '' ? null : parseInt(body.peopleAffected, 10);
|
|
if (n !== null && (!Number.isInteger(n) || n < 0)) throw new Error('People affected must be a whole number.');
|
|
data.peopleAffected = n;
|
|
}
|
|
if (body?.status !== undefined) {
|
|
if (!['open', 'contained', 'closed'].includes(body.status)) throw new Error('status must be open, contained or closed');
|
|
data.status = body.status;
|
|
}
|
|
for (const f of ['title', 'description', 'dataAffected']) {
|
|
if ((!partial || f in data) && !data[f]) throw new Error({ title: 'Give it a short title.', description: 'Describe what happened.', dataAffected: 'Say what information was affected.' }[f]);
|
|
}
|
|
if (!partial && !data.discoveredAt) throw new Error('When was it discovered?');
|
|
if (partial && 'discoveredAt' in data && !data.discoveredAt) throw new Error('When was it discovered?');
|
|
return data;
|
|
}
|
|
|
|
// @route GET /api/privacy/breaches
|
|
const listBreaches = async (req, res) => {
|
|
try {
|
|
res.json(await prisma.securityBreach.findMany({ orderBy: { discoveredAt: 'desc' }, include: { createdBy: { select: { name: true } } } }));
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route POST /api/privacy/breaches
|
|
const createBreach = async (req, res) => {
|
|
try {
|
|
const breach = await prisma.securityBreach.create({ data: { ...breachData(req.body), createdById: req.user.id } });
|
|
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_recorded', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title }, ip: getClientIp(req) });
|
|
res.status(201).json(breach);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @route PUT /api/privacy/breaches/:id
|
|
const updateBreach = async (req, res) => {
|
|
try {
|
|
const existing = await prisma.securityBreach.findUnique({ where: { id: req.params.id } });
|
|
if (!existing) { res.status(404); throw new Error('Not found'); }
|
|
const data = breachData(req.body, { partial: true });
|
|
const breach = await prisma.securityBreach.update({ where: { id: existing.id }, data });
|
|
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_updated', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title, changed: Object.keys(data), status: breach.status }, ip: getClientIp(req) });
|
|
res.json(breach);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
getReadiness,
|
|
getRetention,
|
|
saveRetention,
|
|
runRetentionNow,
|
|
listBreaches,
|
|
createBreach,
|
|
updateBreach,
|
|
getNotice,
|
|
acceptMyNotice,
|
|
getNoticeDraft,
|
|
saveNoticeDraft,
|
|
discardNoticeDraft,
|
|
publishNotice,
|
|
listOperators,
|
|
createOperator,
|
|
updateOperator,
|
|
deleteOperator,
|
|
consentLog,
|
|
accessLog,
|
|
};
|