Rechtemodell Etappe 1: toter Katalog begradigt, Selbst-Erhoehung geschlossen

Vorarbeit fuer die Rollen-Oberflaeche. Eine Checkbox-Liste ueber einem
Katalog, der nicht stimmt, waere schlimmer als gar keine.

18 von 50 Rechten bewachten nichts: tariffs:*, cancellation-periods:*,
contract-durations:* und email-providers:* standen im Katalog und waren
anhakbar - die Routen prueften in Wahrheit providers:*, platforms:* und
settings:*. Die Routen gaten jetzt granular; eine rein additive Migration
vererbt jedes neue Recht an jede Rolle, die bisher das Sammelrecht hatte.
Nachgerechnet: keine Rolle hat etwas verloren.

Der Katalog stand dreifach und divergent - seed.ts, sync-roles.ts und,
abweichend, factoryReset. Die dritte Kopie kannte weder audit:* noch
gdpr:* und legte DSGVO, Audit-Betrieb und Gegenbuch gar nicht an: Nach
einem Werksreset konnte niemand mehr eine Auskunft nach Art. 15
ausfuehren, und aufgefallen waere es erst, wenn eine Frist laeuft. Jetzt
eine Quelle: src/config/rechte-katalog.ts.

Im selben Code: factoryReset setzte das Admin-Kennwort fest auf "admin"
mit bcrypt-Cost 10 - genau das, was seed.ts seit Pentest Runde 12
verbietet. Dieselbe Haertung war nur in einer der beiden Kopien
angekommen. Jetzt zufaellig, Cost 12, einmalig im Log.

Selbst-Erhoehung geschlossen: neues Recht roles:manage, getrennt von
users:*, dazu die Teilmengenregel in rechte.service.ts - niemand kann ein
Recht weitergeben, das er selbst nicht haelt. Sie greift auf allen vier
Wegen: Rolle anlegen, Rolle aendern, Rollen zuweisen und die drei Haken.
Der Haken-Weg war der wichtigste: Er brauchte nur users:create, ein
zweites Konto mit "Entwicklerzugriff" anlegen und sich damit anmelden -
die Developer-Rolle traegt alle Rechte. Geprueft wird der Zuwachs, nicht
der Endzustand, damit Entziehen erlaubt bleibt; und die Rechte des
Handelnden kommen frisch aus der Datenbank, nicht aus dem bis zu 15
Minuten alten Token.

Systemrollen gesperrt (Role.isSystem): Admin liess sich bisher umbenennen
oder leeren - und die versteckten Rollen haengen an ihrem Namen.
Role.isHidden ersetzt die im Frontend hartkodierte Namensliste, in der
"Gegenbuch" fehlte.

Rechteaenderung wirkt sofort: updateRole/deleteRole melden alle Traeger
ab. Werksreset und Backup-Restore verlangen zusaetzlich roles:manage -
beide loeschen alle Rollen und legen ein frisches admin@admin.com an.

Rollenpflege war der einzige Eingriff in die Rechtevergabe ohne
SecurityEvent, obwohl sie viele Konten auf einmal trifft. Jetzt
PERMISSION_CHANGED - ebenso fuer die bisher stummen Haken DSGVO und
Entwicklerzugriff.

Aussperr-Ausweg: prisma/rolle-zuweisen.ts. Umgeht die Regel bewusst - wer
Shell-Zugang hat, hat ohnehin die Datenbank; ein gestohlener Web-Zugang
hat ihn nicht. Schreibt ueber die Hash-Kette, nicht roh. Die Startwache
nennt den Befehl im Klartext.

Geprueft gegen Dev-DB und frische Wegwerf-DB: 7 Eskalationswege alle 403,
2 Gegenproben erlaubt; 6 Sperrtests auf Systemrollen alle 403, eigene
Rollen weiter aenderbar; Stammdaten-Lesen unveraendert 200;
Rechteaenderung sofort 401; Migration und sync-roles dreimal identisch;
db:seed auf bestehender DB ohne Wirkung auf die Rollenmatrix; frische
Installation mit allen 8 Systemrollen und ohne Hinweise.

Beim Deploy: Die Migration beendet alle Sitzungen einmalig. Noetig, weil
die Rechte im Token stehen - sonst liefen bis zu 15 Minuten 403er.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:49:30 +02:00
co-authored by Claude Opus 5
parent cca242119b
commit 77aeb69aeb
31 changed files with 1790 additions and 627 deletions
+13 -191
View File
@@ -1,205 +1,27 @@
/**
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
* Bringt Rechtekatalog und Systemrollen beim Container-Start auf Stand.
*
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
* System schon installiert hat, bekommt nachträglich hinzugefügte
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
* Hintergrund: seed.ts laeuft nur auf leeren Datenbanken (USER_COUNT=0). Wer
* das System schon installiert hat, bekommt nachtraeglich hinzugefuegte
* Rechte oder geaenderte Rollenzuordnungen sonst NICHT.
*
* Dieses Skript synchronisiert ausschließlich:
* - Permission-Katalog (resource/action-Paare aus dem Code)
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
* Mitarbeiter (Nur-Lesen), Kunde)
* Die Definition selbst steht in `src/config/rechte-katalog.ts`, die Logik in
* `src/services/rollen-sync.service.ts`. Dieses Skript ist nur noch der
* Einstiegspunkt fuer die Kommandozeile - bis 09/2026 trug es eine eigene
* Kopie des Katalogs, und es war nicht die einzige.
*
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
* laufenden Prod-DBs sicher.
* KEINE Stammdaten, KEINE Benutzer, KEINE Vertraege - auf laufenden
* Produktionsdatenbanken sicher.
*/
import { PrismaClient } from '@prisma/client';
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
const prisma = new PrismaClient();
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
customers: ['create', 'read', 'update', 'delete'],
contracts: ['create', 'read', 'update', 'delete'],
users: ['create', 'read', 'update', 'delete'],
platforms: ['create', 'read', 'update', 'delete'],
providers: ['create', 'read', 'update', 'delete'],
tariffs: ['create', 'read', 'update', 'delete'],
'cancellation-periods': ['create', 'read', 'update', 'delete'],
'contract-durations': ['create', 'read', 'update', 'delete'],
'contract-categories': ['create', 'read', 'update', 'delete'],
'email-providers': ['create', 'read', 'update', 'delete'],
settings: ['read', 'update'],
developer: ['access'],
emails: ['delete'],
audit: ['read', 'export', 'admin'],
gdpr: ['export', 'delete', 'admin'],
};
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
const existing = await prisma.rolePermission.findMany({
where: { roleId },
select: { permissionId: true },
});
const existingIds = new Set(existing.map((e) => e.permissionId));
const targetIds = new Set(permissionIds);
const missing = permissionIds.filter((id) => !existingIds.has(id));
if (missing.length > 0) {
await prisma.rolePermission.createMany({
data: missing.map((permissionId) => ({ roleId, permissionId })),
skipDuplicates: true,
});
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
}
const excess = existing
.filter((e) => !targetIds.has(e.permissionId))
.map((e) => e.permissionId);
if (excess.length > 0) {
await prisma.rolePermission.deleteMany({
where: { roleId, permissionId: { in: excess } },
});
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
}
}
async function main() {
console.log('[sync-roles] Permissions-Katalog upserten…');
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
for (const action of actions) {
await prisma.permission.upsert({
where: { resource_action: { resource, action } },
update: {},
create: { resource, action },
});
}
}
const allPermissions = await prisma.permission.findMany();
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
// sind separate hidden roles, über Checkboxen zugewiesen)
const adminPermIds = allPermissions
.filter(
(p) =>
!(p.resource === 'developer' && p.action === 'access') &&
p.resource !== 'audit' &&
p.resource !== 'gdpr'
)
.map((p) => p.id);
// Developer: alles
const developerPermIds = allPermissions.map((p) => p.id);
// 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
.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);
// Gegenbuch: NUR audit:read.
//
// Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint
// und /audit-logs/verify, und beide verlangen audit:read. Bis hierher gab es
// dafuer keine passende Rolle: Wer dem Dienstkonto Leserechte aufs Protokoll
// geben wollte, musste den DSGVO-Haken setzen - und der vergibt `audit:*`
// KOMPLETT, also auch `audit:admin` mit seal-backlog, rehash und cleanup.
//
// Damit haette ein Einbruch auf der Gegenbuch-Maschine nicht nur den
// Waechter gehabt, sondern gleich die Mittel, das Bewachte umzuschreiben -
// genau die Waesche aus R185-01, und genau die Trennung, wegen der das
// Gegenbuch ueberhaupt auf einer eigenen Maschine laeuft. Das Kennwort des
// Dienstkontos liegt dort im Klartext in der .env; es muss deshalb so wenig
// wert sein wie moeglich.
const gegenbuchPermIds = allPermissions
.filter((p) => p.resource === 'audit' && p.action === 'read')
.map((p) => p.id);
// Mitarbeiter: customers + contracts + read auf Stammdaten
const employeePermIds = allPermissions
.filter(
(p) =>
p.resource === 'customers' ||
p.resource === 'contracts' ||
(p.action === 'read' &&
[
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
].includes(p.resource))
)
.map((p) => p.id);
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
const readOnlyResources = [
'customers',
'contracts',
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
];
const readOnlyPermIds = allPermissions
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
.map((p) => p.id);
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
{ name: 'Admin', description: 'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz dafür die separaten Rollen DSGVO und Audit-Betrieb)', permIds: adminPermIds },
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
{ 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 (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: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
];
for (const r of rolesSpec) {
const role = await prisma.role.upsert({
where: { name: r.name },
update: { description: r.description },
create: { name: r.name, description: r.description },
});
await syncRolePermissions(role.id, r.permIds);
}
console.log('[sync-roles] fertig.');
}
main()
synchronisiereRechteUndRollen(prisma)
.catch((e) => {
console.error('[sync-roles] Fehler:', e);
console.error('[rollen-sync] Fehler:', e);
process.exit(1);
})
.finally(async () => {