Aufsicht und Eingriff getrennt: neuer Haken "Audit-Betrieb"

Die DSGVO-Rolle trug audit:* komplett, also auch audit:admin. Ein
DSGVO-Beauftragter konnte damit seal-backlog, rehash und cleanup - seine
eigene Beweisgrundlage ersetzen. Wer das Protokoll beaufsichtigt, darf es
nicht umschreiben koennen. Dieselbe Klasse wie R184-02: falsche Domaene,
zu breit gebuendelt.

Der naheliegende Fix waere falsch gewesen. audit:admin einfach aus der
DSGVO-Rolle zu streichen haette es heimatlos gemacht: Die Admin-Rolle ist
ausdruecklich ohne audit/gdpr gebaut, einzige verbleibende Quelle waere
der Entwicklerzugriff - der alles gibt. Prod versiegeln haette dann
Vollzugriff vorausgesetzt.

Deshalb eine eigene versteckte Rolle "Audit-Betrieb" (audit:read +
audit:admin), zugewiesen ueber eine Checkbox wie DSGVO/Entwickler. DSGVO
behaelt audit:read + audit:export + gdpr:*. Fuer kein bestehendes Konto
weitet sich etwas aus; es wird enger, und wer eingreifen koennen soll,
bekommt es ausdruecklich.

Keine zusaetzliche Rechte-Huerde davor, weil das am Henne-Ei-Problem
scheitert: Nach der Aufteilung haelt zunaechst niemand audit:admin,
koennte ihn also auch niemand vergeben. Stattdessen wird die Vergabe
laut - CRITICAL im Protokoll und PERMISSION_CHANGED/CRITICAL im
Alarmkanal, samt Kennzeichen, ob sich jemand den Haken selbst gesetzt hat.

Nebenbei geschlossen: setUserGdprAccess() legte die DSGVO-Rolle im
Notfallpfad mit audit:* komplett an - eine zweite Liste, die dasselbe
bedeuten sollte und die Buendelung stillschweigend zurueckgebracht haette.

ACHTUNG beim Deploy: Bestehende DSGVO-Konten verlieren audit:admin. Wer
Prod versiegeln will, muss sich vorher "Audit-Betrieb" ankreuzen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 17:16:27 +02:00
co-authored by Claude Opus 5
parent 909e523634
commit fc6f39eba0
8 changed files with 232 additions and 21 deletions
+81 -5
View File
@@ -65,9 +65,10 @@ export async function getAllUsers(filters: UserFilters) {
]);
// Get hidden role IDs
const [developerRole, gdprRole] = await Promise.all([
const [developerRole, gdprRole, auditOpsRole] = await Promise.all([
prisma.role.findFirst({ where: { name: 'Developer' } }),
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }),
]);
return {
@@ -78,11 +79,15 @@ export async function getAllUsers(filters: UserFilters) {
const hasGdprAccess = gdprRole
? u.roles.some((ur) => ur.roleId === gdprRole.id)
: false;
const hasAuditOpsAccess = auditOpsRole
? u.roles.some((ur) => ur.roleId === auditOpsRole.id)
: false;
return {
...u,
roles: u.roles.map((r) => r.role),
hasDeveloperAccess,
hasGdprAccess,
hasAuditOpsAccess,
};
}),
pagination: buildPaginationResponse(page, limit, total),
@@ -146,6 +151,7 @@ export async function createUser(data: {
customerId?: number;
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
hasAuditOpsAccess?: boolean;
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
@@ -190,6 +196,10 @@ export async function createUser(data: {
await setUserGdprAccess(user.id, true);
}
if (data.hasAuditOpsAccess) {
await setUserAuditOpsAccess(user.id, true);
}
return user;
}
@@ -206,12 +216,13 @@ export async function updateUser(
customerId?: number;
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
hasAuditOpsAccess?: boolean;
whatsappNumber?: string;
telegramUsername?: string;
signalNumber?: string;
}
) {
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data;
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
// Check if this would remove the last admin
const isBeingDeactivated = userData.isActive === false;
@@ -348,6 +359,10 @@ export async function updateUser(
await setUserGdprAccess(id, hasGdprAccess);
}
if (hasAuditOpsAccess !== undefined) {
await setUserAuditOpsAccess(id, hasAuditOpsAccess);
}
return getUserById(id);
}
@@ -407,6 +422,16 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
}
}
/**
* Name der versteckten Rolle fuer eingreifende Audit-Rechte.
*
* Getrennt von `DSGVO`, weil Aufsicht und Eingriff nicht dieselbe Rolle sein
* duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht
* ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim
* Containerstart angelegt.
*/
export const AUDIT_OPS_ROLLE = 'Audit-Betrieb';
// Helper to set GDPR access for a user
async function setUserGdprAccess(userId: number, enabled: boolean) {
// Get or create DSGVO role
@@ -415,17 +440,24 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
});
if (!gdprRole) {
// Create DSGVO role with all audit:* and gdpr:* permissions
// Rechte-Satz identisch zu sync-roles.ts: gdpr komplett, vom Audit-
// Protokoll nur LESEN und EXPORTIEREN. Ohne `audit:admin` - sonst
// brächte dieser Notfallpfad genau die Bündelung zurück, die wir gerade
// aufgelöst haben (zwei Listen, die dasselbe bedeuten sollen, laufen
// auseinander).
const gdprPermissions = await prisma.permission.findMany({
where: {
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
OR: [
{ resource: 'gdpr' },
{ resource: 'audit', action: { in: ['read', 'export'] } },
],
},
});
gdprRole = await prisma.role.create({
data: {
name: 'DSGVO',
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
permissions: {
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
},
@@ -457,6 +489,50 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
}
}
/**
* Eingreifende Audit-Rechte setzen: versiegeln, neu berechnen, aufraeumen,
* Aufbewahrung aendern. Bewusst getrennt vom DSGVO-Haken (siehe
* `AUDIT_OPS_ROLLE`).
*/
async function setUserAuditOpsAccess(userId: number, enabled: boolean) {
let rolle = await prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } });
if (!rolle) {
// Rechte-Satz identisch zu sync-roles.ts.
const rechte = await prisma.permission.findMany({
where: { resource: 'audit', action: { in: ['read', 'admin'] } },
});
rolle = await prisma.role.create({
data: {
name: AUDIT_OPS_ROLLE,
description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern',
permissions: { create: rechte.map((p) => ({ permissionId: p.id })) },
},
});
}
const hatRolle = await prisma.userRole.findFirst({
where: { userId, roleId: rolle.id },
});
if (enabled && !hatRolle) {
await prisma.userRole.create({ data: { userId, roleId: rolle.id } });
// Token invalidieren bei Rechteaenderung
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else if (!enabled && hatRolle) {
await prisma.userRole.delete({
where: { userId_roleId: { userId, roleId: rolle.id } },
});
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
}
}
export async function deleteUser(id: number) {
// Check if user is an admin
const user = await prisma.user.findUnique({