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:
@@ -93,9 +93,34 @@ async function main() {
|
|||||||
// Developer: alles
|
// Developer: alles
|
||||||
const developerPermIds = allPermissions.map((p) => p.id);
|
const developerPermIds = allPermissions.map((p) => p.id);
|
||||||
|
|
||||||
// DSGVO: audit + gdpr komplett
|
// DSGVO: Datenschutz-Verwaltung plus LESENDEN Zugriff aufs Audit-Protokoll.
|
||||||
|
//
|
||||||
|
// Bewusst OHNE `audit:admin`. Diese Rolle beaufsichtigt das Protokoll - sie
|
||||||
|
// darf es nicht umschreiben koennen. Mit `audit:admin` haette ein
|
||||||
|
// DSGVO-Beauftragter `seal-backlog`, `rehash` und `cleanup`, also die Mittel,
|
||||||
|
// seine eigene Beweisgrundlage zu ersetzen. Wer prueft und wer eingreift,
|
||||||
|
// sind zwei Rollen (Pentest R186, im Anschluss an R184-02: falsche Domaene,
|
||||||
|
// zu breit gebuendelt).
|
||||||
const gdprPermIds = allPermissions
|
const gdprPermIds = allPermissions
|
||||||
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.resource === 'gdpr' ||
|
||||||
|
(p.resource === 'audit' && (p.action === 'read' || p.action === 'export')),
|
||||||
|
)
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
|
// Audit-Betrieb: die eingreifenden Rechte am Protokoll - versiegeln,
|
||||||
|
// neu berechnen, aufraeumen, Aufbewahrung aendern. Plus `audit:read`, denn
|
||||||
|
// siegeln zu duerfen ohne das Ergebnis sehen zu koennen waere unbrauchbar.
|
||||||
|
//
|
||||||
|
// Eigene versteckte Rolle statt einem Anhaengsel an Admin: So weitet sich
|
||||||
|
// durch die Umstellung fuer KEIN bestehendes Konto etwas aus. Wer eingreifen
|
||||||
|
// koennen soll, bekommt es ausdruecklich - und diese Zuweisung ist selbst
|
||||||
|
// ein sichtbarer Vorgang.
|
||||||
|
const auditBetriebPermIds = allPermissions
|
||||||
|
.filter(
|
||||||
|
(p) => p.resource === 'audit' && (p.action === 'read' || p.action === 'admin'),
|
||||||
|
)
|
||||||
.map((p) => p.id);
|
.map((p) => p.id);
|
||||||
|
|
||||||
// Gegenbuch: NUR audit:read.
|
// Gegenbuch: NUR audit:read.
|
||||||
@@ -152,7 +177,8 @@ async function main() {
|
|||||||
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
||||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
||||||
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
||||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
||||||
|
{ name: 'Audit-Betrieb', description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern', permIds: auditBetriebPermIds },
|
||||||
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
||||||
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
||||||
{ name: 'Gegenbuch', description: 'Darf das Audit-Protokoll nur lesen und prüfen – sonst nichts', permIds: gegenbuchPermIds },
|
{ name: 'Gegenbuch', description: 'Darf das Audit-Protokoll nur lesen und prüfen – sonst nichts', permIds: gegenbuchPermIds },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { AUDIT_OPS_ROLLE } from '../services/user.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||||
@@ -162,9 +163,26 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
...beforeUser,
|
...beforeUser,
|
||||||
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
||||||
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
||||||
|
hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Der Audit-Betrieb-Haken vergibt die EINGREIFENDEN Rechte am Protokoll
|
||||||
|
// (versiegeln, neu berechnen, aufraeumen, Aufbewahrung). Ihn zu setzen ist
|
||||||
|
// kein gewoehnliches Benutzer-Update: Er entscheidet, wer die
|
||||||
|
// Beweisgrundlage ersetzen darf.
|
||||||
|
//
|
||||||
|
// Eine eigene Rechte-Huerde davorzusetzen scheitert am Henne-Ei-Problem -
|
||||||
|
// nach der Aufteilung haelt zunaechst niemand `audit:admin`, und dann
|
||||||
|
// koennte ihn auch niemand vergeben. Stattdessen wird die Vergabe LAUT:
|
||||||
|
// CRITICAL im Protokoll und zusaetzlich in den Alarmkanal, so wie beim
|
||||||
|
// Dienstkonto-Kennzeichen (R184-01).
|
||||||
|
const setztAuditBetrieb =
|
||||||
|
data.hasAuditOpsAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
|
||||||
|
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
|
||||||
|
|
||||||
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
|
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
|
||||||
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
|
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
|
||||||
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
|
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
|
||||||
@@ -236,6 +254,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||||
|
hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)',
|
||||||
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
@@ -265,7 +284,7 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||||
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
||||||
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
||||||
sensitivity: setztDienstkonto ? 'CRITICAL' : undefined,
|
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
|
||||||
details: Object.keys(changes).length > 0 ? changes : undefined,
|
details: Object.keys(changes).length > 0 ? changes : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,6 +310,28 @@ export async function updateUser(req: AuthRequest, res: Response): Promise<void>
|
|||||||
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (setztAuditBetrieb) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: aktiviertAuditBetrieb
|
||||||
|
? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` +
|
||||||
|
'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' +
|
||||||
|
'ersetzen, gegen die Manipulation nachgewiesen wird.'
|
||||||
|
: `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: {
|
||||||
|
betroffenesKonto: user.email,
|
||||||
|
aktiviert: aktiviertAuditBetrieb,
|
||||||
|
selbstvergabe: user.id === req.user?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
|
|||||||
@@ -65,9 +65,10 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Get hidden role IDs
|
// 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: 'Developer' } }),
|
||||||
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
||||||
|
prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -78,11 +79,15 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
const hasGdprAccess = gdprRole
|
const hasGdprAccess = gdprRole
|
||||||
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
||||||
: false;
|
: false;
|
||||||
|
const hasAuditOpsAccess = auditOpsRole
|
||||||
|
? u.roles.some((ur) => ur.roleId === auditOpsRole.id)
|
||||||
|
: false;
|
||||||
return {
|
return {
|
||||||
...u,
|
...u,
|
||||||
roles: u.roles.map((r) => r.role),
|
roles: u.roles.map((r) => r.role),
|
||||||
hasDeveloperAccess,
|
hasDeveloperAccess,
|
||||||
hasGdprAccess,
|
hasGdprAccess,
|
||||||
|
hasAuditOpsAccess,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
pagination: buildPaginationResponse(page, limit, total),
|
pagination: buildPaginationResponse(page, limit, total),
|
||||||
@@ -146,6 +151,7 @@ export async function createUser(data: {
|
|||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
@@ -190,6 +196,10 @@ export async function createUser(data: {
|
|||||||
await setUserGdprAccess(user.id, true);
|
await setUserGdprAccess(user.id, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.hasAuditOpsAccess) {
|
||||||
|
await setUserAuditOpsAccess(user.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,12 +216,13 @@ export async function updateUser(
|
|||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: 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
|
// Check if this would remove the last admin
|
||||||
const isBeingDeactivated = userData.isActive === false;
|
const isBeingDeactivated = userData.isActive === false;
|
||||||
@@ -348,6 +359,10 @@ export async function updateUser(
|
|||||||
await setUserGdprAccess(id, hasGdprAccess);
|
await setUserGdprAccess(id, hasGdprAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasAuditOpsAccess !== undefined) {
|
||||||
|
await setUserAuditOpsAccess(id, hasAuditOpsAccess);
|
||||||
|
}
|
||||||
|
|
||||||
return getUserById(id);
|
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
|
// Helper to set GDPR access for a user
|
||||||
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
||||||
// Get or create DSGVO role
|
// Get or create DSGVO role
|
||||||
@@ -415,17 +440,24 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!gdprRole) {
|
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({
|
const gdprPermissions = await prisma.permission.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
|
OR: [
|
||||||
|
{ resource: 'gdpr' },
|
||||||
|
{ resource: 'audit', action: { in: ['read', 'export'] } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
gdprRole = await prisma.role.create({
|
gdprRole = await prisma.role.create({
|
||||||
data: {
|
data: {
|
||||||
name: 'DSGVO',
|
name: 'DSGVO',
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
|
||||||
permissions: {
|
permissions: {
|
||||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
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) {
|
export async function deleteUser(id: number) {
|
||||||
// Check if user is an admin
|
// Check if user is an admin
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
@@ -654,6 +654,10 @@ const USER_UPDATABLE_FIELDS = [
|
|||||||
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
||||||
'hasGdprAccess',
|
'hasGdprAccess',
|
||||||
'hasDeveloperAccess',
|
'hasDeveloperAccess',
|
||||||
|
// Eingreifende Audit-Rechte (versiegeln/aufraeumen/Aufbewahrung), bewusst
|
||||||
|
// getrennt von hasGdprAccess: Aufsicht und Eingriff sind zwei Rollen
|
||||||
|
// (Pentest R186). Mappt auf die versteckte Rolle 'Audit-Betrieb'.
|
||||||
|
'hasAuditOpsAccess',
|
||||||
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
||||||
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
||||||
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
||||||
|
|||||||
@@ -97,6 +97,43 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **⚖️ Aufsicht und Eingriff getrennt: neuer Haken „Audit-Betrieb"** (2026-08-26)
|
||||||
|
- 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 können. Dieselbe Klasse wie R184-02: falsche
|
||||||
|
Domäne, zu breit gebündelt.
|
||||||
|
- **Der naheliegende Fix wäre falsch gewesen.** `audit:admin` einfach aus
|
||||||
|
der DSGVO-Rolle zu streichen hätte es heimatlos gemacht: Die Admin-Rolle
|
||||||
|
ist ausdrücklich **ohne** `audit`/`gdpr` gebaut, einzige verbleibende
|
||||||
|
Quelle wäre der Entwicklerzugriff – der *alles* gibt. Prod versiegeln
|
||||||
|
hätte dann Vollzugriff vorausgesetzt.
|
||||||
|
- Deshalb eine eigene versteckte Rolle **`Audit-Betrieb`**
|
||||||
|
(`audit:read` + `audit:admin`), zugewiesen über eine Checkbox wie
|
||||||
|
DSGVO/Entwickler. DSGVO behält `audit:read` + `audit:export` + `gdpr:*`.
|
||||||
|
**Für kein bestehendes Konto weitet sich etwas aus** – im Gegenteil, es
|
||||||
|
wird enger, und wer eingreifen können soll, bekommt es ausdrücklich.
|
||||||
|
- **Keine zusätzliche Rechte-Hürde davor**, weil das am Henne-Ei-Problem
|
||||||
|
scheitert: Nach der Aufteilung hält zunächst niemand `audit:admin`, könnte
|
||||||
|
ihn also auch niemand vergeben. Stattdessen wird die Vergabe **laut** –
|
||||||
|
CRITICAL im Protokoll **und** `PERMISSION_CHANGED`/CRITICAL im Alarmkanal,
|
||||||
|
inklusive 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 Bündelung stillschweigend zurückgebracht hätte.
|
||||||
|
- **⚠️ Beim Deploy:** Bestehende DSGVO-Konten verlieren `audit:admin`. Wer
|
||||||
|
Prod versiegeln will, muss sich vorher „Audit-Betrieb" ankreuzen.
|
||||||
|
- **Gemeldet, nicht geändert:** Jeder mit `users:update` (also Admin) kann
|
||||||
|
sich DSGVO oder Entwicklerzugriff selbst vergeben – Entwickler heißt
|
||||||
|
*alle* Rechte. Das ist vorbestehend und gehört ins Rollenmodell-Thema des
|
||||||
|
Pentesters.
|
||||||
|
- Dateien: `backend/prisma/sync-roles.ts`,
|
||||||
|
`backend/src/services/user.service.ts`,
|
||||||
|
`backend/src/controllers/user.controller.ts`,
|
||||||
|
`backend/src/utils/sanitize.ts`,
|
||||||
|
`frontend/src/pages/users/UserList.tsx`,
|
||||||
|
`frontend/src/services/api.ts`, `tools/audit-notary/README.md`
|
||||||
|
|
||||||
- [x] **🏷️ Siegel über null Blättern meldet nicht mehr „intakt"** (2026-08-26)
|
- [x] **🏷️ Siegel über null Blättern meldet nicht mehr „intakt"** (2026-08-26)
|
||||||
- Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand
|
- Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand
|
||||||
vorfand, ist rechnerisch tadellos und schützt **nichts**. Gemeldet wurde
|
vorfand, ist rechnerisch tadellos und schützt **nichts**. Gemeldet wurde
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function UserList() {
|
|||||||
<td className="py-3 px-4">{user.email}</td>
|
<td className="py-3 px-4">{user.email}</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role: any) => (
|
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role: any) => (
|
||||||
<Badge key={role.id || role.name} variant="info">
|
<Badge key={role.id || role.name} variant="info">
|
||||||
{role.name}
|
{role.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -246,6 +246,7 @@ function UserModal({
|
|||||||
roleIds: [] as number[],
|
roleIds: [] as number[],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasDeveloperAccess: false,
|
hasDeveloperAccess: false,
|
||||||
|
hasAuditOpsAccess: false,
|
||||||
hasGdprAccess: false,
|
hasGdprAccess: false,
|
||||||
isServiceAccount: false,
|
isServiceAccount: false,
|
||||||
whatsappNumber: '',
|
whatsappNumber: '',
|
||||||
@@ -264,9 +265,10 @@ function UserModal({
|
|||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
|
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(r.name)).map((r: any) => r.id) || [],
|
||||||
isActive: (user as any).isActive ?? true,
|
isActive: (user as any).isActive ?? true,
|
||||||
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
||||||
|
hasAuditOpsAccess: (user as any).hasAuditOpsAccess ?? false,
|
||||||
hasGdprAccess: (user as any).hasGdprAccess ?? false,
|
hasGdprAccess: (user as any).hasGdprAccess ?? false,
|
||||||
isServiceAccount: (user as any).isServiceAccount ?? false,
|
isServiceAccount: (user as any).isServiceAccount ?? false,
|
||||||
whatsappNumber: (user as any).whatsappNumber || '',
|
whatsappNumber: (user as any).whatsappNumber || '',
|
||||||
@@ -283,6 +285,7 @@ function UserModal({
|
|||||||
roleIds: [],
|
roleIds: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasDeveloperAccess: false,
|
hasDeveloperAccess: false,
|
||||||
|
hasAuditOpsAccess: false,
|
||||||
hasGdprAccess: false,
|
hasGdprAccess: false,
|
||||||
isServiceAccount: false,
|
isServiceAccount: false,
|
||||||
whatsappNumber: '',
|
whatsappNumber: '',
|
||||||
@@ -326,6 +329,7 @@ function UserModal({
|
|||||||
roleIds: formData.roleIds,
|
roleIds: formData.roleIds,
|
||||||
isActive: formData.isActive,
|
isActive: formData.isActive,
|
||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
|
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
isServiceAccount: formData.isServiceAccount,
|
isServiceAccount: formData.isServiceAccount,
|
||||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||||
@@ -369,6 +373,7 @@ function UserModal({
|
|||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
roleIds: formData.roleIds,
|
roleIds: formData.roleIds,
|
||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
|
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
isServiceAccount: formData.isServiceAccount,
|
isServiceAccount: formData.isServiceAccount,
|
||||||
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||||
@@ -489,7 +494,7 @@ function UserModal({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role) => (
|
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role) => (
|
||||||
<label key={role.id} className="flex items-center gap-2">
|
<label key={role.id} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -533,7 +538,28 @@ function UserModal({
|
|||||||
<Shield className="w-4 h-4 text-blue-600" />
|
<Shield className="w-4 h-4 text-blue-600" />
|
||||||
DSGVO-Zugriff
|
DSGVO-Zugriff
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">(Audit-Logs, Datenschutz)</span>
|
<span className="text-sm text-gray-500">(Audit-Protokoll lesen, Datenschutz)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.hasAuditOpsAccess}
|
||||||
|
onChange={(e) => setFormData({ ...formData, hasAuditOpsAccess: e.target.checked })}
|
||||||
|
className="mt-1 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Shield className="w-4 h-4 text-amber-600" />
|
||||||
|
Audit-Betrieb
|
||||||
|
<span className="text-sm text-gray-500">(versiegeln, aufräumen, Aufbewahrung)</span>
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm text-gray-500 mt-0.5">
|
||||||
|
Erlaubt Eingriffe am Audit-Protokoll: Bestandssiegel setzen oder
|
||||||
|
ersetzen, aufräumen, Aufbewahrungsfristen ändern. Bewusst getrennt
|
||||||
|
vom DSGVO-Zugriff – wer das Protokoll beaufsichtigt, soll seine
|
||||||
|
eigene Beweisgrundlage nicht ersetzen können.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1548,11 +1548,12 @@ export const userApi = {
|
|||||||
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
||||||
const res = await api.post<ApiResponse<User>>('/users', data);
|
const res = await api.post<ApiResponse<User>>('/users', data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; confirm?: string }) => {
|
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; hasGdprAccess?: boolean; hasDeveloperAccess?: boolean; hasAuditOpsAccess?: boolean; confirm?: string }) => {
|
||||||
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -195,12 +195,12 @@ Im CRM, als Administrator:
|
|||||||
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
|
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
|
||||||
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
|
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
|
||||||
|
|
||||||
> **Nicht den DSGVO-Haken benutzen.** Der sieht naheliegend aus („Audit-Logs,
|
> **Weder den DSGVO- noch den Audit-Betrieb-Haken setzen.** Der DSGVO-Haken
|
||||||
> Datenschutz"), vergibt aber `audit:` **komplett** – einschließlich
|
> gibt zusätzlich Leserechte auf personenbezogene Daten und den Export; der
|
||||||
> `audit:admin` mit `seal-backlog`, `rehash` und `cleanup`. Ein Einbruch auf
|
> Haken „Audit-Betrieb" gibt `audit:admin` mit `seal-backlog`, `rehash` und
|
||||||
> dieser Maschine hätte damit nicht nur den Wächter, sondern gleich die Mittel,
|
> `cleanup`. Ein Einbruch auf dieser Maschine hätte damit nicht nur den
|
||||||
> das Bewachte umzuschreiben. Das Passwort steht hier im Klartext in der
|
> Wächter, sondern gleich die Mittel, das Bewachte umzuschreiben. Das Passwort
|
||||||
> `.env`; es muss so wenig wert sein wie möglich.
|
> steht hier im Klartext in der `.env`; es muss so wenig wert sein wie möglich.
|
||||||
|
|
||||||
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** – keine
|
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** – keine
|
||||||
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
|
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
|
||||||
|
|||||||
Reference in New Issue
Block a user