Compare commits

..
33 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 5 0ef28c7a87 Aufraeumrunde vor Etappe 2: Fehlerhygiene, Admin-Kriterium, Portal-Riegel
Drei Nachrangpunkte aus dem Pentest, vor dem Schneiden des Rechtekatalogs.

1. R192-01 projektweit. Das Muster `error instanceof Error ? error.message`
stand 124-mal in 26 Controllern und konnte ueberall Serverpfade,
Spaltennamen und Bibliotheksinterna ausliefern. Zentral geloest statt
124-mal einzeln - das waere die Falle aus R186-01 und R188 gewesen:
utils/fehlerAntwort.ts mit antworteAufFehler(), jetzt 123 Aufrufe in 26
Dateien und eine Regel.

Die Unterscheidung laeuft ueber die Fehlerklasse. Neue Basisklasse
FachlicherFehler fuer alles, dessen Wortlaut fuer den Aufrufer bestimmt
ist; ApiError, RechteEskalationError, RollenSperrError,
UngueltigeEingabeError, FilterFehler und ReferralError stammen davon ab.
Ein blankes Error gilt weiter als absichtlich. Alles andere - TypeError,
Prisma, JWT-Bibliothek - wird 500 mit allgemeiner Auskunft, Einzelheiten
ins Protokoll.

Mit gefunden: Sechs Stellen in cachedEmail.controller interpolierten die
interne Meldung in den Antworttext; das haette kein Filter erwischt, der
nur das Feld ersetzt. Und POST /auth/refresh gab den Wortlaut der
JWT-Bibliothek zurueck ("jwt malformed", "invalid signature") - der sagt
einem Angreifer, woran sein Token gescheitert ist.

2. Admin-Heuristik. Bisher galt "wer users:delete hat, ist Admin" - ein
Zufallsmerkmal. Bewusst NICHT auf den Rollennamen umgestellt, wie
vorgeschlagen: Eine selbst gebaute Rolle mit users:update verwaltet
tatsaechlich, ein Namenskriterium wuerde sie uebersehen, und dann liesse
sich der letzte Admin loeschen, obwohl die Faehigkeit erhalten bliebe.
Geschuetzt wird jetzt die Faehigkeit selbst: users:update und
roles:manage. Wer der letzte Traeger ist, kann sie nicht verlieren - durch
Rollenwechsel, Deaktivierung oder Loeschung. Die Meldung nennt die
Faehigkeit beim Namen. Nebenbei der letzte Cost-10-Rest im
Kennwort-Zuruecksetzen.

3. Portal-Kunden. Korrektur meiner eigenen Einordnung: Das war kein
Migrationsrueckstand, sondern eine gewollte Trennung. Kunden bekommen
niemals operative Rechte; die Portalansicht ist dafuer nicht gebaut und
prueft es nicht. Die zwei Kopien des festen Arrays sind jetzt eine
Konstante PORTAL_RECHTE mit einem Kommentar, der die Absicht benennt.
Dazu ein harter Riegel im Gate: requirePermission schneidet die Rechte
eines Portal-Zugangs auf PORTAL_RECHTE zu, unabhaengig davon, was sein
Token behauptet. Heute wirkungslos, morgen die Sicherung - bisher haette
eine unbedachte Zeile in der Token-Erzeugung gereicht. Und eine Startwache,
die jedes Nicht-Lese-Recht in dieser Liste meldet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 14:23:50 +02:00
duffyduckandClaude Opus 5 f964610af7 R193-02: Truthiness-Bypass bei den drei Haken (HIGH)
Befund der Pentesterin, Prod-Blocker. Der Eskalations-Guard fragte
`=== true`, die Zuweisung fragte auf Truthiness. Zwischen diesen beiden
Lesarten passte der Angriff: {"hasDeveloperAccess":"ja"} sah fuer den
Guard nach keinem Rechtezuwachs aus (kein 403) und fuer die Zuweisung nach
"gesetzt" (Rolle drauf). Ein Konto mit users:update, das developer:access
nicht haelt, konnte damit einem anderen Konto Vollzugriff geben.

Damit war die Kernentscheidung aus Etappe 1 ausgehebelt: developer:access
und audit:admin sollten ausdruecklich nur ueber die Kommandozeile
erstmalig vergebbar sein. Der Bypass machte sie wieder ueber ein
Browser-Feld erreichbar.

Es ist genau das Muster, das ich der Pentesterin eine Runde vorher selbst
beschrieben habe - eine Absicherung, die nur in einer von zwei Kopien
ankommt, ist keine. Nur dass es diesmal nicht zwei Dateien waren, sondern
zwei Auffassungen desselben Wertes in derselben Datei.

normalisiereHaken() prueft die drei Felder einmal streng auf Boolean,
alles andere 400. Guard und Zuweisung arbeiten danach auf demselben
geprueften Objekt - es gibt nur noch eine Lesart. Zusaetzlich liest
setzeVersteckteRolle strikt === true / === false, damit sich die Lesart
auch dann nicht spaltet, wenn die Funktion kuenftig von woanders gerufen
wird.

Gleiche Bauart nebenan: isActive und isServiceAccount steuern ebenfalls
ein Gate, das strikt auf boolean prueft - ein "ja" rutschte daran vorbei,
ohne das Gate auszuloesen (u.a. die audit:admin-Huerde fuers
Dienstkonto-Kennzeichen), und lief dann in einen Prisma-Fehler. Kein
Bypass, aber ein 500 fuer eine falsche Eingabe. Jetzt alle fuenf
Boolean-Felder einheitlich geprueft.

Nachgeprueft: ihre vier Vektoren plus 30 weitere Kombinationen ueber alle
drei Haken - alle 400, Rollen unveraendert. Derselbe Angriff ueber
POST /users - 400, kein Konto angelegt. Gegenprobe: Wer gdpr:* selbst
haelt, setzt den Haken mit echtem true weiterhin erfolgreich.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 02:45:11 +02:00
duffyduckandClaude Opus 5 75d009138d R192-01: Fehlermeldungen, die fuer Entwickler geschrieben sind
Befund der Pentesterin: PUT /users/:id {"roleIds":{}} gab 400 mit dem
rohen JS-Fehler "object is not iterable". Kein Wipe, keine Eskalation -
dieselbe Familie wie der Prisma-Pfad-Leak aus R190-01.

Zwei Ursachen. Erstens spreizte normalisiereRollenIds die roleIds ohne
Array.isArray-Guard. Zweitens reichte die Fehlerabbildung jede
error.message durch.

Die Unterscheidung laeuft jetzt ueber die Fehlerklasse, nicht ueber eine
Heuristik auf dem Text: Ein blankes Error werfen wir absichtlich und mit
einer Meldung fuer Menschen ("Der letzte Admin kann nicht..."). TypeError
und Verwandte sind Programmierfehler, Prisma-Fehler kommen von aussen -
beides sagt dem Aufrufer nichts Nuetzliches und einem Angreifer zu viel.
Unerwartetes wird 500 statt 400: Ein Eingabefehler-Code waere die naechste
Meldung, die sich falsch ausgibt.

Gegengeprueft, dass mit dem Leck nicht auch das Nuetzliche wegfaellt:
"Rolle kann nicht geloescht werden, da sie 1 Benutzern zugewiesen ist" ->
400, Systemrollen-Sperre -> 403 mit Klartext, Eskalation -> 403 mit den
konkret fehlenden Rechten. {}, "abc", 5, true als roleIds -> 400 "roleIds
muss eine Liste sein", Zustand unveraendert. R190-01/R191-01 gruen.

Offen und groesser als der Befund: Das Muster
`error instanceof Error ? error.message` steht 124-mal in 26
Controller-Dateien. Behoben ist es nur im Benutzer- und Rollenpfad.
Dieselbe Lage wie bei R188 und gehoert genauso zentral geloest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:58:51 +02:00
duffyduckandClaude Opus 5 702e630ee5 R191-01: Ein PUT, eine Klammer
Befund der Pentesterin nach dem R190-01-Fix. Nur der Rollentausch lag in
der Transaktion, die drei Haken liefen danach in eigenen Schreibvorgaengen.
Ein Multi-Feld-PUT war damit nicht gesamt-atomar: Bricht ein Haken-Write
ab, steht der Rollenstand schon und die Haken halb. Kein Verlust, keine
Eskalation, aber ein Zwischenstand, den niemand angefordert hat. Ein PUT
ist ein Vorgang, also gehoert er in eine Klammer.

Die gesamte Schreibphase von updateUser liegt jetzt in einer Transaktion -
Benutzerdaten, Rollentausch, alle drei Haken. createUser ebenso.

Beim Umbau mitgefunden: Die drei Haken-Helfer legten die Rolle bei Bedarf
selbst an, falls sie fehlte. Das war eine vierte Stelle, die definierte,
was "Developer" bedeutet - mit einem anderen Rechtesatz als der Katalog
(nur developer:access statt allem) und ohne isSystem. Eine so entstandene
Rolle waere ueber die Rollenverwaltung aenderbar gewesen. Der Notfallpfad
ist weg: sync-roles legt die Rollen bei jedem Start an, die Startwache
meldet ihr Fehlen, und fehlt sie hier doch, bricht der Vorgang mit klarer
Ansage ab. Drei fast gleiche Funktionen wurden dabei eine.

int4-Ueberlauf (kosmetischer Nebenbefund): IDs jenseits des INT-Bereichs
werden nicht mehr abgefragt, sondern als unbekannt gemeldet.

createUser hasht jetzt mit Cost 12 statt 10, wie seed.ts. Wieder eine
Haertung, die nur in einer von zwei Kopien angekommen war.

Nachgeprueft: Rollback bewiesen, indem die DSGVO-Rolle voruebergehend
umbenannt und {roleIds:[23], hasGdprAccess:true} geschickt wurde - 400 mit
Klartext, Rollen unveraendert. Typvektoren "22", 4.5, 1e3, true, null,
Riesenzahl alle 400 ohne Wipe. R190-01-Regression erneut gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:40:56 +02:00
duffyduckandClaude Opus 5 b2963be482 R190-01: Rollentausch zerstoerte, wo er ablehnte
Befund der Pentesterin. updateUser machte deleteMany + createMany ohne
Transaktion und ohne skipDuplicates. Eine doppelte roleId ([4,4]) oder
eine erfundene ([99999]) kam durch die Teilmengenregel - sie bringt keine
Rechte mit, faellt dort also nicht auf - und schlug erst beim Schreiben
fehl, nach dem Loeschen. Antwort HTTP 400, Konto danach ohne jede Rolle.

Die Antwort log ueber sich selbst: "400" liest sich als abgelehnt, nichts
passiert. Zerstoert wurde trotzdem.

Damit war die Letzter-Admin-Sperre umgangen, ohne sie anzugreifen: Sie
prueft die Absicht (die Admin-Rolle steht im Body, also "bleibt Admin"),
der Schreibvorgang scheiterte danach. Aussperrung, Rueckweg nur per CLI.
Auch versehentlich ausloesbar durch doppelte IDs aus dem Frontend.

Es war meine eigene Haertung, die ich nicht mitgenommen habe: updateRole
hatte Dedup und skipDuplicates seit Etappe 1, das Geschwister
updateUser/createUser nicht. Genau das Muster, das in derselben Runde
dreimal aufgeraeumt wurde - eine Absicherung, die nur in einer von zwei
Kopien ankommt.

normalisiereRollenIds() prueft jetzt vor jeder Entscheidung und jedem
Schreibvorgang gegen die Datenbank, dedupliziert, und der Tausch laeuft
in einer Transaktion. Die Letzter-Admin-Sperre arbeitet damit auf einer
Liste, die auch einloesbar ist.

Nebenbefund beim Nachstellen: Die rohe Prisma-Fehlermeldung ging
wortwoertlich an den Client, inklusive Dateipfad des Servers. Wird jetzt
protokolliert statt ausgeliefert.

Nachgestellt: [22,22] -> 200 mit erhaltener Rolle; [99999] und
[22,99999] -> 400 mit Rollen unveraendert; [-1] -> 400; [22,23] -> 200
korrekt gesetzt. createUser ebenso, abgelehnte Anlagen hinterlassen kein
Konto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:14:53 +02:00
duffyduckandClaude Opus 5 9f879759d8 CLI-Skripte: DATABASE_URL wieder aufbaubar machen
Die drei neuen Wartungsskripte bauten sich einen eigenen PrismaClient und
gingen damit an src/lib/prisma.ts vorbei. Dort wird DATABASE_URL aus den
DB_*-Teilen zusammengesetzt, falls sie fehlt - und genau das ist bei
`docker compose exec <dienst> npx tsx prisma/<skript>.ts` der Fall: Der
Entrypoint exportiert sie nur in den Serverprozess, eine neue Shell erbt
sie nicht. Ergebnis war "Environment variable not found: DATABASE_URL".

Der Kommentar in src/lib/prisma.ts beschreibt genau diesen Fall; die
Loesung war da, ich habe sie nur umgangen.

Betroffen war auch rolle-zuweisen.ts. Das ist der Weg, den die Startwache
im Klartext nennt, wenn kein Konto mehr die Pflichtrechte haelt - er
haette in dem Moment versagt, fuer den er gebaut ist.

Geprueft mit entzogener DATABASE_URL und nur DB_*-Teilen in der
Umgebung: rechte-report, rolle-zuweisen --liste und sync-roles laufen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 14:23:58 +02:00
duffyduckandClaude Opus 5 77aeb69aeb 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>
2026-09-08 13:49:30 +02:00
duffyduckandClaude Opus 5 cca242119b R188/R189-01 aus dem Pentest umgesetzt - Klasse statt Instanz
Beide Findings der Pentesterin waren berechtigt. Ihre Patches liessen
sich nicht anwenden (Basis 791711c, seitdem 42 Commits, sync-roles.ts
kollidiert), und an zwei Stellen greifen sie zu kurz.

R188 - ungueltige IDs im Pfad
-----------------------------
Gemeldet: GET /api/users/:id gibt bei nicht-numerischer ID 500 statt 400.
Ihr Fix schliesst nebenbei mehr, als sie beansprucht: parseInt('12abc')
ergibt 12, also lieferte /api/users/12abc bisher Benutzer 12 aus.

Es waren aber 181 ungepruefte Stellen in 19 Controllern, nicht eine. 181
Einzel-Guards waeren genau der Fehler aus R186-01 gewesen - drei
Filterlisten, die dasselbe bedeuten sollten und auseinanderliefen.
Stattdessen router.param(), an einer Stelle fuer alle 33 Router
registriert, ueber einen mounte()-Helfer, der Pruefung und Einhaengen
zusammenbindet.

Antwort ist 404, nicht 400: Ein Pfadsegment, das keine ID sein kann,
benennt keine Ressource. Der bestehende Praezedenzfall in
provider.controller.ts (Pentest Mai 2026) hatte es genauso entschieden.

Dabei eine aeltere Heuristik abgeloest (Pentest Runde 7). Ihr eigener
Kommentar nannte den Grund fuer sie - "app.param() greift nicht auf in
Sub-Router gemounteten Routes" - und genau das loest mounte(). Sie war zu
eng (/users/abc ging durch und endete als 500) und zu weit (ein
Einstellungs-Schluessel 12abc unter :key wurde geblockt, obwohl das keine
ID ist), und sie antwortete 400, wo jetzt 404 steht.

R189-01 - DSGVO-Rechte ohne Traeger
------------------------------------
Gemeldet: gdpr:* und audit:read/export haengen an DSGVO und Developer,
die Admin-Rolle hat sie nicht, und nach einem frischen Seed war DSGVO
keinem Konto zugewiesen. Auskunft nach Art. 15 und Loeschung nach Art. 17
konnte niemand ausfuehren.

Seed weist admin@admin.com jetzt zusaetzlich die DSGVO-Rolle zu; die
Admin-Rolle selbst bleibt ohne diese Rechte, die Trennung aus R186 bleibt
also erhalten. Label ehrlich gemacht.

Beim Pruefen ihres Patches ein eigener Fund: seed.ts vergab an die
DSGVO-Rolle weiterhin audit:* komplett, inklusive audit:admin - die
Buendelung, die fc6f39e aufgeloest hat. Ich hatte damals zwei Listen
gefunden und die dritte uebersehen. Gerettet hat es nur die Reihenfolge
im Containerstart; ein einzelnes `npm run db:seed` brachte sie zurueck.

Der Seed hilft nur bei Neuinstallation (update: {}). Deshalb zusaetzlich
eine Wache beim Start: Gibt es fuer gdpr:export, gdpr:delete oder
audit:read kein aktives Konto, steht das mit Handlungsanweisung im Log -
Erkennung der ABWESENHEIT einer Faehigkeit, wie beim Heartbeat. Bewusst
nur melden, nicht automatisch vergeben.

Getestet ueber HTTP gegen eine Wegwerf-Instanz: alle ID-Varianten quer
ueber sechs Controller, Nicht-ID-Parameter unveraendert, frischer Seed,
Wache mit und ohne vergebene Rechte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 21:17:12 +02:00
duffyduckandClaude Opus 5 3a50a40ad5 Versiegeln auffindbar machen: Titel und Inhaltsverzeichnis
Der Abschnitt war vollstaendig, aber nicht zu finden. Er hiess "Wenn der
erste Lauf exit=2 meldet" - also nach dem Symptom benannt, nicht nach der
Aufgabe - und stand in Zeile 89 einer 706-Zeilen-Datei ohne
Inhaltsverzeichnis. Wer "wie versiegle ich" suchte, fand ihn nicht; genau
das ist dem Betreiber passiert, obwohl der Text bei ihm lag.

Jetzt "Den Altbestand versiegeln (einmalig, im CRM)", dazu ein
Inhaltsverzeichnis nach Anlass gegliedert: einrichten, im Betrieb, wenn
Alarm kommt, zum Nachlesen. Mit einem ausdruecklichen Hinweis, dass der
Versiegelungs-Abschnitt eigenstaendig ist und auch ohne Gegenbuch gilt.

Der Verweis im Haupt-README zeigt jetzt direkt auf den Anker statt den
Abschnittstitel zu zitieren - so laeuft er beim naechsten Umbenennen
nicht ins Leere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:27:49 +02:00
duffyduckandClaude Opus 5 e76b4ace88 status.txt zeigt die Zusammenfassung, nicht die erste beliebige Zeile
Die Grund-Zeile nahm bei fehlendem Alarm schlicht die erste Ausgabezeile.
Beim ersten Prod-Lauf nach dem Versiegeln stand dort deshalb die
einmalige Notiz "Grundlage fuer die Rehash-Ueberwachung wird gesetzt"
statt des Ergebnisses "OK: Checkpoint N erstellt" - eine Randnotiz
verdeckte die Zusammenfassung.

Jetzt: bei Alarm die ALARM-Zeile, sonst die OK-Zeile, und erst wenn
beides fehlt die erste Zeile ueberhaupt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:25:45 +02:00
duffyduckandClaude Opus 5 ed676ca4eb Versiegeln dokumentiert: Abschnitt aktualisiert und im Haupt-README verlinkt
Der Abschnitt in tools/audit-notary/README.md war an drei Stellen
ueberholt. Er sagte "nimm dein Administratorkonto" - seit der Aufteilung
von DSGVO und Audit-Betrieb hat die Admin-Rolle gar keine Audit-Rechte
mehr, das Recht kommt jetzt ueber den Haken "Audit-Betrieb". Er kannte
weder das RESEAL-Gate noch die Rehash-Anzeige noch den Zustand "leer".

Neu geschrieben, mit dem, was die Praxis gezeigt hat:
- Wer darf es, und warum nicht ueber DSGVO oder Admin
- Vier Felder, die vor dem Siegeln stimmen muessen - allen voran
  rehashes:[], denn nach einer Neuberechnung sagt chainGaps nichts mehr
  ueber die Zeit davor
- Den HTTP-Code beim Pruefen IMMER mit ausgeben: Token laufen nach 15
  Minuten ab, und ohne den Code liest sich eine 401 wie ein fehlender
  Eintrag. Genau das ist uns bei der Vorbereitung des Prod-Siegels
  passiert und haette fast zu einem falschen Stopp gefuehrt.
- Die drei Punkte zur Gegenpruefung danach, inklusive "intakt" statt
  "leer"
- Der Hinweis, dass ein unversiegeltes CRM nicht "bewacht mit Warnung"
  ist, sondern unbewacht: Das Gegenbuch bricht vor dem Anhaengen ab.

Ausserdem im Haupt-README verlinkt. Wer AUDIT_HMAC_KEY auf einer
bestehenden Installation setzt, hat zwangslaeufig einen Altbestand -
erfuhr davon aber nur, wenn er ein Gegenbuch betreibt. Die Anleitung gilt
auch ohne.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:07:12 +02:00
duffyduckandClaude Opus 5 eb0580ac54 Entfernter Protokoll-Anfang wurde nicht erkannt
Gefunden bei der Vorbereitung des Prod-Siegels. Die Verkettung wird
zeilenweise gegen die Vorgaengerin geprueft - die erste Zeile hat keine,
also fiel bisher nichts auf, wenn ein zusammenhaengender Anfang des
Protokolls entfernt wurde. Kein Kettenbruch, kein Befund, valid blieb
gruen. Das ist die stillste Loeschung von allen: Wer die aeltesten
Eintraege loswerden will, muss nur vorne anfangen.

Erkennbar ist es trotzdem. Die allererste Zeile wird ohne Vorgaenger
geschrieben und traegt einen leeren previousHash; traegt die erste
VORHANDENE Zeile einen Wert, hat es eine Vorgaengerin gegeben und die ist
weg. Wird jetzt als Kettenluecke an dieser Zeile gefuehrt, mit derselben
Manifest- und Beglaubigungslogik wie jede andere Luecke - eine
dokumentierte Loeschung erklaert sie also weiterhin.

Nur bei Pruefung des Gesamtbereichs: mit fromId ist ein gefuellter
previousHash selbstverstaendlich. Gegengeprueft, kein Fehlalarm.

Getestet ueber HTTP gegen eine Wegwerf-DB: vollstaendiges Protokoll ->
valid:true; erste drei Zeilen entfernt -> vorher unveraendert valid:true,
jetzt valid:false mit chainGaps:[4], wegen Hash-Version 3 zusaetzlich als
Manipulation eskaliert.

Auf Prod ausgeschlossen: Das Protokoll beginnt bei ID 1 (07.05.2026,
Inbetriebnahme), kein Cleanup, keine Neuberechnung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:53:13 +02:00
duffyduckandClaude Opus 5 2ca6ed2f70 Neuberechnung der Kette als eigene Gegenbuch-Alarmbedingung (R186 Frage b)
Der Tester fragte praezise: Loest cleanup -> rehash OHNE erneutes Siegeln,
ueber NICHT versiegelten Inhalt, etwas Automatisches aus - oder nur die
Prosa in verify, die ein Mensch lesen muss?

Gemessen statt behauptet: Es loeste bereits aus, aber als Nebenwirkung.
Ein Rehash aendert jeden Hash, also stimmt der beglaubigte Kettenkopf
nicht mehr und der bestehende Vergleich schlug an. Das funktioniert, ist
aber ein Zufallstreffer - verschoebe sich der Anker, waere der Melder
lautlos weg. Und die Meldung hiess "Eintrag wurde veraendert" statt "die
Kette wurde neu berechnet", also Wirkung statt Ursache.

Jetzt haengt der Alarm an der Sache selbst: Das Gegenbuch fuehrt
rehashCount/rehashLast im Buch mit und meldet jede neue Neuberechnung
seit der letzten Beglaubigung mit exit 2, samt Zeitpunkt, Zeilenzahl und
dem Befund, der unmittelbar davor galt. Dieselbe Lehre wie R184-01 (Gate
am Ausloeser statt an der Wirkung) und R185-01 (Wurzelwechsel statt
valid).

Reihenfolge geaendert, und das war noetig: Der neue Melder steht VOR dem
Kopf-Hash-Vergleich, sonst haette immer die unpraezisere Meldung
gewonnen. Und der Kopf-Vergleich wird nach einer bestaetigten
Neuberechnung uebersprungen - sonst waere die Bestaetigung wertlos, weil
ein Rehash den Kopf zwangslaeufig aendert. Beim Bauen aufgefallen, nicht
im Entwurf.

NOTARY_REHASH_ACK wird mit der ID des Rehash-Eintrags bestaetigt, nicht
mit true; IDs steigen streng, ein stehen gelassener Wert passt beim
naechsten Vorgang nicht mehr. Ein fehlender beglaubigter Eintrag
alarmiert weiterhin immer: bestaetigt wird die Neuberechnung, nicht das
Verschwinden von Zeilen.

Getestet mit echtem Gegenbuch gegen ein echtes CRM, SSH-signiertes
lokales Buch: Waesche ohne Datenbankzugriff -> CRM meldet valid:true und
chainGaps:[], Gegenbuch exit 2 mit der Neuberechnung als Ursache. Falsche
Ack-ID weiter exit 2, richtige exit 0, Folgelauf ruhig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:30:44 +02:00
duffyduckandClaude Opus 5 df442bb1a0 verify meldet, dass die Kette neu berechnet wurde
Im Betrieb entdeckt, nicht im Test. Das Staging-Gegenbuch meldete "Der
beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem
Protokoll: am 22.08. wurden die Aufbewahrungsfristen auf 0 gesetzt, zwei
Cleanups loeschten 3155 Eintraege (id 1-5356), danach lief ein Rehash.
Seither meldet die Pruefung "Alle Eintraege sind unveraendert und
lueckenlos verkettet".

Wahr - und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der
Rehash verknuepft alles neu; die rund 700 Kettenluecken, die davor
bestanden, sind seitdem unsichtbar. Nachweisbar im Vorbefund, den der
Rehash selbst mitschreibt (R170-01) - nur schaute den nie jemand an. Das
Gegenbuch war der einzige Zeuge; innerhalb des CRM war die Loeschung
nicht mehr feststellbar.

verifyIntegrity sammelt jetzt die Rehash-Marker; die Antwort enthaelt
rehashes[] mit Zeitpunkt, Anzahl, Signatur und Vorbefund. Die Meldung
nennt sie IMMER, auch im gruenen Fall, und der Einstiegssatz lautet dann
"...lueckenlos verkettet - allerdings erst seit der letzten
Neuberechnung".

valid bleibt unberuehrt. Ein Rehash ist eine legitime Massnahme; ihn
dauerhaft als Befund zu fuehren waere der Dauer-Alarm, den wir mit den
beglaubigten Luecken gerade beseitigt haben. Melden, nicht alarmieren.

Umgekehrte Beweislast als bei Manifest und Siegel: Dort zaehlen nur
signierte Traeger, weil ein gefaelschter Marker Luecken wegerklaeren
koennte. Hier erzeugt ein Marker eine Warnung - wuerden nur signierte
zaehlen, koennte man einen Rehash unsichtbar machen, indem man seine
Signatur zerstoert. Deshalb zaehlt jeder auswertbare Marker; eine
fehlende Signatur wird zusaetzlich gemeldet.

Getestet ueber HTTP gegen eine Wegwerf-DB mit echtem Rehash ueber den
regulaeren Endpunkt: sauberer Vorzustand, Loeschung+Rehash (der
Staging-Ablauf im Kleinen), und zerstoerte Signatur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:59:25 +02:00
duffyduckandClaude Opus 5 31c4c209e4 status.txt nennt den Grund, nicht nur das Etikett
In der Statusdatei des Gegenbuchs stand fuer JEDEN Exit-2 derselbe Satz:
"BEFUND - Widerspruch zwischen CRM und Gegenbuch". Ein echter
Widerspruch sah damit genauso aus wie eine quittierpflichtige
Erstsiegelung.

Aufgefallen im Betrieb: Der Betreiber las "BEFUND" auf Staging und
konnte nicht entscheiden, ob er handeln muss - obwohl die Kette dort
valid:true meldet und der Alarm nur den erwarteten Siegelwechsel betraf.
Wer ausschliesslich die Statusdatei liest, und genau dafuer ist sie da
("damit eine Ueberwachung sie abgreifen kann, ohne Logs zu
durchsuchen"), bekam ein Etikett ohne Inhalt.

Der Lauf wird jetzt mitgeschnitten; die erste ALARM-Zeile landet als
"Grund:" in status.txt, ohne Alarm die erste Ausgabezeile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:23:24 +02:00
duffyduckandClaude Opus 5 fc6f39eba0 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>
2026-08-26 17:16:27 +02:00
duffyduckandClaude Opus 5 909e523634 Siegel ueber null Blaettern meldet nicht mehr "intakt"
Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand
vorfand, ist rechnerisch tadellos und schuetzt nichts. Gemeldet wurde
trotzdem "intakt" - formal richtig, aber es liest sich als
Schutzzusage. Der Pentester hat Stagings Leersiegel genau so
missverstanden und hielt es fuer zahnhaltig.

Das ist der rote Faden im Kleinen: ein Signal, das beruhigt, wo nichts
abgesichert ist. Deshalb ein eigener Zustand "leer" mit eigenem Text -
gewertet wie "nicht_noetig", kippt `valid` also nicht, sagt aber auch
nichts zu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:05:24 +02:00
duffyduckandClaude Opus 5 23505afc05 audit:export gatete nichts - Export hing an audit:read
Bei der Gegenprobe zur neuen Rolle "Gegenbuch" gefunden: Ein Konto mit
ausschliesslich audit:read bekam auf GET /audit-logs/export eine 200. Die
Berechtigung audit:export stand im Katalog und in der Rollenverwaltung -
und wurde nirgends geprueft.

Der Unterschied ist nicht kosmetisch. Blaettern zeigt 50 Zeilen; der
Export liefert in einem Zug das gesamte Protokoll inklusive changesBefore
und changesAfter, also der vollstaendigen Vorher/Nachher-Datensaetze,
dazu resourceLabel mit Klartextnamen, IP-Adressen und User-Agents. Live
nachgewiesen auf Staging: 43 Eintraege mit gefuellter resourceLabel
allein fuer resourceType=Customer.

Damit konnte ausgerechnet das Dienstkonto des Gegenbuchs Personendaten
exportieren - das Konto, dessen Passwort im Klartext in der .env auf der
Notar-Maschine liegt, und dem README und Rollenname "nur Pruefwerte
lesen" zusichern.

/audit-logs/export verlangt jetzt audit:export. Betroffen ist genau eine
Rolle: Gegenbuch, und zwar gewollt. Die DSGVO-Rolle traegt audit:*
vollstaendig und behaelt den Export.

In der Oberflaeche erscheinen JSON- und CSV-Knopf nur noch mit
audit:export - sonst stuenden dort Knoepfe, die zuverlaessig 403 liefern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:56:53 +02:00
duffyduckandClaude Opus 5 d2460fa7c0 Rolle "Gegenbuch": Leserecht aufs Audit-Protokoll ohne audit:admin
Beim Selbst-Nachpruefen eines Deploys auf Staging aufgefallen: Das
Gegenbuch-Dienstkonto meldete beim Login audit:read, audit:export,
audit:admin, gdpr:export, gdpr:delete und gdpr:admin. Es braucht genau
eines davon - audit:read -, denn es ruft nur /audit-logs/checkpoint und
/audit-logs/verify auf.

Das war kein Bedienfehler, sondern ein Konstruktionsfehler: Es gab keine
Rolle, die nur Leserecht aufs Protokoll gibt. Wer das wollte, musste den
Haken "DSGVO-Zugriff" setzen - und der vergibt audit:* komplett, also
auch audit:admin mit seal-backlog, rehash und cleanup. Das Label
("Audit-Logs, Datenschutz") legt Lesen nahe und liefert Vollzugriff.

Warum das ernst ist: Das Passwort des Dienstkontos liegt im Klartext in
tools/audit-notary/.env auf der Gegenbuch-Maschine. Mit audit:admin
haette ein Einbruch dort nicht nur den Waechter gehabt, sondern gleich
die Mittel zur Waesche aus R185-01 - und damit genau die Trennung
aufgehoben, wegen der das Gegenbuch auf einer eigenen Maschine laeuft.

Neue Rolle "Gegenbuch" in sync-roles.ts mit ausschliesslich audit:read.
sync-roles laeuft beim Containerstart mit, die Rolle erscheint danach in
der Benutzerverwaltung. README des Gegenbuchs umgeschrieben: Rolle statt
"selbst anlegen", ausdrueckliche Warnung vor dem DSGVO-Haken, dazu eine
Gegenprobe (checkpoint -> 200, seal-backlog -> 403).

Bewusst NICHT angefasst: dass die DSGVO-Rolle selbst audit:admin traegt,
ist ein Gewaltenteilungs-Problem - wer das Protokoll beaufsichtigt, kann
seine Beweisgrundlage ersetzen. Das zu aendern entzieht bestehenden
DSGVO-Konten Rechte und gehoert entschieden, nicht nebenbei gemacht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:41:34 +02:00
duffyduckandClaude Opus 5 ef2411ebe4 Eingegrenzter Export lieferte alles (Pentest R186-01, MEDIUM)
Der Tester fand: GET /audit-logs/export?userId=… filterte nicht.
userId=999999 gab alle 2761 Datensaetze zurueck, byte-identisch zum
ungefilterten Export. HTTP 200, sah korrekt aus.

Die Ursache war breiter als der Befund. Nicht userId allein fehlte: der
Export-Controller pflegte eine eigene, kuerzere Filterliste und verwarf
still userId, customerId, dataSubjectId, resourceId, success UND search.
Der Service konnte alle sechs - sie kamen nie bei ihm an. Dieselbe Luecke
ein drittes Mal in der Oberflaeche: der CSV-Knopf baute seine Parameter
nochmal von Hand, mit wieder anderen fuenf Feldern. Wer im Suchfeld
eingrenzte und dann CSV klickte, bekam das gesamte Protokoll statt seiner
Auswahl.

Warum das mehr ist als ein fehlender Filter: Ein bewusst eingegrenzter
Export - "nur die Spur von Benutzer X" fuer eine DSGVO-Auskunft oder eine
Innentaeter-Pruefung - gab das vollstaendige Protokoll aller Nutzer
heraus, mit einem beruhigenden 200. Auf einem datenminimierungs-
pflichtigen Pfad ist das eine Weitergabe, kein Schoenheitsfehler.

Der Fix ist strukturell: ein gemeinsamer leseFilter(req) fuer Liste und
Export, und die Oberflaeche schickt alle aktiven Filter statt einer
handgepflegten Auswahl. Drei Listen, die dasselbe bedeuten sollen, laufen
frueher oder spaeter auseinander; jetzt gibt es nur noch eine.

Geprueft ueber HTTP gegen eine Wegwerf-DB: Export und Liste liefern fuer
userId, action, search, success und resourceType identische
Treffermengen; auf dem Export-Pfad gilt jetzt dieselbe Validierung
(userId=abc -> 400 statt 200). CSV-Pfad gegengeprueft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 11:46:26 +02:00
duffyduckandClaude Opus 5 e81a83ae8f Siegelwechsel ist ein Alarm, kein Hinweis (Pentest R185-01/-02)
R185-01 (MEDIUM): Die Flanke, die wir selbst gemeldet hatten, hat der
Tester live bestaetigt. seal-backlog war beim ZWEITEN Aufruf genauso
gegatet wie beim ersten ({"confirm":"SEAL"} -> 200), und das Ereignis
landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
automatische Rueckhalt des Gegenbuchs haengt an `valid` - und `valid`
ueberlebt ein ersetzendes Siegel per Konstruktion. Angriff: Altzeile per
DB-Zugriff loeschen, neu siegeln, Luecke ist beglaubigt, valid wieder
true. Live reproduziert.

Zwei Schichten, in seiner Reihenfolge:

1. Alarmkanal. Neuer SecurityEventType AUDIT_SEAL_CHANGED (Migration
   20260826120000). Erstes Siegeln HIGH, Ersetzen CRITICAL - geht damit
   ueber sendPendingCriticalAlerts sofort per Mail raus. Die Details
   halten Wurzel vorher/nachher und den vollstaendigen Vorbefund fest.
2. Gate. Steht bereits ein Siegel, verlangt der Endpunkt
   {"confirm":"RESEAL"} statt SEAL, mit einem Text, der sagt, was dabei
   verloren geht. Ein Austausch der Beweisgrundlage soll nicht dasselbe
   Wort haben wie das Einrichten.

Und im Gegenbuch selbst: dort stand fuer den Wurzelwechsel ein
console.warn, waehrend der Rueckgabecode auf 0 blieb - also exakt das
Muster, das wir dem CRM zweimal angekreidet haben (R179, R183-02), im
Werkzeug, das dagegen gebaut wurde. Jetzt exit 2, mit alter und neuer
Wurzel samt Blattzahl; "10 Blaetter -> 9 Blaetter" zeigt die Loeschung
sofort. Auch die Erstsiegelung meldet sich, statt stillschweigend
uebernommen zu werden.

Aufloesbar gemacht: der Alarm bricht ab, BEVOR angehaengt wird - ohne
Bestaetigungsweg haette auch ein legitimes Siegeln fuer immer alarmiert
(R183-03-Falle). Neu ist NOTARY_SEAL_ACK, bewusst nicht "true", sondern
die Wurzel selbst (mind. 16 Zeichen): ein stehen gelassener Wert passt
beim naechsten Wechsel nicht mehr und kann keinen weiteren Austausch
durchwinken.

R185-02 (LOW): GET /api/audit-logs?action=<x> gab ungueltige Enum-Werte
roh an die Spalte -> 500. Zweifach schlecht: fehlende Validierung und
Fehler-Orakel (200 vs 500 verraet die Enum-Mitglieder). Jetzt 400 mit
der erlaubten Menge im Klartext. Mitgenommen: sensitivity, Datumsfelder,
Zahlenfelder, Textlaengen und ein Deckel auf limit (200), ueber den sich
sonst die ganze Tabelle an der Seitenlogik vorbei ziehen liess. Beide
Endpunkte.

Getestet ueber HTTP gegen eine Wegwerf-DB, inkl. echtem Gegenbuch-Lauf
mit SSH-signiertem lokalem Repo. Zusaetzlich nachgeholt, was der Tester
nicht herstellen konnte: vollstaendig unsigniertes Protokoll ->
kein_siegel statt der frueheren falschen Entwarnung nicht_noetig, und
seal-backlog nennt den fehlenden Schluessel als naechsten Schritt.
Gegenrichtung geprueft, R183-03 bleibt behoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:41:11 +02:00
duffyduckandClaude Opus 5 7af6b7591b Integritaetsstatus des Audit-Protokolls in der Oberflaeche
Der Zustand der Hash-Kette war bisher nur per POST /api/audit-logs/verify
einsehbar - also praktisch nur fuer das Gegenbuch und fuer jemanden mit
curl. Jetzt steht er oben auf Einstellungen -> Audit-Protokoll.

Vier Zustaende statt gruen/rot: unversehrt; Befund; unversehrt aber
ungeschuetzter Altbestand (kein_siegel); nicht vollstaendig pruefbar
(signierte Zeilen ohne AUDIT_HMAC_KEY). Die beiden mittleren sind
bewusst nicht gruen - ein Protokoll mit unversiegeltem Altbestand ist
rechnerisch stimmig, aber am Altbestand unbemerkt aenderbar, und ein
Protokoll, das mangels Schluessel nicht pruefbar ist, ist schlicht
ungeprueft. Beides als "alles in Ordnung" zu zeigen waere genau die
Klasse Fehler, die diese Runde behandelt hat. Schlaegt die Pruefung
selbst fehl, steht dort ausdruecklich, dass das keine Entwarnung ist.

Aufklappbare Einzelheiten trennen die unterschiedlich schweren
Kategorien: nachtraeglich veraendert (ernst) / Verkettung unterbrochen /
davon ohne dokumentierte Loeschung / davon vom Siegel beglaubigt / ohne
Schluessel nicht pruefbar - mit Erklaerung im Klartext.

Die Pruefung liest die gesamte Kette; sie laeuft daher einmal beim
Oeffnen der Seite und wird 5 Minuten wiederverwendet. Bewusst read-only:
kein Siegel- oder Rehash-Knopf, denn diese Eingriffe verlangen
audit:admin und eine ausdrueckliche Bestaetigung und gehoeren nicht neben
eine Statusanzeige, die man im Vorbeigehen anklickt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:11:13 +02:00
duffyduckandClaude Opus 5 ecaeae48d4 Beglaubigte Alt-Luecken: Dauer-Alarm im Gegenbuch beendet
Das Gegenbuch auf Prod meldete stuendlich exit=2, weil die CRM-Pruefung
wegen 6 struktureller Luecken valid:false lieferte (IDs 33, 44, 45, 922,
1434, 2583).

Diagnose: harmlos. Jede Luecke liegt innerhalb eines Schwungs von
Eintraegen mit identischer Sekunde, betrifft nur /login und /refresh,
kein Eintrag fehlt, tamperedEntries ist leer. Das ist die Signatur der
Race-Condition, die am 19.08. mit AuditChainLock geschlossen wurde
(R166-01). Die Zeilen datieren ihren eigenen Code: Eintrag 2583 stammt
vom 21.08., ist aber noch hashVersion=1.

Das eigentliche Problem war nicht die Luecke, sondern der Dauer-Alarm.
Diese Luecken sind nicht heilbar - die Verkettung ist gebrochen, die
Inhalte sind unversehrt. Ohne Aenderung haette das Gegenbuch fuer immer
Alarm gemeldet, und ein Signal, das immer schreit, warnt nicht mehr.
Dieselbe Klasse wie R162, R174, R179, R182, R183-02.

Loesung: Beglaubigung statt Unterdrueckung. Eine Luecke zaehlt nicht mehr
als offener Befund, wenn sie im versiegelten Bereich liegt UND im
Vorbefund des Siegel-Markers steht - also im Zustand, den der Betreiber
beim Siegeln ausdruecklich festgeschrieben hat. Der Vorbefund liegt in
changesBefore und ist ab Version 3 mitgehasht; ohne AUDIT_HMAC_KEY laesst
sich die Liste nicht nachtraeglich erweitern (gleiche Absicherung wie
beim Loeschungs-Manifest, R171-01). Beglaubigt heisst nicht verschwunden:
die Luecken bleiben in chainGaps, stehen zusaetzlich in attestedGaps und
werden im Bericht ausdruecklich benannt.

Nebenbefund derselben Klasse mitbehoben: backlogSealStatus meldete
"nicht_noetig" ("Es gibt keine unsignierten Alteintraege"), wenn
v3FromId === null - das bedeutet aber das Gegenteil, naemlich dass
ueberhaupt nichts signiert ist, etwa weil AUDIT_HMAC_KEY fehlt. Ein
vollstaendig unsigniertes Log bekam damit Entwarnung fuer genau den
Zustand mit der geringsten Beweiskraft. Die Bedingung haengt jetzt an der
Zahl der unsignierten Zeilen; R183-03 bleibt behoben (Regressionstest).

Getestet ueber HTTP gegen eine eigene Wegwerf-Datenbank, nicht am Service
vorbei: Prod-Zustand nachgebaut (10 v1-Zeilen, Bruch bei id 5) ->
valid:false; nach seal-backlog -> valid:true, attestedGaps:[5]. Drei
Gegenproben: neue Luecke nach dem Siegeln -> valid:false; gesiegelte
Altzeile veraendert -> Siegel gebrochen, nichts mehr beglaubigt;
Beglaubigungsliste im Marker gefaelscht (DB-Schreibrecht, kein
Schluessel) -> Marker ungueltig, Status entfernt, valid:false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:07:26 +02:00
duffyduckandClaude Opus 5 e504b8be96 Dienstkonto-Flag: Gate in beide Richtungen, richtige Rechte-Domaene (R184)
R184-01: Setzen war gegatet, Entfernen nicht - und das Entfernen ist der
gefaehrlichere Weg. Der Heartbeat-Wachhund fragt isServiceAccount:true ab,
haengt also am Live-Kennzeichen; die Hypothese des Pentesters war richtig,
Un-Flaggen kappt die Wache. Genau das stilllegen-und-auf-Stille-setzen, gegen
das der Tripwire gebaut wurde. Fix: Gate in beide Richtungen mit eigenem
Wortlaut beim Entfernen.

Wichtiger noch: Die Aenderung geht jetzt zusaetzlich in den Alarmkanal
(PERMISSION_CHANGED/CRITICAL), nicht nur ins Audit-Log - eine CRITICAL-Zeile
muss jemand lesen, das war die R183-02-Klasse.

R184-02: Das Kennzeichen hing an users:update, obwohl es ein
Audit-Governance-Eingriff ist - Geschwister von retention-shorten,
seal-backlog, rehash und cleanup, die alle audit:admin verlangen. Heute
deckungsgleich, aber jede kuenftige Rolle mit Benutzer-bearbeiten haette still
Login-Alarme-herunterstufen geerbt. Fix: zusaetzliche Pruefung auf audit:admin.

Verifiziert: Entfernen ohne Bestaetigung jetzt 400 statt 200, ohne audit:admin
403, zwei CRITICAL-Meldungen im Alarmkanal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:09:08 +02:00
duffyduckandClaude Opus 5 b3a9ef6372 Dienstkonto-Kennzeichen gegatet und laut protokolliert (Pentest R184)
Mit dem Scharfschalten des Feldes wurde ein alarm-senkendes Attribut ueber den
normalen Benutzer-Update-Pfad setzbar - dieselbe Klasse wie R183-01, nur neu
gebaut. Wer sein eigenes Konto so markiert, laesst die eigenen auffaelligen
Anmeldungen als Routine erscheinen.

Alle drei Sorgen des Pentesters bestaetigt: kein Gate, Protokollierung nur als
MEDIUM (Standardstufe fuer User), und jeder mit users:update konnte es auf
jedes Konto setzen, auch auf das eigene.

Fix analog zur Retention-Absenkung: Bestaetigung confirm SERVICE_ACCOUNT beim
Aktivieren; CRITICAL statt MEDIUM mit eigenem Label und Wer/Vorher/Nachher;
kein Selbstbedienen (403 am eigenen Konto, muss ein anderer Administrator
vornehmen). Das Frontend sendet die Bestaetigung mit - der Haken im Formular
ist die Bestaetigung.

Verifiziert ueber den echten Controller: ohne Bestaetigung 400, mit 200, am
eigenen Konto 403, Protokolleintrag CRITICAL mit sprechendem Label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:59:06 +02:00
duffyduckandClaude Opus 5 389fd30094 Build-Fehler im Benutzerformular behoben
Zwei Typfehler aus dem vorigen Commit: Der Formular-Reset beim Anlegen eines
neuen Benutzers kannte isServiceAccount nicht, und der Typ von userApi.create
ebenfalls nicht.

Ursache meines Uebersehens: Ich hatte mit 'npx vite build' geprueft, das
Projekt nutzt aber 'npm run build' = 'tsc && vite build'. Die Typpruefung lief
bei mir damit gar nicht mit. Ab jetzt npm run build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:45:26 +02:00
duffyduckandClaude Opus 5 b696980793 Dienstkonto-Kennzeichen in der Benutzerverwaltung setzbar machen
Nachgezogen: Das Feld isServiceAccount lag in der Datenbank, war aber nirgends
setzbar - weder im Formular noch ueber die API. Der Betreiber haette es nicht
aktivieren koennen, damit waere die R182-Einstufung wirkungslos geblieben.

Jetzt Ankreuzfeld "Dienstkonto" im Benutzerformular mit Erklaerung im
Klartext, Feld in der Mass-Assignment-Whitelist (gilt damit auch beim Anlegen),
im Service-Typ und in allen drei select-Bloecken, damit es beim Bearbeiten
vorbelegt wird.

Verifiziert: Whitelist laesst das Feld durch und blockt Fremdfelder weiterhin,
Wert ueber Prisma lesbar, tsc und vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:09:28 +02:00
duffyduckandClaude Opus 5 910c94daa1 Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)
R183-01: PUT /retention-policies/{id} nahm retentionDays 0 ohne Bestaetigung an
und protokollierte es als MEDIUM - waehrend cleanup, seal und rehash alle ein
Confirm-Gate haben und die Folge CRITICAL ist. Die geladene Waffe war ungegatet,
der Abzug gegatet. Fix: Absenken verlangt confirm SHORTEN, wird als CRITICAL mit
Vorher/Nachher protokolliert, Untergrenze 30 Tage fuer Authentication/AuditLog.

R183-02: Nach dem Cleanup meldete verify "Keine Manipulation" bei valid:false
und 3010 endgueltig geloeschten Anmeldeprotokollen - der Befund stand nur im
Feld, die Prosa beruhigte. Fix: ehrliche Formulierung bei Luecken, und das
Gegenbuch ruft /verify mit und wertet valid:false hart, egal wie der Text
klingt.

R183-03: verify warnte dauerhaft "Altbestand nicht versiegelt", waehrend
seal-backlog zu Recht ablehnte. Eine unaufloesbare Warnung lernt man zu
ignorieren. Fix: eigener Zustand nicht_noetig, echte Warnung nennt den Befehl.

Heartbeat-Wachhund: Bleibt ein Dienstkonto laenger still als
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES (Standard 180), gibt es SUSPICIOUS/CRITICAL.
Ohne je gesehene Anmeldung wird geschwiegen statt geraten, pro Ausfall genau
eine Meldung. Verifiziert in allen drei Faellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:01:32 +02:00
duffyduckandClaude Opus 5 41671cbb96 Dienstkonto-Anmeldungen als Routine statt CRITICAL (Pentest R182)
Das Gegenbuch meldet sich stuendlich an - protokolliert wurde das als
Authentication/CRITICAL. Damit stand das vorhersagbarste Ereignis im System auf
der hoechsten Stufe: Jeder legitime Lauf trainiert den Betreiber darauf,
CRITICAL wegzuklicken, und der erste echte Vorfall erbt diesen Reflex. Dieselbe
Alarm-Muedigkeits-Klasse wie R162, diesmal von uns selbst erzeugt.

Nicht unterdrueckt, sondern eingestuft: Neues Kennzeichen isServiceAccount am
Benutzer (Migration 20260822100000). Anmeldungen solcher Konten erscheinen
weiterhin im Log - man soll sehen, dass das Gegenbuch arbeitet - aber als
Routine (LOW) mit eigenem Label 'Dienstkonto ... angemeldet (planmaessig)'.
Alle anderen Anmeldungen bleiben CRITICAL.

Offen und dem Pentester so benannt: Der eigentliche Tripwire waere der
AUSBLEIBENDE Heartbeat (Dienstkonto meldet sich N Intervalle nicht mehr) sowie
Anmeldungen ausserhalb der Kadenz oder von fremder Quell-IP. Das braucht einen
Zeitgeber und ist noch nicht gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 20:18:54 +02:00
duffyduckandClaude Opus 5 1f53a3304b Doku: Erster Start des Gegenbuchs als Drei-Schritt-Ablauf
Die Genesis-Bestaetigung stand nur als Nebensatz in der Anleitung. Jetzt als
eigener Abschnitt mit den drei Schritten (freigeben, starten, wieder leeren),
der erwarteten Ausgabe und der Begruendung, warum das Leeren wichtig ist:
Bleibt die Zeile gesetzt, wuerde nach einem Verlust des Datenverzeichnisses
stillschweigend eine neue Grundlage gesetzt statt nachgefragt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:58:30 +02:00
duffyduckandClaude Opus 5 601fb03b22 Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken
Fehlerbild aus dem echten Betrieb: "mkdir: cannot create directory
'/gegenbuch/schluessel': Permission denied", Container in der
Neustart-Schleife. Ursache: Das Datenverzeichnis kommt als Bind-Mount vom Host;
der Betreiber hatte das Projekt als root geklont, der Container lief aber
direkt als UID 1000 und durfte dort nichts anlegen. Der .gitkeep-Ansatz hatte
stillschweigend angenommen, dass als normaler Benutzer geklont wird - auf einem
Server ist root der Normalfall.

Fix: Der Container startet als root, setzt /gegenbuch per chown auf den
Arbeitsbenutzer (PUID/PGID, Standard 1000) und startet sich per setpriv als
dieser neu. Die eigentliche Arbeit laeuft weiterhin unprivilegiert. Schlaegt
das chown fehl (rootless Docker), gibt es einen Hinweis mit dem passenden
Host-Befehl statt eines stummen Abbruchs.

Verifiziert mit exakt der Ausgangslage: Datenverzeichnis auf root:root gesetzt,
Container gestartet -> laeuft durch, erzeugte Dateien gehoeren 1000:1000, der
private Schluessel liegt mit 0600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:51:10 +02:00
duffyduckandClaude Opus 5 1eb65809ec Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten
Blocker behoben: Ich hatte ein dauerhaftes API-Token vorausgesetzt - das gibt
es in OpenCRM nicht. Zugangstoken leben 15 Minuten, der Gegenbuch-Container
waere nach dem ersten Durchlauf gestorben. Aufgefallen erst durch die Frage des
Betreibers, woher er den Token nimmt.

Loesung: Das Gegenbuch meldet sich bei jedem Lauf selbst an, mit einem eigenen
Benutzerkonto, dessen Rolle ausschliesslich audit:read traegt. Damit kann es
nur Pruefwerte lesen - keine Kundendaten, keine Aenderungen. CRM_TOKEN bleibt
fuer Tests moeglich, ist aber nicht mehr der Normalweg. Fehlerfaelle (falsches
Passwort, Anmelde-Bremse, CRM nicht erreichbar) werden unterschieden und im
Klartext gemeldet.

.env.example nennt jetzt zu jedem Schalter die gueltigen Werte - bisher liess
sich nur raten, ob es prod oder production heisst. Einschliesslich des Falls
"erst nur Staging testen, Prod spaeter dazunehmen".

Beide READMEs um "Zugang einrichten" ergaenzt: Rolle mit nur audit:read,
Benutzer damit, Zugangsdaten in die .env. Mit dem Hinweis, dass jede Anmeldung
im Audit-Log erscheint - gewollt, denn so faellt auch auf, wenn das Gegenbuch
aufhoert zu arbeiten.

Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung verlangt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 19:31:46 +02:00
duffyduckandClaude Opus 5 89d617ab70 Doku: Gegenbuch in der Haupt-README, zwei Betriebsarten getrennt
Zwei Luecken, beide beim Nachlesen aufgefallen:

1. Die Haupt-README erwaehnte das Gegenbuch mit keinem Wort - ausgerechnet
   dort, wo der Betreiber nach einem git pull nachschaut. Jetzt ein Abschnitt
   direkt nach dem Audit-Siegel: wozu es gut ist, die Richtung der Verbindung
   (Gegenbuch holt, CRM kennt es nicht), die vier Befehle zum Einrichten und
   der Verweis auf die ausfuehrliche Anleitung. Ausdruecklich als optional
   gekennzeichnet - ohne Gegenbuch bleibt der Schutz in der Anwendung
   vollstaendig. Projektbaum ergaenzt.

2. Die Notar-README stammte aus der Zeit mit externem Git-Server. Fuer den
   lokalen Betrieb standen darin Anforderungen, die es dort gar nicht gibt -
   "Force-Push serverseitig sperren, das ist Pflicht" ist ohne Server
   sinnlos und verwirrt. Jetzt eine Entscheidungstabelle ganz oben (lokal =
   Normalfall vs. externes Repository = zusaetzliche Haertung), und alles
   Server-Bezogene gesammelt unter einer eigenen Ueberschrift mit dem Hinweis,
   dass es im lokalen Betrieb uebersprungen werden kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:59:11 +02:00
83 changed files with 5651 additions and 1568 deletions
+66
View File
@@ -398,6 +398,27 @@ lässt das Siegel aus.
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. | | Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
| Verlangsamt das etwas? | Nein, spürbar nicht. | | Verlangsamt das etwas? | Nein, spürbar nicht. |
**Wichtig bei einer bestehenden Installation: den Altbestand versiegeln**
Das Siegel gilt nur für Einträge, die **ab** dem Setzen des Schlüssels
geschrieben werden. Alles, was vorher im Protokoll steht, bleibt ungeschützt
Änderungen daran wären nicht erkennbar. Die Integritätsprüfung sagt das auch:
Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären
nicht erkennbar. Behebbar mit POST /api/audit-logs/seal-backlog
{"confirm":"SEAL"}.
Das ist ein **einmaliger** Schritt: Er zieht ein Siegel über den vorhandenen
Bestand, sodass spätere Änderungen daran auffallen. Der Ablauf wer es darf
(Haken **„Audit-Betrieb"** in der Benutzerverwaltung), was vorher zu prüfen ist
und wie man das Ergebnis gegenprüft steht Schritt für Schritt unter
**[Den Altbestand versiegeln](tools/audit-notary/README.md#den-altbestand-versiegeln-einmalig-im-crm)**.
Die Anleitung liegt beim Gegenbuch, gilt aber **auch ohne** es ist ein
Vorgang im CRM.
Den Zustand siehst du jederzeit unter **Einstellungen → Audit-Protokoll**, ganz
oben.
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)** **Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
Möchtest du den Schlüssel austauschen etwa weil du vermutest, dass er in Möchtest du den Schlüssel austauschen etwa weil du vermutest, dass er in
@@ -452,6 +473,50 @@ zwei Dinge:
Notiere dir die Zahl nach dem ersten Deploy: Bleibt sie konstant, ist alles Notiere dir die Zahl nach dem ersten Deploy: Bleibt sie konstant, ist alles
in Ordnung. Steigt sie, lohnt ein Blick. in Ordnung. Steigt sie, lohnt ein Blick.
### Gegenbuch optionaler Zusatzschutz auf zweitem Rechner
Das Audit-Siegel schützt die Einträge **innerhalb** der Anwendung. Es liegt aber
in derselben Datenbank, die es absichert: Wer vollen Zugriff auf den Server hat,
kommt am Ende auch an das Siegel.
Dagegen gibt es das **Gegenbuch**. Ein zweiter Rechner holt regelmäßig einen
kurzen Kontrollwert vom CRM und schreibt ihn mit. Wird später im CRM etwas
nachträglich verändert oder gelöscht, widerspricht das dem Gegenbuch und fällt
beim nächsten Durchlauf auf.
Die Richtung ist dabei entscheidend:
```
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
```
Wer OpenCRM übernimmt, kommt damit nicht an das Gegenbuch. Das CRM braucht
dafür **keinerlei Konfiguration** es liefert nur einen lesbaren Prüfwert, der
keine Geheimnisse enthält.
**Einrichten** (auf einem anderen Rechner als dem CRM):
```bash
git clone <dieses Repository> opencrm
cd opencrm/tools/audit-notary
cp .env.example .env # CRM-Adresse und Dienstkonto eintragen
docker compose up -d
```
**Vorher im CRM anlegen:** eine Rolle mit ausschließlich dem Recht
`audit:read` und einen Benutzer damit. Das Gegenbuch meldet sich mit diesem
Konto bei jedem Durchlauf selbst an ein dauerhaftes Token gibt es nicht, weil
Zugangstoken nach 15 Minuten ablaufen.
Zwei Bücher auf einer Maschine etwa für Produktion und Test sind
vorgesehen. Alles Weitere in
[tools/audit-notary/README.md](tools/audit-notary/README.md).
> **Optional.** Ohne Gegenbuch bleibt der Schutz innerhalb der Anwendung
> vollständig erhalten. Es deckt zusätzlich den Fall ab, dass jemand den
> CRM-Server selbst übernimmt.
<details> <details>
<summary><b>Technische Details</b> (für Entwickler/Admins)</summary> <summary><b>Technische Details</b> (für Entwickler/Admins)</summary>
@@ -911,6 +976,7 @@ opencrm/
│ │ └── App.tsx # Haupt-Komponente │ │ └── App.tsx # Haupt-Komponente
│ └── package.json │ └── package.json
├── Caddyfile # Optionaler Reverse-Proxy (nur mit --profile caddy) ├── Caddyfile # Optionaler Reverse-Proxy (nur mit --profile caddy)
├── tools/audit-notary/ # Gegenbuch läuft auf einem zweiten Rechner
│ ├── Dockerfile # Multi-Stage Build │ ├── Dockerfile # Multi-Stage Build
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy) │ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
│ ├── Caddyfile # Reverse-Proxy mit SSL │ ├── Caddyfile # Reverse-Proxy mit SSL
+13
View File
@@ -113,3 +113,16 @@ AUDIT_HMAC_KEY_OLD=
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel # Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
# UND Datenbank hat, kann die Kette konsistent neu rechnen. # UND Datenbank hat, kann die Kette konsistent neu rechnen.
# Erzeugung : openssl rand -hex 32 (256 Bit) # Erzeugung : openssl rand -hex 32 (256 Bit)
# ==================== DIENSTKONTO-WACHHUND ====================
# Nach wie vielen Minuten Stille eines Dienstkontos (z. B. des Gegenbuchs)
# ein Sicherheitsereignis erzeugt wird.
#
# Hintergrund: Die planmaessigen Anmeldungen selbst sind bewusst als Routine
# eingestuft, damit sie die CRITICAL-Stufe nicht entwerten. Das Signal ist
# deshalb die ABWESENHEIT: Wer das Gegenbuch stilllegt, setzt darauf, dass
# Stille nicht auffaellt.
#
# Faustregel: etwa das Dreifache des Gegenbuch-Takts. Bei stuendlichem Takt
# also 180. Ohne Dienstkonten im System passiert nichts.
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES=180
@@ -0,0 +1,9 @@
-- Dienstkonto-Kennzeichen (Pentest R182).
--
-- Das Gegenbuch meldet sich stuendlich an. Diese Anmeldung wurde bisher als
-- Authentication/CRITICAL protokolliert - also das vorhersagbarste Ereignis im
-- System auf der hoechsten Stufe. Damit trainiert die Routine den Betreiber
-- darauf, CRITICAL wegzuklicken, und der erste ECHTE Vorfall erbt diesen
-- Reflex. Mit dem Kennzeichen werden solche Anmeldungen als Routine gefuehrt.
ALTER TABLE `User`
ADD COLUMN IF NOT EXISTS `isServiceAccount` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,17 @@
-- Neuer SecurityEventType AUDIT_SEAL_CHANGED (Pentest R185-01).
--
-- Das Setzen oder Ersetzen des Bestandssiegels veraendert die Grundlage, gegen
-- die spaeter Manipulation nachgewiesen wird. Bisher landete das ausschliesslich
-- als CRITICAL-Zeile im Audit-Log - also in einem Kanal, den ein Mensch lesen
-- muss. Der Alarmkanal ist ein separater Store; ohne eigenen Ereignistyp gab es
-- dort gar keinen Eintrag, und `valid` bleibt bei einem ersetzenden Siegel
-- konstruktionsbedingt `true`.
--
-- MODIFY COLUMN ist idempotent (setzt die Enum-Definition, mehrfach ausfuehrbar).
ALTER TABLE `SecurityEvent`
MODIFY COLUMN `type` ENUM(
'LOGIN_FAILED','LOGIN_SUCCESS','RATE_LIMIT_HIT','ACCESS_DENIED',
'SSRF_BLOCKED','PASSWORD_RESET_REQUEST','PASSWORD_RESET_CONFIRM',
'LOGOUT','TOKEN_REJECTED','PERMISSION_CHANGED','AUDIT_SEAL_CHANGED',
'SUSPICIOUS'
) NOT NULL;
@@ -0,0 +1,28 @@
-- Systemrollen kennzeichnen (Rechtemodell, Etappe 1).
--
-- Bis hierher war jede Rolle ueber die Rollen-CRUD frei aenderbar - auch
-- "Admin", "DSGVO" und "Developer". Man konnte sie umbenennen oder ihre
-- Rechte leeren. Zugleich haengen die versteckten Rollen an ihrem NAMEN:
-- `user.service.ts` sucht sie per `findFirst({ where: { name: 'DSGVO' } })`.
-- Ein umbenannter Datensatz haette den Notfallpfad ins Leere laufen lassen,
-- ohne dass irgendwo etwas gemeldet worden waere.
--
-- `isSystem` macht diese Rollen zu dem, was sie immer sein sollten: von der
-- Anwendung gepflegt, ueber die API sichtbar, aber nicht veraenderbar.
-- `isHidden` ersetzt die im Frontend hartkodierte Namensliste, in der
-- "Gegenbuch" bisher fehlte - die Rolle tauchte deshalb als anhakbare
-- Rolle im Benutzerformular auf.
ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isSystem` BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isHidden` BOOLEAN NOT NULL DEFAULT false;
-- Backfill nach Namen. Wer eine dieser Rollen lokal umbenannt hat, wird hier
-- nicht getroffen; `synchronisiereRechteUndRollen` legt beim naechsten Start
-- eine neue Rolle unter dem erwarteten Namen an und markiert sie. Das ist
-- sichtbar (zwei Rollen in der Liste) und damit behandelbar - im Gegensatz
-- zu einer stillen Fehlzuordnung.
UPDATE `Role` SET `isSystem` = true
WHERE `name` IN ('Admin','Developer','DSGVO','Audit-Betrieb','Gegenbuch',
'Mitarbeiter','Mitarbeiter (Nur-Lesen)','Kunde');
UPDATE `Role` SET `isHidden` = true
WHERE `name` IN ('Developer','DSGVO','Audit-Betrieb','Gegenbuch','Kunde');
@@ -0,0 +1,104 @@
-- Rechtekatalog begradigen (Rechtemodell, Etappe 1).
--
-- 18 der 50 Rechte bewachten nichts: `tariffs:*`, `cancellation-periods:*`,
-- `contract-durations:*` und `email-providers:*` standen im Katalog und waren
-- in der Rollenverwaltung anhakbar - die Routen prueften in Wahrheit
-- `providers:*`, `platforms:*` und `settings:*`. Ein Haken, der nichts tut,
-- ist schlimmer als ein fehlender: Er behauptet eine Trennung, die es nicht
-- gibt, und wer sich darauf verlaesst, vergibt zu viel oder zu wenig, ohne
-- es zu merken.
--
-- Diese Migration ist REIN ADDITIV. Sie entzieht keiner Rolle irgendetwas.
-- Sie muss vor dem Code laufen, der die Routen umstellt - sonst verlieren
-- bestehende Rollen den Zugriff auf Tarife, Kuendigungsfristen, Laufzeiten
-- und E-Mail-Provider.
--
-- Das Aufraeumen der jetzt ueberfluessigen Sammelrechte ist ausdruecklich
-- NICHT Aufgabe dieser Migration, sondern eine bewusste Entscheidung des
-- Betreibers in der Rollenoberflaeche.
-- 1) Fehlende Rechte in den Katalog.
-- UNIQUE(resource, action) traegt die Idempotenz.
INSERT IGNORE INTO `Permission` (`resource`,`action`) VALUES
('roles','manage'),
('tariffs','create'),('tariffs','read'),('tariffs','update'),('tariffs','delete'),
('cancellation-periods','create'),('cancellation-periods','read'),
('cancellation-periods','update'),('cancellation-periods','delete'),
('contract-durations','create'),('contract-durations','read'),
('contract-durations','update'),('contract-durations','delete'),
('contract-categories','create'),('contract-categories','read'),
('contract-categories','update'),('contract-categories','delete'),
('email-providers','create'),('email-providers','read'),
('email-providers','update'),('email-providers','delete'),
('platforms','read');
-- 2) Vererbung alt -> neu: Jede Rolle, die bisher das Sammelrecht hielt,
-- bekommt das neue Recht dazu. INSERT IGNORE gegen den Primaerschluessel
-- (roleId, permissionId) macht den Block beliebig wiederholbar.
--
-- `users:delete` -> `roles:manage` bewusst nur ueber `users:delete`, nicht
-- ueber `users:create`/`users:update`: `users:delete` ist im Code bereits
-- die Admin-Heuristik (user.service.ts). Wuerde man `roles:manage` an
-- jeden mit `users:update` vererben, waere die Trennung, die dieses Recht
-- herstellen soll, im selben Zug wieder eingerissen.
INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`)
SELECT rp.`roleId`, neu.`id`
FROM `RolePermission` rp
JOIN `Permission` alt ON alt.`id` = rp.`permissionId`
JOIN (
SELECT 'providers' AS aRes,'read' AS aAct,'tariffs' AS nRes,'read' AS nAct
UNION ALL SELECT 'providers','create','tariffs','create'
UNION ALL SELECT 'providers','update','tariffs','update'
UNION ALL SELECT 'providers','delete','tariffs','delete'
UNION ALL SELECT 'platforms','create','cancellation-periods','create'
UNION ALL SELECT 'platforms','update','cancellation-periods','update'
UNION ALL SELECT 'platforms','delete','cancellation-periods','delete'
UNION ALL SELECT 'platforms','create','contract-durations','create'
UNION ALL SELECT 'platforms','update','contract-durations','update'
UNION ALL SELECT 'platforms','delete','contract-durations','delete'
UNION ALL SELECT 'settings','read', 'email-providers','read'
UNION ALL SELECT 'settings','update','email-providers','create'
UNION ALL SELECT 'settings','update','email-providers','update'
UNION ALL SELECT 'settings','update','email-providers','delete'
UNION ALL SELECT 'users','delete','roles','manage'
) m ON m.aRes = alt.`resource` AND m.aAct = alt.`action`
JOIN `Permission` neu ON neu.`resource` = m.nRes AND neu.`action` = m.nAct;
-- 3) Die bisher ungegateten Lese-Endpunkte (Plattformen, Vertragstypen,
-- Kuendigungsfristen, Laufzeiten) bekommen ab jetzt ein Recht. Damit
-- niemand seine Listen verliert, erhalten es alle Rollen, die die
-- Anwendung ueberhaupt benutzen.
--
-- Bewusst NICHT pauschal an jede Rolle: "Gegenbuch" haelt nur `audit:read`
-- und soll so wenig wert bleiben wie moeglich - sein Kennwort liegt auf
-- der Notar-Maschine im Klartext (R185-01). Dasselbe gilt fuer
-- "Audit-Betrieb".
INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`)
SELECT r.`id`, p.`id`
FROM `Role` r
JOIN `Permission` p
ON (p.`resource`,p.`action`) IN
(('cancellation-periods','read'),('contract-durations','read'),
('contract-categories','read'),('platforms','read'),('tariffs','read'))
WHERE EXISTS (
SELECT 1 FROM `RolePermission` rp2
JOIN `Permission` p2 ON p2.`id` = rp2.`permissionId`
WHERE rp2.`roleId` = r.`id`
AND (p2.`resource`,p2.`action`) IN
(('customers','read'),('contracts','read'),('providers','read'),
('platforms','create'),('settings','read'))
);
-- 4) Alle Sitzungen einmalig beenden.
--
-- Die Rechte stehen im Zugangstoken. Nach der Routenumstellung verlangt
-- z.B. PUT /tariffs/:id das Recht `tariffs:update`; jedes bereits
-- ausgestellte Token traegt aber noch den alten Anspruch und liefe bis zu
-- 15 Minuten lang in ein 403. Die Datenbank waere dann laengst richtig -
-- nur das Token alt.
--
-- Einmal neu anmelden ist ehrlicher als ein Uebergangs-Doppelgate, das in
-- sechs Monaten jemand fuer Absicht haelt. Die Meldung dafuer gibt es
-- bereits im Klartext ("Ihre Berechtigungen wurden geändert."), und das
-- Gegenbuch-Dienstkonto meldet sich stuendlich ohnehin neu an.
UPDATE `User` SET `tokenInvalidatedAt` = NOW();
+162
View File
@@ -0,0 +1,162 @@
/**
* Bestandsaufnahme des Rechtesystems. Liest nur, aendert nichts.
*
* npx tsx prisma/rechte-report.ts → Bericht auf stdout
* npx tsx prisma/rechte-report.ts > vorher.txt
*
* Zweck: Vor und nach dem Rechte-Umbau denselben Bericht erzeugen und
* vergleichen. Die Zusage lautet "niemand verliert Zugriff" - und eine
* Zusage, die niemand nachrechnen kann, ist keine.
*
* Der Bericht ist bewusst zeilenweise und sortiert, damit `diff` darauf
* etwas Lesbares ausgibt.
*/
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
// "Environment variable not found: DATABASE_URL".
import prisma from '../src/lib/prisma.js';
import { ALLE_RECHTE, SYSTEMROLLEN_NAMEN } from '../src/config/rechte-katalog.js';
/**
* Liest isSystem/isHidden, sofern die Spalten existieren.
*
* Der Bericht muss VOR und NACH der Migration laufen - das ist sein Zweck.
* Vorher gibt es die Spalten noch nicht, und ein Absturz an dieser Stelle
* haette genau die Baseline verhindert, gegen die spaeter verglichen wird.
*/
async function leseKennzeichen(): Promise<Map<number, { isSystem: boolean; isHidden: boolean }>> {
const karte = new Map<number, { isSystem: boolean; isHidden: boolean }>();
try {
const zeilen = await prisma.$queryRawUnsafe<
Array<{ id: number; isSystem: number | boolean; isHidden: number | boolean }>
>('SELECT id, isSystem, isHidden FROM `Role`');
for (const z of zeilen) {
karte.set(Number(z.id), { isSystem: Boolean(z.isSystem), isHidden: Boolean(z.isHidden) });
}
} catch {
// Spalten noch nicht vorhanden - das ist der Zustand vor der Migration.
}
return karte;
}
async function main(): Promise<void> {
const kennzeichen = await leseKennzeichen();
const rollen = await prisma.role.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
permissions: { include: { permission: true } },
_count: { select: { users: true } },
},
});
console.log('=== ROLLEN ===');
for (const r of rollen) {
const rechte = r.permissions
.map((rp) => `${rp.permission.resource}:${rp.permission.action}`)
.sort();
const k = kennzeichen.get(r.id);
const etikett = [k?.isSystem ? 'System' : null, k?.isHidden ? 'versteckt' : null]
.filter(Boolean)
.join(', ');
console.log(
`\n[${r.name}] #${r.id}` +
(etikett ? ` (${etikett})` : '') +
` ${r._count.users} Konten, ${rechte.length} Rechte`,
);
for (const recht of rechte) console.log(` ${recht}`);
}
console.log('\n=== KONTEN (aktiv) ===');
const konten = await prisma.user.findMany({
where: { isActive: true },
orderBy: { email: 'asc' },
select: {
email: true,
roles: {
select: {
role: {
// Ausdrueckliche Spaltenauswahl, damit der Bericht auch auf einer
// noch nicht migrierten Datenbank laeuft.
select: {
name: true,
permissions: { include: { permission: true } },
},
},
},
},
},
});
for (const k of konten) {
const rechte = new Set<string>();
for (const ur of k.roles) {
for (const rp of ur.role.permissions) {
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
}
}
const rollenNamen = k.roles.map((ur) => ur.role.name).sort().join(', ') || '(keine)';
console.log(`\n[${k.email}] Rollen: ${rollenNamen} ${rechte.size} Rechte`);
for (const recht of [...rechte].sort()) console.log(` ${recht}`);
}
// --- Auffaelligkeiten -----------------------------------------------------
// Nicht als Alarm gemeint, sondern als das, was man vor einem Deploy
// wissen will. Eine umbenannte Systemrolle etwa wird vom Backfill der
// Migration nicht getroffen.
console.log('\n=== HINWEISE ===');
const hinweise: string[] = [];
const vorhandeneNamen = new Set(rollen.map((r) => r.name));
for (const erwartet of SYSTEMROLLEN_NAMEN) {
if (!vorhandeneNamen.has(erwartet)) {
hinweise.push(`Systemrolle "${erwartet}" fehlt in der Datenbank.`);
}
}
for (const r of rollen) {
const k = kennzeichen.get(r.id);
// Vor der Migration gibt es die Spalte nicht - dann ist "nicht
// gekennzeichnet" kein Befund, sondern der erwartete Zustand.
if (kennzeichen.size === 0) continue;
if (SYSTEMROLLEN_NAMEN.includes(r.name) && !k?.isSystem) {
hinweise.push(`Rolle "${r.name}" ist eine Systemrolle, aber isSystem ist nicht gesetzt.`);
}
if (!SYSTEMROLLEN_NAMEN.includes(r.name) && k?.isSystem) {
hinweise.push(`Rolle "${r.name}" traegt isSystem, gehoert aber nicht zum Katalog.`);
}
}
const alleRechteDb = await prisma.permission.findMany();
const imKatalog = new Set(ALLE_RECHTE);
for (const p of alleRechteDb) {
const s = `${p.resource}:${p.action}`;
if (!imKatalog.has(s)) hinweise.push(`Recht "${s}" steht in der Datenbank, aber nicht im Katalog.`);
}
const inDb = new Set(alleRechteDb.map((p) => `${p.resource}:${p.action}`));
for (const s of ALLE_RECHTE) {
if (!inDb.has(s)) hinweise.push(`Recht "${s}" steht im Katalog, aber nicht in der Datenbank.`);
}
// Konten ohne jede Rolle koennen sich anmelden und sehen nichts - eine
// haeufige Ursache fuer "bei mir ist alles leer".
for (const k of konten) {
if (k.roles.length === 0) hinweise.push(`Konto "${k.email}" hat keine Rolle.`);
}
if (hinweise.length === 0) console.log(' keine');
else for (const h of hinweise) console.log(` ! ${h}`);
}
main()
.catch((e) => {
console.error('[rechte-report] Fehler:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Weist einem Konto eine Rolle zu oder nimmt sie ihm weg - von der
* Kommandozeile aus.
*
* npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname>
* npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> --entfernen
* npx tsx prisma/rolle-zuweisen.ts --liste
*
* Im Container:
* docker compose exec backend npx tsx prisma/rolle-zuweisen.ts \
* name@firma.de Developer
*
* WOZU: Die Teilmengenregel verhindert, dass jemand ueber die Weboberflaeche
* Rechte vergibt, die er selbst nicht haelt. Das ist gewollt - es schliesst
* die Selbst-Erhoehung. Es hat aber eine Kehrseite: Nach einer
* Neuinstallation haelt niemand `developer:access` oder `audit:admin`, und
* dann kann diese Rechte auch niemand erstmalig vergeben.
*
* Dieses Skript ist der dokumentierte Ausweg. Es umgeht die Regel bewusst,
* denn die Vertrauensgrenze stimmt: Wer eine Shell auf dieser Maschine hat,
* hat ohnehin Zugriff auf die Datenbank. Ein gestohlener Web-Zugang hat das
* nicht - und genau das ist der Unterschied, den die Regel schuetzen soll.
*
* Der Vorgang wird protokolliert und meldet die Traeger ab, damit die
* Aenderung sofort wirkt und nicht spurlos bleibt.
*/
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
// "Environment variable not found: DATABASE_URL".
import prisma from '../src/lib/prisma.js';
import { createAuditLog } from '../src/services/audit.service.js';
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.includes('--liste') || args.length === 0) {
const rollen = await prisma.role.findMany({
orderBy: { name: 'asc' },
include: { _count: { select: { users: true } } },
});
console.log('Vorhandene Rollen:');
for (const r of rollen) {
const kennz = [r.isSystem ? 'System' : null, r.isHidden ? 'versteckt' : null]
.filter(Boolean)
.join(', ');
console.log(` ${r.name}${kennz ? ` (${kennz})` : ''} ${r._count.users} Konten`);
}
if (args.length === 0) {
console.log('\nAufruf: npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> [--entfernen]');
}
return;
}
const entfernen = args.includes('--entfernen');
const [email, rollenName] = args.filter((a) => !a.startsWith('--'));
if (!email || !rollenName) {
console.error('Aufruf: npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> [--entfernen]');
process.exit(1);
}
const konto = await prisma.user.findUnique({ where: { email } });
if (!konto) {
console.error(`Kein Konto mit der Adresse "${email}".`);
process.exit(1);
}
const rolle = await prisma.role.findUnique({ where: { name: rollenName } });
if (!rolle) {
console.error(`Keine Rolle mit dem Namen "${rollenName}". Vorhandene mit --liste ansehen.`);
process.exit(1);
}
const vorhanden = await prisma.userRole.findUnique({
where: { userId_roleId: { userId: konto.id, roleId: rolle.id } },
});
if (entfernen) {
if (!vorhanden) {
console.log(`"${email}" hat die Rolle "${rollenName}" gar nicht. Nichts zu tun.`);
return;
}
await prisma.userRole.delete({
where: { userId_roleId: { userId: konto.id, roleId: rolle.id } },
});
} else {
if (vorhanden) {
console.log(`"${email}" hat die Rolle "${rollenName}" bereits. Nichts zu tun.`);
return;
}
await prisma.userRole.create({ data: { userId: konto.id, roleId: rolle.id } });
}
// Sofort wirksam machen: Die Rechte stehen im Zugangstoken, sonst behielte
// das Konto bis zu 15 Minuten den alten Stand.
await prisma.user.update({
where: { id: konto.id },
data: { tokenInvalidatedAt: new Date() },
});
// Nachvollziehbar machen. Ein Eingriff von der Kommandozeile ist berechtigt,
// aber er darf nicht unsichtbar sein - sonst waere das Skript selbst die
// Luecke, die es schliessen soll.
//
// Ueber createAuditLog, NICHT ueber prisma.auditLog.create: Das Protokoll
// ist eine Hash-Kette. Ein roh eingefuegter Datensatz haette kein `hash`
// und keinen `previousHash` und wuerde bei der naechsten Pruefung als
// Luecke erscheinen - das Skript wuerde also ausgerechnet dort Zweifel
// saeen, wo es Klarheit schaffen soll.
await createAuditLog({
userEmail: 'system (CLI)',
userRole: 'Kommandozeile',
action: entfernen ? 'DELETE' : 'CREATE',
sensitivity: 'CRITICAL',
resourceType: 'UserRole',
resourceId: `${konto.id}:${rolle.id}`,
resourceLabel: entfernen
? `Rolle "${rolle.name}" von ${email} entfernt (Kommandozeile)`
: `Rolle "${rolle.name}" an ${email} vergeben (Kommandozeile)`,
endpoint: 'prisma/rolle-zuweisen.ts',
httpMethod: 'CLI',
ipAddress: 'lokal',
changesAfter: { konto: email, rolle: rolle.name, entfernt: entfernen },
});
console.log(
entfernen
? `Rolle "${rolle.name}" von "${email}" entfernt.`
: `Rolle "${rolle.name}" an "${email}" vergeben.`,
);
console.log('Das Konto muss sich neu anmelden, damit die Änderung greift.');
}
main()
.catch((e) => {
console.error('[rolle-zuweisen] Fehler:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+16
View File
@@ -76,6 +76,11 @@ model User {
firstName String firstName String
lastName String lastName String
isActive Boolean @default(true) isActive Boolean @default(true)
/// Dienstkonto (z. B. das Gegenbuch). Meldet sich planmaessig und haeufig an.
/// Solche Anmeldungen werden im Audit-Log als Routine gefuehrt statt als
/// CRITICAL - sonst trainiert das vorhersagbarste Ereignis im System den
/// Betreiber darauf, die hoechste Stufe wegzuklicken (Pentest R182).
isServiceAccount Boolean @default(false)
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung) tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
// Passwort-Reset // Passwort-Reset
@@ -101,6 +106,16 @@ model Role {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
description String? description String?
/// Von der Anwendung gepflegte Rolle (config/rechte-katalog.ts). Über die
/// API sichtbar, aber nicht umbenennbar, nicht löschbar, Rechte nicht
/// änderbar. Ohne diese Sperre liess sich die Admin-Rolle über die
/// Rollen-CRUD leeren oder umbenennen, und die Namens-Schlüssel, an denen
/// die versteckten Rollen hängen, waren nur scheinbar stabil.
isSystem Boolean @default(false)
/// Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden über
/// die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff,
/// Audit-Betrieb) oder gehören zu einem technischen Konto.
isHidden Boolean @default(false)
permissions RolePermission[] permissions RolePermission[]
users UserRole[] users UserRole[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -1477,6 +1492,7 @@ enum SecurityEventType {
LOGOUT // expliziter Logout LOGOUT // expliziter Logout
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
AUDIT_SEAL_CHANGED // Bestandssiegel gesetzt oder ersetzt (Beweis-Grundlage)
SUSPICIOUS // generischer Catch-All SUSPICIOUS // generischer Catch-All
} }
+27 -213
View File
@@ -1,225 +1,28 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import crypto from 'crypto'; import crypto from 'crypto';
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../src/config/rechte-katalog.js';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
console.log('Seeding database...'); console.log('Seeding database...');
// ==================== PERMISSIONS ==================== // ==================== RECHTE UND ROLLEN ====================
// Ressourcen mit ihren erlaubten Aktionen // Katalog und Systemrollen kommen aus einer einzigen Definition
const resourcePermissions: Record<string, string[]> = { // (src/config/rechte-katalog.ts), aufgeloest von rollen-sync.service.ts.
// Haupt-Ressourcen (CRUD) //
customers: ['create', 'read', 'update', 'delete'], // Bis 09/2026 stand hier eine zweite, eigene Kopie - und sie wich ab: Die
contracts: ['create', 'read', 'update', 'delete'], // DSGVO-Rolle bekam `audit:*` komplett, also auch `audit:admin`. Gerettet
users: ['create', 'read', 'update', 'delete'], // hat das nur die Reihenfolge im Container-Start (sync-roles lief danach
platforms: ['create', 'read', 'update', 'delete'], // und raeumte Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte
providers: ['create', 'read', 'update', 'delete'], // die Buendelung zurueck. Drei Beschreibungen desselben Sachverhalts sind
tariffs: ['create', 'read', 'update', 'delete'], // zwei zu viel.
// Konfiguration (CRUD) await synchronisiereRechteUndRollen(prisma);
'cancellation-periods': ['create', 'read', 'update', 'delete'],
'contract-durations': ['create', 'read', 'update', 'delete'],
'contract-categories': ['create', 'read', 'update', 'delete'],
'email-providers': ['create', 'read', 'update', 'delete'],
// Einstellungen (nur lesen/ändern)
settings: ['read', 'update'],
// Spezial-Permissions
developer: ['access'],
emails: ['delete'],
// DSGVO & Audit
audit: ['read', 'export', 'admin'],
gdpr: ['export', 'delete', 'admin'],
};
const permissions: { resource: string; action: string }[] = []; const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
for (const [resource, actions] of Object.entries(resourcePermissions)) { const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
for (const action of actions) {
permissions.push({ resource, action });
}
}
for (const perm of permissions) {
await prisma.permission.upsert({
where: { resource_action: perm },
update: {},
create: perm,
});
}
console.log(`Permissions created (${permissions.length} total)`);
// Get all permissions
const allPermissions = await prisma.permission.findMany();
const customerReadPerm = allPermissions.find(
(p) => p.resource === 'customers' && p.action === 'read'
);
const contractReadPerm = allPermissions.find(
(p) => p.resource === 'contracts' && p.action === 'read'
);
const platformReadPerm = allPermissions.find(
(p) => p.resource === 'platforms' && p.action === 'read'
);
const providerReadPerm = allPermissions.find(
(p) => p.resource === 'providers' && p.action === 'read'
);
// Helper: Sync permissions for a role (adds missing, removes excess)
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);
// Add missing permissions
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 hinzugefügt für Rolle #${roleId}`);
}
// Remove excess permissions
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 entfernt für Rolle #${roleId}`);
}
}
// Create roles
// Admin - all permissions EXCEPT developer:access and audit/gdpr (controlled separately via checkboxes)
const adminPermissions = allPermissions.filter(
(p) =>
!(p.resource === 'developer' && p.action === 'access') &&
p.resource !== 'audit' &&
p.resource !== 'gdpr'
);
const adminRole = await prisma.role.upsert({
where: { name: 'Admin' },
update: {},
create: {
name: 'Admin',
description: 'Voller Zugriff auf alle Funktionen',
permissions: {
create: adminPermissions.map((p) => ({ permissionId: p.id })),
},
},
});
await syncRolePermissions(adminRole.id, adminPermissions.map((p) => p.id));
// Developer - ALL permissions (developer:access + alles andere)
const developerPermissions = allPermissions;
const developerRole = await prisma.role.upsert({
where: { name: 'Developer' },
update: {},
create: {
name: 'Developer',
description: 'Voller Zugriff inkl. Entwickler-Tools',
permissions: {
create: developerPermissions.map((p) => ({ permissionId: p.id })),
},
},
});
await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id));
// DSGVO - audit and gdpr permissions (hidden role, controlled via hasGdprAccess)
const gdprPermissions = allPermissions.filter(
(p) => p.resource === 'audit' || p.resource === 'gdpr'
);
const gdprRole = await prisma.role.upsert({
where: { name: 'DSGVO' },
update: {},
create: {
name: 'DSGVO',
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
permissions: {
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
},
},
});
await syncRolePermissions(gdprRole.id, gdprPermissions.map((p) => p.id));
// Employee - full access to customers, contracts, read access to lookup tables
const employeePermIds = allPermissions
.filter(
(p) =>
p.resource === 'customers' ||
p.resource === 'contracts' ||
// Read-only Zugriff auf Stammdaten und Konfiguration
(p.action === 'read' && [
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
].includes(p.resource))
)
.map((p) => p.id);
const employeeRole = await prisma.role.upsert({
where: { name: 'Mitarbeiter' },
update: {},
create: {
name: 'Mitarbeiter',
description: 'Kann Kunden und Verträge verwalten',
permissions: {
create: employeePermIds.map((id) => ({ permissionId: id })),
},
},
});
await syncRolePermissions(employeeRole.id, employeePermIds);
// Read-only employee - read access to main entities and lookup tables
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 readOnlyRole = await prisma.role.upsert({
where: { name: 'Mitarbeiter (Nur-Lesen)' },
update: {},
create: {
name: 'Mitarbeiter (Nur-Lesen)',
description: 'Kann nur lesen, keine Änderungen',
permissions: {
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
},
},
});
await syncRolePermissions(readOnlyRole.id, readOnlyPermIds);
// Customer role - read own data only (handled in middleware)
const customerRole = await prisma.role.upsert({
where: { name: 'Kunde' },
update: {},
create: {
name: 'Kunde',
description: 'Kann nur eigene Daten lesen',
permissions: {
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
},
},
});
await syncRolePermissions(customerRole.id, readOnlyPermIds);
console.log('Roles created');
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo // Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen- // landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
@@ -265,8 +68,19 @@ async function main() {
password: hashedPassword, password: hashedPassword,
firstName: 'Admin', firstName: 'Admin',
lastName: 'User', lastName: 'User',
// Zusaetzlich die DSGVO-Rolle (Pentest R189-01).
//
// Ohne sie kann nach einem frischen Seed NIEMAND eine Auskunft nach
// Art. 15 oder eine Loeschung nach Art. 17 ausfuehren - die Rechte
// haengen an DSGVO und Developer, und beide waren keinem Konto
// zugewiesen. Ein Ausfall mit Fristwirkung, ausgeloest durch nichts
// weiter als eine Neuinstallation.
//
// Die Trennung bleibt: Die Admin-ROLLE bekommt diese Rechte weiterhin
// nicht. Nur dieses eine Bootstrap-Konto traegt beide, damit ueberhaupt
// jemand handlungsfaehig ist.
roles: { roles: {
create: [{ roleId: adminRole.id }], create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
}, },
}, },
}); });
+20 -148
View File
@@ -1,160 +1,32 @@
/** /**
* 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 * Hintergrund: seed.ts laeuft nur auf leeren Datenbanken (USER_COUNT=0). Wer
* System schon installiert hat, bekommt nachträglich hinzugefügte * das System schon installiert hat, bekommt nachtraeglich hinzugefuegte
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann * Rechte oder geaenderte Rollenzuordnungen sonst NICHT.
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
* *
* Dieses Skript synchronisiert ausschließlich: * Die Definition selbst steht in `src/config/rechte-katalog.ts`, die Logik in
* - Permission-Katalog (resource/action-Paare aus dem Code) * `src/services/rollen-sync.service.ts`. Dieses Skript ist nur noch der
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter, * Einstiegspunkt fuer die Kommandozeile - bis 09/2026 trug es eine eigene
* Mitarbeiter (Nur-Lesen), Kunde) * Kopie des Katalogs, und es war nicht die einzige.
* *
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf * KEINE Stammdaten, KEINE Benutzer, KEINE Vertraege - auf laufenden
* laufenden Prod-DBs sicher. * Produktionsdatenbanken sicher.
*/ */
import { PrismaClient } from '@prisma/client'; // Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
// "Environment variable not found: DATABASE_URL".
import prisma from '../src/lib/prisma.js';
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
const prisma = new PrismaClient();
const RESOURCE_PERMISSIONS: Record<string, string[]> = { synchronisiereRechteUndRollen(prisma)
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: audit + gdpr komplett
const gdprPermIds = allPermissions
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
.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 Funktionen', permIds: adminPermIds },
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
{ 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()
.catch((e) => { .catch((e) => {
console.error('[sync-roles] Fehler:', e); console.error('[rollen-sync] Fehler:', e);
process.exit(1); process.exit(1);
}) })
.finally(async () => { .finally(async () => {
+343
View File
@@ -0,0 +1,343 @@
/**
* Der Rechte- und Rollenkatalog. Eine Quelle, drei Verbraucher.
*
* Bis hierher stand derselbe Katalog dreimal im Code: in `prisma/seed.ts`,
* in `prisma/sync-roles.ts` und - abweichend - in `factoryReset`
* (`services/backup.service.ts`). Die dritte Kopie kannte weder `audit:*`
* noch `gdpr:*` und legte die Rollen DSGVO, Audit-Betrieb und Gegenbuch gar
* nicht erst an: Nach einem Werksreset konnte niemand mehr eine Auskunft
* nach Art. 15 DSGVO ausfuehren, und gemerkt haette man es erst, wenn eine
* Frist laeuft.
*
* Drei Beschreibungen desselben Sachverhalts sind zwei zu viel. Welche davon
* gilt, entschied bisher die Reihenfolge im Container-Start.
*
* Dieses Modul liegt bewusst unter `src/` und nicht unter `prisma/`: Es wird
* sowohl vom kompilierten Backend (`dist/`) als auch von den CLI-Skripten
* unter `prisma/` gebraucht, die per `tsx` laufen. Das Dockerfile kopiert
* `src/` zusaetzlich ins Runtime-Image, genau dafuer.
*
* KEIN Prisma-Import hier - reine Daten. Die Aufloesung gegen die Datenbank
* macht `services/rollen-sync.service.ts`.
*/
export interface RechtDefinition {
resource: string;
action: string;
/** Klartext fuer die Oberflaeche. "cancellation-periods:update" sagt einem
* Sachbearbeiter nichts. */
bezeichnung: string;
/** Ueberschrift, unter der das Recht in der Rechtematrix steht. */
gruppe: string;
}
export const GRUPPE = {
KUNDEN: 'Kunden',
VERTRAEGE: 'Verträge',
EMAILS: 'E-Mails',
STAMMDATEN: 'Stammdaten',
EINSTELLUNGEN: 'Einstellungen',
BENUTZER: 'Benutzer und Rechte',
AUFSICHT: 'Aufsicht und Datenschutz',
SYSTEM: 'System',
} as const;
/**
* Der vollstaendige Katalog. Was hier nicht steht, existiert nicht.
*
* Bis 09/2026 standen hier 18 Rechte, die nichts bewachten: `tariffs:*`,
* `cancellation-periods:*`, `contract-durations:*` und `email-providers:*`
* waren anhakbar, aber die Routen prueften in Wahrheit `providers:*`,
* `platforms:*` und `settings:*`. Ein Haken, der nichts tut, ist schlimmer
* als ein fehlender: Er behauptet eine Trennung, die es nicht gibt.
* Seit dem granularen Gaten stimmt jede Zeile hier mit mindestens einer
* Route ueberein.
*/
export const RECHTE_KATALOG: RechtDefinition[] = [
// ---- Kunden -------------------------------------------------------------
{ resource: 'customers', action: 'create', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden anlegen' },
{ resource: 'customers', action: 'read', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden sehen' },
{ resource: 'customers', action: 'update', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden bearbeiten' },
{ resource: 'customers', action: 'delete', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden löschen' },
// ---- Verträge -----------------------------------------------------------
{ resource: 'contracts', action: 'create', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge anlegen' },
{ resource: 'contracts', action: 'read', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge sehen' },
{ resource: 'contracts', action: 'update', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge bearbeiten' },
{ resource: 'contracts', action: 'delete', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge löschen' },
// ---- E-Mails ------------------------------------------------------------
{ resource: 'emails', action: 'delete', gruppe: GRUPPE.EMAILS, bezeichnung: 'E-Mails löschen' },
// ---- Stammdaten ---------------------------------------------------------
{ resource: 'platforms', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen anlegen' },
{ resource: 'platforms', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen sehen' },
{ resource: 'platforms', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen bearbeiten' },
{ resource: 'platforms', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen löschen' },
{ resource: 'providers', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter anlegen' },
{ resource: 'providers', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter sehen' },
{ resource: 'providers', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter bearbeiten' },
{ resource: 'providers', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter löschen' },
{ resource: 'tariffs', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife anlegen' },
{ resource: 'tariffs', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife sehen' },
{ resource: 'tariffs', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife bearbeiten' },
{ resource: 'tariffs', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife löschen' },
{ resource: 'cancellation-periods', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen anlegen' },
{ resource: 'cancellation-periods', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen sehen' },
{ resource: 'cancellation-periods', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen bearbeiten' },
{ resource: 'cancellation-periods', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen löschen' },
{ resource: 'contract-durations', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten anlegen' },
{ resource: 'contract-durations', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten sehen' },
{ resource: 'contract-durations', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten bearbeiten' },
{ resource: 'contract-durations', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten löschen' },
{ resource: 'contract-categories', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen anlegen' },
{ resource: 'contract-categories', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen sehen' },
{ resource: 'contract-categories', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen bearbeiten' },
{ resource: 'contract-categories', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen löschen' },
// ---- Einstellungen ------------------------------------------------------
{ resource: 'settings', action: 'read', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'Einstellungen sehen' },
{ resource: 'settings', action: 'update', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'Einstellungen ändern' },
{ resource: 'email-providers', action: 'create', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider anlegen' },
{ resource: 'email-providers', action: 'read', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider sehen' },
{ resource: 'email-providers', action: 'update', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider bearbeiten' },
{ resource: 'email-providers', action: 'delete', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider löschen' },
// ---- Benutzer und Rechte ------------------------------------------------
{ resource: 'users', action: 'create', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer anlegen' },
{ resource: 'users', action: 'read', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer sehen' },
{ resource: 'users', action: 'update', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer bearbeiten' },
{ resource: 'users', action: 'delete', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer löschen' },
{
resource: 'roles',
action: 'manage',
gruppe: GRUPPE.BENUTZER,
// Getrennt von `users:*`, weil "Konten anlegen" und "festlegen, was ein
// Konto darf" zwei verschiedene Befugnisse sind (Pentest, Rollenmodell).
bezeichnung: 'Rollen und Rechte verwalten',
},
// ---- Aufsicht und Datenschutz -------------------------------------------
{ resource: 'audit', action: 'read', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll lesen' },
{ resource: 'audit', action: 'export', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll exportieren' },
{ resource: 'audit', action: 'admin', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll versiegeln, aufräumen, Aufbewahrung ändern' },
{ resource: 'gdpr', action: 'export', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Auskunft nach Art. 15 DSGVO erteilen' },
{ resource: 'gdpr', action: 'delete', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Löschung nach Art. 17 DSGVO ausführen' },
{ resource: 'gdpr', action: 'admin', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Datenschutz-Verwaltung' },
// ---- System -------------------------------------------------------------
{ resource: 'developer', action: 'access', gruppe: GRUPPE.SYSTEM, bezeichnung: 'Entwicklerwerkzeuge und Datenbankzugriff' },
];
/** `resource:action` in der Schreibweise, die `requirePermission` erwartet. */
export function alsRechtString(r: { resource: string; action: string }): string {
return `${r.resource}:${r.action}`;
}
export const ALLE_RECHTE: string[] = RECHTE_KATALOG.map(alsRechtString);
// ---------------------------------------------------------------------------
// Rollen
// ---------------------------------------------------------------------------
/**
* Die Namen der von der Anwendung gepflegten Rollen. Sie werden an mehreren
* Stellen als Schluessel benutzt (Checkbox-Zuweisung, Notfallpfade in
* `user.service.ts`, Anzeigefilter im Frontend). Ab der Systemrollen-Sperre
* sind sie stabil: Eine Rolle mit `isSystem` laesst sich nicht mehr
* umbenennen, deshalb traegt der Name jetzt, was er vorher nur zu tragen
* schien.
*/
export const ROLLE_ADMIN = 'Admin';
export const ROLLE_DEVELOPER = 'Developer';
export const ROLLE_DSGVO = 'DSGVO';
export const ROLLE_AUDIT_BETRIEB = 'Audit-Betrieb';
export const ROLLE_GEGENBUCH = 'Gegenbuch';
export const ROLLE_MITARBEITER = 'Mitarbeiter';
export const ROLLE_MITARBEITER_LESEND = 'Mitarbeiter (Nur-Lesen)';
export const ROLLE_KUNDE = 'Kunde';
export interface SystemrollenSpec {
name: string;
description: string;
/**
* Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden ueber
* die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff,
* Audit-Betrieb) oder gehoeren zu einem technischen Konto.
*/
isHidden: boolean;
/** Praedikat ueber den Katalog. */
rechte: (r: RechtDefinition) => boolean;
}
/** Stammdaten, die ein Sachbearbeiter zum Arbeiten lesen koennen muss. */
const LESE_STAMMDATEN = [
'platforms',
'providers',
'tariffs',
'cancellation-periods',
'contract-durations',
'contract-categories',
];
export const SYSTEMROLLEN: SystemrollenSpec[] = [
{
name: ROLLE_ADMIN,
description:
'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz dafür die separaten Rollen DSGVO und Audit-Betrieb)',
isHidden: false,
// Alles ausser den Entwicklerwerkzeugen und der Aufsicht. Die Trennung
// ist Absicht: Wer verwaltet, beaufsichtigt sich nicht selbst.
rechte: (r) =>
r.resource !== 'developer' && r.resource !== 'audit' && r.resource !== 'gdpr',
},
{
name: ROLLE_DEVELOPER,
description: 'Voller Zugriff inkl. Entwickler-Tools',
isHidden: true,
rechte: () => true,
},
{
name: ROLLE_DSGVO,
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
isHidden: true,
// 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 (Pentest R186).
rechte: (r) =>
r.resource === 'gdpr' ||
(r.resource === 'audit' && (r.action === 'read' || r.action === 'export')),
},
{
name: ROLLE_AUDIT_BETRIEB,
description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern',
isHidden: true,
// Die eingreifenden Rechte am Protokoll, plus `audit:read` - siegeln zu
// duerfen, ohne das Ergebnis sehen zu koennen, waere unbrauchbar.
rechte: (r) => r.resource === 'audit' && (r.action === 'read' || r.action === 'admin'),
},
{
name: ROLLE_GEGENBUCH,
description: 'Darf das Audit-Protokoll nur lesen und prüfen sonst nichts',
isHidden: true,
// Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint
// und /verify, und beide verlangen `audit:read`. Das Kennwort des
// Dienstkontos liegt auf der Gegenbuch-Maschine im Klartext in der .env;
// es muss deshalb so wenig wert sein wie moeglich (Pentest R185-01).
rechte: (r) => r.resource === 'audit' && r.action === 'read',
},
{
name: ROLLE_MITARBEITER,
description: 'Kann Kunden und Verträge verwalten',
isHidden: false,
rechte: (r) =>
r.resource === 'customers' ||
r.resource === 'contracts' ||
(r.action === 'read' && LESE_STAMMDATEN.includes(r.resource)),
},
{
name: ROLLE_MITARBEITER_LESEND,
description: 'Kann nur lesen, keine Änderungen',
isHidden: false,
rechte: (r) =>
r.action === 'read' &&
(r.resource === 'customers' ||
r.resource === 'contracts' ||
LESE_STAMMDATEN.includes(r.resource)),
},
{
name: ROLLE_KUNDE,
description: 'Kann nur eigene Daten lesen',
isHidden: true,
// Hinweis: Portal-Kunden bekommen ihre Rechte NICHT aus dieser Rolle,
// sondern aus einem festen Array in `auth.service.ts`. Die Rolle existiert
// fuer CRM-seitig angelegte Kundenkonten.
rechte: (r) =>
r.action === 'read' &&
(r.resource === 'customers' ||
r.resource === 'contracts' ||
LESE_STAMMDATEN.includes(r.resource)),
},
];
export const SYSTEMROLLEN_NAMEN: string[] = SYSTEMROLLEN.map((r) => r.name);
/** Die ueber Haken im Benutzerformular vergebenen Rollen. */
export const VERSTECKTE_ROLLEN_NAMEN: string[] = SYSTEMROLLEN.filter((r) => r.isHidden).map(
(r) => r.name,
);
/** Rechte einer Systemrolle als `resource:action`-Liste. */
export function rechteDerSystemrolle(name: string): string[] {
const spec = SYSTEMROLLEN.find((r) => r.name === name);
if (!spec) return [];
return RECHTE_KATALOG.filter(spec.rechte).map(alsRechtString);
}
/**
* Vergleicht Rollennamen so, wie die Sperre sie vergleichen muss: getrimmt
* und ohne Ruecksicht auf Gross-/Kleinschreibung. Sonst liesse sich eine
* zweite Rolle " admin " anlegen, die in Listen wie die echte aussieht.
*/
export function istSystemrollenName(name: string): boolean {
const normalisiert = name.trim().toLocaleLowerCase('de-DE');
return SYSTEMROLLEN_NAMEN.some((n) => n.toLocaleLowerCase('de-DE') === normalisiert);
}
// ---------------------------------------------------------------------------
// Kundenportal - bewusst NICHT Teil des Rollensystems
// ---------------------------------------------------------------------------
/**
* Die Rechte eines angemeldeten Portal-Kunden. Fest, kurz, abschliessend.
*
* DIESE TRENNUNG IST ABSICHT UND SOLL BLEIBEN.
*
* Kunden im Portal ziehen ihre Rechte NICHT aus Rollen, sondern aus dieser
* Liste. Das ist kein Altbestand, den man noch migrieren muesste - es ist
* die Sicherung dagegen, dass eine Aenderung am Rollenmodell versehentlich
* auf die Kundenseite durchschlaegt. Ein Kunde, der ueber eine Rolle
* ploetzlich `contracts:create` haelt, koennte in fremdem Namen Vertraege
* anlegen; die Portalansicht ist fuer so etwas nicht gebaut und pruefte es
* nicht.
*
* Wer hier etwas hinzufuegen will, aendert nicht eine Liste, sondern eine
* Vertrauensgrenze. `pruefePortalRechte()` weist beim Start alles ab, was
* nicht Lesen ist - absichtlich als Alarm und nicht als stiller Default.
*
* Die Durchsetzung liegt zusaetzlich im Gate (`requirePermission`): Ein
* Portal-Zugang bekommt dort nur, was auch hier steht, unabhaengig davon,
* was in seinem Token behauptet wird.
*/
export const PORTAL_RECHTE: readonly string[] = [
'contracts:read', // eigene Vertraege lesen
'customers:read', // eigene Kundendaten lesen
] as const;
const PORTAL_ERLAUBTE_AKTIONEN = ['read'];
/**
* Wacht darueber, dass die Portal-Liste eine Leseliste bleibt.
*
* Wird beim Start aufgerufen. Ein schreibendes Recht hier waere kein
* Schoenheitsfehler, sondern eine stille Ausweitung der Kundenrechte - und
* genau die soll nicht durch eine unbedachte Zeile passieren koennen.
*/
export function pruefePortalRechte(): string[] {
const beanstandet: string[] = [];
for (const recht of PORTAL_RECHTE) {
const [, aktion] = recht.split(':');
if (!PORTAL_ERLAUBTE_AKTIONEN.includes(aktion)) {
beanstandet.push(recht);
}
}
return beanstandet;
}
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import * as appSettingService from '../services/appSetting.service.js'; import * as appSettingService from '../services/appSetting.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
@@ -81,10 +82,7 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
}); });
res.json({ success: true, message: 'Einstellung gespeichert' } as ApiResponse); res.json({ success: true, message: 'Einstellung gespeichert' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellung');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellung',
} as ApiResponse);
} }
} }
@@ -146,9 +144,6 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse); res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellungen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellungen',
} as ApiResponse);
} }
} }
+339 -71
View File
@@ -1,48 +1,127 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { FachlicherFehler } from '../utils/apiError.js';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
import * as auditService from '../services/audit.service.js'; import * as auditService from '../services/audit.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { AuditAction, AuditSensitivity } from '@prisma/client'; import { AuditAction, AuditSensitivity } from '@prisma/client';
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
/**
* Filterwerte aus der Query pruefen (Pentest R185-02).
*
* Vorher gingen `action` und `sensitivity` als roher String an die Enum-Spalte.
* Ein ungueltiger Wert liess Prisma auflaufen und der Handler antwortete 500.
* Das ist zweierlei: eine fehlende Validierung und ein Fehler-Orakel. Wer
* 200 gegen 500 vergleicht, liest die Enum-Mitglieder aus, ohne sie zu kennen.
* Ein ungueltiger Filter ist eine schlechte ANFRAGE, keine Server-Panne: 400.
*
* Dasselbe gilt fuer Datumsangaben (`new Date('foo')` ergibt Invalid Date und
* sprengt die Query erst in der Datenbank) und fuer Zahlen (`parseInt('x')`
* ergibt NaN).
*/
class FilterFehler extends FachlicherFehler {
constructor(nachricht: string) {
super(nachricht, 400);
}
}
const AUDIT_ACTIONS = Object.values(AuditAction) as string[];
const AUDIT_SENSITIVITIES = Object.values(AuditSensitivity) as string[];
function pruefeEnum<T extends string>(
wert: unknown, erlaubt: string[], feld: string,
): T | undefined {
if (wert === undefined || wert === '') return undefined;
if (typeof wert !== 'string' || !erlaubt.includes(wert)) {
// Die erlaubten Werte stehen ohnehin in der Oberflaeche und im Schema
// sie zu nennen verraet nichts und erspart Rateversuche.
throw new FilterFehler(
`Ungültiger Wert für "${feld}". Erlaubt: ${erlaubt.join(', ')}.`,
);
}
return wert as T;
}
function pruefeDatum(wert: unknown, feld: string): Date | undefined {
if (wert === undefined || wert === '') return undefined;
const d = new Date(wert as string);
if (Number.isNaN(d.getTime())) {
throw new FilterFehler(`Ungültiges Datum für "${feld}".`);
}
return d;
}
function pruefeZahl(wert: unknown, feld: string): number | undefined {
if (wert === undefined || wert === '') return undefined;
const n = Number(wert);
if (!Number.isInteger(n) || n < 0) {
throw new FilterFehler(`Ungültige Zahl für "${feld}".`);
}
return n;
}
/** Freitextfelder begrenzen unbegrenzte LIKE-Muster sind teuer. */
function pruefeText(wert: unknown, feld: string, maxLaenge = 200): string | undefined {
if (wert === undefined || wert === '') return undefined;
if (typeof wert !== 'string' || wert.length > maxLaenge) {
throw new FilterFehler(`Ungültiger Wert für "${feld}" (max. ${maxLaenge} Zeichen).`);
}
return wert;
}
/**
* Filter aus der Query lesen EINE Stelle fuer Liste und Export (R186-01).
*
* Vorher pflegte jeder Endpunkt seine eigene Liste, und die des Exports war
* kuerzer: `userId`, `customerId`, `dataSubjectId`, `resourceId`, `success` und
* `search` wurden dort stillschweigend verworfen. Ein bewusst eingegrenzter
* Export „nur die Spur von Benutzer X“ fuer eine DSGVO-Auskunft oder eine
* Innentaeter-Pruefung lieferte damit das VOLLSTAENDIGE Protokoll aller
* Nutzer zurueck. Mit HTTP 200 und ohne jeden Hinweis: ein beruhigendes
* Signal ueber einem Ergebnis, das genau das Gegenteil dessen ist, was
* angefragt wurde. Auf einem datenminimierungspflichtigen Export ist das
* nicht nur ein fehlender Filter, sondern eine Weitergabe.
*
* Zwei Listen, die dasselbe bedeuten sollen, laufen frueher oder spaeter
* auseinander. Deshalb gibt es jetzt nur noch diese eine.
*/
function leseFilter(req: AuthRequest) {
const q = req.query;
return {
userId: pruefeZahl(q.userId, 'userId'),
customerId: pruefeZahl(q.customerId, 'customerId'),
dataSubjectId: pruefeZahl(q.dataSubjectId, 'dataSubjectId'),
action: pruefeEnum<AuditAction>(q.action, AUDIT_ACTIONS, 'action'),
sensitivity: pruefeEnum<AuditSensitivity>(q.sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
resourceType: pruefeText(q.resourceType, 'resourceType', 100),
resourceId: pruefeText(q.resourceId, 'resourceId', 100),
startDate: pruefeDatum(q.startDate, 'startDate'),
endDate: pruefeDatum(q.endDate, 'endDate'),
success: q.success !== undefined ? q.success === 'true' : undefined,
search: pruefeText(q.search, 'search'),
};
}
/** /**
* Audit-Logs mit Filtern abrufen * Audit-Logs mit Filtern abrufen
*/ */
export async function getAuditLogs(req: AuthRequest, res: Response) { export async function getAuditLogs(req: AuthRequest, res: Response) {
try { try {
const {
userId,
customerId,
dataSubjectId,
action,
sensitivity,
resourceType,
resourceId,
startDate,
endDate,
success,
search,
page,
limit,
} = req.query;
const result = await auditService.searchAuditLogs({ const result = await auditService.searchAuditLogs({
userId: userId ? parseInt(userId as string) : undefined, ...leseFilter(req),
customerId: customerId ? parseInt(customerId as string) : undefined, page: pruefeZahl(req.query.page, 'page') || 1,
dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined, // Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
action: action as AuditAction | undefined, // Zug ziehen, an der Seitenlogik vorbei.
sensitivity: sensitivity as AuditSensitivity | undefined, limit: Math.min(pruefeZahl(req.query.limit, 'limit') || 50, 200),
resourceType: resourceType as string | undefined,
resourceId: resourceId as string | undefined,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
success: success !== undefined ? success === 'true' : undefined,
search: search as string | undefined,
page: page ? parseInt(page as string) : 1,
limit: limit ? parseInt(limit as string) : 50,
}); });
res.json({ success: true, ...result }); res.json({ success: true, ...result });
} catch (error) { } catch (error) {
if (error instanceof FilterFehler) {
res.status(400).json({ success: false, error: error.message });
return;
}
console.error('Fehler beim Abrufen der Audit-Logs:', error); console.error('Fehler beim Abrufen der Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' }); res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
} }
@@ -96,25 +175,8 @@ export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
*/ */
export async function exportAuditLogs(req: AuthRequest, res: Response) { export async function exportAuditLogs(req: AuthRequest, res: Response) {
try { try {
const format = (req.query.format as 'json' | 'csv') || 'json'; const format = req.query.format === 'csv' ? 'csv' : 'json';
const { const content = await auditService.exportAuditLogs(leseFilter(req), format);
action,
sensitivity,
resourceType,
startDate,
endDate,
} = req.query;
const content = await auditService.exportAuditLogs(
{
action: action as AuditAction | undefined,
sensitivity: sensitivity as AuditSensitivity | undefined,
resourceType: resourceType as string | undefined,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
},
format
);
if (format === 'csv') { if (format === 'csv') {
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`; const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
@@ -126,6 +188,10 @@ export async function exportAuditLogs(req: AuthRequest, res: Response) {
res.json({ success: true, data: JSON.parse(content) }) res.json({ success: true, data: JSON.parse(content) })
} }
} catch (error) { } catch (error) {
if (error instanceof FilterFehler) {
res.status(400).json({ success: false, error: error.message });
return;
}
console.error('Fehler beim Exportieren der Audit-Logs:', error); console.error('Fehler beim Exportieren der Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' }); res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
} }
@@ -149,16 +215,38 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
const tampered = result.tamperedEntries.length; const tampered = result.tamperedEntries.length;
const gaps = result.chainGaps.length; const gaps = result.chainGaps.length;
const unexplained = result.unexplainedGaps.length; const unexplained = result.unexplainedGaps.length;
// Beglaubigte Alt-Luecken sind kein offener Befund mehr, verschwinden aber
// auch nicht aus dem Bericht - sie werden eigens benannt.
const beglaubigt = result.attestedGaps.length;
const offeneGaps = gaps - beglaubigt;
const offeneUnexplained = result.unexplainedGaps.filter(
(id) => !result.attestedGaps.includes(id),
).length;
const luecken = gaps > 0 const luecken = offeneGaps > 0
? `${gaps} strukturelle Lücken` + ? `${offeneGaps} strukturelle Lücke${offeneGaps === 1 ? '' : 'n'}` +
(unexplained === 0 (offeneUnexplained === 0
? ' (alle durch protokollierte Löschungen erklärt)' ? ' (alle durch protokollierte Löschungen erklärt)'
: unexplained < gaps : offeneUnexplained < offeneGaps
? `, davon ${unexplained} ohne dokumentierte Löschung` ? `, davon ${offeneUnexplained} ohne dokumentierte Löschung`
: ' ohne dokumentierte Löschung') : ' ohne dokumentierte Löschung')
: ''; : '';
// Der Satz erscheint IMMER, wenn beglaubigte Luecken existieren - auch
// neben einem Befund. Wer den Bericht liest, soll nie den Eindruck
// bekommen, die Kette sei lueckenlos, wenn sie es nicht ist.
const weitere = tampered > 0 || offeneGaps > 0 ? 'weitere ' : '';
const beglaubigtText =
beglaubigt === 0
? ''
: beglaubigt === 1
? ` Eine ${weitere}Lücke stammt aus der Zeit vor dem Bestandssiegel und ist darin als ` +
`Vorbefund beglaubigt (ID ${result.attestedGaps[0]}); der betroffene Eintrag selbst ` +
'ist unverändert.'
: ` ${beglaubigt} ${weitere}Lücken stammen aus der Zeit vor dem Bestandssiegel und sind ` +
`darin als Vorbefund beglaubigt (IDs ${result.attestedGaps.join(', ')}); ` +
'die betroffenen Einträge selbst sind unverändert.';
const unverifiable = result.unverifiableEntries.length; const unverifiable = result.unverifiableEntries.length;
const keinSchluessel = unverifiable > 0 const keinSchluessel = unverifiable > 0
? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.` ? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.`
@@ -180,9 +268,59 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' + ? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht Änderungen am ' + 'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht Änderungen am ' +
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.' 'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
: result.backlogSealStatus === 'kein_siegel' : result.backlogSealStatus === 'leer'
? ' Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären nicht erkennbar.' ? ' Hinweis: Es besteht ein Bestandssiegel, das aber NICHTS umschließt ' +
: ''; 'zum Zeitpunkt des Siegelns gab es keine unsignierten Alteinträge. Es sichert ' +
'also nichts ab. Das ist kein Fehler, aber auch keine Zusage.'
: result.backlogSealStatus === 'nicht_noetig'
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
: result.backlogSealStatus === 'kein_siegel'
? ' Hinweis: Der Altbestand ist nicht versiegelt Änderungen daran wären nicht erkennbar. ' +
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
: '';
// Eine Neuberechnung der Kette gehoert IMMER erwaehnt auch und gerade,
// wenn sonst alles grün ist. Nach einem Rehash ist die Kette
// zwangslaeufig stimmig, auch ueber Loeschungen hinweg. „Lueckenlos
// verkettet“ heisst dann nur noch „seit dem Rehash“, und wer das nicht
// mitliest, nimmt eine Entwarnung mit, die es so nicht gibt.
const rehashText = (() => {
if (result.rehashes.length === 0) return '';
const letzter = result.rehashes[result.rehashes.length - 1];
const datum = new Date(letzter.zeitpunkt).toLocaleString('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
});
const wieOft =
result.rehashes.length === 1
? 'Die Kette wurde einmal neu berechnet'
: `Die Kette wurde ${result.rehashes.length}× neu berechnet, zuletzt`;
// Nur benennen, was es zu benennen gibt. Bei sauberem Vorzustand ist die
// Auskunft „nichts uebertuencht“ selbst eine nuetzliche Information
// ein Rehash ueber einer unbeanstandeten Kette wiegt anders als einer
// ueber 700 Luecken.
const vb = letzter.vorbefund;
const uebertuencht = !vb
? ' Der Zustand vor dieser Neuberechnung ist nicht mehr feststellbar.'
: vb.manipuliert === 0 && vb.luecken === 0
? ' Die Kette war unmittelbar davor unbeanstandet es wurde nichts überdeckt.'
: ' Unmittelbar davor: ' +
`${vb.manipuliert} beanstandete${vb.manipuliert === 1 ? 'r Eintrag' : ' Einträge'}` +
` und ${vb.luecken} Lücke${vb.luecken === 1 ? '' : 'n'}. ` +
'Diese Spuren sind seitdem nicht mehr in der Kette sichtbar, sondern nur noch im ' +
`Vorbefund des Rehash-Eintrags (id ${letzter.id}).`;
const ohneSignatur = result.rehashes.some((r) => !r.signiert)
? ' Achtung: Mindestens ein Rehash-Eintrag trägt keine gültige Signatur.'
: '';
return (
`${wieOft} am ${datum}` +
(letzter.neuBerechnet !== null ? ` (${letzter.neuBerechnet} Einträge)` : '') +
'. Ein Rehash verknüpft alle Einträge neu die Aussage dieser Prüfung ' +
'reicht deshalb nur bis dorthin zurück, nicht weiter.' +
uebertuencht +
ohneSignatur
);
})();
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es // Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03). // ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
@@ -197,13 +335,27 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
const siegelProblem = const siegelProblem =
result.backlogSealStatus === 'gebrochen' || result.backlogSealStatus === 'entfernt'; result.backlogSealStatus === 'gebrochen' || result.backlogSealStatus === 'entfernt';
// „Keine Manipulation“ darf NICHT dastehen, solange `valid` falsch ist
// (Pentest R183-02). Nach einem Cleanup mit abgesenkter Aufbewahrung waren
// tausende Anmeldeprotokolle endgueltig geloescht die Luecken durch
// Tombstones „erklaert“, die Meldung las sich beruhigend, und der Befund
// stand nur noch im Feld `valid`. Wer die Prosa liest statt des Felds,
// klickt genau das weg, was ihn haette warnen sollen.
const message = tampered > 0 const message = tampered > 0
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '') ? `${tampered} MANIPULIERTE Einträge gefunden` + (offeneGaps > 0 ? ` (zusätzlich ${luecken})` : '')
: siegelProblem : siegelProblem
? 'Die Kette selbst ist rechnerisch stimmig, ABER:' ? 'Die Kette selbst ist rechnerisch stimmig, ABER:'
: gaps > 0 : offeneGaps > 0
? `Keine Manipulation. ${luecken} Inhalte unverändert.` ? `Die Kette ist nicht mehr lückenlos: ${luecken}. Die verbliebenen Inhalte sind ` +
: 'Alle Einträge sind unverändert und lückenlos verkettet'; 'unverändert aber gelöschte Einträge lassen sich naturgemäß nicht mehr prüfen. ' +
'Dokumentierte Löschungen sind erwartbar; unerwartete gehören nachgegangen.'
: beglaubigt > 0
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
'entstanden.'
: result.rehashes.length > 0
? 'Alle Einträge sind unverändert und lückenlos verkettet allerdings erst ' +
'seit der letzten Neuberechnung.'
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
res.json({ res.json({
success: true, success: true,
@@ -217,8 +369,12 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
chainGaps: result.chainGaps, chainGaps: result.chainGaps,
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig. // Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
unexplainedGaps: result.unexplainedGaps, unexplainedGaps: result.unexplainedGaps,
// Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt.
attestedGaps: result.attestedGaps,
// Protokollierte Neuberechnungen begrenzen die Reichweite der Aussage.
rehashes: result.rehashes,
tampered: tampered > 0, tampered: tampered > 0,
message: message + keinSchluessel + siegel + mehrfach, message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach,
unverifiableEntries: result.unverifiableEntries, unverifiableEntries: result.unverifiableEntries,
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand. // Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
backlogSealStatus: result.backlogSealStatus, backlogSealStatus: result.backlogSealStatus,
@@ -300,32 +456,94 @@ export async function getCheckpoint(req: AuthRequest, res: Response) {
*/ */
export async function sealBacklog(req: AuthRequest, res: Response) { export async function sealBacklog(req: AuthRequest, res: Response) {
try { try {
if (req.body?.confirm !== 'SEAL') { // Erst nachsehen, ob schon ein Siegel steht (Pentest R185-01).
//
// Ein zweites Siegeln ist etwas grundlegend anderes als das erste: Es
// ERSETZT die Grundlage, gegen die Manipulation nachgewiesen wird. Wer den
// Altbestand per Datenbankzugriff beschneidet und danach neu siegelt,
// bekommt eine passende Wurzel und eine ueber den neuen Vorbefund
// beglaubigte Luecke - und `valid` steht wieder auf `true`. Deshalb
// verlangt das Ersetzen ein eigenes Wort und nicht dasselbe wie das
// Einrichten.
const vorher = await auditService.verifyIntegrity();
const siegelSteht =
vorher.backlogSealStatus === 'intakt' ||
vorher.backlogSealStatus === 'gebrochen' ||
vorher.backlogSealStatus === 'entfernt';
const erwartet = siegelSteht ? 'RESEAL' : 'SEAL';
if (req.body?.confirm !== erwartet) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: error: siegelSteht
'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' + ? 'Es besteht bereits ein Bestandssiegel. Erneutes Siegeln ERSETZT die ' +
'{"confirm":"SEAL"} mitsenden.', 'bisherige Beweisgrundlage: bestehende Lücken werden neu beglaubigt und ' +
'ein Befund am Altbestand verschwindet aus der Prüfung. Das ist kein ' +
'Wartungsschritt. Zum Bestätigen {"confirm":"RESEAL"} mitsenden und ' +
'vorher klären, warum das bisherige Siegel nicht mehr passt.'
: 'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' +
'{"confirm":"SEAL"} mitsenden.',
aktuellerSiegelzustand: vorher.backlogSealStatus,
}); });
return; return;
} }
const result = await auditService.sealBacklog({ const result = await auditService.sealBacklog({
userEmail: req.user?.email, userEmail: req.user?.email,
ipAddress: req.ip || (req.socket as any)?.remoteAddress, ipAddress: req.ip || (req.socket as any)?.remoteAddress,
}); });
// In den ALARMKANAL, nicht nur ins Audit-Log (Pentest R185-01).
//
// Die CRITICAL-Zeile im Audit-Log gab es schon - aber die muss jemand
// lesen, und genau das ist die R183-02/R184-01-Klasse. Der automatische
// Rueckhalt des Gegenbuchs haengt an `valid`, und `valid` ueberlebt ein
// ersetzendes Siegel per Konstruktion. Der einzige maschinell erkennbare
// Anker ist der Wechsel der Wurzel - der gehoert dorthin, wo etwas von
// selbst passiert.
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'AUDIT_SEAL_CHANGED',
severity: siegelSteht ? 'CRITICAL' : 'HIGH',
message: siegelSteht
? `Bestandssiegel ERSETZT (${result.sealedCount} Alteinträge, id ${result.fromId}` +
`${result.toId}). Die bisherige Beweisgrundlage gilt nicht mehr; der Zustand ` +
`davor war "${vorher.backlogSealStatus}". Wenn das keine geplante Maßnahme war, ` +
'ist es ein Befund.'
: `Bestandssiegel erstmals gesetzt (${result.sealedCount} Alteinträge, id ` +
`${result.fromId}${result.toId}). Ab jetzt fallen Änderungen am Altbestand auf.`,
ipAddress: ctx.ipAddress,
userId: req.user?.userId,
userEmail: req.user?.email,
endpoint: ctx.endpoint,
details: {
ersetzt: siegelSteht,
zustandVorher: vorher.backlogSealStatus,
wurzelVorher: vorher.backlogSealRoot,
wurzelNachher: result.root,
befundVorher: {
manipuliert: vorher.tamperedEntries,
luecken: vorher.chainGaps,
altbestandVeraendert: vorher.backlogTampered,
altbestandFehlend: vorher.backlogMissing,
},
},
});
res.json({ res.json({
success: true, success: true,
data: result, data: result,
message: message:
`${result.sealedCount} Alteinträge (id ${result.fromId}${result.toId}) versiegelt. ` + `${result.sealedCount} Alteinträge (id ${result.fromId}${result.toId}) versiegelt. ` +
'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.', 'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.' +
(siegelSteht
? ' ACHTUNG: Dies war ein ERSETZENDES Siegel die vorherige Beweisgrundlage ' +
'gilt nicht mehr.'
: ''),
}); });
} catch (error) { } catch (error) {
console.error('Fehler beim Versiegeln des Altbestands:', error); console.error('Fehler beim Versiegeln des Altbestands:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Versiegeln');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versiegeln',
});
} }
} }
@@ -353,6 +571,48 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
} }
const { retentionDays, description, legalBasis, isActive } = req.body; const { retentionDays, description, legalBasis, isActive } = req.body;
// Die Richtlinie bestimmt, WAS der Cleanup vernichtet sie ist die
// geladene Waffe, der Cleanup nur der Abzug (Pentest R183-01). Bisher war
// ausgerechnet der Abzug gegatet und die Waffe frei zugaenglich: Ein
// `retentionDays: 0` ging ohne Bestaetigung durch und wurde nur als MEDIUM
// protokolliert, waehrend seine Wirkung CRITICAL ist. Die ausloesende Tat
// war damit leiser als die Folge und lag unter der Schwelle, bei der
// jemand hinsieht.
const bisher = (await auditService.getRetentionPolicies()).find((p) => p.id === id);
if (!bisher) {
return res.status(404).json({ success: false, error: 'Aufbewahrungsrichtlinie nicht gefunden' });
}
const neueTage = typeof retentionDays === 'number' ? retentionDays : bisher.retentionDays;
// Untergrenze: Auth-Eintraege sind der Einbruchsbeleg schlechthin. Eine
// Aufbewahrung von faktisch null macht sie mit dem naechsten Cleanup
// spurlos entfernbar ueber einen sanktionierten Pfad.
const UNTERGRENZE: Record<string, number> = { Authentication: 30, AuditLog: 30 };
const grenze = UNTERGRENZE[bisher.resourceType];
if (grenze !== undefined && neueTage < grenze) {
return res.status(400).json({
success: false,
error:
`Für ${bisher.resourceType} sind mindestens ${grenze} Tage Aufbewahrung vorgeschrieben ` +
`(angefragt: ${neueTage}). Diese Einträge belegen Anmeldungen und Zugriffe eine ` +
`Aufbewahrung nahe null macht sie beim nächsten Cleanup spurlos entfernbar.`,
});
}
// Absenken ist die gefaehrliche Richtung und braucht dieselbe ausdrueckliche
// Bestaetigung wie der Cleanup selbst.
const senktAb = neueTage < bisher.retentionDays;
if (senktAb && req.body?.confirm !== 'SHORTEN') {
return res.status(400).json({
success: false,
error:
`Das verkürzt die Aufbewahrung von ${bisher.retentionDays} auf ${neueTage} Tage. ` +
'Beim nächsten Cleanup werden dadurch Einträge endgültig gelöscht, die heute noch ' +
'da sind. Zum Bestätigen {"confirm":"SHORTEN"} mitsenden.',
});
}
const policy = await auditService.updateRetentionPolicy(id, { const policy = await auditService.updateRetentionPolicy(id, {
retentionDays, retentionDays,
description, description,
@@ -363,7 +623,15 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
await logChange({ await logChange({
req, action: 'UPDATE', resourceType: 'RetentionPolicy', req, action: 'UPDATE', resourceType: 'RetentionPolicy',
resourceId: id.toString(), resourceId: id.toString(),
label: `Aufbewahrungsrichtlinie aktualisiert`, // Absenkung wird wie ihre Folge eingestuft: CRITICAL, nicht MEDIUM.
sensitivity: senktAb ? 'CRITICAL' : undefined,
label: senktAb
? `Aufbewahrung VERKÜRZT für ${bisher.resourceType}` +
(bisher.sensitivity ? `/${bisher.sensitivity}` : '') +
`: ${bisher.retentionDays}${neueTage} Tage`
: `Aufbewahrungsrichtlinie aktualisiert`,
before: { retentionDays: bisher.retentionDays },
details: { retentionDays: neueTage },
}); });
res.json({ success: true, data: policy }); res.json({ success: true, data: policy });
+9 -12
View File
@@ -1,4 +1,5 @@
import { Request, Response, CookieOptions } from 'express'; import { Request, Response, CookieOptions } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as authService from '../services/auth.service.js'; import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js'; import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
@@ -281,10 +282,7 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
ipAddress: ctx.ipAddress, ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint, endpoint: ctx.endpoint,
}); });
res.status(400).json({ antworteAufFehler(res, error, 'Passwort-Reset fehlgeschlagen');
success: false,
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
} as ApiResponse);
} }
} }
@@ -400,10 +398,12 @@ export async function refresh(req: Request, res: Response): Promise<void> {
endpoint: ctx.endpoint, endpoint: ctx.endpoint,
}); });
} }
res.status(401).json({ // `msg` geht weiterhin in den Alarmkanal (oben), aber nicht mehr an den
success: false, // Aufrufer: Bei einem abgelehnten Refresh stammt der Wortlaut aus der
error: msg, // JWT-Bibliothek ("jwt malformed", "invalid signature") und sagt einem
} as ApiResponse); // Angreifer, WORAN sein Token gescheitert ist. Fuer den berechtigten
// Nutzer aendert das nichts - er muss sich so oder so neu anmelden.
antworteAufFehler(res, error, 'Refresh fehlgeschlagen', 401);
} }
} }
@@ -525,9 +525,6 @@ export async function changeInitialPortalPassword(req: AuthRequest, res: Respons
clearRefreshCookie(res); clearRefreshCookie(res);
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse); res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Passwort konnte nicht geändert werden', 500);
success: false,
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
} as ApiResponse);
} }
} }
+6 -1
View File
@@ -374,7 +374,12 @@ export async function factoryReset(req: Request, res: Response) {
label: `Werkseinstellungen wiederhergestellt`, label: `Werkseinstellungen wiederhergestellt`,
}); });
res.json({ res.json({
message: 'Werkseinstellungen wiederhergestellt. Bitte melden Sie sich mit admin@admin.com / admin an.', // Das Kennwort ist nicht mehr "admin", sondern zufaellig und steht
// genau einmal im Server-Log. Die alte Meldung hier nannte es noch
// im Klartext - sie haette nach der Haertung ins Leere gefuehrt.
message:
'Werkseinstellungen wiederhergestellt. Das neue Kennwort für admin@admin.com ' +
'steht einmalig im Server-Log (docker compose logs backend).',
}); });
} else { } else {
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error }); res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { isValidIBAN } from 'ibantools'; import { isValidIBAN } from 'ibantools';
import * as blzData from '../services/blzData.service.js'; import * as blzData from '../services/blzData.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
@@ -47,9 +48,6 @@ export async function lookupIban(req: AuthRequest, res: Response): Promise<void>
}, },
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim IBAN-Nachschlagen', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim IBAN-Nachschlagen',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
import * as birthdayService from '../services/birthday.service.js'; import * as birthdayService from '../services/birthday.service.js';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js'; import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
@@ -89,10 +90,7 @@ export async function resetBirthdayGreeting(req: AuthRequest, res: Response) {
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error('Fehler beim Zurücksetzen:', error); console.error('Fehler beim Zurücksetzen:', error);
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Zurücksetzen', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
});
} }
} }
@@ -180,9 +178,6 @@ export async function sendBirthdayGreeting(req: AuthRequest, res: Response) {
}); });
} catch (error) { } catch (error) {
console.error('Fehler beim Senden des Geburtstagsgrußes:', error); console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Senden', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
} }
} }
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import * as blzData from '../services/blzData.service.js'; import * as blzData from '../services/blzData.service.js';
@@ -9,10 +10,7 @@ export async function getStatus(req: AuthRequest, res: Response): Promise<void>
const status = await blzData.getStatus(); const status = await blzData.getStatus();
res.json({ success: true, data: status } as ApiResponse); res.json({ success: true, data: status } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Laden des BLZ-Status', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Laden des BLZ-Status',
} as ApiResponse);
} }
} }
@@ -32,9 +30,6 @@ export async function updateNow(req: AuthRequest, res: Response): Promise<void>
const status = await blzData.getStatus(); const status = await blzData.getStatus();
res.json({ success: true, data: { result, status } } as ApiResponse); res.json({ success: true, data: { result, status } } as ApiResponse);
} catch (error) { } catch (error) {
res.status(502).json({ antworteAufFehler(res, error, 'BLZ-Aktualisierung fehlgeschlagen', 502);
success: false,
error: error instanceof Error ? error.message : 'BLZ-Aktualisierung fehlgeschlagen',
} as ApiResponse);
} }
} }
@@ -1,6 +1,7 @@
// ==================== CACHED EMAIL CONTROLLER ==================== // ==================== CACHED EMAIL CONTROLLER ====================
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as cachedEmailService from '../services/cachedEmail.service.js'; import * as cachedEmailService from '../services/cachedEmail.service.js';
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js'; import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
import * as invoiceService from '../services/invoice.service.js'; import * as invoiceService from '../services/invoice.service.js';
@@ -272,10 +273,7 @@ export async function assignToContract(req: AuthRequest, res: Response): Promise
res.json({ success: true, data: email } as ApiResponse); res.json({ success: true, data: email } as ApiResponse);
} catch (error) { } catch (error) {
console.error('assignToContract error:', error); console.error('assignToContract error:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Zuordnen der E-Mail');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zuordnen der E-Mail',
} as ApiResponse);
} }
} }
@@ -290,10 +288,7 @@ export async function unassignFromContract(req: AuthRequest, res: Response): Pro
res.json({ success: true, data: email } as ApiResponse); res.json({ success: true, data: email } as ApiResponse);
} catch (error) { } catch (error) {
console.error('unassignFromContract error:', error); console.error('unassignFromContract error:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aufheben der Zuordnung');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aufheben der Zuordnung',
} as ApiResponse);
} }
} }
@@ -1660,12 +1655,7 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
} catch (error) { } catch (error) {
console.error('saveAttachmentTo error:', error); console.error('saveAttachmentTo error:', error);
// Detailliertere Fehlermeldung für Debugging // Detailliertere Fehlermeldung für Debugging
const status = error instanceof ApiError ? error.statusCode : 500; antworteAufFehler(res, error, 'Fehler beim Speichern des Anhangs', 500);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).json({
success: false,
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
} as ApiResponse);
} }
} }
@@ -1909,11 +1899,7 @@ export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<v
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
console.error('saveEmailAsPdf error:', error); console.error('saveEmailAsPdf error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; antworteAufFehler(res, error, 'Fehler beim Erstellen der PDF', 500);
res.status(500).json({
success: false,
error: `Fehler beim Erstellen der PDF: ${errorMessage}`,
} as ApiResponse);
} }
} }
@@ -2036,11 +2022,7 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
console.error('saveEmailAsInvoice error:', error); console.error('saveEmailAsInvoice error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 500);
res.status(500).json({
success: false,
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
} as ApiResponse);
} }
} }
@@ -2145,9 +2127,7 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
console.error('saveEmailAsContractDocument error:', error); console.error('saveEmailAsContractDocument error:', error);
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race- // Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
// Lock) statt pauschal 500. // Lock) statt pauschal 500.
const status = error instanceof ApiError ? error.statusCode : 500; antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
} }
} }
@@ -2332,12 +2312,7 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
console.error('saveAttachmentAsInvoice error:', error); console.error('saveAttachmentAsInvoice error:', error);
const status = error instanceof ApiError ? error.statusCode : 500; antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 500);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).json({
success: false,
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
} as ApiResponse);
} }
} }
@@ -2512,11 +2487,6 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
} catch (error) { } catch (error) {
console.error('saveAttachmentAsContractDocument error:', error); console.error('saveAttachmentAsContractDocument error:', error);
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500. // Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
const status = error instanceof ApiError ? error.statusCode : 500; antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).json({
success: false,
error: `Fehler beim Speichern: ${errorMessage}`,
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as cancellationPeriodService from '../services/cancellation-period.service.js'; import * as cancellationPeriodService from '../services/cancellation-period.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js'; import { ApiResponse } from '../types/index.js';
@@ -46,10 +47,7 @@ export async function createCancellationPeriod(req: Request, res: Response): Pro
}); });
res.status(201).json({ success: true, data: period } as ApiResponse); res.status(201).json({ success: true, data: period } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Kündigungsfrist');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Kündigungsfrist',
} as ApiResponse);
} }
} }
@@ -64,10 +62,7 @@ export async function updateCancellationPeriod(req: Request, res: Response): Pro
}); });
res.json({ success: true, data: period } as ApiResponse); res.json({ success: true, data: period } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Kündigungsfrist');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Kündigungsfrist',
} as ApiResponse);
} }
} }
@@ -83,9 +78,6 @@ export async function deleteCancellationPeriod(req: Request, res: Response): Pro
}); });
res.json({ success: true, message: 'Kündigungsfrist gelöscht' } as ApiResponse); res.json({ success: true, message: 'Kündigungsfrist gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Kündigungsfrist');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Kündigungsfrist',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as contractDurationService from '../services/contract-duration.service.js'; import * as contractDurationService from '../services/contract-duration.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js'; import { ApiResponse } from '../types/index.js';
@@ -46,10 +47,7 @@ export async function createContractDuration(req: Request, res: Response): Promi
}); });
res.status(201).json({ success: true, data: duration } as ApiResponse); res.status(201).json({ success: true, data: duration } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Laufzeit');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Laufzeit',
} as ApiResponse);
} }
} }
@@ -64,10 +62,7 @@ export async function updateContractDuration(req: Request, res: Response): Promi
}); });
res.json({ success: true, data: duration } as ApiResponse); res.json({ success: true, data: duration } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Laufzeit');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Laufzeit',
} as ApiResponse);
} }
} }
@@ -83,9 +78,6 @@ export async function deleteContractDuration(req: Request, res: Response): Promi
}); });
res.json({ success: true, message: 'Laufzeit gelöscht' } as ApiResponse); res.json({ success: true, message: 'Laufzeit gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Laufzeit');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Laufzeit',
} as ApiResponse);
} }
} }
+10 -37
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import fs from 'fs'; import fs from 'fs';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import * as contractService from '../services/contract.service.js'; import * as contractService from '../services/contract.service.js';
@@ -211,10 +212,7 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any); const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse); res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen des Vertrags');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Vertrags',
} as ApiResponse);
} }
} }
@@ -374,10 +372,7 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
: sanitizeContract(contract as any); : sanitizeContract(contract as any);
res.json({ success: true, data: sanitized } as ApiResponse); res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Vertrags');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Vertrags',
} as ApiResponse);
} }
} }
@@ -396,10 +391,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
}); });
res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse); res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Vertrags');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags',
} as ApiResponse);
} }
} }
@@ -447,10 +439,7 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any); const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse); res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen des Folgevertrags');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Folgevertrags',
} as ApiResponse);
} }
} }
@@ -502,10 +491,7 @@ export async function createRenewal(req: AuthRequest, res: Response): Promise<vo
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any); const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse); res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der VVL');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
} as ApiResponse);
} }
} }
@@ -793,10 +779,7 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
res.json({ success: true, data: contractMeter } as ApiResponse); res.json({ success: true, data: contractMeter } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Folgezählers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
} as ApiResponse);
} }
} }
@@ -813,10 +796,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
}); });
res.json({ success: true, data: null } as ApiResponse); res.json({ success: true, data: null } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Entfernen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
} as ApiResponse);
} }
} }
@@ -905,13 +885,9 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400 // Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument- // vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
// Schreibfehler. // Schreibfehler.
const status = error instanceof ApiError ? error.statusCode : 400;
// Multer hat die Datei schon geschrieben bei Reject räumen. // Multer hat die Datei schon geschrieben bei Reject räumen.
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ } if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(status).json({ antworteAufFehler(res, error, 'Fehler beim Hochladen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
} as ApiResponse);
} }
} }
@@ -948,10 +924,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse); res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as contractCategoryService from '../services/contractCategory.service.js'; import * as contractCategoryService from '../services/contractCategory.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js'; import { ApiResponse } from '../types/index.js';
@@ -46,10 +47,7 @@ export async function createContractCategory(req: Request, res: Response): Promi
}); });
res.status(201).json({ success: true, data: category } as ApiResponse); res.status(201).json({ success: true, data: category } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertragskategorie');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertragskategorie',
} as ApiResponse);
} }
} }
@@ -64,10 +62,7 @@ export async function updateContractCategory(req: Request, res: Response): Promi
}); });
res.json({ success: true, data: category } as ApiResponse); res.json({ success: true, data: category } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertragskategorie');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertragskategorie',
} as ApiResponse);
} }
} }
@@ -83,9 +78,6 @@ export async function deleteContractCategory(req: Request, res: Response): Promi
}); });
res.json({ success: true, message: 'Vertragskategorie gelöscht' } as ApiResponse); res.json({ success: true, message: 'Vertragskategorie gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Vertragskategorie');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertragskategorie',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as contractHistoryService from '../services/contractHistory.service.js'; import * as contractHistoryService from '../services/contractHistory.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
@@ -47,10 +48,7 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
res.status(201).json({ success: true, data: entry } as ApiResponse); res.status(201).json({ success: true, data: entry } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen des Eintrags');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Eintrags',
} as ApiResponse);
} }
} }
@@ -74,10 +72,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
res.json({ success: true, data: entry } as ApiResponse); res.json({ success: true, data: entry } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Eintrags');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Eintrags',
} as ApiResponse);
} }
} }
@@ -97,9 +92,6 @@ export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promi
res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse); res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Eintrags');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Eintrags',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as contractTaskService from '../services/contractTask.service.js'; import * as contractTaskService from '../services/contractTask.service.js';
import * as contractService from '../services/contract.service.js'; import * as contractService from '../services/contract.service.js';
import * as customerService from '../services/customer.service.js'; import * as customerService from '../services/customer.service.js';
@@ -146,10 +147,7 @@ export async function createGeneralTask(req: AuthRequest, res: Response): Promis
}); });
res.status(201).json({ success: true, data: task } as ApiResponse); res.status(201).json({ success: true, data: task } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
} as ApiResponse);
} }
} }
@@ -187,10 +185,7 @@ export async function createTask(req: AuthRequest, res: Response): Promise<void>
res.status(201).json({ success: true, data: task } as ApiResponse); res.status(201).json({ success: true, data: task } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
} as ApiResponse);
} }
} }
@@ -239,10 +234,7 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
res.status(201).json({ success: true, data: task } as ApiResponse); res.status(201).json({ success: true, data: task } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Support-Anfrage');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Support-Anfrage',
} as ApiResponse);
} }
} }
@@ -265,10 +257,7 @@ export async function updateTask(req: AuthRequest, res: Response): Promise<void>
res.json({ success: true, data: task } as ApiResponse); res.json({ success: true, data: task } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Aufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Aufgabe',
} as ApiResponse);
} }
} }
@@ -283,10 +272,7 @@ export async function completeTask(req: AuthRequest, res: Response): Promise<voi
}); });
res.json({ success: true, data: task } as ApiResponse); res.json({ success: true, data: task } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Abschließen der Aufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Aufgabe',
} as ApiResponse);
} }
} }
@@ -301,10 +287,7 @@ export async function reopenTask(req: AuthRequest, res: Response): Promise<void>
}); });
res.json({ success: true, data: task } as ApiResponse); res.json({ success: true, data: task } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Aufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Aufgabe',
} as ApiResponse);
} }
} }
@@ -319,10 +302,7 @@ export async function deleteTask(req: AuthRequest, res: Response): Promise<void>
}); });
res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse); res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Aufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Aufgabe',
} as ApiResponse);
} }
} }
@@ -357,10 +337,7 @@ export async function createSubtask(req: AuthRequest, res: Response): Promise<vo
res.status(201).json({ success: true, data: subtask } as ApiResponse); res.status(201).json({ success: true, data: subtask } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Unteraufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Unteraufgabe',
} as ApiResponse);
} }
} }
@@ -439,10 +416,7 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
res.status(201).json({ success: true, data: subtask } as ApiResponse); res.status(201).json({ success: true, data: subtask } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Antwort');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Antwort',
} as ApiResponse);
} }
} }
@@ -467,10 +441,7 @@ export async function updateSubtask(req: AuthRequest, res: Response): Promise<vo
}); });
res.json({ success: true, data: subtask } as ApiResponse); res.json({ success: true, data: subtask } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Unteraufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Unteraufgabe',
} as ApiResponse);
} }
} }
@@ -485,10 +456,7 @@ export async function completeSubtask(req: AuthRequest, res: Response): Promise<
}); });
res.json({ success: true, data: subtask } as ApiResponse); res.json({ success: true, data: subtask } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Abschließen der Unteraufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Unteraufgabe',
} as ApiResponse);
} }
} }
@@ -503,10 +471,7 @@ export async function reopenSubtask(req: AuthRequest, res: Response): Promise<vo
}); });
res.json({ success: true, data: subtask } as ApiResponse); res.json({ success: true, data: subtask } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Unteraufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Unteraufgabe',
} as ApiResponse);
} }
} }
@@ -521,9 +486,6 @@ export async function deleteSubtask(req: AuthRequest, res: Response): Promise<vo
}); });
res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse); res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Unteraufgabe');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Unteraufgabe',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiError } from '../utils/apiError.js'; import { ApiError } from '../utils/apiError.js';
@@ -36,11 +37,9 @@ function idParam(req: AuthRequest, res: Response, name: string): number | null {
} }
function handleError(res: Response, error: unknown, fallback: string) { function handleError(res: Response, error: unknown, fallback: string) {
const status = error instanceof ApiError ? error.statusCode : 500; // Eigene Huelle beibehalten, damit die Aufrufer unveraendert bleiben - die
res.status(status).json({ // Entscheidung, was nach draussen geht, faellt jetzt zentral.
success: false, antworteAufFehler(res, error, fallback, 500);
error: error instanceof Error ? error.message : fallback,
} as ApiResponse);
} }
// ---- Gesamtübersicht (Hauptmenü) ---- // ---- Gesamtübersicht (Hauptmenü) ----
+30 -119
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import * as customerService from '../services/customer.service.js'; import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js'; import * as authService from '../services/auth.service.js';
@@ -155,10 +156,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
: sanitizeCustomerStrict(customer as any); : sanitizeCustomerStrict(customer as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse); res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen des Kunden');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Kunden',
} as ApiResponse);
} }
} }
@@ -276,10 +274,7 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
res.json({ success: true, data: sanitized } as ApiResponse); res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) { } catch (error) {
console.error('Update customer error:', error); console.error('Update customer error:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Kunden');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Kunden',
} as ApiResponse);
} }
} }
@@ -296,10 +291,7 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
}); });
res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse); res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Kunden');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Kunden',
} as ApiResponse);
} }
} }
@@ -328,10 +320,7 @@ export async function createAddress(req: AuthRequest, res: Response): Promise<vo
}); });
res.status(201).json({ success: true, data: address } as ApiResponse); res.status(201).json({ success: true, data: address } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Adresse');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Adresse',
} as ApiResponse);
} }
} }
@@ -392,10 +381,7 @@ export async function updateAddress(req: AuthRequest, res: Response): Promise<vo
res.json({ success: true, data: address } as ApiResponse); res.json({ success: true, data: address } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Adresse');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Adresse',
} as ApiResponse);
} }
} }
@@ -414,10 +400,7 @@ export async function deleteAddress(req: AuthRequest, res: Response): Promise<vo
}); });
res.json({ success: true, message: 'Adresse gelöscht' } as ApiResponse); res.json({ success: true, message: 'Adresse gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Adresse');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Adresse',
} as ApiResponse);
} }
} }
@@ -448,10 +431,7 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
}); });
res.status(201).json({ success: true, data: card } as ApiResponse); res.status(201).json({ success: true, data: card } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Bankkarte');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Bankkarte',
} as ApiResponse);
} }
} }
@@ -510,10 +490,7 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
res.json({ success: true, data: card } as ApiResponse); res.json({ success: true, data: card } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Bankkarte');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Bankkarte',
} as ApiResponse);
} }
} }
@@ -532,10 +509,7 @@ export async function deleteBankCard(req: AuthRequest, res: Response): Promise<v
}); });
res.json({ success: true, message: 'Bankkarte gelöscht' } as ApiResponse); res.json({ success: true, message: 'Bankkarte gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Bankkarte');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Bankkarte',
} as ApiResponse);
} }
} }
@@ -565,10 +539,7 @@ export async function createDocument(req: AuthRequest, res: Response): Promise<v
}); });
res.status(201).json({ success: true, data: doc } as ApiResponse); res.status(201).json({ success: true, data: doc } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen des Ausweises');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Ausweises',
} as ApiResponse);
} }
} }
@@ -630,10 +601,7 @@ export async function updateDocument(req: AuthRequest, res: Response): Promise<v
res.json({ success: true, data: doc } as ApiResponse); res.json({ success: true, data: doc } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Ausweises');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Ausweises',
} as ApiResponse);
} }
} }
@@ -652,10 +620,7 @@ export async function deleteDocument(req: AuthRequest, res: Response): Promise<v
}); });
res.json({ success: true, message: 'Ausweis gelöscht' } as ApiResponse); res.json({ success: true, message: 'Ausweis gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Ausweises');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Ausweises',
} as ApiResponse);
} }
} }
@@ -688,10 +653,7 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
}); });
res.status(201).json({ success: true, data: meter } as ApiResponse); res.status(201).json({ success: true, data: meter } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen des Zählers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Zählers',
} as ApiResponse);
} }
} }
@@ -746,10 +708,7 @@ export async function updateMeter(req: AuthRequest, res: Response): Promise<void
res.json({ success: true, data: meter } as ApiResponse); res.json({ success: true, data: meter } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählers',
} as ApiResponse);
} }
} }
@@ -765,10 +724,7 @@ export async function deleteMeter(req: AuthRequest, res: Response): Promise<void
}); });
res.json({ success: true, message: 'Zähler gelöscht' } as ApiResponse); res.json({ success: true, message: 'Zähler gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Zählers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählers',
} as ApiResponse);
} }
} }
@@ -814,10 +770,7 @@ export async function addMeterReading(req: AuthRequest, res: Response): Promise<
res.status(201).json({ success: true, data: reading } as ApiResponse); res.status(201).json({ success: true, data: reading } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Zählerstands');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Zählerstands',
} as ApiResponse);
} }
} }
@@ -845,10 +798,7 @@ export async function updateMeterReading(req: AuthRequest, res: Response): Promi
}); });
res.json({ success: true, data: reading } as ApiResponse); res.json({ success: true, data: reading } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählerstands');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählerstands',
} as ApiResponse);
} }
} }
@@ -865,10 +815,7 @@ export async function deleteMeterReading(req: AuthRequest, res: Response): Promi
}); });
res.json({ success: true, data: null } as ApiResponse); res.json({ success: true, data: null } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Zählerstands');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählerstands',
} as ApiResponse);
} }
} }
@@ -923,10 +870,7 @@ export async function reportMeterReading(req: AuthRequest, res: Response): Promi
res.status(201).json({ success: true, data: reading } as ApiResponse); res.status(201).json({ success: true, data: reading } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Melden des Zählerstands');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Melden des Zählerstands',
} as ApiResponse);
} }
} }
@@ -978,10 +922,7 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
res.json({ success: true, data: reading } as ApiResponse); res.json({ success: true, data: reading } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
} as ApiResponse);
} }
} }
@@ -1091,10 +1032,7 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
res.json({ success: true, data: settings } as ApiResponse); res.json({ success: true, data: settings } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Portal-Einstellungen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Portal-Einstellungen',
} as ApiResponse);
} }
} }
@@ -1108,10 +1046,7 @@ export async function generatePortalPassword(req: Request, res: Response): Promi
const password = generateSecurePassword({ length: 16 }); const password = generateSecurePassword({ length: 16 });
res.json({ success: true, data: { password } } as ApiResponse); res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Generieren des Passworts', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
} as ApiResponse);
} }
} }
@@ -1199,10 +1134,7 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse); res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Versenden der Zugangsdaten', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
} as ApiResponse);
} }
} }
@@ -1228,10 +1160,7 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
}); });
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse); res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
} as ApiResponse);
} }
} }
@@ -1304,10 +1233,7 @@ export async function addRepresentative(req: AuthRequest, res: Response): Promis
}); });
res.status(201).json({ success: true, data: representative } as ApiResponse); res.status(201).json({ success: true, data: representative } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Vertreters');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Vertreters',
} as ApiResponse);
} }
} }
@@ -1326,10 +1252,7 @@ export async function removeRepresentative(req: AuthRequest, res: Response): Pro
}); });
res.json({ success: true, message: 'Vertreter entfernt' } as ApiResponse); res.json({ success: true, message: 'Vertreter entfernt' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Entfernen des Vertreters');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Entfernen des Vertreters',
} as ApiResponse);
} }
} }
@@ -1354,11 +1277,7 @@ export async function getSalutationPreference(req: AuthRequest, res: Response):
} catch (error) { } catch (error) {
// Pentest R104.1: ApiError-Statuscodes durchreichen (404 statt 500 // Pentest R104.1: ApiError-Statuscodes durchreichen (404 statt 500
// bei nicht-existierendem Kunden). // bei nicht-existierendem Kunden).
const status = error instanceof ApiError ? error.statusCode : 500; antworteAufFehler(res, error, 'Fehler beim Laden der Anrede-Präferenz', 500);
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Laden der Anrede-Präferenz',
} as ApiResponse);
} }
} }
@@ -1387,11 +1306,7 @@ export async function setSalutationPreference(req: AuthRequest, res: Response):
const result = await customerService.getSalutationPreference(userId, customerId); const result = await customerService.getSalutationPreference(userId, customerId);
res.json({ success: true, data: result } as ApiResponse); res.json({ success: true, data: result } as ApiResponse);
} catch (error) { } catch (error) {
const status = error instanceof ApiError ? error.statusCode : 500; antworteAufFehler(res, error, 'Fehler beim Speichern der Anrede-Präferenz', 500);
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Anrede-Präferenz',
} as ApiResponse);
} }
} }
@@ -1412,11 +1327,7 @@ export async function clearSalutationPreference(req: AuthRequest, res: Response)
const result = await customerService.getSalutationPreference(userId, customerId); const result = await customerService.getSalutationPreference(userId, customerId);
res.json({ success: true, data: result } as ApiResponse); res.json({ success: true, data: result } as ApiResponse);
} catch (error) { } catch (error) {
const status = error instanceof ApiError ? error.statusCode : 500; antworteAufFehler(res, error, 'Fehler beim Zurücksetzen der Anrede-Präferenz', 500);
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen der Anrede-Präferenz',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import * as referralService from '../services/customerReferral.service.js'; import * as referralService from '../services/customerReferral.service.js';
@@ -98,11 +99,7 @@ export async function createReferral(req: AuthRequest, res: Response): Promise<v
res.status(201).json({ success: true, data: created } as ApiResponse); res.status(201).json({ success: true, data: created } as ApiResponse);
} catch (error) { } catch (error) {
const status = (error as referralService.ReferralError)?.status ?? 500; antworteAufFehler(res, error, 'Fehler beim Anlegen der Werbe-Beziehung', 500);
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Anlegen der Werbe-Beziehung',
} as ApiResponse);
} }
} }
@@ -153,11 +150,7 @@ export async function updateReferral(req: AuthRequest, res: Response): Promise<v
res.json({ success: true, data: updated } as ApiResponse); res.json({ success: true, data: updated } as ApiResponse);
} catch (error) { } catch (error) {
const status = (error as referralService.ReferralError)?.status ?? 500; antworteAufFehler(res, error, 'Fehler beim Ändern der Werbe-Beziehung', 500);
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Ändern der Werbe-Beziehung',
} as ApiResponse);
} }
} }
@@ -1,6 +1,7 @@
// ==================== EMAIL PROVIDER CONTROLLER ==================== // ==================== EMAIL PROVIDER CONTROLLER ====================
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as emailProviderService from '../services/emailProvider/emailProviderService.js'; import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js'; import { ApiResponse } from '../types/index.js';
@@ -60,10 +61,7 @@ export async function createProviderConfig(req: Request, res: Response): Promise
}); });
res.status(201).json({ success: true, data: config } as ApiResponse); res.status(201).json({ success: true, data: config } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen des Email-Providers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Email-Providers',
} as ApiResponse);
} }
} }
@@ -80,10 +78,7 @@ export async function updateProviderConfig(req: Request, res: Response): Promise
}); });
res.json({ success: true, data: config } as ApiResponse); res.json({ success: true, data: config } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Email-Providers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Email-Providers',
} as ApiResponse);
} }
} }
@@ -99,10 +94,7 @@ export async function deleteProviderConfig(req: Request, res: Response): Promise
}); });
res.json({ success: true, message: 'Email-Provider gelöscht' } as ApiResponse); res.json({ success: true, message: 'Email-Provider gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Email-Providers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Email-Providers',
} as ApiResponse);
} }
} }
@@ -158,10 +150,7 @@ export async function testConnection(req: Request, res: Response): Promise<void>
const result = await emailProviderService.testProviderConnection({ id, testData }); const result = await emailProviderService.testProviderConnection({ id, testData });
res.json({ success: result.success, data: result } as ApiResponse); res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Verbindungstest fehlgeschlagen', 500);
success: false,
error: error instanceof Error ? error.message : 'Verbindungstest fehlgeschlagen',
} as ApiResponse);
} }
} }
@@ -342,10 +331,7 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
console.error('testMailAccess error:', error); console.error('testMailAccess error:', error);
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Test', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Test',
} as ApiResponse);
} }
} }
@@ -355,10 +341,7 @@ export async function checkEmailExists(req: Request, res: Response): Promise<voi
const result = await emailProviderService.checkEmailExists(localPart); const result = await emailProviderService.checkEmailExists(localPart);
res.json({ success: true, data: result } as ApiResponse); res.json({ success: true, data: result } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Fehler bei der E-Mail-Prüfung', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Prüfung',
} as ApiResponse);
} }
} }
@@ -377,10 +360,7 @@ export async function provisionEmail(req: Request, res: Response): Promise<void>
const result = await emailProviderService.provisionEmail(localPart, customerEmail); const result = await emailProviderService.provisionEmail(localPart, customerEmail);
res.json({ success: result.success, data: result } as ApiResponse); res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Fehler bei der E-Mail-Provisionierung', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Provisionierung',
} as ApiResponse);
} }
} }
@@ -390,10 +370,7 @@ export async function deprovisionEmail(req: Request, res: Response): Promise<voi
const result = await emailProviderService.deprovisionEmail(localPart); const result = await emailProviderService.deprovisionEmail(localPart);
res.json({ success: result.success, data: result } as ApiResponse); res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der E-Mail', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der E-Mail',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
import * as factoryDefaultsService from '../services/factoryDefaults.service.js'; import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
import { createAuditLog } from '../services/audit.service.js'; import { createAuditLog } from '../services/audit.service.js';
@@ -31,10 +32,7 @@ export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
res.send(buffer); res.send(buffer);
} catch (error) { } catch (error) {
console.error('Fehler beim Factory-Defaults-Export:', error); console.error('Fehler beim Factory-Defaults-Export:', error);
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Export', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Export',
});
} }
} }
@@ -93,9 +91,6 @@ export async function importFactoryDefaults(req: AuthRequest, res: Response) {
res.json({ success: true, data: result }); res.json({ success: true, data: result });
} catch (error) { } catch (error) {
console.error('Fehler beim Factory-Defaults-Import:', error); console.error('Fehler beim Factory-Defaults-Import:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Import');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Import',
});
} }
} }
+11 -40
View File
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
import * as gdprService from '../services/gdpr.service.js'; import * as gdprService from '../services/gdpr.service.js';
import * as consentService from '../services/consent.service.js'; import * as consentService from '../services/consent.service.js';
@@ -54,10 +55,7 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
} }
} catch (error) { } catch (error) {
console.error('Fehler beim Datenexport:', error); console.error('Fehler beim Datenexport:', error);
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Datenexport', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Datenexport',
});
} }
} }
@@ -98,10 +96,7 @@ export async function createDeletionRequest(req: AuthRequest, res: Response) {
res.status(201).json({ success: true, data: request }); res.status(201).json({ success: true, data: request });
} catch (error) { } catch (error) {
console.error('Fehler beim Erstellen der Löschanfrage:', error); console.error('Fehler beim Erstellen der Löschanfrage:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
});
} }
} }
@@ -183,10 +178,7 @@ export async function processDeletionRequest(req: AuthRequest, res: Response) {
res.json({ success: true, data: result }); res.json({ success: true, data: result });
} catch (error) { } catch (error) {
console.error('Fehler beim Bearbeiten der Löschanfrage:', error); console.error('Fehler beim Bearbeiten der Löschanfrage:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Bearbeiten');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Bearbeiten',
});
} }
} }
@@ -346,10 +338,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
res.json({ success: true, data: consent }); res.json({ success: true, data: consent });
} catch (error) { } catch (error) {
console.error('Fehler beim Aktualisieren der Einwilligung:', error); console.error('Fehler beim Aktualisieren der Einwilligung:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
});
} }
} }
@@ -697,10 +686,7 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
}); });
} catch (error) { } catch (error) {
console.error('Fehler beim Senden des Consent-Links:', error); console.error('Fehler beim Senden des Consent-Links:', error);
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Senden', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
} }
} }
@@ -819,10 +805,7 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
}); });
} catch (error) { } catch (error) {
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error); console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Senden', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
} }
} }
@@ -875,10 +858,7 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
res.json({ success: true, data: auth }); res.json({ success: true, data: auth });
} catch (error) { } catch (error) {
console.error('Fehler beim Erteilen der Vollmacht:', error); console.error('Fehler beim Erteilen der Vollmacht:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erteilen der Vollmacht');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erteilen der Vollmacht',
});
} }
} }
@@ -904,10 +884,7 @@ export async function withdrawAuthorization(req: AuthRequest, res: Response) {
res.json({ success: true, data: auth }); res.json({ success: true, data: auth });
} catch (error) { } catch (error) {
console.error('Fehler beim Widerrufen der Vollmacht:', error); console.error('Fehler beim Widerrufen der Vollmacht:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Widerrufen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Widerrufen',
});
} }
} }
@@ -1012,10 +989,7 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
res.json({ success: true, data: auth }); res.json({ success: true, data: auth });
} catch (error) { } catch (error) {
console.error('Fehler beim Upload des Vollmacht-Dokuments:', error); console.error('Fehler beim Upload des Vollmacht-Dokuments:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Upload');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Upload',
});
} }
} }
@@ -1039,10 +1013,7 @@ export async function deleteAuthorizationDocument(req: AuthRequest, res: Respons
res.json({ success: true, data: auth }); res.json({ success: true, data: auth });
} catch (error) { } catch (error) {
console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error); console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
});
} }
} }
+5 -16
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as invoiceService from '../services/invoice.service.js'; import * as invoiceService from '../services/invoice.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
@@ -101,10 +102,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
res.status(201).json({ success: true, data: invoice } as ApiResponse); res.status(201).json({ success: true, data: invoice } as ApiResponse);
} catch (error) { } catch (error) {
console.error('addInvoice error:', error); console.error('addInvoice error:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Hinzufügen der Rechnung');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen der Rechnung',
} as ApiResponse);
} }
} }
@@ -136,10 +134,7 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
res.json({ success: true, data: invoice } as ApiResponse); res.json({ success: true, data: invoice } as ApiResponse);
} catch (error) { } catch (error) {
console.error('updateInvoice error:', error); console.error('updateInvoice error:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Rechnung');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rechnung',
} as ApiResponse);
} }
} }
@@ -163,10 +158,7 @@ export async function deleteInvoice(req: AuthRequest, res: Response): Promise<vo
res.json({ success: true, data: null } as ApiResponse); res.json({ success: true, data: null } as ApiResponse);
} catch (error) { } catch (error) {
console.error('deleteInvoice error:', error); console.error('deleteInvoice error:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Rechnung');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rechnung',
} as ApiResponse);
} }
} }
@@ -208,9 +200,6 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
}); });
res.status(201).json({ success: true, data: invoice } as ApiResponse); res.status(201).json({ success: true, data: invoice } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Hinzufügen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { AuthRequest, ApiResponse } from '../types/index.js'; import { AuthRequest, ApiResponse } from '../types/index.js';
import * as appSettingService from '../services/appSetting.service.js'; import * as appSettingService from '../services/appSetting.service.js';
@@ -141,10 +142,7 @@ export async function testAlert(_req: AuthRequest, res: Response): Promise<void>
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse); res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
} }
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Test-Alert fehlgeschlagen', 500);
success: false,
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
} as ApiResponse);
} }
} }
@@ -201,9 +199,6 @@ export async function runDigestNow(_req: AuthRequest, res: Response): Promise<vo
const result = await sendDigest({ force: true }); const result = await sendDigest({ force: true });
res.json({ success: true, data: result } as ApiResponse); res.json({ success: true, data: result } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Digest fehlgeschlagen', 500);
success: false,
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Response } from 'express'; import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
import * as pdfTemplateService from '../services/pdfTemplate.service.js'; import * as pdfTemplateService from '../services/pdfTemplate.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
@@ -57,10 +58,7 @@ export async function createTemplate(req: AuthRequest, res: Response) {
res.status(201).json({ success: true, data: { ...template, pdfFields } }); res.status(201).json({ success: true, data: { ...template, pdfFields } });
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
});
} }
} }
@@ -87,10 +85,7 @@ export async function updateTemplate(req: AuthRequest, res: Response) {
res.json({ success: true, data: template }); res.json({ success: true, data: template });
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
});
} }
} }
@@ -119,10 +114,7 @@ export async function extractFields(req: AuthRequest, res: Response) {
const result = await pdfTemplateService.extractPdfFields(template.templatePath); const result = await pdfTemplateService.extractPdfFields(template.templatePath);
res.json({ success: true, data: result.fields, totalPages: result.totalPages }); res.json({ success: true, data: result.fields, totalPages: result.totalPages });
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Auslesen der PDF-Felder');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Auslesen der PDF-Felder',
});
} }
} }
@@ -137,7 +129,7 @@ export async function getAnnotatedPreview(req: AuthRequest, res: Response) {
res.setHeader('Content-Disposition', 'inline; filename="preview.pdf"'); res.setHeader('Content-Disposition', 'inline; filename="preview.pdf"');
res.send(pdfBuffer); res.send(pdfBuffer);
} catch (error) { } catch (error) {
res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' }); antworteAufFehler(res, error, 'Fehler', 400);
} }
} }
@@ -154,7 +146,7 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId); const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
res.json({ success: true, data: inputs }); res.json({ success: true, data: inputs });
} catch (error) { } catch (error) {
res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' }); antworteAufFehler(res, error, 'Fehler', 400);
} }
} }
@@ -195,9 +187,6 @@ export async function generatePdf(req: AuthRequest, res: Response) {
res.send(pdfBuffer); res.send(pdfBuffer);
} catch (error) { } catch (error) {
console.error('PDF generate error:', error); console.error('PDF generate error:', error);
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Generieren');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren',
});
} }
} }
+4 -12
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as platformService from '../services/platform.service.js'; import * as platformService from '../services/platform.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js'; import { ApiResponse } from '../types/index.js';
@@ -46,10 +47,7 @@ export async function createPlatform(req: Request, res: Response): Promise<void>
}); });
res.status(201).json({ success: true, data: platform } as ApiResponse); res.status(201).json({ success: true, data: platform } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertriebsplattform');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertriebsplattform',
} as ApiResponse);
} }
} }
@@ -64,10 +62,7 @@ export async function updatePlatform(req: Request, res: Response): Promise<void>
}); });
res.json({ success: true, data: platform } as ApiResponse); res.json({ success: true, data: platform } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertriebsplattform');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertriebsplattform',
} as ApiResponse);
} }
} }
@@ -83,9 +78,6 @@ export async function deletePlatform(req: Request, res: Response): Promise<void>
}); });
res.json({ success: true, message: 'Vertriebsplattform gelöscht' } as ApiResponse); res.json({ success: true, message: 'Vertriebsplattform gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Vertriebsplattform');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertriebsplattform',
} as ApiResponse);
} }
} }
+4 -12
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import * as providerService from '../services/provider.service.js'; import * as providerService from '../services/provider.service.js';
@@ -99,10 +100,7 @@ export async function createProvider(req: Request, res: Response): Promise<void>
}); });
res.status(201).json({ success: true, data: provider } as ApiResponse); res.status(201).json({ success: true, data: provider } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen des Anbieters');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Anbieters',
} as ApiResponse);
} }
} }
@@ -136,10 +134,7 @@ export async function updateProvider(req: Request, res: Response): Promise<void>
}); });
res.json({ success: true, data: provider } as ApiResponse); res.json({ success: true, data: provider } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Anbieters');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Anbieters',
} as ApiResponse);
} }
} }
@@ -155,9 +150,6 @@ export async function deleteProvider(req: Request, res: Response): Promise<void>
}); });
res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse); res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Anbieters');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Anbieters',
} as ApiResponse);
} }
} }
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js'; import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js'; import { ApiResponse, AuthRequest } from '../types/index.js';
@@ -93,11 +94,7 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
}); });
res.status(201).json({ success: true, data: email } as ApiResponse); res.status(201).json({ success: true, data: email } as ApiResponse);
} catch (error) { } catch (error) {
const status = error instanceof ApiError ? error.statusCode : 400; antworteAufFehler(res, error, 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse', 400);
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
} as ApiResponse);
} }
} }
@@ -119,11 +116,7 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
}); });
res.json({ success: true, data: email } as ApiResponse); res.json({ success: true, data: email } as ApiResponse);
} catch (error) { } catch (error) {
const status = error instanceof ApiError ? error.statusCode : 400; antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse', 400);
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
} as ApiResponse);
} }
} }
@@ -140,10 +133,7 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
}); });
res.json({ success: true, message: 'Stressfrei-Wechseln Adresse gelöscht' } as ApiResponse); res.json({ success: true, message: 'Stressfrei-Wechseln Adresse gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen der Stressfrei-Wechseln Adresse');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Stressfrei-Wechseln Adresse',
} as ApiResponse);
} }
} }
@@ -180,10 +170,7 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
message: 'Weiterleitungen aktualisiert', message: 'Weiterleitungen aktualisiert',
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Synchronisieren der Weiterleitungen', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Synchronisieren der Weiterleitungen',
} as ApiResponse);
} }
} }
@@ -228,11 +215,7 @@ export async function updateAdditionalForwards(req: AuthRequest, res: Response):
message: 'Weiterleitungen aktualisiert', message: 'Weiterleitungen aktualisiert',
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
const status = error instanceof ApiError ? error.statusCode : 500; antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Weiterleitungen', 500);
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Weiterleitungen',
} as ApiResponse);
} }
} }
@@ -255,9 +238,6 @@ export async function resetPassword(req: AuthRequest, res: Response): Promise<vo
message: 'Passwort wurde zurückgesetzt', message: 'Passwort wurde zurückgesetzt',
} as ApiResponse); } as ApiResponse);
} catch (error) { } catch (error) {
res.status(500).json({ antworteAufFehler(res, error, 'Fehler beim Zurücksetzen des Passworts', 500);
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts',
} as ApiResponse);
} }
} }
+4 -12
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as tariffService from '../services/tariff.service.js'; import * as tariffService from '../services/tariff.service.js';
import { logChange } from '../services/audit.service.js'; import { logChange } from '../services/audit.service.js';
import { ApiResponse } from '../types/index.js'; import { ApiResponse } from '../types/index.js';
@@ -48,10 +49,7 @@ export async function createTariff(req: Request, res: Response): Promise<void> {
}); });
res.status(201).json({ success: true, data: tariff } as ApiResponse); res.status(201).json({ success: true, data: tariff } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Erstellen des Tarifs');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Tarifs',
} as ApiResponse);
} }
} }
@@ -66,10 +64,7 @@ export async function updateTariff(req: Request, res: Response): Promise<void> {
}); });
res.json({ success: true, data: tariff } as ApiResponse); res.json({ success: true, data: tariff } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Tarifs');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Tarifs',
} as ApiResponse);
} }
} }
@@ -85,9 +80,6 @@ export async function deleteTariff(req: Request, res: Response): Promise<void> {
}); });
res.json({ success: true, message: 'Tarif gelöscht' } as ApiResponse); res.json({ success: true, message: 'Tarif gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Tarifs');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Tarifs',
} as ApiResponse);
} }
} }
+454 -45
View File
@@ -1,10 +1,14 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import bcrypt from 'bcryptjs'; 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 { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js'; import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
import { pickUserCreate, pickUserUpdate, pickRoleUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
import { RechteEskalationError, RollenSperrError, UngueltigeEingabeError } from '../services/rechte.service.js';
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
// Users // Users
@@ -54,6 +58,11 @@ export async function createUser(req: Request, res: Response): Promise<void> {
try { try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserCreate(req.body) as any; const data = pickUserCreate(req.body) as any;
const boolFehler = pruefeBooleanFelder(data, ['isActive', 'isServiceAccount', 'hasGdprAccess', 'hasDeveloperAccess', 'hasAuditOpsAccess']);
if (boolFehler) {
res.status(400).json({ success: false, error: boolFehler } as ApiResponse);
return;
}
// Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB // Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB
// (Pentest 29.4 SMTP-Header-Injection). // (Pentest 29.4 SMTP-Header-Injection).
if (!isValidEmail(data?.email) || !data?.email) { if (!isValidEmail(data?.email) || !data?.email) {
@@ -84,7 +93,12 @@ export async function createUser(req: Request, res: Response): Promise<void> {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse); res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
return; return;
} }
const user = await userService.createUser(data); const handelnder = handelnderOderNull(req);
if (handelnder === null) {
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
return;
}
const user = await userService.createUser(data, handelnder);
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'User', req, action: 'CREATE', resourceType: 'User',
resourceId: user.id.toString(), resourceId: user.id.toString(),
@@ -92,14 +106,11 @@ export async function createUser(req: Request, res: Response): Promise<void> {
}); });
res.status(201).json({ success: true, data: user } as ApiResponse); res.status(201).json({ success: true, data: user } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufRollenFehler(res, error, 'Fehler beim Erstellen des Benutzers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers',
} as ApiResponse);
} }
} }
export async function updateUser(req: Request, res: Response): Promise<void> { export async function updateUser(req: AuthRequest, res: Response): Promise<void> {
try { try {
const userId = parseInt(req.params.id); const userId = parseInt(req.params.id);
// `permissions` und `password` darf der generische Update nicht // `permissions` und `password` darf der generische Update nicht
@@ -129,6 +140,11 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
} }
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz) // Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
const data = pickUserUpdate(req.body) as Record<string, unknown>; const data = pickUserUpdate(req.body) as Record<string, unknown>;
const boolFehler = pruefeBooleanFelder(data, ['isActive', 'isServiceAccount', 'hasGdprAccess', 'hasDeveloperAccess', 'hasAuditOpsAccess']);
if (boolFehler) {
res.status(400).json({ success: false, error: boolFehler } as ApiResponse);
return;
}
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4). // Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen. // null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
if (data?.email !== undefined && !isValidEmail(data.email)) { if (data?.email !== undefined && !isValidEmail(data.email)) {
@@ -161,10 +177,129 @@ export async function updateUser(req: Request, 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;
const user = await userService.updateUser(userId, data as any); // Die drei Haken vergeben versteckte Rollen: DSGVO, Developer und
// Audit-Betrieb. Sie sind kein gewoehnliches Benutzerfeld - "Developer"
// traegt ALLE Rechte, "Audit-Betrieb" entscheidet, wer die
// Beweisgrundlage ersetzen darf.
//
// Bis 09/2026 stand hier, eine Rechte-Huerde scheitere am
// Henne-Ei-Problem: Nach der Aufteilung haelt zunaechst niemand
// `audit:admin`, also koennte ihn auch niemand vergeben. Das Argument war
// richtig, aber der Schluss zu weit. Ohne Huerde genuegte `users:create`,
// um sich zum Vollzugriff zu befoerdern - nicht einmal am eigenen Konto,
// sondern ueber ein frisch angelegtes zweites mit gesetztem Haken.
//
// Die Huerde steckt jetzt in der Teilmengenregel (rechte.service.ts):
// Wer einen Haken setzt, muss die dahinterliegenden Rechte selbst
// halten. Das Henne-Ei-Problem loest nicht die Weboberflaeche, sondern
// die Kommandozeile - `npx tsx prisma/rolle-zuweisen.ts` auf der
// Maschine. Das ist die richtige Grenze: "Datenbankzugriff" und
// "Protokoll neu berechnen" sollten Shell-Zugang voraussetzen, nicht ein
// Haekchen im Browser. Ein gestohlener Admin-Zugang hat den Container
// nicht.
const setztAuditBetrieb =
data.hasAuditOpsAccess !== undefined &&
before !== null &&
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
const setztDsgvo =
data.hasGdprAccess !== undefined &&
before !== null &&
data.hasGdprAccess !== (before as any).hasGdprAccess;
const setztDeveloper =
data.hasDeveloperAccess !== undefined &&
before !== null &&
data.hasDeveloperAccess !== (before as any).hasDeveloperAccess;
// Keine Selbstbedienung. Die Teilmengenregel allein reicht dafuer nicht:
// Wer die Rechte bereits haelt, koennte sie sich formal selbst erneut
// zuweisen - und ein Vorgang, bei dem Antragsteller und Genehmigender
// dieselbe Person sind, hinterlaesst keine ueberpruefbare Spur.
if ((setztAuditBetrieb || setztDsgvo || setztDeveloper) && req.user?.userId === userId) {
res.status(403).json({
success: false,
error:
'DSGVO-Zugriff, Audit-Betrieb und Entwicklerzugriff lassen sich nicht am eigenen ' +
'Konto ändern. Bitte von einer anderen Person mit den entsprechenden Rechten ' +
'vornehmen lassen.',
} as ApiResponse);
return;
}
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
// Routine erscheinen. Es bekommt deshalb dieselbe Behandlung wie das
// Absenken einer Aufbewahrungsfrist Bestaetigung, laute Protokollierung,
// und kein Selbstbedienen.
const setztDienstkonto =
typeof (data as any).isServiceAccount === 'boolean' &&
(data as any).isServiceAccount !== before?.isServiceAccount;
const aktiviertDienstkonto = setztDienstkonto && (data as any).isServiceAccount === true;
// R184-02: Das Kennzeichen ist ein Audit-Governance-Eingriff, kein
// gewoehnliches Benutzerfeld es ist Geschwister von retention-shorten,
// seal-backlog, rehash und cleanup, die alle `audit:admin` verlangen. Es
// haengt aber an der Route mit `users:update`. Heute deckungsgleich, weil
// nur die Administratorrolle beides hat aber jede kuenftige Rolle mit
// „Benutzer bearbeiten“ wuerde still „Login-Alarme herunterstufen“ erben.
// Deshalb hier zusaetzlich auf die Domaene pruefen, in der der Schaden
// liegt.
if (setztDienstkonto && !(req.user?.permissions || []).includes('audit:admin')) {
res.status(403).json({
success: false,
error:
'Das Dienstkonto-Kennzeichen ändert, wie Anmeldungen im Audit-Log bewertet werden. ' +
'Dafür genügt „Benutzer bearbeiten“ nicht es erfordert die Berechtigung ' +
'audit:admin, wie das Verkürzen von Aufbewahrungsfristen auch.',
} as ApiResponse);
return;
}
if (setztDienstkonto && req.user?.userId === userId) {
res.status(403).json({
success: false,
error:
'Das Dienstkonto-Kennzeichen lässt sich nicht am eigenen Konto setzen oder entfernen. ' +
'Es stuft Anmeldungen dieses Kontos als Routine ein wer das für sich selbst täte, ' +
'könnte die eigenen Anmeldungen unauffällig machen. Bitte von einem anderen ' +
'Administrator vornehmen lassen.',
} as ApiResponse);
return;
}
// Gate in BEIDE Richtungen (Pentest R184-01). Das Entfernen war ungegatet
// und es ist der gefaehrlichere Weg: Ein un-geflaggtes Konto faellt aus der
// Heartbeat-Wache, weil diese am Live-Kennzeichen haengt. Genau das
// „stilllegen und auf Stille setzen“, gegen das der Wachhund gebaut wurde.
if (setztDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') {
res.status(400).json({
success: false,
error: aktiviertDienstkonto
? `Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im ` +
'Audit-Log als Routine geführt statt als kritisches Ereignis. Das ist für ' +
'planmäßig arbeitende Dienste gedacht (etwa das Gegenbuch) für ein Konto, das ' +
'ein Mensch benutzt, wäre es eine Tarnung. ' +
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.'
: `Damit fällt ${before?.email ?? 'dieses Konto'} aus der Überwachung heraus: Es wird ` +
'dann nicht mehr gemeldet, wenn es sich nicht mehr anmeldet. Bei einem laufenden ' +
'Dienst wie dem Gegenbuch hieße das, sein Ausfall bliebe unbemerkt. ' +
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.',
} as ApiResponse);
return;
}
const handelnder = handelnderOderNull(req);
if (handelnder === null) {
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
return;
}
const user = await userService.updateUser(userId, data as any, handelnder);
if (user) { if (user) {
// Audit: Geänderte Felder ermitteln und loggen // Audit: Geänderte Felder ermitteln und loggen
if (before) { if (before) {
@@ -172,6 +307,8 @@ export async function updateUser(req: Request, 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)',
}; };
for (const [key, newVal] of Object.entries(data)) { for (const [key, newVal] of Object.entries(data)) {
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue; if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
@@ -191,9 +328,102 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
await logChange({ await logChange({
req, action: 'UPDATE', resourceType: 'User', req, action: 'UPDATE', resourceType: 'User',
resourceId: user.id.toString(), resourceId: user.id.toString(),
label: changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`, label: setztDienstkonto
? `Dienstkonto-Kennzeichen ${aktiviertDienstkonto ? 'GESETZT' : 'entfernt'} für ` +
`${user.email} Anmeldungen werden künftig ` +
`${aktiviertDienstkonto ? 'als Routine' : 'wieder als kritisch'} geführt`
: changeList
? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}`
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
details: Object.keys(changes).length > 0 ? changes : undefined, details: Object.keys(changes).length > 0 ? changes : undefined,
}); });
// Zusaetzlich in den Alarmkanal (Pentest R184-01). Eine CRITICAL-Zeile
// im Audit-Log muss jemand LESEN das ist die R183-02-Klasse. Das
// Entfernen des Kennzeichens nimmt das Konto aus der Heartbeat-Wache;
// ab dann faellt sein Ausbleiben nicht mehr auf. Diese Aenderung
// gehoert deshalb dorthin, wo automatisch reagiert wird.
if (setztDienstkonto) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PERMISSION_CHANGED',
severity: 'CRITICAL',
message: aktiviertDienstkonto
? `Konto ${user.email} als Dienstkonto markiert seine Anmeldungen gelten ab ` +
'jetzt als Routine statt als kritisches Ereignis.'
: `Konto ${user.email} ist KEIN Dienstkonto mehr es fällt damit aus der ` +
'Überwachung heraus: Ein Ausbleiben seiner Anmeldungen wird nicht mehr gemeldet.',
ipAddress: ctx.ipAddress,
userId: req.user?.userId,
userEmail: req.user?.email,
endpoint: ctx.endpoint,
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
});
}
// DSGVO und Entwicklerzugriff bekamen bis 09/2026 KEINEN Eintrag im
// Alarmkanal - nur Audit-Betrieb und das Dienstkonto-Kennzeichen.
// Dabei traegt die Developer-Rolle saemtliche Rechte des Systems:
// Der weitreichendste Haken war der leiseste.
if (setztDeveloper) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PERMISSION_CHANGED',
severity: 'CRITICAL',
message: data.hasDeveloperAccess === true
? `Konto ${user.email} hat ab jetzt Entwicklerzugriff das schliesst die ` +
'Datenbankwerkzeuge und damit saemtliche Rechte des Systems ein.'
: `Konto ${user.email} hat keinen Entwicklerzugriff mehr.`,
ipAddress: ctx.ipAddress,
userId: req.user?.userId,
userEmail: req.user?.email,
endpoint: ctx.endpoint,
details: { betroffenesKonto: user.email, aktiviert: data.hasDeveloperAccess === true },
});
}
if (setztDsgvo) {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PERMISSION_CHANGED',
severity: 'HIGH',
message: data.hasGdprAccess === true
? `Konto ${user.email} darf ab jetzt das Audit-Protokoll lesen und exportieren ` +
'sowie Auskunft und Loeschung nach DSGVO ausfuehren.'
: `Konto ${user.email} hat keinen DSGVO-Zugriff mehr es kann damit keine ` +
'Auskunft nach Art. 15 und keine Loeschung nach Art. 17 mehr ausfuehren.',
ipAddress: ctx.ipAddress,
userId: req.user?.userId,
userEmail: req.user?.email,
endpoint: ctx.endpoint,
details: { betroffenesKonto: user.email, aktiviert: data.hasGdprAccess === true },
});
}
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',
@@ -204,10 +434,7 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
} }
res.json({ success: true, data: user } as ApiResponse); res.json({ success: true, data: user } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren des Benutzers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers',
} as ApiResponse);
} }
} }
@@ -264,7 +491,15 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
} as ApiResponse); } as ApiResponse);
return; return;
} }
const user = await userService.updateUser(userId, { password } as any); const handelnderPw = handelnderOderNull(req);
if (handelnderPw === null) {
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
return;
}
// Reines Kennwort-Setzen: Der Zuwachs ist leer, die Teilmengenregel
// laeuft ins Leere. Der Handelnde wird trotzdem uebergeben, damit es
// keine Signatur gibt, die ihn weglassen darf.
const user = await userService.updateUser(userId, { password } as any, handelnderPw);
if (!user) { if (!user) {
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse); res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
return; return;
@@ -286,10 +521,7 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
}); });
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse); res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
} as ApiResponse);
} }
} }
@@ -305,14 +537,70 @@ export async function deleteUser(req: Request, res: Response): Promise<void> {
}); });
res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse); res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufFehler(res, error, 'Fehler beim Löschen des Benutzers');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Benutzers',
} as ApiResponse);
} }
} }
// Roles // Roles
/**
* Meldet jede Aenderung am Rollenmodell in den Alarmkanal.
*
* Die Rollenpflege war bis 09/2026 der einzige Eingriff in die Rechtevergabe
* ohne SecurityEvent - Aenderungen an einzelnen Konten wurden gemeldet, das
* Umschreiben einer Rolle, die an zwanzig Konten haengt, nicht. Damit war
* ausgerechnet der wirksamste Weg der leiseste.
*/
/**
* Die ID des Handelnden - oder null.
*
* `userId` fehlt bei Kundenportal-Anmeldungen. Ohne sie laesst sich die
* Teilmengenregel nicht auswerten, und "nicht auswertbar" darf nicht
* stillschweigend zu "erlaubt" werden. Deshalb wird hier abgelehnt statt
* durchgewinkt.
*/
/**
* Weist Boolean-Felder ab, die keine Booleans sind.
*
* `isActive` und `isServiceAccount` steuern beide ein Gate, das strikt auf
* `boolean` prueft - ein `"ja"` rutschte daran vorbei, ohne das Gate
* auszuloesen, und lief danach in einen Prisma-Fehler. Kein Bypass, weil der
* Schreibvorgang scheiterte, aber ein 500 fuer eine Eingabe, die schlicht
* falsch war. Dieselbe Bauart wie R193-02 bei den drei Haken: Ein Gate, das
* strenger liest als der Rest, uebersieht genau das, was dazwischen passt.
*/
function pruefeBooleanFelder(daten: Record<string, unknown>, felder: string[]): string | null {
for (const feld of felder) {
const wert = daten[feld];
if (wert !== undefined && typeof wert !== 'boolean') {
return `${feld} muss true oder false sein (empfangen: ${JSON.stringify(wert)})`;
}
}
return null;
}
function handelnderOderNull(req: AuthRequest): number | null {
return typeof req.user?.userId === 'number' ? req.user.userId : null;
}
function meldeRollenAenderung(
req: AuthRequest,
nachricht: string,
details: Record<string, unknown>,
): void {
const ctx = contextFromRequest(req);
emitSecurityEvent({
type: 'PERMISSION_CHANGED',
severity: 'HIGH',
message: nachricht,
ipAddress: ctx.ipAddress,
userId: req.user?.userId,
userEmail: req.user?.email,
endpoint: ctx.endpoint,
details,
});
}
export async function getRoles(req: Request, res: Response): Promise<void> { export async function getRoles(req: Request, res: Response): Promise<void> {
try { try {
const roles = await userService.getAllRoles(); const roles = await userService.getAllRoles();
@@ -344,43 +632,164 @@ export async function getRole(req: Request, res: Response): Promise<void> {
} }
} }
export async function createRole(req: Request, res: Response): Promise<void> { /**
* Prueft Name und Rechte-IDs aus dem Request.
*
* Gibt einen Fehlertext zurueck oder null. Bis 09/2026 gab es das gar nicht:
* Ein leerer Name landete in der Datenbank, doppelte IDs liefen in einen
* Primaerschluesselkonflikt und eine erfundene ID in einen
* Fremdschluesselfehler - beides kam als HTTP 500 zurueck und sah damit nach
* einem Serverfehler aus, obwohl es eine ungueltige Eingabe war.
*/
function pruefeRollenEingabe(
daten: Partial<Record<string, unknown>>,
nameNoetig: boolean,
): string | null {
if (nameNoetig || daten.name !== undefined) {
if (typeof daten.name !== 'string' || daten.name.trim().length === 0) {
return 'Name der Rolle fehlt';
}
if (daten.name.trim().length > 100) {
return 'Name der Rolle ist zu lang (maximal 100 Zeichen)';
}
}
if (daten.description !== undefined && typeof daten.description !== 'string') {
return 'Beschreibung muss Text sein';
}
if (nameNoetig || daten.permissionIds !== undefined) {
const ids = daten.permissionIds;
if (!Array.isArray(ids)) return 'permissionIds muss eine Liste sein';
if (!ids.every((id) => Number.isInteger(id) && (id as number) >= 1)) {
return 'permissionIds darf nur positive ganze Zahlen enthalten';
}
}
return null;
}
/** Bildet die Fehler aus der Rechteprüfung auf HTTP-Codes ab. */
function antworteAufRollenFehler(res: Response, error: unknown, fallback: string): void {
// 403 statt 400: "Sie duerfen das nicht" ist etwas anderes als "Ihre
// Eingabe ist kaputt". Ohne die Unterscheidung kann die Oberflaeche keinen
// brauchbaren Hinweis geben.
if (error instanceof RechteEskalationError || error instanceof RollenSperrError) {
res.status(403).json({ success: false, error: error.message } as ApiResponse);
return;
}
if (error instanceof UngueltigeEingabeError) {
res.status(400).json({ success: false, error: error.message } as ApiResponse);
return;
}
// Ab hier gilt: Nur was wir SELBST formuliert haben, geht nach draussen.
//
// Vorher wurde jede `error.message` durchgereicht. Damit landete erst der
// vollstaendige Prisma-Aufruf samt Serverpfad beim Client, und danach der
// Wortlaut eines TypeError ("object is not iterable…") bei
// `{"roleIds":{}}` (Pentest R190-01 und R192-01). Beide Male dieselbe
// Ursache: eine Fehlermeldung, die fuer Entwickler geschrieben ist, an
// einen Empfaenger, fuer den sie nicht gedacht war.
//
// Die Unterscheidung ist die Fehlerklasse, keine Heuristik auf dem Text:
// Ein blankes `Error` werfen wir absichtlich und mit einer Meldung fuer
// Menschen ("Der letzte Admin kann nicht…"). TypeError, RangeError und
// Verwandte sind Programmierfehler, Prisma-Fehler kommen von aussen -
// beides sagt dem Aufrufer nichts Nuetzliches und dem Angreifer zu viel.
const istAbsichtlich = error instanceof Error && error.constructor === Error;
if (istAbsichtlich) {
res.status(400).json({ success: false, error: error.message } as ApiResponse);
return;
}
// Unerwartet: Das ist ein Serverfehler, kein Eingabefehler - und ein 400
// waere hier die naechste Meldung, die sich falsch ausgibt. Einzelheiten
// ins Protokoll, damit sie nicht verlorengehen.
console.error(`[${fallback}] unerwarteter Fehler:`, error);
res.status(500).json({ success: false, error: fallback } as ApiResponse);
}
export async function createRole(req: AuthRequest, res: Response): Promise<void> {
try { try {
const role = await userService.createRole(req.body); const daten = pickRoleUpdate(req.body);
const fehler = pruefeRollenEingabe(daten, true);
if (fehler) {
res.status(400).json({ success: false, error: fehler } as ApiResponse);
return;
}
const handelnder = handelnderOderNull(req);
if (handelnder === null) {
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
return;
}
const role = await userService.createRole(
{
name: daten.name as string,
description: daten.description as string | undefined,
permissionIds: daten.permissionIds as number[],
},
handelnder,
);
await logChange({ await logChange({
req, action: 'CREATE', resourceType: 'Role', req, action: 'CREATE', resourceType: 'Role',
resourceId: role.id.toString(), resourceId: role.id.toString(),
label: `Rolle ${role.name} angelegt`, label: `Rolle ${role.name} angelegt`,
}); });
meldeRollenAenderung(req, `Rolle "${role.name}" angelegt`, {
rolle: role.name,
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
});
res.status(201).json({ success: true, data: role } as ApiResponse); res.status(201).json({ success: true, data: role } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufRollenFehler(res, error, 'Fehler beim Erstellen der Rolle');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Rolle',
} as ApiResponse);
} }
} }
export async function updateRole(req: Request, res: Response): Promise<void> { export async function updateRole(req: AuthRequest, res: Response): Promise<void> {
try { try {
const role = await userService.updateRole(parseInt(req.params.id), req.body); const daten = pickRoleUpdate(req.body);
if (role) { const fehler = pruefeRollenEingabe(daten, false);
await logChange({ if (fehler) {
req, action: 'UPDATE', resourceType: 'Role', res.status(400).json({ success: false, error: fehler } as ApiResponse);
resourceId: role.id.toString(), return;
label: `Rolle ${role.name} aktualisiert`,
});
} }
const handelnder = handelnderOderNull(req);
if (handelnder === null) {
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
return;
}
const role = await userService.updateRole(
parseInt(req.params.id),
{
name: daten.name as string | undefined,
description: daten.description as string | undefined,
permissionIds: daten.permissionIds as number[] | undefined,
},
handelnder,
);
if (!role) {
res.status(404).json({ success: false, error: 'Rolle nicht gefunden' } as ApiResponse);
return;
}
await logChange({
req, action: 'UPDATE', resourceType: 'Role',
resourceId: role.id.toString(),
label: `Rolle ${role.name} aktualisiert`,
});
meldeRollenAenderung(req, `Rechte der Rolle "${role.name}" geändert`, {
rolle: role.name,
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
traegerAbgemeldet: daten.permissionIds !== undefined,
});
res.json({ success: true, data: role } as ApiResponse); res.json({ success: true, data: role } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren der Rolle');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rolle',
} as ApiResponse);
} }
} }
export async function deleteRole(req: Request, res: Response): Promise<void> { export async function deleteRole(req: AuthRequest, res: Response): Promise<void> {
try { try {
const roleId = parseInt(req.params.id); const roleId = parseInt(req.params.id);
const role = await userService.getRoleById(roleId); const role = await userService.getRoleById(roleId);
@@ -390,12 +799,12 @@ export async function deleteRole(req: Request, res: Response): Promise<void> {
resourceId: roleId.toString(), resourceId: roleId.toString(),
label: `Rolle ${role?.name || roleId} gelöscht`, label: `Rolle ${role?.name || roleId} gelöscht`,
}); });
meldeRollenAenderung(req, `Rolle "${role?.name || roleId}" gelöscht`, {
rolle: role?.name,
});
res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse); res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse);
} catch (error) { } catch (error) {
res.status(400).json({ antworteAufRollenFehler(res, error, 'Fehler beim Löschen der Rolle');
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rolle',
} as ApiResponse);
} }
} }
+70 -54
View File
@@ -1,4 +1,4 @@
import express from 'express'; import express, { Router } from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
@@ -69,8 +69,11 @@ import { startContractStatusScheduler } from './services/contractStatusScheduler
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js'; import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js'; import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
import monitoringRoutes from './routes/monitoring.routes.js'; import monitoringRoutes from './routes/monitoring.routes.js';
import { registriereIdPruefung } from './middleware/routeIds.js';
import { auditContextMiddleware } from './middleware/auditContext.js'; import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js'; import { auditMiddleware } from './middleware/audit.js';
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
import { authenticate } from './middleware/auth.js'; import { authenticate } from './middleware/auth.js';
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ==================== // ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
@@ -325,69 +328,76 @@ app.use('/api', (_req, res, next) => {
next(); next();
}); });
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was // HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert // 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` also `6abc`,
// wurde kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input- // weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
// Validierung. Pentest Runde 7 (2026-05-17), LOW. // Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
// //
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb // Genau das loest `mounte()` weiter unten die Pruefung wird an jedem Router
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` // registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie // zwar in beide Richtungen:
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings //
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt // Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
// nicht geblockt. // endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/; // ID-Parameter und laesst nur kanonische Zahlen zu.
app.use('/api', (req, res, next) => { // Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
for (const seg of req.path.split('/')) { // `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) { //
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' }); // Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt zwei Antworten
return; // fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
}
}
next();
});
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148). // Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
// Großzügige Obergrenze pro IP ergänzt die feineren Limiter (Login etc.), // Großzügige Obergrenze pro IP ergänzt die feineren Limiter (Login etc.),
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung. // die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
app.use('/api', apiBackstopRateLimiter); app.use('/api', apiBackstopRateLimiter);
// Router einhängen IMMER über `mounte`, nie über `app.use` direkt.
//
// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188)
// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie
// stillschweigend deshalb steht die Pruefung im selben Handgriff wie das
// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann.
const mounte = (pfad: string, router: Router): void => {
app.use(pfad, registriereIdPruefung(router));
};
// Öffentliche Routes (OHNE Authentifizierung) // Öffentliche Routes (OHNE Authentifizierung)
app.use('/api/public/consent', consentPublicRoutes); mounte('/api/public/consent', consentPublicRoutes);
// Routes // Routes
app.use('/api/auth', authRoutes); mounte('/api/auth', authRoutes);
app.use('/api/customers', customerRoutes); mounte('/api/customers', customerRoutes);
app.use('/api/addresses', addressRoutes); mounte('/api/addresses', addressRoutes);
app.use('/api/bank-cards', bankcardRoutes); mounte('/api/bank-cards', bankcardRoutes);
app.use('/api/documents', documentRoutes); mounte('/api/documents', documentRoutes);
app.use('/api/meters', meterRoutes); mounte('/api/meters', meterRoutes);
app.use('/api/stressfrei-emails', stressfreiEmailRoutes); mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
app.use('/api/contracts', contractRoutes); mounte('/api/contracts', contractRoutes);
app.use('/api/credit-notes', creditNoteRoutes); mounte('/api/credit-notes', creditNoteRoutes);
app.use('/api/company-profile', companyProfileRoutes); mounte('/api/company-profile', companyProfileRoutes);
app.use('/api/platforms', platformRoutes); mounte('/api/platforms', platformRoutes);
app.use('/api/cancellation-periods', cancellationPeriodRoutes); mounte('/api/cancellation-periods', cancellationPeriodRoutes);
app.use('/api/contract-durations', contractDurationRoutes); mounte('/api/contract-durations', contractDurationRoutes);
app.use('/api/providers', providerRoutes); mounte('/api/providers', providerRoutes);
app.use('/api/tariffs', tariffRoutes); mounte('/api/tariffs', tariffRoutes);
app.use('/api/users', userRoutes); mounte('/api/users', userRoutes);
app.use('/api/upload', uploadRoutes); mounte('/api/upload', uploadRoutes);
app.use('/api/developer', developerRoutes); mounte('/api/developer', developerRoutes);
app.use('/api/contract-categories', contractCategoryRoutes); mounte('/api/contract-categories', contractCategoryRoutes);
app.use('/api', contractTaskRoutes); mounte('/api', contractTaskRoutes);
app.use('/api/settings', appSettingRoutes); mounte('/api/settings', appSettingRoutes);
app.use('/api/email-providers', emailProviderRoutes); mounte('/api/email-providers', emailProviderRoutes);
app.use('/api', cachedEmailRoutes); mounte('/api', cachedEmailRoutes);
app.use('/api/energy-details', invoiceRoutes); mounte('/api/energy-details', invoiceRoutes);
app.use('/api', contractHistoryRoutes); mounte('/api', contractHistoryRoutes);
app.use('/api/audit-logs', auditLogRoutes); mounte('/api/audit-logs', auditLogRoutes);
app.use('/api/gdpr', gdprRoutes); mounte('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes); mounte('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes); mounte('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes); mounte('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes); mounte('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes); mounte('/api/monitoring', monitoringRoutes);
// Health check BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy // Health check BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch // pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
@@ -487,6 +497,12 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
const LISTEN_ADDR = process.env.LISTEN_ADDR const LISTEN_ADDR = process.env.LISTEN_ADDR
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0'); || (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
starteHeartbeatMonitor();
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
void pruefePflichtrechte();
app.listen(PORT as number, LISTEN_ADDR, () => { app.listen(PORT as number, LISTEN_ADDR, () => {
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`); console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten // Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
+21 -1
View File
@@ -167,6 +167,21 @@ const ACTION_LABELS: Record<string, string> = {
TOKEN_REFRESH: 'Sitzung verlängert', TOKEN_REFRESH: 'Sitzung verlängert',
}; };
/**
* Ist das die planmaessige Anmeldung eines Dienstkontos? (Pentest R182)
*
* Vorhersagbare Ereignisse sind miserable Alarme und exzellente Grundlinien.
* Das Gegenbuch meldet sich stuendlich an als CRITICAL gefuehrt trainiert
* genau diese Routine den Betreiber darauf, die hoechste Stufe wegzuklicken,
* und der erste ECHTE Vorfall erbt den Reflex. Der Eintrag bleibt (man soll
* sehen, dass das Gegenbuch arbeitet), aber als Routine.
*/
function istDienstkontoAnmeldung(responseBody: unknown, success: boolean): boolean {
if (!success || !responseBody || typeof responseBody !== 'object') return false;
const data = (responseBody as { data?: { user?: { isServiceAccount?: boolean } } }).data;
return data?.user?.isServiceAccount === true;
}
/** /**
* Unterscheidet die drei Ausgaenge von POST /auth/refresh (Pentest R166-03). * Unterscheidet die drei Ausgaenge von POST /auth/refresh (Pentest R166-03).
* *
@@ -219,6 +234,9 @@ function generateHumanLabel(
// Auth // Auth
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) { if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
const email = req.body?.email || ''; const email = req.body?.email || '';
if (action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, true)) {
return `Dienstkonto ${email} angemeldet (planmäßig)`;
}
return action === 'LOGIN' return action === 'LOGIN'
? `Benutzer ${email} hat sich angemeldet` ? `Benutzer ${email} hat sich angemeldet`
: `Anmeldung fehlgeschlagen für ${email}`; : `Anmeldung fehlgeschlagen für ${email}`;
@@ -486,7 +504,9 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL). // Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
sensitivity: action === 'TOKEN_REFRESH' sensitivity: action === 'TOKEN_REFRESH'
? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW') ? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW')
: undefined, : action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, responseSuccess)
? 'LOW'
: undefined,
resourceType: mapping.type, resourceType: mapping.type,
resourceId, resourceId,
resourceLabel, resourceLabel,
+13 -1
View File
@@ -1,4 +1,5 @@
import { Response, NextFunction } from 'express'; import { Response, NextFunction } from 'express';
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { AuthRequest, JwtPayload } from '../types/index.js'; import { AuthRequest, JwtPayload } from '../types/index.js';
@@ -128,7 +129,18 @@ export function requirePermission(...requiredPermissions: string[]) {
return; return;
} }
const userPermissions = req.user.permissions || []; // Portal-Zugaenge bekommen hier nur, was in PORTAL_RECHTE steht -
// unabhaengig davon, was ihr Token behauptet.
//
// Das ist ein Riegel, kein Filter: Die Trennung zwischen Kundenportal und
// Rollensystem ist Absicht (siehe config/rechte-katalog.ts). Ein Kunde
// darf niemals operative Rechte bekommen - die Portalansicht ist dafuer
// nicht gebaut und prueft es nicht. Bisher haette eine einzige unbedachte
// Zeile in der Token-Erzeugung gereicht, um das zu kippen; ab hier
// braeuchte es zwei, an zwei Stellen, in Kenntnis dieses Kommentars.
const userPermissions = req.user.isCustomerPortal
? (req.user.permissions || []).filter((p) => PORTAL_RECHTE.includes(p))
: req.user.permissions || [];
// Check if user has any of the required permissions // Check if user has any of the required permissions
const hasPermission = requiredPermissions.some((perm) => const hasPermission = requiredPermissions.some((perm) =>
+106
View File
@@ -0,0 +1,106 @@
import { Request, Response, NextFunction, Router, RequestParamHandler } from 'express';
import { ApiResponse } from '../types/index.js';
/**
* Numerische Pfad-Parameter zentral pruefen (Pentest R188).
*
* Ausgangslage: In den Controllern stand 181-mal `parseInt(req.params.<x>)`
* ohne Pruefung. Das hatte zwei Folgen, und die zweite ist die unangenehmere:
*
* 1. Ein nicht-numerisches Segment ergab `NaN`, Prisma lief auf und der
* Handler antwortete mit **500**. `GET /api/users/permissions` etwa
* trifft `/:id` und war damit ein Serverfehler statt gibt es nicht".
*
* 2. `parseInt` liest so weit, wie es kann: `parseInt('12abc')` ist **12**.
* `GET /api/users/12abc` lieferte also Benutzer 12 aus. Eine schlampig
* geratene ID wurde stillschweigend zu einer gueltigen gemacht.
*
* Die naheliegende Antwort waere gewesen, an allen 181 Stellen einen Guard
* einzusetzen. Genau daran haben wir uns in dieser Reihe mehrfach die Finger
* verbrannt: Was an vielen Stellen gepflegt werden muss, laeuft auseinander
* (zuletzt R186-01, drei Filterlisten, die dasselbe bedeuten sollten). Deshalb
* eine Stelle statt 181 und eine neue Route ist automatisch mit abgedeckt.
*
* Umgesetzt ueber `router.param()`: Express ruft den Callback, bevor der
* Handler laeuft, und nur fuer Routen, die den Parameter wirklich benutzen.
*
* **Antwort ist 404, nicht 400.** Ein Pfad-Segment, das keine ID sein kann,
* benennt keine Ressource das ist nicht gefunden", nicht „falsch gefragt".
* Der Codebestand hat es an der einzigen bereits abgesicherten Stelle
* (`provider.controller.ts`, Pentest Mai 2026) genauso entschieden. Nebenbei
* verraet eine einheitliche 404 einem Probierenden nicht, ob ein Endpunkt
* existiert und eine Zahl erwartet oder ob es den Pfad gar nicht gibt.
*/
/**
* Parameternamen, die eine Datenbank-ID bezeichnen abgeglichen mit allen
* Routendateien. Bewusst NICHT dabei und deshalb unberuehrt: `consentType`,
* `filename`, `hash`, `key`, `localPart`, `name`, `tableName`.
*
* Die Namensregel heisst `id` oder endet auf `Id`" gilt im gesamten Bestand;
* eine neue Route mit `:fooId` gehoert hier ergaenzt.
*/
const ID_PARAMETER = [
'id',
'contractId',
'contractMeterId',
'customerId',
'documentId',
'ecdId',
'emailId',
'entryId',
'invoiceId',
'meterId',
'phoneNumberId',
'providerId',
'readingId',
'referralId',
'representativeId',
'simCardId',
'subtaskId',
'taskId',
] as const;
/** Obergrenze von MySQL INT darueber gibt es keine Zeile, nur einen Fehler. */
const MAX_ID = 2147483647;
/**
* Gueltig ist ausschliesslich eine kanonische positive Ganzzahl.
*
* Fuehrende Nullen werden abgelehnt: `007` und `7` wuerden dieselbe Zeile
* bezeichnen, und zwei Schreibweisen fuer dieselbe Ressource sind eine
* unnoetige Einladung etwa fuer Zaehler, Zwischenspeicher oder Sperren, die
* auf dem Pfad als Schluessel arbeiten.
*/
export function istGueltigeId(wert: string): boolean {
if (!/^[1-9][0-9]*$/.test(wert)) return false;
const n = Number(wert);
return Number.isSafeInteger(n) && n <= MAX_ID;
}
const pruefeIdParameter: RequestParamHandler = (
_req: Request,
res: Response,
next: NextFunction,
wert: unknown,
) => {
if (typeof wert === 'string' && istGueltigeId(wert)) {
next();
return;
}
// Bewusst ohne Angabe, WELCHER Parameter beanstandet wurde: Die Meldung
// soll nicht zur Landkarte werden, welche Endpunkte welche IDs erwarten.
res.status(404).json({ success: false, error: 'Nicht gefunden' } as ApiResponse);
};
/**
* Haengt die Pruefung an einen Router. Namen, die der Router gar nicht
* verwendet, kosten nichts Express ruft den Callback nur fuer Parameter,
* die in einer getroffenen Route vorkommen.
*/
export function registriereIdPruefung<T extends Router>(router: T): T {
for (const name of ID_PARAMETER) {
router.param(name, pruefeIdParameter);
}
return router;
}
+20 -2
View File
@@ -61,11 +61,17 @@ router.post(
backupController.createBackup backupController.createBackup
); );
// Backup wiederherstellen // Backup wiederherstellen.
//
// Ebenfalls UND-verknuepft: Ein Restore ersetzt auch Benutzer, Rollen und
// Rechtezuordnungen. Wer eine aeltere Sicherung einspielt, in der er selbst
// mehr durfte, hat sich damit befoerdert - derselbe Umweg wie beim
// Werksreset, nur leiser.
router.post( router.post(
'/backup/:name/restore', '/backup/:name/restore',
authenticate, authenticate,
requirePermission('settings:update'), requirePermission('settings:update'),
requirePermission('roles:manage'),
backupController.restoreBackup backupController.restoreBackup
); );
@@ -94,11 +100,23 @@ router.post(
backupController.uploadBackup backupController.uploadBackup
); );
// Werkseinstellungen (alles löschen) // Werkseinstellungen (alles löschen).
//
// Verlangt UND-verknuepft `settings:update` und `roles:manage` - zwei
// requirePermission hintereinander, weil ein einzelner Aufruf mit mehreren
// Rechten ODER bedeutet.
//
// Grund: Der Werksreset loescht alle Rollen und Rechtezuordnungen und legt
// ein frisches admin@admin.com an. Eine Rolle mit nur `settings:update`
// haette damit die gesamte Rechtevergabe zuruecksetzen und sich anschliessend
// ueber das neue Konto anmelden koennen - eine Rechteerhoehung ueber den
// Umweg "alles wegwerfen". Wer das Rechtemodell platt machen darf, muss es
// auch pflegen duerfen.
router.post( router.post(
'/factory-reset', '/factory-reset',
authenticate, authenticate,
requirePermission('settings:update'), requirePermission('settings:update'),
requirePermission('roles:manage'),
backupController.factoryReset backupController.factoryReset
); );
+11 -1
View File
@@ -16,7 +16,17 @@ router.use(authenticate);
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs); router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
// Audit-Logs exportieren // Audit-Logs exportieren
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs); //
// Verlangt `audit:export`, nicht `audit:read`. Die Berechtigung stand im
// Katalog und in der Rollenverwaltung, gatete aber NICHTS - jeder Leser
// konnte das vollstaendige Protokoll in einem Zug herausziehen.
//
// Das ist etwas anderes als Blaettern: Der Export liefert `changesBefore` und
// `changesAfter`, also die vollstaendigen Vorher/Nachher-Datensaetze, dazu
// `resourceLabel` mit Klartextnamen, IP-Adressen und User-Agents. Aufgefallen
// am Dienstkonto des Gegenbuchs: Es soll ausschliesslich Pruefwerte lesen -
// und konnte Personendaten exportieren.
router.get('/export', requirePermission('audit:export'), auditLogController.exportAuditLogs);
// Retention-Policies // Retention-Policies
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies); router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
@@ -1,13 +1,17 @@
// Kuendigungsfristen haben eigene Rechte (`cancellation-periods:*`).
//
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
// nicht. `cancellation-periods:*` stand im Katalog und bewachte nichts.
import { Router } from 'express'; import { Router } from 'express';
import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js'; import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
router.get('/', authenticate, cancellationPeriodController.getCancellationPeriods); router.get('/', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriods);
router.post('/', authenticate, requirePermission('platforms:create'), cancellationPeriodController.createCancellationPeriod); router.post('/', authenticate, requirePermission('cancellation-periods:create'), cancellationPeriodController.createCancellationPeriod);
router.get('/:id', authenticate, cancellationPeriodController.getCancellationPeriod); router.get('/:id', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriod);
router.put('/:id', authenticate, requirePermission('platforms:update'), cancellationPeriodController.updateCancellationPeriod); router.put('/:id', authenticate, requirePermission('cancellation-periods:update'), cancellationPeriodController.updateCancellationPeriod);
router.delete('/:id', authenticate, requirePermission('platforms:delete'), cancellationPeriodController.deleteCancellationPeriod); router.delete('/:id', authenticate, requirePermission('cancellation-periods:delete'), cancellationPeriodController.deleteCancellationPeriod);
export default router; export default router;
@@ -1,13 +1,17 @@
// Vertragslaufzeiten haben eigene Rechte (`contract-durations:*`).
//
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
// nicht. `contract-durations:*` stand im Katalog und bewachte nichts.
import { Router } from 'express'; import { Router } from 'express';
import * as contractDurationController from '../controllers/contract-duration.controller.js'; import * as contractDurationController from '../controllers/contract-duration.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
router.get('/', authenticate, contractDurationController.getContractDurations); router.get('/', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDurations);
router.post('/', authenticate, requirePermission('platforms:create'), contractDurationController.createContractDuration); router.post('/', authenticate, requirePermission('contract-durations:create'), contractDurationController.createContractDuration);
router.get('/:id', authenticate, contractDurationController.getContractDuration); router.get('/:id', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDuration);
router.put('/:id', authenticate, requirePermission('platforms:update'), contractDurationController.updateContractDuration); router.put('/:id', authenticate, requirePermission('contract-durations:update'), contractDurationController.updateContractDuration);
router.delete('/:id', authenticate, requirePermission('platforms:delete'), contractDurationController.deleteContractDuration); router.delete('/:id', authenticate, requirePermission('contract-durations:delete'), contractDurationController.deleteContractDuration);
export default router; export default router;
@@ -4,9 +4,12 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
// Lesen für alle authentifizierten Benutzer // Lesen verlangt jetzt `contract-categories:read`, vorher genuegte
router.get('/', authenticate, contractCategoryController.getContractCategories); // Angemeldetsein. Das Recht stand im Katalog und bewachte nichts; die
router.get('/:id', authenticate, contractCategoryController.getContractCategory); // Migration vergibt es an jede Rolle, die die Anwendung benutzt, damit
// niemand seine Listen verliert.
router.get('/', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategories);
router.get('/:id', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategory);
// Ändern/Löschen: `contract-categories:*` wird per seed.ts an Admin- // Ändern/Löschen: `contract-categories:*` wird per seed.ts an Admin-
// Rollen vergeben. Vorher stand hier `developer:access` mit dem // Rollen vergeben. Vorher stand hier `developer:access` mit dem
+18 -7
View File
@@ -1,3 +1,14 @@
// Die Providerkonfiguration hat eigene Rechte (`email-providers:*`).
//
// Bis 09/2026 gateten diese Routen auf `settings:read`/`settings:update` -
// wer irgendeine Einstellung aendern durfte, konnte damit auch die
// Zugangsdaten des Mailservers auslesen und aendern. `email-providers:*`
// stand derweil im Katalog und bewachte nichts.
//
// BEWUSST NICHT umgestellt: /domain und /public-settings (ungegatet, werden
// vom Kundenportal beim Postfach-Antrag gebraucht) sowie check/provision/
// deprovision - das sind kundenbezogene Vorgaenge auf `customers:*`, keine
// Providerkonfiguration. `email-providers:*` waere dort die falsche Domaene.
// ==================== EMAIL PROVIDER ROUTES ==================== // ==================== EMAIL PROVIDER ROUTES ====================
import { Router } from 'express'; import { Router } from 'express';
@@ -7,15 +18,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
// Provider Config CRUD (Admin-only) // Provider Config CRUD (Admin-only)
router.get('/configs', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfigs); router.get('/configs', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfigs);
router.get('/configs/:id', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfig); router.get('/configs/:id', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfig);
router.post('/configs', authenticate, requirePermission('settings:update'), emailProviderController.createProviderConfig); router.post('/configs', authenticate, requirePermission('email-providers:create'), emailProviderController.createProviderConfig);
router.put('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.updateProviderConfig); router.put('/configs/:id', authenticate, requirePermission('email-providers:update'), emailProviderController.updateProviderConfig);
router.delete('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.deleteProviderConfig); router.delete('/configs/:id', authenticate, requirePermission('email-providers:delete'), emailProviderController.deleteProviderConfig);
// Email Operations // Email Operations
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection); router.post('/test-connection', authenticate, requirePermission('email-providers:update'), emailProviderController.testConnection);
router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess); router.post('/test-mail-access', authenticate, requirePermission('email-providers:update'), emailProviderController.testMailAccess);
router.get('/domain', authenticate, emailProviderController.getProviderDomain); router.get('/domain', authenticate, emailProviderController.getProviderDomain);
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings); router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists); router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
+3 -2
View File
@@ -1,12 +1,13 @@
// Lesen verlangt jetzt `platforms:read`, vorher genuegte Angemeldetsein.
import { Router } from 'express'; import { Router } from 'express';
import * as platformController from '../controllers/platform.controller.js'; import * as platformController from '../controllers/platform.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
router.get('/', authenticate, platformController.getPlatforms); router.get('/', authenticate, requirePermission('platforms:read'), platformController.getPlatforms);
router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform); router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform);
router.get('/:id', authenticate, platformController.getPlatform); router.get('/:id', authenticate, requirePermission('platforms:read'), platformController.getPlatform);
router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform); router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform);
router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform); router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform);
+3 -3
View File
@@ -12,8 +12,8 @@ router.get('/:id', authenticate, requirePermission('providers:read'), providerCo
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider); router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider); router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
// Nested tariff routes // Tarife unter dem Anbieter - eigene Rechte, siehe tariff.routes.ts
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs); router.get('/:providerId/tariffs', authenticate, requirePermission('tariffs:read'), tariffController.getTariffs);
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff); router.post('/:providerId/tariffs', authenticate, requirePermission('tariffs:create'), tariffController.createTariff);
export default router; export default router;
+9 -3
View File
@@ -1,3 +1,9 @@
// Tarife haben eigene Rechte (`tariffs:*`).
//
// Bis 09/2026 gateten diese Routen auf `providers:*`, waehrend `tariffs:*`
// im Katalog stand und nichts bewachte - man konnte "Tarife bearbeiten"
// anhaken, ohne dass es etwas bewirkte, und wer Anbieter pflegen durfte,
// durfte automatisch auch alle Tarife aendern.
import { Router } from 'express'; import { Router } from 'express';
import * as tariffController from '../controllers/tariff.controller.js'; import * as tariffController from '../controllers/tariff.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js'; import { authenticate, requirePermission } from '../middleware/auth.js';
@@ -5,8 +11,8 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router(); const router = Router();
// Standalone tariff routes (for update/delete by tariff id) // Standalone tariff routes (for update/delete by tariff id)
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff); router.get('/:id', authenticate, requirePermission('tariffs:read'), tariffController.getTariff);
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff); router.put('/:id', authenticate, requirePermission('tariffs:update'), tariffController.updateTariff);
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff); router.delete('/:id', authenticate, requirePermission('tariffs:delete'), tariffController.deleteTariff);
export default router; export default router;
+19 -7
View File
@@ -16,14 +16,26 @@ router.delete('/:id', authenticate, requirePermission('users:delete'), userContr
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann. // davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword); router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
// Roles // Rollen und Rechte.
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles); //
router.post('/roles', authenticate, requirePermission('users:create'), userController.createRole); // Die Schreibwege haengen an `roles:manage`, nicht mehr an `users:*`:
router.get('/roles/:id', authenticate, requirePermission('users:read'), userController.getRole); // "Konten anlegen" und "festlegen, was ein Konto darf" sind zwei
router.put('/roles/:id', authenticate, requirePermission('users:update'), userController.updateRole); // verschiedene Befugnisse. Wer beides hatte, konnte sich eine Rolle mit
router.delete('/roles/:id', authenticate, requirePermission('users:delete'), userController.deleteRole); // `developer:access` bauen und zuweisen - die Rollenverwaltung war damit
// faktisch eine Rechteerhoehung mit Zwischenschritt. Die zweite Haelfte des
// Riegels ist die Teilmengenregel in rechte.service.ts.
//
// Die LESEwege bleiben zusaetzlich mit `users:read` erreichbar: Das
// Benutzerformular braucht die Rollenliste zum Zuweisen, ohne dass der
// Bearbeiter Rollen pflegen koennen muss. `requirePermission` ist
// ODER-verknuepft, das traegt ohne Zusatzcode.
router.get('/roles/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRoles);
router.post('/roles', authenticate, requirePermission('roles:manage'), userController.createRole);
router.get('/roles/:id', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRole);
router.put('/roles/:id', authenticate, requirePermission('roles:manage'), userController.updateRole);
router.delete('/roles/:id', authenticate, requirePermission('roles:manage'), userController.deleteRole);
// Permissions // Permissions
router.get('/permissions/list', authenticate, requirePermission('users:read'), userController.getPermissions); router.get('/permissions/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getPermissions);
export default router; export default router;
+210 -5
View File
@@ -15,6 +15,12 @@ export async function logChange(opts: {
label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen" label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen"
details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' } details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' }
customerId?: number; customerId?: number;
/// Ueberschreibt die aus dem Ressourcentyp abgeleitete Stufe. Noetig, wenn
/// eine Aktion gefaehrlicher ist als ihr Typ vermuten laesst - etwa das
/// Verkuerzen einer Aufbewahrungsfrist, dessen Folge CRITICAL ist
/// (Pentest R183-01).
sensitivity?: AuditSensitivity;
before?: Record<string, unknown>;
}) { }) {
try { try {
const user = opts.req?.user; const user = opts.req?.user;
@@ -32,6 +38,8 @@ export async function logChange(opts: {
httpMethod: opts.req?.method || '', httpMethod: opts.req?.method || '',
ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown', ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown',
dataSubjectId: opts.customerId, dataSubjectId: opts.customerId,
sensitivity: opts.sensitivity,
changesBefore: opts.before,
changesAfter: opts.details, changesAfter: opts.details,
}); });
} catch (error) { } catch (error) {
@@ -865,7 +873,11 @@ export async function sealBacklog(
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
valid: boolean; valid: boolean;
checkedCount: number; checkedCount: number;
/** Alle beanstandeten Zeilen (tampered + chainGaps) Abwaertskompatibilitaet. */ /**
* Alle noch offenen Beanstandungen: veraenderte Zeilen plus Luecken, die
* NICHT vom Bestandssiegel beglaubigt sind. Nur an dieser Liste haengt
* `valid` beglaubigte Alt-Luecken stehen in `attestedGaps`.
*/
invalidEntries: number[]; invalidEntries: number[];
/** /**
* ERNST: Der Inhalt der Zeile passt nicht mehr zu ihrem Hash jemand hat * ERNST: Der Inhalt der Zeile passt nicht mehr zu ihrem Hash jemand hat
@@ -890,12 +902,37 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
* eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log. * eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log.
*/ */
unverifiableEntries: number[]; unverifiableEntries: number[];
/**
* Teilmenge von `chainGaps`, die beim Versiegeln des Altbestands bereits
* bestand und im signierten Siegel-Marker als Vorbefund festgehalten ist.
* Diese Luecken sind BEGLAUBIGT: sie bleiben sichtbar, kippen `valid` aber
* nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten).
*/
attestedGaps: number[];
/**
* Protokollierte Neuberechnungen der Kette (Rehash), aelteste zuerst.
*
* Ein Rehash verknuepft alle Eintraege neu. Danach ist die Kette
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg, die vorher als
* Luecken sichtbar waren. Lueckenlos verkettet heisst nach einem Rehash
* also nur noch: seit dem Rehash. Wer das nicht mitliest, nimmt eine
* Entwarnung mit, die es nicht gibt.
*/
rehashes: Array<{
id: number;
zeitpunkt: string;
neuBerechnet: number | null;
/** Marker mit gueltiger HMAC-Signatur? Siehe Kommentar an der Auswertung. */
signiert: boolean;
/** Befund unmittelbar VOR dem Rehash - was also uebertuencht wurde. */
vorbefund: { manipuliert: number; luecken: number } | null;
}>;
/** /**
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand. * Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein * `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
* gueltiger Marker mehr der Anker wurde herausgeloest (Pentest R174-01). * gueltiger Marker mehr der Anker wurde herausgeloest (Pentest R174-01).
*/ */
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt'; backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */ /** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
backlogTampered: number[]; backlogTampered: number[];
/** Gesiegelte Zeilen, die nicht mehr existieren Beweismaterial entfernt. */ /** Gesiegelte Zeilen, die nicht mehr existieren Beweismaterial entfernt. */
@@ -1025,6 +1062,68 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
const gapErklaert = (prevId: number, curId: number) => const gapErklaert = (prevId: number, curId: number) =>
deletionRanges.some((r) => r.from <= curId && r.to >= prevId); deletionRanges.some((r) => r.from <= curId && r.to >= prevId);
// ---------------------------------------------------------------------
// Protokollierte Neuberechnungen (Rehash) einsammeln.
//
// Ein Rehash macht die Kette rechnerisch stimmig - auch dort, wo vorher
// Loeschungen als Luecken sichtbar waren. Genau das ist auf Staging passiert:
// zwei Cleanups mit Aufbewahrung 0 entfernten 3155 Eintraege, der folgende
// Rehash liess rund 700 Kettenluecken verschwinden, und die Pruefung meldete
// danach „unveraendert und lueckenlos verkettet“. Wahr - und trotzdem das
// Gegenteil dessen, was ein Leser mitnimmt.
//
// Umgekehrte Beweislast als bei Manifest und Siegel: Dort zaehlen NUR
// signierte Traeger, weil ein gefaelschter Marker Luecken wegerklaeren
// koennte - Misstrauen ist die sichere Richtung. Hier erzeugt ein Marker eine
// WARNUNG. Wuerden wir nur signierte gelten lassen, koennte jemand einen
// Rehash unsichtbar machen, indem er dessen Signatur zerstoert. Deshalb
// zaehlt hier jeder auswertbare Marker; ob er signiert ist, wird nur
// mitgeteilt.
const rehashKandidaten = await prisma.auditLog.findMany({
where: { resourceType: 'AuditLog', action: 'UPDATE', endpoint: '/api/audit-logs/rehash' },
orderBy: { id: 'asc' },
});
const rehashSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
(k): k is string => !!k,
);
const rehashes: Array<{
id: number;
zeitpunkt: string;
neuBerechnet: number | null;
signiert: boolean;
vorbefund: { manipuliert: number; luecken: number } | null;
}> = [];
for (const row of rehashKandidaten) {
let neuBerechnet: number | null = null;
let vorbefund: { manipuliert: number; luecken: number } | null = null;
try {
const nach = JSON.parse(row.changesAfter || '{}');
if (typeof nach.neuBerechnet !== 'number') continue; // kein Rehash-Marker
neuBerechnet = nach.neuBerechnet;
const vor = JSON.parse(row.changesBefore || '{}');
if (Array.isArray(vor.manipuliert) && Array.isArray(vor.ketten_luecken)) {
vorbefund = {
manipuliert: vor.manipuliert.length,
luecken: vor.ketten_luecken.length,
};
}
} catch {
continue;
}
const signiert =
row.hashVersion >= 3 &&
rehashSchluessel.some(
(k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k),
);
rehashes.push({
id: row.id,
zeitpunkt: row.createdAt.toISOString(),
neuBerechnet,
signiert,
vorbefund,
});
}
const tamperedEntries: number[] = []; const tamperedEntries: number[] = [];
const chainGaps: number[] = []; const chainGaps: number[] = [];
const unexplainedGaps: number[] = []; const unexplainedGaps: number[] = [];
@@ -1040,7 +1139,9 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01). // ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
const backlogTampered: number[] = []; const backlogTampered: number[] = [];
const backlogMissing: number[] = []; const backlogMissing: number[] = [];
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' = 'kein_siegel'; // Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten).
const beglaubigteLuecken = new Set<number>();
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
let backlogSealRoot: string | null = null; let backlogSealRoot: string | null = null;
const siegelKandidaten = await prisma.auditLog.findMany({ const siegelKandidaten = await prisma.auditLog.findMany({
@@ -1084,8 +1185,25 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
// Deshalb gilt jetzt: Blaetter vorhanden, aber kein gueltiger Marker = // Deshalb gilt jetzt: Blaetter vorhanden, aber kein gueltiger Marker =
// Siegel ENTFERNT und damit ein Befund nicht „nie versiegelt“. // Siegel ENTFERNT und damit ein Befund nicht „nie versiegelt“.
const blattAnzahl = await prisma.auditBacklogSeal.count(); const blattAnzahl = await prisma.auditBacklogSeal.count();
const unsignierteAnzahl = await prisma.auditLog.count({ where: { hashVersion: { lt: 3 } } });
if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) { if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) {
backlogSealStatus = 'entfernt'; backlogSealStatus = 'entfernt';
} else if (!siegel && blattAnzahl === 0 && unsignierteAnzahl === 0) {
// Es gibt gar keinen Altbestand: Entweder ist alles signiert, oder das Log
// beginnt erst mit der Signierung. Dann ist „nicht versiegelt“ kein Mangel
// (Pentest R183-03) die bisherige Warnung liess sich nicht aufloesen,
// weil seal-backlog zu Recht ablehnte. Eine Warnung, die der Betreiber
// nicht beheben kann, lernt er zu ignorieren.
//
// Die Bedingung haengt bewusst an der Zahl der UNSIGNIERTEN Zeilen und
// nicht mehr an `v3FromId === null`. Letzteres bedeutet naemlich das
// genaue Gegenteil: dass ueberhaupt nichts signiert ist etwa weil
// AUDIT_HMAC_KEY fehlt. Ein vollstaendig unsigniertes Log meldete damit
// „Bestandssiegel nicht noetig“, also Entwarnung fuer genau den Zustand
// mit der geringsten Beweiskraft. Jetzt bleibt es bei „kein_siegel“; die
// Fehlermeldung von seal-backlog nennt den fehlenden Schluessel als
// naechsten Schritt, die Warnung ist also aufloesbar.
backlogSealStatus = 'nicht_noetig';
} }
if (siegel) { if (siegel) {
@@ -1125,12 +1243,88 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
backlogTampered.length === 0 && backlogMissing.length === 0 && wurzelJetzt === meta.root backlogTampered.length === 0 && backlogMissing.length === 0 && wurzelJetzt === meta.root
? 'intakt' ? 'intakt'
: 'gebrochen'; : 'gebrochen';
// Ein Siegel ueber NULL Blaettern ist rechnerisch tadellos und schuetzt
// nichts: es gab zum Zeitpunkt des Siegelns keinen Altbestand. „intakt“
// zu melden waere formal richtig und trotzdem irrefuehrend - es liest
// sich als Schutzzusage. Der Pentester hat Stagings Leersiegel genau so
// missverstanden, und das ist der rote Faden im Kleinen: ein Signal, das
// beruhigt, wo nichts abgesichert ist. Eigener Zustand, gleiche Wertung
// wie „nicht noetig“ - kein Befund, aber auch keine Zusage.
if (backlogSealStatus === 'intakt' && blaetter.length === 0) {
backlogSealStatus = 'leer';
}
// ---------------------------------------------------------------
// Beglaubigte Alt-Luecken
//
// Die Race-Luecken aus der Zeit vor dem Sperr-Fix lassen sich nicht
// mehr heilen: die Verkettung ist gebrochen, die Inhalte sind aber
// unversehrt. Wuerden sie `valid` dauerhaft auf false halten, meldete
// das Gegenbuch stuendlich Alarm, ohne dass es je etwas zu tun gaebe -
// und genau daran stirbt jede Warnung. Ein Signal, das immer schreit,
// warnt nicht mehr.
//
// Deshalb gilt eine Luecke als BEGLAUBIGT, wenn beides zutrifft:
// 1. sie liegt im versiegelten Bereich (id <= toId), und
// 2. sie steht im Vorbefund des Siegel-Markers, also im Zustand, den
// der Betreiber beim Siegeln ausdruecklich festgeschrieben hat.
// Der Vorbefund liegt in `changesBefore` und ist ab Version 3 mit-
// gehasht - die Liste laesst sich also ohne Schluessel nicht nachtraeg-
// lich erweitern (gleiche Absicherung wie beim Manifest, R171-01).
//
// Beglaubigt heisst NICHT verschwunden: die Luecken bleiben in
// `chainGaps` und werden weiter berichtet. Sie zaehlen nur nicht mehr
// als offener Befund. Alles andere schlaegt unveraendert an - eine
// NEUE Luecke, eine veraenderte oder entfernte Altzeile, ein gebroche-
// nes Siegel. Bei nicht intaktem Siegel wird gar nichts beglaubigt.
if (backlogSealStatus === 'intakt') {
try {
const roh = siegel.changesEncrypted
? decrypt(siegel.changesBefore || '')
: siegel.changesBefore || '{}';
const vorbefund = JSON.parse(roh);
for (const id of vorbefund?.befund?.ketten_luecken || []) {
if (typeof id === 'number' && id <= bis) beglaubigteLuecken.add(id);
}
} catch {
// Unlesbarer Vorbefund beglaubigt nichts - die sichere Richtung.
}
}
} catch { } catch {
backlogSealStatus = 'gebrochen'; backlogSealStatus = 'gebrochen';
} }
} }
tamperedEntries.push(...backlogTampered, ...backlogMissing); tamperedEntries.push(...backlogTampered, ...backlogMissing);
// ---------------------------------------------------------------------
// Fehlender ANFANG des Protokolls.
//
// Die Verkettung wird zeilenweise gegen die Vorgaengerin geprueft - die
// erste Zeile hat keine, also fiel bisher NICHTS auf, wenn ein
// zusammenhaengender Anfang entfernt wurde. Kein Kettenbruch, kein Befund,
// `valid` blieb gruen. Das ist die stillste Loeschung von allen: Wer die
// aeltesten Eintraege loswerden will, muss nur vorne anfangen.
//
// Erkennbar ist es trotzdem: Die allererste Zeile eines Protokolls wird ohne
// Vorgaenger geschrieben und traegt deshalb einen leeren `previousHash`.
// Traegt die erste vorhandene Zeile einen Wert, hat es eine Vorgaengerin
// gegeben - und die ist weg.
//
// Nur bei Pruefung des GESAMTEN Bereichs; bei `fromId` ist ein gefuellter
// `previousHash` selbstverstaendlich und kein Befund.
if (fromId === undefined && logs.length > 0 && logs[0].previousHash) {
chainGaps.push(logs[0].id);
// Ein protokolliertes Loeschungs-Manifest erklaert auch diese Luecke; die
// Pruefung laeuft ueber denselben Weg wie bei jeder anderen.
if (!gapErklaert(0, logs[0].id)) {
unexplainedGaps.push(logs[0].id);
if (erwarteteVersion(logs[0].id) === 3) {
tamperedEntries.push(logs[0].id);
}
}
}
for (let i = 0; i < logs.length; i++) { for (let i = 0; i < logs.length; i++) {
const log = logs[i]; const log = logs[i];
@@ -1237,13 +1431,22 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
// Eskalierte Luecken stehen sowohl in tamperedEntries als auch in chainGaps; // Eskalierte Luecken stehen sowohl in tamperedEntries als auch in chainGaps;
// ohne Entdopplung zaehlte dieselbe Zeile zweimal (Pentest R171-03). // ohne Entdopplung zaehlte dieselbe Zeile zweimal (Pentest R171-03).
const invalidEntries = [...new Set([...tamperedEntries, ...chainGaps])].sort((a, b) => a - b); //
// Beglaubigte Alt-Luecken zaehlen NICHT als offener Befund (siehe die
// Begruendung an `beglaubigteLuecken`). Sie bleiben in `chainGaps`
// sichtbar und stehen zusaetzlich in `attestedGaps`.
const attestedGaps = chainGaps.filter((id) => beglaubigteLuecken.has(id));
const offeneLuecken = chainGaps.filter((id) => !beglaubigteLuecken.has(id));
const invalidEntries = [...new Set([...tamperedEntries, ...offeneLuecken])].sort((a, b) => a - b);
// Ein gebrochenes oder entferntes Siegel muss `valid` kippen, auch wenn keine // Ein gebrochenes oder entferntes Siegel muss `valid` kippen, auch wenn keine
// einzelne Zeile beanstandet ist sonst bliebe der stille Anker-Verlust // einzelne Zeile beanstandet ist sonst bliebe der stille Anker-Verlust
// unsichtbar (R174-01). // unsichtbar (R174-01).
const siegelInOrdnung = const siegelInOrdnung =
backlogSealStatus === 'intakt' || backlogSealStatus === 'kein_siegel'; backlogSealStatus === 'intakt' ||
backlogSealStatus === 'leer' ||
backlogSealStatus === 'kein_siegel' ||
backlogSealStatus === 'nicht_noetig';
return { return {
valid: invalidEntries.length === 0 && siegelInOrdnung, valid: invalidEntries.length === 0 && siegelInOrdnung,
@@ -1253,6 +1456,8 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
chainGaps, chainGaps,
unexplainedGaps, unexplainedGaps,
unverifiableEntries, unverifiableEntries,
attestedGaps,
rehashes,
backlogSealStatus, backlogSealStatus,
backlogTampered, backlogTampered,
backlogMissing, backlogMissing,
+8 -8
View File
@@ -1,4 +1,5 @@
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import crypto from 'crypto'; import crypto from 'crypto';
@@ -333,6 +334,9 @@ export async function login(email: string, password: string) {
permissions: Array.from(permissions), permissions: Array.from(permissions),
customerId: user.customerId, customerId: user.customerId,
isCustomerPortal: false, isCustomerPortal: false,
// Wird von der Audit-Middleware gelesen, um planmaessige
// Dienstkonto-Anmeldungen als Routine einzustufen (R182).
isServiceAccount: user.isServiceAccount,
}, },
}; };
} }
@@ -417,10 +421,9 @@ export async function customerLogin(email: string, password: string) {
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id); const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
// Kundenportal-Berechtigungen (eingeschränkt) // Kundenportal-Berechtigungen (eingeschränkt)
const customerPermissions = [ // Feste Portal-Rechte aus config/rechte-katalog.ts. Bewusst getrennt vom
'contracts:read', // Eigene Verträge lesen // Rollensystem - siehe den Kommentar dort.
'customers:read', // Eigene Kundendaten lesen const customerPermissions = [...PORTAL_RECHTE];
];
const payload: JwtPayload = { const payload: JwtPayload = {
email: customer.portalEmail!, email: customer.portalEmail!,
@@ -788,10 +791,7 @@ export async function getCustomerPortalUser(customerId: number) {
if (!customer || !customer.portalEnabled) return null; if (!customer || !customer.portalEnabled) return null;
const customerPermissions = [ const customerPermissions = [...PORTAL_RECHTE];
'contracts:read',
'customers:read',
];
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10): // Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen. // ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
+67 -102
View File
@@ -10,6 +10,31 @@ import * as path from 'path';
import archiver from 'archiver'; import archiver from 'archiver';
import AdmZip from 'adm-zip'; import AdmZip from 'adm-zip';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { synchronisiereRechteUndRollen } from './rollen-sync.service.js';
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../config/rechte-katalog.js';
/**
* Zufaelliges Initial-Kennwort fuer den Bootstrap-Admin nach einem
* Werksreset. Gleiche Regeln wie in prisma/seed.ts: 28 Zeichen, mindestens
* eines aus jeder Klasse, kryptografisch sichere Auswahl - Math.random() ist
* vorhersagbar und reicht dafuer nicht (Pentest 2026-05-20).
*/
function erzeugeInitialKennwort(): string {
const gross = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const klein = 'abcdefghijkmnopqrstuvwxyz';
const ziffern = '23456789';
const sonder = '!@#$%&*+=?';
const alle = gross + klein + ziffern + sonder;
const waehle = (s: string) => s[crypto.randomInt(0, s.length)];
const zeichen = [waehle(gross), waehle(klein), waehle(ziffern), waehle(sonder)];
for (let i = zeichen.length; i < 28; i++) zeichen.push(waehle(alle));
for (let i = zeichen.length - 1; i > 0; i--) {
const j = crypto.randomInt(0, i + 1);
[zeichen[i], zeichen[j]] = [zeichen[j], zeichen[i]];
}
return zeichen.join('');
}
// Verzeichnisse // Verzeichnisse
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups'); const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
@@ -1187,110 +1212,40 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
} }
} }
// Grundlegende Stammdaten neu anlegen (aus Seed) // ==================== RECHTE UND ROLLEN ====================
// Berechtigungen - muss mit seed.ts übereinstimmen! // Aus derselben Definition wie Seed und Container-Start
const resourcePermissions: Record<string, string[]> = { // (config/rechte-katalog.ts).
// Haupt-Ressourcen (CRUD) //
customers: ['create', 'read', 'update', 'delete'], // Hier stand bis 09/2026 eine DRITTE Kopie des Katalogs, mit dem
contracts: ['create', 'read', 'update', 'delete'], // Kommentar "muss mit seed.ts uebereinstimmen!" darueber - und sie stimmte
users: ['create', 'read', 'update', 'delete'], // nicht: Die Lookup-Tabellen hatten nur `read`, `email-providers`,
platforms: ['create', 'read', 'update', 'delete'], // `audit` und `gdpr` fehlten ganz, und von den Rollen wurden nur fuenf
providers: ['create', 'read', 'update', 'delete'], // angelegt. DSGVO, Audit-Betrieb und Gegenbuch gab es nach einem
tariffs: ['create', 'read', 'update', 'delete'], // Werksreset nicht mehr.
// Lookup-Tabellen (nur lesen) //
'contract-categories': ['read'], // Die Folge war kein Schoenheitsfehler: Ohne die DSGVO-Rolle kann
'cancellation-periods': ['read'], // niemand eine Auskunft nach Art. 15 oder eine Loeschung nach Art. 17
'contract-durations': ['read'], // ausfuehren. Ein Werksreset setzte damit stillschweigend die
// Einstellungen (nur lesen/ändern) // Handlungsfaehigkeit fuer Betroffenenrechte aus, und aufgefallen waere
settings: ['read', 'update'], // es erst, wenn eine Frist laeuft.
// Spezial-Permissions await synchronisiereRechteUndRollen(prisma, (zeile) =>
developer: ['access'], console.log(`[FactoryReset] ${zeile}`),
emails: ['delete'], );
};
for (const [resource, actions] of Object.entries(resourcePermissions)) { const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
for (const action of actions) { const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
await prisma.permission.create({ console.log('[FactoryReset] Rechte und Rollen erstellt');
data: { resource, action },
});
}
}
console.log('[FactoryReset] Berechtigungen erstellt');
// Admin-Rolle mit allen Berechtigungen (außer developer:access) // Standard Admin-Benutzer erstellen.
const allPermissions = await prisma.permission.findMany(); //
const adminRole = await prisma.role.create({ // Das Kennwort war hier bis 09/2026 fest auf "admin" verdrahtet, mit
data: { // bcrypt-Cost 10. Genau das verbietet seed.ts seit Pentest Runde 12
name: 'Admin', // ausdruecklich - und wieder war die Haertung nur in einer der beiden
description: 'Voller Zugriff auf alle Funktionen', // Kopien angekommen. Wer einen Werksreset ausloest, bekommt jetzt ein
permissions: { // zufaelliges Kennwort, das genau einmal im Log erscheint.
create: allPermissions
.filter(p => !(p.resource === 'developer' && p.action === 'access'))
.map(p => ({ permissionId: p.id })),
},
},
});
// Developer-Rolle - ALLE Berechtigungen inkl. developer:access
await prisma.role.create({
data: {
name: 'Developer',
description: 'Voller Zugriff inkl. Entwickler-Tools',
permissions: {
create: allPermissions.map(p => ({ permissionId: p.id })),
},
},
});
// Mitarbeiter-Rolle - customers, contracts + read-only auf Stammdaten
const employeePermIds = allPermissions
.filter(p =>
p.resource === 'customers' ||
p.resource === 'contracts' ||
(p.action === 'read' && ['platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'].includes(p.resource))
)
.map(p => p.id);
await prisma.role.create({
data: {
name: 'Mitarbeiter',
description: 'Kann Kunden und Verträge verwalten',
permissions: {
create: employeePermIds.map(id => ({ permissionId: id })),
},
},
});
// Nur-Lesen Rolle
const readOnlyResources = ['customers', 'contracts', 'platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'];
const readOnlyPermIds = allPermissions
.filter(p => p.action === 'read' && readOnlyResources.includes(p.resource))
.map(p => p.id);
await prisma.role.create({
data: {
name: 'Mitarbeiter (Nur-Lesen)',
description: 'Kann nur lesen, keine Änderungen',
permissions: {
create: readOnlyPermIds.map(id => ({ permissionId: id })),
},
},
});
// Kunden-Rolle
await prisma.role.create({
data: {
name: 'Kunde',
description: 'Kann nur eigene Daten lesen',
permissions: {
create: readOnlyPermIds.map(id => ({ permissionId: id })),
},
},
});
console.log('[FactoryReset] Rollen erstellt');
// Standard Admin-Benutzer erstellen
console.log('[FactoryReset] Erstelle Admin-Benutzer...'); console.log('[FactoryReset] Erstelle Admin-Benutzer...');
const hashedPassword = await bcrypt.hash('admin', 10); const adminPlainPassword = erzeugeInitialKennwort();
console.log('[FactoryReset] Passwort gehasht, Admin-Rolle ID:', adminRole.id); const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
const adminUser = await prisma.user.create({ const adminUser = await prisma.user.create({
data: { data: {
@@ -1298,11 +1253,21 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
password: hashedPassword, password: hashedPassword,
firstName: 'Admin', firstName: 'Admin',
lastName: 'User', lastName: 'User',
// Admin UND DSGVO (Pentest R189-01): Die Admin-Rolle traegt die
// Datenschutzrechte bewusst nicht, also braucht dieses eine
// Bootstrap-Konto beide - sonst ist nach dem Reset niemand
// handlungsfaehig.
roles: { roles: {
create: [{ roleId: adminRole.id }], create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
}, },
}, },
}); });
console.log('========================================================');
console.log(' Admin-User: admin@admin.com');
console.log(` Initial-Passwort: ${adminPlainPassword}`);
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
console.log(' Bitte sofort nach dem ersten Login ändern.');
console.log('========================================================');
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id); console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
// Standard Kündigungsfristen (wie in seed.ts) // Standard Kündigungsfristen (wie in seed.ts)
@@ -2,6 +2,7 @@
// "Geworben / angeworben": wer hat wen an Board geholt. // "Geworben / angeworben": wer hat wen an Board geholt.
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import { FachlicherFehler } from '../utils/apiError.js';
// Erlaubte Beziehungsarten (Whitelist). Server vertraut dem Frontend-Dropdown // Erlaubte Beziehungsarten (Whitelist). Server vertraut dem Frontend-Dropdown
// nicht ein handgebauter Request mit beliebigem String wird abgewiesen. // nicht ein handgebauter Request mit beliebigem String wird abgewiesen.
@@ -64,10 +65,11 @@ export async function getReferralsForCustomer(customerId: number) {
}; };
} }
export class ReferralError extends Error { export class ReferralError extends FachlicherFehler {
status: number; /** Beibehalten, weil bestehende Aufrufer `.status` lesen. */
readonly status: number;
constructor(message: string, status = 400) { constructor(message: string, status = 400) {
super(message); super(message, status);
this.status = status; this.status = status;
} }
} }
@@ -0,0 +1,83 @@
import prisma from '../lib/prisma.js';
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
/**
* Wachhund auf den AUSBLEIBENDEN Heartbeat (Pentest R182/R183).
*
* Dienstkonten allen voran das Gegenbuch melden sich in festem Takt an.
* Diese Anmeldungen sind bewusst als Routine eingestuft, damit sie die
* CRITICAL-Stufe nicht entwerten. Damit fehlt aber die andere Haelfte: Wer das
* Gegenbuch stilllegt, setzt genau darauf, dass STILLE nicht auffaellt. Ein
* gestopptes Gegenbuch ist sonst nicht von laeuft ruhig zu unterscheiden.
*
* Das Sicherheitssignal ist deshalb nicht die Anwesenheit der Anmeldung,
* sondern ihre Abweichung: bleibt sie aus, wird es gemeldet.
*
* Bewusste Zurueckhaltung: Ohne jemals gesehene Anmeldung gibt es keine
* Grundlinie dann wird geschwiegen statt geraten. Und pro Ausfall wird nur
* einmal gemeldet, nicht bei jedem Durchlauf.
*/
const PRUEFTAKT_MS = 15 * 60 * 1000;
const MAX_ALTER_MINUTEN = Number.parseInt(process.env.SERVICE_ACCOUNT_MAX_SILENCE_MINUTES || '180', 10);
export async function pruefeHeartbeats(): Promise<void> {
const konten = await prisma.user.findMany({
where: { isServiceAccount: true, isActive: true },
select: { id: true, email: true },
});
if (konten.length === 0) return;
const grenze = new Date(Date.now() - MAX_ALTER_MINUTEN * 60 * 1000);
for (const konto of konten) {
const letzte = await prisma.auditLog.findFirst({
where: { userId: konto.id, action: 'LOGIN' },
orderBy: { createdAt: 'desc' },
select: { createdAt: true },
});
// Nie angemeldet = keine Grundlinie. Schweigen statt raten.
if (!letzte) continue;
if (letzte.createdAt >= grenze) continue;
// Pro Ausfall nur einmal melden.
const schonGemeldet = await prisma.securityEvent.findFirst({
where: {
type: 'SUSPICIOUS',
userEmail: konto.email,
createdAt: { gte: letzte.createdAt },
message: { contains: 'Dienstkonto' },
},
});
if (schonGemeldet) continue;
const stillSeit = Math.round((Date.now() - letzte.createdAt.getTime()) / 60000);
await emitSecurityEvent({
type: 'SUSPICIOUS',
severity: 'CRITICAL',
message:
`Dienstkonto ${konto.email} meldet sich seit ${stillSeit} Minuten nicht mehr ` +
`(erwartet mindestens alle ${MAX_ALTER_MINUTEN} Minuten). ` +
'Entweder steht der zugehörige Dienst etwa das Gegenbuch oder er wurde stillgelegt. ' +
'Stille ist hier kein guter Zustand: Ohne laufendes Gegenbuch fällt eine nachträgliche ' +
'Änderung am Audit-Log nicht mehr auf.',
userEmail: konto.email,
userId: konto.id,
endpoint: 'heartbeat-monitor',
});
}
}
export function starteHeartbeatMonitor(): void {
if (!Number.isFinite(MAX_ALTER_MINUTEN) || MAX_ALTER_MINUTEN <= 0) {
console.warn('[Heartbeat] SERVICE_ACCOUNT_MAX_SILENCE_MINUTES ungültig Wachhund bleibt aus.');
return;
}
const lauf = () => {
pruefeHeartbeats().catch((e) => console.error('[Heartbeat] Fehler:', e));
};
// Nicht sofort beim Start: Nach einem Neustart darf ein kurz zurueckliegender
// Ausfall nicht doppelt melden, und die DB soll erst oben sein.
setTimeout(lauf, 60_000);
setInterval(lauf, PRUEFTAKT_MS).unref();
}
@@ -0,0 +1,147 @@
import prisma from '../lib/prisma.js';
import { SYSTEMROLLEN, pruefePortalRechte } from '../config/rechte-katalog.js';
/**
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
* ausueben kann (Pentest R189-01).
*
* Hintergrund: `gdpr:export`, `gdpr:delete` und `gdpr:admin` haengen an den
* Rollen DSGVO und Developer nicht an der Admin-Rolle. Nach einem frischen
* Seed war keine der beiden einem Konto zugewiesen. Ergebnis: Eine Auskunft
* nach Art. 15 oder eine Loeschung nach Art. 17 konnte NIEMAND ausfuehren,
* obwohl mehrere Admin"-Konten existierten. Ein Ausfall mit Fristwirkung,
* ausgeloest durch nichts weiter als eine Neuinstallation.
*
* Der Seed weist das Recht jetzt zu aber nur bei NEUINSTALLATION. Auf einer
* laufenden Datenbank aendert er nichts, und niemand merkt es, bis es darauf
* ankommt. Deshalb diese Wache: Sie erkennt die ABWESENHEIT einer Faehigkeit,
* so wie der Heartbeat das Ausbleiben eines Dienstkontos erkennt.
*
* Bewusst nur eine Meldung, keine automatische Vergabe: Rechte zu verteilen,
* ohne dass ein Mensch es veranlasst hat, waere der groessere Fehler.
*/
/** Rechte, deren Fehlen ein rechtliches und kein technisches Problem ist. */
const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }> = [
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
{ resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' },
// Ohne dieses Recht laesst sich keine Rolle mehr anlegen oder aendern - die
// Rechtevergabe waere eingefroren. Kein Rechtsproblem wie die beiden
// darueber, aber dieselbe Bauart: eine Faehigkeit, deren Fehlen erst
// auffaellt, wenn man sie braucht.
{ resource: 'roles', action: 'manage', wofuer: 'Pflege der Rollen und Rechte' },
];
export async function pruefePflichtrechte(): Promise<void> {
try {
const fehlend: string[] = [];
for (const recht of PFLICHTRECHTE) {
const traeger = await prisma.user.count({
where: {
isActive: true,
roles: {
some: {
role: {
permissions: {
some: {
permission: { resource: recht.resource, action: recht.action },
},
},
},
},
},
},
});
if (traeger === 0) {
fehlend.push(`${recht.resource}:${recht.action} (${recht.wofuer})`);
}
}
// Wache ueber die Portal-Trennung.
//
// Ein schreibendes Recht in PORTAL_RECHTE waere kein Schoenheitsfehler,
// sondern eine stille Ausweitung der Kundenrechte - jeder Portal-Kunde
// haette es sofort. Diese Meldung soll den Tag ueberleben, an dem jemand
// "nur kurz" etwas hinzufuegt.
const portalBeanstandet = pruefePortalRechte();
if (portalBeanstandet.length > 0) {
console.warn(
'\n' +
'========================================================================\n' +
' ACHTUNG: Das Kundenportal traegt Rechte, die nicht Lesen sind:\n' +
portalBeanstandet.map((r) => ` ${r}\n`).join('') +
'\n' +
' Kunden sollen im Portal NIEMALS operative Rechte haben. Die Ansicht\n' +
' ist dafuer nicht gebaut und prueft es nicht. Bitte PORTAL_RECHTE in\n' +
' config/rechte-katalog.ts pruefen.\n' +
'========================================================================\n',
);
}
// Zweite Wache: Stimmen die Systemrollen noch?
//
// Die versteckten Rollen werden ueber ihren NAMEN gefunden
// (`findFirst({ where: { name: 'DSGVO' } })`). Fehlt eine, oder ist ihr
// isSystem-Flag von Hand entfernt worden, laeuft der Notfallpfad ins
// Leere - lautlos. Das hier ist die Meldung, die es dann geben soll.
const rollenHinweise: string[] = [];
for (const spec of SYSTEMROLLEN) {
const rolle = await prisma.role.findUnique({ where: { name: spec.name } });
if (!rolle) {
rollenHinweise.push(`Systemrolle „${spec.name}" fehlt.`);
} else if (!rolle.isSystem) {
rollenHinweise.push(
`Systemrolle „${spec.name}" ist nicht als Systemrolle gekennzeichnet ` +
'sie ist damit über die Rollenverwaltung änderbar.',
);
}
}
if (rollenHinweise.length > 0) {
console.warn(
'\n' +
'========================================================================\n' +
' ACHTUNG: Die Systemrollen stimmen nicht mit dem Katalog überein:\n' +
rollenHinweise.map((h) => ` ${h}\n`).join('') +
'\n' +
' Beheben: npx tsx prisma/sync-roles.ts\n' +
'========================================================================\n',
);
}
if (fehlend.length === 0) return;
console.warn(
'\n' +
'========================================================================\n' +
' ACHTUNG: Für folgende Rechte gibt es KEIN aktives Konto:\n' +
fehlend.map((f) => ` ${f}\n`).join('') +
'\n' +
' Das ist kein Fehler der Anwendung, sondern eine Lücke in der\n' +
' Rechtevergabe und sie fällt erst auf, wenn eine Frist läuft.\n' +
'\n' +
' Beheben: In der Benutzerverwaltung bei einem verantwortlichen Konto\n' +
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll versiegeln,\n' +
' aufräumen, Aufbewahrung ändern zusätzlich „Audit-Betrieb".\n' +
'\n' +
' Geht das nicht, weil niemand mehr die nötigen Rechte hat: Rechte\n' +
' lassen sich über die Oberfläche nur weitergeben, nicht erschaffen.\n' +
' Für die Erstvergabe gibt es den Weg über die Kommandozeile:\n' +
' docker compose exec backend \\\n' +
' npx tsx prisma/rolle-zuweisen.ts <e-mail> DSGVO\n' +
' („--liste" zeigt die vorhandenen Rollen.)\n' +
'========================================================================\n',
);
} catch (err) {
// Eine Wache darf den Start nicht verhindern. Aber schweigen darf sie
// auch nicht: „nicht geprüft" ist nicht dasselbe wie „nichts gefunden".
console.warn(
'[Pflichtrechte] Prüfung nicht möglich der Zustand der Rechtevergabe ist ' +
'damit UNBEKANNT, nicht in Ordnung:',
err instanceof Error ? err.message : err,
);
}
}
+285
View File
@@ -0,0 +1,285 @@
/**
* Rechteaufloesung und die Regel, die Selbst-Erhoehung verhindert.
*
* Die Regel lautet: Niemand kann ein Recht weitergeben, das er selbst nicht
* besitzt. Ohne sie genuegte `users:create`, um sich zum Vollzugriff zu
* befoerdern - eine Rolle mit `developer:access` anlegen und sich zuweisen,
* oder gleich ein zweites Konto mit dem Haken "Entwicklerzugriff" erzeugen.
* Die Rollenverwaltung war damit faktisch eine Rechteerhoehung mit
* Zwischenschritt.
*
* Zwei Dinge sind hier bewusst so gebaut:
*
* 1. Die effektiven Rechte werden IMMER frisch aus der Datenbank gelesen,
* nie aus `req.user.permissions`. Der JWT-Claim ist bis zu 15 Minuten alt
* (JWT_EXPIRES_IN). Fuer ein Gate ist das vertretbar - fuer die Frage
* "darf dieser Mensch dieses Recht weitergeben" nicht: Ein Konto, dem
* gerade `gdpr:admin` entzogen wurde, koennte es im Fenster noch
* weiterreichen und damit den Entzug ueberdauern.
*
* 2. Geprueft wird immer nur der ZUWACHS. Rechte entziehen bleibt jederzeit
* erlaubt, auch solche, die der Handelnde selbst nicht hat - sonst
* koennte ein Admin einen uebernommenen Developer-Zugang nicht mehr
* entschaerfen, und die Regel wuerde den Angreifer schuetzen.
*/
import prisma from '../lib/prisma.js';
import { FachlicherFehler } from '../utils/apiError.js';
/** Groesste Zahl, die in eine INT-Spalte passt. Darueber gibt es keine ID. */
const INT_MAX = 2147483647;
import {
ROLLE_DEVELOPER,
ROLLE_DSGVO,
ROLLE_AUDIT_BETRIEB,
rechteDerSystemrolle,
} from '../config/rechte-katalog.js';
/** Der Handelnde wollte Rechte vergeben, die er selbst nicht besitzt. */
export class RechteEskalationError extends FachlicherFehler {
constructor(public readonly fehlend: string[]) {
super(
'Sie können nur Rechte vergeben, die Sie selbst besitzen. ' +
`Nicht vergeben werden können: ${fehlend.join(', ')}`,
403,
);
}
}
/**
* Die Eingabe nennt etwas, das es nicht gibt - eine unbekannte Rechte- oder
* Rollen-ID. Eigene Klasse, damit daraus ein 400 wird und nicht ein 500 aus
* einem Fremdschluesselfehler tief in Prisma.
*/
export class UngueltigeEingabeError extends FachlicherFehler {
constructor(nachricht: string) {
super(nachricht, 400);
}
}
/** Der Vorgang zielte auf eine Systemrolle, die von der Anwendung gepflegt wird. */
export class RollenSperrError extends FachlicherFehler {
constructor(nachricht: string) {
super(nachricht, 403);
}
}
/**
* Die effektiven Rechte eines Kontos, frisch aus der Datenbank.
*
* Das ist dieselbe Aufloesung, die auth.service.ts beim Anmelden und beim
* Erneuern des Tokens vornimmt - hier an einer Stelle, statt in vier Kopien.
*/
export async function effektiveRechte(userId: number): Promise<Set<string>> {
const konto = await prisma.user.findUnique({
where: { id: userId },
include: {
roles: {
include: { role: { include: { permissions: { include: { permission: true } } } } },
},
},
});
const rechte = new Set<string>();
if (!konto) return rechte;
for (const ur of konto.roles) {
for (const rp of ur.role.permissions) {
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
}
}
return rechte;
}
/** Vereinigung der Rechte mehrerer Rollen. */
export async function rechteVonRollen(roleIds: number[]): Promise<Set<string>> {
const rechte = new Set<string>();
if (roleIds.length === 0) return rechte;
const rollen = await prisma.role.findMany({
where: { id: { in: roleIds } },
include: { permissions: { include: { permission: true } } },
});
for (const r of rollen) {
for (const rp of r.permissions) {
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
}
}
return rechte;
}
/**
* Loest Rechte-IDs auf. Wirft bei unbekannter ID einen sprechenden Fehler -
* bisher lief eine erfundene ID in einen Fremdschluesselfehler und kam als
* HTTP 500 zurueck, also als "unser Fehler" statt "Ihre Eingabe".
*/
export async function rechteVonPermissionIds(ids: number[]): Promise<Set<string>> {
const rechte = new Set<string>();
if (ids.length === 0) return rechte;
const eindeutig = [...new Set(ids)];
const zuGross = eindeutig.filter((id) => id > INT_MAX);
const abfragbar = eindeutig.filter((id) => id <= INT_MAX);
const gefunden = abfragbar.length
? await prisma.permission.findMany({ where: { id: { in: abfragbar } } })
: [];
if (gefunden.length !== abfragbar.length || zuGross.length > 0) {
const bekannt = new Set(gefunden.map((p) => p.id));
const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross];
throw new UngueltigeEingabeError(`Unbekannte Rechte-ID: ${unbekannt.join(', ')}`);
}
for (const p of gefunden) rechte.add(`${p.resource}:${p.action}`);
return rechte;
}
/**
* Die Kernregel. Wirft RechteEskalationError, wenn der Handelnde etwas
* weitergeben will, das er selbst nicht haelt.
*/
export async function pruefeTeilmenge(
handelnderId: number,
benoetigt: Iterable<string>,
): Promise<void> {
const zuPruefen = [...benoetigt];
if (zuPruefen.length === 0) return;
const eigene = await effektiveRechte(handelnderId);
const fehlend = zuPruefen.filter((r) => !eigene.has(r)).sort();
if (fehlend.length > 0) throw new RechteEskalationError(fehlend);
}
/**
* Beendet die Sitzungen aller Traeger einer Rolle.
*
* Noetig, weil die Rechte im Zugangstoken stehen: Ohne das behielte jeder
* Traeger bis zu 15 Minuten lang die alten Rechte, und ein Entzug waere
* genau so lange wirkungslos. `updateUser` machte das laengst - die
* Rollenpflege nicht, und dort wiegt es schwerer, weil sie viele Konten auf
* einmal betrifft.
*
* Muss bei Loeschungen VOR dem Loeschen laufen: Danach sind die Traeger
* durch den Cascade nicht mehr ermittelbar.
*/
export async function meldeTraegerAb(roleId: number): Promise<number> {
const traeger = await prisma.userRole.findMany({
where: { roleId },
select: { userId: true },
});
if (traeger.length === 0) return 0;
await prisma.user.updateMany({
where: { id: { in: traeger.map((t) => t.userId) } },
data: { tokenInvalidatedAt: new Date() },
});
return traeger.length;
}
/**
* Die Rechte, die hinter den drei Haken im Benutzerformular stehen.
*
* Die Haken sind keine Datenbankspalten, sondern Kurzschrift fuer die
* versteckten Rollen DSGVO, Developer und Audit-Betrieb. Sie muessen unter
* dieselbe Teilmengenregel wie die Rollenzuweisung fallen, sonst ist der
* Rest Theater: Die Umgehung braucht nur `users:create` - ein zweites Konto
* mit dem Haken "Entwicklerzugriff" anlegen und sich damit anmelden. Die
* Developer-Rolle traegt ALLE Rechte. Eine reine Selbstvergabe-Sperre
* griffe dagegen nicht, denn der Angreifer vergibt sich nichts selbst.
*
* Nur das EINSCHALTEN wird geprueft. Wer einen Haken entfernt, nimmt Rechte
* weg - das darf jeder duerfen, der das Konto verwalten darf.
*/
export function rechteDerHaken(haken: Haken): string[] {
const rechte: string[] = [];
if (haken.hasDeveloperAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DEVELOPER));
if (haken.hasGdprAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DSGVO));
if (haken.hasAuditOpsAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_AUDIT_BETRIEB));
return [...new Set(rechte)];
}
/**
* Prueft Rollen-IDs und gibt sie doppelt-frei zurueck.
*
* Muss VOR jedem Schreibvorgang laufen. Ohne das kam eine Dublette
* (`[4,4]`) oder eine erfundene ID erst beim Schreiben zum Vorschein - und
* beim Rollentausch geschah das NACH dem Loeschen der alten Zuordnungen.
* Ergebnis war ein HTTP 400, das wie "Eingabe abgelehnt, nichts passiert"
* aussah, waehrend das Konto in Wahrheit ohne jede Rolle dastand.
*
* Besonders bitter am letzten Admin: Die Sperre prueft die ABSICHT (die
* Admin-Rolle steht ja in der Anfrage) und liess den Vorgang durch - der
* Schreibvorgang scheiterte danach. Aussperrung, obwohl die Sperre gegriffen
* zu haben schien (Pentest R190-01).
*
* `rechteVonRollen` fing das nicht ab: Eine unbekannte ID findet einfach
* keine Rolle, bringt also keine Rechte mit und faellt durch die
* Teilmengenregel nicht auf. Das war richtig fuer die Rechtefrage und
* falsch als Eingabepruefung - zwei verschiedene Aufgaben.
*/
export async function normalisiereRollenIds(roleIds: number[]): Promise<number[]> {
// Erst pruefen, dass es ueberhaupt eine Liste ist. Ohne das lief ein
// `{"roleIds":{}}` in einen TypeError beim Aufspreizen, und dessen
// Wortlaut ging an den Client (Pentest R192-01).
if (!Array.isArray(roleIds)) {
throw new UngueltigeEingabeError('roleIds muss eine Liste sein');
}
const eindeutig = [...new Set(roleIds)];
if (eindeutig.length === 0) return [];
if (!eindeutig.every((id) => Number.isInteger(id) && id >= 1)) {
throw new UngueltigeEingabeError('roleIds darf nur positive ganze Zahlen enthalten');
}
// Zahlen jenseits des INT-Bereichs gar nicht erst abfragen: Prisma bricht
// dort mit einem Fremdfehler ab, und der kam als allgemeines "Fehler beim
// Aktualisieren" zurueck - eine andere Antwort auf dieselbe Eingabeklasse
// (Pentest R191, kosmetisch). Eine ID, die nicht in die Spalte passt,
// benennt keine Rolle; sie ist schlicht unbekannt.
const zuGross = eindeutig.filter((id) => id > INT_MAX);
const abfragbar = eindeutig.filter((id) => id <= INT_MAX);
const gefunden = abfragbar.length
? await prisma.role.findMany({ where: { id: { in: abfragbar } }, select: { id: true } })
: [];
if (gefunden.length !== abfragbar.length || zuGross.length > 0) {
const bekannt = new Set(gefunden.map((r) => r.id));
const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross];
throw new UngueltigeEingabeError(`Unbekannte Rollen-ID: ${unbekannt.join(', ')}`);
}
return eindeutig;
}
/** Die drei Haken, nachdem sie geprueft wurden. Nur noch echte Booleans. */
export interface Haken {
hasDeveloperAccess?: boolean;
hasGdprAccess?: boolean;
hasAuditOpsAccess?: boolean;
}
const HAKEN_FELDER = ['hasDeveloperAccess', 'hasGdprAccess', 'hasAuditOpsAccess'] as const;
/**
* Prueft die drei Haken-Felder streng auf Boolean.
*
* Der Grund ist ein Angriff, keine Formalie (Pentest R193-02): Der Guard
* fragte `=== true`, die Zuweisung fragte auf Truthiness. Zwei verschiedene
* Vorstellungen davon, was "der Haken ist gesetzt" bedeutet - und dazwischen
* passte ein Bypass. Mit `{"hasDeveloperAccess": "ja"}` sah der Guard keinen
* Rechtezuwachs und liess durch, waehrend die Zuweisung den String als
* "gesetzt" las und die Developer-Rolle vergab. Damit war `developer:access`
* wieder ueber ein Browser-Feld erreichbar, obwohl es ausdruecklich nur ueber
* die Kommandozeile vergeben werden sollte.
*
* Die Lehre ist dieselbe wie bei R190-01 und R191-01: Eine Absicherung, die
* nur eine von zwei Kopien erreicht, ist keine. Deshalb gibt es ab hier nur
* noch EINE Lesart - alles, was kein echtes true oder false ist, wird
* abgewiesen, statt irgendwie ausgelegt zu werden.
*/
export function normalisiereHaken(roh: Record<string, unknown>): Haken {
const gepruef: Haken = {};
for (const feld of HAKEN_FELDER) {
const wert = roh[feld];
if (wert === undefined) continue;
if (typeof wert !== 'boolean') {
throw new UngueltigeEingabeError(
`${feld} muss true oder false sein (empfangen: ${JSON.stringify(wert)})`,
);
}
gepruef[feld] = wert;
}
return gepruef;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Bringt Rechtekatalog und Systemrollen in der Datenbank auf den Stand des
* Codes. Idempotent - laeuft bei jedem Container-Start.
*
* Verbraucher: `prisma/sync-roles.ts` (Container-Start), `prisma/seed.ts`
* (Erstinstallation) und `factoryReset` in `backup.service.ts`. Alle drei
* benutzen dieselbe Definition aus `config/rechte-katalog.ts`; vorher hatte
* jeder seine eigene, und die dritte wich ab.
*
* Was hier NICHT passiert: Stammdaten, Benutzer, Vertraege. Das Skript ist
* auf einer laufenden Produktionsdatenbank sicher.
*/
import type { PrismaClient } from '@prisma/client';
import {
RECHTE_KATALOG,
SYSTEMROLLEN,
alsRechtString,
} from '../config/rechte-katalog.js';
type Protokoll = (zeile: string) => void;
/**
* Setzt die Rechte einer Rolle exakt auf `permissionIds` - fehlende kommen
* dazu, ueberzaehlige fliegen raus.
*
* Der Vollersatz ist Absicht: Er ist der Grund, warum eine per Adminer an
* einer Systemrolle vorgenommene Aenderung den naechsten Container-Start
* nicht ueberlebt.
*/
async function synchronisiereRollenrechte(
prisma: PrismaClient,
roleId: number,
permissionIds: number[],
log: Protokoll,
): Promise<void> {
const vorhanden = await prisma.rolePermission.findMany({
where: { roleId },
select: { permissionId: true },
});
const vorhandenIds = new Set(vorhanden.map((e) => e.permissionId));
const zielIds = new Set(permissionIds);
const fehlend = permissionIds.filter((id) => !vorhandenIds.has(id));
if (fehlend.length > 0) {
await prisma.rolePermission.createMany({
data: fehlend.map((permissionId) => ({ roleId, permissionId })),
skipDuplicates: true,
});
log(` → +${fehlend.length} Rechte an Rolle #${roleId}`);
}
const ueberzaehlig = vorhanden
.filter((e) => !zielIds.has(e.permissionId))
.map((e) => e.permissionId);
if (ueberzaehlig.length > 0) {
await prisma.rolePermission.deleteMany({
where: { roleId, permissionId: { in: ueberzaehlig } },
});
log(` → -${ueberzaehlig.length} Rechte von Rolle #${roleId}`);
}
}
/**
* Legt alle Rechte aus dem Katalog an und bringt die Systemrollen auf Stand.
* Setzt dabei auch `isSystem` und `isHidden` - das ist die eigentliche
* Absicherung gegen eine von Hand verstellte Datenbank, die Migration setzt
* die Flags nur einmalig.
*/
export async function synchronisiereRechteUndRollen(
prisma: PrismaClient,
log: Protokoll = (z) => console.log(z),
): Promise<void> {
log('[rollen-sync] Rechtekatalog upserten…');
for (const recht of RECHTE_KATALOG) {
await prisma.permission.upsert({
where: { resource_action: { resource: recht.resource, action: recht.action } },
update: {},
create: { resource: recht.resource, action: recht.action },
});
}
const alleRechte = await prisma.permission.findMany();
log(`[rollen-sync] ${alleRechte.length} Rechte in der Datenbank`);
// Auflösung Katalog → Datenbank-IDs. Rechte, die in der Datenbank stehen,
// aber nicht im Katalog, bleiben unangetastet und werden auch keiner Rolle
// zugeteilt: Der Katalog ist die Wahrheit, nicht der Altbestand.
const idFuerRecht = new Map<string, number>();
for (const p of alleRechte) idFuerRecht.set(alsRechtString(p), p.id);
for (const spec of SYSTEMROLLEN) {
const rechteIds = RECHTE_KATALOG.filter(spec.rechte)
.map((r) => idFuerRecht.get(alsRechtString(r)))
.filter((id): id is number => id !== undefined);
const rolle = await prisma.role.upsert({
where: { name: spec.name },
update: {
description: spec.description,
isSystem: true,
isHidden: spec.isHidden,
},
create: {
name: spec.name,
description: spec.description,
isSystem: true,
isHidden: spec.isHidden,
},
});
await synchronisiereRollenrechte(prisma, rolle.id, rechteIds, log);
}
log('[rollen-sync] fertig.');
}
+435 -264
View File
@@ -1,6 +1,33 @@
import prisma from '../lib/prisma.js'; import prisma from '../lib/prisma.js';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { paginate, buildPaginationResponse } from '../utils/helpers.js'; import { paginate, buildPaginationResponse } from '../utils/helpers.js';
import {
pruefeTeilmenge,
rechteVonPermissionIds,
rechteVonRollen,
rechteDerHaken,
normalisiereHaken,
UngueltigeEingabeError,
effektiveRechte,
normalisiereRollenIds,
meldeTraegerAb,
RollenSperrError,
} from './rechte.service.js';
import type { Haken } from './rechte.service.js';
import {
istSystemrollenName,
RECHTE_KATALOG,
ROLLE_DEVELOPER,
ROLLE_DSGVO,
ROLLE_AUDIT_BETRIEB,
} from '../config/rechte-katalog.js';
import { Prisma } from '@prisma/client';
/**
* Re-Export aus dem Katalog. Der Name ist an mehreren Stellen im Umlauf;
* die Wahrheit steht jetzt an einer Stelle.
*/
export const AUDIT_OPS_ROLLE = ROLLE_AUDIT_BETRIEB;
export interface UserFilters { export interface UserFilters {
search?: string; search?: string;
@@ -44,6 +71,7 @@ export async function getAllUsers(filters: UserFilters) {
firstName: true, firstName: true,
lastName: true, lastName: true,
isActive: true, isActive: true,
isServiceAccount: true,
customerId: true, customerId: true,
whatsappNumber: true, whatsappNumber: true,
telegramUsername: true, telegramUsername: true,
@@ -64,9 +92,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 {
@@ -77,11 +106,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),
@@ -97,6 +130,7 @@ export async function getUserById(id: number) {
firstName: true, firstName: true,
lastName: true, lastName: true,
isActive: true, isActive: true,
isServiceAccount: true,
customerId: true, customerId: true,
whatsappNumber: true, whatsappNumber: true,
telegramUsername: true, telegramUsername: true,
@@ -135,6 +169,79 @@ export async function getUserById(id: number) {
}; };
} }
/**
* Faehigkeiten, ohne die sich das System nicht mehr verwalten laesst.
*
* Bis 09/2026 galt hier "wer `users:delete` hat, ist Admin". Das war ein
* Zufallsmerkmal: Wer Konten anlegen und bearbeiten darf, aber nicht loeschen,
* verwaltet genauso - zaehlte aber nicht. Umgekehrt zaehlte jemand mit nur
* `users:delete` als Admin, obwohl er kein Konto anlegen kann. Ein
* Sicherheitsnetz an einem Merkmal, das mit der geschuetzten Eigenschaft nur
* lose zusammenhing (Pentest R193, Nachrangpunkt 2).
*
* Bewusst NICHT auf die Rolle "Admin" umgestellt, wie zunaechst vorgeschlagen:
* Eine selbst gebaute Rolle mit `users:update` verwaltet tatsaechlich, und ein
* Namenskriterium wuerde sie uebersehen - man koennte dann den letzten
* Admin loeschen, obwohl die Verwaltungsfaehigkeit erhalten bliebe. Geschuetzt
* wird die FAEHIGKEIT, nicht ihr ueblicher Traeger.
*
* `users:update` ist das Minimum, um Konten wieder in Ordnung zu bringen
* (Rollen zuweisen). `roles:manage` ist das Minimum, um Rollen zu definieren.
* Faellt eines davon ersatzlos weg, hilft nur noch die Kommandozeile.
*/
const UNVERZICHTBARE_FAEHIGKEITEN = [
{ recht: 'users:update', wofuer: 'Benutzer verwalten' },
{ recht: 'roles:manage', wofuer: 'Rollen und Rechte pflegen' },
] as const;
/** Die Rechte eines Kontos aus seinen geladenen Rollen. */
function rechteAusRollen(
rollen: Array<{ role: { permissions: Array<{ permission: { resource: string; action: string } }> } }>,
): Set<string> {
const rechte = new Set<string>();
for (const ur of rollen) {
for (const rp of ur.role.permissions) {
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
}
}
return rechte;
}
/**
* Verhindert, dass die letzte Person mit einer unverzichtbaren Faehigkeit
* sie verliert - durch Rollenwechsel, Deaktivierung oder Loeschung.
*
* `kuenftigeRechte === null` heisst: Das Konto faellt ganz weg.
*/
async function pruefeLetzterVerwalter(
zielId: number,
bisherigeRechte: Set<string>,
kuenftigeRechte: Set<string> | null,
): Promise<void> {
for (const { recht, wofuer } of UNVERZICHTBARE_FAEHIGKEITEN) {
if (!bisherigeRechte.has(recht)) continue; // hatte es nie
if (kuenftigeRechte?.has(recht)) continue; // behaelt es
const [resource, action] = recht.split(':');
const andere = await prisma.user.count({
where: {
id: { not: zielId },
isActive: true,
roles: { some: { role: { permissions: { some: { permission: { resource, action } } } } } },
},
});
if (andere === 0) {
throw new Error(
`Dieses Konto ist das letzte, das „${wofuer}" kann (${recht}). ` +
'Ohne es liesse sich das System nur noch über die Kommandozeile ' +
'wieder in Ordnung bringen. Bitte zuerst einem anderen aktiven Konto ' +
'die entsprechende Rolle geben.',
);
}
}
}
export async function createUser(data: { export async function createUser(data: {
email: string; email: string;
password: string; password: string;
@@ -144,49 +251,73 @@ 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;
}) { },
const hashedPassword = await bcrypt.hash(data.password, 10); handelnderId: number,
) {
// Was das neue Konto koennen wird - aus den Rollen UND aus den Haken.
// `handelnderId` ist Pflichtparameter, damit kein Aufrufer die Pruefung
// vergessen kann; der Compiler erzwingt sie.
// Erst pruefen, dann rechnen: Eine unbekannte oder doppelte Rollen-ID
// faellt der Teilmengenregel nicht auf (sie bringt keine Rechte mit) und
// schluege erst beim Schreiben fehl.
const rollenIds = await normalisiereRollenIds(data.roleIds);
const user = await prisma.user.create({ // Einmal pruefen, dann ueberall dieselbe Lesart: Guard und Zuweisung
data: { // arbeiten auf DEMSELBEN Objekt (Pentest R193-02).
email: data.email, const haken = normalisiereHaken(data as Record<string, unknown>);
password: hashedPassword,
firstName: data.firstName, await pruefeTeilmenge(handelnderId, [
lastName: data.lastName, ...(await rechteVonRollen(rollenIds)),
customerId: data.customerId, ...rechteDerHaken(haken),
whatsappNumber: data.whatsappNumber || null, ]);
telegramUsername: data.telegramUsername || null,
signalNumber: data.signalNumber || null, // Cost 12 wie in prisma/seed.ts (OWASP 2026). Hier stand 10 - dieselbe
roles: { // Haertung, die im Seed laengst galt, war an dieser Stelle nie
create: data.roleIds.map((roleId) => ({ roleId })), // angekommen. Bestehende Kennwoerter bleiben pruefbar, der Cost steckt im
// Hash.
const hashedPassword = await bcrypt.hash(data.password, 12);
const user = await prisma.$transaction(async (tx) => {
const angelegt = await tx.user.create({
data: {
email: data.email,
password: hashedPassword,
firstName: data.firstName,
lastName: data.lastName,
customerId: data.customerId,
whatsappNumber: data.whatsappNumber || null,
telegramUsername: data.telegramUsername || null,
signalNumber: data.signalNumber || null,
roles: {
create: rollenIds.map((roleId) => ({ roleId })),
},
}, },
}, select: {
select: { id: true,
id: true, email: true,
email: true, firstName: true,
firstName: true, lastName: true,
lastName: true, isActive: true,
isActive: true, isServiceAccount: true,
customerId: true, customerId: true,
roles: { roles: {
include: { role: true }, include: { role: true },
},
}, },
}, });
// Die Haken in derselben Transaktion wie das Konto (Pentest R191-01).
// Sonst koennte ein halb ausgestattetes Konto zurueckbleiben: angelegt,
// aber ohne die zugesagte versteckte Rolle.
await setzeHaken(tx, angelegt.id, haken);
return angelegt;
}); });
// Entwicklerzugriff setzen falls aktiviert
if (data.hasDeveloperAccess) {
await setUserDeveloperAccess(user.id, true);
}
// DSGVO-Zugriff setzen falls aktiviert
if (data.hasGdprAccess) {
await setUserGdprAccess(user.id, true);
}
return user; return user;
} }
@@ -198,16 +329,50 @@ export async function updateUser(
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
isActive?: boolean; isActive?: boolean;
isServiceAccount?: boolean;
roleIds?: number[]; roleIds?: number[];
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;
} },
handelnderId: number,
) { ) {
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data; const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
// Rollen-IDs pruefen, bevor irgendetwas geschrieben oder entschieden wird.
// Die Letzter-Admin-Sperre weiter unten arbeitet mit dieser Liste - mit
// einer unbekannten ID darin haette sie eine Absicht bestaetigt, die der
// Schreibvorgang danach nicht einloesen konnte (Pentest R190-01).
const gepruefteRollenIds =
roleIds !== undefined ? await normalisiereRollenIds(roleIds) : undefined;
// Die Haken einmal pruefen - danach gibt es nur noch diese eine Lesart,
// fuer den Guard wie fuer die Zuweisung (Pentest R193-02).
const haken = normalisiereHaken({ hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess });
// Teilmengenregel auf dem ZUWACHS, nicht auf dem Endzustand.
//
// Wer nur den Nachnamen eines hoeher privilegierten Kollegen korrigiert,
// schickt das Formular unveraendert mit - inklusive gesetzter Haken. Wuerde
// hier der Endzustand geprueft, waere jede solche Korrektur ein 403.
// Geprueft wird deshalb nur, was das Zielkonto NEU dazubekommt.
{
const bisher = await effektiveRechte(id);
const zuwachs = new Set<string>();
if (roleIds !== undefined) {
for (const r of await rechteVonRollen(gepruefteRollenIds!)) {
if (!bisher.has(r)) zuwachs.add(r);
}
}
for (const r of rechteDerHaken(haken)) {
if (!bisher.has(r)) zuwachs.add(r);
}
await pruefeTeilmenge(handelnderId, zuwachs);
}
// 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;
@@ -232,74 +397,31 @@ export async function updateUser(
}, },
}); });
const isCurrentlyAdmin = currentUser?.roles.some((ur) => // Geschuetzt wird die FAEHIGKEIT, nicht ihr ueblicher Traeger -
ur.role.permissions.some( // siehe UNVERZICHTBARE_FAEHIGKEITEN.
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete' const bisherigeRechte = rechteAusRollen(currentUser?.roles ?? []);
)
);
if (isCurrentlyAdmin) { let kuenftigeRechte: Set<string> | null;
// Check if user will still be admin after role change if (isBeingDeactivated) {
let willStillBeAdmin = false; // Ein deaktiviertes Konto kann nichts mehr, egal welche Rollen formal
if (rolesAreBeingChanged) { // drankleben.
const newRoles = await prisma.role.findMany({ kuenftigeRechte = null;
where: { id: { in: roleIds } }, } else if (rolesAreBeingChanged) {
include: { const neueRollen = await prisma.role.findMany({
permissions: { where: { id: { in: gepruefteRollenIds! } },
include: { permission: true }, include: { permissions: { include: { permission: true } } },
}, });
}, kuenftigeRechte = rechteAusRollen(neueRollen.map((role) => ({ role })));
}); } else {
willStillBeAdmin = newRoles.some((role) => kuenftigeRechte = bisherigeRechte;
role.permissions.some(
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
)
);
} else {
willStillBeAdmin = true; // Roles not being changed
}
// If user is losing admin status or being deactivated, check for other admins
if (!willStillBeAdmin || isBeingDeactivated) {
const otherAdminCount = await prisma.user.count({
where: {
id: { not: id },
isActive: true,
roles: {
some: {
role: {
permissions: {
some: {
permission: {
resource: 'users',
action: 'delete',
},
},
},
},
},
},
},
});
if (otherAdminCount === 0) {
if (isBeingDeactivated) {
throw new Error(
'Dieser Benutzer ist der letzte Administrator und kann nicht deaktiviert werden'
);
} else {
throw new Error(
'Die Admin-Rolle kann nicht entfernt werden, da dies der letzte Administrator ist'
);
}
}
}
} }
await pruefeLetzterVerwalter(id, bisherigeRechte, kuenftigeRechte);
} }
// Hash password if provided // Hash password if provided
if (password) { if (password) {
(userData as Record<string, unknown>).password = await bcrypt.hash(password, 10); (userData as Record<string, unknown>).password = await bcrypt.hash(password, 12);
} }
// Prüfen ob Rollen geändert werden (für Zwangslogout) // Prüfen ob Rollen geändert werden (für Zwangslogout)
@@ -316,142 +438,123 @@ export async function updateUser(
!currentRoleIds.every((id, i) => id === newRoleIds[i]); !currentRoleIds.every((id, i) => id === newRoleIds[i]);
} }
// Update user - bei Rollenänderung Token invalidieren // Die gesamte Schreibphase in EINER Transaktion.
await prisma.user.update({ //
where: { id }, // Zwei Stufen, beide aus dem Pentest:
data: { //
...userData, // R190-01: deleteMany und createMany standen nackt nebeneinander.
// Token invalidieren wenn Rollen geändert werden // Scheiterte das Anlegen, war das Loeschen schon passiert - das Konto
...(rolesChanged && { tokenInvalidatedAt: new Date() }), // hatte gar keine Rolle mehr, bei einer Antwort, die wie "abgelehnt,
}, // nichts geschehen" aussah.
}); //
// R191-01: Danach lag zwar der Rollentausch in einer Transaktion, die
// Update roles if provided // drei Haken aber liefen als eigene Schreibvorgaenge hinterher. Ein
if (roleIds) { // Datenbankfehler dort hinterliess den Rollenstand gesetzt und die Haken
await prisma.userRole.deleteMany({ where: { userId: id } }); // halb - kein Verlust und keine Rechteerhoehung, aber ein Zwischenstand,
await prisma.userRole.createMany({ // den niemand angefordert hat. Ein PUT ist ein Vorgang, also gehoert er
data: roleIds.map((roleId) => ({ userId: id, roleId })), // in eine Klammer.
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id },
data: {
...userData,
// Token invalidieren wenn Rollen geändert werden
...(rolesChanged && { tokenInvalidatedAt: new Date() }),
},
}); });
}
// Handle developer access if (gepruefteRollenIds !== undefined) {
if (hasDeveloperAccess !== undefined) { await tx.userRole.deleteMany({ where: { userId: id } });
await setUserDeveloperAccess(id, hasDeveloperAccess); await tx.userRole.createMany({
} data: gepruefteRollenIds.map((roleId) => ({ userId: id, roleId })),
skipDuplicates: true,
});
}
// Handle GDPR access // Nach dem Rollentausch: Der loescht ALLE Zuordnungen, auch die
if (hasGdprAccess !== undefined) { // versteckten Rollen. Die Haken setzen sie anschliessend wieder.
await setUserGdprAccess(id, hasGdprAccess); await setzeHaken(tx, id, haken);
} });
return getUserById(id); return getUserById(id);
} }
// Helper to set developer access for a user /**
async function setUserDeveloperAccess(userId: number, enabled: boolean) { * Setzt oder entfernt eine der drei versteckten Rollen (DSGVO, Developer,
// Get or create developer:access permission * Audit-Betrieb) - innerhalb der uebergebenen Transaktion.
let developerPerm = await prisma.permission.findFirst({ *
where: { resource: 'developer', action: 'access' }, * Ersetzt drei fast gleiche Funktionen, die jede fuer sich die Rolle bei
}); * Bedarf ANLEGTEN, falls sie fehlte. Das war als Notfallpfad gedacht und
* zugleich eine vierte Stelle, die festlegte, was "Developer" bedeutet -
if (!developerPerm) { * mit einem anderen Rechtesatz als der Katalog (nur `developer:access`
developerPerm = await prisma.permission.create({ * statt allem) und ohne `isSystem`. Eine so entstandene Rolle waere ueber
data: { resource: 'developer', action: 'access' }, * die Rollenverwaltung aenderbar gewesen.
}); *
* Den Notfallpfad braucht es nicht mehr: `synchronisiereRechteUndRollen`
* legt die Rollen bei jedem Containerstart an, und die Startwache meldet,
* wenn eine fehlt. Fehlt sie hier trotzdem, ist Abbrechen mit klarer Ansage
* ehrlicher, als stillschweigend etwas Aehnliches zu erfinden.
*/
async function setzeVersteckteRolle(
tx: Prisma.TransactionClient,
userId: number,
rollenName: string,
aktiv: boolean,
): Promise<void> {
if (typeof aktiv !== 'boolean') {
throw new UngueltigeEingabeError(`Haken für „${rollenName}" muss true oder false sein`);
}
const rolle = await tx.role.findUnique({ where: { name: rollenName } });
if (!rolle) {
throw new Error(
`Die Rolle „${rollenName}" fehlt in der Datenbank. Sie wird beim ` +
'Containerstart angelegt einmalig nachholen mit: npx tsx prisma/sync-roles.ts',
);
} }
// Get or create Developer role const vorhanden = await tx.userRole.findUnique({
let developerRole = await prisma.role.findFirst({ where: { userId_roleId: { userId, roleId: rolle.id } },
where: { name: 'Developer' },
}); });
if (!developerRole) { // Strikt auf `true`, nicht auf Truthiness. Hier war die zweite Haelfte des
developerRole = await prisma.role.create({ // Bypasses aus R193-02: Der Guard fragte `=== true`, diese Zeile fragte
data: { // "irgendwie wahr". `normalisiereHaken` laesst inzwischen nichts anderes
name: 'Developer', // mehr durch - aber die Lesart darf sich auch dann nicht unterscheiden,
description: 'Entwicklerzugriff auf Datenbanktools', // wenn jemand diese Funktion kuenftig von woanders aufruft.
permissions: { if (aktiv === true && !vorhanden) {
create: [{ permissionId: developerPerm.id }], await tx.userRole.create({ data: { userId, roleId: rolle.id } });
}, } else if (aktiv === false && vorhanden) {
}, await tx.userRole.delete({
where: { userId_roleId: { userId, roleId: rolle.id } },
}); });
} else {
return; // nichts geaendert - dann auch niemanden abmelden
} }
// Check if user already has Developer role // Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens.
const hasRole = await prisma.userRole.findFirst({ await tx.user.update({
where: { userId, roleId: developerRole.id }, where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
}); });
}
if (enabled && !hasRole) { /** Die drei Haken auf ihre versteckten Rollen abbilden. */
await prisma.userRole.create({ async function setzeHaken(
data: { userId, roleId: developerRole.id }, tx: Prisma.TransactionClient,
}); userId: number,
// Token invalidieren bei Rechteänderung haken: Haken,
await prisma.user.update({ ): Promise<void> {
where: { id: userId }, if (haken.hasDeveloperAccess !== undefined) {
data: { tokenInvalidatedAt: new Date() }, await setzeVersteckteRolle(tx, userId, ROLLE_DEVELOPER, haken.hasDeveloperAccess);
}); }
} else if (!enabled && hasRole) { if (haken.hasGdprAccess !== undefined) {
await prisma.userRole.delete({ await setzeVersteckteRolle(tx, userId, ROLLE_DSGVO, haken.hasGdprAccess);
where: { userId_roleId: { userId, roleId: developerRole.id } }, }
}); if (haken.hasAuditOpsAccess !== undefined) {
// Token invalidieren bei Rechteänderung await setzeVersteckteRolle(tx, userId, AUDIT_OPS_ROLLE, haken.hasAuditOpsAccess);
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} }
} }
// Helper to set GDPR access for a user
async function setUserGdprAccess(userId: number, enabled: boolean) {
// Get or create DSGVO role
let gdprRole = await prisma.role.findFirst({
where: { name: 'DSGVO' },
});
if (!gdprRole) {
// Create DSGVO role with all audit:* and gdpr:* permissions
const gdprPermissions = await prisma.permission.findMany({
where: {
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
},
});
gdprRole = await prisma.role.create({
data: {
name: 'DSGVO',
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
permissions: {
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
},
},
});
}
// Check if user already has DSGVO role
const hasRole = await prisma.userRole.findFirst({
where: { userId, roleId: gdprRole.id },
});
if (enabled && !hasRole) {
await prisma.userRole.create({
data: { userId, roleId: gdprRole.id },
});
await prisma.user.update({
where: { id: userId },
data: { tokenInvalidatedAt: new Date() },
});
} else if (!enabled && hasRole) {
await prisma.userRole.delete({
where: { userId_roleId: { userId, roleId: gdprRole.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
@@ -476,42 +579,9 @@ export async function deleteUser(id: number) {
throw new Error('Benutzer nicht gefunden'); throw new Error('Benutzer nicht gefunden');
} }
// Check if user has admin permissions (users:delete means admin) // Dasselbe Kriterium wie beim Bearbeiten: Geschuetzt wird die Faehigkeit,
const isAdmin = user.roles.some((ur) => // das System zu verwalten - nicht die Rolle, die sie ueblicherweise traegt.
ur.role.permissions.some( await pruefeLetzterVerwalter(id, rechteAusRollen(user.roles), null);
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
)
);
if (isAdmin) {
// Count other admins (users with users:delete permission)
const adminCount = await prisma.user.count({
where: {
id: { not: id },
isActive: true,
roles: {
some: {
role: {
permissions: {
some: {
permission: {
resource: 'users',
action: 'delete',
},
},
},
},
},
},
},
});
if (adminCount === 0) {
throw new Error(
'Dieser Benutzer ist der letzte Administrator und kann nicht gelöscht werden'
);
}
}
return prisma.user.delete({ where: { id } }); return prisma.user.delete({ where: { id } });
} }
@@ -542,17 +612,42 @@ export async function getRoleById(id: number) {
}); });
} }
export async function createRole(data: { /**
name: string; * Legt eine Rolle an.
description?: string; *
permissionIds: number[]; * `handelnderId` ist Pflichtparameter, nicht optional: So kann ein Controller
}) { * die Eskalationspruefung nicht vergessen, und der Compiler erzwingt sie bei
* jedem kuenftigen Aufrufer. Bis 09/2026 ging hier `req.body` ungefiltert
* durch - wer `users:create` hatte, konnte eine Rolle mit `developer:access`
* bauen und sie sich anschliessend selbst zuweisen.
*/
export async function createRole(
data: {
name: string;
description?: string;
permissionIds: number[];
},
handelnderId: number,
) {
const name = data.name.trim();
// Kein zweites "Admin". Getrimmt und ohne Ruecksicht auf Gross-/
// Kleinschreibung verglichen, sonst liesse sich " admin " anlegen, das in
// einer Liste wie das Original aussieht.
if (istSystemrollenName(name)) {
throw new RollenSperrError(
`${name}" ist der Name einer Systemrolle und kann nicht neu vergeben werden.`,
);
}
await pruefeTeilmenge(handelnderId, await rechteVonPermissionIds(data.permissionIds));
return prisma.role.create({ return prisma.role.create({
data: { data: {
name: data.name, name,
description: data.description, description: data.description,
permissions: { permissions: {
create: data.permissionIds.map((permissionId) => ({ permissionId })), create: [...new Set(data.permissionIds)].map((permissionId) => ({ permissionId })),
}, },
}, },
include: { include: {
@@ -563,15 +658,50 @@ export async function createRole(data: {
}); });
} }
/**
* Aendert eine Rolle. Systemrollen sind gesperrt.
*
* Geprueft wird nur der ZUWACHS gegenueber dem bisherigen Rechtesatz der
* Rolle - wer Rechte wegnimmt, braucht sie nicht selbst zu besitzen.
*/
export async function updateRole( export async function updateRole(
id: number, id: number,
data: { data: {
name?: string; name?: string;
description?: string; description?: string;
permissionIds?: number[]; permissionIds?: number[];
} },
handelnderId: number,
) { ) {
const bestehend = await prisma.role.findUnique({
where: { id },
include: { permissions: { include: { permission: true } } },
});
if (!bestehend) return null;
if (bestehend.isSystem) {
throw new RollenSperrError(
`${bestehend.name}" ist eine Systemrolle. Sie wird von der Anwendung ` +
'gepflegt und kann hier nicht geändert werden.',
);
}
if (data.name !== undefined && istSystemrollenName(data.name)) {
throw new RollenSperrError(
`${data.name.trim()}" ist der Name einer Systemrolle und kann nicht vergeben werden.`,
);
}
const { permissionIds, ...roleData } = data; const { permissionIds, ...roleData } = data;
if (roleData.name !== undefined) roleData.name = roleData.name.trim();
if (permissionIds) {
const bisher = new Set(
bestehend.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
);
const kuenftig = await rechteVonPermissionIds(permissionIds);
const zuwachs = [...kuenftig].filter((r) => !bisher.has(r));
await pruefeTeilmenge(handelnderId, zuwachs);
}
await prisma.role.update({ await prisma.role.update({
where: { id }, where: { id },
@@ -581,28 +711,69 @@ export async function updateRole(
if (permissionIds) { if (permissionIds) {
await prisma.rolePermission.deleteMany({ where: { roleId: id } }); await prisma.rolePermission.deleteMany({ where: { roleId: id } });
await prisma.rolePermission.createMany({ await prisma.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })), // Dedupliziert: Doppelte IDs im Body liefen vorher in einen
// Primaerschluesselkonflikt und kamen als HTTP 500 zurueck.
data: [...new Set(permissionIds)].map((permissionId) => ({ roleId: id, permissionId })),
skipDuplicates: true,
}); });
// Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens.
await meldeTraegerAb(id);
} }
return getRoleById(id); return getRoleById(id);
} }
export async function deleteRole(id: number) { export async function deleteRole(id: number) {
// Check if role is assigned to any users const bestehend = await prisma.role.findUnique({ where: { id } });
if (!bestehend) {
throw new Error('Rolle nicht gefunden');
}
if (bestehend.isSystem) {
throw new RollenSperrError(
`${bestehend.name}" ist eine Systemrolle und kann nicht gelöscht werden.`,
);
}
// Vor dem Loeschen abmelden - danach sind die Traeger durch den Cascade
// nicht mehr ermittelbar. Greift heute nur theoretisch, weil zugewiesene
// Rollen ohnehin abgelehnt werden; die Reihenfolge bleibt trotzdem die
// richtige, falls diese Sperre je gelockert wird.
const count = await prisma.userRole.count({ where: { roleId: id } }); const count = await prisma.userRole.count({ where: { roleId: id } });
if (count > 0) { if (count > 0) {
throw new Error( throw new Error(
`Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist` `Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist`
); );
} }
await meldeTraegerAb(id);
return prisma.role.delete({ where: { id } }); return prisma.role.delete({ where: { id } });
} }
// Permission operations // Permission operations
/**
* Liefert die vergebbaren Rechte - angereichert um Klartext und Gruppe.
*
* Gefiltert auf den Katalog: In der Datenbank koennen Rechte aus frueheren
* Schemata liegen (`customers:access`, `settings:create` und aehnliche), die
* nirgends geprueft werden. Ungefiltert wuerden sie in der Rollenoberflaeche
* als anhakbare Kaestchen erscheinen, die nichts bewirken - genau der
* Zustand, den dieser Umbau beseitigt. Geloescht werden sie nicht: Ein
* Lesefilter ist die kleinere Behauptung als ein DELETE, und der
* Rechte-Report meldet sie ohnehin.
*/
export async function getAllPermissions() { export async function getAllPermissions() {
return prisma.permission.findMany({ const alle = await prisma.permission.findMany({
orderBy: [{ resource: 'asc' }, { action: 'asc' }], orderBy: [{ resource: 'asc' }, { action: 'asc' }],
}); });
const beschreibung = new Map(
RECHTE_KATALOG.map((r) => [`${r.resource}:${r.action}`, r]),
);
return alle
.filter((p) => beschreibung.has(`${p.resource}:${p.action}`))
.map((p) => {
const k = beschreibung.get(`${p.resource}:${p.action}`)!;
return { ...p, bezeichnung: k.bezeichnung, gruppe: k.gruppe };
});
} }
+25 -3
View File
@@ -8,11 +8,33 @@
* obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits * obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits
* angelegt" eindeutig eine 400-Class-Situation ist. * angelegt" eindeutig eine 400-Class-Situation ist.
*/ */
export class ApiError extends Error { /**
* Basisklasse fuer Fehler, deren Wortlaut fuer den AUFRUFER bestimmt ist.
*
* Der Unterschied ist der Kern von R190-01 und R192-01: Bis 09/2026 reichten
* die Controller jede `error.message` durch. Damit landete erst der
* vollstaendige Prisma-Aufruf samt Serverpfad beim Client und dann der
* Wortlaut eines TypeError. Beide Male dieselbe Ursache - eine Meldung, die
* fuer Entwickler geschrieben ist, an einen Empfaenger, fuer den sie nicht
* gedacht war.
*
* Ab hier gilt: Was von dieser Klasse abstammt (oder ein blankes `Error` ist,
* das wir absichtlich geworfen haben), geht nach draussen. Alles andere -
* TypeError, Prisma, was auch immer - wird protokolliert und durch eine
* allgemeine Auskunft ersetzt.
*/
export class FachlicherFehler extends Error {
readonly statusCode: number; readonly statusCode: number;
constructor(statusCode: number, message: string) { constructor(message: string, statusCode = 400) {
super(message); super(message);
this.name = 'ApiError'; this.name = new.target.name;
this.statusCode = statusCode; this.statusCode = statusCode;
} }
} }
export class ApiError extends FachlicherFehler {
constructor(statusCode: number, message: string) {
super(message, statusCode);
this.name = 'ApiError';
}
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Eine Stelle, an der entschieden wird, was ein Fehler dem Aufrufer sagt.
*
* Vorher stand in 26 Controllern 124-mal dieselbe Zeile:
*
* error: error instanceof Error ? error.message : 'Irgendein Fallback'
*
* Das reicht JEDE Fehlermeldung durch - auch die, die fuer Entwickler
* geschrieben sind. Konkret gefunden wurden ein vollstaendiger Prisma-Aufruf
* samt Serverpfad (R190-01) und der Wortlaut eines TypeError (R192-01). Kein
* Loch fuer sich genommen, aber eine Landkarte: Pfade, Spaltennamen,
* eingesetzte Bibliotheken.
*
* 124 Einzelkorrekturen waeren die falsche Antwort gewesen - das ist die
* Falle aus R186-01 und R188, wo dieselbe Regel in mehreren Kopien lebte und
* auseinanderlief. Deshalb eine Funktion.
*
* Die Unterscheidung laeuft ueber die Fehlerklasse, nicht ueber eine
* Heuristik auf dem Text:
*
* - `FachlicherFehler` (und Abkoemmlinge wie `ApiError`) - absichtlich
* geworfen, Wortlaut fuer den Aufrufer, eigener Statuscode.
* - blankes `Error` - ebenfalls absichtlich geworfen, historisch die
* ueblichste Form ("Der letzte Admin kann nicht..."). Geht mit dem
* uebergebenen Status hinaus.
* - alles andere (TypeError, RangeError, Prisma...) - Programmierfehler oder
* Fremdfehler. Geht als 500 mit der allgemeinen Auskunft hinaus, die
* Einzelheiten ins Protokoll. 500 und nicht 400, weil ein
* Eingabefehler-Code hier die naechste Meldung waere, die sich falsch
* ausgibt.
*/
import { Response } from 'express';
import { FachlicherFehler } from './apiError.js';
export function antworteAufFehler(
res: Response,
error: unknown,
fallback: string,
status = 400,
): void {
if (error instanceof FachlicherFehler) {
res.status(error.statusCode).json({ success: false, error: error.message });
return;
}
if (error instanceof Error && error.constructor === Error) {
res.status(status).json({ success: false, error: error.message });
return;
}
console.error(`[${fallback}] unerwarteter Fehler:`, error);
res.status(500).json({ success: false, error: fallback });
}
+20
View File
@@ -640,6 +640,10 @@ const USER_UPDATABLE_FIELDS = [
'firstName', 'firstName',
'lastName', 'lastName',
'isActive', 'isActive',
// Kennzeichnet planmaessig arbeitende Konten (z. B. das Gegenbuch). Ihre
// Anmeldungen werden als Routine statt CRITICAL gefuehrt, dafuer wacht der
// Heartbeat-Monitor ueber ihr AUSBLEIBEN (Pentest R182/R183).
'isServiceAccount',
'whatsappNumber', 'whatsappNumber',
'telegramUsername', 'telegramUsername',
'signalNumber', 'signalNumber',
@@ -650,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
@@ -837,6 +845,18 @@ export function pickUserCreate(body: unknown): Partial<Record<string, unknown>>
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true }); return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
} }
// Rollen-Whitelist.
//
// `createRole`/`updateRole` reichten `req.body` bis 09/2026 ungefiltert an
// Prisma durch - dasselbe Muster wie R110, nur an der Stelle, an der
// festgelegt wird, wer was darf. `isSystem` und `isHidden` duerfen NIEMALS
// aus dem Request kommen: Sie sind die Sperre selbst, nicht ihr Gegenstand.
const ROLE_UPDATABLE_FIELDS = ['name', 'description', 'permissionIds'] as const;
export function pickRoleUpdate(body: unknown): Partial<Record<string, unknown>> {
return pick((body as object) || {}, ROLE_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
}
// ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ==================== // ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ====================
// Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten // Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten
// `req.body` ungefiltert an Prisma durch gleiches Muster wie das // `req.body` ungefiltert an Prisma durch gleiches Muster wie das
+859
View File
@@ -97,6 +97,865 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **🧹 Aufräumrunde vor Etappe 2: drei Nachrangpunkte** (2026-09-09)
**1. R192-01 projektweit — interne Fehlermeldungen an den Client**
- Das Muster `error instanceof Error ? error.message` stand **124-mal in 26
Controllern**. Jede dieser Stellen konnte Serverpfade, Spaltennamen und
Bibliotheksinterna ausliefern — kein Loch für sich, aber eine Landkarte.
- Zentral gelöst statt 124-mal einzeln (die Falle aus R186-01/R188):
`src/utils/fehlerAntwort.ts` mit `antworteAufFehler()`. Jetzt **123
Aufrufe in 26 Dateien**, eine Regel.
- Die Unterscheidung läuft über die **Fehlerklasse**: Neue Basisklasse
`FachlicherFehler` (in `utils/apiError.ts`) für alles, dessen Wortlaut
für den Aufrufer bestimmt ist — `ApiError`, `RechteEskalationError` (403),
`RollenSperrError` (403), `UngueltigeEingabeError` (400), `FilterFehler`,
`ReferralError` stammen jetzt davon ab. Ein blankes `Error` gilt weiter
als absichtlich. Alles andere (TypeError, Prisma, JWT-Bibliothek) → **500**
mit allgemeiner Auskunft, Einzelheiten ins Protokoll.
- Mit gefunden: Sechs Stellen in `cachedEmail.controller.ts` **interpolierten**
die interne Meldung in den Antworttext (`Fehler beim Speichern: ${msg}`) —
die hätte kein Klassenfilter erwischt, der nur das Feld ersetzt.
Und `POST /auth/refresh` gab den Wortlaut der JWT-Bibliothek zurück
(„jwt malformed", „invalid signature") — der sagt einem Angreifer, **woran**
sein Token gescheitert ist. Geht jetzt nur noch in den Alarmkanal.
**2. Admin-Heuristik — geschützt wird jetzt die Fähigkeit, nicht ihr Träger**
- Bisher galt „wer `users:delete` hat, ist Admin". Ein Zufallsmerkmal: Wer
Konten anlegen und bearbeiten darf, aber nicht löschen, verwaltet genauso —
zählte aber nicht.
- **Bewusst nicht auf die Rolle „Admin" umgestellt**, wie zunächst
vorgeschlagen: Eine selbst gebaute Rolle mit `users:update` verwaltet
tatsächlich, ein Namenskriterium würde sie übersehen — man könnte dann den
letzten Admin löschen, obwohl die Verwaltungsfähigkeit erhalten bliebe.
- Neu: `UNVERZICHTBARE_FAEHIGKEITEN` = `users:update` („Benutzer verwalten")
und `roles:manage` („Rollen und Rechte pflegen"). Wer die letzte Person mit
einer davon ist, kann sie nicht verlieren — durch Rollenwechsel,
Deaktivierung oder Löschung. Die Meldung nennt die Fähigkeit beim Namen
statt „letzter Administrator".
- Nachgeprüft: Mit drei Trägern ist alles erlaubt; ist einer der letzte,
→ 400 mit Klartext. Deaktivierung zählt als Verlust (ein inaktives Konto
kann nichts, egal welche Rollen drankleben).
- Nebenbei: `updateUser` hashte beim Passwort-Zurücksetzen noch mit Cost 10 —
der letzte Rest der Inkonsistenz aus R191-01.
**3. Portal-Kunden — Trennung festgenagelt, nicht aufgelöst**
- Korrektur meiner eigenen Einordnung: Ich hatte das als „noch zu migrieren"
geführt. Falsch. Kunden bekommen **niemals** operative Rechte; die
Portalansicht ist dafür nicht gebaut und prüft es nicht.
- Die zwei Kopien des festen Arrays in `auth.service.ts` sind jetzt eine
Konstante `PORTAL_RECHTE` mit einem Kommentar, der die Trennung als
Absicht benennt.
- **Harter Riegel im Gate:** `requirePermission` schneidet die Rechte eines
Portal-Zugangs auf `PORTAL_RECHTE` zu — unabhängig davon, was sein Token
behauptet. Heute wirkungslos (sie halten ohnehin nur diese zwei), morgen
die Sicherung: Bisher hätte eine unbedachte Zeile in der Token-Erzeugung
gereicht, um die Trennung zu kippen.
- **Startwache:** `pruefePortalRechte()` meldet beim Start jedes Recht in
`PORTAL_RECHTE`, das nicht Lesen ist. Damit überlebt die Regel den Tag, an
dem jemand „nur kurz" etwas hinzufügt.
- Dateien: `utils/fehlerAntwort.ts` (neu), `utils/apiError.ts`,
`config/rechte-katalog.ts`, `middleware/auth.ts`, `services/auth.service.ts`,
`services/user.service.ts`, `services/rechte.service.ts`,
`services/pflichtrechte.service.ts`, 26 Controller
- [x] **🚨 R193-02: Truthiness-Bypass bei den drei Haken (HIGH)** (2026-09-09)
- Befund der Pentesterin, **Prod-Blocker**: Der Eskalations-Guard fragte
`=== true`, die Zuweisung fragte auf Truthiness. Zwischen diesen beiden
Lesarten passte der Angriff: `{"hasDeveloperAccess":"ja"}` sah für den
Guard nach keinem Rechtezuwachs aus (also kein 403) und für die Zuweisung
nach „gesetzt" (also Rolle drauf). Ein Konto mit `users:update`, das
`developer:access` **nicht** hält, konnte damit einem anderen Konto
Vollzugriff geben — und sich anmelden.
- Damit war die Kernentscheidung aus Etappe 1 ausgehebelt: `developer:access`
und `audit:admin` sollten ausdrücklich **nur über die Kommandozeile**
erstmalig vergebbar sein. Der Bypass machte sie wieder über ein
Browser-Feld erreichbar.
- Es ist exakt das Muster, das ich der Pentesterin eine Runde vorher selbst
beschrieben hatte: *„Eine Absicherung, die nur in einer von zwei Kopien
ankommt, ist keine."* Nur dass es diesmal nicht zwei Dateien waren,
sondern zwei **Auffassungen desselben Wertes** in derselben Datei.
- Fix: `normalisiereHaken()` prüft die drei Felder einmal streng auf Boolean
(alles andere 400), und **Guard und Zuweisung arbeiten danach auf
demselben geprüften Objekt** — es gibt nur noch eine Lesart. Zusätzlich
liest `setzeVersteckteRolle` strikt `=== true` / `=== false`, damit sich
die Lesart auch dann nicht spaltet, wenn die Funktion künftig von
woanders gerufen wird.
- **Gleiche Bauart nebenan gefunden:** `isActive` und `isServiceAccount`
steuern ebenfalls ein Gate, das strikt auf `boolean` prüft — ein `"ja"`
rutschte daran vorbei, ohne das Gate (u. a. die `audit:admin`-Hürde für
das Dienstkonto-Kennzeichen) auszulösen, und lief dann in einen
Prisma-Fehler. Kein Bypass, weil der Schreibvorgang scheiterte, aber ein
500 für eine schlicht falsche Eingabe. Jetzt alle fünf Boolean-Felder
einheitlich geprüft → 400.
- Nachgeprüft: Ihre vier Vektoren plus 30 weitere Kombinationen
(`1`, `"true"`, `"false"`, `{}`, `[1]`, `null`, `"0"`, `-1`, `2.5` × drei
Haken) → **alle 400, Rollen unverändert**. Derselbe Angriff über
`POST /users` → 400, **kein Konto angelegt**. Gegenprobe: Ein Täter, der
`gdpr:*` selbst hält, setzt den Haken mit echtem `true` weiterhin
erfolgreich (200), und `hasDeveloperAccess:true` bleibt 403.
- Dateien: `src/services/rechte.service.ts`, `src/services/user.service.ts`,
`src/controllers/user.controller.ts`
- [x] **🤐 R192-01: Fehlermeldungen, die für Entwickler geschrieben sind** (2026-09-09)
- Befund der Pentesterin: `PUT /users/:id {"roleIds":{}}` gab 400 mit dem
rohen JS-Fehler `object is not iterable (cannot read property
Symbol(Symbol.iterator))`. Kein Wipe, keine Eskalation — dieselbe Familie
wie der Prisma-Pfad-Leak aus R190-01.
- Zwei Ursachen, beide behoben:
1. `normalisiereRollenIds` spreizte `roleIds` ohne `Array.isArray`-Guard.
2. Die Fehlerabbildung reichte **jede** `error.message` durch.
- Die Unterscheidung läuft jetzt über die **Fehlerklasse**, nicht über eine
Heuristik auf dem Text: Ein blankes `Error` werfen wir absichtlich und mit
einer Meldung für Menschen („Der letzte Admin kann nicht…"). `TypeError`
und Verwandte sind Programmierfehler, Prisma-Fehler kommen von außen —
beides sagt dem Aufrufer nichts Nützliches und einem Angreifer zu viel.
Unerwartetes wird jetzt **500** statt 400: Ein Eingabefehler-Code wäre die
nächste Meldung, die sich falsch ausgibt.
- Gegengeprüft, dass mit dem Leck nicht auch das Nützliche wegfällt:
„Rolle kann nicht gelöscht werden, da sie 1 Benutzern zugewiesen ist" → 400,
Systemrollen-Sperre → 403 mit Klartext, Eskalation → 403 mit den konkret
fehlenden Rechten. `{}`, `"abc"`, `5`, `true` als `roleIds` → 400
„roleIds muss eine Liste sein", Zustand unverändert. R190-01/R191-01
grün.
- **⚠️ Offen, größer als der Befund:** Das Muster
`error instanceof Error ? error.message` steht **124-mal in 26
Controller-Dateien**. Behoben ist es nur im Benutzer-/Rollenpfad. Das ist
dieselbe Lage wie bei R188 (181 ungeprüfte IDs) und gehört genauso
zentral gelöst statt 124-mal einzeln — eigene Runde.
- Dateien: `src/services/rechte.service.ts`, `src/controllers/user.controller.ts`
- [x] **🔗 R191-01: Ein PUT, eine Klammer** (2026-09-09)
- Befund der Pentesterin nach dem R190-01-Fix: Nur der Rollentausch lag in
der Transaktion, die drei Haken (DSGVO/Developer/Audit-Betrieb) liefen
**danach** in eigenen Schreibvorgängen. Ein Multi-Feld-PUT war damit nicht
gesamt-atomar — bricht ein Haken-Write ab, steht der Rollenstand schon und
die Haken halb. Kein Verlust, keine Eskalation, aber ein Zwischenstand, den
niemand angefordert hat.
- Fix: Die **gesamte** Schreibphase von `updateUser` in einer
`$transaction` — Benutzerdaten, Rollentausch, alle drei Haken.
`createUser` ebenso: Konto und Haken zusammen.
- **Beim Umbau mitgefunden:** Die drei Haken-Helfer **legten die Rolle bei
Bedarf selbst an**, falls sie fehlte. Das war eine *vierte* Stelle, die
definierte, was „Developer" bedeutet — mit einem anderen Rechtesatz als der
Katalog (nur `developer:access` statt allem) und **ohne `isSystem`**. Eine
so entstandene Rolle wäre über die Rollenverwaltung änderbar gewesen. Der
Notfallpfad ist weg: `sync-roles` legt die Rollen bei jedem Start an, die
Startwache meldet ihr Fehlen, und fehlt sie hier doch, bricht der Vorgang
mit klarer Ansage ab statt etwas Ähnliches zu erfinden. Drei fast gleiche
Funktionen wurden dabei eine.
- **int4-Überlauf** (ihr kosmetischer Nebenbefund): IDs jenseits des
INT-Bereichs werden gar nicht mehr abgefragt, sondern als unbekannt
gemeldet — `9999999999` sagt jetzt „Unbekannte Rollen-ID" statt
„Fehler beim Aktualisieren". Gleiche Behandlung für Rechte-IDs.
- **`createUser` hasht jetzt mit Cost 12** statt 10, wie `seed.ts`. Wieder
eine Härtung, die nur in einer von zwei Kopien angekommen war.
- Nachgeprüft: Rollback bewiesen, indem die DSGVO-Rolle vorübergehend
umbenannt und ein `{roleIds:[23], hasGdprAccess:true}` geschickt wurde →
400 mit Klartext, Rollen **unverändert** (kein Teil-Write). Ihre
Typvektoren `"22"`, `4.5`, `1e3`, `true`, `null`, Riesenzahl → alle 400,
kein Wipe. R190-01-Regression erneut grün.
- Dateien: `src/services/user.service.ts`, `src/services/rechte.service.ts`
- [x] **🧨 R190-01: Rollentausch zerstörte, wo er ablehnte** (2026-09-09)
- Befund der Pentesterin: `updateUser` machte `deleteMany` + `createMany`
**ohne Transaktion und ohne `skipDuplicates`**. Eine doppelte roleId
(`[4,4]`) oder eine erfundene (`[99999]`) kam durch die Teilmengenregel
sie bringt ja keine Rechte mit und schlug erst beim Schreiben fehl,
**nach** dem Löschen. Antwort: HTTP 400. Konto danach: **keine Rolle**.
- Die Antwort log über sich selbst: „400" liest sich als *abgelehnt, nichts
passiert*. Zerstört wurde trotzdem.
- **Die Letzter-Admin-Sperre wurde damit umgangen**, ohne sie anzugreifen:
Sie prüft die *Absicht* (die Admin-Rolle steht im Body → „bleibt Admin"),
der Schreibvorgang scheiterte danach. Aussperrung, Rückweg nur per CLI.
Auch versehentlich auslösbar durch doppelte IDs aus dem Frontend.
- **Es war meine Härtung, die ich nicht mitgenommen habe.** `updateRole`
hatte Dedup und `skipDuplicates` seit Etappe 1 das Geschwister
`updateUser`/`createUser` nicht. Genau das Muster, das ich in derselben
Runde dreimal angeprangert habe: eine Absicherung, die nur in einer von
zwei Kopien ankommt.
- Fix: `normalisiereRollenIds()` prüft **vor** jeder Entscheidung und jedem
Schreibvorgang gegen die Datenbank (unbekannte ID → 400 mit Klartext),
dedupliziert, und der Tausch läuft in `$transaction` mit
`skipDuplicates`. Die Letzter-Admin-Sperre arbeitet damit auf einer Liste,
die auch einlösbar ist.
- **Nebenbefund beim Nachstellen**: Die rohe Prisma-Fehlermeldung ging
wortwörtlich an den Client inklusive Dateipfad des Servers. Wird jetzt
protokolliert statt ausgeliefert.
- Nachgestellt und gegengeprüft: `[22,22]` → 200, Rolle erhalten;
`[99999]` und `[22,99999]` → 400, Rollen **unverändert**; `[-1]` → 400;
`[22,23]` → 200 korrekt gesetzt. `createUser` ebenso, abgelehnte Anlagen
hinterlassen kein Konto.
- Bestätigt dicht gemeldet: Restore/Factory-Reset ohne `roles:manage` → 403,
roleIds+Haken gleichzeitig → 403 ohne Teil-Write, alle vier Vergabewege →
403, isSystem-Sperre inkl. `" aDmIn "` → 403.
- Dateien: `src/services/rechte.service.ts`, `src/services/user.service.ts`,
`src/controllers/user.controller.ts`
- [x] **🔑 Rechtemodell Etappe 1: Katalog begradigt, Selbst-Erhöhung geschlossen** (2026-09-04)
- Vorarbeit für die Rollen-Oberfläche (Etappe 2). Eine Checkbox-Liste über
einem Katalog, der nicht stimmt, wäre 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 prüften in Wahrheit
`providers:*`, `platforms:*` und `settings:*`. Jetzt gaten die Routen auf
ihre eigenen Rechte; 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
ausführen**, und aufgefallen wäre es erst, wenn eine Frist läuft. Jetzt
eine Quelle: `backend/src/config/rechte-katalog.ts`.
- **Nebenbefund 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 Härtung war nur in einer der beiden
Kopien angekommen. Jetzt zufällig, Cost 12, einmalig im Log.
- **Selbst-Erhöhung geschlossen** (der offene Punkt aus dem Audit-Betrieb-
Eintrag vom 26.08.): Neues Recht `roles:manage`, getrennt von `users:*`.
Dazu die Teilmengenregel in `rechte.service.ts` niemand kann ein Recht
weitergeben, das er selbst nicht hält. Sie greift auf **allen vier** Wegen:
Rolle anlegen, Rolle ändern, Rollen zuweisen und die drei Haken
(DSGVO/Developer/Audit-Betrieb). Der Haken-Weg war der wichtigste: Er
brauchte nur `users:create` ein zweites Konto mit „Entwicklerzugriff"
anlegen und sich damit anmelden, und die Developer-Rolle trägt *alle*
Rechte.
- Geprüft wird der **Zuwachs**, nicht der Endzustand: Rechte entziehen bleibt
jederzeit erlaubt, sonst könnte ein Admin einen übernommenen
Developer-Zugang nicht mehr entschärfen. Und die Prüfung liest die Rechte
des Handelnden **frisch aus der Datenbank**, nicht aus dem JWT der ist
bis zu 15 Minuten alt, und ein gerade entzogenes Recht darf nicht im
Nachlauf noch weitergereicht werden.
- **Systemrollen sind gesperrt** (`Role.isSystem`): Admin ließ sich bisher
umbenennen oder leeren und die versteckten Rollen hängen an ihrem
*Namen*. `Role.isHidden` ersetzt die im Frontend hartkodierte Namensliste,
in der „Gegenbuch" fehlte.
- **Rechteänderung wirkt sofort**: `updateRole`/`deleteRole` melden alle
Träger ab. Vorher behielten sie bis zu 15 Minuten die alten Rechte.
- **Werksreset und Backup-Restore** verlangen jetzt zusätzlich
`roles:manage`. Beide löschen alle Rollen und legen ein frisches
`admin@admin.com` an eine Rolle mit nur `settings:update` hätte damit die
ganze Rechtevergabe zurücksetzen und sich anschließend anmelden können.
- **Aussperr-Ausweg**: `npx tsx prisma/rolle-zuweisen.ts <email> <Rolle>`.
Umgeht die Regel bewusst wer Shell-Zugang hat, hat ohnehin die Datenbank;
ein gestohlener Web-Zugang hat ihn nicht. Schreibt einen Eintrag über die
Hash-Kette (nicht roh, sonst risse er eine Lücke) und meldet ab.
Die Startwache nennt diesen Weg jetzt im Klartext.
- **Rollenpflege ist nicht mehr der leiseste Eingriff**: Sie war der einzige
Weg in die Rechtevergabe ohne SecurityEvent, obwohl sie viele Konten auf
einmal trifft. Jetzt `PERMISSION_CHANGED` ebenso für die Haken DSGVO und
Entwicklerzugriff, die bisher stumm waren.
- **Nachgeprüft** (Dev-Datenbank + frische Wegwerf-Datenbank): 7 Eskalations-
wege → alle 403, 2 Gegenproben → erlaubt; 6 Sperrtests auf Systemrollen →
alle 403, eigene Rollen weiter änder- und löschbar; Stammdaten-Lesen für
Mitarbeiter unverändert 200; Rechteänderung → sofort 401; Migration und
`sync-roles` dreimal hintereinander → identischer Bericht; `db:seed` auf
bestehender Datenbank → Rollenmatrix unverändert; frische Installation →
alle 8 Systemrollen, keine Hinweise.
- **Neu**: `prisma/rechte-report.ts` Bestandsaufnahme zum Vorher/Nachher-
Vergleich. Läuft auf beiden Seiten der Migration. Meldet auch Waisen: acht
Rechte aus alten Schemata (`*:access`, `settings:create/delete`) liegen
noch in der Datenbank und bewachen nichts. Nicht gelöscht, sondern am
Lesezugriff gefiltert ein Filter ist die kleinere Behauptung als ein
DELETE.
- **Nachtrag (Staging-Deploy)**: Die drei CLI-Skripte bauten sich einen
eigenen `PrismaClient` und gingen damit an `src/lib/prisma.ts` vorbei dort
wird `DATABASE_URL` aus den `DB_*`-Teilen zusammengesetzt, wenn sie fehlt.
Genau das ist bei `docker compose exec` der Fall: Der Entrypoint exportiert
sie nur in den Serverprozess. Folge: „Environment variable not found".
Betroffen war auch `rolle-zuweisen.ts` der Aussperr-Ausweg hätte in dem
Moment versagt, für den er gebaut ist. Jetzt nutzen alle drei den
gemeinsamen Client.
- **Beim Deploy**: Die Migration beendet alle Sitzungen einmalig
(`tokenInvalidatedAt`). Nötig, weil die Rechte im Token stehen sonst
liefen bis zu 15 Minuten 403er. Alle müssen sich einmal neu anmelden.
- Dateien: `src/config/rechte-katalog.ts`, `src/services/rechte.service.ts`,
`src/services/rollen-sync.service.ts`, `prisma/rechte-report.ts`,
`prisma/rolle-zuweisen.ts` (alle neu); zwei Migrationen;
`user.service.ts`, `user.controller.ts`, `backup.service.ts`,
`pflichtrechte.service.ts`, `sanitize.ts`, 8 Route-Dateien, 5 Frontend-Seiten
- [x] **🔢 R188: Ungültige IDs im Pfad zentral statt 181-mal** (2026-09-03)
- Meldung der Pentesterin: `GET /api/users/:id` gibt bei nicht-numerischer
ID **500** statt 400 (`/api/users/permissions` trifft `/:id`). Ihr Patch
setzt einen Guard in `getUser`.
- **Berechtigt und größer als gemeldet.** Ihr Fix schließt nebenbei etwas,
das sie nicht beansprucht: `parseInt('12abc')` ergibt **12**, also lieferte
`GET /api/users/12abc` bisher Benutzer 12 aus. Und es waren **181**
ungeprüfte Stellen in 19 Controllern, nicht eine.
- 181 Einzel-Guards wären genau der Fehler aus R186-01 gewesen (drei
Filterlisten, die auseinanderliefen). Stattdessen `router.param()`, an
**einer** Stelle für alle 33 Router registriert über einen `mounte()`-
Helfer, der Prüfung und Einhängen zusammenbindet. Wer künftig `app.use`
schreibt statt `mounte`, umgeht sie nicht versehentlich, sondern sichtbar.
- **Antwort ist 404, nicht 400:** Ein Pfadsegment, das keine ID sein kann,
benennt keine Ressource. Der bestehende Präzedenzfall
(`provider.controller.ts`, Pentest Mai 2026) hatte es genauso entschieden.
- **Eine ältere Heuristik abgelöst** (Pentest Runde 7): Sie blockte
`^\d+[a-zA-Z]+$` im Pfad ihr eigener Kommentar nannte den Grund,
`app.param()` greift nicht auf in Sub-Router gemounteten Routes", und
genau das löst `mounte()`. Sie war **zu eng** (`/users/abc` ging durch →
500) und **zu weit** (ein Einstellungs-Schlüssel `12abc` unter `:key` wurde
geblockt, obwohl das keine ID ist), und sie antwortete 400, wo jetzt 404
steht zwei Antworten für dieselbe Eingabeklasse.
- Getestet über HTTP: `abc`, `permissions`, `12abc`, `6abc`, `0`, `-1`,
`007`, `1e3`, Überlauf, `1;DROP` → alle **404**; `1` → 200. Quer über
sechs Controller gleich. Nicht-ID-Parameter (`roles/list`,
`permissions/list`, `settings/:key`) unverändert.
- Dateien: `backend/src/middleware/routeIds.ts` (neu), `backend/src/index.ts`
- [x] **⚖️ R189-01: DSGVO-Rechte hingen an keinem Konto** (2026-09-03)
- Meldung: `gdpr:*` und `audit:read/export` hängen an den Rollen DSGVO und
Developer die Admin-Rolle hat sie **nicht**, und nach einem frischen Seed
war DSGVO **keinem Konto** zugewiesen. Auskunft nach Art. 15 und Löschung
nach Art. 17 konnte damit niemand ausführen. Ausfall mit Fristwirkung.
- Seed weist `admin@admin.com` jetzt zusätzlich die DSGVO-Rolle zu. Die
Admin-**Rolle** bekommt diese Rechte weiterhin nicht die Trennung aus
R186 bleibt, nur das eine Bootstrap-Konto trägt beides.
- Label ehrlich gemacht: „Voller Zugriff auf alle **Fachfunktionen** (ohne
Audit & Datenschutz dafür die separaten Rollen DSGVO und Audit-Betrieb)".
- **Eigener Fund beim Prüfen ihres Patches:** `seed.ts` vergab an die
DSGVO-Rolle weiterhin `audit:*` **komplett**, inklusive `audit:admin`
also die Bündelung, die in `fc6f39e` aufgelöst wurde. Ich hatte damals zwei
Listen gefunden (`sync-roles.ts`, `user.service.ts`) und die dritte
übersehen. Gerettet hat es nur die Reihenfolge im Containerstart; ein
einzelnes `npm run db:seed` brachte sie zurück. Korrigiert.
- **Der Seed hilft aber nur bei Neuinstallation** (`update: {}`). Deshalb
zusätzlich eine Wache beim Start: Gibt es für `gdpr:export`, `gdpr:delete`
oder `audit:read` **kein aktives Konto**, steht das mit Handlungsanweisung
im Log. Erkennt die *Abwesenheit* einer Fähigkeit dasselbe Muster wie der
Heartbeat. Bewusst nur melden, nicht automatisch vergeben.
- Getestet: frischer Seed → Admin hat `audit:read, audit:export, gdpr:*` und
**kein** `audit:admin`. Rolle entzogen → Warnung erscheint mit allen drei
Rechten. Rolle zurück → still.
- Dateien: `backend/prisma/seed.ts`, `backend/prisma/sync-roles.ts`,
`backend/src/services/pflichtrechte.service.ts` (neu), `backend/src/index.ts`
- [x] **🕳️ Entfernter Protokoll-ANFANG wurde nicht erkannt** (2026-08-26)
- Gefunden bei der Vorbereitung des Prod-Siegels: Die Verkettung wird
zeilenweise gegen die Vorgängerin geprüft die **erste** Zeile hat keine,
also fiel bisher **nichts** auf, wenn ein zusammenhängender Anfang des
Protokolls entfernt wurde. Kein Kettenbruch, kein Befund, `valid` blieb
grün. Die stillste Löschung von allen: Wer die ältesten Einträge loswerden
will, muss nur vorne anfangen.
- Erkennbar ist es trotzdem: Die allererste Zeile wird ohne Vorgänger
geschrieben und trägt einen leeren `previousHash`. Trägt die erste
**vorhandene** Zeile einen Wert, hat es eine Vorgängerin gegeben und die
ist weg. Wird jetzt als Kettenlücke an dieser Zeile geführt, mit derselben
Manifest- und Beglaubigungslogik wie jede andere Lücke.
- Nur bei Prüfung des Gesamtbereichs; mit `fromId` ist ein gefüllter
`previousHash` selbstverständlich (gegengeprüft, kein Fehlalarm).
- Getestet über HTTP: vollständiges Protokoll → `valid:true`; erste drei
Zeilen entfernt → **vorher** unverändert `valid:true`, **jetzt**
`valid:false`, `chainGaps:[4]`, wegen Hash-Version 3 zusätzlich als
Manipulation eskaliert.
- Auf Prod ausgeschlossen: Das Protokoll beginnt bei ID 1 (07.05.2026,
Inbetriebnahme), kein Cleanup, keine Neuberechnung.
- Datei: `backend/src/services/audit.service.ts`
- [x] **⏱️ Neuberechnung der Kette ist jetzt eine eigene Gegenbuch-Alarmbedingung (Pentest R186, Frage b)** (2026-08-26)
- Der Tester fragte präzise: Löst `cleanup``rehash` **ohne** erneutes
Siegeln, über **nicht versiegelten** Inhalt, etwas **Automatisches** aus
oder nur die Prosa in `verify`, die ein Mensch lesen muss?
- **Gemessen statt behauptet:** Es löste bereits aus, aber als *Nebenwirkung*.
Ein Rehash ändert jeden Hash, also stimmt der beglaubigte Kettenkopf nicht
mehr und der bestehende Vergleich schlug an. Funktioniert ist aber ein
Zufallstreffer: Verschöbe sich der Anker, wäre der Melder lautlos weg. Und
die Meldung hieß „Eintrag wurde verändert" statt „die Kette wurde neu
berechnet", also Wirkung statt Ursache.
- Jetzt hängt der Alarm an der Sache selbst: Das Gegenbuch führt
`rehashCount`/`rehashLast` im Buch mit und meldet jede neue Neuberechnung
seit der letzten Beglaubigung mit **exit 2** samt Zeitpunkt, Zeilenzahl
und dem Befund, der unmittelbar davor galt. Dieselbe Lehre wie R184-01
(Gate am Auslöser, nicht an der Wirkung) und R185-01 (Wurzelwechsel statt
`valid`).
- **Reihenfolge geändert, und das war nötig:** Der neue Melder steht **vor**
dem Kopf-Hash-Vergleich, sonst hätte immer die unpräzisere Meldung
gewonnen. Und der Kopf-Vergleich wird nach einer *bestätigten*
Neuberechnung übersprungen sonst wäre die Bestätigung wertlos gewesen,
weil ein Rehash den Kopf zwangsläufig ändert. Beim Bauen aufgefallen, nicht
im Entwurf.
- `NOTARY_REHASH_ACK` wird mit der **ID des Rehash-Eintrags** bestätigt, nicht
mit `true`. IDs steigen streng ein stehen gelassener Wert passt beim
nächsten Vorgang nicht mehr. Analog zu `NOTARY_SEAL_ACK`.
- Ein **fehlender** beglaubigter Eintrag alarmiert weiterhin immer: Bestätigt
wird die Neuberechnung, nicht das Verschwinden von Zeilen.
- Bestandsbücher ohne das neue Feld alarmieren nicht rückwirkend, sondern
setzen die Grundlage mit Ausgabe, statt es stillschweigend zu tun.
- **Getestet mit echtem Gegenbuch gegen ein echtes CRM** (SSH-signiertes
lokales Buch): Wäsche ohne Datenbankzugriff (3 Zeilen gelöscht → Rehash)
→ CRM meldet `valid:true`, `chainGaps:[]`; Gegenbuch **exit 2** und nennt
die Neuberechnung als Ursache. Falsche Ack-ID → weiter exit 2, richtige →
exit 0 mit neuer Grundlage, Folgelauf ruhig.
- Dateien: `tools/audit-notary/notary.mjs`,
`tools/audit-notary/{docker-compose.yml,.env.example,README.md}`
- [x] **🔍 `verify` meldet jetzt, dass die Kette neu berechnet wurde** (2026-08-26)
- **Im Betrieb entdeckt, nicht im Test.** Das Staging-Gegenbuch meldete
„Der beglaubigte Eintrag 5352 existiert nicht mehr". Rekonstruktion aus dem
Protokoll: am 22.08. wurden die Aufbewahrungsfristen auf **0** gesetzt,
zwei Cleanups löschten **3.155** Einträge (id 15356), danach lief ein
**Rehash**. Seither meldet die Prüfung „Alle Einträge sind unverändert und
**lückenlos** verkettet".
- Wahr und praktisch das Gegenteil dessen, was ein Leser mitnimmt. Der
Rehash verknüpft alles neu; die rund **700** Kettenlücken, die davor
bestanden, sind seitdem unsichtbar. Nachweisbar im Vorbefund, den der
Rehash selbst mitschreibt (R170-01) nur schaute den nie jemand an.
- Das Gegenbuch war der einzige Zeuge. Innerhalb des CRM war die Löschung
nicht mehr feststellbar.
- `verifyIntegrity` sammelt jetzt die Rehash-Marker; die Antwort enthält
`rehashes[]` (Zeitpunkt, Anzahl, Signatur, Vorbefund). Die Meldung nennt
sie **immer**, auch im grünen Fall, und der Einstiegssatz lautet dann
„…lückenlos verkettet allerdings erst seit der letzten Neuberechnung".
- **`valid` bleibt unberührt.** Ein Rehash ist eine legitime Maßnahme; ihn
dauerhaft als Befund zu führen wäre der Dauer-Alarm, den wir mit den
beglaubigten Lücken gerade beseitigt haben. Melden, nicht alarmieren.
- **Umgekehrte Beweislast als bei Manifest und Siegel:** Dort zählen nur
signierte Träger, weil ein gefälschter Marker Lücken *wegerklären* könnte.
Hier erzeugt ein Marker eine *Warnung* würden nur signierte zählen,
könnte man einen Rehash unsichtbar machen, indem man seine Signatur
zerstört. Deshalb zählt jeder auswertbare Marker; fehlende Signatur wird
zusätzlich gemeldet.
- Getestet über HTTP gegen eine Wegwerf-DB, mit echtem Rehash über den
regulären Endpunkt: sauberer Vorzustand → „es wurde nichts überdeckt";
Löschung + Rehash (der Staging-Ablauf im Kleinen) → `chainGaps` fällt von
1 auf 0, die Meldung nennt Zeitpunkt und Vorbefund; zerstörte Signatur →
Marker wird weiterhin gemeldet, mit Hinweis.
- Dateien: `backend/src/services/audit.service.ts`,
`backend/src/controllers/auditLog.controller.ts`,
`frontend/src/services/api.ts`,
`frontend/src/pages/settings/AuditIntegrityCard.tsx`
- [x] **📄 `status.txt` nennt jetzt den Grund, nicht nur das Etikett** (2026-08-26)
- In der Statusdatei des Gegenbuchs stand für **jeden** Exit-2 derselbe Satz:
„BEFUND Widerspruch zwischen CRM und Gegenbuch". Ein echter Widerspruch
sah damit genauso aus wie eine quittierpflichtige Erstsiegelung.
- Aufgefallen im Betrieb: Der Betreiber las „BEFUND" auf Staging und konnte
nicht entscheiden, ob er handeln muss obwohl die Kette dort `valid:true`
meldet und der Alarm nur den erwarteten Siegelwechsel betraf. Wer nur die
Statusdatei liest (und dafür ist sie da „damit eine Überwachung sie
abgreifen kann, ohne Logs zu durchsuchen"), bekam ein Etikett ohne Inhalt.
- Der Lauf wird jetzt mitgeschnitten; die erste `ALARM:`-Zeile landet als
`Grund:` in `status.txt`. Ohne Alarm die erste Ausgabezeile.
- Datei: `tools/audit-notary/entrypoint.sh`
- [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)
- Ein Bestandssiegel, das zum Zeitpunkt des Siegelns keinen Altbestand
vorfand, ist rechnerisch tadellos und schützt **nichts**. Gemeldet wurde
trotzdem `intakt` formal richtig, aber es liest sich als Schutzzusage.
- Der Pentester hat Stagings Leersiegel genau so missverstanden und hielt es
für zahnhaltig. Das ist der rote Faden im Kleinen: ein Signal, das
beruhigt, wo nichts abgesichert ist.
- Eigener Zustand `leer` mit eigenem Text („besteht ein Bestandssiegel, das
aber NICHTS umschließt … kein Fehler, aber auch keine Zusage").
Wertung wie `nicht_noetig`: kippt `valid` nicht.
- Dateien: `backend/src/services/audit.service.ts`,
`backend/src/controllers/auditLog.controller.ts`,
`frontend/src/services/api.ts`,
`frontend/src/pages/settings/AuditIntegrityCard.tsx`
- [x] **🔒 `audit:export` gatete nichts Export hing an `audit:read`** (2026-08-26)
- **Bei der Gegenprobe zur neuen Rolle `Gegenbuch` gefunden:** Ein Konto mit
ausschließlich `audit:read` bekam auf `GET /audit-logs/export` **200**.
Die Berechtigung `audit:export` stand im Katalog und in der
Rollenverwaltung und wurde **nirgends** geprüft.
- **Der Unterschied ist nicht kosmetisch.** Blättern zeigt 50 Zeilen; der
Export liefert in einem Zug das gesamte Protokoll inklusive
`changesBefore`/`changesAfter` also der vollständigen Vorher/Nachher-
Datensätze dazu `resourceLabel` mit Klartextnamen, IP-Adressen und
User-Agents. Live nachgewiesen: 43 Einträge mit gefüllter
`resourceLabel` allein für `resourceType=Customer`.
- Damit konnte ausgerechnet das Dienstkonto des Gegenbuchs, dessen Passwort
im Klartext in der `.env` auf der Notar-Maschine liegt, Personendaten
exportieren während README und Rollenname „nur Prüfwerte lesen"
versprachen.
- `/audit-logs/export` verlangt jetzt `audit:export`. Betroffen ist genau
eine Rolle: `Gegenbuch` (gewollt). Die DSGVO-Rolle hat `audit:*`
vollständig und behält den Export.
- Oberfläche: JSON- und CSV-Knopf werden nur noch mit `audit:export`
angezeigt sonst stünden dort Knöpfe, die zuverlässig 403 liefern.
- Dateien: `backend/src/routes/auditLog.routes.ts`,
`frontend/src/pages/settings/AuditLogs.tsx`,
`tools/audit-notary/README.md`
- [x] **🔑 Rolle „Gegenbuch": Leserecht aufs Audit-Protokoll ohne `audit:admin`** (2026-08-26)
- **Beim Selbst-Nachprüfen eines Deploys aufgefallen:** Das Gegenbuch-
Dienstkonto auf Staging meldete beim Login
`["audit:read","audit:export","audit:admin","gdpr:export","gdpr:delete","gdpr:admin"]`.
Es braucht **genau eines** davon `audit:read` denn es ruft nur
`/audit-logs/checkpoint` und `/audit-logs/verify` auf.
- **Kein Bedienfehler, ein Konstruktionsfehler:** Es gab keine Rolle, die
nur Leserecht aufs Protokoll gibt. Wer das wollte, musste den Haken
„DSGVO-Zugriff" setzen und der vergibt `audit:*` komplett, also auch
`audit:admin` mit `seal-backlog`, `rehash`, `cleanup`. Das Label
(„Audit-Logs, Datenschutz") legt Lesen nahe und liefert Vollzugriff.
- **Warum das ernst ist:** Das Passwort des Dienstkontos liegt im Klartext
in `tools/audit-notary/.env` auf der Gegenbuch-Maschine. Mit `audit:admin`
hätte ein Einbruch dort nicht nur den Wächter gehabt, sondern gleich die
Mittel zur Wäsche aus R185-01 und damit genau die Trennung aufgehoben,
wegen der das Gegenbuch überhaupt auf einer eigenen Maschine läuft.
- Neue Rolle **`Gegenbuch`** in `sync-roles.ts` mit ausschließlich
`audit:read`. Läuft beim Containerstart mit, erscheint danach in der
Benutzerverwaltung. README des Gegenbuchs umgeschrieben: Rolle statt
„selbst anlegen", ausdrückliche Warnung vor dem DSGVO-Haken, plus eine
Gegenprobe (`checkpoint` → 200, `seal-backlog` → 403).
- **Offen, bewusst nicht angefasst:** Dass die DSGVO-Rolle selbst
`audit:admin` trägt, ist ein Gewaltenteilungs-Problem wer das Protokoll
beaufsichtigt, kann seine Beweisgrundlage ersetzen. Ändern hieße
bestehenden DSGVO-Konten Rechte entziehen; gehört entschieden, nicht
nebenbei gemacht. An den Pentester gemeldet, dessen nächstes Thema das
Rechtemodell ist.
- Dateien: `backend/prisma/sync-roles.ts`, `tools/audit-notary/README.md`
- [x] **📤 Eingegrenzter Export lieferte alles (Pentest R186-01, MEDIUM)** (2026-08-26)
- Der Tester fand: `GET /audit-logs/export?userId=…` filterte **nicht**
`userId=999999` gab alle 2761 Datensätze zurück, byte-identisch zum
ungefilterten Export. HTTP 200, sah korrekt aus.
- **Ursache war schlimmer als der Befund.** Nicht `userId` allein fehlte:
der Export-Controller pflegte eine **eigene, kürzere** Filterliste und
verwarf still `userId`, `customerId`, `dataSubjectId`, `resourceId`,
`success` **und** `search`. Der Service konnte alle sechs sie kamen
nie bei ihm an.
- **Dieselbe Lücke ein drittes Mal in der Oberfläche:** Der CSV-Knopf baute
seine Parameter nochmal von Hand, mit wieder anderen fünf Feldern. Wer im
Suchfeld eingrenzte und dann CSV klickte, bekam das gesamte Protokoll
statt seiner Auswahl.
- **Warum das mehr ist als ein fehlender Filter:** Ein bewusst eingegrenzter
Export „nur die Spur von Benutzer X" für eine DSGVO-Auskunft oder eine
Innentäter-Prüfung gab das vollständige Protokoll **aller** Nutzer
heraus, mit einem beruhigenden 200. Auf einem datenminimierungspflichtigen
Pfad ist das eine Weitergabe, kein Schönheitsfehler.
- **Fix ist strukturell, nicht punktuell:** ein gemeinsamer `leseFilter(req)`
für Liste und Export; die Oberfläche schickt alle aktiven Filter statt
einer handgepflegten Auswahl. Drei Listen, die dasselbe bedeuten sollen,
laufen früher oder später auseinander jetzt gibt es nur noch eine.
- Geprüft über HTTP: Export und Liste liefern für `userId`, `action`,
`search`, `success`, `resourceType` **identische** Treffermengen; auf dem
Export-Pfad gilt jetzt dieselbe Validierung (`userId=abc` → 400 statt
200). CSV-Pfad gegengeprüft.
- Dateien: `backend/src/controllers/auditLog.controller.ts`,
`frontend/src/pages/settings/AuditLogs.tsx`
- [x] **🚨 Siegelwechsel ist ein Alarm, kein Hinweis + Filter-Validierung (Pentest R185)** (2026-08-26)
- **R185-01 (MEDIUM)** Die Flanke, die wir dem Tester selbst gemeldet
hatten, hat er live bestätigt: `seal-backlog` war beim **zweiten** Aufruf
genauso gegatet wie beim ersten (`{"confirm":"SEAL"}` → 200), und das
Ereignis landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
automatische Rückhalt des Gegenbuchs hängt an `valid` und `valid`
überlebt ein ersetzendes Siegel **per Konstruktion**. Angriff: Altzeile
per DB-Zugriff löschen → neu siegeln → Lücke ist beglaubigt, `valid`
wieder `true`. **Live reproduziert.**
- Zwei Schichten, in seiner Reihenfolge:
1. **Alarmkanal.** Neuer `SecurityEventType` `AUDIT_SEAL_CHANGED` (Migration
`20260826120000`). Erstes Siegeln → HIGH, Ersetzen → **CRITICAL** (geht
damit über `sendPendingCriticalAlerts` sofort per Mail raus). Die
Ereignis-Details halten Wurzel vorher/nachher und den vollständigen
Vorbefund fest.
2. **Gate.** Steht bereits ein Siegel, verlangt der Endpunkt
`{"confirm":"RESEAL"}` statt `SEAL` mit einem Text, der sagt, was
dabei verloren geht. Ein Austausch der Beweisgrundlage soll nicht
dasselbe Wort haben wie das Einrichten.
- **Und im Gegenbuch selbst:** Dort stand für den Wurzelwechsel ein
`console.warn`, während der Rückgabecode auf **0** blieb also exakt das
Muster, das wir dem CRM zweimal angekreidet haben (R179/R183-02), im
Werkzeug, das dagegen gebaut wurde. Jetzt **exit 2**, mit alter und neuer
Wurzel samt Blattzahl (`10 Blätter → 9 Blätter` zeigt die Löschung
sofort). Auch die Erstsiegelung meldet sich, statt stillschweigend
übernommen zu werden.
- **Auflösbar gemacht:** Der Alarm bricht ab, *bevor* angehängt wird ohne
Bestätigungsweg hätte auch ein legitimes Siegeln für immer alarmiert
(R183-03-Falle). Neu: `NOTARY_SEAL_ACK`. Bewusst **nicht** `true`, sondern
die **Wurzel selbst** (mind. 16 Zeichen): ein stehen gelassener Wert passt
beim nächsten Wechsel nicht mehr und kann keinen weiteren Austausch
durchwinken der Unterschied zu `NOTARY_GENESIS_ACK`, wo genau diese
Falle dokumentiert werden musste.
- **R185-02 (LOW)** `GET /api/audit-logs?action=<x>`: ungültige Enum-Werte
gingen roh an die Spalte → **500**. Zweifach schlecht: fehlende
Validierung *und* Fehler-Orakel (200 vs. 500 verrät die Enum-Mitglieder).
Jetzt 400 mit der erlaubten Menge im Klartext die steht ohnehin in der
Oberfläche. Gleich mitgenommen: `sensitivity`, Datumsfelder
(`new Date('foo')` → Invalid Date → 500), Zahlenfelder (`parseInt` → NaN),
Textlängen, und ein Deckel auf `limit` (200), über den sich sonst die
ganze Tabelle an der Seitenlogik vorbei ziehen ließ. Beide Endpunkte
(`/audit-logs` und `/audit-logs/export`).
- **Getestet über HTTP gegen eine Wegwerf-DB**, inkl. echtem Gegenbuch-Lauf
mit SSH-signiertem lokalem Repo: Erstsiegeln mit `SEAL` → 200; zweites mit
`SEAL`**400**; mit `RESEAL` → 200; beide Alarmkanal-Ereignisse mit
korrekter Severity vorhanden. Wäsche (Zeile 7 gelöscht → RESEAL) → CRM
meldet `valid:true`, **Gegenbuch exit=2**. Bestätigung: falsche Wurzel →
weiter exit 2, zu kurzer Wert → weiter exit 2, richtige Wurzel → exit 0
und beglaubigt, Folgelauf ruhig.
- **Nachgeholt, was der Tester nicht herstellen konnte:** vollständig
unsigniertes Protokoll (Platzhalter-`AUDIT_HMAC_KEY`) → `kein_siegel`
statt der früheren falschen Entwarnung `nicht_noetig`, und
`seal-backlog` nennt den fehlenden Schlüssel als nächsten Schritt die
Warnung ist also auflösbar. Gegenrichtung (Log beginnt signiert) →
weiterhin `nicht_noetig`, R183-03 bleibt behoben.
- Dateien: `backend/src/controllers/auditLog.controller.ts`,
`backend/prisma/schema.prisma` + Migration, `tools/audit-notary/notary.mjs`,
`tools/audit-notary/{docker-compose.yml,.env.example,README.md}`,
`frontend/src/services/api.ts`, `frontend/src/pages/settings/Monitoring.tsx`
- [x] **👁️ Integritätsstatus in der Oberfläche (Einstellungen → Audit-Protokoll)** (2026-08-26)
- Bisher war der Zustand der Hash-Kette nur per `POST /api/audit-logs/verify`
einsehbar also praktisch nur für das Gegenbuch und für jemanden mit
`curl`. Jetzt steht er oben auf der Audit-Seite.
- Vier Zustände statt „grün/rot": **unversehrt**, **Befund**, **unversehrt
aber ungeschützter Altbestand** (`kein_siegel`), **nicht vollständig
prüfbar** (signierte Zeilen ohne `AUDIT_HMAC_KEY`).
- Die beiden mittleren Zustände sind bewusst nicht grün. Ein Protokoll mit
unversiegeltem Altbestand ist rechnerisch stimmig, aber am Altbestand
unbemerkt änderbar; ein Protokoll, das mangels Schlüssel nicht prüfbar ist,
ist schlicht ungeprüft. Beides als „alles in Ordnung" zu zeigen wäre
genau die Klasse Fehler, die diese ganze Runde behandelt hat.
- Schlägt die Prüfung selbst fehl, steht dort ausdrücklich: *„Das ist keine
Entwarnung der Zustand der Kette ist damit schlicht unbekannt."*
- Aufklappbare Einzelheiten trennen die Kategorien, die nicht gleich schwer
wiegen: nachträglich verändert (ernst) / Verkettung unterbrochen /
davon ohne dokumentierte Löschung / davon vom Siegel beglaubigt /
ohne Schlüssel nicht prüfbar mit einer Erklärung im Klartext darunter.
- Die Prüfung liest die **gesamte** Kette. Sie läuft deshalb einmal beim
Öffnen der Seite und wird 5 Minuten wiederverwendet; „Neu prüfen" erzwingt
einen frischen Lauf.
- Bewusst **read-only**: kein Siegel- oder Rehash-Knopf. Diese Eingriffe
verlangen `audit:admin` und eine ausdrückliche Bestätigung; sie gehören
nicht neben eine Statusanzeige, die man im Vorbeigehen anklickt.
- Dateien: `frontend/src/pages/settings/AuditIntegrityCard.tsx` (neu),
`frontend/src/pages/settings/AuditLogs.tsx`, `frontend/src/services/api.ts`
(Typ `IntegrityResult` ausgelagert)
- [x] **🧾 Beglaubigte Alt-Lücken: Dauer-Alarm im Gegenbuch beendet** (2026-08-26)
- **Ausgangslage.** Das Gegenbuch auf Prod meldete stündlich `exit=2`. Die
CRM-Prüfung lieferte `valid: false` wegen **6 struktureller Lücken**
(IDs 33, 44, 45, 922, 1434, 2583).
- **Diagnose: harmlos.** Jede Lücke liegt innerhalb eines Schwungs von
Einträgen mit **identischer Sekunde**, betrifft nur `/login` und
`/refresh`, kein Eintrag fehlt (kein 404), `tamperedEntries` leer. Das ist
die Signatur der Race-Condition, die am 19.08. mit `AuditChainLock`
geschlossen wurde (R166-01). Bestätigt durch die Zeilen selbst: Eintrag
2583 stammt vom 21.08., ist aber noch `hashVersion=1` auf Prod lief zu
dem Zeitpunkt also der alte Stand.
- **Das eigentliche Problem war nicht die Lücke, sondern der Dauer-Alarm.**
Diese Lücken sind nicht heilbar: die Verkettung ist gebrochen, die Inhalte
sind unversehrt. Ohne Änderung hätte das Gegenbuch für immer Alarm gemeldet
und ein Signal, das immer schreit, warnt nicht mehr. Dieselbe Klasse wie
R162, R174, R179, R182, R183-02.
- **Lösung: Beglaubigung statt Unterdrückung.** Eine Lücke zählt nicht mehr
als offener Befund, wenn beides gilt: sie liegt im **versiegelten Bereich**
UND steht im **Vorbefund des Siegel-Markers**, also im Zustand, den der
Betreiber beim Siegeln ausdrücklich festgeschrieben hat. Der Vorbefund
liegt in `changesBefore` und ist ab Version 3 mitgehasht die Liste lässt
sich ohne `AUDIT_HMAC_KEY` nicht nachträglich erweitern (gleiche
Absicherung wie beim Löschungs-Manifest, R171-01).
- Beglaubigt heißt **nicht verschwunden**: die Lücken bleiben in `chainGaps`,
stehen zusätzlich in neuem Feld `attestedGaps` und werden im Bericht
ausdrücklich benannt.
- **Nebenbefund derselben Klasse mitbehoben:** `backlogSealStatus` meldete
`nicht_noetig` ("Es gibt keine unsignierten Alteinträge"), wenn
`v3FromId === null` das bedeutet aber das **Gegenteil**: dass überhaupt
nichts signiert ist, etwa weil `AUDIT_HMAC_KEY` fehlt. Ein vollständig
unsigniertes Log bekam damit Entwarnung für genau den Zustand mit der
geringsten Beweiskraft. Die Bedingung hängt jetzt an der Zahl der
unsignierten Zeilen. R183-03 (unauflösbare Warnung bei frisch signiertem
Log) bleibt behoben per Regressionstest geprüft.
- **Getestet über HTTP gegen eine eigene Wegwerf-Datenbank**, nicht am
Service vorbei: Prod-Zustand nachgebaut (10 v1-Zeilen, Bruch bei id 5) →
`valid:false`; nach `seal-backlog``valid:true`, `attestedGaps:[5]`.
Drei Gegenproben: **neue Lücke** nach dem Siegeln → `valid:false`;
**gesiegelte Altzeile verändert** → Siegel `gebrochen`, nichts mehr
beglaubigt; **Beglaubigungsliste im Marker gefälscht** (DB-Schreibrecht,
kein Schlüssel) → Marker ungültig, Status `entfernt`, `valid:false`.
- Doku: Abschnitt „Wenn der erste Lauf `exit=2` meldet" in
`tools/audit-notary/README.md` inklusive der Warnung, **vor** dem
Siegeln zu prüfen, was man da festschreibt.
- Dateien: `backend/src/services/audit.service.ts`,
`backend/src/controllers/auditLog.controller.ts`,
`frontend/src/services/api.ts`, `tools/audit-notary/README.md`
- [x] **🔓 Dienstkonto-Flag: Gate in beide Richtungen, richtige Rechte-Domaene (Pentest R184-01/-02)** (2026-08-24)
- **R184-01** Setzen war gegatet, **Entfernen nicht**. Und das Entfernen ist
der gefaehrlichere Weg: Der Heartbeat-Wachhund fragt `isServiceAccount:
true` ab, haengt also am **Live-Kennzeichen**. Seine Hypothese war damit
richtig Un-Flaggen kappt die Wache und macht genau das moeglich, wogegen
der Tripwire gebaut wurde: stilllegen und auf Stille setzen.
Fix: Gate in beide Richtungen, mit unterschiedlichem Wortlaut (beim
Entfernen: „faellt aus der Ueberwachung heraus“).
- **Wichtiger noch:** Die Aenderung geht jetzt zusaetzlich in den
**Alarmkanal** (`PERMISSION_CHANGED`/CRITICAL), nicht nur ins Audit-Log.
Eine CRITICAL-Zeile muss jemand LESEN das war die R183-02-Klasse. Jetzt
reagiert die Ueberwachung automatisch.
- **R184-02** Das Kennzeichen hing an `users:update`, einer
Anwendungs-Berechtigung, obwohl es ein Audit-Governance-Eingriff ist
Geschwister von retention-shorten, seal-backlog, rehash und cleanup, die
alle `audit:admin` verlangen. Heute deckungsgleich, aber jede kuenftige
Rolle mit „Benutzer bearbeiten“ haette still „Login-Alarme herunterstufen“
geerbt. Fix: zusaetzliche Pruefung auf `audit:admin` im Controller.
- Verifiziert: Setzen mit `audit:admin` 200 · Entfernen ohne Bestaetigung
**400** (vorher 200) · mit Bestaetigung 200 · ohne `audit:admin` **403** ·
zwei CRITICAL-Meldungen im Alarmkanal mit sprechendem Text. Frontend sendet
die Bestaetigung in beide Richtungen. `npm run build` und Backend-`tsc`
gruen.
- Seine Non-Findings uebernommen: Selbst-Block nicht ueber Methode, Pfad oder
ID-Aliasing umgehbar; Create streift das Feld ab; Confirm-Gate strikt.
- [x] **🔒 Dienstkonto-Kennzeichen gegatet, laut protokolliert, kein Selbstbedienen (Pentest R184)** (2026-08-24)
- Der Pentester hat sofort erkannt, was das Scharfschalten des Feldes
bedeutet: Ein Attribut, das die **Alarmstufe senkt**, war ueber den
normalen Benutzer-Update-Pfad setzbar dieselbe Klasse wie R183-01,
nur neu gebaut. Wer sein eigenes Konto so markiert, laesst die eigenen
auffaelligen Anmeldungen als Routine erscheinen. Das ist die Waesche.
- Ist-Zustand vor dem Fix, alle drei seiner Sorgen bestaetigt: kein Gate,
Protokollierung nur als MEDIUM (Standardstufe fuer `User`), und jeder mit
`users:update` konnte es auf jedes Konto setzen auch auf das eigene.
- Fix, analog zur Retention-Absenkung:
* **Bestaetigung** `{"confirm":"SERVICE_ACCOUNT"}` beim Aktivieren, mit
Klartext, was das bedeutet.
* **CRITICAL** statt MEDIUM, mit eigenem Label („Dienstkonto-Kennzeichen
GESETZT fuer … Anmeldungen werden kuenftig als Routine gefuehrt“) sowie
Wer/Vorher/Nachher.
* **Kein Selbstbedienen**: am eigenen Konto ist das Kennzeichen weder
setzbar noch entfernbar (403 mit Begruendung) muss ein anderer
Administrator vornehmen.
- Frontend sendet die Bestaetigung mit; der Haken im Formular IST die
Bestaetigung, der Betreiber merkt nichts davon.
- Verifiziert ueber den echten Controller: ohne Bestaetigung 400, mit 200,
am eigenen Konto 403, Protokolleintrag CRITICAL mit sprechendem Label.
`npm run build` (inkl. `tsc`) und Backend-`tsc` gruen.
- [x] **🖱️ Dienstkonto-Kennzeichen in der Benutzerverwaltung** (2026-08-24)
- Nachgezogen: Das Feld `isServiceAccount` lag zwar in der Datenbank, war
aber **nirgends setzbar** weder im Formular noch ueber die API. Der
Betreiber haette es nicht aktivieren koennen, damit waere die
R182-Einstufung wirkungslos geblieben.
- Jetzt: Ankreuzfeld „Dienstkonto“ im Benutzerformular unter den
zusaetzlichen Berechtigungen, mit Erklaerung im Klartext (planmaessige
Anmeldungen als Routine, dafuer Meldung bei AUSBLEIBEN). Feld in der
Whitelist (`USER_UPDATABLE_FIELDS`, damit auch beim Anlegen), im
Service-Typ und in allen drei `select`-Bloecken, damit es beim Bearbeiten
vorbelegt wird.
- Verifiziert: Whitelist laesst das Feld durch und blockt Fremdfelder
weiterhin; Wert ueber Prisma lesbar; `tsc` und `vite build` gruen.
- [x] **🚨 Retention-Governance + Heartbeat-Wachhund (Pentest R183, R182-Rest)** (2026-08-24)
- **R183-01 (HIGH)** Die geladene Waffe war ungegatet, der Abzug gegatet:
`PUT /retention-policies/{id}` nahm `retentionDays: 0` **ohne
Bestaetigung** an und protokollierte es als MEDIUM waehrend cleanup,
seal und rehash alle ein Confirm-Gate haben und die FOLGE CRITICAL ist.
Die ausloesende Tat war leiser als ihre Wirkung.
Fix: Absenken verlangt `{"confirm":"SHORTEN"}`, wird als **CRITICAL** mit
Vorher/Nachher protokolliert („Aufbewahrung VERKUERZT … 730 → 0 Tage“), und
fuer `Authentication`/`AuditLog` gilt eine **Untergrenze von 30 Tagen**.
`logChange` nimmt dafuer jetzt Sensitivitaet und Vorzustand entgegen.
- **R183-02 (HIGH)** Nach dem Cleanup meldete verify
„**Keine Manipulation.** 656 strukturelle Luecken (alle durch protokollierte
Loeschungen erklaert)“ bei `valid: false` und 3010 endgueltig geloeschten
Anmeldeprotokollen. Der Befund stand nur im Feld, die Prosa beruhigte.
Dieselbe Alarm-Muedigkeit wie R162/R182, diesmal im Verifizierer selbst.
Fix: Bei Luecken heisst es jetzt „Die Kette ist nicht mehr lueckenlos …
geloeschte Eintraege lassen sich naturgemaess nicht mehr pruefen“.
Zusaetzlich wertet das **Gegenbuch `valid:false` hart** es ruft
`/verify` mit und schlaegt Alarm, egal wie der Text klingt.
- **R183-03 (MEDIUM)** verify warnte dauerhaft „Altbestand nicht
versiegelt“, waehrend seal-backlog zu Recht ablehnte („kein Altbestand“).
Eine Warnung, die niemand aufloesen kann, lernt man zu ignorieren.
Fix: eigener Zustand `nicht_noetig` mit Klartext; die echte Warnung nennt
jetzt den Befehl zum Beheben.
- **Heartbeat-Wachhund gebaut** (seine wichtigere Haelfte): Bleibt ein
Dienstkonto laenger still als `SERVICE_ACCOUNT_MAX_SILENCE_MINUTES`
(Standard 180), gibt es `SUSPICIOUS/CRITICAL`. Ohne je gesehene Anmeldung
wird geschwiegen statt geraten; pro Ausfall wird genau einmal gemeldet.
Verifiziert: keine Grundlinie → still; 500 Minuten Stille → Meldung;
Wiederholung → keine Dublette.
- Seine Non-Findings bestaetigt uebernommen: Confirm-Gates gegen neun
Umgehungsvarianten dicht, rehash-Protokoll ehrlich, Tombstone-Mechanik und
Anker-Backstop intakt.
- [x] **🔧 Gegenbuch-Container: Rechte am Bind-Mount selbst geraderuecken** (2026-08-22)
- Fehlerbild aus dem echten Betrieb: `mkdir: cannot create directory
'/gegenbuch/schluessel': Permission denied`, Container in der
Neustart-Schleife. Ursache: Das Datenverzeichnis kommt als Bind-Mount vom
Host; der Betreiber hatte das Projekt als **root** geklont, der Container
lief aber direkt als UID 1000 und durfte dort nichts anlegen.
Mein `.gitkeep`-Ansatz hatte stillschweigend angenommen, dass als normaler
Benutzer geklont wird auf einem Server ist root der Normalfall.
- Fix: Der Container startet als root, setzt `/gegenbuch` per `chown` auf den
Arbeitsbenutzer (`PUID`/`PGID`, Standard 1000) und startet sich per
`setpriv` als dieser neu. Die eigentliche Arbeit laeuft damit weiterhin
unprivilegiert. Schlaegt das `chown` fehl (z. B. rootless Docker), gibt es
einen Hinweis mit dem passenden Host-Befehl statt eines stummen Abbruchs.
- Verifiziert mit exakt der Ausgangslage: Datenverzeichnis auf `root:root`
gesetzt, Container gestartet → laeuft durch, Schluessel und Buch werden
angelegt, erzeugte Dateien gehoeren 1000:1000, der private Schluessel liegt
mit `0600`.
- [x] **🔑 Gegenbuch: Dienstkonto statt Token, .env mit gueltigen Werten** (2026-08-22)
- **Blocker gefunden und behoben:** Ich hatte ein dauerhaftes API-Token
vorausgesetzt das gibt es in OpenCRM gar nicht. Zugangstoken leben
**15 Minuten**; der Gegenbuch-Container waere nach dem ersten Durchlauf
gestorben. Aufgefallen erst, als der Betreiber fragte, woher er den Token
nimmt.
Loesung: Das Gegenbuch meldet sich bei **jedem Lauf selbst an** mit einem
eigenen Benutzerkonto, dessen Rolle ausschliesslich `audit:read` traegt.
Damit kann es nur Pruefwerte lesen: keine Kundendaten, keine Aenderungen.
`CRM_TOKEN` bleibt fuer Tests moeglich, ist aber nicht mehr der Normalweg.
- Fehlerfaelle sauber gemeldet: falsches Passwort → Klartext statt HTTP-Code,
Anmelde-Bremse (429) benannt, CRM nicht erreichbar unterschieden.
- **`.env.example` nennt jetzt die gueltigen Werte** bisher liess sich nur
raten, ob es `prod` oder `production` heisst. Fuer jeden Schalter steht
dabei, was erlaubt ist, inkl. des Falls „erst nur Staging testen, Prod
spaeter dazunehmen“ (`COMPOSE_PROFILES=staging``prod,staging`).
- Beide READMEs um „Zugang einrichten“ ergaenzt: Rolle mit nur `audit:read`,
Benutzer damit, Zugangsdaten in die `.env`. Mit dem Hinweis, dass jede
Anmeldung im Audit-Log erscheint gewollt, denn so sieht man auch, wenn
das Gegenbuch aufhoert zu arbeiten.
- Verifiziert gegen eine Attrappe, die wie das echte CRM eine Anmeldung
verlangt: Anmeldung + Lauf erfolgreich, falsches Passwort → verstaendliche
Meldung, exit 1.
- [x] **🐳 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine** (2026-08-22) - [x] **🐳 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine** (2026-08-22)
- Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer **eigenen Maschine** - Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer **eigenen Maschine**
fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch fuer Prod und Staging; ein externes Git-Repository entfaellt, das Buch
+6 -4
View File
@@ -26,28 +26,28 @@ export default function Settings() {
icon: Clock, icon: Clock,
title: 'Kündigungsfristen', title: 'Kündigungsfristen',
description: 'Konfigurieren Sie die verfügbaren Kündigungsfristen für Verträge.', description: 'Konfigurieren Sie die verfügbaren Kündigungsfristen für Verträge.',
show: hasPermission('platforms:read'), show: hasPermission('cancellation-periods:read'),
}, },
{ {
to: '/settings/contract-durations', to: '/settings/contract-durations',
icon: Calendar, icon: Calendar,
title: 'Vertragslaufzeiten', title: 'Vertragslaufzeiten',
description: 'Konfigurieren Sie die verfügbaren Laufzeiten für Verträge.', description: 'Konfigurieren Sie die verfügbaren Laufzeiten für Verträge.',
show: hasPermission('platforms:read'), show: hasPermission('contract-durations:read'),
}, },
{ {
to: '/settings/providers', to: '/settings/providers',
icon: Building2, icon: Building2,
title: 'Anbieter & Tarife', title: 'Anbieter & Tarife',
description: 'Verwalten Sie Anbieter und deren Tarife für Verträge.', description: 'Verwalten Sie Anbieter und deren Tarife für Verträge.',
show: hasPermission('providers:read') || hasPermission('platforms:read'), show: hasPermission('providers:read') || hasPermission('tariffs:read'),
}, },
{ {
to: '/settings/contract-categories', to: '/settings/contract-categories',
icon: FileType, icon: FileType,
title: 'Vertragstypen', title: 'Vertragstypen',
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).', description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
show: hasPermission('platforms:read'), show: hasPermission('contract-categories:read'),
}, },
{ {
to: '/settings/credit-note-number-range', to: '/settings/credit-note-number-range',
@@ -180,6 +180,7 @@ export default function Settings() {
</div> </div>
</div> </div>
</Link> </Link>
{hasPermission('email-providers:read') && (
<Link <Link
to="/settings/email-providers" to="/settings/email-providers"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group" className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
@@ -197,6 +198,7 @@ export default function Settings() {
</div> </div>
</div> </div>
</Link> </Link>
)}
<Link <Link
to="/settings/database-backup" to="/settings/database-backup"
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group" className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
@@ -0,0 +1,209 @@
import { useQuery } from '@tanstack/react-query';
import { auditLogApi, IntegrityResult } from '../../services/api';
import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button';
import {
ShieldCheck, ShieldAlert, ShieldQuestion, RefreshCw, Loader2, ChevronDown, ChevronUp,
} from 'lucide-react';
import { useState } from 'react';
/**
* Statuskarte fuer die Unversehrtheit des Audit-Protokolls.
*
* Die Prueflast steigt mit der Groesse des Protokolls (die Pruefung liest die
* gesamte Kette). Deshalb laeuft sie beim Oeffnen der Seite EINMAL und wird
* fuenf Minuten lang wiederverwendet; wer sofort neu pruefen will, nutzt den
* Knopf.
*/
type Ampel = 'ok' | 'befund' | 'hinweis' | 'unbekannt';
function ampelFuer(r: IntegrityResult): Ampel {
if (!r.valid) return 'befund';
// Gueltig, aber nicht vollstaendig pruefbar: kein gruenes Licht vortaeuschen.
if (r.unverifiableEntries.length > 0) return 'unbekannt';
if (r.backlogSealStatus === 'kein_siegel') return 'hinweis';
return 'ok';
}
const AMPEL_STIL: Record<Ampel, { box: string; titel: string; text: string }> = {
ok: { box: 'bg-green-50 border-green-200', titel: 'text-green-900', text: 'text-green-800' },
befund: { box: 'bg-red-50 border-red-200', titel: 'text-red-900', text: 'text-red-800' },
hinweis: { box: 'bg-yellow-50 border-yellow-200',titel: 'text-yellow-900', text: 'text-yellow-800' },
unbekannt: { box: 'bg-gray-50 border-gray-200', titel: 'text-gray-900', text: 'text-gray-700' },
};
function AmpelIcon({ ampel }: { ampel: Ampel }) {
const c = 'w-7 h-7 shrink-0';
if (ampel === 'ok') return <ShieldCheck className={`${c} text-green-600`} />;
if (ampel === 'befund') return <ShieldAlert className={`${c} text-red-600`} />;
if (ampel === 'hinweis') return <ShieldAlert className={`${c} text-yellow-600`} />;
return <ShieldQuestion className={`${c} text-gray-500`} />;
}
const AMPEL_TITEL: Record<Ampel, string> = {
ok: 'Protokoll unversehrt',
befund: 'Befund das gehört angesehen',
hinweis: 'Unversehrt, aber ungeschützter Altbestand',
unbekannt: 'Nicht vollständig prüfbar',
};
const SIEGEL_TEXT: Record<IntegrityResult['backlogSealStatus'], string> = {
intakt: 'versiegelt und intakt',
leer: 'Siegel vorhanden, aber ohne Inhalt',
kein_siegel: 'nicht versiegelt',
nicht_noetig: 'nicht nötig (alles signiert)',
gebrochen: 'GEBROCHEN',
entfernt: 'ENTFERNT',
};
/** Zeigt eine Zahl nur, wenn sie ungleich null ist sonst bleibt es ruhig. */
function Zahl({ label, ids, ton }: { label: string; ids: number[]; ton: string }) {
if (ids.length === 0) return null;
const gekuerzt = ids.length > 12 ? `${ids.slice(0, 12).join(', ')} … (+${ids.length - 12})` : ids.join(', ');
return (
<div className="flex items-baseline gap-2 text-sm">
<span className={`font-medium ${ton}`}>{label}:</span>
<span className="text-gray-600">{ids.length}</span>
<span className="text-gray-400 font-mono text-xs break-all">({gekuerzt})</span>
</div>
);
}
export default function AuditIntegrityCard() {
const [offen, setOffen] = useState(false);
const { data, isFetching, isError, error, refetch } = useQuery({
queryKey: ['audit-integrity'],
queryFn: () => auditLogApi.verifyIntegrity(),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: false,
});
const ergebnis = data?.data;
return (
<Card
className="mb-6"
title="Unversehrtheit des Protokolls"
actions={
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
{isFetching
? <Loader2 className="w-4 h-4 mr-2 animate-spin" />
: <RefreshCw className="w-4 h-4 mr-2" />}
Neu prüfen
</Button>
}
>
{isFetching && !ergebnis && (
<div className="flex items-center gap-3 text-gray-500">
<Loader2 className="w-5 h-5 animate-spin" />
<span>Die Kette wird geprüft </span>
</div>
)}
{isError && (
<div className="flex items-start gap-4 p-4 rounded-lg border bg-gray-50 border-gray-200">
<ShieldQuestion className="w-7 h-7 shrink-0 text-gray-500" />
<div>
<p className="font-medium text-gray-900">Prüfung nicht möglich</p>
<p className="text-sm text-gray-700 mt-1">
{error instanceof Error ? error.message : 'Unbekannter Fehler'}
</p>
<p className="text-sm text-gray-600 mt-2">
Das ist <strong>keine</strong> Entwarnung der Zustand der Kette ist damit
schlicht unbekannt.
</p>
</div>
</div>
)}
{ergebnis && (() => {
const ampel = ampelFuer(ergebnis);
const stil = AMPEL_STIL[ampel];
return (
<>
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
<AmpelIcon ampel={ampel} />
<div className="min-w-0">
<p className={`font-medium ${stil.titel}`}>
{AMPEL_TITEL[ampel]}
{ampel === 'ok' && ergebnis.rehashes.length > 0 && (
<span className="font-normal"> seit der letzten Neuberechnung</span>
)}
</p>
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
<p className="text-sm text-gray-500 mt-2">
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
{' '}Altbestand: {SIEGEL_TEXT[ergebnis.backlogSealStatus]}
{ergebnis.backlogSealCount > 1 && ` · ${ergebnis.backlogSealCount}× versiegelt`}
</p>
</div>
</div>
<button
type="button"
onClick={() => setOffen(o => !o)}
className="mt-3 flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
>
{offen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
Einzelheiten
</button>
{offen && (
<div className="mt-3 space-y-2 border-t pt-3">
<Zahl label="Nachträglich verändert" ids={ergebnis.tamperedEntries} ton="text-red-700" />
<Zahl label="Gesiegelte Alteinträge verändert" ids={ergebnis.backlogTampered} ton="text-red-700" />
<Zahl label="Gesiegelte Alteinträge entfernt" ids={ergebnis.backlogMissing} ton="text-red-700" />
<Zahl label="Verkettung unterbrochen" ids={ergebnis.chainGaps} ton="text-yellow-700" />
<Zahl label="davon ohne dokumentierte Löschung" ids={ergebnis.unexplainedGaps} ton="text-yellow-700" />
<Zahl label="davon vom Siegel beglaubigt" ids={ergebnis.attestedGaps} ton="text-gray-700" />
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
{ergebnis.tamperedEntries.length === 0 &&
ergebnis.chainGaps.length === 0 &&
ergebnis.unverifiableEntries.length === 0 &&
ergebnis.rehashes.length === 0 && (
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
)}
{ergebnis.rehashes.length > 0 && (
<div className="pt-2 border-t mt-2">
<p className="text-sm font-medium text-gray-700">Neuberechnungen der Kette</p>
{ergebnis.rehashes.map((r) => (
<p key={r.id} className="text-sm text-gray-600 mt-1">
{new Date(r.zeitpunkt).toLocaleString('de-DE')} · Eintrag {r.id}
{r.neuBerechnet !== null && ` · ${r.neuBerechnet} Einträge neu verkettet`}
{r.vorbefund && (
<span className="text-gray-500">
{' '}· davor: {r.vorbefund.manipuliert} beanstandet,{' '}
{r.vorbefund.luecken} Lücken
</span>
)}
{!r.signiert && (
<span className="text-red-700"> · ohne gültige Signatur</span>
)}
</p>
))}
<p className="text-xs text-gray-500 mt-2">
Ein Rehash verknüpft alle Einträge neu. Was davor als Lücke oder
Beanstandung sichtbar war, ist danach nicht mehr in der Kette zu sehen
nur noch im Vorbefund des jeweiligen Eintrags. Die Aussage dieser Prüfung
reicht deshalb bis zur letzten Neuberechnung zurück, nicht weiter.
</p>
</div>
)}
<p className="text-xs text-gray-500 pt-2">
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
mehr zu ihrer Prüfsumme das ist ernst.
{' '}<strong>Verkettung unterbrochen</strong> heißt: die Zeilen selbst sind
unversehrt, aber ein Glied fehlt oder wurde parallel geschrieben.
{' '}<strong>Beglaubigt</strong> sind Lücken, die beim Versiegeln des Altbestands
bereits bestanden und im signierten Siegel festgehalten sind.
</p>
</div>
)}
</>
);
})()}
</Card>
);
}
+30 -13
View File
@@ -1,12 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { auditLogApi, AuditLogSearchParams, authApi } from '../../services/api'; import { auditLogApi, AuditLogSearchParams, authApi } from '../../services/api';
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types'; import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
import Card from '../../components/ui/Card'; import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import Input from '../../components/ui/Input'; import Input from '../../components/ui/Input';
import Select from '../../components/ui/Select'; import Select from '../../components/ui/Select';
import AuditIntegrityCard from './AuditIntegrityCard';
import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
const ACTION_OPTIONS = [ const ACTION_OPTIONS = [
@@ -278,6 +280,10 @@ function DetailModal({ log, onClose }: DetailModalProps) {
export default function AuditLogs() { export default function AuditLogs() {
const navigate = useNavigate(); const navigate = useNavigate();
// Export haengt an `audit:export`, Lesen an `audit:read` ein Konto darf
// blaettern duerfen, ohne das gesamte Protokoll herausziehen zu koennen.
// Ohne diese Abfrage stuenden hier Knoepfe, die zuverlaessig 403 liefern.
const { hasPermission } = useAuth();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [filters, setFilters] = useState<AuditLogSearchParams>({ const [filters, setFilters] = useState<AuditLogSearchParams>({
page: 1, page: 1,
@@ -307,11 +313,16 @@ export default function AuditLogs() {
const downloadToken = await authApi.getDownloadToken(); const downloadToken = await authApi.getDownloadToken();
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('format', 'csv'); params.set('format', 'csv');
if (filters.action) params.set('action', filters.action); // ALLE aktiven Filter mitgeben (Pentest R186-01). Vorher standen hier
if (filters.sensitivity) params.set('sensitivity', filters.sensitivity); // nur fuenf wer im Suchfeld eingrenzte und dann CSV klickte, bekam
if (filters.resourceType) params.set('resourceType', filters.resourceType); // stillschweigend das gesamte Protokoll statt seiner Auswahl.
if (filters.startDate) params.set('startDate', filters.startDate); // `page`/`limit` gehoeren nicht dazu: der Export ist bewusst
if (filters.endDate) params.set('endDate', filters.endDate); // vollstaendig ueber die gefilterte Menge.
for (const [schluessel, wert] of Object.entries(filters)) {
if (schluessel === 'page' || schluessel === 'limit') continue;
if (wert === undefined || wert === null || wert === '') continue;
params.set(schluessel, String(wert));
}
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank'); window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
} else { } else {
const result = await auditLogApi.export({ ...filters, format }); const result = await auditLogApi.export({ ...filters, format });
@@ -341,6 +352,8 @@ export default function AuditLogs() {
<h1 className="text-2xl font-bold">Audit-Protokoll</h1> <h1 className="text-2xl font-bold">Audit-Protokoll</h1>
</div> </div>
<AuditIntegrityCard />
{/* Filter */} {/* Filter */}
<Card className="mb-6"> <Card className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
@@ -385,14 +398,18 @@ export default function AuditLogs() {
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
Aktualisieren Aktualisieren
</Button> </Button>
<Button variant="secondary" onClick={() => handleExport('json')}> {hasPermission('audit:export') && (
<Download className="w-4 h-4 mr-2" /> <Button variant="secondary" onClick={() => handleExport('json')}>
JSON <Download className="w-4 h-4 mr-2" />
</Button> JSON
<Button variant="secondary" onClick={() => handleExport('csv')}> </Button>
<Download className="w-4 h-4 mr-2" /> )}
CSV {hasPermission('audit:export') && (
</Button> <Button variant="secondary" onClick={() => handleExport('csv')}>
<Download className="w-4 h-4 mr-2" />
CSV
</Button>
)}
</div> </div>
</div> </div>
</Card> </Card>
@@ -49,7 +49,7 @@ export default function CancellationPeriodList() {
</Button> </Button>
</Link> </Link>
<h1 className="text-2xl font-bold flex-1">Kündigungsfristen</h1> <h1 className="text-2xl font-bold flex-1">Kündigungsfristen</h1>
{hasPermission('platforms:create') && ( {hasPermission('cancellation-periods:create') && (
<Button onClick={() => setShowModal(true)}> <Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Neue Frist Neue Frist
@@ -101,12 +101,12 @@ export default function CancellationPeriodList() {
</td> </td>
<td className="py-3 px-4 text-right"> <td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{hasPermission('platforms:update') && ( {hasPermission('cancellation-periods:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(period)}> <Button variant="ghost" size="sm" onClick={() => handleEdit(period)}>
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</Button> </Button>
)} )}
{hasPermission('platforms:delete') && ( {hasPermission('cancellation-periods:delete') && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -90,7 +90,7 @@ export default function ContractCategoryList() {
</Button> </Button>
</Link> </Link>
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1> <h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
{hasPermission('developer:access') && ( {hasPermission('contract-categories:create') && (
<Button onClick={() => setShowModal(true)}> <Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Neuer Vertragstyp Neuer Vertragstyp
@@ -144,12 +144,12 @@ export default function ContractCategoryList() {
</div> </div>
</div> </div>
<div className="flex gap-2 ml-4"> <div className="flex gap-2 ml-4">
{hasPermission('developer:access') && ( {hasPermission('contract-categories:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten"> <Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</Button> </Button>
)} )}
{hasPermission('developer:access') && ( {hasPermission('contract-categories:delete') && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -49,7 +49,7 @@ export default function ContractDurationList() {
</Button> </Button>
</Link> </Link>
<h1 className="text-2xl font-bold flex-1">Vertragslaufzeiten</h1> <h1 className="text-2xl font-bold flex-1">Vertragslaufzeiten</h1>
{hasPermission('platforms:create') && ( {hasPermission('contract-durations:create') && (
<Button onClick={() => setShowModal(true)}> <Button onClick={() => setShowModal(true)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Neue Laufzeit Neue Laufzeit
@@ -101,12 +101,12 @@ export default function ContractDurationList() {
</td> </td>
<td className="py-3 px-4 text-right"> <td className="py-3 px-4 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{hasPermission('platforms:update') && ( {hasPermission('contract-durations:update') && (
<Button variant="ghost" size="sm" onClick={() => handleEdit(duration)}> <Button variant="ghost" size="sm" onClick={() => handleEdit(duration)}>
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</Button> </Button>
)} )}
{hasPermission('platforms:delete') && ( {hasPermission('contract-durations:delete') && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -37,6 +37,7 @@ const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
{ value: 'LOGOUT', label: 'Logout' }, { value: 'LOGOUT', label: 'Logout' },
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' }, { value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' }, { value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
{ value: 'AUDIT_SEAL_CHANGED', label: 'Bestandssiegel gesetzt/ersetzt' },
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' }, { value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
]; ];
+3 -3
View File
@@ -204,7 +204,7 @@ function ProviderRow({
<div className="border-t bg-gray-50 p-4"> <div className="border-t bg-gray-50 p-4">
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<h4 className="font-medium text-gray-700">Tarife</h4> <h4 className="font-medium text-gray-700">Tarife</h4>
{hasPermission('providers:create') && ( {hasPermission('tariffs:create') && (
<Button size="sm" onClick={() => setShowTariffModal(true)}> <Button size="sm" onClick={() => setShowTariffModal(true)}>
<Plus className="w-4 h-4 mr-1" /> <Plus className="w-4 h-4 mr-1" />
Tarif hinzufügen Tarif hinzufügen
@@ -227,7 +227,7 @@ function ProviderRow({
)} )}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{hasPermission('providers:update') && ( {hasPermission('tariffs:update') && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -240,7 +240,7 @@ function ProviderRow({
<Edit className="w-3 h-3" /> <Edit className="w-3 h-3" />
</Button> </Button>
)} )}
{hasPermission('providers:delete') && ( {hasPermission('tariffs:delete') && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
+65 -4
View File
@@ -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,7 +246,9 @@ function UserModal({
roleIds: [] as number[], roleIds: [] as number[],
isActive: true, isActive: true,
hasDeveloperAccess: false, hasDeveloperAccess: false,
hasAuditOpsAccess: false,
hasGdprAccess: false, hasGdprAccess: false,
isServiceAccount: false,
whatsappNumber: '', whatsappNumber: '',
telegramUsername: '', telegramUsername: '',
signalNumber: '', signalNumber: '',
@@ -263,10 +265,12 @@ 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,
whatsappNumber: (user as any).whatsappNumber || '', whatsappNumber: (user as any).whatsappNumber || '',
telegramUsername: (user as any).telegramUsername || '', telegramUsername: (user as any).telegramUsername || '',
signalNumber: (user as any).signalNumber || '', signalNumber: (user as any).signalNumber || '',
@@ -281,7 +285,9 @@ function UserModal({
roleIds: [], roleIds: [],
isActive: true, isActive: true,
hasDeveloperAccess: false, hasDeveloperAccess: false,
hasAuditOpsAccess: false,
hasGdprAccess: false, hasGdprAccess: false,
isServiceAccount: false,
whatsappNumber: '', whatsappNumber: '',
telegramUsername: '', telegramUsername: '',
signalNumber: '', signalNumber: '',
@@ -323,7 +329,16 @@ 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,
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
// im Formular IST diese Bestätigung.
// Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses
// Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche
// Bestätigung; der Haken im Formular ist diese Bestätigung.
confirm: 'SERVICE_ACCOUNT',
whatsappNumber: formData.whatsappNumber || undefined, whatsappNumber: formData.whatsappNumber || undefined,
telegramUsername: formData.telegramUsername || undefined, telegramUsername: formData.telegramUsername || undefined,
signalNumber: formData.signalNumber || undefined, signalNumber: formData.signalNumber || undefined,
@@ -358,7 +373,16 @@ 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,
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
// im Formular IST diese Bestätigung.
// Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses
// Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche
// Bestätigung; der Haken im Formular ist diese Bestätigung.
confirm: 'SERVICE_ACCOUNT',
whatsappNumber: formData.whatsappNumber || undefined, whatsappNumber: formData.whatsappNumber || undefined,
telegramUsername: formData.telegramUsername || undefined, telegramUsername: formData.telegramUsername || undefined,
signalNumber: formData.signalNumber || undefined, signalNumber: formData.signalNumber || undefined,
@@ -470,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"
@@ -487,6 +511,22 @@ function UserModal({
</div> </div>
<label className="block text-sm font-medium text-gray-700 mt-4 mb-2">Zusätzliche Berechtigungen</label> <label className="block text-sm font-medium text-gray-700 mt-4 mb-2">Zusätzliche Berechtigungen</label>
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={formData.isServiceAccount}
onChange={(e) => setFormData({ ...formData, isServiceAccount: e.target.checked })}
className="mt-1 rounded border-blue-300 text-blue-600 focus:ring-blue-500"
/>
<span>
<span className="font-medium">Dienstkonto</span>
<span className="block text-sm text-gray-500">
Für Konten, die sich planmäßig und regelmäßig anmelden etwa das Gegenbuch.
Ihre Anmeldungen erscheinen im Audit-Log als Routine statt als kritisches
Ereignis. Dafür wird gemeldet, wenn ein solches Konto <em>ausbleibt</em>.
</span>
</span>
</label>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
@@ -498,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
+41 -4
View File
@@ -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; 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[] }) => { 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;
}, },
@@ -1734,6 +1735,42 @@ export interface AuditLogSearchParams {
search?: string; search?: string;
} }
/** Ergebnis der Integritaetspruefung des Audit-Protokolls. */
export interface IntegrityResult {
valid: boolean;
checkedCount: number;
/** Noch offene Beanstandungen nur hieran haengt `valid`. */
invalidEntries: number[];
/** ERNST: Inhalt einer bestehenden Zeile wurde nachtraeglich veraendert. */
tamperedEntries: number[];
/** Verkettung unterbrochen; die Zeilen selbst koennen unversehrt sein. */
chainGaps: number[];
/** Luecken ohne protokolliertes Loeschungs-Manifest. */
unexplainedGaps: number[];
/** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */
attestedGaps: number[];
/**
* Protokollierte Neuberechnungen der Kette. Nach einem Rehash ist die Kette
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg. Die Aussage der
* Pruefung reicht dann nur bis zur letzten Neuberechnung zurueck.
*/
rehashes: Array<{
id: number;
zeitpunkt: string;
neuBerechnet: number | null;
signiert: boolean;
vorbefund: { manipuliert: number; luecken: number } | null;
}>;
/** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */
unverifiableEntries: number[];
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
backlogTampered: number[];
backlogMissing: number[];
backlogSealCount: number;
tampered: boolean;
message: string;
}
export const auditLogApi = { export const auditLogApi = {
search: async (params?: AuditLogSearchParams) => { search: async (params?: AuditLogSearchParams) => {
const res = await api.get<ApiResponse<AuditLog[]>>('/audit-logs', { params }); const res = await api.get<ApiResponse<AuditLog[]>>('/audit-logs', { params });
@@ -1752,7 +1789,7 @@ export const auditLogApi = {
return res.data; return res.data;
}, },
verifyIntegrity: async () => { verifyIntegrity: async () => {
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; unexplainedGaps: number[]; unverifiableEntries: number[]; tampered: boolean; message: string }>>('/audit-logs/verify'); const res = await api.post<ApiResponse<IntegrityResult>>('/audit-logs/verify');
return res.data; return res.data;
}, },
rehash: async () => { rehash: async () => {
@@ -1848,7 +1885,7 @@ export interface EmailLog {
export type SecurityEventType = export type SecurityEventType =
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED' | 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM' | 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS'; | 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'AUDIT_SEAL_CHANGED' | 'SUSPICIOUS';
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
+66 -17
View File
@@ -6,42 +6,91 @@
# Was hier passiert: Der Rechner holt regelmäßig einen kurzen Kontrollwert von # Was hier passiert: Der Rechner holt regelmäßig einen kurzen Kontrollwert von
# OpenCRM ab und schreibt ihn in ein Buch, das nur hier liegt. Wird später im # OpenCRM ab und schreibt ihn in ein Buch, das nur hier liegt. Wird später im
# CRM etwas nachträglich verändert, widerspricht das dem Buch. # CRM etwas nachträglich verändert, widerspricht das dem Buch.
#
# Welche Bücher sollen laufen?
COMPOSE_PROFILES=prod,staging
# Nur Kosmetik steht in den Einträgen des Buchs.
# ============================================================
# Welche Bücher sollen laufen?
# ============================================================
# Gültige Werte: prod | staging | prod,staging | (leer = keins)
# Genau so geschrieben nicht "production" oder "test".
#
# Nur Staging testen: COMPOSE_PROFILES=staging
# Später Prod dazunehmen: COMPOSE_PROFILES=prod,staging
# Danach: docker compose up -d (der laufende Dienst bleibt unberührt)
COMPOSE_PROFILES=staging
# Steht in den Einträgen des Buchs. Beliebiger Text, nur Kosmetik.
NOTAR_EMAIL=gegenbuch@example.de NOTAR_EMAIL=gegenbuch@example.de
# ---------------- Produktion ----------------
# Von welcher OpenCRM-Instanz wird geholt? # ============================================================
# Produktion
# ============================================================
# Adresse der OpenCRM-Instanz, von der geholt wird.
# Gültig: vollständige URL mit https:// und OHNE Schrägstrich am Ende.
PROD_CRM_URL=https://crm.example.de PROD_CRM_URL=https://crm.example.de
# Token eines Benutzers mit dem Recht audit:read MEHR NICHT. # Zugang: ein eigenes Benutzerkonto im CRM, das NUR das Recht "audit:read" hat.
# Damit lassen sich ausschließlich Prüfwerte lesen: keine Kundendaten, keine # Wie man es anlegt, steht in der README unter "Zugang einrichten".
# Änderungen. Selbst wenn es abhandenkommt, ist damit nichts anzufangen. #
PROD_CRM_TOKEN= # Das Gegenbuch meldet sich damit bei jedem Durchlauf selbst an. Ein fest
# hinterlegtes Token gibt es bewusst nicht Zugangstoken laufen nach
# 15 Minuten ab und wären beim nächsten Durchlauf längst ungültig.
PROD_CRM_EMAIL=gegenbuch@deine-domain.de
PROD_CRM_PASSWORD=
# Wie oft geprüft wird (Sekunden). 3600 = stündlich. # Wie oft geprüft wird, in Sekunden.
# Gültig: ganze Zahl > 0. Üblich: 3600 (stündlich), 900 (viertelstündlich)
# Kürzer heißt: kleineres Zeitfenster, in dem eine Änderung unbemerkt bliebe. # Kürzer heißt: kleineres Zeitfenster, in dem eine Änderung unbemerkt bliebe.
PROD_INTERVAL=3600 PROD_INTERVAL=3600
# Beim ALLERERSTEN Start einmalig auf true setzen, danach wieder leeren. # Beim ALLERERSTEN Start einmalig setzen, danach wieder leeren.
# Gültige Werte: true | (leer)
# Grund: Die erste Eintragung legt fest, was als Ausgangszustand gilt das # Grund: Die erste Eintragung legt fest, was als Ausgangszustand gilt das
# soll nicht versehentlich passieren. # soll nicht versehentlich passieren.
PROD_GENESIS_ACK= PROD_GENESIS_ACK=
# Normalerweise leer lassen. Nur nötig, wenn das Gedächtnis des Gegenbuchs # Normalerweise leer lassen.
# verlorenging (z. B. Verzeichnis gelöscht) und du geklärt hast, warum. # Gültige Werte: true | (leer)
# Nur nötig, wenn das Datenverzeichnis verlorenging (z. B. gelöscht) UND du
# geklärt hast, warum. Siehe README, Abschnitt "Wenn das Gedächtnis fehlt".
PROD_ADOPT_ACK= PROD_ADOPT_ACK=
# Wo das Buch liegt. DIESES VERZEICHNIS GEHÖRT INS BACKUP. # Normalerweise leer lassen.
# Gültige Werte: die neue Siegelwurzel (mind. 16 Zeichen) | (leer)
# Das Gegenbuch schlägt Alarm, wenn sich die Wurzel des Bestandssiegels
# ändert denn ein erneutes Siegeln ersetzt die Grundlage, gegen die
# Manipulation nachgewiesen wird. War der Wechsel gewollt, hier die Wurzel
# eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
# Bewusst KEIN "true": ein stehen gelassener Wert passt beim nächsten
# Wechsel nicht mehr und kann darum keinen weiteren stillschweigend
# durchwinken.
PROD_SEAL_ACK=
# Normalerweise leer lassen.
# Gültige Werte: die ID des Rehash-Eintrags | (leer)
# Das Gegenbuch schlägt Alarm, wenn die Hash-Kette neu berechnet wurde. Ein
# Rehash verknüpft alle Einträge neu Lücken, die eine Löschung sichtbar
# gemacht hätten, verschwinden dabei aus der Kette, und die Prüfung im CRM
# meldet danach wieder „lückenlos". War die Neuberechnung geplant, hier die
# ID eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
PROD_REHASH_ACK=
# Wo das Buch liegt relativ zu diesem Verzeichnis.
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
PROD_DIR=./data/prod PROD_DIR=./data/prod
# ---------------- Test / Staging ----------------
# ============================================================
# Test / Staging
# ============================================================
# Gleiche Regeln wie oben, eigenes Konto und eigenes Verzeichnis.
STAGING_CRM_URL=https://staging.example.de STAGING_CRM_URL=https://staging.example.de
STAGING_CRM_TOKEN= STAGING_CRM_EMAIL=gegenbuch@deine-domain.de
STAGING_CRM_PASSWORD=
STAGING_INTERVAL=3600 STAGING_INTERVAL=3600
STAGING_GENESIS_ACK= STAGING_GENESIS_ACK=
STAGING_ADOPT_ACK= STAGING_ADOPT_ACK=
STAGING_SEAL_ACK=
STAGING_REHASH_ACK=
STAGING_DIR=./data/staging STAGING_DIR=./data/staging
+7 -3
View File
@@ -15,9 +15,13 @@ COPY notary.mjs /opt/notary/notary.mjs
COPY entrypoint.sh /opt/notary/entrypoint.sh COPY entrypoint.sh /opt/notary/entrypoint.sh
RUN chmod +x /opt/notary/entrypoint.sh RUN chmod +x /opt/notary/entrypoint.sh
# Nicht als root laufen. Das Node-Image bringt bereits einen Benutzer mit # Der Container STARTET als root aber nur, um die Rechte auf dem
# UID 1000 mit ("node") der passt zu Bind-Mounts unter ./data/. # Datenverzeichnis geradezuziehen. Danach gibt der entrypoint die Privilegien
USER node # ab und arbeitet als unprivilegierter Benutzer weiter.
#
# Grund: Das Datenverzeichnis kommt als Bind-Mount vom Host. Wer das Projekt
# als root geklont hat, hat dort root-eigene Verzeichnisse ein Container, der
# direkt als UID 1000 startet, kann darin nichts anlegen.
WORKDIR /gegenbuch WORKDIR /gegenbuch
ENTRYPOINT ["/opt/notary/entrypoint.sh"] ENTRYPOINT ["/opt/notary/entrypoint.sh"]
+374 -6
View File
@@ -6,9 +6,59 @@ absichern sollen. Wer dort schreiben kann, sitzt am Ende immer schon auf der
Ebene, die den Beweis führt. Genau das hat der Pentest über mehrere Runden Ebene, die den Beweis führt. Genau das hat der Pentest über mehrere Runden
Schicht für Schicht gezeigt. Schicht für Schicht gezeigt.
Das Gegenbuch durchbricht das: Ein zweiter Rechner holt regelmäßig einen kurzen Das Gegenbuch durchbricht das: Ein **zweiter Rechner** holt regelmäßig einen
Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und hängt ihn kurzen Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und schreibt
signiert an ein privates Repository an. ihn signiert fort. Wird später im CRM etwas nachträglich verändert,
widerspricht das dem Gegenbuch.
## Was du hier suchst
**Einrichten**
[Betriebsart wählen](#zwei-betriebsarten--erst-hier-entscheiden) ·
[mit Docker](#einrichten-mit-docker-empfohlen) ·
[erster Start](#erster-start--drei-schritte) ·
[Zugangskonto im CRM](#zugang-einrichten-das-brauchst-du-vorher) ·
[ohne Docker](#einrichten-ohne-docker)
**Im Betrieb**
[**Altbestand versiegeln**](#den-altbestand-versiegeln-einmalig-im-crm) ·
[Rückgabecodes](#rückgabecodes) ·
[Überwachung](#überwachung) ·
[wo die Daten liegen](#wo-die-daten-liegen)
**Wenn Alarm kommt**
[Siegelwurzel hat gewechselt](#wenn-sich-die-siegelwurzel-ändert-alarm-und-warum) ·
[Kette wurde neu berechnet](#wenn-die-kette-neu-berechnet-wurde-ebenfalls-alarm) ·
[Zurückspulen erkannt](#der-rewind-wächter) ·
[Gedächtnis fehlt](#wenn-das-gedächtnis-trotzdem-fehlt)
**Zum Nachlesen**
[was erkannt wird](#was-das-skript-erkennt) ·
[was nicht ehrlich](#was-es-nicht-leistet--ehrlich) ·
[Prüfmodus für Auditoren](#prüfmodus-für-auditoren)
> **Nur schnell versiegeln?** Der Abschnitt
> [Altbestand versiegeln](#den-altbestand-versiegeln-einmalig-im-crm) ist
> eigenständig und gilt **auch ohne Gegenbuch** es ist ein Vorgang im CRM,
> nicht hier.
## Zwei Betriebsarten erst hier entscheiden
| | **Lokal** (Normalfall) | **Mit externem Repository** |
|---|---|---|
| Das Buch liegt | auf dem Gegenbuch-Rechner | zusätzlich auf einem dritten Server |
| Aufwand | Docker starten, fertig | privates Git-Repo, Schlüssel, Server-Regeln |
| Schützt gegen | jemand verändert Daten **im CRM** | zusätzlich: jemand übernimmt den **Gegenbuch-Rechner** |
**Für die allermeisten Installationen ist „lokal" die richtige Wahl.** Der
Schutz, um den es geht nachträgliche Änderungen im CRM auffliegen zu lassen
steht damit vollständig. Die zweite Variante deckt einen Angreifer ab, der
zusätzlich den Gegenbuch-Rechner übernimmt; sie kostet spürbar mehr Einrichtung
und laufende Aufmerksamkeit.
Diese Anleitung beschreibt zuerst den lokalen Betrieb. Alles zur zweiten
Variante steht gesammelt unter **„Zusätzliche Härtung"** weiter unten wer
lokal betreibt, kann diesen ganzen Teil überspringen.
## Die eine nicht verhandelbare Bedingung ## Die eine nicht verhandelbare Bedingung
@@ -30,6 +80,35 @@ cp .env.example .env
docker compose up -d docker compose up -d
``` ```
### Erster Start drei Schritte
Beim allerersten Lauf meldet der Container `exit=4` und verlangt eine
Bestätigung. Das ist Absicht: Der erste Eintrag legt fest, was als
Ausgangszustand gilt.
```bash
# 1. In der .env freigeben
STAGING_GENESIS_ACK=true
# 2. Neu starten (kein --build nötig, nur die Umgebung ändert sich)
docker compose up -d && docker compose logs -f
# Erwartet: "OK: Checkpoint 1 erstellt" und exit=3
# exit=3 ist hier richtig beim ersten Mal gibt es nichts zu vergleichen.
# 3. Wieder leeren und erneut starten
STAGING_GENESIS_ACK=
docker compose up -d
```
Ab dem nächsten Durchlauf steht dort `exit=0 (in Ordnung)`.
**Warum Schritt 3 wichtig ist:** Bleibt die Zeile auf `true`, würde der
Container nach einem Verlust des Datenverzeichnisses stillschweigend eine neue
Grundlage setzen, statt zu fragen genau davor schützt die Abfrage.
Für Produktion später dasselbe mit `PROD_GENESIS_ACK`. Schnell nachsehen ohne
Logs: `cat data/staging/status.txt`.
Beim ersten Start einmalig `PROD_GENESIS_ACK=true` setzen (und danach wieder Beim ersten Start einmalig `PROD_GENESIS_ACK=true` setzen (und danach wieder
leeren) die erste Eintragung legt fest, was als Ausgangszustand gilt, und das leeren) die erste Eintragung legt fest, was als Ausgangszustand gilt, und das
soll nicht versehentlich passieren. soll nicht versehentlich passieren.
@@ -38,10 +117,274 @@ soll nicht versehentlich passieren.
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln. getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`. Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
### Den Altbestand versiegeln (einmalig, im CRM)
Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** Einträge
aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt
ist, meldet die Prüfung `valid: false`, das Gegenbuch schlägt zu Recht Alarm
und, wichtig: **es beglaubigt so lange gar nichts.** Es bricht vor dem Anhängen
ab, weil ein Checkpoint über einen ungeklärten Zustand diesen mitbeglaubigen
würde. Ein unversiegeltes CRM ist also nicht „bewacht mit Warnung", sondern
unbewacht.
Das Siegeln ist **einmalig** und passiert im CRM, nicht hier.
#### Wer darf das
Das Recht `audit:admin`. Das bekommt man über den Haken **„Audit-Betrieb"** in
der Benutzerverwaltung **nicht** über die Admin-Rolle (die hat bewusst keine
Audit-Rechte) und **nicht** über den DSGVO-Haken (der darf lesen, nicht
eingreifen). Das Gegenbuch-Dienstkonto hat es erst recht nicht.
```bash
CRM=https://<crm>
read -s -p "Passwort: " PASS; echo
AT=$(curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.token')
curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.user.permissions[]' | grep audit
```
Muss `audit:read` **und** `audit:admin` zeigen.
#### Erst nachsehen, was du festschreibst
Das Siegel hält den **aktuellen** Zustand fest, samt aller vorhandenen Lücken.
Wer blind siegelt, beglaubigt womöglich eine Lücke, die von einer Löschung
stammt. Der Schritt ist praktisch einwegs: Danach ist die Beglaubigung tragend,
und ein Siegel wieder zu entfernen erzeugt den Zustand `entfernt` also selbst
einen Befund.
```bash
B="Authorization: Bearer $AT"
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
| jq '.data | {valid, checkedCount, rehashes, chainGaps, unexplainedGaps,
tamperedEntries, backlogSealStatus}'
```
Vier Dinge müssen stimmen:
| Feld | Erwartung | Warum |
|---|---|---|
| `rehashes` | `[]` | Eine Neuberechnung verknüpft alles neu danach ist die Kette *zwangsläufig* stimmig, auch über Löschungen hinweg. Steht hier etwas, sagt `chainGaps` nichts über die Zeit davor aus. |
| `tamperedEntries` | `[]` | Veränderter Inhalt gehört geklärt, nicht beglaubigt. |
| `chainGaps` | erklärbar | siehe unten |
| `backlogSealStatus` | `kein_siegel` | sonst ist es kein Erst-Siegeln (siehe nächster Abschnitt) |
**Lücken einordnen.** Sieh dir die betroffenen IDs und ihre Nachbarn an:
```bash
for id in <lücke-1> <nachbar> …; do
printf '%-6s ' "$id"
curl -s -o /tmp/r.json -w 'HTTP %{http_code} ' "$CRM/api/audit-logs/$id" -H "$B"
jq -r 'if .success then (.data|"\(.createdAt[0:19]) v\(.hashVersion) \(.action)/\(.resourceType)") else .error end' /tmp/r.json
done
```
> **Den HTTP-Code mit ausgeben, immer.** Zugangstoken laufen nach 15 Minuten ab.
> Ohne den Code liest sich eine abgelehnte Anfrage (401) wie ein fehlender
> Eintrag ein Prüfwerkzeug, das ein verweigertes Lesen als Löschung meldet.
> Uns ist genau das passiert, mitten in der Vorbereitung eines Prod-Siegels.
Liegen die Lücken jeweils **innerhalb einer Sekunde** zusammen mit ihren
Nachbarn, betreffen nur `Authentication` und fehlt kein Eintrag, sind es
Schreibkollisionen aus parallelen Anfragen harmlos, historisch, nicht mehr
reproduzierbar. Fehlt dagegen ein Eintrag oder passt eine Lücke nicht in dieses
Bild: **erst klären, dann siegeln.**
*(Ein abgeschnittener **Anfang** des Protokolls wird seit dem Anfangs-Detektor
mitgeprüft: Die erste Zeile eines Protokolls trägt einen leeren `previousHash`;
trägt die erste vorhandene Zeile einen Wert, fehlt eine Vorgängerin. Das
erscheint als ganz niedrige Lücke und kippt `valid` du musst es nicht selbst
suchen.)*
#### Siegeln
```bash
curl -s -X POST $CRM/api/audit-logs/seal-backlog -H "$B" \
-H 'Content-Type: application/json' -d '{"confirm":"SEAL"}' | jq
```
`SEAL` ist das Erst-Siegeln. Besteht bereits ein Siegel, verlangt der Endpunkt
stattdessen `RESEAL` siehe nächster Abschnitt. Notiere `root` aus der Antwort.
#### Danach: drei Punkte gegenprüfen
```bash
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
| jq '.data | {valid, chainGaps, attestedGaps, backlogSealStatus, backlogSealCount}'
SID=$(curl -s "$CRM/api/audit-logs?resourceType=AuditBacklogSeal&limit=1" -H "$B" | jq -r '.data[0].id')
curl -s "$CRM/api/audit-logs/$SID" -H "$B" \
| jq '{vorbefund:.data.changesBefore.befund.ketten_luecken, siegel:.data.changesAfter}'
```
1. `valid: true`, und `chainGaps` = `attestedGaps` = die bekannten Lücken
2. `backlogSealStatus: "intakt"` **nicht** `leer`. `leer` heißt: Das Siegel
umschließt nichts, es gab keinen Altbestand. Kein Fehler, aber auch keine
Zusage.
3. Die Lücken stehen im `changesBefore.befund.ketten_luecken` des Markers
genau daran hängt die Beglaubigung.
Die Lücken verschwinden also **nicht** aus dem Bericht. Sie zählen nur nicht
mehr als offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte
Altzeile und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus.
#### Zuletzt: das Gegenbuch quittieren
Der nächste Lauf meldet **einmal** `exit=2` („Erstmals ein Bestandssiegel
gesetzt") erwartet, kein Befund. Wurzel aus `data/<instanz>/status.txt` in
`PROD_SEAL_ACK` bzw. `STAGING_SEAL_ACK` eintragen, `docker compose up -d`, nach
dem Lauf mit `exit=0` wieder leeren. Details im nächsten Abschnitt.
Ab da schreibt das Gegenbuch wieder Checkpoints fort und setzt im selben Lauf
die Grundlage für die Rehash-Überwachung.
### Wenn sich die Siegelwurzel ändert: Alarm, und warum
Erneutes Siegeln **ersetzt** die Grundlage, gegen die Manipulation nachgewiesen
wird. Wer eine Altzeile per Datenbankzugriff entfernt und danach neu siegelt,
bekommt eine passende Wurzel und eine beglaubigte Lücke — und die Prüfung im
CRM meldet wieder `valid: true`. Der Wechsel der Wurzel ist die einzige Spur
davon, die eine Maschine sehen kann.
Deshalb ist er ein **Alarm** (exit 2), kein Hinweis. Vorher stand hier eine
Zeile Prosa, während der Rückgabecode auf 0 blieb — also genau das Muster, das
wir dem CRM selbst zweimal angekreidet haben.
Der Alarm nennt die alte und die neue Wurzel samt Blattzahl. Ein Sprung von
`10 Blätter` auf `9 Blätter` sagt sofort, was passiert ist.
**War der Wechsel gewollt** (typisch: dein einmaliges Erstsiegeln), bestätigst
du ihn mit der Wurzel, die der Alarm ausgibt:
```bash
# in der .env
PROD_SEAL_ACK=72062c88a8b6e2b53b30496b483885cd
docker compose up -d # ein Lauf die neue Wurzel wird beglaubigt
PROD_SEAL_ACK= # danach wieder leeren
```
Bestätigt wird bewusst **nicht** mit `true`, sondern mit der Wurzel selbst.
Ein versehentlich stehen gelassener Wert passt beim nächsten Wechsel nicht mehr
und kann deshalb keinen weiteren Austausch stillschweigend durchwinken.
**War er nicht gewollt**, sieh im CRM nach: das Sicherheits-Ereignis
`AUDIT_SEAL_CHANGED` (Einstellungen → Monitoring) und die CRITICAL-Zeile zu
`/api/audit-logs/seal-backlog` im Audit-Protokoll nennen Konto, Zeitpunkt und
den Befund, der vor dem Siegeln galt.
Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
### Wenn die Kette neu berechnet wurde: ebenfalls Alarm
Ein **Rehash** verknüpft alle Einträge neu. Danach ist die Kette
zwangsläufig stimmig — auch über Löschungen hinweg, die vorher als Lücken
sichtbar gewesen wären. Die Reihenfolge `cleanup``rehash` macht aus einem
beschnittenen Protokoll ein scheinbar makelloses, und die Prüfung im CRM meldet
danach wieder „lückenlos verkettet". Beides braucht nur `audit:admin`, keinen
Datenbankzugriff.
Genau das ist im Betrieb vorgekommen: Auf einer Testinstanz wurden 3.155
Einträge gelöscht und anschließend neu berechnet. Die Prüfung war danach grün,
die 656 Kettenlücken standen nur noch im Vorbefund des Rehash-Eintrags — den
niemand liest. Das Gegenbuch war der einzige Zeuge.
Deshalb ist eine neue Neuberechnung seit der letzten Beglaubigung ein **Alarm**
(exit 2). Er nennt Zeitpunkt, Zahl der betroffenen Zeilen und den Befund, der
unmittelbar davor galt:
```
ALARM: Die Hash-Kette wurde neu berechnet (1 neuer Vorgang seit der letzten Beglaubigung).
zuletzt: 2026-08-26T16:28:25.467Z (Eintrag 13, 9 Zeilen)
Befund unmittelbar davor: 1 beanstandet, 1 Lücken
NOTARY_REHASH_ACK=13
```
**War die Neuberechnung gewollt**, bestätigst du sie mit der genannten ID:
```bash
# in der .env
PROD_REHASH_ACK=13
docker compose up -d
PROD_REHASH_ACK= # danach wieder leeren
```
Auch hier wird nicht mit `true` bestätigt, sondern mit einem Wert, der zum
Vorgang gehört. IDs steigen streng — ein stehen gelassener Wert passt bei der
nächsten Neuberechnung nicht mehr.
**Warum ein eigener Melder, wo doch schon der Kettenkopf verglichen wird?**
Eine Neuberechnung ändert jeden Hash, der beglaubigte Kopf stimmt also
ohnehin nicht mehr — der Alarm käme auch so. Aber als *Nebenwirkung*, nicht als
gebaute Warnung: Verschöbe sich der Anker irgendwann, wäre der Melder lautlos
weg. Und die Meldung hieße „Eintrag wurde verändert" statt „die Kette wurde neu
berechnet" — die Wirkung statt der Ursache. Deshalb hängt der Alarm an der
Sache selbst und steht **vor** dem Kopf-Vergleich; nach einer bestätigten
Neuberechnung wird dieser übersprungen, weil der veränderte Kopf dann die
erwartete Folge ist.
Ein fehlender beglaubigter Eintrag bleibt davon unberührt und alarmiert immer:
Bestätigt wird die Neuberechnung, nicht das Verschwinden von Zeilen.
### Zugang einrichten (das brauchst du vorher)
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM kein Token. Der
Grund: Zugangstoken laufen nach 15 Minuten ab und wären beim nächsten
stündlichen Durchlauf längst ungültig. Das Gegenbuch meldet sich deshalb bei
jedem Lauf selbst an.
Im CRM, als Administrator:
1. **Benutzer anlegen**, z. B. `gegenbuch@deine-domain.de`, mit einem langen,
zufälligen Passwort.
2. Ihm die Rolle **`Gegenbuch`** geben **nur diese**. Sie bringt genau ein
Recht mit: `audit:read`.
3. Zusätzlich **„Dienstkonto"** ankreuzen. Dann gelten seine Anmeldungen als
Routine statt als kritisches Ereignis und sein *Ausbleiben* wird gemeldet.
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
> **Weder den DSGVO- noch den Audit-Betrieb-Haken setzen.** Der DSGVO-Haken
> gibt zusätzlich Leserechte auf personenbezogene Daten und den Export; der
> Haken „Audit-Betrieb" gibt `audit:admin` mit `seal-backlog`, `rehash` und
> `cleanup`. Ein Einbruch auf dieser Maschine hätte damit nicht nur den
> Wächter, sondern gleich die Mittel, das Bewachte umzuschreiben. Das Passwort
> 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
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
die Zugangsdaten abhandenkommen, ist damit nichts anzufangen.
Gegenprobe nach dem Einrichten **200, dann dreimal 403**:
```bash
B="Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w 'checkpoint %{http_code}\n' https://<crm>/api/audit-logs/checkpoint -H "$B"
curl -s -o /dev/null -w 'export %{http_code}\n' https://<crm>/api/audit-logs/export -H "$B"
curl -s -o /dev/null -w 'seal-backlog %{http_code}\n' -X POST https://<crm>/api/audit-logs/seal-backlog -H "$B" -H 'Content-Type: application/json' -d '{}'
curl -s -o /dev/null -w 'kunden %{http_code}\n' https://<crm>/api/customers -H "$B"
```
Der Export gehört ausdrücklich dazu: Er liefert `changesBefore`/`changesAfter`,
also die vollständigen Vorher/Nachher-Datensätze samt Klartextnamen. Prüfwerte
lesen und das Protokoll herausziehen sind zwei verschiedene Dinge deshalb
hängt der Export an `audit:export`, das die Rolle `Gegenbuch` nicht hat.
Für Produktion und Test jeweils ein eigenes Konto in der jeweiligen Instanz.
> Jede Anmeldung erscheint im Audit-Log der jeweiligen Instanz. Das ist so
> gewollt: Man sieht, dass das Gegenbuch arbeitet und wenn es aufhört, fällt
> auch das auf.
### Wer redet mit wem ### Wer redet mit wem
``` ```
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Token nur mit audit:read) Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
OpenCRM ─────────────────> (kennt das Gegenbuch nicht) OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
``` ```
@@ -68,7 +411,14 @@ tools/audit-notary/data/prod/
``` ```
Der Inhalt ist vom Repository ausgenommen der Signaturschlüssel gehört dort Der Inhalt ist vom Repository ausgenommen der Signaturschlüssel gehört dort
nicht hinein. Die Verzeichnisse selbst sind über `.gitkeep` vorhanden, damit nicht hinein.
**Zu Dateirechten:** Der Container startet kurz als `root`, setzt das
Datenverzeichnis auf den Arbeitsbenutzer um und gibt die Privilegien dann ab.
Das ist nötig, weil das Verzeichnis vom Host kommt wer das Projekt als `root`
geklont hat, hätte sonst ein Verzeichnis, in das der Container nicht schreiben
darf. Passiert automatisch, du musst nichts tun. Ein anderer Zielbenutzer geht
über `PUID`/`PGID`. Die Verzeichnisse selbst sind über `.gitkeep` vorhanden, damit
sie nach einem `git clone` schon existieren und Docker sie nicht als `root` sie nach einem `git clone` schon existieren und Docker sie nicht als `root`
anlegt. anlegt.
@@ -102,6 +452,21 @@ bei jedem Lauf, damit sie nicht in Vergessenheit gerät.
--- ---
---
# Zusätzliche Härtung: externes Repository
> **Alles ab hier gilt nur für die zweite Betriebsart.** Wer das Gegenbuch
> lokal auf einer eigenen Maschine betreibt der Normalfall, siehe oben kann
> diesen gesamten Abschnitt überspringen. Die Anforderungen darin (Git-Server,
> Rewind-Sperre, geschützte Refs) beziehen sich auf ein zusätzliches Repository
> auf einem dritten Server und existieren im lokalen Betrieb nicht.
Sinn der Variante: Beim lokalen Betrieb liegt das Buch auf demselben Rechner
wie der Signaturschlüssel. Wer diesen Rechner übernimmt, kann beides
manipulieren. Ein zusätzliches Repository auf einem dritten Server, das nur
Anhängen erlaubt, schließt auch das vorausgesetzt, dessen Regeln stimmen.
## Einrichten ohne Docker ## Einrichten ohne Docker
Auf einem **anderen** Rechner als dem CRM-Server: Auf einem **anderen** Rechner als dem CRM-Server:
@@ -294,7 +659,7 @@ Deshalb gilt jetzt:
|---|---| |---|---|
| 0 | alles in Ordnung, Checkpoint angehängt (bzw. Prüfung bestanden) | | 0 | alles in Ordnung, Checkpoint angehängt (bzw. Prüfung bestanden) |
| 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) | | 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) |
| 2 | **Befund** Widerspruch zwischen CRM und Gegenbuch, oder ungültige Signatur | | 2 | **Befund** Widerspruch zwischen CRM und Gegenbuch, ungültige Signatur, oder die Wurzel des Bestandssiegels hat sich geändert (`NOTARY_SEAL_ACK`) |
| 5 | **Anker unvollständig** die Kette ist gültig, aber `refs/notary/seq-N` fehlt. Reparierbar durch einen Notar-Schreiblauf | | 5 | **Anker unvollständig** die Kette ist gültig, aber `refs/notary/seq-N` fehlt. Reparierbar durch einen Notar-Schreiblauf |
| 4 | **Wächter-Gedächtnis fehlt** Erstinbetriebnahme unbestätigt, oder Speicher nach der Etablierung verloren | | 4 | **Wächter-Gedächtnis fehlt** Erstinbetriebnahme unbestätigt, oder Speicher nach der Etablierung verloren |
| 3 | beglaubigter Stand nicht abschließend feststellbar Remote fehlt/unerreichbar, erste Beobachtung, Zurückspulen nicht ausschließbar, **oder** Checkpoint erstellt aber nicht verankert | | 3 | beglaubigter Stand nicht abschließend feststellbar Remote fehlt/unerreichbar, erste Beobachtung, Zurückspulen nicht ausschließbar, **oder** Checkpoint erstellt aber nicht verankert |
@@ -343,6 +708,9 @@ an, bis er geklärt ist.
| Untergeschobener Commit | Commit ohne gültige Signatur in der Historie | | Untergeschobener Commit | Commit ohne gültige Signatur in der Historie |
| Nie gepushte lokale Commits | Abgleich gegen den Remote-Kopf | | Nie gepushte lokale Commits | Abgleich gegen den Remote-Kopf |
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen | | Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt `valid` allein bleibt dabei `true`** |
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
| **`cleanup` + `rehash` ohne erneutes Siegeln** (Wäsche ohne Datenbankzugriff) | **neue Neuberechnung seit der letzten Beglaubigung `valid` und Kette sind danach makellos** |
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
nichts an** der manipulierte Zustand wird also nicht als neue Wahrheit nichts an** der manipulierte Zustand wird also nicht als neue Wahrheit
+10 -2
View File
@@ -26,13 +26,18 @@ services:
environment: environment:
INSTANZ: prod INSTANZ: prod
CRM_URL: ${PROD_CRM_URL} CRM_URL: ${PROD_CRM_URL}
CRM_TOKEN: ${PROD_CRM_TOKEN} CRM_EMAIL: ${PROD_CRM_EMAIL}
CRM_PASSWORD: ${PROD_CRM_PASSWORD}
NOTARY_INTERVAL: ${PROD_INTERVAL:-3600} NOTARY_INTERVAL: ${PROD_INTERVAL:-3600}
NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost} NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost}
# Nur beim allerersten Lauf einmalig auf true, danach wieder leeren: # Nur beim allerersten Lauf einmalig auf true, danach wieder leeren:
NOTARY_GENESIS_ACK: ${PROD_GENESIS_ACK:-} NOTARY_GENESIS_ACK: ${PROD_GENESIS_ACK:-}
# Nur nach geklärtem Verlust des Beobachtungsspeichers, siehe README: # Nur nach geklärtem Verlust des Beobachtungsspeichers, siehe README:
NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-} NOTARY_ADOPT_ACK: ${PROD_ADOPT_ACK:-}
# Nur nach einem GEWOLLTEN Siegelwechsel, mit der neuen Wurzel:
NOTARY_SEAL_ACK: ${PROD_SEAL_ACK:-}
# Nur nach einer GEWOLLTEN Neuberechnung, mit der ID des Rehash-Eintrags:
NOTARY_REHASH_ACK: ${PROD_REHASH_ACK:-}
volumes: volumes:
# Buch, Schlüssel, Beobachtungsspeicher und Statusdatei. # Buch, Schlüssel, Beobachtungsspeicher und Statusdatei.
# Dieses Verzeichnis ist das Gegenbuch es gehört ins Backup. # Dieses Verzeichnis ist das Gegenbuch es gehört ins Backup.
@@ -46,10 +51,13 @@ services:
environment: environment:
INSTANZ: staging INSTANZ: staging
CRM_URL: ${STAGING_CRM_URL} CRM_URL: ${STAGING_CRM_URL}
CRM_TOKEN: ${STAGING_CRM_TOKEN} CRM_EMAIL: ${STAGING_CRM_EMAIL}
CRM_PASSWORD: ${STAGING_CRM_PASSWORD}
NOTARY_INTERVAL: ${STAGING_INTERVAL:-3600} NOTARY_INTERVAL: ${STAGING_INTERVAL:-3600}
NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost} NOTAR_EMAIL: ${NOTAR_EMAIL:-gegenbuch@localhost}
NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-} NOTARY_GENESIS_ACK: ${STAGING_GENESIS_ACK:-}
NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-} NOTARY_ADOPT_ACK: ${STAGING_ADOPT_ACK:-}
NOTARY_SEAL_ACK: ${STAGING_SEAL_ACK:-}
NOTARY_REHASH_ACK: ${STAGING_REHASH_ACK:-}
volumes: volumes:
- ${STAGING_DIR:-./data/staging}:/gegenbuch - ${STAGING_DIR:-./data/staging}:/gegenbuch
+40 -3
View File
@@ -4,7 +4,8 @@ set -uo pipefail
: "${INSTANZ:?INSTANZ fehlt (z. B. prod oder staging)}" : "${INSTANZ:?INSTANZ fehlt (z. B. prod oder staging)}"
: "${CRM_URL:?CRM_URL fehlt von welcher OpenCRM-Instanz soll geholt werden?}" : "${CRM_URL:?CRM_URL fehlt von welcher OpenCRM-Instanz soll geholt werden?}"
: "${CRM_TOKEN:?CRM_TOKEN fehlt Token eines Benutzers mit audit:read}" : "${CRM_EMAIL:?CRM_EMAIL fehlt Dienstkonto im CRM mit dem Recht audit:read}"
: "${CRM_PASSWORD:?CRM_PASSWORD fehlt Passwort dieses Dienstkontos}"
INTERVALL="${NOTARY_INTERVAL:-3600}" INTERVALL="${NOTARY_INTERVAL:-3600}"
BUCH=/gegenbuch/buch BUCH=/gegenbuch/buch
@@ -13,6 +14,22 @@ export NOTARY_STATE_FILE=/gegenbuch/beobachtungen.jsonl
export NOTARY_ALLOW_LOCAL=true # Buch liegt auf dieser Maschine export NOTARY_ALLOW_LOCAL=true # Buch liegt auf dieser Maschine
export NOTARY_PUSH=false # es gibt kein Ziel zum Pushen export NOTARY_PUSH=false # es gibt kein Ziel zum Pushen
# --- Rechte geraderuecken, dann Privilegien abgeben ------------------------
# Laeuft der Container als root, gehoert das Bind-Mount-Verzeichnis vermutlich
# ebenfalls root (typisch, wenn das Projekt als root geklont wurde). Wir setzen
# es auf den Arbeitsbenutzer um und starten uns selbst als dieser neu.
BENUTZER_UID="${PUID:-1000}"
BENUTZER_GID="${PGID:-1000}"
if [ "$(id -u)" = "0" ]; then
if ! chown -R "$BENUTZER_UID:$BENUTZER_GID" /gegenbuch 2>/dev/null; then
echo "[$INSTANZ] HINWEIS: Rechte auf /gegenbuch liessen sich nicht setzen." >&2
echo "[$INSTANZ] Falls es gleich an fehlenden Schreibrechten scheitert, auf dem Host:" >&2
echo "[$INSTANZ] chown -R $BENUTZER_UID:$BENUTZER_GID <datenverzeichnis>" >&2
fi
exec setpriv --reuid="$BENUTZER_UID" --regid="$BENUTZER_GID" --init-groups \
--inh-caps=-all "$0" "$@"
fi
echo "[$INSTANZ] Gegenbuch startet Quelle: $CRM_URL, Takt: ${INTERVALL}s" echo "[$INSTANZ] Gegenbuch startet Quelle: $CRM_URL, Takt: ${INTERVALL}s"
# --- Signaturschlüssel: einmalig auf DIESER Maschine erzeugen --------------- # --- Signaturschlüssel: einmalig auf DIESER Maschine erzeugen ---------------
@@ -53,8 +70,16 @@ fi
# Logs zu durchsuchen. # Logs zu durchsuchen.
while true; do while true; do
ZEIT=$(date -Iseconds) ZEIT=$(date -Iseconds)
node /opt/notary/notary.mjs # Ausgabe mitschneiden, um den GRUND in die Statusdatei zu bekommen.
#
# Vorher stand dort nur das Etikett ("BEFUND"), und das ist fuer jeden
# Exit-2 dasselbe - ein echter Widerspruch sieht darin aus wie eine
# quittierpflichtige Erstsiegelung. Wer nur die Statusdatei liest, kann
# nicht entscheiden, ob er handeln muss. Genau die Sorte Signal, gegen die
# dieses Werkzeug gebaut ist.
node /opt/notary/notary.mjs > /tmp/lauf.log 2>&1
CODE=$? CODE=$?
cat /tmp/lauf.log
case $CODE in case $CODE in
0) LAGE="in Ordnung" ;; 0) LAGE="in Ordnung" ;;
1) LAGE="Betriebsfehler (Konfiguration, CRM nicht erreichbar, Commit)" ;; 1) LAGE="Betriebsfehler (Konfiguration, CRM nicht erreichbar, Commit)" ;;
@@ -65,6 +90,18 @@ while true; do
*) LAGE="unbekannter Code" ;; *) LAGE="unbekannter Code" ;;
esac esac
echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)" echo "[$INSTANZ] $ZEIT exit=$CODE ($LAGE)"
printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE" > /gegenbuch/status.txt # Die aussagekraeftigste Zeile mitnehmen, nicht einfach die erste.
#
# Bei Alarm ist das die ALARM-Zeile. Sonst die Zusammenfassung ("OK: …") -
# denn ein Lauf kann davor beilaeufige Hinweise ausgeben (etwa das einmalige
# Setzen einer Ueberwachungs-Grundlage), und die verdeckten sonst das
# Ergebnis. Erst wenn beides fehlt, die erste Zeile ueberhaupt.
GRUND=$(grep -m1 '^ALARM: ' /tmp/lauf.log | sed 's/^ALARM: //')
[ -z "$GRUND" ] && GRUND=$(grep -m1 '^OK: ' /tmp/lauf.log)
[ -z "$GRUND" ] && GRUND=$(grep -m1 -v '^[[:space:]]*$' /tmp/lauf.log)
{
printf '%s exit=%s %s\n' "$ZEIT" "$CODE" "$LAGE"
[ -n "$GRUND" ] && printf 'Grund: %s\n' "$GRUND"
} > /gegenbuch/status.txt
sleep "$INTERVALL" sleep "$INTERVALL"
done done
+247 -8
View File
@@ -132,8 +132,49 @@ if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschu
); );
process.exit(1); process.exit(1);
} }
if (!CRM_URL || !CRM_TOKEN) { const CRM_EMAIL = process.env.CRM_EMAIL;
console.error('CRM_URL und CRM_TOKEN müssen gesetzt sein.'); const CRM_PASSWORD = process.env.CRM_PASSWORD;
// Bestaetigung fuer einen Wechsel der Siegelwurzel (Pentest R185-01).
//
// Ein Wechsel loest Alarm aus und der Alarm bricht ab, BEVOR der neue Stand
// ins Gegenbuch kommt. Ohne Bestaetigungsweg wuerde deshalb auch ein voellig
// legitimes Siegeln von da an bei jedem Lauf erneut alarmieren, ohne dass der
// Betreiber es je aufloesen koennte. Genau daran stirbt eine Warnung
// (dieselbe Lehre wie R183-03).
//
// Bestaetigt wird deshalb nicht pauschal mit „true“, sondern mit der WURZEL,
// die man akzeptiert mindestens 16 Zeichen. Ein versehentlich stehen
// gelassener Wert passt beim naechsten Wechsel nicht mehr und kann darum
// keinen weiteren Austausch stillschweigend durchwinken. Das ist der
// Unterschied zu NOTARY_GENESIS_ACK, wo genau diese Falle dokumentiert
// werden musste.
const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim();
const siegelBestaetigt = (wurzel) =>
SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);
// Bestaetigung fuer eine Neuberechnung der Kette (Pentest R186, Frage (b)).
//
// Bestaetigt wird mit der ID des juengsten Rehash-Eintrags. IDs steigen
// streng, ein stehen gelassener Wert passt also beim naechsten Rehash nicht
// mehr - dasselbe selbstentwertende Prinzip wie bei NOTARY_SEAL_ACK, nur mit
// einem Wert, den man vorlesen kann.
const REHASH_ACK = (process.env.NOTARY_REHASH_ACK || '').trim();
// Wurde in diesem Lauf eine Neuberechnung ausdruecklich bestaetigt? Dann ist
// der veraenderte Kettenkopf die erwartete Folge und kein eigener Befund.
let rehashBestaetigt = false;
if (!CRM_URL) {
console.error('CRM_URL muss gesetzt sein.');
process.exit(1);
}
if (!CRM_TOKEN && !(CRM_EMAIL && CRM_PASSWORD)) {
console.error(
'Es fehlt der Zugang zum CRM.\n' +
'Entweder CRM_EMAIL und CRM_PASSWORD eines Dienstkontos setzen (empfohlen \n' +
'das Gegenbuch meldet sich dann bei jedem Lauf selbst an), oder ein bereits\n' +
'vorhandenes CRM_TOKEN mitgeben (läuft nach 15 Minuten ab, nur für Tests).',
);
process.exit(1); process.exit(1);
} }
@@ -180,13 +221,63 @@ if (SIGN && !PIN) {
} }
} }
async function hole(pfad) { // Access-Tokens leben nur 15 Minuten ein dauerhaft hinterlegtes Token waere
// beim naechsten stuendlichen Lauf laengst abgelaufen. Deshalb meldet sich das
// Gegenbuch mit einem eigenen Dienstkonto an und holt sich pro Lauf ein
// frisches. Das Konto braucht ausschliesslich die Berechtigung `audit:read`.
let zugangsToken = CRM_TOKEN || null;
async function anmelden() {
let r;
try {
r = await fetch(`${CRM_URL}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: CRM_EMAIL, password: CRM_PASSWORD }),
});
} catch (e) {
console.error(
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
` Grund: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
if (r.status === 401) {
console.error(
'Anmeldung am CRM abgelehnt E-Mail oder Passwort des Dienstkontos stimmen nicht.',
);
process.exit(1);
}
if (r.status === 429) {
console.error('Das CRM hat die Anmeldung wegen zu vieler Versuche gebremst. Später erneut.');
process.exit(1);
}
if (!r.ok) {
console.error(`Anmeldung am CRM fehlgeschlagen: HTTP ${r.status}`);
process.exit(1);
}
const j = await r.json();
if (!j.success || !j.data?.token) {
console.error(`Anmeldung am CRM fehlgeschlagen: ${j.error || 'kein Token in der Antwort'}`);
process.exit(1);
}
zugangsToken = j.data.token;
}
async function hole(pfad, methode = 'GET') {
// Fehler werden hier zu einer erklaerenden Zeile frueher flog ein // Fehler werden hier zu einer erklaerenden Zeile frueher flog ein
// Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer // Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer
// git-Meldungen schon abgestellt war. // git-Meldungen schon abgestellt war.
let r; let r;
try { try {
r = await fetch(`${CRM_URL}${pfad}`, { headers: { Authorization: `Bearer ${CRM_TOKEN}` } }); r = await fetch(`${CRM_URL}${pfad}`, {
method: methode,
headers: {
Authorization: `Bearer ${zugangsToken}`,
...(methode === 'POST' ? { 'content-type': 'application/json' } : {}),
},
...(methode === 'POST' ? { body: '{}' } : {}),
});
} catch (e) { } catch (e) {
console.error( console.error(
`Das CRM ist nicht erreichbar (${CRM_URL}).\n` + `Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
@@ -672,8 +763,20 @@ if (Number.isFinite(MIN_SEQ) && bisher.length < MIN_SEQ) {
// 4) Abgleich mit dem CRM, VOR dem Anhaengen. // 4) Abgleich mit dem CRM, VOR dem Anhaengen.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const letzter = bisher[bisher.length - 1]; const letzter = bisher[bisher.length - 1];
if (!CRM_TOKEN) await anmelden();
const aktuell = await hole('/api/audit-logs/checkpoint'); const aktuell = await hole('/api/audit-logs/checkpoint');
// Die Vollpruefung wird IMMER geholt, auch beim allerersten Lauf: Aus ihr
// stammt die Zahl der protokollierten Neuberechnungen, und die gehoert schon
// in den ersten Eintrag - sonst gaebe es beim zweiten Lauf keine Grundlage,
// gegen die sich ein neuer Rehash abheben koennte.
const pruefung = await hole('/api/audit-logs/verify', 'POST');
const rehashListe = Array.isArray(pruefung?.rehashes) ? pruefung.rehashes : null;
const rehashAnzahl = rehashListe ? rehashListe.length : null;
const rehashLetzte = rehashListe && rehashListe.length
? rehashListe[rehashListe.length - 1].id
: null;
if (letzter) { if (letzter) {
if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) { if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
alarm( alarm(
@@ -681,9 +784,93 @@ if (letzter) {
`jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`, `jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`,
); );
} }
// `valid: false` wird HART gewertet, unabhaengig von der erklaerenden Prosa
// (Pentest R183-02). Ein Cleanup mit abgesenkter Aufbewahrung kann tausende
// Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
// ausweisen die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
// das Feld, nicht der Satz.
if (pruefung && pruefung.valid === false) {
alarm(
'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
` CRM-Text: ${pruefung.message}\n` +
'Auch wenn dieser Text harmlos klingt: Es wird nichts beglaubigt, solange die\n' +
'Prüfung nicht sauber ist. Häufigste Ursache ist ein Cleanup mit verkürzter\n' +
'Aufbewahrung dann wurden Einträge endgültig gelöscht.',
);
}
const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`); const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
// Ein fehlender beglaubigter Eintrag ist IMMER ein Befund - auch wenn eine
// Neuberechnung bestaetigt wurde. Bestaetigt wird der Rehash, nicht das
// Verschwinden von Zeilen.
if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`); if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);
if (rueck.atHash !== letzter.chainHead) {
// ---------------------------------------------------------------
// Neuberechnung der Kette (Rehash) als EIGENE Alarmbedingung.
//
// Ein Rehash wurde bisher nur als Nebenwirkung gefangen: Er aendert jeden
// Hash, also stimmt der beglaubigte Kettenkopf nicht mehr und der Vergleich
// weiter oben schlaegt an. Das funktioniert - aber es ist ein Zufallstreffer
// und keine gebaute Warnung. Verschoebe sich der Anker irgendwann (anderer
// beglaubigter Wert, anderer Vergleich), waere der Melder lautlos weg.
//
// Deshalb haengt der Alarm jetzt an der Waffe selbst, nicht an ihrer Spur -
// dieselbe Lehre wie R184-01 (Gate am Ausloeser statt an der Wirkung) und
// R185-01 (Wurzelwechsel statt `valid`).
//
// Das Restrisiko, das dieser Melder abdeckt: `cleanup` + `rehash` OHNE
// erneutes Siegeln, ueber Inhalt, der gar nicht versiegelt ist. Dort greift
// weder das Bestandssiegel noch der Wurzelwechsel.
if (rehashAnzahl !== null && typeof letzter.rehashCount === 'number') {
if (rehashAnzahl > letzter.rehashCount) {
const neue = rehashAnzahl - letzter.rehashCount;
if (REHASH_ACK && String(rehashLetzte) === REHASH_ACK) {
rehashBestaetigt = true;
console.log(
`Neuberechnung bestätigt (NOTARY_REHASH_ACK=${REHASH_ACK}). ` +
'NOTARY_REHASH_ACK danach wieder leeren.',
);
} else {
const l = rehashListe[rehashListe.length - 1];
const vb = l.vorbefund;
alarm(
`Die Hash-Kette wurde neu berechnet (${neue} neue${neue === 1 ? 'r' : ''} Vorgang` +
`${neue === 1 ? '' : 'e'} seit der letzten Beglaubigung).\n` +
` zuletzt: ${l.zeitpunkt} (Eintrag ${l.id}, ${l.neuBerechnet} Zeilen)\n` +
(vb
? ` Befund unmittelbar davor: ${vb.manipuliert} beanstandet, ${vb.luecken} Lücken\n`
: ' Der Zustand davor ist nicht mehr feststellbar.\n') +
(l.signiert ? '' : ' ACHTUNG: Dieser Rehash-Eintrag trägt keine gültige Signatur.\n') +
'Ein Rehash verknüpft alle Einträge neu. Lücken, die vorher eine Löschung\n' +
'sichtbar gemacht hätten, verschwinden dabei aus der Kette die Prüfung im CRM\n' +
'meldet danach wieder „lückenlos". Genau deshalb ist das hier ein Alarm und\n' +
'kein Hinweis.\n' +
'War es geplant, bestätigen und danach wieder leeren:\n' +
` NOTARY_REHASH_ACK=${rehashLetzte}`,
);
}
}
} else if (rehashAnzahl !== null && typeof letzter.rehashCount !== 'number') {
// Gegenbuch aus der Zeit vor diesem Melder: Es gibt keine Grundlage, gegen
// die sich etwas abheben koennte. Stillschweigend zu alarmieren waere ein
// Fehlalarm bei jedem Bestandsbuch; stillschweigend zu uebernehmen waere
// eine unsichtbare Annahme. Also: uebernehmen und es sagen.
console.log(
`Grundlage für die Rehash-Überwachung wird gesetzt: ${rehashAnzahl} protokollierte ` +
'Neuberechnung(en) im CRM. Ab dem nächsten Lauf fällt jede weitere auf.',
);
}
// Der Kopf-Hash-Vergleich steht bewusst NACH der Rehash-Pruefung.
//
// Zwei Gruende: Erstens benennt der Rehash-Alarm die Ursache, waehrend
// „Eintrag wurde veraendert“ nur die Wirkung beschreibt - bei gleicher Lage
// ist die praezisere Meldung die nuetzlichere. Zweitens aendert eine
// Neuberechnung ZWANGSLAEUFIG jeden Hash; stuende dieser Vergleich davor
// oder ungeschuetzt dahinter, liesse sich eine bestaetigte Neuberechnung nie
// aufloesen und die Bestaetigung waere wertlos.
if (rueck.atHash !== letzter.chainHead && !rehashBestaetigt) {
alarm( alarm(
`Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` + `Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`, ` beglaubigt: ${letzter.chainHead}\n jetzt : ${rueck.atHash}`,
@@ -695,10 +882,59 @@ if (letzter) {
if (letzter.sealLeafCount > 0 && aktuell.sealLeafCount === 0) { if (letzter.sealLeafCount > 0 && aktuell.sealLeafCount === 0) {
alarm('Die Blattwerte des Bestandssiegels wurden entfernt.'); alarm('Die Blattwerte des Bestandssiegels wurden entfernt.');
} }
// Wechsel der Siegelwurzel ist ein ALARM, kein Hinweis (Pentest R185-01).
//
// Vorher stand hier ein console.warn und der Rueckgabecode blieb 0 also
// genau das Muster, das wir dem CRM zweimal angekreidet haben: die Warnung
// steht in der Prosa, die Maschine meldet „in Ordnung“. Das ist hier
// besonders teuer, weil `valid` ein ERSETZENDES Siegel per Konstruktion
// ueberlebt: Wer eine Altzeile per Datenbankzugriff entfernt und danach neu
// siegelt, bekommt eine passende Wurzel und eine beglaubigte Luecke. Die
// Vollpruefung sagt dann `true`. Der Wurzelwechsel ist der EINZIGE
// maschinell erkennbare Anker dagegen und der gehoert in den Alarm.
//
// Ein legitimes Neu-Siegeln loest hier einmal aus. Das ist gewollt: ein
// Austausch der Beweisgrundlage soll einmal wehtun und bestaetigt werden,
// statt lautlos durchzulaufen.
if (letzter.sealRoot && aktuell.sealRoot && letzter.sealRoot !== aktuell.sealRoot) { if (letzter.sealRoot && aktuell.sealRoot && letzter.sealRoot !== aktuell.sealRoot) {
console.warn( if (siegelBestaetigt(aktuell.sealRoot)) {
`HINWEIS: Das Bestandssiegel wurde erneuert (${letzter.sealRoot.slice(0, 12)}… → ` + console.log(
`${aktuell.sealRoot.slice(0, 12)}). Legitim nach einem Retention-Lauf sonst prüfen.`, `Siegelwechsel bestätigt (NOTARY_SEAL_ACK): ${letzter.sealRoot.slice(0, 16)}` +
`${aktuell.sealRoot.slice(0, 16)}…. Die neue Wurzel wird beglaubigt.\n` +
' NOTARY_SEAL_ACK danach wieder leeren.',
);
} else alarm(
'Das Bestandssiegel wurde ERSETZT die beglaubigte Grundlage ist eine andere.\n' +
` beglaubigt: ${letzter.sealRoot.slice(0, 16)}… (${letzter.sealLeafCount} Blätter)\n` +
` jetzt : ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
'Ein erneutes Siegeln schreibt den AKTUELLEN Stand des Altbestands fest. Wurden\n' +
'vorher Einträge entfernt, sind deren Lücken danach beglaubigt und die Prüfung im\n' +
'CRM meldet wieder valid:true dieser Wurzelwechsel ist die einzige Spur davon.\n' +
'Wenn das keine geplante Maßnahme war: Im CRM das Ereignis AUDIT_SEAL_CHANGED und\n' +
'die CRITICAL-Zeile zu /api/audit-logs/seal-backlog ansehen (wer, wann, Vorbefund).\n' +
'War es geplant, den Wechsel bestätigen und danach wieder leeren:\n' +
` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
);
}
// Erstsiegelung: vorher nichts beglaubigt, jetzt eine Wurzel. Das ist der
// eine legitime Einrichtungsschritt aber auch das Fenster, in dem ein
// beschnittener Altbestand einmalig festgeschrieben werden koennte. Deshalb
// ebenfalls melden, mit eigenem Text statt stillschweigend zu uebernehmen.
if (!letzter.sealRoot && aktuell.sealRoot) {
if (siegelBestaetigt(aktuell.sealRoot)) {
console.log(
`Erstsiegelung bestätigt (NOTARY_SEAL_ACK): ${aktuell.sealRoot.slice(0, 16)}…. ` +
'Die Wurzel wird beglaubigt.\n NOTARY_SEAL_ACK danach wieder leeren.',
);
} else alarm(
'Erstmals ein Bestandssiegel gesetzt bisher war keines beglaubigt.\n' +
` Wurzel: ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
'War das dein Einrichtungsschritt, ist alles in Ordnung: Der nächste Lauf läuft\n' +
'wieder auf 0, sobald diese Wurzel im Gegenbuch steht.\n' +
'War es das NICHT, dann hat jemand den Stand des Altbestands festgeschrieben \n' +
'samt aller Lücken, die zu diesem Zeitpunkt bestanden.\n' +
'Zum Bestätigen setzen und danach wieder leeren:\n' +
` NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
); );
} }
} }
@@ -764,6 +1000,9 @@ const eintrag = {
chainHead: aktuell.chainHead, chainHead: aktuell.chainHead,
sealRoot: aktuell.sealRoot, sealRoot: aktuell.sealRoot,
sealLeafCount: aktuell.sealLeafCount, sealLeafCount: aktuell.sealLeafCount,
// Grundlage fuer die Rehash-Ueberwachung des naechsten Laufs.
rehashCount: rehashAnzahl,
rehashLast: rehashLetzte,
}; };
const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n'; const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n';