Datenminimierung: Rohdaten, Protokoll und Loeschung

Drei Befunde aus einer Durchsicht der gespeicherten personenbezogenen Daten:

Der Import legte bisher jede Spalte der hochgeladenen Datei dauerhaft ab,
auch Mailadressen oder Rechnungsdaten, die nie zugeordnet wurden. Nach dem
Buchen oder Verwerfen bleiben jetzt nur noch die gemappten Spalten stehen.

Ein fehlgeschlagener Login protokollierte den eingetippten Benutzernamen.
Wer sein Passwort ins Namensfeld tippt, hatte es damit im Klartext im
Audit-Log. Der Name wird nur noch gespeichert, wenn das Konto existiert.

Das Loeschen eines Spielers liess Spuren in den Import-Zeilen und in den
Details frueherer Audit-Eintraege zurueck. Beides wird jetzt mit abgeraeumt;
die Vorgaenge bleiben als Eintrag bestehen, nur ohne Inhalt.

Dazu eine Aufbewahrungsfrist fuer das Audit-Log (Standard 12 Monate, ueber
AUDIT_RETENTION_DAYS einstellbar). Die Guthaben-Historie ist davon nicht
betroffen - sie bleibt die Quelle der Wahrheit fuer bezahlte Ansprueche.
This commit is contained in:
2026-09-18 15:22:02 +02:00
parent f775b0a594
commit bc86e2df26
8 changed files with 363 additions and 12 deletions
+18 -3
View File
@@ -1,10 +1,12 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
export interface ActionContext {
// null fuer Vorgaenge ohne angemeldeten Account, etwa ein fehlgeschlagener Login.
// Beide null fuer Vorgaenge ohne bekanntes Konto, etwa einen fehlgeschlagenen
// Login auf einen Benutzernamen, den es gar nicht gibt.
actorId: number | null;
actorName: string;
actorName: string | null;
ip?: string | undefined;
}
@@ -25,7 +27,7 @@ export async function recordAudit(
await client.auditLog.create({
data: {
actorUserId: ctx.actorId,
actorName: ctx.actorName,
actorName: ctx.actorName ?? null,
action: entry.action,
targetType: entry.targetType,
targetId: entry.targetId == null ? null : String(entry.targetId),
@@ -34,3 +36,16 @@ export async function recordAudit(
},
});
}
/**
* Entfernt Audit-Eintraege, die aelter sind als die eingestellte Frist. Die
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit fuer
* bezahlte Ansprueche und hat eine andere Aufbewahrung als das Protokoll.
*/
export async function cleanupOldAuditEntries(): Promise<number> {
if (env.AUDIT_RETENTION_DAYS === 0) return 0;
const cutoff = new Date(Date.now() - env.AUDIT_RETENTION_DAYS * 24 * 3600 * 1000);
const result = await prisma.auditLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
return result.count;
}
+45
View File
@@ -163,6 +163,45 @@ function parseDate(value: string | undefined): Date | null {
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/**
* Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsaechlich
* zugeordnet wurden. Ein Shop-Export enthaelt oft deutlich mehr - Mailadresse,
* Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Laeuft,
* sobald ein Import abgeschlossen oder verworfen ist.
*/
async function pruneRawRows(
client: Pick<typeof prisma, 'importRow'>,
importId: number,
mapping: ColumnMapping | null,
): Promise<void> {
const keep = new Set(
[mapping?.playerKey, mapping?.playerName, mapping?.product, mapping?.orderId, mapping?.purchasedAt].filter(
(column): column is string => Boolean(column),
),
);
const rows = await client.importRow.findMany({
where: { importId },
select: { id: true, raw: true },
});
for (const row of rows) {
const raw = row.raw as Record<string, string> | null;
if (!raw) continue;
const reduced: Record<string, string> = {};
for (const column of keep) {
const value = raw[column];
if (value !== undefined) reduced[column] = value;
}
await client.importRow.update({
where: { id: row.id },
data: { raw: reduced as Prisma.InputJsonValue },
});
}
}
/**
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
@@ -402,6 +441,9 @@ export async function commitImport(importId: number, ctx: ActionContext) {
return { booked, createdPlayers: createdPlayers.size, import: updated };
});
// Nach dem Buchen werden die ungenutzten Spalten der Datei nicht mehr gebraucht.
await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null);
return result;
}
@@ -417,6 +459,9 @@ export async function discardImport(importId: number, ctx: ActionContext): Promi
data: { status: ImportStatus.DISCARDED },
});
// Ein verworfener Import braucht die Rohdaten erst recht nicht mehr.
await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null);
await recordAudit(prisma, ctx, {
action: 'import.discard',
targetType: 'import',
+30 -2
View File
@@ -132,6 +132,12 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
});
}
/**
* Loescht einen Spieler samt allem, was an ihm haengt. Spuren in den
* Import-Zeilen und in frueheren Audit-Eintraegen werden dabei mit entfernt,
* damit nach der Loeschung wirklich nichts Personenbezogenes uebrig bleibt.
* Die Audit-Eintraege selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
*/
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
const existing = await prisma.player.findUnique({
where: { id: playerId },
@@ -140,15 +146,37 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
if (!existing) throw notFound('Spieler');
await prisma.$transaction(async (tx) => {
// Rohzeilen der Importe verlieren ihren Personenbezug.
await tx.importRow.updateMany({
where: {
OR: [
{ resolvedPlayerId: playerId },
...(existing.steamId ? [{ playerKey: existing.steamId }] : []),
],
},
data: {
playerKey: null,
playerName: null,
resolvedPlayerId: null,
raw: {},
},
});
// Fruehere Audit-Eintraege zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
await tx.auditLog.updateMany({
where: { targetType: 'player', targetId: String(playerId) },
data: { details: { redacted: true } },
});
await tx.player.delete({ where: { id: playerId } });
await recordAudit(tx, ctx, {
action: 'player.delete',
targetType: 'player',
targetId: playerId,
// Bewusst ohne Name und SteamID: der Eintrag soll die Loeschung belegen,
// nicht die geloeschten Daten konservieren.
details: {
displayName: existing.displayName,
steamId: existing.steamId,
removedCharacters: existing._count.characters,
removedLedgerRows: existing._count.ledger,
},