25681075b4
24.6 (Portal kann Consent auf PENDING zurücksetzen): - gdpr.controller updateCustomerConsent prüft jetzt explizit, dass der Portal-User nur GRANTED oder WITHDRAWN setzen kann. PENDING ist nur der initiale System-Status; ein Reset darauf hätte die DSGVO-Auswertung verfälscht. 26.7 (documentPath ohne Validierung): - Neuer Helper isValidDocumentPath + assertValidDocumentPath in utils/sanitize: nur /?uploads/<safe>, keine "..", keine javascript:/data:/vbscript:, kein HTML. - consent.service.updateConsent ruft den Assert auf – Defense-in- Depth gegen zukünftige Caller, die documentPath aus User-Input durchreichen könnten. - authorization.service.grantAuthorization analog. - Cleanup-Skript (prisma/cleanup-xss-and-mass-assignment) entfernt seine lokale Kopie der Path-Validierung und nutzt den shared Helper – Single Source of Truth. 27.1 (Altdaten in Staging-DB): - Cleanup-Skript läuft sowieso bei jedem Container-Start. Nina- Records mit "../../../etc/passwd" werden beim nächsten Restart genullt (oder verschwinden mit dem VM-Snapshot-Wechsel). Live-Test isValidDocumentPath: 13/13 OK – legitime Pfade durch, Traversal/JS-URI/HTML blockiert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
293 lines
8.0 KiB
TypeScript
293 lines
8.0 KiB
TypeScript
import { ConsentType, ConsentStatus } from '@prisma/client';
|
||
import prisma from '../lib/prisma.js';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { assertValidDocumentPath } from '../utils/sanitize.js';
|
||
|
||
// Whitelist legitimer Werte für CustomerConsent.source. Schema-Kommentar:
|
||
// "portal", "telefon", "papier", "email". Public-Link-Flow nutzt
|
||
// 'public-link', CRM-Backend-Override 'crm-backend'. Alles andere
|
||
// (z.B. "ADMIN_OVERRIDE", "<script>") wird abgelehnt – Pentest 2026-05-20.
|
||
export const ALLOWED_CONSENT_SOURCES: ReadonlySet<string> = new Set([
|
||
'portal',
|
||
'public-link',
|
||
'telefon',
|
||
'papier',
|
||
'email',
|
||
'crm-backend',
|
||
]);
|
||
|
||
export function sanitizeConsentSource(value: unknown, fallback: string): string {
|
||
const v = typeof value === 'string' ? value : '';
|
||
return ALLOWED_CONSENT_SOURCES.has(v) ? v : fallback;
|
||
}
|
||
|
||
export interface UpdateConsentData {
|
||
status: ConsentStatus;
|
||
source?: string;
|
||
documentPath?: string;
|
||
version?: string;
|
||
ipAddress?: string;
|
||
createdBy: string;
|
||
}
|
||
|
||
/**
|
||
* Holt alle Einwilligungen eines Kunden
|
||
*/
|
||
export async function getCustomerConsents(customerId: number) {
|
||
const consents = await prisma.customerConsent.findMany({
|
||
where: { customerId },
|
||
orderBy: { consentType: 'asc' },
|
||
});
|
||
|
||
// Alle verfügbaren Consent-Typen mit Status
|
||
const allTypes = Object.values(ConsentType);
|
||
const consentMap = new Map(consents.map((c) => [c.consentType, c]));
|
||
|
||
return allTypes.map((type) => {
|
||
const existing = consentMap.get(type);
|
||
return existing || {
|
||
id: null,
|
||
customerId,
|
||
consentType: type,
|
||
status: 'PENDING' as ConsentStatus,
|
||
grantedAt: null,
|
||
withdrawnAt: null,
|
||
source: null,
|
||
documentPath: null,
|
||
version: null,
|
||
ipAddress: null,
|
||
createdBy: null,
|
||
createdAt: null,
|
||
updatedAt: null,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Aktualisiert oder erstellt eine Einwilligung
|
||
*/
|
||
export async function updateConsent(
|
||
customerId: number,
|
||
consentType: ConsentType,
|
||
data: UpdateConsentData
|
||
) {
|
||
// Prüfen ob Kunde existiert
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
});
|
||
|
||
if (!customer) {
|
||
throw new Error('Kunde nicht gefunden');
|
||
}
|
||
|
||
// Pentest 26.7: documentPath darf nur ein gültiger /uploads/<safe>-Pfad
|
||
// sein. Aktuell hat KEIN Endpoint diesen Wert aus User-Input gemappt
|
||
// (Portal: nicht aus Body, Admin-Auth-Upload: server-generated). Diese
|
||
// Service-Side-Validation ist Defense-in-Depth gegen zukünftige Caller.
|
||
assertValidDocumentPath(data.documentPath, 'documentPath');
|
||
|
||
const now = new Date();
|
||
const updateData = {
|
||
status: data.status,
|
||
source: data.source,
|
||
documentPath: data.documentPath,
|
||
version: data.version,
|
||
ipAddress: data.ipAddress,
|
||
grantedAt: data.status === 'GRANTED' ? now : undefined,
|
||
withdrawnAt: data.status === 'WITHDRAWN' ? now : undefined,
|
||
};
|
||
|
||
const result = await prisma.customerConsent.upsert({
|
||
where: {
|
||
customerId_consentType: { customerId, consentType },
|
||
},
|
||
update: updateData,
|
||
create: {
|
||
customerId,
|
||
consentType,
|
||
...updateData,
|
||
createdBy: data.createdBy,
|
||
},
|
||
});
|
||
|
||
// Bei Widerruf: Datenschutz-PDF löschen wenn keine Einwilligung mehr besteht
|
||
if (data.status === 'WITHDRAWN') {
|
||
await deletePrivacyPdfOnWithdraw(customerId);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Holt die Historie einer Einwilligung (aus Audit-Logs)
|
||
*/
|
||
export async function getConsentHistory(customerId: number, consentType: ConsentType) {
|
||
// Aus Audit-Logs die Änderungen dieser Einwilligung abrufen
|
||
const logs = await prisma.auditLog.findMany({
|
||
where: {
|
||
resourceType: 'CustomerConsent',
|
||
dataSubjectId: customerId,
|
||
changesAfter: { contains: consentType },
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 50,
|
||
});
|
||
|
||
return logs;
|
||
}
|
||
|
||
/**
|
||
* Prüft ob eine bestimmte Einwilligung erteilt wurde
|
||
*/
|
||
export async function hasConsent(customerId: number, consentType: ConsentType): Promise<boolean> {
|
||
const consent = await prisma.customerConsent.findUnique({
|
||
where: {
|
||
customerId_consentType: { customerId, consentType },
|
||
},
|
||
});
|
||
|
||
return consent?.status === 'GRANTED';
|
||
}
|
||
|
||
/**
|
||
* Prüft ob ein Kunde die DSGVO-Einwilligung erfüllt hat.
|
||
* Erfüllt = entweder privacyPolicyPath vorhanden ODER alle Online-Consents GRANTED.
|
||
*/
|
||
export async function hasFullConsent(customerId: number): Promise<{
|
||
hasConsent: boolean;
|
||
hasPaperConsent: boolean;
|
||
hasOnlineConsent: boolean;
|
||
consentDetails: { type: string; status: string }[];
|
||
consentHash: string | null;
|
||
}> {
|
||
// Prüfe ob Papier-Datenschutzerklärung vorhanden
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: { privacyPolicyPath: true, consentHash: true },
|
||
});
|
||
|
||
const hasPaperConsent = !!customer?.privacyPolicyPath;
|
||
|
||
// Online-Consents prüfen
|
||
const allTypes = Object.values(ConsentType);
|
||
const consents = await prisma.customerConsent.findMany({
|
||
where: { customerId },
|
||
});
|
||
|
||
const consentMap = new Map(consents.map((c) => [c.consentType, c.status]));
|
||
const consentDetails = allTypes.map((type) => ({
|
||
type,
|
||
status: (consentMap.get(type) || 'PENDING') as string,
|
||
}));
|
||
|
||
const hasOnlineConsent = allTypes.every(
|
||
(type) => consentMap.get(type) === 'GRANTED'
|
||
);
|
||
|
||
return {
|
||
hasConsent: hasPaperConsent || hasOnlineConsent,
|
||
hasPaperConsent,
|
||
hasOnlineConsent,
|
||
consentDetails,
|
||
consentHash: customer?.consentHash || null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Widerruft alle Einwilligungen eines Kunden
|
||
*/
|
||
export async function withdrawAllConsents(customerId: number, withdrawnBy: string) {
|
||
const result = await prisma.customerConsent.updateMany({
|
||
where: {
|
||
customerId,
|
||
status: 'GRANTED',
|
||
},
|
||
data: {
|
||
status: 'WITHDRAWN',
|
||
withdrawnAt: new Date(),
|
||
},
|
||
});
|
||
|
||
// Datenschutz-PDF löschen
|
||
await deletePrivacyPdfOnWithdraw(customerId);
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Löscht die Datenschutz-PDF bei Widerruf.
|
||
* Sobald auch nur eine Einwilligung widerrufen wird, ist die Gesamteinwilligung ungültig.
|
||
*/
|
||
async function deletePrivacyPdfOnWithdraw(customerId: number) {
|
||
const customer = await prisma.customer.findUnique({
|
||
where: { id: customerId },
|
||
select: { privacyPolicyPath: true },
|
||
});
|
||
|
||
if (customer?.privacyPolicyPath) {
|
||
try {
|
||
const filePath = path.join(process.cwd(), customer.privacyPolicyPath);
|
||
if (fs.existsSync(filePath)) {
|
||
fs.unlinkSync(filePath);
|
||
}
|
||
} catch (err) {
|
||
console.error('Fehler beim Löschen der Datenschutz-PDF:', err);
|
||
}
|
||
|
||
await prisma.customer.update({
|
||
where: { id: customerId },
|
||
data: { privacyPolicyPath: null },
|
||
});
|
||
|
||
console.log(`Datenschutz-PDF für Kunde ${customerId} gelöscht (Einwilligung widerrufen)`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Consent-Übersicht für DSGVO-Dashboard
|
||
*/
|
||
export async function getConsentOverview() {
|
||
const allConsents = await prisma.customerConsent.groupBy({
|
||
by: ['consentType', 'status'],
|
||
_count: { id: true },
|
||
});
|
||
|
||
// Gruppieren nach Typ
|
||
const overview: Record<string, { granted: number; withdrawn: number; pending: number }> = {};
|
||
|
||
for (const type of Object.values(ConsentType)) {
|
||
overview[type] = { granted: 0, withdrawn: 0, pending: 0 };
|
||
}
|
||
|
||
for (const row of allConsents) {
|
||
const type = row.consentType;
|
||
const status = row.status.toLowerCase() as 'granted' | 'withdrawn' | 'pending';
|
||
overview[type][status] = row._count.id;
|
||
}
|
||
|
||
return overview;
|
||
}
|
||
|
||
/**
|
||
* Consent-Typ Labels für UI
|
||
*/
|
||
export const CONSENT_TYPE_LABELS: Record<ConsentType, { label: string; description: string }> = {
|
||
DATA_PROCESSING: {
|
||
label: 'Datenverarbeitung',
|
||
description: 'Grundlegende Verarbeitung personenbezogener Daten zur Vertragserfüllung',
|
||
},
|
||
MARKETING_EMAIL: {
|
||
label: 'Elektronisches Marketing',
|
||
description: 'Zusendung von Werbung und Angeboten über elektronische Kommunikationswege (E-Mail, Messenger etc.)',
|
||
},
|
||
MARKETING_PHONE: {
|
||
label: 'Telefonmarketing',
|
||
description: 'Kontaktaufnahme zu Werbezwecken per Telefon',
|
||
},
|
||
DATA_SHARING_PARTNER: {
|
||
label: 'Datenweitergabe',
|
||
description: 'Weitergabe von Daten an Partnerunternehmen',
|
||
},
|
||
};
|