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:
@@ -27,6 +27,10 @@ const schema = z.object({
|
||||
.optional()
|
||||
.transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')),
|
||||
|
||||
// Aufbewahrung der Audit-Eintraege in Tagen. 0 schaltet das Aufraeumen ab.
|
||||
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
||||
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
||||
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
});
|
||||
|
||||
|
||||
+9
-2
@@ -1,10 +1,11 @@
|
||||
import { buildApp } from './app.js';
|
||||
import { bootstrap } from './bootstrap.js';
|
||||
import { cleanupExpiredSessions } from './auth/session.js';
|
||||
import { cleanupOldAuditEntries } from './services/audit.js';
|
||||
import { prisma } from './db.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const app = await buildApp();
|
||||
@@ -27,7 +28,13 @@ async function main(): Promise<void> {
|
||||
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
||||
})
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen'));
|
||||
}, SESSION_CLEANUP_INTERVAL_MS);
|
||||
|
||||
cleanupOldAuditEntries()
|
||||
.then((count) => {
|
||||
if (count > 0) app.log.info(`${count} abgelaufene Audit-Eintraege entfernt`);
|
||||
})
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen des Audit-Logs fehlgeschlagen'));
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
cleanup.unref();
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Steam kennt dieselbe Person in mehreren Schreibweisen. Damit ein Spieler
|
||||
* nicht doppelt angelegt wird, sobald eine Quelle das andere Format liefert,
|
||||
* wird intern immer die 64-Bit-Form gespeichert.
|
||||
*
|
||||
* STEAM_0:1:12345678 (Legacy, was Garry's Mod anzeigt)
|
||||
* [U:1:24691357] (SteamID3)
|
||||
* 76561198024691357 (SteamID64, die kanonische Form)
|
||||
*/
|
||||
|
||||
// Offset zwischen Account-ID und SteamID64.
|
||||
const BASE = 76561197960265728n;
|
||||
|
||||
const LEGACY = /^STEAM_[0-5]:([01]):(\d+)$/i;
|
||||
const STEAM3 = /^\[?U:1:(\d+)\]?$/i;
|
||||
const STEAM64 = /^\d{17}$/;
|
||||
|
||||
/**
|
||||
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurueck, wenn der Wert
|
||||
* nach keiner bekannten Steam-Schreibweise aussieht.
|
||||
*/
|
||||
export function normalizeSteamId(input: string): string | null {
|
||||
const value = input.trim();
|
||||
if (value.length === 0) return null;
|
||||
|
||||
const legacy = LEGACY.exec(value);
|
||||
if (legacy) {
|
||||
const universe = BigInt(legacy[1] as string);
|
||||
const accountNumber = BigInt(legacy[2] as string);
|
||||
return (BASE + accountNumber * 2n + universe).toString();
|
||||
}
|
||||
|
||||
const modern = STEAM3.exec(value);
|
||||
if (modern) {
|
||||
return (BASE + BigInt(modern[1] as string)).toString();
|
||||
}
|
||||
|
||||
if (STEAM64.test(value)) {
|
||||
const numeric = BigInt(value);
|
||||
// Alles unterhalb des Offsets waere keine Nutzer-ID.
|
||||
return numeric > BASE ? numeric.toString() : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Legacy-Form zur Anzeige, etwa fuer Server-Konsolen. */
|
||||
export function toLegacySteamId(steamId64: string): string | null {
|
||||
if (!STEAM64.test(steamId64)) return null;
|
||||
|
||||
const numeric = BigInt(steamId64);
|
||||
if (numeric <= BASE) return null;
|
||||
|
||||
const offset = numeric - BASE;
|
||||
return `STEAM_0:${offset % 2n}:${offset / 2n}`;
|
||||
}
|
||||
|
||||
export function looksLikeSteamId(input: string): boolean {
|
||||
const value = input.trim();
|
||||
return LEGACY.test(value) || STEAM3.test(value) || STEAM64.test(value);
|
||||
}
|
||||
@@ -96,11 +96,19 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
const passwordOk = await verifyPassword(digest, body.password);
|
||||
|
||||
if (!user || !passwordOk || !user.isActive) {
|
||||
await recordAudit(prisma, { actorId: null, actorName: body.username, ip: request.ip }, {
|
||||
// Der eingegebene Name wird nur protokolliert, wenn es das Konto wirklich
|
||||
// gibt. Sonst landet ein versehentlich ins Namensfeld getipptes Passwort
|
||||
// im Klartext im Audit-Log.
|
||||
await recordAudit(
|
||||
prisma,
|
||||
{ actorId: null, actorName: user ? user.username : null, ip: request.ip },
|
||||
{
|
||||
action: 'auth.login_failed',
|
||||
targetType: 'user',
|
||||
targetId: user?.id ?? null,
|
||||
}).catch(() => undefined);
|
||||
details: user ? { reason: user.isActive ? 'wrong_password' : 'account_disabled' } : { reason: 'unknown_account' },
|
||||
},
|
||||
).catch(() => undefined);
|
||||
throw unauthorized('Benutzername oder Passwort ist falsch');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||
|
||||
type Imports = typeof import('../src/services/imports.js');
|
||||
type Players = typeof import('../src/services/players.js');
|
||||
type Characters = typeof import('../src/services/characters.js');
|
||||
type Credits = typeof import('../src/services/credits.js');
|
||||
type Audit = typeof import('../src/services/audit.js');
|
||||
type Db = typeof import('../src/db.js');
|
||||
|
||||
let database: TestDatabase;
|
||||
let imports: Imports;
|
||||
let players: Players;
|
||||
let characters: Characters;
|
||||
let credits: Credits;
|
||||
let audit: Audit;
|
||||
let db: Db;
|
||||
let ctx: { actorId: number; actorName: string };
|
||||
|
||||
// Ein Export, wie ihn ein Shop liefern koennte: mehr Spalten als die App braucht.
|
||||
const CSV = [
|
||||
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
|
||||
'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
|
||||
].join('\n');
|
||||
|
||||
const MAPPING = {
|
||||
playerKey: 'Steam-ID',
|
||||
product: 'Produkt',
|
||||
orderId: 'Bestellnummer',
|
||||
createMissingPlayers: true,
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
database = await startTestDatabase(54333);
|
||||
process.env.DATABASE_URL = database.url;
|
||||
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
db = await import('../src/db.js');
|
||||
imports = await import('../src/services/imports.js');
|
||||
players = await import('../src/services/players.js');
|
||||
characters = await import('../src/services/characters.js');
|
||||
credits = await import('../src/services/credits.js');
|
||||
audit = await import('../src/services/audit.js');
|
||||
|
||||
const user = await db.prisma.user.create({
|
||||
data: { username: 'datenschuetzer', passwordHash: 'x', role: 'ADMIN' },
|
||||
});
|
||||
ctx = { actorId: user.id, actorName: user.username };
|
||||
|
||||
await db.prisma.productMapping.create({
|
||||
data: { matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await db.prisma.$disconnect();
|
||||
await database.stop();
|
||||
});
|
||||
|
||||
describe('Datenminimierung beim Import', () => {
|
||||
it('verwirft nach dem Buchen die nicht zugeordneten Spalten', async () => {
|
||||
const parsed = imports.parseUpload('shop.csv', Buffer.from(CSV, 'utf8'));
|
||||
const importId = await imports.createImport('shop.csv', parsed, ctx);
|
||||
await imports.applyMapping(importId, MAPPING, ctx);
|
||||
|
||||
const before = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
|
||||
const rawBefore = before.raw as Record<string, string>;
|
||||
assert.equal(rawBefore.Email, 'spieler@example.com', 'vor dem Buchen ist noch alles da');
|
||||
|
||||
await imports.commitImport(importId, ctx);
|
||||
|
||||
const after = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
|
||||
const rawAfter = after.raw as Record<string, string>;
|
||||
|
||||
assert.equal(rawAfter.Email, undefined, 'die Mailadresse ist weg');
|
||||
assert.equal(rawAfter.Rechnungsadresse, undefined, 'die Anschrift ist weg');
|
||||
assert.equal(rawAfter.Zahlungsart, undefined, 'die Zahlungsart ist weg');
|
||||
assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt');
|
||||
});
|
||||
|
||||
it('raeumt auch einen verworfenen Import auf', async () => {
|
||||
const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8'));
|
||||
const importId = await imports.createImport('shop2.csv', parsed, ctx);
|
||||
await imports.applyMapping(importId, MAPPING, ctx);
|
||||
|
||||
await imports.discardImport(importId, ctx);
|
||||
|
||||
const row = await db.prisma.importRow.findFirstOrThrow({ where: { importId } });
|
||||
const raw = row.raw as Record<string, string>;
|
||||
assert.equal(raw.Email, undefined, 'auch ohne Buchung bleiben keine Rohdaten liegen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fehlgeschlagene Anmeldung', () => {
|
||||
it('schreibt einen unbekannten Benutzernamen nicht ins Protokoll', async () => {
|
||||
// Genau der Fall: jemand tippt sein Passwort ins Namensfeld.
|
||||
const versehen = 'MeinGeheimesPasswort123';
|
||||
|
||||
await audit.recordAudit(db.prisma, { actorId: null, actorName: null, ip: '127.0.0.1' }, {
|
||||
action: 'auth.login_failed',
|
||||
targetType: 'user',
|
||||
targetId: null,
|
||||
details: { reason: 'unknown_account' },
|
||||
});
|
||||
|
||||
const entries = await db.prisma.auditLog.findMany({
|
||||
where: { action: 'auth.login_failed' },
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
assert.notEqual(entry.actorName, versehen);
|
||||
assert.equal(entry.actorName, null, 'ohne bekanntes Konto bleibt das Namensfeld leer');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loeschung eines Spielers', () => {
|
||||
it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => {
|
||||
// Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik.
|
||||
const csv = [
|
||||
'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart',
|
||||
'P-3;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal',
|
||||
].join('\n');
|
||||
|
||||
const parsed = imports.parseUpload('shop3.csv', Buffer.from(csv, 'utf8'));
|
||||
const importId = await imports.createImport('shop3.csv', parsed, ctx);
|
||||
await imports.applyMapping(importId, MAPPING, ctx);
|
||||
await imports.commitImport(importId, ctx);
|
||||
|
||||
const player = await db.prisma.player.findUniqueOrThrow({
|
||||
where: { steamId: '76561198000000501' },
|
||||
});
|
||||
await characters.createCharacter(player.id, { name: 'Testfigur' }, ctx);
|
||||
|
||||
await players.deletePlayer(player.id, ctx);
|
||||
|
||||
const gone = await db.prisma.player.findUnique({ where: { id: player.id } });
|
||||
assert.equal(gone, null, 'der Spieler ist weg');
|
||||
|
||||
const ledgerLeft = await db.prisma.creditLedger.count({ where: { playerId: player.id } });
|
||||
assert.equal(ledgerLeft, 0, 'die Guthaben-Zeilen sind mit weg');
|
||||
|
||||
const rows = await db.prisma.importRow.findMany({ where: { importId } });
|
||||
for (const row of rows) {
|
||||
assert.equal(row.playerKey, null, 'die SteamID steht nicht mehr in der Import-Zeile');
|
||||
assert.equal(row.resolvedPlayerId, null);
|
||||
assert.deepEqual(row.raw, {}, 'die Rohzeile ist geleert');
|
||||
}
|
||||
|
||||
const auditEntries = await db.prisma.auditLog.findMany({
|
||||
where: { targetType: 'player', targetId: String(player.id) },
|
||||
});
|
||||
assert.ok(auditEntries.length > 0, 'die Vorgaenge selbst bleiben nachvollziehbar');
|
||||
for (const entry of auditEntries) {
|
||||
const details = JSON.stringify(entry.details ?? {});
|
||||
assert.ok(
|
||||
!details.includes('76561198000000501'),
|
||||
`SteamID steckt noch in ${entry.action}: ${details}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aufbewahrung des Audit-Logs', () => {
|
||||
it('loescht Eintraege jenseits der Frist, neuere bleiben', async () => {
|
||||
const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000);
|
||||
|
||||
const veraltet = await db.prisma.auditLog.create({
|
||||
data: { action: 'test.alt', targetType: 'test', createdAt: alt },
|
||||
});
|
||||
const frisch = await db.prisma.auditLog.create({
|
||||
data: { action: 'test.frisch', targetType: 'test' },
|
||||
});
|
||||
|
||||
const removed = await audit.cleanupOldAuditEntries();
|
||||
assert.ok(removed >= 1);
|
||||
|
||||
assert.equal(await db.prisma.auditLog.findUnique({ where: { id: veraltet.id } }), null);
|
||||
assert.ok(await db.prisma.auditLog.findUnique({ where: { id: frisch.id } }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user