Compare commits

..
123 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
duffyduckandClaude Opus 5 cb9f1f5fce Gegenbuch: Daten im Projektverzeichnis statt in Docker-Volumes
Projektkonvention wie beim Hauptstack: Bind-Mount auf
tools/audit-notary/data/<instanz>/ statt benannter Volumes.

Wichtiger Nebeneffekt, der vorher fehlte: Das Verzeichnis war nicht von der
Versionsverwaltung ausgenommen - Signaturschluessel und Gegenbuch waeren beim
naechsten Commit im Repository gelandet. Jetzt ist der Inhalt ignoriert,
waehrend die Verzeichnisse selbst ueber .gitkeep bestehen bleiben. Letzteres
ist noetig, weil Docker fehlende Bind-Mount-Ziele als root anlegt und der
Container als UID 1000 laeuft - der erste Start waere sonst am Schreibrecht
gescheitert.

Verifiziert mit echtem docker compose gegen eine CRM-Attrappe: Buch,
Schluessel, Beobachtungsspeicher und status.txt landen unter
tools/audit-notary/data/prod/, Normalbetrieb exit 0. Testcontainer, Image,
Attrappe und .env danach entfernt; nur die drei .gitkeep bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:54:45 +02:00
duffyduckandClaude Opus 5 a62c51b7c7 Gegenbuch als Docker-Setup, lokales Buch auf eigener Maschine
Betreiber-Entscheidung: Das Gegenbuch laeuft auf einer eigenen Maschine fuer
Prod und Staging; ein externes Git-Repository entfaellt, das Buch liegt lokal.
Die Trennung, auf die es ankommt, ist damit gegeben - wer OpenCRM uebernimmt,
kommt nicht ans Buch.

Richtung bewusst so herum: Das Gegenbuch holt ueber HTTPS mit einem Token, das
nur audit:read kann. OpenCRM kennt weder Adresse noch Schluessel des
Gegenbuchs. Kein SSH-Zugang zum CRM noetig.

tools/audit-notary/ enthaelt jetzt Dockerfile, entrypoint.sh,
docker-compose.yml und .env.example. Zwei Dienste (prod, staging) mit
getrennten Verzeichnissen und Schluesseln, gesteuert ueber COMPOSE_PROFILES -
dasselbe Muster wie beim Caddy-Profil im Hauptstack. Der Signaturschluessel
wird beim ersten Start auf der Gegenbuch-Maschine erzeugt.

Lokaler Betrieb ist jetzt ein vollwertiger Modus statt eines Testschalters. Die
Erfolgsmeldung benennt bei jedem Lauf, was abgedeckt ist und was nicht - statt
der frueheren pauschalen Formulierung "kein Manipulationsschutz", die im
Einsatz auf eigener Maschine schlicht falsch war.

Verifiziert mit echtem Docker-Build gegen eine CRM-Attrappe: Genesis ohne
Bestaetigung -> Code 4; mit Bestaetigung Normalbetrieb exit 0; Eintrag
veraendert -> Alarm exit 2; Eintraege geloescht -> Alarm exit 2; Siegel
verschwunden -> Alarm.

Behoben beim Bauen: useradd -u 1000 || true verschluckte, dass UID 1000 im
Node-Image vergeben ist - der Container startete gar nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:51:45 +02:00
duffyduckandClaude Opus 5 773033936d Gegenbuch: Pruefmodus schreibt nicht mehr, Widerspruch aufgeloest (R181)
R181-01: Der Pruefmodus sagte zu, nichts zu veraendern und kein Schreibrecht zu
brauchen - und pushte trotzdem, weil ankerNachziehen() in jedem Modus lief. Ein
read-only Audit mutierte damit still das geteilte Substrat.

R181-02: Ein Widerspruch zwischen den eigenen Fixes. R180-01 erhebt die
Serversperre auf refs/notary/* zur tragenden Pflicht, R180-02 verlangt dort
Schreibrecht zur Selbstheilung. Sobald je ein Anker fehlte, bekam jeder
read-only pruefende Auditor dauerhaft einen Fehler auf einer voellig gueltigen
Kette, den er nicht beheben konnte. Fix: Reparieren nur im Notar-Schreiblauf,
im Pruefmodus wird der fehlende Anker gemeldet.

Eigener Rueckgabecode 5: "Anker unvollstaendig" ist nicht "nicht feststellbar" -
die Kette ist gueltig, nur das Substrat-Gedaechtnis unvollstaendig, ein
benannter reparierbarer Defekt. Dieselbe Trennung wie bei Genesis/Adoption.

Empirisch beantwortet: Die Notar-Identitaet laesst sich eng auf das Anlegen von
refs/notary/* beschraenken, ohne Loeschen oder Ueberschreiben - serverseitig
unterscheidbar an der Null-OID. Mit pre-receive-Hook verifiziert: Backfill
greift, Loeschen und Force-Overwrite bleiben abgewiesen. Hook als Beispiel in
der README.

Verifiziert: read-only --check mit fehlendem Anker -> Code 5, nichts gepusht;
Notar-Schreiblauf unter derselben ACL -> Anker nachgetragen, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:56:36 +02:00
duffyduckandClaude Opus 5 a954f0f736 Gegenbuch: Anker belegen nichts mehr, Anker-Verlust wird laut (R180-01/-02)
R180-01: Der Code nahm den hoechsten noch vorhandenen refs/notary/seq-* als
"hoechsten je". Loescht ein Angreifer nur die oberen Anker und laesst einen
niedrigeren stehen, senkt er den Vergleichswert selbst - ein frischer
Auditoren-Klon meldete OK, exit 0 auf gewaschenem Stand. Perverser Gradient:
Wer alle Refs loeschte, flog auf (Code 4); wer weniger loeschte, kam durch,
weil ankerBelegt sowohl die Gewissheit begruendete als auch den
Code-4-Diskriminator kurzschloss.

Fix: Anker begruenden keine Gewissheit mehr. Sie koennen ein Zurueckspulen
widerlegen, aber nie Unversehrtheit belegen. Der Code-4-Diskriminator haengt
nicht mehr an ihnen und greift nur im Schreiblauf.

R180-02: Der Anker-Push-Fehlschlag war nur eine Warnung mit exit 0 -
ausgerechnet bei der tragenden Eigenschaft. Der Normalbetrieb senkte damit den
Hoechststand still um eins. Fix: exit 3 bei Fehlschlag, und jeder Lauf zieht
fehlende Anker nach, bevor er etwas als OK meldet.

Verifiziert: Teil-Loeschung mit frischem Auditoren-Klon -> exit 3 mit Vorbehalt
statt exit 0; Anker-Push per Hook abgelehnt -> exit 3 statt Hinweis; Folgelauf
traegt den fehlenden Anker nach.

README: Der Schutz von refs/notary/* gegen Loeschen und Ueberschreiben ist von
der Fussnote zur tragenden Voraussetzung erhoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 10:59:07 +02:00
duffyduckandClaude Opus 5 1a252c5059 Gegenbuch: Gedaechtnis ins Substrat verlegt, Verlust laut behandelt (R180)
"Erste Beobachtung -> exit 3" galt genau einen Lauf. Danach baselinete der
Waechter auf den aktuellen Kopf - nach einem Rewind also auf den gewaschenen
Stand - und meldete dauerhaft gruen. Der Angreifer musste nur ein einziges
exit 3 ueberstehen, ausgerechnet den Code, den der Betreiber bei
Remote-Ausfaellen ohnehin staendig sieht. Damit war der Waechter, der Rewind
ohne Notar-Host-Integritaet fangen sollte, wieder an genau diese gekoppelt.

Fix: Jeder verankerte Checkpoint bekommt einen eigenen Ref refs/notary/seq-N.
Der ueberlebt einen Force-Push auf den Zweig - die hoechste je existierende
Nummer ist damit aus dem Server rekonstruierbar. Geprueft wird, ob der hoechste
verankerte Checkpoint noch im aktuellen Kopf enthalten ist und ob die Reihe
mindestens so lang ist wie verankert.

Laute Verlustbehandlung mit Diskriminator "traegt der Remote schon
Checkpoints?": keine Historie -> Genesis, einmalig NOTARY_GENESIS_ACK; Historie
vorhanden aber kein Gedaechtnis -> Anomalie, Code 4, keine stille Adoption,
erst nach NOTARY_ADOPT_ACK.

Verifiziert: Genesis ohne Bestaetigung -> Code 4; mit Bestaetigung Kette
aufgebaut samt refs/notary/seq-1..5; Rewind -> exit 2 auch nach Loeschen des
lokalen Speichers und bei jedem Folgelauf (vorher: ein exit 3, danach dauerhaft
gruen); zusaetzlich Anker-Refs geloescht -> Code 4 statt stiller Uebernahme.

Dokumentiert: Die Anker-Refs muessen serverseitig ebenfalls vor Loeschen und
Ueberschreiben geschuetzt sein, sonst verschiebt sich das Problem eine Ebene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 09:10:02 +02:00
duffyduckandClaude Opus 5 b6b6f7c0a7 Gegenbuch: Rewind-Waechter statt Probe-Push, Tor vor dem Anhaengen (R179 a/b)
(a) Probe-Push verworfen. Er haette nur den geprobten Ref beurteilt, die eigene
Push-Identitaet gemessen statt die des Angreifers (Bypass-Rechte fuer Admins
gehen genau dann auseinander, wenn es zaehlt), nur einen Zeitpunkt abgedeckt -
und einen zerstoerungsfreien Force-Push gibt es nicht: die bestaetigende
Beobachtung waere derselbe Vorgang wie der Schaden.

Stattdessen ein Fast-Forward-Waechter: Der beobachtete Remote-Kopf wird
ausserhalb des Klons festgehalten; beim naechsten Lauf muss der neue Kopf ein
Nachfahre des alten sein. Das erkennt das Ereignis statt die Regel abzufragen
und wirkt unabhaengig von serverseitigem Schutz. Ein belegter Fast-Forward
gilt als Nachweis und blendet den Rewind-Vorbehalt aus.

(b) Code 3 als Tor vor dem Anhaengen statt als Status danach: Der Schreiblauf
signiert mit dem neuen Checkpoint zugleich ueber den Bestand darunter - ist die
Basis ungeklaert, waere das Anhaengen selbst das Waschmittel. Grundlage nicht
feststellbar -> nichts anhaengen, exit 3. Erster Lauf -> Basislinie, ehrlich
gemeldet, exit 3. Anhaengen geklappt, Push gescheitert -> exit 3 mit "erstellt,
aber NICHT verankert". Nur Anhaengen + Push + belegte Verankerung -> exit 0.

Verifiziert: Basislinie exit 3; Folgelauf exit 0 ohne Vorbehalt; Rewind aus
einem frischen Auditoren-Klon ohne MIN_SEQ und ohne Zusicherung -> Alarm exit 2
(bisher stilles Gruen); kaputtes Push-Ziel -> "erstellt, aber nicht verankert"
exit 3, Folgelauf haelt den ungepushten Commit fail-closed an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:29:20 +02:00
duffyduckandClaude Opus 5 8d1ffc0df8 Gegenbuch: Unsicherheit erreicht jetzt den Rueckgabecode (Pentest R179)
R179-b: Der Rewind-Vorbehalt stand in der Ausgabe, der Exit blieb 0. Die eigene
README sagt "fuer Cron gilt: jeder Code ausser 0 gehoert gemeldet" - der
Zustand "ich bin an dieser Stelle blind" erreichte die Ueberwachung also nie.
Dieselbe Klasse wie R174, eine Ebene hoeher. Fix: Code 0 nur bei belegter
Gewissheit (Rewind-Schutz zugesichert oder Mindesthoehe erfuellt), sonst Code 3
- bewusst nicht mit dem Manipulationsalarm 2 verschmolzen.

R179-01: NOTARY_MIN_SEQ ist eine Untergrenze, kein Ist-Stand. Ein veralteter
Wert liess einen Teil-Rewind darueber lautlos durch, und die blosse Praesenz
einer Zahl blendete den Vorbehalt aus - MIN_SEQ=0 war ein Freibrief, ein
Tippfehler wurde still verschluckt. Ein veralteter Anker erzeugte damit ein
selbstbewussteres Ergebnis als gar keiner. Fix: Der Vorbehalt haengt allein an
NOTARY_REWIND_PROTECTED=true und benennt bei gesetztem MIN_SEQ dessen Grenze;
MIN_SEQ <= 0 oder unparsbar fuehrt zu exit 1 statt stiller Annahme.

Verifiziert gegen den Pentest-Aufbau (10 Checkpoints, Rewind auf 7, DB passend
gekuerzt, frischer Klon): nicht gesetzt -> exit 3 (vorher 0); 10 und 8 -> Alarm
2; 7 veraltet -> 0 mit Vorbehalt (vorher ohne); 0 und xyz -> exit 1 (vorher
stilles 0); Rewind-Schutz zugesichert -> 0 ohne Vorbehalt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:10:36 +02:00
duffyduckandClaude Opus 5 d50d8f6036 Gegenbuch: Rewind auf signierten Praefix benennbar gemacht (Pentest R178-01)
Das Signatur-Gate faengt Force-Push mit fremder oder unsignierter Historie -
aber ein Rewind auf einen aelteren, echt signierten Stand ist signaturseitig
einwandfrei. Angreifer spult origin/main auf einen frueheren Checkpoint zurueck
und kuerzt die Datenbank passend: alle Signaturen G, Pin korrekt, Reihe
lueckenlos. Ein Notar-Klon mit lokalem Vorlauf merkt es, ein frischer
Auditoren-Klon meldete OK - ausgerechnet im dokumentierten Pruef-Fall.

Das laesst sich im Skript nicht kryptographisch erkennen, die Historie ist echt.
Deshalb zwei Dinge statt eines Scheinfixes: NOTARY_MIN_SEQ als Bezugspunkt (ist
die Reihe kuerzer, Alarm; jeder Lauf nennt die Nummer), und ohne diesen
Bezugspunkt sagt die Erfolgsmeldung ausdruecklich, dass ein Zurueckspulen nicht
erkennbar war.

README: serverseitiger Rewind-Schutz (non-fast-forward verbieten) jetzt als
Pflicht formuliert, samt Begruendung und dem Hinweis, dass ein frischer Klon
den Rewind nicht sieht.

Kleinkram: NOTARY_SIGNER_FINGERPRINT wird beim Einlesen getrimmt (ein
Zeilenumbruch loeste 4/4 Fehlalarme aus, die auf den korrekten Fingerabdruck
zeigten); die "nie gepusht"-Meldung priorisiert Untersuchen statt Pushen.
Beim Testen selbst gefunden: der erste git-Aufruf war ungeschuetzt und warf bei
kaputtem Klon einen Stacktrace.

Verifiziert: Rewind 3->1 mit gekuerzter DB -> frischer Klon ohne Bezugspunkt OK
mit Vorbehalt, mit NOTARY_MIN_SEQ=3 -> Alarm exit 2; Pin mit Zeilenumbruch kein
Fehlalarm; regulaerer Lauf unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:46:02 +02:00
duffyduckandClaude Opus 5 8d2dfb8be1 Gegenbuch: Fingerabdruck-Pin verpflichtend und vollstaendig angewandt (R177)
R177-01 (MEDIUM): Der Pin war optional. Ohne ihn war der Vertrauensanker die
gesamte allowed_signers-Menge, nicht der eine Notar-Schluessel: ein zweiter
dort gelisteter Schluessel konnte das Gegenbuch waschen und force-pushen, und
%G? war G. R176-01 hatte "jeder selbst erzeugte Schluessel" geschlossen, "jeder
erlaubte Schluessel" blieb offen. Fix: Pin wird aus user.signingkey abgeleitet;
laesst er sich nicht bestimmen, wird abgebrochen statt die ganze Liste zu
akzeptieren.

R177-02 (MEDIUM): Der Schreib-Lauf prueft den frischen Commit nur auf %G?,
nicht auf den Pin. Ein Notar-Host mit falsch konfiguriertem Schluessel meldete
"beglaubigt" und pushte - und ab da war die Kette dauerhaft rot, behebbar nur
per Force-Push, den die Branch-Protection gerade verhindern soll. Fix:
Pin-Abgleich am frischen Commit vor dem Push, bei Abweichung Ruecknahme.

Kleinkram: NOTARY_ALLOW_LOCAL faerbt Erfolgsmeldungen ein und pusht nicht mehr
ins Leere; CRM-Fehler liefern eine erklaerende Zeile statt Node-Stacktrace.

Verifiziert mit drei SSH-Schluesseln gegen echten Remote: keyC-Angriff -> Alarm
exit 2 auch ohne gesetzten Pin; Schreiblauf mit falschem Schluessel ->
zurueckgerollt, nichts gepusht; CRM nicht erreichbar/401 -> saubere Meldung;
Lokalmodus eingefaerbt; saubere Historie ohne Fehlalarm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 21:09:58 +02:00
duffyduckandClaude Opus 5 5952fb1894 Gegenbuch: nur vertrauenswuerdige Signaturen, fail-closed ohne Remote (R176)
R176-01 (HIGH): Das Signatur-Gate akzeptierte %G? = G ODER U. Bei
SSH-Signaturen bedeutet U woertlich "gute Signatur, aber kein passender
Principal" - der Schluessel steht also NICHT in allowed_signers. Damit
passierte jeder selbst erzeugte Schluessel das Gate und der einzige
In-System-Vertrauensanker war wirkungslos. End-to-end reproduziert: Gegenbuch
mit fremdem Schluessel re-signiert und force-gepusht -> "OK", exit 0.
Vorbedingung war nur Remote-Schreibrecht, kein Host-Zugriff.
Fix: nur G an beiden Stellen, dazu optionales Pinnen des erwarteten
Signierschluessels ueber NOTARY_SIGNER_FINGERPRINT (%GF).

R176-02 (MEDIUM): War der Remote unerreichbar, fiel der Ablauf still auf HEAD
zurueck und die "nie gepusht"-Pruefung wurde uebersprungen - ausgerechnet unter
der Bedingung, die einen Push-Fehlschlag verursacht. --check meldete waehrend
eines Ausfalls gruenes Licht auf nicht notarisiertem Zustand. Fix: fail-closed
mit Code 3, ebenso ohne konfigurierten Remote (Testlauf nur mit
NOTARY_ALLOW_LOCAL=true).

Nebenbei: git-eigene Fehlermeldungen standen vor der eigenen Erklaerung, stderr
wird jetzt abgefangen und gezielt weitergereicht.

Verifiziert mit zwei SSH-Schluesseln gegen echten Remote: Angriff mit fremdem
Key + Force-Push -> Alarm exit 2 (vorher OK); Remote unerreichbar -> exit 3;
kein Remote -> exit 3; falscher Fingerabdruck-Pin -> Alarm; saubere Historie
ohne Fehlalarm. Rueckgabecodes in der README dokumentiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:26:55 +02:00
duffyduckandClaude Opus 5 375d4ae1e2 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)
R175-01 (HIGH): Die erste Fassung signierte zwar, prueft aber nie. Sie las ihre
Wahrheit per readFileSync aus der lokalen Arbeitsdatei, nirgends gab es ein
git verify-commit - das -S war write-only ohne Konsument. Live reproduziert:
DB-Tail abgeschnitten und die lokale Ledger-Zeile angepasst -> "OK, Checkpoint
beglaubigt", exit 0, kein Alarm.

Fix: Wahrheitsquelle ist der signierte Commit-Baum (bevorzugt der Remote-Kopf);
jeder Commit mit Gegenbuch-Aenderung muss eine gueltige Signatur tragen; weicht
die Arbeitsdatei vom signierten Stand ab, wird abgebrochen; lokale, nie
gepushte Commits gelten nicht als beglaubigt; die Signatur des frisch
erzeugten Commits wird gegengeprueft. Dazu ein Pruefmodus --check fuer
Auditoren.

R175-02 (MEDIUM): /checkpoint fuhr je Aufruf ein volles verifyIntegrity (O(n),
0,85 s bei 16k Zeilen) - authentifizierte DoS-Verstaerkung. Gebraucht wurde nur
die Siegel-Wurzel. Jetzt Kopf-Hash aus der Kopfzeile, Wurzel aus dem juengsten
gueltigen Marker, sealLeafCount statt des teuren Status.

R175-03: writeFileSync lief vor dem Commit, eine verwaiste Zeile wurde vom
Folgelauf zementiert. Jetzt Ruecknahme bei Fehlschlag, und NOTARY_SIGN=false
verlangt zusaetzlich NOTARY_INSECURE_ACK.

Verifiziert mit echtem SSH-Signaturschluessel: stilles Waschen -> Alarm;
erfundene Zeile -> Alarm; unsignierter Commit -> Alarm; Commit-Fehlschlag ->
zurueckgerollt; Reflex-Schalter verweigert; Pruefmodus haengt nichts an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:13:54 +02:00
duffyduckandClaude Opus 5 f3ded9afbc Gegenbuch: externe Notarisierung der Audit-Kette
Abschluss der Anker-Kette. Alle bisherigen Schutzebenen liegen in derselben
Datenbank, die sie absichern sollen - der Pentest hat das ueber mehrere Runden
Schicht fuer Schicht gezeigt, zuletzt in R174-01 am Siegel-Marker selbst.

Aufteilung nach der Analyse des Pentesters (der Schutz kommt vom Ort, nicht von
der Signatur): Das CRM liefert nur einen lesbaren Kontrollwert ohne Geheimnisse
(GET /api/audit-logs/checkpoint, audit:read). Signiert, zeitgestempelt und
angehaengt wird auf einem anderen Rechner - Schluessel und Push-Recht liegen
nicht in den Deploy-Secrets des CRM. Ohne diese Trennung waere es D1 nochmal,
nur schlimmer: sieht nach doppeltem Boden aus, tut still nichts.

Der Kontrollwert enthaelt bewusst maxId. Ein blosser Kopf-Hash erkennt
Umschreiben, aber kein Abschneiden am Ende - genau die R174-01-Klasse, eine
Ebene hoeher. atId erlaubt der Gegenstelle, einen frueher beglaubigten Kopf
erneut abzufragen und nachzurechnen.

Gegenstelle: tools/audit-notary/notary.mjs (Cron auf zweitem Rechner, privates
Git-Repo als Append-only-Ablage, signierte Commits). Prueft vor dem Anhaengen
und bricht bei Widerspruch mit Exit-Code 2 ab, ohne zu schreiben.

Verifiziert gegen eine CRM-Attrappe mit echter DB: beglaubigte Zeile veraendert
-> Alarm; am Ende abgeschnitten (maxId 5->4) -> Alarm; Gegenbuch selbst
gekuerzt (seq-Luecke) -> Alarm; in allen Faellen nichts angehaengt.

Ehrlich dokumentiert: Restfenster zwischen zwei Laeufen bleibt und ist
inhaerent; ein stiller Cron-Ausfall erzeugt im CRM keine Warnung und muss auf
dem Gegenbuch-Rechner ueberwacht werden; Force-Push muss serverseitig gesperrt
sein, sonst ist Append-only nur geliehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:20:55 +02:00
duffyduckandClaude Opus 5 2d55fd23f9 Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)
Der Siegelzustand hing ausschliesslich am Marker im Audit-Log - und den kann
ein DB-Schreiber ohne Schluessel loeschen. Danach meldete die Pruefung
kein_siegel, also Entwarnung, ununterscheidbar von "nie versiegelt". Die
Blattwerte blieben verwaist liegen und wurden nie konsultiert, die zuvor
erkannte V1-Faelschung war wieder unsichtbar.

Besonders bitter im Tail-Fall: Steht der Marker am Ketten-Ende - genau der
Zustand direkt nach dem einmaligen seal-backlog beim Deploy - reisst beim
Loeschen nicht einmal eine Luecke. Ergebnis war valid:true und "Alle Eintraege
unveraendert und lueckenlos verkettet", also null Spur. Meine Antwort auf die
Frage des Pentesters war damit falsch: die Luecke reisst nur, solange der
Marker nicht am Ende steht.

Fix: Gegen-Check "Blaetter vorhanden, aber kein gueltiger Marker" -> neuer
Status entfernt statt kein_siegel, mit ausdruecklicher Meldung. Ein gebrochenes
oder entferntes Siegel kippt jetzt valid auf false, auch ohne beanstandete
Einzelzeile. Der beruhigende Einstiegssatz entfaellt bei Siegelproblemen.

Verifiziert ueber den echten HTTP-Pfad, exakt Szenario 4: Marker nachweislich
das Ketten-Ende, Marker + Middleware-Decoy geloescht, 0 Ketten-Luecken ->
valid:false, Status entfernt, Klartext-Warnung. Auch der Nicht-Tail-Fall
geprueft. Wegwerf-Datenbanken danach geloescht, Dev unberuehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:45:10 +02:00
duffyduckandClaude Opus 5 ac62198a01 Bestandssiegel betriebstauglich gemacht (Pentest R173-01/-02/-03)
R173-01 (HIGH): Das Siegel war ueber HTTP tot. Die generische auditMiddleware
protokolliert den POST /seal-backlog-Request selbst als AuditLog/CREATE mit
demselben endpoint - exakt die Signatur, mit der der Marker gesucht wurde, nur
mit hoeherer id und ohne changesAfter. Der Selektor griff diese Zeile, root war
undefined, Ergebnis: dauerhaft "gebrochen" bei null manipulierten Zeilen.
Mein Testfehler: sealBacklog()/verifyIntegrity() direkt aufgerufen, nie ueber
HTTP - die Middleware lief nie mit. Dieselbe Fehlerklasse wie R165.
Fix: eigener Ressourcentyp AuditBacklogSeal, den die Middleware nie vergibt,
zusaetzlich muss der Marker auswertbares {toId, root} tragen.

R173-02 (MEDIUM): Fehlende Zeilen wurden uebersprungen, das Loeschen eines
gesiegelten Einbruchsbelegs erschien nur als unerklaerte Luecke, waehrend der
Indikator "intakt" meldete. Fix: fehlende gesiegelte Zeilen sind ein
Siegelbruch mit eigener Liste (backlogMissing) und werden namentlich gemeldet.

R173-03 (MEDIUM): Die R170-01-Haertung (Vorbefund im Marker) war auf
seal-backlog nie angewandt, ein Re-Seal absorbierte Manipulationen mit weniger
Spur als ein Rehash. Fix: Der Marker haelt den Befund vor dem Siegeln fest
samt Status und Wurzel des vorherigen Siegels; verify weist die Anzahl
gueltiger Siegel aus und warnt bei mehr als einem.

Verifiziert ueber den echten HTTP-Pfad inkl. Middleware (separate Wegwerf-DB,
Mischbestand 6xV1/4xV2/4xV3): Protokollzeile vorhanden, Status trotzdem
intakt; Loeschen einer gesiegelten Zeile -> gebrochen mit backlogMissing[2];
erneutes Siegeln -> Anzahl 2 gemeldet, Vorbefund im Marker. tsc + vite build
gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:20:32 +02:00
duffyduckandClaude Opus 5 2932598c98 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02)
Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein DB-Schreibzugriff
konnte eine LOGIN_FAILED-Zeile auf success=1 setzen, das Label umschreiben und
errorMessage leeren - alles Nicht-Hash-Felder, Hash unveraendert - und /verify
meldete weiterhin valid=true. Ein Einbruchsversuch war unsichtbar in einen
Erfolg umschreibbar.

Rueckwirkend signieren geht nicht, ein Rehash waere die falsche Medizin.
Stattdessen ein einmaliges, nicht destruktives Bestandssiegel: je Altzeile ein
Blattwert, die Wurzel darueber in einem HMAC-signierten Marker.

Umgesetzt nach den vier Bedingungen aus dem Pentest:
1. Blaetter ueber den vollen Zeileninhalt inkl. id und hashVersion, nicht ueber
   den 7-Feld-V1-Hash - sonst lebte die Luecke im Siegel weiter.
2. Wurzel signiert (steht im Marker, der selbst V3/HMAC ist). Ohne
   AUDIT_HMAC_KEY wird das Siegeln abgelehnt.
3. Bereich fix auf [1 ... v3FromId-1] statt Live-Abfrage hashVersion < 3. Sonst
   haette ein Up-Flip der Grenzzeile sie aus der geprueften Menge gedraengt.
4. Pruefung je id: vorhanden, weiterhin Altbestand, Inhalt == Blatt, dazu
   Wurzelabgleich.

Neuer Endpunkt POST /audit-logs/seal-backlog (audit:admin, confirm SEAL).
/verify meldet den Siegelzustand im Klartext, auch wenn kein Siegel existiert.

Verifiziert in separater Wegwerf-DB mit Mischbestand (6xV1, 5xV2, 5xV3): ohne
Siegel ist der Angriff unsichtbar, mit Siegel wird er erkannt und die Zeile
benannt; der Up-Flip der Grenzzeile wird ebenfalls erkannt. Wegwerf-DB danach
geloescht, Dev-Daten unberuehrt. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:12:51 +02:00
duffyduckandClaude Opus 5 2b0af772a2 Manifest-Kanal abgesichert (Pentest R171-01 HIGH, R171-03 LOW)
verifyIntegrity vertraute Loeschungs-Manifesten bedingungslos, ohne zu pruefen,
ob die Traegerzeile signiert und gueltig ist. Das Manifest steht in
changesAfter, und dieses Feld ist erst ab Hash-Version 2 mitgehasht - auf
V1-Altzeilen also voellig unauthentifiziert. Ein Angreifer konnte in eine
beliebige V1-Zeile ein erfundenes Manifest schreiben, ohne deren Hash zu
aendern, und damit eigene Loeschungen als "erklaert" ausweisen. Damit fiel
zugleich die Eskalation an signierten Zeilen aus - Anker UND Versionsgrenze
umgangen.

Fix: Ein Manifest zaehlt nur, wenn die Traegerzeile laut Versionsgrenze Stufe 3
sein muss, dies auch deklariert, und ihre HMAC-Signatur mit einem
konfigurierten Schluessel aufgeht. Ohne Schluessel gibt es keine gueltigen
Traeger - Luecken bleiben dann unerklaert, die sichere Richtung.

R171-03: Eskalierte Luecken standen in tamperedEntries und chainGaps,
invalidEntries zaehlte sie doppelt. Jetzt entdoppelt.

Verifiziert: boeswillige Loeschung -> Befund; erfundenes Manifest in
herabgestufter Traegerzeile -> ignoriert, Luecke bleibt Befund, Traegerzeile
selbst beanstandet; legitimes signiertes Manifest erklaert die Luecke
weiterhin. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:42:48 +02:00
duffyduckandClaude Opus 5 32c9efacda rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)
POST /audit-logs/rehash rechnet die Kette mit dem HMAC-Schluessel neu und macht
sie damit wieder stimmig - auch wenn sie vorher berechtigte Beanstandungen
enthielt. Der Anker schuetzt gegen einen DB-Schreiber ohne Schluessel, nicht
gegen einen Admin mit audit:admin. Der bisherige Marker hielt nur fest, DASS
rehasht wurde, nicht WAS dabei verschwand.

Fix 1: Vor dem Rehash wird verifyIntegrity() erhoben und samt Kettenkopf im
Marker gesichert - Anzahl geprueft, Listen der manipulierten Zeilen, der
Ketten-Luecken, der Luecken ohne dokumentierte Loeschung, der nicht pruefbaren.
Dazu ausloesender Benutzer und IP statt pauschal "system". Der Marker entsteht
nach dem Rehash, ist Teil der neuen Kette und signiert.

Fix 2: rehash verlangt {"confirm":"REHASH"}, cleanup verlangt
{"confirm":"CLEANUP"}. Beide wurden bei blinder Methoden-Erkundung per POST
unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400.

Verifiziert: blinder POST -> 400 ohne Wirkung; mit Bestaetigung laeuft der
Rehash und der Marker enthaelt Ausloeser, Vorbefund (1 manipuliert, 7 Luecken
mit exakten IDs) und Kettenkopf.

Hinweis: Der Test hat auf der DEV-Datenbank real rehasht, die dortigen
historischen Beanstandungen sind damit geglaettet. Staging/Prod unberuehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:16:08 +02:00
duffyduckandClaude Opus 5 791711ca58 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01)
Jede Kulanz-Wiedervorlage rotierte auf einen frischen Token mit eigenem,
zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token konnte damit aus
dem erkennbaren Replay-Zustand in eine eigene, sauber weiterrotierende Sitzung
entkommen, die nie wieder mit der des Opfers kollidiert - dauerhaft unsichtbar,
kein einziges CRITICAL. Das hebelte die Kern-Garantie von R164-02 aus:
Diebstahl faellt bei der naechsten Nutzung auf.

Fix: Kulanz idempotent. Die jti des Nachfolgers wird bereits beim Einloesen im
selben bedingten UPDATE reserviert (replacedByJti). Eine Wiedervorlage im
Fenster gibt denselben bereits ausgestellten Nachfolger zurueck, statt neu zu
rotieren - ohne neuen Datensatz. Parallele Tabs laufen dadurch auf eine Linie
zusammen; wer den Token spaeter vorlegt, kollidiert zwangslaeufig und loest den
Familien-Widerruf aus. Fehlt der Nachfolger, wird bewusst nicht ersatzweise
rotiert (das waere wieder der Fork), sondern fail-closed als Replay gewertet.

Verifiziert (PoC nachgebaut): T0 legit -> TA, T0 replayt -> TC, TA.jti ==
TC.jti - kein Fork mehr. Ueber HTTP: zwei Tabs beide erfolgreich auf derselben
Linie; gestohlener Token nach Fensterablauf -> 401, Familie widerrufen,
Angreifer-Linie tot, SUSPICIOUS/CRITICAL gemeldet. Regression: 40 parallel ->
4 erfolgreich auf einer Linie, seriell 1-4 ok und 5. Replay, Token ohne jti
fail-closed, Logout widerruft. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:17:51 +02:00
duffyduckandClaude Opus 5 655d20db23 .env.example: Caddy-Standard "aus" unmissverstaendlich gemacht
Der Schalter stand bereits standardmaessig auf aus (COMPOSE_PROFILES leer),
aber eine leere Zeile liest sich wie ein vergessener Wert - genau diese Frage
kam beim Audit-Schluessel schon einmal auf. Jetzt steht der Standard an der
Zeile selbst ("leer lassen = Caddy aus") und im Kommentarblock mit Pfeil
markiert. Die drei Caddy-Werte darunter sind als "nur bei aktivem Caddy
noetig" gekennzeichnet.

Vorher geprueft, dass Kommentare hinter dem Wert von compose sauber
abgeschnitten werden (WERT=3010 # Kommentar -> 3010) - die Datei nutzt diesen
Stil bereits an anderer Stelle. Anschliessend mit der woertlichen Zeile aus
.env.example gegengetestet: nur der ungeschuetzte Dienst startet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:46:23 +02:00
duffyduckandClaude Opus 5 ffeb4dbfd8 Caddy per Schalter in der .env statt per CLI-Flag
Frage aus dem Betrieb: laesst sich ein Compose-Abschnitt ueber eine Variable
in der .env zu- und abschalten, statt ihn auszukommentieren? Bedingte Bloecke
kennt Compose nicht - aber COMPOSE_PROFILES darf in der .env stehen und
aktiviert das Profil, ohne dass ein Flag noetig ist.

  COMPOSE_PROFILES=        -> Caddy wird nicht angelegt (Standard)
  COMPOSE_PROFILES=caddy   -> Caddy startet bei `docker-compose up -d` mit

Vorteil gegenueber `--profile caddy`: der Schalter wirkt auch bei down, logs
und ps, wo das Flag leicht vergessen wird und dann verwaiste Container
zurueckbleiben.

Empirisch geprueft mit einem separaten Testprojekt (compose 1.29.2): ohne den
Eintrag startete nur der ungeschuetzte Dienst, mit Eintrag beide.

Dokumentiert in .env.example, im Kommentar am caddy-Dienst und in der README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:43:59 +02:00
duffyduckandClaude Opus 5 22501f4650 Nur noch eine docker-compose.yml und eine .env
Der zweite Stack unter docker/ war ein Duplikat aus Februar, das nie
mitgepflegt wurde - und genau deshalb schwer zu finden und leicht falsch zu
bedienen. Statt ihn zu loeschen (Caddy ist fuer Betreiber ohne eigenen
Reverse-Proxy zurecht gewuenscht) ist er jetzt in den Hauptstack integriert:

- caddy als optionaler Dienst mit `profiles: ["caddy"]` in docker-compose.yml.
  Ohne `--profile caddy` wird er nicht einmal angelegt - empirisch geprueft mit
  einem separaten Testprojekt: `up -d` startete nur den ungeschuetzten Dienst.
  Der laufende Stack bleibt damit unveraendert.
- Caddyfile in den Projektstamm verschoben, proxy-Ziel auf den Dienstnamen
  `opencrm` angepasst, Zertifikate unter ./data/caddy wie alle anderen Daten.
- DOMAIN, CADDY_DIR und CADDY_CONFIG_DIR in die .env.example aufgenommen.
- docker/ entfernt (Dockerfile, entrypoint.sh, docker-compose.yml,
  .env.example, README.md). Das dortige Dockerfile basierte noch auf Alpine -
  genau die Variante, von der das Projekt wegen Prisma-/TLS-Problemen bewusst
  auf node:20-slim gewechselt ist. Das gepflegte backend/Dockerfile baut
  Frontend und Backend ebenso.
- README: Abschnitt "Docker (Produktion)" ersetzt durch "Betrieb mit eigenem
  SSL", inkl. Hinweisen zu Let's-Encrypt-Limits, DNS-Voraussetzung,
  HTTPS_ENABLED und dem weiterhin offenen App-Port. Projektbaum und
  .dockerignore nachgezogen.

Geprueft: docker-compose config gueltig, caddy validate gueltig, laufende
Container unberuehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:40:43 +02:00
duffyduckandClaude Opus 5 ee83b09ed8 Caddy-Stack (docker/) auf Stand gebracht statt entfernt
Der Stack bleibt bewusst erhalten: Er richtet sich an Betreiber OHNE eigenen
Reverse-Proxy, weil Caddy das Zertifikat selbst holt und erneuert. Er war
allerdings seit Februar stehengeblieben und reichte zehn Variablen nicht an
den Container durch - teils sicherheitsrelevant.

Behoben:
- HTTPS_ENABLED ergaenzt (Default true, Caddy terminiert TLS). Fehlte bisher
  komplett, dadurch waere der Refresh-Cookie OHNE Secure-Attribut gesetzt
  worden und trust proxy falsch gewesen.
- JWT_EXPIRES_IN Default von 7d auf 15m korrigiert - der Wert galt dem
  ACCESS-Token und stammte aus der Zeit vor dem Access-/Refresh-Pattern. Ein
  Access-Token mit einer Woche Lebensdauer im Browser-Speicher macht das
  XSS-Fenster unnoetig gross.
- JWT_REFRESH_EXPIRES_IN, CORS_ORIGINS, LISTEN_ADDR, SSRF_BLOCK_PRIVATE_IPS
  ergaenzt. JWT_REFRESH_EXPIRES_IN stand bereits in docker/.env.example,
  wurde aber nie durchgereicht - dieselbe Fehlerklasse wie beim Audit-Siegel.
- Caddyfile: gzip fuer /api/* deaktiviert (BREACH, CVE-2013-3587). Die
  Konfiguration komprimierte bisher alles, obwohl die README das fuer die
  API ausdruecklich ausschliesst. Statische Assets bleiben komprimiert.
  Mit `caddy validate` geprueft.
- docker/.env.example um die neuen Variablen erweitert, dazu ein Hinweis auf
  Sonderzeichen im DB-Passwort (dieser Stack setzt die DATABASE_URL direkt
  zusammen, anders als der Entrypoint im Projektstamm).
- README benennt jetzt, wann welcher Stack der richtige ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:36:03 +02:00
duffyduckandClaude Opus 5 0ae6b13d6c Audit-Siegel: Schluessel erreicht jetzt tatsaechlich den Container
Ohne diesen Fix waere das Siegel im Docker-Betrieb wirkungslos geblieben - die
Variable wurde nirgends an den Container durchgereicht. docker-compose.yml
listet die Umgebungsvariablen einzeln auf und speist sie aus der .env im
Projektstamm; ein env_file gibt es nicht, backend/.env wird vom Container also
gar nicht gelesen. Dokumentiert und ergaenzt hatte ich bisher nur
backend/.env.example - also die Datei, die fuer den Container irrelevant ist.

- AUDIT_HMAC_KEY und AUDIT_HMAC_KEY_OLD in docker-compose.yml und in
  docker/docker-compose.yml aufgenommen (beide mit :- Default, damit ohne Wert
  weiterhin fail-safe ungesiegelt geschrieben wird)
- beide Variablen samt Erklaerung in die .env.example im Projektstamm und in
  docker/.env.example uebernommen
- README benennt jetzt explizit, welche Datei fuer welchen Betrieb gilt
  (Stamm-.env bei Docker, backend/.env nur ohne Container)

Verifiziert: `docker-compose config` loest AUDIT_HMAC_KEY korrekt auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:29:34 +02:00
duffyduckandClaude Opus 5 ab0d6214f2 Audit-Siegel: Platzhalter zaehlt nicht als Schluessel + Betriebsfalle dokumentiert
.env.example zeigt jetzt wie bei den anderen Secrets einen sichtbaren
Platzhalter an der Variablen statt eines leeren Werts - verstaendlicher, aber
mit Schutz dagegen: ein nicht ersetzter Platzhalter (enthaelt < oder >, oder
beginnt mit change/dein/your/hier) gilt NICHT als Schluessel. Sonst wuerde mit
einem oeffentlich im Repository stehenden Wert gesiegelt, was Sicherheit
vortaeuscht. Das Backend warnt in dem Fall im Log und laesst das Siegel aus.
Verifiziert: mit Platzhalter geschriebene Zeile bleibt hashVersion 2.

Dabei aufgefallen und dokumentiert: Wird das Siegel nach Aktivierung wieder
abgeschaltet (Schluessel fehlt, z. B. beim Rebuild verloren), sind die in
dieser Zeit entstandenen Eintraege ungesiegelt und werden beanstandet, sobald
der Schluessel zurueck ist. Das ist Absicht - ein ungesiegelter Eintrag
inmitten gesiegelter ist von einer Faelschung nicht zu unterscheiden. Warnung
in README und .env.example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:24:59 +02:00
duffyduckandClaude Opus 5 6de3a91aa7 .env.example: erklaert, warum beim Audit-Siegel kein Beispielwert steht
Die leeren Werte bei AUDIT_HMAC_KEY/AUDIT_HMAC_KEY_OLD wirkten wie ein
Versehen. Sie sind Absicht: leer bedeutet "Siegel aus" (fail-safe), und ein
Beispielwert waere hier gefaehrlicher als keiner - er stuende oeffentlich im
Repository, und jeder koennte damit Eintraege siegeln, waehrend der Betreiber
sich gesiegelt waehnt. Anders als bei JWT_SECRET, wo ein Platzhalter sinnvoll
ist, weil die Anwendung ohne Wert gar nicht laeuft.

Entsprechender Hinweis jetzt direkt ueber beiden Variablen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:21:33 +02:00
duffyduckandClaude Opus 5 fb0915df12 Audit-Siegel: Umstiegsweg dokumentiert + Rotations-Fussangel entschaerft
Frage aus dem Betrieb: bestehende Installation hat noch keinen
AUDIT_HMAC_KEY - was passiert beim nachtraeglichen Setzen? Antwort jetzt in
README und .env.example: setzen, neu starten, fertig. Bestehende Eintraege
bleiben unveraendert gueltig, neue werden gesiegelt, alt und neu koexistieren
ohne Fehlalarm. Nachgemessen auf gemischtem Bestand (4903 x V1, 57 x V2,
67 x V3): 0 Beanstandungen. Rueckwirkend siegeln ist nicht moeglich.

Dabei zwei Fehler in der eigenen Doku gefunden und korrigiert:

1. Behauptet war, nach dem Leeren von AUDIT_HMAC_KEY_OLD seien alte Eintraege
   "nicht mehr pruefbar". Tatsaechlich werden sie als MANIPULIERT gemeldet -
   ein falscher Schluessel ist von einer Faelschung nicht zu unterscheiden.
   Gemessen: 67 Eintraege als manipuliert. Nur wenn GAR KEIN Schluessel
   gesetzt ist, gilt "nicht pruefbar". Doku entsprechend korrigiert, inkl.
   Warnung, das Feld nicht voreilig zu leeren.

2. AUDIT_HMAC_KEY_OLD bot nur einen Platz. Beim ZWEITEN Wechsel waeren alle
   mit dem ersten Schluessel gesiegelten Eintraege faelschlich als
   manipuliert erschienen (reproduziert: 67). Das Feld nimmt jetzt eine
   kommagetrennte Liste entgegen; verifiziert: mit beiden Alt-Schluesseln
   0 Beanstandungen, mit nur dem juengsten 67.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:14:24 +02:00
duffyduckandClaude Opus 5 93cb5ffd27 Doku: Audit-Siegel (AUDIT_HMAC_KEY) verstaendlich erklaert
README und .env.example erklaeren jetzt zuerst in Alltagssprache, was das
Audit-Log ist, wogegen der Schluessel schuetzt und was passiert, wenn man ihn
weglaesst oder verliert - inklusive Bild vom Notarstempel. Die technische
Ebene bleibt vollstaendig erhalten, in der README als aufklappbarer Block
(Hash-Versionen 1/2/3, Version-Floor, Loeschungs-Manifest, Rueckgabefelder von
/verify, bekannte Grenze) und in .env.example als eigener Abschnitt.

Ausserdem dokumentiert: Schluesselwechsel ueber AUDIT_HMAC_KEY_OLD ohne
Rehash, pro Umgebung ein eigener Schluessel, und die Warnung zu den beiden
Endpunkten mit Nebenwirkung (rehash/cleanup).

AUDIT_HMAC_KEY zusaetzlich in die Production-Checkliste aufgenommen.

Korrigiert: Die Integritaetspruefung ist NICHT ueber die Oberflaeche
erreichbar (verifyIntegrity wird von keiner Komponente genutzt) - die Doku
zeigt jetzt den API-Aufruf. Rechte, Endpunkte und Rueckgabefelder gegen den
Code geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:06:35 +02:00
duffyduckandClaude Opus 5 9fcab6f17e Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)
Die Rotation war wirkungslos: Der alte Refresh-Token blieb bis exp gueltig, ein
gestohlener Token also bis zu 7 Tage parallel zum legitimen nutzbar - der
Pentester trug mit einem Token 90 Parallel-Requests.

Umgesetzt nach OAuth-Sicherheits-BCP: Jeder Refresh-Token traegt eine jti und
gehoert zu einer Sitzungsfamilie (neue Tabelle RefreshTokenRecord). Beim
Einloesen wird die jti verbraucht; taucht sie erneut auf, wird die gesamte
Familie widerrufen und der Vorfall als SUSPICIOUS/CRITICAL gemeldet. Der Token
selbst wird nicht gespeichert - die Signatur authentifiziert ihn bereits, und
ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.

Kulanzfenster fuer parallele Tabs: 15 s und hoechstens 3 Wiederverwendungen.
Ohne Toleranz wuerde der zweite legitime Tab die Sitzung sprengen; die enge
Grenze laesst einen Missbrauchs-Burst trotzdem auflaufen.

Das Einloesen ist atomar (bedingtes UPDATE statt Lesen-dann-Schreiben) -
derselbe Fehlertyp wie bei der Audit-Kette: im ersten Testlauf kamen 90
gleichzeitige Requests ausnahmslos durch, weil alle den Token als unbenutzt
lasen.

Verifiziert: 90 parallele Requests -> nur 4 erfolgreich (1 + Kulanz 3), 27 als
Replay erkannt, alle Folge-Tokens tot; 2 parallele Tabs weiterhin erfolgreich;
gestohlener Token spaeter erneut abgewiesen; Logout widerruft die Familie;
ueber HTTP kommt SUSPICIOUS/CRITICAL an. tsc + vite build gruen.

Deploy-Hinweis: Refresh-Tokens ohne jti (Bestand vor dem Deploy) werden
fail-closed abgewiesen - alle angemeldeten Nutzer muessen sich einmalig neu
anmelden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:02:53 +02:00
duffyduckandClaude Opus 5 044a12f73e Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)
Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die
Kette selbsttragend - wer die DB schreiben kann, konnte jede Zeile aendern und
alle Folgehashes konsistent nachziehen, die Pruefung meldete "gueltig".

Version 3 signiert denselben Inhalt per HMAC-SHA256 mit AUDIT_HMAC_KEY, einem
Schluessel ausserhalb der Datenbank. Ohne ihn laesst sich keine gueltige
Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr.

Fail-safe: Ohne Schluessel wird weiter Version 2 geschrieben, es faellt nichts
aus. Signierte Zeilen gelten dann als nicht pruefbar (unverifiableEntries) und
ausdruecklich nicht als manipuliert. AUDIT_HMAC_KEY_OLD erlaubt einen
Schluesselwechsel ohne Rehash.

Restluecke der Versionsgrenze geschlossen: Wird die FRUEHESTE Zeile einer Stufe
herabgestuft, wandert MIN(id) mit - die Grenze allein haette den Downgrade
durchgewunken (der erste Testlauf fiel genau darauf durch). Der Nachfolger ist
jedoch HMAC-signiert und sein previousHash ohne Schluessel nicht faelschbar;
eine unerklaerte Luecke vor einer signierten Zeile gilt deshalb als Befund.

Verifiziert: Inhalt geaendert -> erkannt; Downgrade 3->2 auf der fruehesten
V3-Zeile -> erkannt; dasselbe auf der letzten V3-Zeile (kein Nachfolger) ->
erkannt; ohne Schluessel 0 manipuliert / 2 nicht pruefbar; 40 parallele
Schreiber -> 40/40, 0 Forks, alle V3. tsc + vite build gruen.

AUDIT_HMAC_KEY in .env.example dokumentiert. Der Schluessel selbst liegt nur
lokal in .env (gitignored).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:50:57 +02:00
duffyduckandClaude Opus 5 1a349d142e hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)
verifyIntegrity waehlte die Pruefstaerke nach der von der Zeile selbst
deklarierten hashVersion - und die ist nicht gehasht. Angriff: hashVersion 2->1
setzen, die nur von V2 abgedeckten Felder aendern (success false->true,
errorMessage leeren, resourceLabel umschreiben) und den schwachen V1-Hash ueber
die 7 unveraenderten Felder nachziehen. Ergebnis: Fehl-Login als Erfolg
getarnt, Pruefung meldet "gueltig". An der letzten Zeile der Kette entsteht
dabei nicht einmal ein Gap - dauerhaft unsichtbar.

Fix: Version-Floor. Die erwartete Pruefstaerke leitet sich aus der Kette ab
(MIN(id) WHERE hashVersion >= 2), nicht aus der Selbstauskunft. Ab dieser
Grenze muss jede Zeile V2 sein; weicht die deklarierte Version ab, gilt die
Zeile selbst als manipuliert. Geprueft wird immer mit dem erwarteten Verfahren.
Die Grenze laesst sich durch Herabstufen einzelner Zeilen nicht verschieben.

Zusaetzlich konsultiert die Pruefung jetzt das Loeschungs-Manifest: neu
unexplainedGaps - nur Luecken ohne protokollierte Loeschung sind
erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich
eine boeswillige Loeschung als harmloser Gap tarnen konnte.

Verifiziert (PoC nachgebaut): Downgrade mit Nachfolger erkannt, Downgrade der
Tail-Zeile erkannt, Gegenrichtung (V1 faelschlich als V2) erkannt, keine
Falschmeldungen auf Bestandsdaten. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:12:13 +02:00
duffyduckandClaude Opus 5 f2a4baacdb Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)
R166-01 (HIGH): Der GET_LOCK-Ansatz gab die Sperre im finally INNERHALB des
Transaktions-Callbacks frei, also vor dem COMMIT. Im Fenster Release-Commit las
der naechste Schreiber ein noch nicht sichtbares Kettenende - zwei Zeilen hingen
am selben Vorgaenger. Meine vorherige Messung war zu schwach: sie suchte Luecken
zwischen Nachbarn, nicht Forks. Fix: einzeiliger Mutex AuditChainLock mit
FOR UPDATE (InnoDB-Zeilensperren fallen erst beim COMMIT) plus
isolationLevel ReadCommitted. Belegt im Direktvergleich mit geweitetem Fenster:
Release-vor-Commit forkt, Zeilensperre nicht.

R166-02 (MEDIUM): Der Hash deckte nur 7 Felder ab. changesBefore/After, success,
ipAddress, resourceLabel, dataSubjectId, userId/customerId waren ungeschuetzt -
ein Einzeledit dort blieb unsichtbar. Fix: hashVersion + generateHashV2 ueber
alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash
gueltig. Verifiziert: 5/5 zuvor ungeschuetzte Felder werden jetzt erkannt.

R166-03 (LOW): "kein Cookie" (normaler Erstbesuch) wurde als HIGH/abgelehnt
gefuehrt - jetzt eigener Ausgang mit LOW. Nur echte Ablehnung bleibt HIGH.

R166-04 (LOW, pre-existing): GET /retention-policies wurde von GET /:id
verschluckt. Konkrete Routen jetzt vor der Parameter-Route.

Design-Empfehlungen: runRetentionCleanup schreibt ein Loeschungs-Manifest
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag - Luecken
ausserhalb bleiben erklaerungsbeduerftig. rehashAll schreibt einen Marker.

Verifiziert: 50 parallele Schreiber -> 50/50, 0 Forks, alle V2, manipuliert 0,
Luecken unveraendert 7. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 14:56:10 +02:00
duffyduckandClaude Opus 5 c7d6b6de7e Audit-Pruefung: "manipuliert" von "Luecke" getrennt + Retention fuer Routine-Auth
Problem 1 (Deutbarkeit): verifyIntegrity warf zwei voellig unterschiedliche
Befunde in einen Topf und meldete beides als "N manipulierte Eintraege". Eine
harmlose Verkettungsluecke sah damit aus wie ein Angriff - die Meldung war im
Alltag nicht deutbar und dadurch wertlos, dasselbe Muster wie beim
Refresh-Rauschen.

Fix: Rueckgabe um tamperedEntries (Inhalt nachtraeglich veraendert, ernst) und
chainGaps (Verkettung unterbrochen durch parallele Schreibvorgaenge oder
geloeschte Zeilen, meist harmlos) erweitert. invalidEntries bleibt als Summe
erhalten. Controller formuliert die Meldung eindeutig, Frontend-API-Typ
nachgezogen.

Problem 2 (Aufbewahrung): Token-Refreshes landen seit der Entrauschung als
Authentication/LOW. Diese Kombination traf auf keine spezifische Regel und fiel
in die Auffangregel * mit 3650 Tagen - das Rauschen waere 10 Jahre aufbewahrt
worden, echte Logins nur 2. Fix: Regel Authentication/LOW mit 90 Tagen, als
idempotente Migration und im Seed.

Sensitivitaet steuert die Aufbewahrung und ist keine Alarmstufe - normale
Logins und Zugriffe auf Bankdaten/Ausweise bleiben bewusst CRITICAL, ein
Herabstufen wuerde still die Aufbewahrungsfrist verlaengern.

Verifiziert: Live-Test gegen Dev-DB - echte Manipulation einer Zeile wird als
manipuliert erkannt und nicht mit Luecken verwechselt, Ketten-Luecken bleiben
bei 7, Originalzustand exakt wiederhergestellt. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:30:36 +02:00
duffyduckandClaude Opus 5 477a850a91 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)
createAuditLog las den Vorgaenger-Hash und schrieb den neuen Eintrag als zwei
getrennte Schritte. Zwei parallele Requests lasen denselben letzten Hash und
haengten sich beide daran - die Kette zerriss (Bruchstellen im Bestand vom
05.05. und 07.05.2026).

Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen benannten
MySQL-Lock (GET_LOCK). Der Lock liegt in der DB und wirkt daher auch ueber
mehrere App-Instanzen hinweg. Release im finally, weil benannte Locks nicht
transaktional sind - sonst wandert die Sperre mit der Verbindung zurueck in den
Pool und blockiert alle weiteren Schreiber.

Verworfener erster Ansatz: SELECT ... FOR UPDATE auf das Kettenende nimmt Gap-/
Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren - gemessen gingen
38 von 40 parallelen Eintraegen durch Deadlocks verloren, still verschluckt vom
catch. Ein fehlender Audit-Eintrag ist unsichtbar und damit gefaehrlicher als
ein sichtbarer Kettenbruch.

Verifiziert: 100 parallele Schreiber -> 100/100 geschrieben, 0 neue Brueche
(444 ms); Folge-Schreiber in 6 ms, IS_FREE_LOCK frei (kein Lock-Leak).
Ungueltige Zeilen bleiben bei den 7 historischen. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:37:34 +02:00
duffyduckandClaude Opus 5 89ae7b73a1 Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben
verifyIntegrity meldete 3107 von 4630 Zeilen als "manipuliert" - davon 3100
Fehlalarme, exakt die Zeilen mit resourceId = NULL aus 08.02.-01.05.2026.

Ursache: Der R121-Fix nahm an, resourceId sei beim Schreiben immer undefined
gewesen (Key faellt bei JSON.stringify weg) und daher wuerden alle
Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 - aeltere
Zeilen wurden mit explizitem null serialisiert, der Key war drin.

Sicherheitsrelevant, weil ein staendig grundlos ausloesender Alarm ignoriert
wird - echte Manipulation ginge im Laerm unter.

Fix: generateHashLegacy() reproduziert das alte Schreibverhalten,
verifyIntegrity akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
wenn die aktuelle Variante nicht passt). Bewusst KEIN Rehash - der wuerde die
Manipulations-Beweiskraft der Vergangenheit zerstoeren. Gespeicherte Hashes
bleiben unangetastet.

Verifiziert: ungueltig 3107 -> 7 (echte Ketten-Brueche). Adversarial
gegengetestet: Manipulation an userEmail/action/endpoint/createdAt/resourceId
wird bei alten wie neuen Zeilen zu 100 % erkannt (10/10). tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:32:22 +02:00
duffyduckandClaude Opus 5 ad520e20a7 Audit-Log: Pfad-Matching im finish-Handler gefixt (Pentest R165-01)
Die Entrauschung aus de0d6bd war live wirkungslos - aber nicht wegen eines
Deploy-Miss, sondern weil der Code nie erreicht wurde: auditMiddleware liest
req.path erst im res.on('finish')-Handler. Express strippt beim Router-Dispatch
den Mount-Prefix aus req.url und stellt ihn nur beim next()-Durchlauf wieder
her - ein terminaler Handler (res.json()) ruft nie next(), also bleibt req.path
router-relativ (/refresh statt /api/auth/refresh). Saemtliche
path.includes('/auth/...')-Checks liefen ins Leere -> Fallback POST->CREATE mit
Default-Sensitivitaet CRITICAL.

Betraf nicht nur den neuen TOKEN_REFRESH: LOGIN/LOGOUT/LOGIN_FAILED waren im
Audit-Stream seit jeher generisch (pre-existing), ebenso das endpoint-Feld.
Der SecurityEvent-Stream war nie betroffen (eigene emit-Calls), daher lief das
Alerting korrekt.

Fix: vollen Pfad einmal synchron beim Eintritt festhalten (req.originalUrl,
wird von Express nie mutiert) und downstream ausschliesslich diesen nutzen -
determineAction, generateHumanLabel, extractDataSubjectId, manuallyLoggedPaths
und endpoint. TOKEN_REFRESH zusaetzlich in die "immer loggen"-Ausnahme.

Verifiziert (E2E mit echter Middleware gegen Dev-DB): TOKEN_REFRESH/LOW,
TOKEN_REFRESH/HIGH, LOGIN/CRITICAL, LOGIN_FAILED/CRITICAL, LOGOUT/CRITICAL,
alle mit vollem endpoint-Pfad. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:11:44 +02:00
duffyduckandClaude Opus 4.8 d599eb3702 Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)
Fehlgeschlagener /auth/refresh (Replay/Brute-Force auf geraubte
Refresh-Tokens) wurde als TOKEN_REFRESH/LOW geloggt und entging der
Alarmierung - ein Angreifer konnte von /login auf /refresh ausweichen,
um unter der LOGIN_FAILED-Schwelle zu bleiben.

Audit-Actions speisen die Alert-Engine nicht (die zaehlt SecurityEvent
via emit). Fix daher an zwei Ebenen:

- Detection: refresh()-Catch emittiert TOKEN_REJECTED -> greift die
  bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP -> CRITICAL). Severity
  wie Access-Token: abgelaufen/revoked = LOW (kein Sofort-Alert),
  ungueltige Signatur/Manipulation = HIGH. auth.service reicht dafuer
  err.code REFRESH_EXPIRED/REFRESH_INVALID durch. "Kein Cookie" emittiert
  bewusst nicht (normaler Erstbesuch).
- Audit-Triage: fehlgeschlagener Refresh -> Sensitivitaet HIGH statt LOW
  + Label "Token-Refresh abgelehnt". Action bleibt TOKEN_REFRESH
  (semantisch ein Refresh, kein Login).

Verifiziert: tsx-Test abgelaufen->LOW, manipuliert/garbage->HIGH; tsc gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 18:20:21 +02:00
duffyduckandClaude Opus 4.8 de0d6bd817 Audit-Log: stiller Token-Refresh entrauscht (eigene Action TOKEN_REFRESH)
POST /auth/refresh wurde als CREATE / "Anmeldung erstellt" / CRITICAL /
anonymous geloggt und sah damit wie eine anonyme Login-Flut aus. Es ist
aber der regulaere Silent-Refresh des Frontend-Interceptors (Access-Token
lebt nur im Speicher -> nach Reload/401 einmaliger Cookie-Refresh).

- Neuer AuditAction-Wert TOKEN_REFRESH (Migration 20260818120000,
  idempotentes MODIFY COLUMN)
- determineAction() mappt /auth/refresh -> TOKEN_REFRESH, Label
  "Sitzung verlaengert (Token erneuert)", Sensitivitaet explizit LOW
  (statt Default Authentication -> CRITICAL)
- LOGIN/LOGOUT/LOGIN_FAILED bleiben unveraendert CRITICAL
- Frontend: Filter-Option + dezente Badge-Farbe + Typ-Union
- anonymous bewusst beibehalten (Endpoint ohne authenticate-Middleware)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 17:51:21 +02:00
duffyduckandClaude Opus 4.8 7bdb885a10 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)
createContract/updateContract spreadeten energyDetails/tvDetails/
carInsuranceDetails/mobileDetails (via ...mobileData) roh an Prisma.
Injizierte id/contractId konnten ein Detail-Objekt auf einen Fremdvertrag
reparenten oder den PK frei setzen (stilles 200 statt 400) - MEDIUM
(Integritaet; kein Cross-Tenant, staff-only, Portal 403).

Fix: Feld-Whitelists (pickEnergyScalars/pickMobileScalars/pickTvScalars/
pickCarInsuranceScalars, analog R158) an allen Spread-Stellen in create+update.
internet war bereits explizit (preparedInternetData) - safe. Whitelists
programmatisch gegen die DB-Spalten abgeglichen (minus id/contractId/
verschluesselt) - alle Diffs leer.

Verifiziert: energyDetails{basePrice:99.99, id:999999, contractId:fremd}
-> basePrice aktualisiert, ecd.id + contractId unveraendert (kein Reparenting).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 16:10:45 +02:00
duffyduckandClaude Opus 4.8 8964820542 Kunde-E-Mail: Pflicht nur beim Anlegen, nicht beim Bearbeiten
Bestandskunden ohne E-Mail bleiben editierbar. Beim Anlegen ist E-Mail
weiterhin Pflicht (Frontend required + Backend createCustomer). Die
Domain-Pruefung (keine verwaltete Provider-Domain) greift unveraendert bei
create UND update, falls eine E-Mail gesetzt wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:26:03 +02:00
duffyduckandClaude Opus 4.8 15142676ef Aufgaben ohne Kunde/Vertrag anlegbar
ContractTask.contractId nullable (Migration). Neuer staff-only Endpoint
POST /tasks fuer allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag gibt
es keinen Kunden -> visibleInPortal serverseitig immer false, Portal-Reply
403 bei contractloser Aufgabe, getAllTasks-Portal-Filter schliesst sie
automatisch aus (kein contract-Match).

Task-Modal (Mitarbeiter): Checkbox "Ohne Kunde (allgemeine Aufgabe)" blendet
Kunden-/Vertragsauswahl UND "Im Kundenportal sichtbar" aus. Task-Liste zeigt
solche Aufgaben als "Allgemeine Aufgabe (ohne Vertrag)" ohne Vertrags-Link/
Zum-Vertrag-Button.

Verifiziert: contractlose Aufgabe -> contractId null, visibleInPortal
erzwungen false (auch wenn true geschickt); mit Vertrag weiterhin waehlbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:15:33 +02:00
duffyduckandClaude Opus 4.8 b8128616f2 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain erlaubt
Die private Kunden-E-Mail (Customer.email) darf nicht auf einer der bei den
E-Mail-Providern konfigurierten Domains (bzw. Subdomains) liegen - sonst
traegt man versehentlich eine unserer verwalteten Weiterleitungs-/Mailbox-
Adressen als private Adresse ein. Zudem ist E-Mail jetzt Pflichtfeld.

Backend: createCustomer/updateCustomer pruefen Pflicht + Domain (neue Helper
getConfiguredEmailDomains/emailUsesDomain im emailProvider-Service);
email aus nullableFields entfernt. Frontend: E-Mail-Feld required + Hinweis.

Verifiziert: Provider-Domain + Subdomain (case-insensitiv) verboten,
Fremd-Domains erlaubt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:08:12 +02:00
duffyduckandClaude Opus 4.8 db4b9edd13 Energievertrag: Ankreuzfeld "Keine Bonis erwuenscht"
Neues Feld EnergyContractDetails.noBonusDesired (Boolean, default false) +
Migration. Checkbox im Vertragsformular (Strom/Gas, bei den Bonus-Feldern),
Anzeige im Vertragsdetail wenn gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 14:03:15 +02:00
duffyduckandClaude Opus 4.8 1a45c24abf MaLo-ID an die Lieferadresse (Strom/Gas) + Address-owner-Regression gefixt
MaLo-ID (Marktlokation) gehoert zur (Liefer-)Adresse, nicht zum Vertrag.
Address bekommt maloIdElectricity + maloIdGas (getrennte Marktlokationen je
Sparte), pflegbar im AddressModal (nur Lieferadresse). Im Vertrag ist die
MaLo-ID jetzt ein Lesefeld, das je nach Vertragstyp die MaLo der gewaehlten
Lieferadresse zeigt; ContractDetail/-Modal ebenso.

Schema + Migration 20260814100000: 2 Spalten (idempotent) + Daten-Migration
(bestehende EnergyContractDetails.maloId -> jeweilige Lieferadresse,
ELECTRICITY->maloIdElectricity / GAS->maloIdGas). Migrationslogik verifiziert.

Dabei einen selbst verursachten Regressions-Bug gefixt: beim R156-Umbau waren
die 10 owner*-Adressfelder aus der Address-Whitelist gefallen -> Eigentuemer-
Sektion speicherte seit cb21a2c nicht mehr. Address-Whitelist jetzt via
Pick-Helper, programmatisch gegen alle DB-Spalten abgeglichen (owner* + MaLo
drin, id/customerId/Timestamps raus). BankCard/Document gegengeprueft: ok
(nur documentPath bewusst upload-only ausgeschlossen).

Verifiziert: tsc+build gruen; owner + maloId speichern wieder, Injection
(id/customerId) blockiert; Daten-Migration Strom->Strom / Gas->Gas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 14:38:16 +02:00
duffyduckandClaude Opus 4.8 e01c793b58 Fix: contractNumberAtProvider in Contract-Whitelist (Pentest R159)
Beim Umstieg auf die Feld-Whitelist (94e4fde) ist "Vertragsnummer beim
Anbieter" (contractNumberAtProvider) durchgerutscht - ich hatte nur die
SalesPlatform-Variante + customerNumberAtProvider. Folge: Staff konnte die
Anbieter-Vertragsnummer nicht mehr neu setzen/aendern (still gedroppt),
bestehende Werte blieben (Prisma nullt abwesende Felder nicht). Reine
funktionale Regression, kein Security-Issue (Weglassen ist fail-safe).

Whitelist jetzt programmatisch gegen ALLE DB-Spalten abgeglichen (SHOW COLUMNS
minus bewusste Ausschluesse = Whitelist, beide Diffs leer) - kein weiteres
Feld fehlt. Verifiziert: contractNumberAtProvider persistiert wieder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 20:39:09 +02:00
duffyduckandClaude Opus 4.8 94e4fdee23 Mass-Assignment-Schutz: Contract-Create/Update Feld-Whitelist (R158)
Letzter Spread-Endpunkt: createContract/updateContract reichten rohen
...contractData an Prisma durch (via as any im Controller). customerId ist
zwar legitim aenderbar (Kunden-Select aktiv), aber id/contractNumber/
createdAt/updatedAt/portalPasswordEncrypted und die cancellation*Path-Felder
waren so mit-injizierbar. Jetzt explizite Feld-Whitelist (pickContractScalars),
konsistent zur R156-Haertung von BankCard/Address/Document.

Whitelist autoritativ aus den DB-Spalten abgeleitet - der ContractCreateData-
Typ ist unvollstaendig: previousProviderId/previousContractNumber/
previousCustomerNumber/nextReviewDate sind echte Formularfelder, die sonst
still gebrochen waeren. cancellation*Path bleiben bewusst draussen (nur ueber
die Upload-/Delete-Endpunkte setzbar).

Verifiziert: legit Felder (inkl. der 4 zuvor untypisierten) persistieren;
injizierte id/contractNumber/cancellationLetterPath werden ignoriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 19:27:25 +02:00
duffyduckandClaude Opus 4.8 fc3131059b Neuer Vertragsstatus "Gekuendigt / bestaetigt" + Kuendigungs-Workflow
Bisheriges "Gekuendigt" (CANCELLED) umbenannt in "Gekuendigt / Bestaetigung
abwarten" und wird jetzt automatisch gesetzt, sobald ein Kuendigungsschreiben
hochgeladen wird. Neuer Status CANCELLED_CONFIRMED ("Gekuendigt / bestaetigt")
wird automatisch gesetzt, sobald ein Kuendigungsbestaetigungsdatum vorliegt
(Dokument fuellt das Datum oder manuell) + Vertragsende = Kuendigungsdatum.

Schema: Enum-Wert CANCELLED_CONFIRMED + Migration (idempotentes MODIFY COLUMN);
Daten-Migration hebt bestehende CANCELLED (alte Logik: nur bei Bestaetigung
gesetzt) auf CANCELLED_CONFIRMED.

Backend: neue Trigger maybeMarkAwaitingConfirmationOnLetter (Schreiben->CANCELLED)
im Upload-Handler; maybeCancelOnCancellationConfirmation setzt jetzt
CANCELLED_CONFIRMED (auch aus CANCELLED). Cockpit-Semantik mitgewandert
(Fristen-Skip/"beendet" fuer CANCELLED_CONFIRMED; Ladeliste + Kuendigungs-
bestaetigungs-Filter erweitert).

Frontend: Labels/Farben/Status-Erklaerungen + Status-Dropdown in ContractList,
ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
(CANCELLED orange "abwarten", CANCELLED_CONFIRMED rot).

Verifiziert: tsc+build gruen; Schreiben->CANCELLED, Bestaetigung->
CANCELLED_CONFIRMED+Enddatum; Daten-Migration idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:14:34 +02:00
duffyduckandClaude Opus 4.8 a5b8922dd0 README: Reseed-Anleitung fuer bekannte Admin-/Portal-Creds
Eigene Unterrubrik unter "Erster Login": wie man auf bestehender DB
(z.B. Staging nach Reset) datenerhaltend bekannte Zugangsdaten herstellt.
Betont, dass der Seed nur Rollen + Admin-User upsertet (keine Kundendaten),
das SEED_ADMIN_PASSWORD>=25-Zeichen-Verfahren + RUN_SEED, und dass
Portal-Passwoerter danach im UI gesetzt werden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 17:04:36 +02:00
duffyduckandClaude Opus 4.8 cb21a2c9be Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)
Die create/update-Services reichten rohen req.body an Prisma durch, wodurch
customerId (Owner) und id (PK) per Update mutierbar waren - staff-only, kein
Cross-Tenant-Bruch, aber echte Integritaetsschwaeche; mit dem neuen
cardNumber-Feld liegt zudem Finanz-PII auf dieser Flaeche.

Fix: explizite Feld-Whitelist im Service (create+update) fuer BankCard,
Address und IdentityDocument - nur benannte Spalten gehen an Prisma, kein
...data/req.body-Spread mehr. Controller-Helper pickBankCardFields haelt
zusaetzlich die Audit-Logs sauber (keine Phantom-Eintraege injizierter Keys).

Verifiziert: updateBankCard mit {customerId:99999, id:88888, bogusField, ...}
-> id+customerId unveraendert, nur cardNumber gesetzt, Fremdfelder ignoriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 16:40:15 +02:00
duffyduckandClaude Opus 4.8 0d42bd89e8 Bankkarte-/Ausweis-Details in Vertrag + neues Feld Kartennummer
Schema: BankCard.cardNumber (String?, optional) + Migration
20260813100000_bank_card_number (ADD COLUMN IF NOT EXISTS), auf Dev
angewandt + prisma generate; Prod via migrate deploy. Eingabefeld
"Kartennummer" im Bankkarten-Modal (Kundenakte).

Vertragsansicht (ContractDetail) und Vertrag bearbeiten (ContractForm)
zeigen jetzt bei Bankkarte zusaetzlich BIC/Bank/Kartennummer/Ablaufdatum
und bei Ausweis Behoerde/Ausstellung/Ablaufdatum sowie Geburtsort +
Geburtsdatum des Kunden - jeweils mit Copy-Button und nur wenn gesetzt.
Im Form je Select in eigenem div gewrappt (Grid-Alignment).

Verifiziert: tsc + vite build gruen, cardNumber Round-Trip (update->read),
Contract-Include liefert alle Felder inkl. customer.birthDate/birthPlace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 15:00:29 +02:00
duffyduckandClaude Opus 4.8 f3668b8a5a Magic-Byte-Seed: aus Prod-Image ausschliessen + Prod-Riegel
Der Pentester-Helfer soll nicht ins Produktions-Image und darf nie gegen
Prod laufen:
- .dockerignore schliesst backend/scripts/seed-magic-byte-test.ts aus dem
  Build-Context aus -> Datei liegt nicht mehr im Prod-Container (bleibt im
  Repo fuer Dev/Staging-Checkout).
- Script bricht bei NODE_ENV=production im "create"-Modus hart ab (exit 1);
  "cleanup" bleibt erlaubt, damit man immer aufraeumen kann.

Verifiziert: create@production -> Abbruch exit 1; cleanup@production laeuft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 13:49:32 +02:00
duffyduckandClaude Opus 4.8 2ea211cca0 Pentester-Helfer: Seed-Script fuer Magic-Byte-Mismatch-Test (R152)
backend/scripts/seed-magic-byte-test.ts legt eine dedizierte TEST-Bankkarte
(Marker im accountHolder) mit einer getarnten Datei an: .pdf-Endung, aber
SVG-mit-<script>-Inhalt (Non-Whitelist-Magic-Byte, Stored-XSS-Payload).
Damit kann der Pentester den Magic-Byte-Mismatch->attachment-Zweig des
Download-Endpoints live ausloesen (auf Staging fehlte bisher ein
Non-Whitelist-Upload). Echte Kundendaten werden nicht angefasst; cleanup
entfernt Karte + Datei.

Modi: create [--customer <id>] | cleanup.

Verifiziert (create -> Controller-Integrationstest -> cleanup): inline
angefragt -> Content-Disposition attachment + nosniff (nicht inline) + Log;
ohne disposition -> attachment; fremder Portal-User -> 403.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 13:31:08 +02:00
duffyduckandClaude Opus 4.8 06d51cf680 PDF-Viewer-Modal fuer Bankkarte-/Ausweis-Dokument
Neue Komponente PdfViewerModal (Modal + iframe auf viewUrl(documentPath),
inkl. Neuer-Tab/Download-Links). Buttons:
- Vertragsansicht (ContractDetail): im Card-Header von Bankkarte/Ausweis
- Vertrag bearbeiten (ContractForm): neben dem Label Bankkarte/Ausweis

Button erscheint NUR wenn ein documentPath hinterlegt/gewaehlt ist. Nutzt den
bestehenden /api/files/download-Endpoint (Per-File-Ownership-Check) - kein
neuer Zugriffspfad. iframe (nicht embed/object wegen object-src 'none');
same-origin via CSP default-src 'self' erlaubt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 13:15:57 +02:00
duffyduckandClaude Opus 4.8 b2b1cb3387 Vertragssuche: Karteninhaber (cardUser) durchsuchbar (Pentest R150)
getAllContracts durchsucht jetzt auch simCards.cardUser - zusaetzlich zu
Rufnummer/SIM-Nummer/IMEI. R150-Randnotiz, fachlich gewuenscht (Staff sucht
nach Karteninhaber). Portal-Suche bleibt durch das bestehende customerIds-
Scoping begrenzt.

Verifiziert: Suche nach gesetztem cardUser findet den Vertrag, Kontroll-
Suche 0 Treffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 12:50:36 +02:00
duffyduckandClaude Opus 4.8 2d12221c41 Vertragsliste (Staff-Tabelle): Karteninhaber/Netz/Kuendigung nachgezogen
Der vorige Commit ergaenzte nur die Baum-Render-Pfade (Kundenakte + Portal).
Die Staff-Ansicht unter /contracts rendert aber eine eigene Tabelle aus der
flachen getAll-Liste - dort fehlten die neuen Felder noch. Jetzt zeigt auch
die Staff-Tabelle Karteninhaber + Netz hinter der Rufnummer und die
Kuendigungsbestaetigung in roter Schrift. Backend liefert die Felder via
getAllContracts bereits mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 12:26:58 +02:00
duffyduckandClaude Opus 4.8 c5cf2c7416 Vertragslisten: Karteninhaber + Mobilfunknetz + Kuendigung (rot)
Beide Vertragslisten (Kundenakte-Baum + Hauptmenue /contracts) zeigen bei
Mobilfunkvertraegen mit Rufnummer jetzt zusaetzlich Karteninhaber
(SimCard.cardUser der angezeigten SIM, nur wenn gesetzt) und Netz
(mobileNetwork -> Telekom/Vodafone/Telefonica (o2)) inline. Vertraege mit
Kuendigungsbestaetigung (cancellationConfirmationDate) bekommen eine eigene
Zeile in roter, fetter Schrift.

Backend: getContractTreeForCustomer + getAllContracts selektieren nun
mobileNetwork + cardUser (Tree zusaetzlich cancellationConfirmationDate).
Shared-Helper getContractTypeInfo um cardUser/network + mobileNetworkLabel
erweitert. Beide Listen rendern ueber den Tree-Endpoint.

Verifiziert: tsc + vite build gruen, Tree-Service liefert die Felder,
Helper-Mapping getestet (Vodafone/Telekom/o2, leerer Karteninhaber ausgelassen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 12:18:24 +02:00
duffyduckandClaude Opus 4.8 8e46dbbfed Globaler /api-Rate-Limit-Backstop (Pentest R148)
Es gab bislang keinen generellen Limiter - nur Login/Passwort-Reset/
Staff-ReAuth/Consent; authentifizierte Endpoints waren gegen Enumeration/
DoS ungedrosselt. Neuer apiBackstopRateLimiter auf alle /api-Requests,
vor den Routern gemountet (ergaenzt die feineren Limiter, ersetzt sie nicht).

Key = nur IPv6-/56-normalisierte IP - bewusst nicht IP+User, da der User-
Claim hier nur unverifiziert lesbar waere (authenticate laeuft erst pro
Route) und ein Angreifer sonst per Fake-userId beliebig Buckets erzeugen
koennte. Limit per Env API_RATE_LIMIT_PER_MIN (Default 1200/min, Floor 60),
/api/health ausgenommen. Kein SecurityEvent pro Block (Flood-Amplification).
Deckt zugleich den offenen IPv6-Rate-Limit-Test ausserhalb der Auth-Pfade ab.

Verifiziert: 60x200 dann 429, health bleibt 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:54:12 +02:00
duffyduckandClaude Opus 4.8 955fceb3b8 BLZ-Guard: vollstaendige Formatpruefung aller Eintraege (Pentest R148)
Der Poisoning-Guard pruefte bisher nur die erste current-Zeile - ein Set mit
1 echten + 999 Fake-Eintraegen kaeme durch. Jetzt wird JEDER Eintrag in
current + next.upsert geprueft (8-stellige BLZ, Wert [Name] oder [Name,BIC]),
next.remove auf 8-stellige BLZ, next.valid auf ein gueltiges Datum.

Dabei korrekt beruecksichtigt: Banken ohne BIC haben nur [Name] (Laenge 1) -
lookupBlz liefert dann bic:''. Real gegen den echten Datensatz verifiziert
(3506 Eintraege, inkl. BIC-lose wie BLZ 60050009).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:46:56 +02:00
duffyduckandClaude Opus 4.8 d0619141bb Fix: Modal-Formulare nach Abbrechen nicht mehr mit alten Daten vorbefuellt
Die Modals in der Kundenakte (Bankkarte, Adresse, Ausweis, Zaehler,
Zaehlerstand) bleiben dauerhaft gemountet und wurden nur ueber isOpen
umgeschaltet. Der Reset-Effekt haengte an <entity>?.id - bei Neuanlage
immer undefined, also kein Reset beim erneuten Oeffnen: nach "Abbrechen"
standen die vorher getippten Daten noch drin.

Jetzt Reset beim Oeffnen (Guard if(!isOpen), Deps [isOpen, <entity>?.id]),
weiterhin kein Reset bei jedem Tastendruck. Ausserdem den fehlerhaften
useState(()=>{})-Init-Missbrauch im Bankkarten-Modal entfernt.
StressfreiEmail-/AdditionalForwards-Modal waren bereits korrekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:31:03 +02:00
duffyduckandClaude Opus 4.8 a28c8355fd BLZ-Updater: Versionsformat vor URL-Interpolation strikt pruefen
Die von der npm-Registry gelieferte "latest"-Version wird in die jsDelivr-
CDN-URL interpoliert. Nun nur noch rein numerisches semver (^\d.\d.\d$)
zugelassen, damit eine manipulierte Registry-Antwort (../, Slashes, Query)
den Pfad nicht verbiegen kann. Host bleibt ohnehin fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:09:24 +02:00
duffyduckandClaude Opus 4.8 91fbe12299 BLZ-Bankdaten: Laufzeit-Auto-Update ins Volume + Einstellungen-Seite
Statt npm-Rebuild-Wartung aktualisiert sich der Bankleitzahlen-Datensatz
jetzt zur Laufzeit. Ein Scheduler (taeglich 03:30 + Catch-up 90s) prueft
gemaess konfigurierbarem Intervall und laedt current.json/next.json von
npm/jsDelivr (Paket bankdata-germany) in das neue Bind-Mount-Volume
BANKDATA_DIR (./data/bankdata -> /app/bankdata).

Lookup bevorzugt den Volume-Datensatz vor den ins Image gebackenen Daten
(Fallback). Es wird kein Fremdcode ausgefuehrt - nur JSON gelesen und die
current+next-Delta-Logik nachgebaut. Validierung (>=1000 Eintraege, Format)
+ atomarer Write (tmp+rename) schuetzen den guten Stand vor Muell.

Datenschutz: Der Updater sendet keine Kundendaten, laedt nur eine
oeffentliche Datendatei; abschaltbar; bei Fehler/ohne Egress greift Builtin.

Neue Einstellungen-Seite /settings/bank-data zeigt Datenstand, Update-
Verfuegbarkeit und bietet "Jetzt aktualisieren" + Auto-Update-Schalter +
Intervall. Endpoints GET /api/settings/blz, POST /api/settings/blz/update-now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 13:15:07 +02:00
duffyduckandClaude Opus 4.8 73bd72bd9f Bankkarte: IBAN pruefen + BIC/Bank offline aus Bundesbank-BLZ ausfuellen
Neuer Button "BIC & Bank aus IBAN abrufen" im Bankkarten-Modal fuellt BIC +
Banknamen automatisch und validiert dabei die IBAN-Pruefziffer (mod-97).
Leeres IBAN-Feld -> OK-Messagebox statt Anfrage.

Datenschutzfreundlich/offline: kein Dritt-Dienst. Nachschlag im eigenen
Backend ueber die Bundesbank-Bankleitzahlendatei (bankdata-germany) +
ibantools fuer die Pruefziffer. Die IBAN verlaesst nie den Server; zurueck
kommen nur oeffentliche Bankverzeichnis-Daten.

Endpoint: POST /api/bank-cards/iban-lookup (nur eingeloggt).
Wartung: bankdata-germany/ibantools ~quartalsweise per npm update ziehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 12:32:53 +02:00
duffyduckandClaude Opus 4.8 aff3f7e84a Update-Banner: klebt oben fest (sticky), Sticky-Header rasten darunter ein
Der Hinweis "Neue Version verfuegbar" scrollte bisher weg. Jetzt sticky an
der Viewport-Oberkante; das Banner meldet seine gemessene Hoehe als CSS-Var
--app-banner-h, an der sich die Sticky-Header von ContractDetail/ContractForm
ausrichten (top-[var(--app-banner-h,0px)]), damit sie nicht dahinter
verschwinden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 12:20:31 +02:00
duffyduckandClaude Opus 4.8 e1ca56ebfb getCockpit fail-closed (R146) - konsistent zu listAll/getContracts
Pentester R146 (Konsistenz + Defense-in-Depth): getCockpit war der
einzige der drei Geschwister-Endpunkte noch auf dem alten Fail-open-
Muster. Der Cockpit-SERVICE ist bereits fail-closed (customerIds:[] ->
IN () -> 0), aber der CONTROLLER uebergab bei falsy customerId
undefined statt [] -> {} -> alle Kunden. Genau die R4-HIGH-Stelle
'Cockpit leakt alle Vertraege'.

Jetzt: Portal-Token wird IMMER gescoped (ohne customerId -> []),
identischer Einzeiler wie listAll/getContracts. Nicht erreichbar (Token
traegt immer customerId), aber der urspruengliche HIGH-Endpunkt soll
nicht das letzte Fail-open-Muster bleiben.

Verifiziert: Cockpit Staff -> 17 Vertraege; Portal []->0 (contracts +
cancellationConfirmations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 11:44:52 +02:00
duffyduckandClaude Opus 4.8 8d93c1767b R145-Hygiene: limit-Floor + getContracts fail-closed (Konsistenz)
Pentester R145 (nice-to-have, keine Findings):
1) listAll: limit bekommt einen Floor (Math.max(1, ...)) - limit=-5
   ergab vorher take:-5 an Prisma. page ebenso auf >=1 geklemmt.
2) getContracts nutzte dasselbe 'if (isCustomerPortal && customerId)'-
   Muster und war NICHT fail-closed. Jetzt konsistent zu listAll:
   - Controller: Portal-Token immer gescoped (ohne customerId -> []).
   - Service getAllContracts: 'if (customerIds)' statt '.length > 0',
     damit ein leeres Array strikt auf IN () filtert (0 Treffer) statt
     durchzufallen. Einziger Caller ist der Contract-Controller ->
     keine Regression fuer den Normalfall.

Verifiziert: Staff -> alle; Portal customerIds=[] -> 0 Vertraege/Belege.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 11:32:21 +02:00
duffyduckandClaude Opus 4.8 4c5548ea3c Belegübersicht: fail-closed Portal-Scoping (R144)
Pentester R144 (fail-closed, kein Finding): listAll scopte ueber
'if (isCustomerPortal && customerId)'. Fiele customerId bei einem
Portal-Token mal falsy aus, rutschte er in den Staff-Zweig (alle
Belege).

Jetzt: Portal-Token wird IMMER gescoped; ohne customerId -> leere
Menge (customerIds=[]) statt undefined/Staff. In:[] kann nie matchen.
Aktuell nicht erreichbar (Portal-Token traegt immer customerId), aber
robuster.

Verifiziert: customerIds=[] -> 0 Belege.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 11:18:46 +02:00
duffyduckandClaude Opus 4.8 48e65be91c Hauptmenue: Gutschriften/Lieferscheine-Gesamtuebersicht (portal-scoped)
Neuer Menuepunkt 'Gutschriften' -> Seite /credit-notes mit Tabelle
aller Belege (Beleg-Nr, Art, Kunde, Vertrag, Betrag, Datum, PDF),
Suche + Pagination.

Neuer Endpoint GET /credit-notes (NICHT staff-only wie die uebrigen
Credit-Note-Endpoints): Staff sieht alle Belege aller Kunden, Portal-
Kunden nur eigene + vertretene (Vollmacht via hasAuthorization).
customerIds kommt aus dem JWT, nicht aus Query/Body -> nicht
manipulierbar. Fuer Portal wird receiptPath aus der Response entfernt
(Belege bleiben staff-only). Route requirePermission contracts:read.

Verifiziert: Staff -> alle Belege; Portal-scoped -> nur eigene, korrekt
zugeordnet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 10:23:53 +02:00
duffyduckandClaude Opus 4.8 bbe1aed230 Portal-Passwort: encrypted ohne Hash gilt jetzt als desync (R143)
Pentester R143 (Robustheit): getCustomerPortalPassword lieferte 'ok',
wenn portalPasswordEncrypted gesetzt, aber portalPasswordHash null ist.
Ohne Hash ist ein Login unmoeglich -> ein revealtes Passwort waere
irrefuehrend. Jetzt -> desync (Reveal/Send blocken mit 409). Aktuell
ueber die API nicht erreichbar, nur als DB-Altlast; jetzt sauber
abgefangen.

Verifiziert: encrypted+hash=null -> desync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 10:06:00 +02:00
duffyduckandClaude Opus 4.8 5047bfaab3 Gutschrift: nur mit Empfaengeradresse anlegbar (Rechnung > Liefer)
Eine Gutschrift/ein Lieferschein braucht eine Empfaengeradresse aufs
Dokument. Rechnungsadresse hat Vorrang, sonst Lieferadresse. Ist keine
von beiden hinterlegt -> Anlegen blockiert.

- Frontend: Klick auf 'Gutschrift anlegen' prueft defaults.hasRecipient-
  Address; wenn false -> Modal-OK-Meldung statt Formular.
- Backend Defense-in-Depth: createCreditNote wirft 400, wenn weder
  billingAddressId noch addressId gesetzt. getCreditNoteDefaults liefert
  hasRecipientAddress.

Verifiziert: ohne Adresse -> hasRecipientAddress false + create 400;
mit Adresse -> ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 09:39:04 +02:00
duffyduckandClaude Opus 4.8 0078654465 Gutschrift: Beleg-Upload auch fuer Sachwerte + kein Unterschriftsblock bei Geld
- Beleg-Upload jetzt fuer beide Arten: bei Geld die Ueberweisungs-
  bestaetigung, bei Sachwert das unterschriebene Dokument. ReceiptControls
  in der Liste fuer Geld UND Sachwert (Label je nach Typ). Endpoint war
  schon typ-agnostisch.
- PDF-Unterschriftsblock nur noch bei Sachwert - eine Ueberweisung wird
  nicht unterschrieben (Beleg = hochgeladene Ueberweisungsbestaetigung).
  Bei Geld entfaellt der Unterschrift/Ort-Block; im Formular sind Ort +
  'Unterschrift am' bei Geld ausgeblendet.

Verifiziert: beide PDFs erzeugen sauber (je 1 Seite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 09:26:35 +02:00
duffyduckandClaude Opus 4.8 5e606df49e Gutschrift: separater Lieferschein-Nummernkreis (GoBD, luecken-frei)
Pentester R142: Uebergang Geld->betragsloser Sachwert setzte die schon
vergebene Gutschriftsnummer auf null -> Luecke in der GS-Serie.

Loesung: betragsloser Sachwert = Lieferschein mit eigener
Lieferscheinnummer aus separatem Nummernkreis.

- Schema: CreditNote.deliveryNoteNumber (nullbar, unique) + neues Model
  DeliveryNoteNumberRange (Default-Praefix 'LS-') + Migration.
- deliveryNoteNumberRange.service (mirror, eigener Zaehler, FOR UPDATE).
- Nummern lazy pro Serie, NIE freigeben: Uebergaenge behalten die
  jeweils vergebene Nummer der anderen Serie reserviert -> kein
  Doppelverbrauch, keine Luecke. effectiveNumber() liefert je nach Typ
  die passende (LS/GS) fuer Anzeige/PDF/Audit.
- Endpunkte GET/PUT /credit-notes/delivery-note-number-range; Settings-
  Seite verwaltet jetzt beide Nummernkreise. PDF-Titel 'Sachwert-
  Uebergabe', Dateiname lieferschein-...
- Frontend: Typ + displayNumber in Liste/Modal.

Verifiziert: Sachwert 0 -> LS-Nr, GS-Zaehler unberuehrt; Geld -> GS-Nr;
Uebergaenge behalten beide Nummern (kein Neuverbrauch, keine Luecke).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 08:47:49 +02:00
duffyduckandClaude Opus 4.8 79f6f3e629 Portal-Passwort: Reveal/Send prueft Konsistenz gegen Login-Hash
Pentester-Hinweis: bcrypt-Hash (Login) und verschluesseltes Reveal-Feld
koennen out-of-sync sein -> Support liest ein Passwort vor, das beim
Login scheitert.

Analyse: alle aktuellen Schreibpfade sind konsistent (beide Felder
zusammen, oder encrypted=null, oder Rehash desselben Passworts) - der
Code erzeugt keinen Desync. Ursache = Altlast/manueller DB-Eingriff.

Fix (defensiv, unabhaengig von der Ursache):
- getCustomerPortalPassword liefert {status: ok|none|desync} und prueft
  den entschluesselten Klartext per bcrypt.compare gegen den Login-Hash.
- Bei desync (oder Entschluesselungsfehler) geben WEDER Reveal NOCH
  Send-Credentials das Passwort aus -> 409 'Dateninkonsistenz, bitte
  neu setzen'. Reveal-Read wird mit Status auditiert.
- Neues Diagnose-Script scripts/check-portal-password-sync.ts scannt
  alle Portal-Kunden auf Desync (nur Diagnose, aendert nichts) - fuer
  Prod, da der Pentester keinen FS-Zugriff hat.

Verifiziert: desync -> nicht ausgegeben; konsistent -> ok; kein PW ->
none. Scan laeuft (0 Desync auf Dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-11 22:46:53 +02:00
duffyduckandClaude Opus 4.8 15ac003dad Gutschrift: betragsloser Sachwert bekommt keine Gutschriftsnummer
Ein betragsloser Sachwert ist eher ein Lieferschein als eine Gutschrift
-> er soll KEINE Gutschriftsnummer aus dem Nummernkreis verbrauchen.

- Schema: CreditNote.number nullbar (Migration MODIFY ... NULL, UNIQUE
  bleibt - MySQL erlaubt mehrere NULLs).
- createCreditNote: betragsloser Sachwert -> number=null, assignNextNumber
  wird NICHT aufgerufen (Zaehler unangetastet).
- updateCreditNote: Uebergaenge - wird betragslos -> Nummer entfernen;
  bekommt nachtraeglich Betrag & hatte keine -> jetzt Nummer vergeben.
- PDF/Liste/Modal/Audit: Fallback 'Sachwert-Uebergabe'/'Beleg #id' wenn
  keine Nummer; PDF-Titel 'Sachwert-Uebergabe', kein ZUGFeRD (schon vorher).

Verifiziert: Sachwert 0 -> number null + Zaehler bleibt; Geld -> Nummer
+ Zaehler +1; Sachwert nachtraeglich mit Betrag -> Nummer vergeben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 17:40:15 +02:00
duffyduckandClaude Opus 4.8 6f95005530 Gutschrift: Sachwert ohne Betrag = keine Rechnung (kein ZUGFeRD)
Eine Sachwert-Gutschrift darf betragslos sein (Betrag leer/0): dann
findet keine Rechnungsstellung statt - der Kunde hat den Gegenstand
einfach als Subvention erhalten.

- Backend: leeres amount -> 0; Sachwert erlaubt 0, Geld verlangt > 0
  (400 sonst). vatRelevant bei 0 erzwungen-false.
- PDF: betragsloser Sachwert -> Titel 'Sachwert-Uebergabe', kein
  Betrags-/USt-Block (Hinweis keine Rechnungsstellung), KEIN ZUGFeRD-
  Embedding. Mit Betrag -> unveraendert ZUGFeRD.
- Frontend: Wert-Feld bei Sachwert optional; USt-Block ausgeblendet ohne
  Betrag; Liste zeigt 'Sachwert ohne Betrag (keine Rechnung)'.

Verifiziert: Sachwert 0 -> kein factur-x.xml; Geld 0 -> 400;
Sachwert 200 -> ZUGFeRD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 13:22:13 +02:00
duffyduckandClaude Opus 4.8 4d14390b2a Cockpit-Filter Kuendigungsbestaetigung: Status EXPIRED mitzaehlen
Verträge mit hinterlegter Kuendigungsbestaetigung im Status EXPIRED
(Abgelaufen) werden jetzt auch im Cockpit-Filter gelistet, zusaetzlich
zu ACTIVE/DRAFT/CANCELLED. Die Cockpit-Query laedt EXPIRED ohnehin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 23:42:31 +02:00
duffyduckandClaude Opus 4.8 3e2d9395a7 Hygiene R140: Datei-Loesch-Helfer konsolidieren + DB-vor-Datei
Pentester-Hygiene zu a6b1dac:

1) Konsolidierung: neuer utils/fileCleanup.ts mit deleteFileAbsolute
   (absoluter Pfad, z.B. Multer-Temp) + deleteUploadByRelativePath
   (in DB gespeicherter /uploads/-Pfad). Ersetzt die 3x kopierten
   deleteFileIfExists/cleanupFile in creditNote-, upload- und
   customer-Service.

2) Reihenfolge: In deleteCreditNote/updateCreditNote erst die DB-
   Operation, DANN die Datei loeschen. Schlaegt der DB-Schritt fehl,
   bleibt die Datei erhalten (kein ins-Leere-zeigender Zustand).

Verifiziert: Update -> pdfPath null + alte Datei weg; Delete -> gibt
geloeschte Row zurueck (Audit) + Datei weg. Kein Regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:57:40 +02:00
duffyduckandClaude Opus 4.8 c29ffd7bea Vertragsformular: Link-Icon am Zaehler-Label zur Kundenakte (Strom/Gas)
Wie bei Bankkarte/Ausweis/Adresse: das 'Zaehler'-Label im Energie-
Vertragsformular bekommt ein LabelWithLink zum Zaehler-Tab der
Kundenakte (/customers/:id?tab=meters), oeffnet im neuen Tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:51:38 +02:00
duffyduckandClaude Opus 4.8 5f06ed9830 Fix Folgevertrag aus deaktiviertem Vertrag + Kundendaten-Modal erweitern
1) Folgevertrag-Bug: Backend legt den Folgevertrag korrekt mit
   previousContractId an. Der Verlust passierte im Frontend - das
   Vorgaenger-Dropdown holt Vertraege ueber getAll, das DEACTIVATED
   standardmaessig ausblendet. Beim Bearbeiten des Folgevertrags war der
   deaktivierte Vorgaenger nicht als Option da -> Verknuepfung ging beim
   Speichern verloren.
   Fix: getAllContracts + Controller + contractApi.getAll um
   includeDeactivated erweitert; Vorgaenger-Dropdown nutzt es und markiert
   deaktivierte Vertraege mit '· deaktiviert'. Verifiziert (Flag inkludiert
   deaktivierte; Folgevertrag setzt previousContractId).

2) Kundendaten-Modal: zeigt jetzt zusaetzlich Lieferadresse + (falls
   abweichend) Rechnungsadresse des geoeffneten Vertrags, die Stressfrei-
   Adresse des Vertrags einzeln und darunter alle weiteren Stressfrei-
   Adressen des Kunden. CustomerInfoModal nimmt optionale Vertragskontext-
   Props; ContractDetail + ContractForm uebergeben sie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:49:41 +02:00
duffyduckandClaude Opus 4.8 a6b1dac606 Gutschriften: Dateien beim Loeschen/Bearbeiten mit aufraeumen (R138)
Pentester-Hinweis: generierte Gutschrift-PDFs blieben nach dem Loeschen
der Gutschrift als verwaiste Files im Upload-Ordner liegen (harmlos, da
ohne DB-Referenz nicht mehr abrufbar - aber unsauber).

deleteCreditNote entfernt jetzt PDF (pdfPath) + Ueberweisungsbeleg
(receiptPath) von der Platte. updateCreditNote loescht das alte PDF
beim Leeren von pdfPath. Kein verwaister Ordner-Muell mehr.

Verifiziert: PDF nach Erzeugung vorhanden, nach Loeschen der Gutschrift
weg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:31:33 +02:00
duffyduckandClaude Opus 4.8 5fb01627b0 Vertrag-Update: Kuendigungs-Datumsfelder robust normalisieren (R138)
cancellationConfirmationDate / cancellationConfirmationOptionsDate im
PUT /contracts/:id ueber validateOptionalIsoDate normalisieren: nimmt
Datum-only (YYYY-MM-DD) UND volles ISO, liefert einen sauberen Date an
Prisma. Vorher lehnte Prisma ein Datum-only ab (400 statt Verarbeitung).
Ungueltige Formate -> sauberes 400. Konsistent zum Upload-Pfad, der
confirmationDate bereits so validiert.

Verifiziert: '2027-09-13' akzeptiert + als DATETIME geschrieben;
deutsches Format abgelehnt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 21:40:31 +02:00
duffyduckandClaude Opus 4.8 0f51a1cc3b Gutschriften/Kuendigung: DRAFT-endDate-Schutz + Belege staff-only
Pentest R138 Hygiene + neue Vorgaben:

1) endDate wird bei DRAFT-Vertraegen NICHT mehr gesetzt (Entwurf = nur
   Vorlage). Nur Status wurde vorher geschont, endDate zog trotzdem mit.

2) Ueberweisungsbelege (credit-note-receipts) sind jetzt reine
   Mitarbeiter/Admin-Downloads: neuer FileOwner-kind 'contract-staff'
   blockt Portal-Kunden im fileDownload-Controller. Das generierte
   Gutschrift-PDF (credit-notes) bleibt vertragsbasiert -> der
   besitzende Kunde darf seine eigene Gutschrift laden.

Bereits vorher abgesichert (bestaetigt): Kunden koennen keine
Gutschriften anlegen (blockPortal) und keine Belege hochladen
(Portal-403 im Upload).

Verifiziert: DRAFT haelt endDate; Beleg-Owner=contract-staff,
PDF-Owner=contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 21:29:43 +02:00
duffyduckandClaude Opus 4.8 8169c74d8e Vertrag: Auto-CANCELLED bei Kuendigungsbestaetigung + Cockpit-Filter
1) Auto-Status: Wird eine Kuendigungsbestaetigung hinzugefuegt (Datum
   ueber Formular und/oder Dokument-Upload), wird der Vertrag von ACTIVE
   auf CANCELLED gesetzt und das Vertragsende = Kuendigungsdatum.
   Zentrale Funktion maybeCancelOnCancellationConfirmation (idempotent,
   nur aus ACTIVE). Upload-Route ersetzt die alte Inline-Logik (setzt
   jetzt auch endDate); Update-Controller triggert nur bei neu/geaendertem
   Bestaetigungsdatum (manuelle Status-Korrekturen bleiben unangetastet).

2) Cockpit-Filter 'Kuendigungsbestaetigung': neue Liste
   cancellationConfirmations (Vertraege mit Bestaetigung in Status
   ACTIVE/DRAFT/CANCELLED) + Filter-Option im Cockpit-Dropdown. Eigene
   Liste, weil bereits CANCELLED-Vertraege mangels Issue sonst nicht in
   der Cockpit-Liste auftauchen.

Beides lokal verifiziert (Helper: ACTIVE->CANCELLED + endDate; Cockpit:
Vertrag erscheint in der Liste mit korrektem Status).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 15:13:05 +02:00
duffyduckandClaude Opus 4.8 5423c83009 Gutschriften Phase 3b/2: ZUGFeRD/Factur-X (hybrides PDF/A-3)
- zugferd.service.ts: CII-XML (EN 16931, urn:cen.eu:en16931:2017,
  Dokumenttyp 381 Gutschrift). Steuerkategorie S bei USt, sonst E mit
  Befreiungsgrund. Verkaeufer=Firma (CompanyProfile), Kaeufer=Kunde.
- zugferdPdf.service.ts: bettet factur-x.xml als AF /Data ein, setzt
  sRGB-OutputIntent + XMP (PDF/A-3B pdfaid + Factur-X-Extension-Schema)
  via pdf-lib.
- creditNotePdf: eingebettete DejaVuSans-Fonts (Pflicht fuer PDF/A)
  statt Standard-Helvetica; nach PDF-Erzeugung ZUGFeRD-Embedding.
- assets/fonts (DejaVuSans + Bold) + assets/icc (sRGB) ins Repo;
  Dockerfile kopiert backend/assets ins Runtime-Image.

Lokal strukturell verifiziert: 1 Seite, /AF, /Metadata, /OutputIntents,
EmbeddedFiles, Font eingebettet, XML wohlgeformt (xmllint), TypeCode
381, GrandTotal korrekt.

WICHTIG: vor Prod gegen einen ZUGFeRD-/Factur-X-Validator pruefen
(Staging + echte Firmendaten). Feinheiten (Trailer-ID, XMP, XML-MIME)
ggf. nach erstem Validator-Lauf nachziehen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 12:56:12 +02:00
duffyduckandClaude Opus 4.8 043bc173b7 Gutschrift-PDF: Fusszeile etwas tiefer positioniert
Unteren Rand fuer die Fusszeile temporaer auf 22 verkleinert, damit sie
naeher am Seitenende sitzt - weiterhin nur 1 Seite (verifiziert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 19:01:34 +02:00
duffyduckandClaude Opus 4.8 32236706db Gutschrift-PDF: Fusszeile auf Seite 1 (keine zweite Seite mehr)
Footer wurde mit fester y=790 gesetzt und lief dadurch ueber den
unteren Seitenrand -> pdfkit legte eine zweite Seite an. Position jetzt
aus Seitengeometrie berechnet (heightOfString + page.height/margins),
sodass die Fusszeile am unteren Rand der ersten Seite endet, auch bei
zweizeiligem Umbruch. Verifiziert: PDF hat nur noch 1 Seite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 18:39:41 +02:00
duffyduckandClaude Opus 4.8 f24c4308aa Gutschriften: Auszahlungskonto (Bankkonto des Kunden) fuer Ueberweisungen
Bei Geld-Gutschriften kann die Auszahlung auf ein anderes Konto gehen
als das Vertrags-Abbuchkonto:

- CreditNote.payoutBankCardId + Migration (FK ON DELETE SET NULL, damit
  Loeschen einer Bankkarte die Gutschrift nicht mitreisst).
- Formular: Dropdown mit allen Bankkonten des Kunden (Default =
  Vertrags-Abbuchkonto). getCreditNoteDefaults liefert bankCards +
  contractBankCardId.
- Server prueft, dass die gewaehlte Bankkarte dem Kunden des Vertrags
  gehoert (kein Fremdkonto unterschieben).
- PDF: bei Ueberweisung 'Unsere Bankverbindung' (Absender) + darunter
  'an Bankkonto: <Kunden-IBAN> (<Inhaber>)'. Section-Zeile zeigt das
  Auszahlungskonto.

Lokal verifiziert (Anlage + PDF).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:49:35 +02:00
duffyduckandClaude Opus 4.8 642fb05c76 Gutschriften Phase 3b/1: Gutschrift-PDF (pdfkit)
PDF-Erzeugung fuer Gutschriften mit Firmenstammdaten als Absender,
Empfaenger (Kunde+Adresse), Netto/USt/Brutto (bzw. ohne USt-Ausweis),
Sachwert/'Ware erhalten' bzw. Ueberweisungs-Bankverbindung,
Unterschriftsblock, Firmen-Fusszeile.

Endpoint POST /credit-notes/:id/pdf; 'PDF'-Button in CreditNotesSection
(erzeugen + im Tab oeffnen bzw. vorhandenes ansehen). PDF-Pfad wird bei
inhaltlicher Aenderung geleert -> Neu-Erzeugung. Download ueber
fileDownload (credit-notes subDir, Vertrags-Ownership).

Lokal verifiziert (valides %PDF, korrekte Betraege).

ZUGFeRD-XML-Embedding (PDF/A-3, EN 16931) folgt als Teil 2 mit
Validator-Pruefung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:32:34 +02:00
duffyduckandClaude Opus 4.8 2c705272ea Gutschriften Phase 3a: Firmenstammdaten (Absender) fuer PDF/ZUGFeRD
Neues CompanyProfile-Modell (Einzel-Zeile) + Migration (IF NOT EXISTS):
Firmenname, Anschrift, USt-IdNr, Steuernummer, Handelsregister,
Geschaeftsfuehrer, Kontakt, IBAN/BIC/Bank. Fliesst spaeter in
Gutschrift-PDF + ZUGFeRD-Verkaeuferdaten ein.

Backend: Service (getOrCreate/update mit Feld-Whitelist), Controller,
Routes GET/PUT /api/company-profile (settings:read/update), auditiert.

Frontend: Settings-Seite 'Firmenstammdaten (Absender)'
(/settings/company-profile) mit Firma/Anschrift, Steuer/Register,
Kontakt/Bank. Typ + companyProfileApi + Menue-Eintrag.

Phase 3b (PDF + ZUGFeRD-Erzeugung) folgt darauf auf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:19:36 +02:00
duffyduckandClaude Opus 4.8 9e38cb32f0 Gutschriften Phase 2b: Vertrags-UI + Beleg-Upload + Nummernkreis-Seite
Frontend:
- CreditNotesSection im Vertragsdetail (eigene Section wie Rechnungen):
  Liste + Anlegen/Bearbeiten (Modal). Geld/Sachwert mit passenden
  Feldern, USt-Block mit Live-Vorschau Netto/USt/Brutto, Datum, Ort,
  Unterschrift, 'Ware erhalten'. Loeschen mit Bestaetigung.
- Ueberweisungsbeleg-Upload/-Anzeige/-Entfernen (nur Geld-Gutschriften).
- Nummernkreis-Verwaltung als Settings-Seite mit Live-Vorschau der
  naechsten Nummer.
- Frontend-Typen (CreditNote etc.) + creditNoteApi.

Backend:
- Ueberweisungsbeleg-Upload-Route /upload/credit-notes/:id/receipt
  (+ DELETE), Portal geblockt, Ownership ueber Vertrag.
- fileDownload: Ownership-Aufloesung fuer credit-note-receipts +
  credit-notes (Vertrag) ergaenzt.

Als Section statt Tab-Balken, weil die Vertragsansicht kartenbasiert
ist (kein bestehender Tab-Balken); spaeter umbaubar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 13:15:02 +02:00
duffyduckandClaude Opus 4.8 3580c51cb1 Gutschriften Phase 2a: Kleinunternehmer-Flag am Kunden + USt-Default
Kunden-Feld vatExempt (Kleinunternehmer/USt-befreit §19) + Migration
(ADD COLUMN IF NOT EXISTS). Checkbox im Kundenformular nur fuer
Firmenkunden.

Gutschrift-Vorbelegung vatRelevant wird aus dem Kunden abgeleitet:
Firmenkunde ohne USt-Befreiung -> USt-relevant an (Netto), sonst aus
(wie Privat). Jede Gutschrift speichert ihren eigenen Snapshot, ein
spaeterer Statuswechsel des Kunden aendert bestehende Gutschriften
nicht.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 10:10:13 +02:00
duffyduckandClaude Opus 4.8 f1c37a7d25 Gutschriften Phase 1: Backend (Modell, Nummernkreis, CRUD, USt-Rechnung)
Neues Feature Gutschriftsverwaltung (Subventionen am Vertrag):

- Modelle CreditNote + CreditNoteNumberRange + Enums (GELD/SACHWERT,
  PRIVAT/FIRMA, NETTO/BRUTTO) + Migration (IF NOT EXISTS, auf Dev
  angewandt).
- USt pro Gutschrift waehlbar (vatRelevant + Basis Netto/Brutto +
  Satz); Netto/USt/Brutto werden berechnet und getrennt gespeichert
  (ZUGFeRD-tauglich). Kundentyp Privat/Firma aus Kunde vorbelegt.
- Nummernkreis in Settings verwaltbar; Nummernvergabe transaktional
  mit SELECT ... FOR UPDATE (keine Doppelvergabe). Bsp GS-2026-0001.
- Service/Controller/Routes: GET/POST /contracts/:id/credit-notes,
  GET .../defaults, GET/PUT/DELETE /credit-notes/:id,
  GET/PUT /credit-notes/number-range. Portal-Token geblockt (interner
  Bereich), CREATE/UPDATE/DELETE auditiert.

Verifiziert: USt-Rechnung (200 netto->238, 200 brutto->168,07+31,93)
und fortlaufende Nummernvergabe.

Phase 2 (Vertrag-UI + Beleg-Upload + Nummernkreis-UI) und Phase 3
(PDF + ZUGFeRD) folgen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-06 09:03:42 +02:00
171 changed files with 16210 additions and 2279 deletions
+5 -1
View File
@@ -12,7 +12,7 @@ backend/dist
.vscode
.idea
*.md
!docker/README.md
# Environment files (use Docker environment instead)
.env
@@ -37,6 +37,10 @@ Thumbs.db
__tests__
coverage
# Pentester-/Dev-Helferscripte gehören NICHT ins Prod-Image (nur lokal/Staging
# via Repo-Checkout ausführen). Erzeugen sonst nur toten Ballast im Container.
backend/scripts/seed-magic-byte-test.ts
# Docker files in wrong location
docker-compose.yml
+47
View File
@@ -16,6 +16,8 @@ DB_DATA_DIR=./data/db
UPLOADS_DIR=./data/uploads
FACTORY_DEFAULTS_DIR=./data/factory-defaults
BACKUPS_DIR=./data/backups
# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen)
BANKDATA_DIR=./data/bankdata
# ============== DATENBANK ==============
# Der App-User (DB_USER) wird beim ersten Start automatisch von MariaDB
@@ -51,6 +53,51 @@ JWT_REFRESH_EXPIRES_IN=7d
# Generieren: openssl rand -hex 32
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
# ---------- Optionaler Reverse-Proxy (Caddy) ----------
# Brauchst du nur, wenn du KEINEN eigenen Reverse-Proxy hast (nginx, Nginx
# Proxy Manager, Traefik) und Caddy das SSL übernehmen soll.
#
# EIN/AUS über diesen Schalter nichts auskommentieren:
# leer -> Caddy AUS, wird nicht einmal angelegt ← Standard
# caddy -> Caddy startet bei jedem `docker-compose up -d` mit
# Der Schalter wirkt auch bei `down`, `logs` usw. im Gegensatz zum
# Kommandozeilen-Flag `--profile caddy`, das man leicht vergisst.
COMPOSE_PROFILES= # leer lassen = Caddy aus
# Die folgenden drei Werte werden NUR gebraucht, wenn oben `caddy` steht.
# Ohne Caddy kannst du sie ignorieren.
DOMAIN=crm.example.com
CADDY_DIR=./data/caddy
CADDY_CONFIG_DIR=./data/caddy-config
# ---------- Siegel für das Audit-Log ----------
# OpenCRM protokolliert, wer wann welche Daten gesehen oder geändert hat die
# Beweisspur bei Streit oder Datenschutz-Nachfragen. Dieser Schlüssel versieht
# jeden neuen Eintrag mit einem Siegel, das sich nur damit erzeugen lässt. Er
# liegt AUSSERHALB der Datenbank; wer nur die Datenbank in die Hände bekommt,
# kann damit nichts fälschen, ohne dass es auffliegt.
#
# Generieren: openssl rand -hex 32 (pro Umgebung ein eigener Wert)
#
# Ohne Wert läuft alles normal weiter, nur eben ungesiegelt nichts fällt aus.
# Nachträglich setzen ist jederzeit möglich: bestehende Einträge bleiben gültig,
# ab dem Neustart werden neue gesiegelt (keine Fehlalarme dadurch).
#
# ACHTUNG einmal gesetzt, nicht wieder entfernen: Einträge, die ohne Schlüssel
# entstehen, werden beanstandet, sobald er zurück ist (ein ungesiegelter Eintrag
# inmitten gesiegelter ist von einer Fälschung nicht zu unterscheiden).
# Schlüssel sichern wie ein Passwort geht er verloren, sind die damit
# gesiegelten Einträge dauerhaft nicht mehr prüfbar.
# Ausführlich erklärt in der README, Abschnitt "Audit-Siegel".
AUDIT_HMAC_KEY=
# Nur beim Schlüsselwechsel füllen: die FRÜHEREN Schlüssel, kommagetrennt und
# jüngster zuerst. Damit bleiben die bisherigen Einträge prüfbar, während neue
# schon mit dem neuen Schlüssel gesiegelt werden. Nicht voreilig leeren sonst
# werden die alten Einträge als "manipuliert" gemeldet.
# Beispiel: AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>
AUDIT_HMAC_KEY_OLD=
# Server
NODE_ENV=development
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
+12
View File
@@ -36,6 +36,18 @@ data/factory-defaults/*
data/backups/*
!data/backups/.gitkeep
# Gegenbuch (laeuft auf eigener Maschine): enthaelt das Buch selbst, den
# Signaturschluessel und den Beobachtungsspeicher. Nichts davon gehoert ins
# Repository - der Schluessel schon gar nicht.
tools/audit-notary/data/*
!tools/audit-notary/data/.gitkeep
!tools/audit-notary/data/prod/
tools/audit-notary/data/prod/*
!tools/audit-notary/data/prod/.gitkeep
!tools/audit-notary/data/staging/
tools/audit-notary/data/staging/*
!tools/audit-notary/data/staging/.gitkeep
# Factory-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
factory-exports/*
!factory-exports/.gitkeep
+42
View File
@@ -0,0 +1,42 @@
# OpenCRM Caddyfile
# ===================
# Wird NUR gebraucht, wenn du den optionalen Caddy-Dienst startest:
# docker-compose --profile caddy up -d
# Caddy holt und erneuert dann das SSL-Zertifikat automatisch fuer ${DOMAIN}.
# Betreibst du bereits einen eigenen Reverse-Proxy (nginx, NPM, Traefik),
# brauchst du diese Datei nicht - dann laeuft alles ueber deinen Proxy.
{$DOMAIN:localhost} {
# Reverse proxy to OpenCRM app
reverse_proxy opencrm:3001
# Logging
log {
output stdout
format console
}
# Security headers
header {
# Clickjacking protection
X-Frame-Options "SAMEORIGIN"
# XSS protection
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
# Referrer policy
Referrer-Policy "strict-origin-when-cross-origin"
}
# Gzip compression NUR fuer statische Assets, NICHT fuer /api/*.
# Komprimierte API-Antworten ermoeglichen die BREACH-Attacke
# (CVE-2013-3587): aus der Antwortgroesse liessen sich unter bestimmten
# Bedingungen Geheimnisse aus JSON-Responses ableiten. Statische Dateien
# sind davon nicht betroffen und bleiben komprimiert.
@nicht_api not path /api/*
encode @nicht_api gzip
# Handle file uploads (increase body limit)
request_body {
max_size 50MB
}
}
+352 -28
View File
@@ -229,6 +229,42 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren
> siehe [Production-Deployment](#production-deployment).
### Zugangsdaten auf bestehender DB neu setzen (Reseed)
Nützlich z.B. auf **Staging/Test nach einem DB-Reset**, wenn Admin-/Portal-
Passwörter randomisiert sind und man wieder **bekannte** Zugangsdaten braucht
(etwa für einen Pentester).
**Der Seed ist datenerhaltend:** Er `upsert`et nur die Rollen und den
Admin-User (`admin@admin.com`) und synchronisiert die Rollen-Berechtigungen.
**Kundendaten, Verträge, Bankkarten usw. werden NICHT angefasst** (das einzige
`deleteMany` betrifft ausschließlich Rollen↔Permission-Zuordnungen). Ein
erneuter Seed setzt also faktisch nur das Admin-Passwort neu.
**Admin-Passwort deterministisch neu setzen:**
```bash
# 1) In der .env / docker-compose environment setzen:
SEED_ADMIN_PASSWORD=<ein dir bekanntes Passwort, MIND. 25 Zeichen>
RUN_SEED=true # erzwingt den Seed auch bei nicht-leerer DB
# 2) Container neu starten der Entrypoint seedet erneut (upsert):
docker compose up -d
# 3) Login: admin@admin.com / <SEED_ADMIN_PASSWORD>
# 4) Danach RUN_SEED wieder auf false (sonst re-seedet jeder Restart harmlos,
# aber unnötig). SEED_ADMIN_PASSWORD kann gesetzt bleiben.
```
> ⚠️ `SEED_ADMIN_PASSWORD` **muss ≥ 25 Zeichen** sein, sonst wird es ignoriert
> (Passwort-Policy) und der Seed generiert wieder ein Zufallspasswort (→ Logs,
> siehe oben).
**Portal-Login (Kunde):** Portal-Passwörter werden **nicht** vom Seed gesetzt.
Nach dem Admin-Login lässt sich in der Kundenakte für einen (Test-)Kunden das
Portal-Passwort im UI **anzeigen/neu setzen** damit hat man wieder einen
bekannten Portal-Zugang für Scoping-Tests.
## Production-Deployment
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
@@ -245,6 +281,10 @@ LISTEN_ADDR=127.0.0.1
# Bei separatem Frontend-Host: erlaubte Origins
CORS_ORIGINS=https://crm.deine-domain.de
# Siegel fuer das Audit-Log schuetzt die Beweisspur vor nachtraeglicher
# Faelschung. Siehe Abschnitt "Audit-Siegel" weiter unten.
AUDIT_HMAC_KEY=$(openssl rand -hex 32)
```
### Deployment-Modus: On-Prem vs. Cloud
@@ -262,6 +302,279 @@ SSRF-Schalter:
SSRF_BLOCK_PRIVATE_IPS=true
```
### Audit-Siegel (`AUDIT_HMAC_KEY`)
**Worum geht es?**
OpenCRM führt ein Audit-Log: eine lückenlose Aufzeichnung, wer wann welche
Daten gesehen oder geändert hat. Das ist die Beweisspur, wenn es Streit gibt,
etwas verschwindet oder der Datenschutz nachfragt.
Damit diese Aufzeichnung etwas wert ist, muss man ihr ansehen können, ob
jemand nachträglich daran herumgeschrieben hat. Dafür trägt jeder Eintrag
einen Fingerabdruck, der auch den Fingerabdruck des vorherigen Eintrags
enthält wie eine Kette. Ändert jemand einen alten Eintrag, passen die
Fingerabdrücke nicht mehr und es fällt auf.
**Und wozu dann noch ein Schlüssel?**
Weil jemand mit Zugriff auf die Datenbank die ganze Kette neu berechnen
könnte. Er ändert also einen Eintrag zum Beispiel einen fehlgeschlagenen
Login-Versuch in einen erfolgreichen und zieht anschließend alle
Fingerabdrücke glatt. Danach sieht die Fälschung echt aus.
Mit `AUDIT_HMAC_KEY` bekommt jeder Eintrag zusätzlich ein **Siegel**, das sich
nur mit diesem Schlüssel erzeugen lässt. Der Schlüssel liegt in der
`.env`-Datei, also **außerhalb der Datenbank**. Wer nur die Datenbank in die
Hände bekommt, kann damit nichts fälschen, ohne dass es auffliegt.
> Bildlich: Die Fingerabdruck-Kette ist die fortlaufende Nummerierung der
> Aktenseiten. Der Schlüssel ist der Stempel des Notars. Die Akte steht zwar
> im Schrank aber den Stempel hat nur der Notar.
**Einrichten**
```bash
# Einmalig pro Umgebung einen Schlüssel erzeugen
openssl rand -hex 32
```
**In welche Datei?** Das hängt davon ab, wie du OpenCRM betreibst:
| Betrieb | Datei |
|---|---|
| **Docker** (Standard) | **`.env` im Projektstamm** |
| Backend direkt, ohne Container (Entwicklung) | `backend/.env` |
Beim Docker-Betrieb reicht der Eintrag in der Stamm-`.env` `docker-compose.yml`
reicht die Variable an den Container weiter. Ein Eintrag in `backend/.env` wirkt
dort **nicht**, weil der Container diese Datei nicht liest.
Wichtig: **pro Umgebung ein eigener Schlüssel** (Entwicklung, Test, Produktion)
und niemals ins Git-Repository.
**Bestehende Installation: bisher kein Schlüssel gesetzt?**
Genau so ist es gedacht der Schlüssel kam mit einem Update dazu. Du kannst
ihn jederzeit nachträglich setzen:
1. Schlüssel erzeugen (`openssl rand -hex 32`), in die `.env` eintragen
2. Backend neu starten
3. Fertig mehr ist nicht nötig
Was dabei mit deinen **bereits vorhandenen** Einträgen passiert:
- Sie bleiben **unverändert** und weiterhin gültig. Es wird nichts neu
berechnet und nichts nachträglich gesiegelt.
- Ab dem Neustart werden **neue** Einträge gesiegelt. Alt und neu liegen
friedlich nebeneinander, die Prüfung erkennt für jeden Eintrag automatisch,
nach welchem Verfahren er zu prüfen ist.
- Es gibt **keine** Fehlalarme dadurch. (Nachgemessen auf einer Installation
mit 4903 alten, 57 mittleren und 67 gesiegelten Einträgen: 0 Beanstandungen.)
Rückwirkend lässt sich der Schutz nicht herstellen ältere Einträge bleiben
ungesiegelt. Das ist kein Fehler, sondern liegt in der Natur der Sache: Ein
Siegel kann man nur beim Anlegen vergeben, nicht nachträglich. Je früher du
den Schlüssel setzt, desto größer der geschützte Zeitraum.
> ⚠️ **Einmal gesetzt, nicht wieder abschalten.** Fehlt der Schlüssel später
> (z. B. weil er beim Container-Rebuild aus der `.env` verlorenging), läuft die
> Anwendung normal weiter aber die in dieser Zeit entstandenen Einträge sind
> ungesiegelt und werden von der Prüfung **beanstandet**, sobald der Schlüssel
> wieder da ist. Das ist Absicht: Ein ungesiegelter Eintrag inmitten
> gesiegelter ist von einer Fälschung nicht zu unterscheiden. Nimm den
> Schlüssel deshalb in deine Deploy-Checkliste auf.
Ein noch nicht ersetzter Platzhalter (`<hier-eigenen-wert-eintragen>`) zählt
bewusst **nicht** als Schlüssel sonst würde mit einem öffentlich bekannten
Wert gesiegelt. Das Backend schreibt in diesem Fall eine Warnung ins Log und
lässt das Siegel aus.
**Häufige Fragen**
| Frage | Antwort |
|---|---|
| Was, wenn ich den Schlüssel gar nicht setze? | Nichts fällt aus. Das Audit-Log läuft normal weiter, nur ohne dieses zusätzliche Siegel. |
| Was, wenn ich ihn verliere? | Lässt du das Feld dann **leer**, gelten die gesiegelten Einträge als **„nicht prüfbar"** ausdrücklich nicht als gefälscht, also kein Fehlalarm. Der Nachweis für diesen Zeitraum ist aber weg. Deshalb: sichern wie ein Passwort. |
| Und wenn ich stattdessen einen **neuen** Schlüssel eintrage? | Dann werden die alten Einträge als **„manipuliert" gemeldet** das System kann einen falschen Schlüssel nicht von einer echten Fälschung unterscheiden. Trage den alten Schlüssel deshalb zusätzlich unter `AUDIT_HMAC_KEY_OLD` ein (siehe unten). |
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
| 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`)**
Möchtest du den Schlüssel austauschen etwa weil du vermutest, dass er in
falsche Hände geraten ist geht das ohne Datenverlust:
```env
AUDIT_HMAC_KEY=<neuer Schlüssel>
AUDIT_HMAC_KEY_OLD=<bisheriger Schlüssel>
```
Neue Einträge werden ab sofort mit dem neuen Schlüssel gesiegelt, die
bisherigen bleiben über den alten Schlüssel weiterhin prüfbar.
> ⚠️ **`AUDIT_HMAC_KEY_OLD` nicht voreilig leeren.** Solange Einträge
> existieren, die mit einem alten Schlüssel gesiegelt wurden, muss dieser dort
> stehen bleiben. Entfernst du ihn, werden diese Einträge als **„manipuliert"
> gemeldet** nicht als „nicht prüfbar". Das System kann einen falschen
> Schlüssel nicht von einer echten Fälschung unterscheiden. Leeren kannst du
> das Feld gefahrlos erst, wenn die betroffenen Einträge durch die
> Aufbewahrungsfristen ohnehin gelöscht sind.
**Mehrfach gewechselt?** Das Feld nimmt mehrere Schlüssel kommagetrennt auf
vom jüngsten zum ältesten:
```env
AUDIT_HMAC_KEY=<aktueller Schlüssel>
AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>,<ganz alter>
```
Ohne das würde beim zweiten Wechsel der zuerst genutzte Schlüssel verloren
gehen und alle damit gesiegelten Einträge fälschlich als manipuliert
erscheinen.
**Prüfen, ob alles in Ordnung ist**
Die Prüfung läuft derzeit nur über die API (eine Schaltfläche in der Oberfläche
gibt es dafür noch nicht) als angemeldeter Benutzer mit dem Recht
`audit:read`:
```bash
curl -X POST https://crm.deine-domain.de/api/audit-logs/verify \
-H "Authorization: Bearer <Access-Token>"
```
Die Antwort enthält einen Klartext-Satz im Feld `message` und unterscheidet
zwei Dinge:
- **„Manipulierte Einträge"** jemand hat einen bestehenden Eintrag
nachträglich verändert. Das ist ernst.
- **„Strukturelle Lücken"** die Kette hat eine Unterbrechung, die Inhalte
sind aber unverändert. Meist harmlos (z. B. gelöschte alte Einträge).
Notiere dir die Zahl nach dem ersten Deploy: Bleibt sie konstant, ist alles
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>
<summary><b>Technische Details</b> (für Entwickler/Admins)</summary>
**Verfahren.** Jeder Audit-Eintrag trägt einen Hash über seine Inhaltsspalten
plus den Hash des Vorgängers (`previousHash`) daraus entsteht die Kette. Es
gibt drei Hash-Versionen, die Spalte `hashVersion` hält fest, welche verwendet
wurde:
| Version | Verfahren | Abdeckung |
|---|---|---|
| 1 | SHA-256 | 7 Felder Altbestand vor der Härtung |
| 2 | SHA-256 | alle 24 Inhaltsspalten, unsigniert |
| 3 | **HMAC-SHA256** mit `AUDIT_HMAC_KEY` | alle 24 Inhaltsspalten, signiert |
Bestandsdaten werden **nicht** nachträglich neu berechnet ein Rehash würde die
Beweiskraft der Vergangenheit überschreiben. Alte Einträge bleiben mit ihrer
Version gültig.
**Version-Floor.** Die zu erwartende Version wird aus der Kette abgeleitet
(erste je mit Version *n* geschriebene Zeile), **nicht** aus der Selbstauskunft
der Zeile. Sonst ließe sich per Downgrade (`hashVersion` 3 → 1) die schwächere
Prüfung erzwingen und anschließend ein gültiger Hash über die wenigen
abgedeckten Felder nachziehen. Zusätzlich gilt: Eine unerklärte Lücke direkt vor
einer signierten Zeile ist ein Befund, kein struktureller Zufall deren
`previousHash` lässt sich ohne Schlüssel nicht fälschen.
**Löschungen.** `POST /api/audit-logs/cleanup` schreibt ein Manifest
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag. Die
Prüfung liest es aus und meldet nur Lücken **ohne** dokumentierte Löschung als
erklärungsbedürftig (`unexplainedGaps`) sonst könnte sich eine böswillige
Löschung als harmlose Lücke tarnen.
**Rückgabe von `POST /api/audit-logs/verify`:**
| Feld | Bedeutung |
|---|---|
| `tamperedEntries` | Inhalt nachträglich verändert, Versions-Downgrade oder gebrochener Anker **ernst** |
| `chainGaps` | Verkettung unterbrochen (alle) |
| `unexplainedGaps` | Teilmenge davon ohne dokumentierte Löschung |
| `unverifiableEntries` | signiert, aber kein Schlüssel konfiguriert **kein** Manipulationsverdacht |
**Fail-safe.** Ohne `AUDIT_HMAC_KEY` schreibt der Dienst weiter Version 2; das
Logging fällt nie wegen fehlender Konfiguration aus.
**Bekannte Grenze.** Der Anker schützt gegen reinen Datenbank-Schreibzugriff.
Wer Schlüssel *und* Datenbank kontrolliert, kann die Kette konsistent neu
rechnen. Eine Off-Site-Notarisierung (regelmäßiger Export/Versiegelung des
Kettenkopfes außerhalb des Systems) wäre die nächste Stufe.
**Achtung zwei Endpunkte mit Nebenwirkung:**
`POST /api/audit-logs/rehash` berechnet alle Hashes neu; danach meldet die
Prüfung überall „gültig", aber eine bestehende Fälschung würde mitbesiegelt.
`POST /api/audit-logs/cleanup` löscht nach Aufbewahrungsregeln, jede gelöschte
Zeile erzeugt eine Lücke. Beide laufen nur manuell und schreiben einen Marker
ins Log.
</details>
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
sind UNABHÄNGIG vom Flag **immer** geblockt das ist Mindestschutz gegen
AWS/GCP/Azure-IMDS-Diebstahl.
@@ -568,39 +881,49 @@ docker-compose down -v # Container stoppen + Daten löschen
docker-compose logs -f # Logs anzeigen
```
### Docker (Produktion)
### Betrieb mit eigenem SSL (optionaler Caddy-Dienst)
Im `docker/` Verzeichnis liegt ein komplettes Produktions-Setup:
Es gibt **eine** `docker-compose.yml` und **eine** `.env` im Projektstamm.
Standardmässig bringt der Stack **keinen** Reverse-Proxy mit die Annahme ist,
dass du bereits einen hast (nginx, Nginx Proxy Manager, Traefik) und OpenCRM
dahinter hängst.
Hast du **keinen**, kannst du Caddy als optionalen Dienst dazuschalten. Er holt
und erneuert das SSL-Zertifikat automatisch:
Dafür gibt es einen Schalter in der `.env` nichts muss auskommentiert werden:
```env
COMPOSE_PROFILES=caddy # leer = kein Caddy (Standard)
DOMAIN=crm.example.de
HTTPS_ENABLED=true
```
```bash
cd docker
# Image bauen
docker-compose build
# Container starten
docker-compose up -d
# Logs anzeigen
docker-compose logs -f app
docker-compose up -d # startet jetzt zusätzlich Caddy
```
**Komponenten:**
- **MariaDB 10.11**: Datenbank
- **App**: Backend + Frontend in einem Container
- **Caddy**: Reverse-Proxy mit automatischem SSL
Solange `COMPOSE_PROFILES` leer ist, wird der Caddy-Dienst **gar nicht erst
angelegt** der Stack verhält sich exakt wie ohne ihn. Der Schalter wirkt auch
bei `down`, `logs` und `ps`, im Gegensatz zum Flag `--profile caddy`, das man
bei Folgebefehlen leicht vergisst.
**Umgebungsvariablen (`docker/.env`):**
```env
MYSQL_ROOT_PASSWORD=sicheres-root-passwort
MYSQL_DATABASE=opencrm
MYSQL_USER=opencrm
MYSQL_PASSWORD=sicheres-passwort
JWT_SECRET=sehr-langer-zufaelliger-string
ENCRYPTION_KEY=64-zeichen-hex-string
DOMAIN=crm.example.com
RUN_SEED=true # Nur beim ersten Start
```
**Was dabei zu beachten ist:**
- Die Zertifikate liegen unter `./data/caddy`. Diesen Ordner **nicht löschen**,
sonst werden bei jedem Start neue Zertifikate angefordert und du läufst in
die Mengenbegrenzung von Let's Encrypt.
- Die Domain muss vorher per DNS auf den Server zeigen, sonst schlägt die
Zertifikatsausstellung fehl.
- `HTTPS_ENABLED=true` setzen das aktiviert das `Secure`-Attribut am
Refresh-Cookie und die korrekte Proxy-Erkennung.
- Der direkte App-Port (`OPENCRM_PORT`, Standard 3010) bleibt weiterhin offen.
Wenn nur Caddy von aussen erreichbar sein soll, schliesse ihn per Firewall
oder setze ihn in der `.env` auf einen Wert, den du nicht freigibst.
Die Konfiguration liegt in der `Caddyfile` im Projektstamm. Sie liefert
Sicherheits-Header mit und komprimiert bewusst **nicht** die API-Antworten
(BREACH-Schutz, siehe Abschnitt zum Reverse-Proxy oben).
## Projektstruktur
@@ -652,7 +975,8 @@ opencrm/
│ │ ├── types/ # TypeScript-Typen
│ │ └── App.tsx # Haupt-Komponente
│ └── package.json
├── docker/ # Docker-Deployment
├── Caddyfile # Optionaler Reverse-Proxy (nur mit --profile caddy)
├── tools/audit-notary/ # Gegenbuch läuft auf einem zweiten Rechner
│ ├── Dockerfile # Multi-Stage Build
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
│ ├── Caddyfile # Reverse-Proxy mit SSL
+107
View File
@@ -19,3 +19,110 @@ ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
# Server
PORT=3001
NODE_ENV=development
# ==================== AUDIT-SIEGEL ====================
# Was ist das Audit-Log?
# OpenCRM protokolliert luekenlos, wer wann welche Daten gesehen oder
# geaendert hat. Das ist die Beweisspur, wenn es Streit gibt oder der
# Datenschutz nachfragt.
#
# Wogegen schuetzt dieser Schluessel?
# Ohne ihn koennte jemand mit Zugriff auf die Datenbank einen Eintrag
# nachtraeglich umschreiben - zum Beispiel einen fehlgeschlagenen
# Login-Versuch in einen erfolgreichen verwandeln - und die Faelschung so
# glattziehen, dass die Pruefung sie fuer echt haelt.
# Mit dem Schluessel bekommt jeder Eintrag ein Siegel, das sich nur mit
# diesem Schluessel erzeugen laesst. Wer ihn nicht hat, kann nichts
# faelschen, ohne dass es auffliegt. Bildlich: Der Schluessel ist der
# Stempel des Notars - die Akte liegt zwar im Schrank, aber den Stempel
# hat nur der Notar.
#
# Wie einrichten?
# Einmalig einen Schluessel erzeugen und hier eintragen:
# openssl rand -hex 32
# Pro Umgebung ein EIGENER Schluessel (Entwicklung, Test, Produktion).
#
# Was passiert, wenn ich ihn weglasse?
# Nichts faellt aus. Das Audit-Log laeuft normal weiter, nur eben ohne
# dieses zusaetzliche Siegel.
#
# Ich habe bisher keinen Schluessel - kann ich ihn nachtraeglich setzen?
# Ja. Schluessel erzeugen, eintragen, Backend neu starten - fertig.
# Bestehende Eintraege bleiben unveraendert gueltig, ab dem Neustart
# werden neue gesiegelt. Alt und neu liegen nebeneinander, es gibt
# dadurch KEINE Fehlalarme. Rueckwirkend siegeln geht nicht - je frueher
# gesetzt, desto groesser der geschuetzte Zeitraum.
#
# ACHTUNG: einmal gesetzt, nicht wieder abschalten.
# Fehlt der Schluessel spaeter (z. B. beim Container-Rebuild verlorengegangen),
# laeuft alles weiter - aber die in dieser Zeit entstandenen Eintraege sind
# ungesiegelt und werden beanstandet, sobald der Schluessel wieder da ist.
# Ein ungesiegelter Eintrag inmitten gesiegelter ist von einer Faelschung
# nicht zu unterscheiden. Also: in die Deploy-Checkliste aufnehmen.
#
# Was passiert, wenn ich ihn verliere?
# Laesst du das Feld LEER, gelten die gesiegelten Eintraege als
# "nicht pruefbar" - NICHT als gefaelscht, also kein Fehlalarm. Der
# Nachweis fuer diesen Zeitraum ist aber weg.
# Traegst du stattdessen einen NEUEN Schluessel ein, ohne den alten unten
# zu hinterlegen, werden die alten Eintraege als "manipuliert" gemeldet -
# ein falscher Schluessel ist von einer Faelschung nicht zu unterscheiden.
# Deshalb: Schluessel sichern, so wie ein Passwort.
#
# Eigenen Wert erzeugen mit: openssl rand -hex 32
# Der Platzhalter unten zaehlt NICHT als Schluessel - laesst du ihn stehen,
# bleibt das Siegel aus (es wird also nicht versehentlich mit einem oeffentlich
# bekannten Wert gesiegelt). Leer lassen ist ebenfalls in Ordnung.
AUDIT_HMAC_KEY=<hier-eigenen-wert-eintragen>
# Frueher verwendete Schluessel - beim Wechsel hier eintragen.
# Moechtest du den Schluessel oben austauschen (z. B. weil du vermutest,
# dass er in falsche Haende geraten ist), trage den ALTEN Schluessel hier
# ein und den NEUEN oben. Dann bleiben die bisherigen Eintraege pruefbar,
# waehrend neue schon mit dem neuen Schluessel gesiegelt werden.
#
# ACHTUNG: Dieses Feld NICHT voreilig leeren. Solange Eintraege existieren,
# die mit einem alten Schluessel gesiegelt wurden, muessen sie hier stehen.
# Sonst werden diese Eintraege als "manipuliert" gemeldet (nicht als
# "nicht pruefbar"). Gefahrlos leeren kannst du erst, wenn die betroffenen
# Eintraege durch die Aufbewahrungsfristen ohnehin geloescht sind.
#
# Mehrfach gewechselt? Mehrere Schluessel kommagetrennt, juengster zuerst:
# AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>,<ganz alter>
#
# Beim ersten Einrichten leer lassen - erst beim Schluesselwechsel noetig.
AUDIT_HMAC_KEY_OLD=
# --- Technisch (fuer Entwickler/Admins) ---------------------------------
# Verfahren : HMAC-SHA256 ueber alle Inhaltsspalten eines Audit-Eintrags,
# inkl. previousHash (Verkettung). Entspricht Hash-Version 3.
# Abdeckung : Version 1 = 7 Felder (Altbestand), Version 2 = alle 24
# Inhaltsspalten (SHA-256, unsigniert), Version 3 = wie 2, aber
# HMAC-signiert. Die erwartete Version wird aus der Kette
# abgeleitet (Version-Floor), nicht aus der Selbstauskunft der
# Zeile - sonst liesse sich per Downgrade die schwaechere
# Pruefung erzwingen.
# Fail-safe : Ohne Schluessel schreibt der Dienst weiter Version 2. Bereits
# signierte Zeilen landen dann in `unverifiableEntries`,
# ausdruecklich NICHT in `tamperedEntries`.
# Rotation : AUDIT_HMAC_KEY_OLD (kommagetrennte Liste) wird bei der
# Pruefung zusaetzlich akzeptiert - Wechsel ohne Rehash. Ein
# falscher/fehlender Alt-Schluessel liefert einen HMAC-Mismatch
# und damit einen Manipulations-Befund; das ist nicht von einer
# echten Faelschung unterscheidbar und daher Absicht.
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
# UND Datenbank hat, kann die Kette konsistent neu rechnen.
# 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
+1
View File
@@ -36,3 +36,4 @@ npm-debug.log*
# OS
.DS_Store
Thumbs.db
bankdata/
+5 -1
View File
@@ -53,6 +53,10 @@ COPY backend/prisma ./prisma
COPY --from=backend-builder /build/backend/src ./src
COPY backend/tsconfig.json ./tsconfig.json
# Statische Assets (eingebettete Fonts für PDF/A + sRGB-ICC-Profil für den
# ZUGFeRD-OutputIntent). Werden zur Laufzeit aus process.cwd()/assets gelesen.
COPY backend/assets ./assets
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
COPY --from=frontend-builder /build/frontend/dist ./public
@@ -63,7 +67,7 @@ COPY backend/factory-defaults /app/factory-defaults-builtin
COPY backend/scripts /app/scripts
# Daten-Verzeichnisse (werden via Bind-Mount überlagert; hier nur als Fallback)
RUN mkdir -p uploads factory-defaults prisma/backups
RUN mkdir -p uploads factory-defaults prisma/backups bankdata
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -12,6 +12,7 @@
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"bankdata-germany": "^2.2602.0",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@ -21,6 +22,7 @@
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
"helmet": "^8.1.0",
"ibantools": "^4.5.4",
"imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3",
@@ -984,6 +986,15 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/bankdata-germany": {
"version": "2.2602.0",
"resolved": "https://registry.npmjs.org/bankdata-germany/-/bankdata-germany-2.2602.0.tgz",
"integrity": "sha512-3iBD/cIKHZsmAaQIYEMsORhvMjILDTJ4gDQJYNVm+YXCzA5U96mUoB7KBy01rtwpFt/rKrNuCVNc8KZVx+jwpQ==",
"license": "MIT OR MPL-2.0",
"engines": {
"node": ">=20"
}
},
"node_modules/bare-events": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
@@ -1992,6 +2003,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/ibantools": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/ibantools/-/ibantools-4.5.4.tgz",
"integrity": "sha512-6jX1gh4aH6XH+o0ey+wtkMTzkcvsEta7DakIOZSng9voZYpMw3U+gK1+tZChk3aRcPcloEt0NOzksjaRZiqXbw==",
"license": "MIT or MPL-2.0"
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+2
View File
@@ -24,6 +24,7 @@
"@types/cookie-parser": "^1.4.10",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"bankdata-germany": "^2.2602.0",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@ -33,6 +34,7 @@
"express-rate-limit": "^8.4.0",
"express-validator": "^7.2.0",
"helmet": "^8.1.0",
"ibantools": "^4.5.4",
"imapflow": "^1.2.8",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.9.3",
@@ -0,0 +1,49 @@
-- Gutschriften (Subventionen) zu einem Vertrag: Geldbetrag (Überweisung)
-- oder Sachwert. USt pro Gutschrift wählbar (vatRelevant + Basis Netto/Brutto).
-- ZUGFeRD-taugliches Datenmodell (Netto/USt/Brutto getrennt).
-- ON DELETE CASCADE: wird der Vertrag gelöscht, verschwinden die Gutschriften.
CREATE TABLE IF NOT EXISTS `CreditNote` (
`id` INT NOT NULL AUTO_INCREMENT,
`contractId` INT NOT NULL,
`number` VARCHAR(191) NOT NULL,
`type` ENUM('GELD', 'SACHWERT') NOT NULL,
`sachwertDescription` VARCHAR(191) NULL,
`customerType` ENUM('PRIVAT', 'FIRMA') NOT NULL,
`vatRelevant` BOOLEAN NOT NULL DEFAULT false,
`amountBasis` ENUM('NETTO', 'BRUTTO') NOT NULL DEFAULT 'BRUTTO',
`vatRate` DOUBLE NOT NULL DEFAULT 19,
`amountNet` DOUBLE NOT NULL DEFAULT 0,
`amountVat` DOUBLE NOT NULL DEFAULT 0,
`amountGross` DOUBLE NOT NULL DEFAULT 0,
`currency` VARCHAR(191) NOT NULL DEFAULT 'EUR',
`creditDate` DATETIME(3) NOT NULL,
`place` VARCHAR(191) NULL,
`signedAt` DATETIME(3) NULL,
`goodsReceived` BOOLEAN NOT NULL DEFAULT false,
`receiptPath` VARCHAR(191) NULL,
`pdfPath` VARCHAR(191) NULL,
`notes` TEXT NULL,
`createdBy` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `CreditNote_number_key` (`number`),
INDEX `CreditNote_contractId_idx` (`contractId`),
CONSTRAINT `CreditNote_contractId_fkey` FOREIGN KEY (`contractId`)
REFERENCES `Contract` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Nummernkreis (Einzel-Zeile, in den Einstellungen verwaltbar).
CREATE TABLE IF NOT EXISTS `CreditNoteNumberRange` (
`id` INT NOT NULL AUTO_INCREMENT,
`prefix` VARCHAR(191) NOT NULL DEFAULT 'GS-',
`includeYear` BOOLEAN NOT NULL DEFAULT true,
`separator` VARCHAR(191) NOT NULL DEFAULT '-',
`padding` INT NOT NULL DEFAULT 4,
`nextNumber` INT NOT NULL DEFAULT 1,
`resetYearly` BOOLEAN NOT NULL DEFAULT true,
`currentYear` INT NULL,
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,3 @@
-- Kleinunternehmer/USt-befreit-Flag am Kunden. Steuert nur die Vorbelegung
-- neuer Gutschriften (Firmenkunde mit vatExempt=true -> keine USt-Vorbelegung).
ALTER TABLE `Customer` ADD COLUMN IF NOT EXISTS `vatExempt` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,23 @@
-- Absender-/Firmenstammdaten (Einzel-Zeile) fuer Gutschrift-PDF + ZUGFeRD.
CREATE TABLE IF NOT EXISTS `CompanyProfile` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL DEFAULT '',
`street` VARCHAR(191) NOT NULL DEFAULT '',
`houseNumber` VARCHAR(191) NOT NULL DEFAULT '',
`postalCode` VARCHAR(191) NOT NULL DEFAULT '',
`city` VARCHAR(191) NOT NULL DEFAULT '',
`country` VARCHAR(191) NOT NULL DEFAULT 'DE',
`vatId` VARCHAR(191) NOT NULL DEFAULT '',
`taxNumber` VARCHAR(191) NOT NULL DEFAULT '',
`commercialRegister` VARCHAR(191) NOT NULL DEFAULT '',
`managingDirector` VARCHAR(191) NOT NULL DEFAULT '',
`email` VARCHAR(191) NOT NULL DEFAULT '',
`phone` VARCHAR(191) NOT NULL DEFAULT '',
`website` VARCHAR(191) NOT NULL DEFAULT '',
`iban` VARCHAR(191) NOT NULL DEFAULT '',
`bic` VARCHAR(191) NOT NULL DEFAULT '',
`bankName` VARCHAR(191) NOT NULL DEFAULT '',
`logoPath` VARCHAR(191) NULL,
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,20 @@
-- Auszahlungskonto (Bankkarte des Kunden) fuer Geld-Gutschriften.
-- ON DELETE SET NULL: Loeschen der Bankkarte reisst die Gutschrift nicht mit.
ALTER TABLE `CreditNote` ADD COLUMN IF NOT EXISTS `payoutBankCardId` INT NULL;
-- FK nur anlegen, wenn noch nicht vorhanden (idempotent via information_schema).
SET @fk := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote'
AND CONSTRAINT_NAME = 'CreditNote_payoutBankCardId_fkey');
SET @sql := IF(@fk = 0,
'ALTER TABLE `CreditNote` ADD CONSTRAINT `CreditNote_payoutBankCardId_fkey` FOREIGN KEY (`payoutBankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote'
AND INDEX_NAME = 'CreditNote_payoutBankCardId_idx');
SET @sql2 := IF(@idx = 0,
'CREATE INDEX `CreditNote_payoutBankCardId_idx` ON `CreditNote`(`payoutBankCardId`)',
'SELECT 1');
PREPARE stmt2 FROM @sql2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2;
@@ -0,0 +1,4 @@
-- Gutschrift-Nummer nullbar: betragslose Sachwert-Uebergaben (Lieferschein)
-- verbrauchen keine Gutschriftsnummer. UNIQUE bleibt (MySQL erlaubt mehrere
-- NULLs in einem UNIQUE-Index).
ALTER TABLE `CreditNote` MODIFY COLUMN `number` VARCHAR(191) NULL;
@@ -0,0 +1,26 @@
-- Lieferschein-Nummerierung fuer betragslose Sachwert-Uebergaben:
-- eigene Nummer am Beleg + eigener Nummernkreis (getrennt von Gutschriften).
ALTER TABLE `CreditNote` ADD COLUMN IF NOT EXISTS `deliveryNoteNumber` VARCHAR(191) NULL;
-- UNIQUE-Index auf deliveryNoteNumber (idempotent).
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote'
AND INDEX_NAME = 'CreditNote_deliveryNoteNumber_key');
SET @sql := IF(@idx = 0,
'CREATE UNIQUE INDEX `CreditNote_deliveryNoteNumber_key` ON `CreditNote`(`deliveryNoteNumber`)',
'SELECT 1');
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
CREATE TABLE IF NOT EXISTS `DeliveryNoteNumberRange` (
`id` INT NOT NULL AUTO_INCREMENT,
`prefix` VARCHAR(191) NOT NULL DEFAULT 'LS-',
`includeYear` BOOLEAN NOT NULL DEFAULT true,
`separator` VARCHAR(191) NOT NULL DEFAULT '-',
`padding` INT NOT NULL DEFAULT 4,
`nextNumber` INT NOT NULL DEFAULT 1,
`resetYearly` BOOLEAN NOT NULL DEFAULT true,
`currentYear` INT NULL,
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,2 @@
-- Kartennummer (Debit-/Kontokarte) als optionales Feld an der Bankkarte.
ALTER TABLE `BankCard` ADD COLUMN IF NOT EXISTS `cardNumber` VARCHAR(191) NULL;
@@ -0,0 +1,15 @@
-- Neuer Vertragsstatus CANCELLED_CONFIRMED ("Gekündigt / bestätigt"):
-- gesetzt, sobald eine Kündigungsbestätigung (Datum/Dokument) vorliegt.
-- CANCELLED bleibt "Gekündigt / Bestätigung abwarten" (Kündigungsschreiben raus).
-- MODIFY COLUMN ist idempotent (erneutes Ausführen ergibt denselben Zustand).
ALTER TABLE `Contract` MODIFY COLUMN `status`
ENUM('DRAFT','PENDING','ACTIVE','CANCELLED','CANCELLED_CONFIRMED','EXPIRED','DEACTIVATED','ONGOING')
NOT NULL DEFAULT 'DRAFT';
-- Daten-Migration: bestehende CANCELLED-Verträge auf CANCELLED_CONFIRMED heben.
-- Die alte Logik setzte CANCELLED NUR, wenn eine Kündigungsbestätigung
-- (Datum ODER Dokument) vorlag diese Verträge sind also bereits bestätigt.
-- CANCELLED bedeutet ab jetzt "Bestätigung abwarten" (nur Kündigungsschreiben).
UPDATE `Contract` SET `status` = 'CANCELLED_CONFIRMED'
WHERE `status` = 'CANCELLED'
AND (`cancellationConfirmationPath` IS NOT NULL OR `cancellationConfirmationDate` IS NOT NULL);
@@ -0,0 +1,23 @@
-- MaLo-IDs (Marktlokation) je Sparte an die Adresse: die MaLo gehört zur
-- (Liefer-)Adresse, nicht zum Vertrag. Strom + Gas getrennt.
ALTER TABLE `Address` ADD COLUMN IF NOT EXISTS `maloIdElectricity` VARCHAR(191) NULL;
ALTER TABLE `Address` ADD COLUMN IF NOT EXISTS `maloIdGas` VARCHAR(191) NULL;
-- Daten-Migration: bestehende Vertrags-MaLo-IDs an die jeweilige Lieferadresse
-- übertragen je nach Vertragstyp in Strom- bzw. Gas-Feld. Nur setzen, wenn
-- am Ziel-Adressfeld noch nichts steht (idempotent, kein Überschreiben).
UPDATE `Address` a
JOIN `Contract` c ON c.addressId = a.id
JOIN `EnergyContractDetails` ecd ON ecd.contractId = c.id
SET a.maloIdElectricity = ecd.maloId
WHERE c.type = 'ELECTRICITY'
AND ecd.maloId IS NOT NULL AND ecd.maloId <> ''
AND (a.maloIdElectricity IS NULL OR a.maloIdElectricity = '');
UPDATE `Address` a
JOIN `Contract` c ON c.addressId = a.id
JOIN `EnergyContractDetails` ecd ON ecd.contractId = c.id
SET a.maloIdGas = ecd.maloId
WHERE c.type = 'GAS'
AND ecd.maloId IS NOT NULL AND ecd.maloId <> ''
AND (a.maloIdGas IS NULL OR a.maloIdGas = '');
@@ -0,0 +1,2 @@
-- "Keine Bonis erwünscht": Kunde möchte bei diesem Energievertrag keine Bonis.
ALTER TABLE `EnergyContractDetails` ADD COLUMN IF NOT EXISTS `noBonusDesired` BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,4 @@
-- Aufgaben ohne Vertrag/Kunde erlauben: contractId nullable.
-- Der bestehende FK (onDelete Cascade) bleibt: gesetzte contractId cascaden
-- weiterhin, NULL bleibt unberührt.
ALTER TABLE `ContractTask` MODIFY COLUMN `contractId` INT NULL;
@@ -0,0 +1,8 @@
-- Neuer AuditAction-Wert TOKEN_REFRESH: trennt stille Access-Token-Refreshes
-- vom interaktiven Login-Rauschen (eigene Action + LOW-Sensitivität).
-- MODIFY COLUMN ist idempotent (setzt die Enum-Definition, mehrfach ausführbar).
ALTER TABLE `AuditLog`
MODIFY COLUMN `action` ENUM(
'CREATE','READ','UPDATE','DELETE','EXPORT','ANONYMIZE',
'LOGIN','LOGOUT','LOGIN_FAILED','TOKEN_REFRESH'
) NOT NULL;
@@ -0,0 +1,19 @@
-- Aufbewahrungsregel fuer routinemaessige Auth-Eintraege (Token-Refresh).
--
-- Seit der Entrauschung landen erfolgreiche Token-Refreshes als
-- `Authentication / LOW`. Diese Kombination traf auf KEINE spezifische Regel
-- (es gab nur `Authentication / CRITICAL`) und fiel damit in die Auffangregel
-- `*` mit 3650 Tagen. Ergebnis: Das Rauschen waere 10 Jahre aufbewahrt worden,
-- echte Logins dagegen nur 2 Jahre - genau verkehrt herum.
--
-- 90 Tage reichen, um einen Refresh-Vorgang im Nachhinein nachzuvollziehen.
-- Idempotent: der Unique-Index (resourceType, sensitivity) verhindert Dubletten.
INSERT INTO `AuditRetentionPolicy`
(`resourceType`, `sensitivity`, `retentionDays`, `description`, `legalBasis`, `isActive`, `createdAt`, `updatedAt`)
VALUES
('Authentication', 'LOW', 90, 'Routine-Auth (stiller Token-Refresh)', 'Betriebsnotwendigkeit / Datenminimierung (DSGVO Art. 5)', 1, NOW(3), NOW(3))
ON DUPLICATE KEY UPDATE
`retentionDays` = VALUES(`retentionDays`),
`description` = VALUES(`description`),
`legalBasis` = VALUES(`legalBasis`),
`updatedAt` = NOW(3);
@@ -0,0 +1,15 @@
-- Einzeiliger Mutex fuer die Audit-Hash-Kette (Pentest R166-01).
--
-- Vorher wurde ueber GET_LOCK serialisiert. Dessen RELEASE_LOCK muss auf
-- derselben Verbindung laufen und stand daher im finally INNERHALB des
-- Transaktions-Callbacks - also VOR dem COMMIT. In diesem Fenster konnte der
-- naechste Schreiber die Sperre holen und das Kettenende lesen, bevor die
-- Vorgaengerzeile committed war: beide haengten sich an denselben Vorgaenger
-- (Fork). InnoDB-Zeilensperren fallen dagegen erst beim COMMIT.
CREATE TABLE IF NOT EXISTS `AuditChainLock` (
`id` INT NOT NULL,
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO `AuditChainLock` (`id`, `updatedAt`) VALUES (1, NOW(3));
@@ -0,0 +1,13 @@
-- Hash-Versionierung fuer Audit-Eintraege (Pentest R166-02).
--
-- Der bisherige Hash deckte nur 7 Felder ab (userEmail, action, resourceType,
-- resourceId, endpoint, createdAt, previousHash). NICHT gehasht waren u. a.
-- changesBefore/changesAfter (die eigentliche Nutzlast), success, ipAddress,
-- resourceLabel, dataSubjectId, userId/customerId - ein nachtraeglicher
-- Einzeledit an genau diesen Feldern blieb also unsichtbar.
--
-- Version 2 hasht alle Inhaltsspalten. Bestandszeilen behalten Version 1 und
-- werden weiterhin mit dem alten Verfahren geprueft - kein Rehash noetig,
-- die Beweiskraft der Vergangenheit bleibt erhalten.
ALTER TABLE `AuditLog`
ADD COLUMN IF NOT EXISTS `hashVersion` INT NOT NULL DEFAULT 1;
@@ -0,0 +1,28 @@
-- Replay-Schutz fuer Refresh-Tokens (Pentest R164-02).
--
-- Die bisherige Rotation bot keinen Replay-Schutz: der alte Token blieb bis exp
-- gueltig (7 Tage), ein gestohlener Token war also parallel zum legitimen
-- nutzbar. Jetzt traegt jeder Refresh-Token eine jti und gehoert zu einer
-- Sitzungsfamilie; beim Einloesen wird die jti verbraucht. Taucht sie erneut
-- auf, wird die gesamte Familie widerrufen und der Vorfall gemeldet.
CREATE TABLE IF NOT EXISTS `RefreshTokenRecord` (
`id` INT NOT NULL AUTO_INCREMENT,
`jti` VARCHAR(191) NOT NULL,
`familyId` VARCHAR(191) NOT NULL,
`userId` INT NULL,
`customerId` INT NULL,
`isCustomerPortal` TINYINT(1) NOT NULL DEFAULT 0,
`issuedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`expiresAt` DATETIME(3) NOT NULL,
`usedAt` DATETIME(3) NULL,
`replacedByJti` VARCHAR(191) NULL,
`reuseCount` INT NOT NULL DEFAULT 0,
`revokedAt` DATETIME(3) NULL,
`revokedReason` VARCHAR(191) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `RefreshTokenRecord_jti_key` (`jti`),
KEY `RefreshTokenRecord_familyId_idx` (`familyId`),
KEY `RefreshTokenRecord_expiresAt_idx` (`expiresAt`),
KEY `RefreshTokenRecord_userId_idx` (`userId`),
KEY `RefreshTokenRecord_customerId_idx` (`customerId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,16 @@
-- Bestandssiegel fuer den nicht signierbaren Altbestand (Pentest R171-02).
--
-- Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein Schreibzugriff auf
-- die Datenbank kann dort Felder wie `success` oder `resourceLabel` aendern,
-- ohne den gespeicherten Hash zu beruehren - die Pruefung meldet weiterhin
-- "gueltig". Live gezeigt: ein fehlgeschlagener Login liess sich unsichtbar in
-- einen erfolgreichen umschreiben.
--
-- Rueckwirkend signieren geht nicht. Stattdessen wird der Altbestand einmalig
-- versiegelt: je Zeile ein Blattwert ueber den vollen Inhalt, die Wurzel
-- darueber in einem HMAC-signierten Marker.
CREATE TABLE IF NOT EXISTS `AuditBacklogSeal` (
`auditLogId` INT NOT NULL,
`leafHash` VARCHAR(191) NOT NULL,
PRIMARY KEY (`auditLogId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -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();
});
+246 -2
View File
@@ -76,6 +76,11 @@ model User {
firstName String
lastName String
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)
// Passwort-Reset
@@ -101,6 +106,16 @@ model Role {
id Int @id @default(autoincrement())
name String @unique
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[]
users UserRole[]
createdAt DateTime @default(now())
@@ -156,6 +171,12 @@ model Customer {
phone String?
mobile String?
taxNumber String?
// Kleinunternehmer nach §19 UStG bzw. sonst USt-befreit: dann wird der
// (Firmen-)Kunde bei Gutschriften wie ein Privatkunde behandelt (keine USt),
// bis er ggf. USt-pflichtig wird. Steuert nur die VORBELEGUNG neuer
// Gutschriften; bereits erstellte Gutschriften speichern ihren eigenen
// Snapshot und bleiben von einem späteren Wechsel unberührt.
vatExempt Boolean @default(false)
businessRegistrationPath String? // PDF-Pfad zur Gewerbeanmeldung
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
commercialRegisterNumber String? // Handelsregisternummer (Text)
@@ -321,6 +342,10 @@ model Address {
city String
country String @default("Deutschland")
isDefault Boolean @default(false)
// Marktlokations-IDs (MaLo) je Sparte die MaLo gehört zur (Liefer-)Adresse,
// nicht zum Vertrag. Strom- und Gas-Marktlokation sind getrennt.
maloIdElectricity String? // MaLo-ID Strom
maloIdGas String? // MaLo-ID Gas
// Eigentümer (leer = Kunde ist selbst Eigentümer)
ownerCompany String?
ownerFirstName String?
@@ -347,6 +372,7 @@ model BankCard {
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
accountHolder String
iban String
cardNumber String? // Karten-/Kontokartennummer (optional, z.B. Debitkarte)
bic String?
bankName String?
expiryDate DateTime?
@@ -354,6 +380,7 @@ model BankCard {
description String? @db.Text // Freitext-Notiz (z.B. "Geschäftskonto", "gemeinsames Konto mit Partner")
isActive Boolean @default(true)
contracts Contract[]
creditNotePayouts CreditNote[] @relation("CreditNotePayoutBankCard")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -695,7 +722,10 @@ enum ContractStatus {
DRAFT
PENDING
ACTIVE
// Kündigungsschreiben raus, Bestätigung des Anbieters steht noch aus.
CANCELLED
// Kündigung vom Anbieter bestätigt (Bestätigungsdatum/-dokument liegt vor).
CANCELLED_CONFIRMED
EXPIRED
DEACTIVATED
// "Läuft weiter auf unbestimmte Zeit" Vertrag ist unbefristet aktiv.
@@ -811,6 +841,7 @@ model Contract {
historyEntries ContractHistoryEntry[]
documents ContractDocument[]
invoices Invoice[]
creditNotes CreditNote[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -854,8 +885,10 @@ enum ContractTaskStatus {
model ContractTask {
id Int @id @default(autoincrement())
contractId Int
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
// Nullable: Aufgaben können auch OHNE Vertrag/Kunde angelegt werden
// (allgemeine interne Aufgabe). Ohne Vertrag → kein Kunde → nie im Portal.
contractId Int?
contract Contract? @relation(fields: [contractId], references: [id], onDelete: Cascade)
title String
description String? @db.Text
status ContractTaskStatus @default(OPEN)
@@ -907,6 +940,7 @@ model EnergyContractDetails {
// `instantBonus` (Annahme: meistgenutzte Variante).
instantBonus Float? // Sofort-Bonus
newCustomerBonus Float? // Neukunden-Bonus
noBonusDesired Boolean @default(false) // Kunde wünscht keine Bonis
previousProviderName String?
previousCustomerNumber String?
invoices Invoice[] // Rechnungen
@@ -946,6 +980,141 @@ model Invoice {
@@index([contractId])
}
// ==================== GUTSCHRIFTEN (CREDIT NOTES / SUBVENTIONEN) ====================
// Art der Gutschrift: Geldbetrag (per Überweisung, in EUR) oder Sachwert
// (Smartphone, Elektrogerät …, näher im Textfeld beschrieben).
enum CreditNoteType {
GELD
SACHWERT
}
// Kundentyp zum Zeitpunkt der Gutschrift bestimmt die USt-Sicht
// (Privat rechnet brutto, Firma zieht Vorsteuer). Wird aus dem Kunden
// vorbelegt, ist aber pro Gutschrift überschreibbar.
enum CreditNoteCustomerType {
PRIVAT
FIRMA
}
// Basis, auf die sich der eingegebene Betrag bezieht (nur relevant, wenn
// vatRelevant = true). Bei NETTO wird die USt aufgeschlagen, bei BRUTTO
// herausgerechnet.
enum CreditNoteAmountBasis {
NETTO
BRUTTO
}
model CreditNote {
id Int @id @default(autoincrement())
contractId Int
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
// Gutschrift-Nummer aus dem Gutschrift-Nummernkreis (z.B. GS-2026-0001).
// NULL bei betragslosen Sachwert-Übergaben (= Lieferschein, s.u.).
number String? @unique
// Lieferschein-/Subventionsnummer aus dem separaten Lieferschein-
// Nummernkreis (z.B. LS-2026-0001) nur bei betragslosen Sachwerten.
// Beide Nummern werden lazy pro Serie vergeben und NIE wieder freigegeben
// (keine Lücken); angezeigt wird je nach aktuellem Typ die passende.
deliveryNoteNumber String? @unique
type CreditNoteType
// Pflicht bei SACHWERT: was wird gewährt (z.B. "Smartphone Samsung A55").
sachwertDescription String?
customerType CreditNoteCustomerType
// USt-relevant ja/nein (Steuerberater-Auslegung: Vermittlungsleistung vs.
// reiner Abschlussbonus/Entgeltminderung). Bei false: net = brutto, USt = 0.
vatRelevant Boolean @default(false)
amountBasis CreditNoteAmountBasis @default(BRUTTO)
vatRate Float @default(19) // Prozent
amountNet Float @default(0)
amountVat Float @default(0)
amountGross Float @default(0)
currency String @default("EUR")
// Datum der Gutschrift (frei änderbar).
creditDate DateTime
// Ort der Unterschrift + Unterschrift-/Bestätigungszeitpunkt.
place String?
signedAt DateTime?
// Sachwert: Bestätigung "Ware erhalten".
goodsReceived Boolean @default(false)
// Auszahlungskonto (nur GELD): Bankkarte des Kunden, auf die überwiesen
// wird. Kann ein anderes Konto sein als das Abbuchkonto des Vertrags.
// ON DELETE SET NULL, damit das Löschen einer Bankkarte die Gutschrift
// (historischer Beleg) nicht mitreißt.
payoutBankCardId Int?
payoutBankCard BankCard? @relation("CreditNotePayoutBankCard", fields: [payoutBankCardId], references: [id], onDelete: SetNull)
// Überweisungsbeleg (nur GELD) hochgeladene Datei.
receiptPath String?
// Generiertes Gutschrift-Dokument (PDF bzw. ZUGFeRD-Hybrid) Phase 3.
pdfPath String?
notes String?
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([contractId])
}
// Absender-/Firmenstammdaten (Einzel-Zeile). Fließen in Gutschrift-PDF und
// ZUGFeRD-XML (Verkäufer/Seller-Party) sowie in die Bankangaben ein.
model CompanyProfile {
id Int @id @default(autoincrement())
name String @default("")
street String @default("")
houseNumber String @default("")
postalCode String @default("")
city String @default("")
country String @default("DE") // ISO-2, für ZUGFeRD countryID
vatId String @default("") // USt-IdNr (DE...)
taxNumber String @default("") // Steuernummer
commercialRegister String @default("") // z.B. HRB 12345, Amtsgericht
managingDirector String @default("") // Geschäftsführer/Inhaber
email String @default("")
phone String @default("")
website String @default("")
iban String @default("")
bic String @default("")
bankName String @default("")
logoPath String?
updatedAt DateTime @updatedAt
}
// Nummernkreis für Gutschriften (Einzel-Zeile, in den Einstellungen
// verwaltbar). Nummer wird transaktional vergeben, damit keine Lücken/
// Doppelvergaben entstehen.
model CreditNoteNumberRange {
id Int @id @default(autoincrement())
prefix String @default("GS-")
includeYear Boolean @default(true)
separator String @default("-")
padding Int @default(4)
nextNumber Int @default(1)
resetYearly Boolean @default(true)
currentYear Int?
updatedAt DateTime @updatedAt
}
// Separater Nummernkreis für Lieferscheine (betragslose Sachwert-Übergaben).
// Eigene Serie, damit die Gutschrift-Nummerierung sauber/lückenlos bleibt.
model DeliveryNoteNumberRange {
id Int @id @default(autoincrement())
prefix String @default("LS-")
includeYear Boolean @default(true)
separator String @default("-")
padding Int @default(4)
nextNumber Int @default(1)
resetYearly Boolean @default(true)
currentYear Int?
updatedAt DateTime @updatedAt
}
// ==================== INTERNET CONTRACT DETAILS ====================
model InternetContractDetails {
@@ -1067,6 +1236,76 @@ model CarInsuranceDetails {
// ==================== AUDIT LOGGING (DSGVO) ====================
/// Einzeiliger Mutex fuer die Audit-Hash-Kette (genau eine Zeile, id = 1).
///
/// Warum eine eigene Tabelle statt GET_LOCK oder `SELECT … FOR UPDATE` auf
/// AuditLog selbst:
/// - GET_LOCK muss auf derselben Verbindung freigegeben werden. Innerhalb des
/// Prisma-Transaktions-Callbacks faellt das Release damit VOR den COMMIT
/// der naechste Schreiber liest das Kettenende, bevor die Vorgaengerzeile
/// sichtbar ist, und haengt sich an denselben Vorgaenger (Fork, R166-01).
/// - `FOR UPDATE` auf das Kettenende von AuditLog nimmt Gap-/Next-Key-Locks,
/// die mit den gleichzeitigen INSERTs kollidieren (Deadlocks, dabei gingen
/// 38 von 40 Eintraegen verloren).
/// InnoDB-Zeilensperren werden erst beim COMMIT freigegeben genau das
/// schliesst das Fenster.
model AuditChainLock {
id Int @id
updatedAt DateTime @updatedAt
}
/// Ausgegebene Refresh-Tokens (Pentest R164-02).
///
/// Vorher war die Rotation wirkungslos: Der alte Token blieb bis `exp`
/// gueltig, ein gestohlener Token also bis zu 7 Tage parallel nutzbar.
/// Jetzt traegt jeder Refresh-Token eine `jti` und gehoert zu einer
/// Sitzungs-`familyId`. Beim Einloesen wird die `jti` verbraucht; taucht sie
/// danach erneut auf, gilt das als Replay und die GESAMTE Familie wird
/// widerrufen (Angreifer und legitimer Nutzer fliegen raus, der Vorfall wird
/// gemeldet) - das uebliche Vorgehen aus der OAuth-Sicherheits-BCP.
///
/// Der Token selbst wird NICHT gespeichert - die Signatur authentifiziert ihn
/// bereits, und ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.
model RefreshTokenRecord {
id Int @id @default(autoincrement())
jti String @unique
familyId String
userId Int?
customerId Int?
isCustomerPortal Boolean @default(false)
issuedAt DateTime @default(now())
expiresAt DateTime
/// Gesetzt, sobald der Token eingeloest wurde (Einmalverwendung).
usedAt DateTime?
replacedByJti String?
/// Wiederverwendungen innerhalb des Kulanzfensters (parallele Tabs).
reuseCount Int @default(0)
revokedAt DateTime?
revokedReason String?
@@index([familyId])
@@index([expiresAt])
@@index([userId])
@@index([customerId])
}
/// Blattwerte des Bestandssiegels (Pentest R171-02 / R172).
///
/// Der Altbestand (Hash-Version 1/2) laesst sich nicht rueckwirkend signieren
/// ein Siegel vergibt man beim Anlegen. Bei V1 sind nur 7 von 24 Spalten
/// gehasht, ein DB-Schreiber kann dort z. B. `success` von false auf true
/// setzen, ohne den Hash zu beruehren: die Pruefung meldet weiterhin "gueltig".
///
/// Das Bestandssiegel schliesst das, ohne irgendetwas umzuschreiben: Zum
/// Zeitpunkt X wird je Altzeile ein Blattwert ueber den VOLLEN Inhalt
/// (alle Spalten inkl. id und hashVersion) gebildet; die Wurzel darueber
/// steht in einem HMAC-signierten Marker. Jede spaetere Aenderung an einer
/// Altzeile bricht Blatt und Wurzel.
model AuditBacklogSeal {
auditLogId Int @id
leafHash String
}
enum AuditAction {
CREATE
READ
@@ -1077,6 +1316,7 @@ enum AuditAction {
LOGIN
LOGOUT
LOGIN_FAILED
TOKEN_REFRESH // stiller Access-Token-Refresh (Cookie), kein interaktiver Login
}
enum AuditSensitivity {
@@ -1129,6 +1369,9 @@ model AuditLog {
createdAt DateTime @default(now())
hash String? // SHA-256 Hash des Eintrags
previousHash String? // Hash des vorherigen Eintrags
/// 1 = Alt-Hash ueber 7 Felder, 2 = Hash ueber alle Inhaltsspalten
/// (Pentest R166-02). Bestandszeilen bleiben mit Version 1 gueltig.
hashVersion Int @default(1)
@@index([userId])
@@index([customerId])
@@ -1249,6 +1492,7 @@ enum SecurityEventType {
LOGOUT // expliziter Logout
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
AUDIT_SEAL_CHANGED // Bestandssiegel gesetzt oder ersetzt (Beweis-Grundlage)
SUSPICIOUS // generischer Catch-All
}
+37 -213
View File
@@ -1,225 +1,28 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
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();
async function main() {
console.log('Seeding database...');
// ==================== PERMISSIONS ====================
// Ressourcen mit ihren erlaubten Aktionen
const resourcePermissions: Record<string, string[]> = {
// Haupt-Ressourcen (CRUD)
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'],
// Konfiguration (CRUD)
'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'],
};
// ==================== RECHTE UND ROLLEN ====================
// Katalog und Systemrollen kommen aus einer einzigen Definition
// (src/config/rechte-katalog.ts), aufgeloest von rollen-sync.service.ts.
//
// Bis 09/2026 stand hier eine zweite, eigene Kopie - und sie wich ab: Die
// DSGVO-Rolle bekam `audit:*` komplett, also auch `audit:admin`. Gerettet
// hat das nur die Reihenfolge im Container-Start (sync-roles lief danach
// und raeumte Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte
// die Buendelung zurueck. Drei Beschreibungen desselben Sachverhalts sind
// zwei zu viel.
await synchronisiereRechteUndRollen(prisma);
const permissions: { resource: string; action: string }[] = [];
for (const [resource, actions] of Object.entries(resourcePermissions)) {
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');
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
@@ -265,8 +68,19 @@ async function main() {
password: hashedPassword,
firstName: 'Admin',
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: {
create: [{ roleId: adminRole.id }],
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
},
},
});
@@ -497,6 +311,16 @@ async function main() {
description: 'Allgemeine Einstellungen',
legalBasis: 'Verjährungsfrist (BGB §195)',
},
{
// Stiller Token-Refresh (Routine). Ohne eigene Regel fiele diese
// Kombination in die Auffangregel `*` mit 3650 Tagen das Rauschen
// wäre dann länger aufbewahrt als echte Logins (730 Tage).
resourceType: 'Authentication',
sensitivity: 'LOW' as const,
retentionDays: 90,
description: 'Routine-Auth (stiller Token-Refresh)',
legalBasis: 'Betriebsnotwendigkeit / Datenminimierung (DSGVO Art. 5)',
},
];
for (const policy of specificPolicies) {
+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
* System schon installiert hat, bekommt nachträglich hinzugefügte
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
* Hintergrund: seed.ts laeuft nur auf leeren Datenbanken (USER_COUNT=0). Wer
* das System schon installiert hat, bekommt nachtraeglich hinzugefuegte
* Rechte oder geaenderte Rollenzuordnungen sonst NICHT.
*
* Dieses Skript synchronisiert ausschließlich:
* - Permission-Katalog (resource/action-Paare aus dem Code)
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
* Mitarbeiter (Nur-Lesen), Kunde)
* Die Definition selbst steht in `src/config/rechte-katalog.ts`, die Logik in
* `src/services/rollen-sync.service.ts`. Dieses Skript ist nur noch der
* Einstiegspunkt fuer die Kommandozeile - bis 09/2026 trug es eine eigene
* Kopie des Katalogs, und es war nicht die einzige.
*
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
* laufenden Prod-DBs sicher.
* KEINE Stammdaten, KEINE Benutzer, KEINE Vertraege - auf laufenden
* Produktionsdatenbanken sicher.
*/
import { PrismaClient } from '@prisma/client';
// 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[]> = {
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()
synchronisiereRechteUndRollen(prisma)
.catch((e) => {
console.error('[sync-roles] Fehler:', e);
console.error('[rollen-sync] Fehler:', e);
process.exit(1);
})
.finally(async () => {
@@ -0,0 +1,61 @@
/**
* Diagnose: findet Portal-Kunden, deren gespeichertes (verschlüsseltes)
* Passwort NICHT zum Login-Hash (bcrypt) passt. Solche „Desyncs" führen dazu,
* dass der Reveal/Send-Credentials ein Passwort ausgibt, das beim Login
* scheitert. Seit dem Reveal-Konsistenzcheck werden sie nicht mehr ausgegeben
* dieses Script findet bestehende Altlasten (z.B. aus manuellen DB-Eingriffen
* oder alten Code-Versionen), damit man sie gezielt neu setzen kann.
*
* Aufruf (im Backend-Container/-Verzeichnis):
* npx tsx scripts/check-portal-password-sync.ts
* Nur Diagnose ändert NICHTS.
*/
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { decrypt } from '../src/utils/encryption.js';
const prisma = new PrismaClient();
async function main() {
const customers = await prisma.customer.findMany({
where: { portalPasswordEncrypted: { not: null }, portalPasswordHash: { not: null } },
select: {
id: true,
customerNumber: true,
portalEmail: true,
portalPasswordEncrypted: true,
portalPasswordHash: true,
},
});
let desync = 0;
for (const c of customers) {
let decrypted: string;
try {
decrypted = decrypt(c.portalPasswordEncrypted!);
} catch {
desync++;
console.log(`DESYNC (Entschlüsselung fehlgeschlagen): Kunde #${c.id} ${c.customerNumber}`);
continue;
}
const matches = await bcrypt.compare(decrypted, c.portalPasswordHash!);
if (!matches) {
desync++;
console.log(`DESYNC: Kunde #${c.id} ${c.customerNumber} (${c.portalEmail ?? '-'}) gespeichertes Passwort passt nicht zum Login-Hash`);
}
}
console.log(`\nGeprüft: ${customers.length} Portal-Kunden mit beiden Feldern. Desync: ${desync}.`);
if (desync > 0) {
console.log('→ Für diese Kunden bitte ein neues Portal-Passwort setzen/generieren.');
} else {
console.log('→ Alles konsistent.');
}
await prisma.$disconnect();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+158
View File
@@ -0,0 +1,158 @@
/**
* Pentester-Hilfsscript (Pentest R152): legt einen KONTROLLIERTEN „getarnten"
* Upload an, um den Magic-Byte-Mismatch→attachment-Zweig des Download-Endpoints
* live zu belegen.
*
* Was passiert:
* - Es wird eine dedizierte TEST-Bankkarte (accountHolder-Marker) angelegt
* ECHTE Kundendaten werden NICHT angefasst/überschrieben.
* - An diese Karte wird eine Datei mit `.pdf`-Endung gehängt, deren Inhalt
* aber ein SVG mit <script> ist (Non-Whitelist-Magic-Byte, klassischer
* Stored-XSS-Payload).
* - documentPath wird exakt gesetzt, damit findUploadOwner die Datei der
* Test-Bankkarte (→ Customer) zuordnet und der Ownership-Check greift.
*
* Erwartetes Verhalten am Endpoint
* GET /api/files/download?path=<pfad>&disposition=inline :
* → HTTP 200, aber `Content-Disposition: attachment` (NICHT inline),
* `X-Content-Type-Options: nosniff` → Browser lädt herunter, rendert
* NICHT im iframe → kein Stored-XSS.
* → Backend-Log: "[fileDownload] inline angefragt, aber Magic-Byte-Check
* fehlgeschlagen: <pfad>"
* Ohne `disposition=inline` ohnehin attachment (Default).
* Als fremder Portal-User: 403 (Ownership-Check, Karte gehört Test-Customer).
*
* Aufruf (im Backend-App-Verzeichnis / Container-WORKDIR /app):
* npx tsx scripts/seed-magic-byte-test.ts create [--customer <id>]
* npx tsx scripts/seed-magic-byte-test.ts cleanup
*
* Danach unbedingt `cleanup` laufen lassen (löscht Datei + Test-Bankkarte).
*/
import fs from 'fs';
import path from 'path';
import prisma from '../src/lib/prisma.js';
const MARKER = 'MAGICBYTE-TEST (Pentester R152)';
const FILENAME = 'magic-byte-test.pdf'; // .pdf-Endung, aber SVG-Inhalt (getarnt)
const UPLOAD_SUBDIR = 'bank-cards';
const RELATIVE_PATH = `/uploads/${UPLOAD_SUBDIR}/${FILENAME}`;
// SVG mit <script> würde bei inline-Auslieferung als image/svg+xml im Browser
// ausgeführt. Genau das muss der Magic-Byte-Gate verhindern.
const SVG_XSS = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="80">
<text x="10" y="40">MAGICBYTE-TEST</text>
<script type="text/javascript">/* Pentest R152 */ alert('XSS-MAGICBYTE-TEST');</script>
</svg>
`;
function uploadsDir(): string {
return path.join(process.cwd(), 'uploads', UPLOAD_SUBDIR);
}
function absoluteFilePath(): string {
return path.join(uploadsDir(), FILENAME);
}
async function create(customerIdArg?: number): Promise<void> {
// Harter Prod-Riegel: dieses Script legt eine Fake-Bankkarte + getarnte
// Datei an das darf NIE gegen Produktion laufen (weder versehentlich noch
// absichtlich). `cleanup` bleibt erlaubt, damit man immer aufräumen kann.
if (process.env.NODE_ENV === 'production') {
console.error(
'ABBRUCH: "create" ist bei NODE_ENV=production deaktiviert. Dieser ' +
'Pentester-/Test-Helfer gehört nur nach Dev/Staging. (Zum Aufräumen ist ' +
'"cleanup" weiterhin erlaubt.)',
);
process.exit(1);
}
// Ziel-Customer bestimmen (Default: erster vorhandener Kunde).
const customer = customerIdArg
? await prisma.customer.findUnique({ where: { id: customerIdArg }, select: { id: true, customerNumber: true } })
: await prisma.customer.findFirst({ orderBy: { id: 'asc' }, select: { id: true, customerNumber: true } });
if (!customer) {
console.error('Kein Kunde gefunden bitte zuerst einen Kunden anlegen oder --customer <id> angeben.');
process.exit(1);
}
// Getarnte Datei schreiben.
fs.mkdirSync(uploadsDir(), { recursive: true });
fs.writeFileSync(absoluteFilePath(), SVG_XSS, 'utf8');
// Test-Bankkarte anlegen oder wiederverwenden (idempotent, kein Duplikat).
const existing = await prisma.bankCard.findFirst({ where: { accountHolder: MARKER } });
const card = existing
? await prisma.bankCard.update({ where: { id: existing.id }, data: { customerId: customer.id, documentPath: RELATIVE_PATH } })
: await prisma.bankCard.create({
data: {
customerId: customer.id,
accountHolder: MARKER,
iban: 'DE00000000000000000000',
documentPath: RELATIVE_PATH,
description: 'Pentest R152 getarnte Datei (SVG-als-PDF). Nach Test via cleanup entfernen.',
},
});
const head = Buffer.alloc(12);
const fd = fs.openSync(absoluteFilePath(), 'r');
fs.readSync(fd, head, 0, 12, 0);
fs.closeSync(fd);
console.log('=== Magic-Byte-Test angelegt ===');
console.log('Test-Customer :', customer.customerNumber, `(id ${customer.id})`);
console.log('Test-Bankkarte : id', card.id, ` accountHolder="${MARKER}"`);
console.log('Datei (Disk) :', absoluteFilePath());
console.log('Erste 12 Bytes :', JSON.stringify(head.toString('latin1')), '(kein PDF/PNG/JPEG/GIF/WebP-Magic)');
console.log('documentPath :', RELATIVE_PATH);
console.log('');
console.log('--- So testen (Token des berechtigten Users anhängen) ---');
console.log(` curl -sSI "https://<host>/api/files/download?path=${RELATIVE_PATH}&disposition=inline&token=<JWT>"`);
console.log(' Erwartet: 200, Content-Disposition: attachment, X-Content-Type-Options: nosniff');
console.log(' (NICHT inline) + Backend-Log "Magic-Byte-Check fehlgeschlagen".');
console.log(` Ohne &disposition=inline: ebenfalls attachment.`);
console.log(' Als fremder Portal-User: 403 (Ownership-Check).');
console.log('');
console.log('>>> Nach dem Test aufräumen: npx tsx scripts/seed-magic-byte-test.ts cleanup');
}
async function cleanup(): Promise<void> {
let removed = 0;
const cards = await prisma.bankCard.findMany({ where: { accountHolder: MARKER } });
for (const c of cards) {
await prisma.bankCard.delete({ where: { id: c.id } });
removed++;
}
// Datei löschen (falls vorhanden).
const abs = absoluteFilePath();
let fileDeleted = false;
if (fs.existsSync(abs)) {
fs.unlinkSync(abs);
fileDeleted = true;
}
console.log('=== Cleanup ===');
console.log('Gelöschte Test-Bankkarten:', removed);
console.log('Datei gelöscht :', fileDeleted ? abs : '(nicht vorhanden)');
}
(async () => {
const mode = process.argv[2] || 'create';
const custIdx = process.argv.indexOf('--customer');
const customerIdArg = custIdx >= 0 ? parseInt(process.argv[custIdx + 1], 10) : undefined;
try {
if (mode === 'create') {
await create(Number.isFinite(customerIdArg as number) ? customerIdArg : undefined);
} else if (mode === 'cleanup') {
await cleanup();
} else {
console.error(`Unbekannter Modus "${mode}". Nutze: create | cleanup`);
process.exit(1);
}
} catch (err) {
console.error('Fehler:', err instanceof Error ? err.message : err);
process.exit(1);
} finally {
await prisma.$disconnect();
}
})();
+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 { antworteAufFehler } from '../utils/fehlerAntwort.js';
import prisma from '../lib/prisma.js';
import * as appSettingService from '../services/appSetting.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellung',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellung');
}
}
@@ -146,9 +144,6 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellungen',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellungen');
}
}
+485 -53
View File
@@ -1,48 +1,127 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { FachlicherFehler } from '../utils/apiError.js';
import { AuthRequest } from '../types/index.js';
import * as auditService from '../services/audit.service.js';
import { logChange } from '../services/audit.service.js';
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
*/
export async function getAuditLogs(req: AuthRequest, res: Response) {
try {
const {
userId,
customerId,
dataSubjectId,
action,
sensitivity,
resourceType,
resourceId,
startDate,
endDate,
success,
search,
page,
limit,
} = req.query;
const result = await auditService.searchAuditLogs({
userId: userId ? parseInt(userId as string) : undefined,
customerId: customerId ? parseInt(customerId as string) : undefined,
dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined,
action: action as AuditAction | undefined,
sensitivity: sensitivity as AuditSensitivity | undefined,
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,
...leseFilter(req),
page: pruefeZahl(req.query.page, 'page') || 1,
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
// Zug ziehen, an der Seitenlogik vorbei.
limit: Math.min(pruefeZahl(req.query.limit, 'limit') || 50, 200),
});
res.json({ success: true, ...result });
} 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);
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) {
try {
const format = (req.query.format as 'json' | 'csv') || 'json';
const {
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
);
const format = req.query.format === 'csv' ? 'csv' : 'json';
const content = await auditService.exportAuditLogs(leseFilter(req), format);
if (format === '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) })
}
} 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);
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
}
@@ -143,15 +209,178 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
toId ? parseInt(toId as string) : undefined
);
// Zwei sehr unterschiedliche Befunde sauber trennen vorher wurde beides
// pauschal als "manipuliert" gemeldet, was strukturelle Lücken wie einen
// echten Angriff aussehen liess (und damit die Meldung entwertete).
const tampered = result.tamperedEntries.length;
const gaps = result.chainGaps.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 = offeneGaps > 0
? `${offeneGaps} strukturelle Lücke${offeneGaps === 1 ? '' : 'n'}` +
(offeneUnexplained === 0
? ' (alle durch protokollierte Löschungen erklärt)'
: offeneUnexplained < offeneGaps
? `, davon ${offeneUnexplained} 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 keinSchluessel = unverifiable > 0
? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.`
: '';
const siegel =
result.backlogSealStatus === 'gebrochen'
? ' ⚠ Bestandssiegel GEBROCHEN:' +
(result.backlogTampered.length
? ` ${result.backlogTampered.length} Alteinträge nachträglich verändert.`
: '') +
(result.backlogMissing.length
? ` ${result.backlogMissing.length} gesiegelte Einträge wurden ENTFERNT (${result.backlogMissing.join(', ')}).`
: '') +
(!result.backlogTampered.length && !result.backlogMissing.length
? ' Der Blattbestand des Siegels stimmt nicht mehr mit der signierten Wurzel überein.'
: '')
: result.backlogSealStatus === 'entfernt'
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht Änderungen am ' +
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
: result.backlogSealStatus === 'leer'
? ' 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
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
const mehrfach =
result.backlogSealCount > 1
? ` Hinweis: Der Altbestand wurde ${result.backlogSealCount}× versiegelt der jüngste Marker gilt. ` +
'Jeder Marker enthält den Befund vor dem Siegeln; bei Zweifeln dort vergleichen.'
: '';
// Bei problematischem Siegel darf KEIN beruhigender Einstiegssatz stehen
// sonst entwertet er die nachfolgende Warnung (Pentest R174-01).
const siegelProblem =
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
? `${tampered} MANIPULIERTE Einträge gefunden` + (offeneGaps > 0 ? ` (zusätzlich ${luecken})` : '')
: siegelProblem
? 'Die Kette selbst ist rechnerisch stimmig, ABER:'
: offeneGaps > 0
? `Die Kette ist nicht mehr lückenlos: ${luecken}. Die verbliebenen Inhalte sind ` +
'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({
success: true,
data: {
valid: result.valid,
checkedCount: result.checkedCount,
invalidEntries: result.invalidEntries,
message: result.valid
? 'Alle Einträge sind valide'
: `${result.invalidEntries.length} manipulierte Einträge gefunden`,
// Ernst: Inhalt einer bestehenden Zeile wurde nachträglich verändert.
tamperedEntries: result.tamperedEntries,
// Meist harmlos: Verkettung unterbrochen, Inhalte selbst unversehrt.
chainGaps: result.chainGaps,
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
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,
message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach,
unverifiableEntries: result.unverifiableEntries,
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
backlogSealStatus: result.backlogSealStatus,
backlogTampered: result.backlogTampered,
backlogMissing: result.backlogMissing,
backlogSealCount: result.backlogSealCount,
},
});
} catch (error) {
@@ -165,7 +394,26 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
*/
export async function rehashAll(req: AuthRequest, res: Response) {
try {
const result = await auditService.rehashAll();
// Ausdrueckliche Bestaetigung verlangen (Pentest R170-01).
//
// Ein Rehash macht die Kette rechnerisch stimmig und setzt damit die
// Beweiskraft der Vergangenheit zurueck - das darf kein Nebeneffekt eines
// versehentlichen oder tastenden POST sein. Genau so wurde der Endpunkt
// bei einer Methoden-Erkundung unbeabsichtigt ausgeloest.
if (req.body?.confirm !== 'REHASH') {
res.status(400).json({
success: false,
error:
'Rehash setzt die Beweiskraft der bestehenden Einträge zurück und ist nicht ' +
'umkehrbar. Zum Bestätigen {"confirm":"REHASH"} mitsenden.',
});
return;
}
const result = await auditService.rehashAll({
userEmail: req.user?.email,
ipAddress: req.ip || (req.socket as any)?.remoteAddress,
});
res.json({
success: true,
data: result,
@@ -177,6 +425,128 @@ export async function rehashAll(req: AuthRequest, res: Response) {
}
}
/**
* Kontrollwert fuer das externe Gegenbuch (Notarisierung).
*
* Bewusst nur lesend und ohne Geheimnisse - die Gegenstelle braucht keinerlei
* Schreibrecht auf dieses System, und dieses System kennt den Schluessel der
* Gegenstelle nicht. Genau diese Trennung ist der Schutz.
*/
export async function getCheckpoint(req: AuthRequest, res: Response) {
try {
const roh = req.query.atId as string | undefined;
const atId = roh !== undefined ? parseInt(roh, 10) : undefined;
if (roh !== undefined && Number.isNaN(atId as number)) {
res.status(400).json({ success: false, error: 'atId muss eine Zahl sein' });
return;
}
const data = await auditService.getCheckpoint(atId);
res.json({ success: true, data });
} catch (error) {
console.error('Fehler beim Erstellen des Kontrollwerts:', error);
res.status(500).json({ success: false, error: 'Fehler beim Erstellen des Kontrollwerts' });
}
}
/**
* Altbestand versiegeln (Pentest R171-02).
*
* Einmalige, NICHT destruktive Aktion: schreibt nichts um, sondern haelt den
* Ist-Inhalt der nicht signierbaren Alteintraege fest.
*/
export async function sealBacklog(req: AuthRequest, res: Response) {
try {
// 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({
success: false,
error: siegelSteht
? 'Es besteht bereits ein Bestandssiegel. Erneutes Siegeln ERSETZT die ' +
'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;
}
const result = await auditService.sealBacklog({
userEmail: req.user?.email,
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({
success: true,
data: result,
message:
`${result.sealedCount} Alteinträge (id ${result.fromId}${result.toId}) versiegelt. ` +
'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) {
console.error('Fehler beim Versiegeln des Altbestands:', error);
antworteAufFehler(res, error, 'Fehler beim Versiegeln');
}
}
/**
* Retention-Policies abrufen
*/
@@ -201,6 +571,48 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
}
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, {
retentionDays,
description,
@@ -211,7 +623,15 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
await logChange({
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
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 });
@@ -226,6 +646,18 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
*/
export async function runRetentionCleanup(req: AuthRequest, res: Response) {
try {
// Auch hier ausdrueckliche Bestaetigung: Cleanup loescht Audit-Zeilen
// endgueltig und reisst dabei die Kette auf (Pentest R170-01).
if (req.body?.confirm !== 'CLEANUP') {
res.status(400).json({
success: false,
error:
'Cleanup löscht Audit-Einträge endgültig. Zum Bestätigen ' +
'{"confirm":"CLEANUP"} mitsenden.',
});
return;
}
const result = await auditService.runRetentionCleanup();
res.json({
+47 -12
View File
@@ -1,4 +1,5 @@
import { Request, Response, CookieOptions } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as authService from '../services/auth.service.js';
import { AuthRequest, ApiResponse } from '../types/index.js';
import prisma from '../lib/prisma.js';
@@ -281,10 +282,7 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint,
});
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
} as ApiResponse);
antworteAufFehler(res, error, 'Passwort-Reset fehlgeschlagen');
}
}
@@ -309,11 +307,16 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
where: { id: user.customerId },
data: { portalTokenInvalidatedAt: new Date() },
});
// Ausgegebene Refresh-Tokens zusätzlich hart widerrufen (R164-02):
// der Zeitstempel-Vergleich allein greift erst beim nächsten Refresh,
// der Widerruf macht die Tokens sofort unbrauchbar.
await authService.revokeAllRefreshTokens({ customerId: user.customerId }, 'Logout');
} else if (user.userId) {
await prisma.user.update({
where: { id: user.userId },
data: { tokenInvalidatedAt: new Date() },
});
await authService.revokeAllRefreshTokens({ userId: user.userId }, 'Logout');
}
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
@@ -362,10 +365,45 @@ export async function refresh(req: Request, res: Response): Promise<void> {
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
clearRefreshCookie(res);
res.status(401).json({
success: false,
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
} as ApiResponse);
// Detection (Pentest R164-01): ein vorgelegter, aber abgelehnter Refresh-Token
// ist potenzieller Replay/Brute-Force → TOKEN_REJECTED emittieren, damit die
// bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP → CRITICAL) greift.
// Severity wie beim Access-Token (auth-Middleware): abgelaufen/revoked = LOW
// (benigne, kein Sofort-Alert), ungültige Signatur/Manipulation = HIGH.
const ctx = contextFromRequest(req);
const code = (error as { code?: string })?.code;
const msg = error instanceof Error ? error.message : 'Refresh fehlgeschlagen';
if (code === 'REFRESH_REPLAY') {
// Ein bereits eingelöster Refresh-Token wurde erneut vorgelegt. Das ist
// kein Betriebsfehler, sondern der Abdruck eines gestohlenen Tokens
// die Sitzungsfamilie ist bereits widerrufen (R164-02).
emitSecurityEvent({
type: 'SUSPICIOUS',
severity: 'CRITICAL',
message: 'Refresh-Token-Replay erkannt Sitzungsfamilie widerrufen',
ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint,
});
} else {
// REFRESH_LEGACY = Token aus der Zeit vor dem Replay-Schutz; das ist eine
// einmalige Neuanmeldung nach dem Deploy, kein Angriffsindiz.
const benign = code === 'REFRESH_EXPIRED' || code === 'REFRESH_LEGACY'
|| code === 'REFRESH_REVOKED' || /invalidiert/i.test(msg);
emitSecurityEvent({
type: 'TOKEN_REJECTED',
severity: benign ? 'LOW' : 'HIGH',
message: `Refresh-Token abgelehnt: ${msg}`,
ipAddress: ctx.ipAddress,
endpoint: ctx.endpoint,
});
}
// `msg` geht weiterhin in den Alarmkanal (oben), aber nicht mehr an den
// Aufrufer: Bei einem abgelehnten Refresh stammt der Wortlaut aus der
// JWT-Bibliothek ("jwt malformed", "invalid signature") und sagt einem
// 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);
}
}
@@ -487,9 +525,6 @@ export async function changeInitialPortalPassword(req: AuthRequest, res: Respons
clearRefreshCookie(res);
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
} as ApiResponse);
antworteAufFehler(res, error, 'Passwort konnte nicht geändert werden', 500);
}
}
+6 -1
View File
@@ -374,7 +374,12 @@ export async function factoryReset(req: Request, res: Response) {
label: `Werkseinstellungen wiederhergestellt`,
});
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 {
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
@@ -0,0 +1,53 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { isValidIBAN } from 'ibantools';
import * as blzData from '../services/blzData.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
/**
* IBAN-Lookup: prüft die Prüfziffer (mod-97, offline über ibantools) und liefert
* für deutsche IBANs BIC und Banknamen aus der Bundesbank-Bankleitzahlen-
* datei (offline; siehe blzData.service Volume-Datensatz bevorzugt, sonst die
* ins Image gebackenen Daten). Die IBAN verlässt NICHT den Server; es findet
* KEIN externer Request statt. Zurückgegeben werden nur öffentliche
* Bankverzeichnis-Daten (BIC/Name), keine kundenbezogenen Informationen.
*
* Antwortformen (immer HTTP 200, sofern eine IBAN übergeben wurde):
* { valid: false } → Prüfziffer falsch (Tippfehler)
* { valid: true, bic, bankName, blz } → gültig + DE-Bank gefunden
* { valid: true, bic: null, bankName: null } → gültig, aber keine DE-Bankdaten
* (Nicht-DE-IBAN oder unbekannte BLZ)
*/
export async function lookupIban(req: AuthRequest, res: Response): Promise<void> {
try {
const raw = typeof req.body?.iban === 'string' ? req.body.iban : '';
const iban = raw.replace(/\s+/g, '').toUpperCase();
if (!iban) {
res.status(400).json({ success: false, error: 'Feld IBAN ist erforderlich.' } as ApiResponse);
return;
}
if (!isValidIBAN(iban)) {
res.json({ success: true, data: { valid: false, iban } } as ApiResponse);
return;
}
const country = iban.slice(0, 2);
const bank = country === 'DE' ? blzData.lookupByIban(iban) : null;
res.json({
success: true,
data: {
valid: true,
iban,
country,
bic: bank?.bic ?? null,
bankName: bank?.bankName ?? null,
blz: bank?.blz ?? null,
},
} as ApiResponse);
} catch (error) {
antworteAufFehler(res, error, 'Fehler beim IBAN-Nachschlagen', 500);
}
}
@@ -1,4 +1,5 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { AuthRequest } from '../types/index.js';
import * as birthdayService from '../services/birthday.service.js';
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
@@ -89,10 +90,7 @@ export async function resetBirthdayGreeting(req: AuthRequest, res: Response) {
res.json({ success: true });
} catch (error) {
console.error('Fehler beim Zurücksetzen:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
});
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen', 500);
}
}
@@ -180,9 +178,6 @@ export async function sendBirthdayGreeting(req: AuthRequest, res: Response) {
});
} catch (error) {
console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
}
}
@@ -0,0 +1,35 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import * as blzData from '../services/blzData.service.js';
// Status der BLZ-/Bankdaten (installierte Version, Quelle, Auto-Update-Stand).
export async function getStatus(req: AuthRequest, res: Response): Promise<void> {
try {
const status = await blzData.getStatus();
res.json({ success: true, data: status } as ApiResponse);
} catch (error) {
antworteAufFehler(res, error, 'Fehler beim Laden des BLZ-Status', 500);
}
}
// Jetzt prüfen/aktualisieren (manueller Trigger). ?force=1 lädt auch bei
// gleicher Version neu.
export async function updateNow(req: AuthRequest, res: Response): Promise<void> {
try {
const force = req.query.force === '1' || req.query.force === 'true';
const result = await blzData.runUpdate(force);
await logChange({
req,
action: 'UPDATE',
resourceType: 'AppSetting',
resourceId: 'blz-data',
label: `BLZ-Bankdaten: ${result.message}`,
});
const status = await blzData.getStatus();
res.json({ success: true, data: { result, status } } as ApiResponse);
} catch (error) {
antworteAufFehler(res, error, 'BLZ-Aktualisierung fehlgeschlagen', 502);
}
}
@@ -1,6 +1,7 @@
// ==================== CACHED EMAIL CONTROLLER ====================
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as cachedEmailService from '../services/cachedEmail.service.js';
import * as stressfreiEmailService from '../services/stressfreiEmail.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);
} catch (error) {
console.error('assignToContract error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zuordnen der E-Mail',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Zuordnen der E-Mail');
}
}
@@ -290,10 +288,7 @@ export async function unassignFromContract(req: AuthRequest, res: Response): Pro
res.json({ success: true, data: email } as ApiResponse);
} catch (error) {
console.error('unassignFromContract error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aufheben der Zuordnung',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aufheben der Zuordnung');
}
}
@@ -1660,12 +1655,7 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
} catch (error) {
console.error('saveAttachmentTo error:', error);
// Detailliertere Fehlermeldung für Debugging
const status = error instanceof ApiError ? error.statusCode : 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);
antworteAufFehler(res, error, 'Fehler beim Speichern des Anhangs', 500);
}
}
@@ -1909,11 +1899,7 @@ export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<v
} as ApiResponse);
} catch (error) {
console.error('saveEmailAsPdf error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({
success: false,
error: `Fehler beim Erstellen der PDF: ${errorMessage}`,
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der PDF', 500);
}
}
@@ -2036,11 +2022,7 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
} as ApiResponse);
} catch (error) {
console.error('saveEmailAsInvoice error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(500).json({
success: false,
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 500);
}
}
@@ -2145,9 +2127,7 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
console.error('saveEmailAsContractDocument error:', error);
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
// Lock) statt pauschal 500.
const status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
}
}
@@ -2332,12 +2312,7 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
} as ApiResponse);
} catch (error) {
console.error('saveAttachmentAsInvoice error:', error);
const status = error instanceof ApiError ? error.statusCode : 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);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 500);
}
}
@@ -2512,11 +2487,6 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
} catch (error) {
console.error('saveAttachmentAsContractDocument error:', error);
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
const status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).json({
success: false,
error: `Fehler beim Speichern: ${errorMessage}`,
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
}
}
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as cancellationPeriodService from '../services/cancellation-period.service.js';
import { logChange } from '../services/audit.service.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Kündigungsfrist',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Kündigungsfrist');
}
}
@@ -64,10 +62,7 @@ export async function updateCancellationPeriod(req: Request, res: Response): Pro
});
res.json({ success: true, data: period } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Kündigungsfrist',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Kündigungsfrist');
}
}
@@ -83,9 +78,6 @@ export async function deleteCancellationPeriod(req: Request, res: Response): Pro
});
res.json({ success: true, message: 'Kündigungsfrist gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Kündigungsfrist',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Kündigungsfrist');
}
}
@@ -0,0 +1,29 @@
import { Response } from 'express';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import * as companyProfileService from '../services/companyProfile.service.js';
export async function getProfile(_req: AuthRequest, res: Response): Promise<void> {
try {
const data = await companyProfileService.getOrCreateProfile();
res.json({ success: true, data } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Firmendaten' } as ApiResponse);
}
}
export async function updateProfile(req: AuthRequest, res: Response): Promise<void> {
try {
const updated = await companyProfileService.updateProfile(req.body ?? {});
await logChange({
req,
action: 'UPDATE',
resourceType: 'CompanyProfile',
resourceId: updated.id.toString(),
label: 'Firmenstammdaten (Absender) geändert',
});
res.json({ success: true, data: updated } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Speichern der Firmendaten' } as ApiResponse);
}
}
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as contractDurationService from '../services/contract-duration.service.js';
import { logChange } from '../services/audit.service.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Laufzeit',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Laufzeit');
}
}
@@ -64,10 +62,7 @@ export async function updateContractDuration(req: Request, res: Response): Promi
});
res.json({ success: true, data: duration } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Laufzeit',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Laufzeit');
}
}
@@ -83,9 +78,6 @@ export async function deleteContractDuration(req: Request, res: Response): Promi
});
res.json({ success: true, message: 'Laufzeit gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Laufzeit',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Laufzeit');
}
}
+73 -55
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import fs from 'fs';
import prisma from '../lib/prisma.js';
import * as contractService from '../services/contract.service.js';
@@ -11,7 +12,7 @@ import { logChange } from '../services/audit.service.js';
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.js';
import { canAccessContract, canAccessCustomer } from '../utils/accessControl.js';
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
import { maybeActivateOnDeliveryConfirmation, maybeCancelOnCancellationConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
/**
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
@@ -96,17 +97,20 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
return;
}
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht.
// Fail-closed (Pentest R145): Portal-Token wird IMMER gescoped; fehlt wider
// Erwarten die customerId, ergibt das eine leere Menge (nicht alle).
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
// Eigene Customer-ID immer
customerIds = [req.user.customerId];
// Vertretene Kunden nur wenn Vollmacht erteilt
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
customerIds.push(repCustId);
if (req.user?.isCustomerPortal) {
customerIds = req.user.customerId ? [req.user.customerId] : [];
if (req.user.customerId) {
// Vertretene Kunden nur wenn Vollmacht erteilt
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
customerIds.push(repCustId);
}
}
}
}
@@ -119,6 +123,7 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
search: search as string,
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
includeDeactivated: includeDeactivated === 'true',
});
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
@@ -207,10 +212,7 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Vertrags',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen des Vertrags');
}
}
@@ -230,8 +232,44 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
// HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1)
const sanitizedBody = sanitizeContractBody(req.body);
// Kündigungs-Datumsfelder robust normalisieren: `validateOptionalIsoDate`
// akzeptiert Datum-only (YYYY-MM-DD) UND volles ISO und liefert einen
// sauberen Date-Wert an Prisma. Ohne das lehnte Prisma ein Datum-only
// ab (Pentest R138, Hygiene-Punkt). Konsistent zum Upload-Pfad, der
// `confirmationDate` bereits so validiert.
for (const df of ['cancellationConfirmationDate', 'cancellationConfirmationOptionsDate'] as const) {
if (df in (sanitizedBody as Record<string, unknown>)) {
let validated: string | null;
try {
validated = validateOptionalIsoDate((sanitizedBody as Record<string, unknown>)[df], df);
} catch (err) {
res.status(400).json({
success: false,
error: err instanceof Error ? err.message : 'Ungültiges Datum',
} as ApiResponse);
return;
}
(sanitizedBody as Record<string, unknown>)[df] = validated ? new Date(validated) : null;
}
}
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
// Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
// datum gesetzt wurde: Vertrag → CANCELLED_CONFIRMED ("Gekündigt / bestätigt")
// + Vertragsende = Kündigungsdatum. Nur bei tatsächlicher Änderung auslösen,
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
{
const asDay = (d: unknown) =>
d ? new Date(d as string).toISOString().split('T')[0] : null;
const beforeConf = asDay((before as any)?.cancellationConfirmationDate);
const afterConf = asDay((sanitizedBody as any)?.cancellationConfirmationDate);
if (afterConf && afterConf !== beforeConf) {
await maybeCancelOnCancellationConfirmation(contractId, req);
}
}
// Geänderte Felder ermitteln
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
@@ -334,10 +372,7 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
: sanitizeContract(contract as any);
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Vertrags',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Vertrags');
}
}
@@ -356,10 +391,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
});
res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Vertrags');
}
}
@@ -407,10 +439,7 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Folgevertrags',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen des Folgevertrags');
}
}
@@ -462,10 +491,7 @@ export async function createRenewal(req: AuthRequest, res: Response): Promise<vo
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der VVL');
}
}
@@ -593,14 +619,19 @@ export async function getCockpit(req: AuthRequest, res: Response): Promise<void>
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
// Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
// (Pentest Runde 4, 2026-05-16: HOCH).
// Fail-closed (Pentest R146): Portal-Token wird IMMER gescoped; fehlt wider
// Erwarten die customerId, ergibt das eine leere Menge (nicht alle) der
// Cockpit-Service filtert `customerIds: []` bereits als „keine Treffer".
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal && req.user.customerId) {
customerIds = [req.user.customerId];
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
customerIds.push(repCustId);
if (req.user?.isCustomerPortal) {
customerIds = req.user.customerId ? [req.user.customerId] : [];
if (req.user.customerId) {
const representedIds: number[] = req.user.representedCustomerIds || [];
for (const repCustId of representedIds) {
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
if (hasAuth) {
customerIds.push(repCustId);
}
}
}
}
@@ -748,10 +779,7 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
res.json({ success: true, data: contractMeter } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Folgezählers');
}
}
@@ -768,10 +796,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
});
res.json({ success: true, data: null } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Entfernen');
}
}
@@ -860,13 +885,9 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
// Schreibfehler.
const status = error instanceof ApiError ? error.statusCode : 400;
// Multer hat die Datei schon geschrieben bei Reject räumen.
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Hochladen');
}
}
@@ -903,10 +924,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen');
}
}
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as contractCategoryService from '../services/contractCategory.service.js';
import { logChange } from '../services/audit.service.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertragskategorie',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertragskategorie');
}
}
@@ -64,10 +62,7 @@ export async function updateContractCategory(req: Request, res: Response): Promi
});
res.json({ success: true, data: category } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertragskategorie',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertragskategorie');
}
}
@@ -83,9 +78,6 @@ export async function deleteContractCategory(req: Request, res: Response): Promi
});
res.json({ success: true, message: 'Vertragskategorie gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertragskategorie',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Vertragskategorie');
}
}
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as contractHistoryService from '../services/contractHistory.service.js';
import { logChange } from '../services/audit.service.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Eintrags',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen des Eintrags');
}
}
@@ -74,10 +72,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
res.json({ success: true, data: entry } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Eintrags',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Eintrags');
}
}
@@ -97,9 +92,6 @@ export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promi
res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Eintrags',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Eintrags');
}
}
@@ -1,4 +1,5 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as contractTaskService from '../services/contractTask.service.js';
import * as contractService from '../services/contract.service.js';
import * as customerService from '../services/customer.service.js';
@@ -118,6 +119,38 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
}
}
// Allgemeine Aufgabe OHNE Vertrag/Kunde (nur Mitarbeiter/Admin). Ohne Vertrag
// gibt es keinen Kunden → visibleInPortal ist hier bedeutungslos und immer
// false (der Service erzwingt das ohnehin).
export async function createGeneralTask(req: AuthRequest, res: Response): Promise<void> {
try {
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
return;
}
const { title, description } = req.body ?? {};
if (!title || !String(title).trim()) {
res.status(400).json({ success: false, error: 'Titel ist erforderlich' } as ApiResponse);
return;
}
const task = await contractTaskService.createTask({
contractId: null,
title: String(title).trim(),
description,
visibleInPortal: false,
createdBy: req.user?.email,
});
await logChange({
req, action: 'CREATE', resourceType: 'ContractTask',
resourceId: task.id.toString(),
label: `Allgemeine Aufgabe "${String(title).trim()}" erstellt (ohne Vertrag)`,
});
res.status(201).json({ success: true, data: task } as ApiResponse);
} catch (error) {
antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
}
}
export async function createTask(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
@@ -152,10 +185,7 @@ export async function createTask(req: AuthRequest, res: Response): Promise<void>
res.status(201).json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
}
}
@@ -204,10 +234,7 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
res.status(201).json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Support-Anfrage',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Support-Anfrage');
}
}
@@ -230,10 +257,7 @@ export async function updateTask(req: AuthRequest, res: Response): Promise<void>
res.json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Aufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Aufgabe');
}
}
@@ -248,10 +272,7 @@ export async function completeTask(req: AuthRequest, res: Response): Promise<voi
});
res.json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Aufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Abschließen der Aufgabe');
}
}
@@ -266,10 +287,7 @@ export async function reopenTask(req: AuthRequest, res: Response): Promise<void>
});
res.json({ success: true, data: task } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Aufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Aufgabe');
}
}
@@ -284,10 +302,7 @@ export async function deleteTask(req: AuthRequest, res: Response): Promise<void>
});
res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Aufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Aufgabe');
}
}
@@ -322,10 +337,7 @@ export async function createSubtask(req: AuthRequest, res: Response): Promise<vo
res.status(201).json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Unteraufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Unteraufgabe');
}
}
@@ -361,6 +373,12 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
return;
}
// Allgemeine Aufgaben (ohne Vertrag/Kunde) sind reine Mitarbeiter-Aufgaben
// und für Portal-User nie zugänglich.
if (task.contractId == null) {
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
return;
}
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
// via hasAuthorization, Pentest Runde 6 HOCH-04). Damit kann ein
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
@@ -398,10 +416,7 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
res.status(201).json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Antwort',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Antwort');
}
}
@@ -426,10 +441,7 @@ export async function updateSubtask(req: AuthRequest, res: Response): Promise<vo
});
res.json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Unteraufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Unteraufgabe');
}
}
@@ -444,10 +456,7 @@ export async function completeSubtask(req: AuthRequest, res: Response): Promise<
});
res.json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Unteraufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Abschließen der Unteraufgabe');
}
}
@@ -462,10 +471,7 @@ export async function reopenSubtask(req: AuthRequest, res: Response): Promise<vo
});
res.json({ success: true, data: subtask } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Unteraufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Unteraufgabe');
}
}
@@ -480,9 +486,6 @@ export async function deleteSubtask(req: AuthRequest, res: Response): Promise<vo
});
res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Unteraufgabe',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Unteraufgabe');
}
}
@@ -0,0 +1,272 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import { ApiError } from '../utils/apiError.js';
import * as creditNoteService from '../services/creditNote.service.js';
import { effectiveNumber } from '../services/creditNote.service.js';
import * as numberRangeService from '../services/creditNoteNumberRange.service.js';
import * as deliveryRangeService from '../services/deliveryNoteNumberRange.service.js';
import * as authorizationService from '../services/authorization.service.js';
import { generateCreditNotePdf } from '../services/creditNotePdf.service.js';
// Gutschriften sind ein reiner Mitarbeiter-/Admin-Bereich (interne
// Subventions-Belege). Portal-Kunden dürfen sie weder sehen noch ändern
// Defense-in-Depth: nicht nur die UI blendet aus, der Endpoint blockt
// Portal-Token explizit.
function blockPortal(req: AuthRequest, res: Response): boolean {
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
return true;
}
return false;
}
function idParam(req: AuthRequest, res: Response, name: string): number | null {
const raw = req.params[name];
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
return null;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isInteger(parsed) || parsed < 1) {
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
return null;
}
return parsed;
}
function handleError(res: Response, error: unknown, fallback: string) {
// Eigene Huelle beibehalten, damit die Aufrufer unveraendert bleiben - die
// Entscheidung, was nach draussen geht, faellt jetzt zentral.
antworteAufFehler(res, error, fallback, 500);
}
// ---- Gesamtübersicht (Hauptmenü) ----
// Anders als die übrigen Credit-Note-Endpunkte NICHT staff-only: Portal-Kunden
// dürfen ihre eigenen (+ vertretene) Belege sehen. Scoping über customerIds
// aus dem JWT eine im Body/Query mitgeschickte customerId hat keinen Effekt.
export async function listAll(req: AuthRequest, res: Response): Promise<void> {
try {
// Portal-User: nur eigene + vertretene Kunden MIT Vollmacht.
// Fail-closed (Pentest R144): Ein Portal-Token wird IMMER gescoped. Fehlt
// wider Erwarten die customerId (null/0), ergibt das eine LEERE Menge
// nicht den Staff-Zweig (der alle Belege zeigen würde).
let customerIds: number[] | undefined;
if (req.user?.isCustomerPortal) {
customerIds = req.user.customerId ? [req.user.customerId] : [];
if (req.user.customerId) {
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
for (const repCustId of representedIds) {
if (await authorizationService.hasAuthorization(repCustId, req.user.customerId)) {
customerIds.push(repCustId);
}
}
}
}
const page = Math.max(parseInt((req.query.page as string) || '1') || 1, 1);
const limit = Math.min(Math.max(parseInt((req.query.limit as string) || '50') || 50, 1), 200);
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
const result = await creditNoteService.getAllCreditNotes({ customerIds, page, limit, search });
// Portal-Kunden dürfen keine Überweisungsbelege laden → receiptPath aus der
// Response entfernen (Beleg-Download bleibt ohnehin staff-only).
const isPortal = !!req.user?.isCustomerPortal;
const items = isPortal
? result.items.map((cn) => ({ ...cn, receiptPath: null }))
: result.items;
res.json({ success: true, data: { items, pagination: result.pagination } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden der Belegübersicht');
}
}
// ---- Gutschriften pro Vertrag ----
export async function listByContract(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const contractId = idParam(req, res, 'contractId');
if (contractId === null) return;
const data = await creditNoteService.getCreditNotesByContract(contractId);
res.json({ success: true, data } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden der Gutschriften');
}
}
// Vorbelegung fürs Formular: Default-Kundentyp (Firma/Privat aus Kunde) +
// Vorschau der nächsten Gutschrift-Nummer.
export async function formDefaults(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const contractId = idParam(req, res, 'contractId');
if (contractId === null) return;
const [defaults, nextNumber] = await Promise.all([
creditNoteService.getCreditNoteDefaults(contractId),
numberRangeService.previewNextNumber(),
]);
res.json({ success: true, data: { ...defaults, nextNumber } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden der Vorbelegung');
}
}
export async function create(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const contractId = idParam(req, res, 'contractId');
if (contractId === null) return;
const created = await creditNoteService.createCreditNote(contractId, req.body ?? {}, req.user?.email);
await logChange({
req,
action: 'CREATE',
resourceType: 'CreditNote',
resourceId: created.id.toString(),
label: `Beleg ${effectiveNumber(created) ?? `#${created.id}`} angelegt (${created.type}, ${created.amountGross} ${created.currency}) an Vertrag #${contractId}`,
});
res.status(201).json({ success: true, data: created } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Anlegen der Gutschrift');
}
}
export async function getOne(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const id = idParam(req, res, 'id');
if (id === null) return;
const data = await creditNoteService.getCreditNoteById(id);
if (!data) {
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' } as ApiResponse);
return;
}
res.json({ success: true, data } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden der Gutschrift');
}
}
export async function update(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const id = idParam(req, res, 'id');
if (id === null) return;
const updated = await creditNoteService.updateCreditNote(id, req.body ?? {});
await logChange({
req,
action: 'UPDATE',
resourceType: 'CreditNote',
resourceId: updated.id.toString(),
label: `Beleg ${effectiveNumber(updated) ?? `#${updated.id}`} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`,
});
res.json({ success: true, data: updated } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Ändern der Gutschrift');
}
}
export async function remove(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const id = idParam(req, res, 'id');
if (id === null) return;
const deleted = await creditNoteService.deleteCreditNote(id);
await logChange({
req,
action: 'DELETE',
resourceType: 'CreditNote',
resourceId: id.toString(),
label: `Beleg ${effectiveNumber(deleted) ?? `#${deleted.id}`} gelöscht`,
});
res.json({ success: true } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Löschen der Gutschrift');
}
}
// PDF (Gutschrift-Dokument) erzeugen. ZUGFeRD-Embedding folgt (Teil 2).
export async function generatePdf(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const id = idParam(req, res, 'id');
if (id === null) return;
const { pdfPath } = await generateCreditNotePdf(id);
await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
label: 'Gutschrift-PDF erzeugt',
});
res.json({ success: true, data: { pdfPath } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Erzeugen des PDF');
}
}
// ---- Nummernkreis (Einstellungen) ----
export async function getNumberRange(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const [range, nextNumber] = await Promise.all([
numberRangeService.getOrCreateRange(),
numberRangeService.previewNextNumber(),
]);
res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden des Nummernkreises');
}
}
export async function updateNumberRange(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const range = await numberRangeService.updateRange(req.body ?? {});
const preview = await numberRangeService.previewNextNumber();
await logChange({
req,
action: 'UPDATE',
resourceType: 'CreditNoteNumberRange',
resourceId: range.id.toString(),
label: `Gutschrift-Nummernkreis geändert (Präfix "${range.prefix}", nächste Nr. ${range.nextNumber})`,
});
res.json({ success: true, data: { ...range, preview } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Speichern des Nummernkreises');
}
}
// ---- Lieferschein-Nummernkreis (betragslose Sachwerte) ----
export async function getDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const [range, nextNumber] = await Promise.all([
deliveryRangeService.getOrCreateRange(),
deliveryRangeService.previewNextNumber(),
]);
res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Laden des Lieferschein-Nummernkreises');
}
}
export async function updateDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
try {
if (blockPortal(req, res)) return;
const range = await deliveryRangeService.updateRange(req.body ?? {});
const preview = await deliveryRangeService.previewNextNumber();
await logChange({
req,
action: 'UPDATE',
resourceType: 'DeliveryNoteNumberRange',
resourceId: range.id.toString(),
label: `Lieferschein-Nummernkreis geändert (Präfix "${range.prefix}", nächste Nr. ${range.nextNumber})`,
});
res.json({ success: true, data: { ...range, preview } } as ApiResponse);
} catch (error) {
handleError(res, error, 'Fehler beim Speichern des Lieferschein-Nummernkreises');
}
}
+97 -128
View File
@@ -1,8 +1,10 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import prisma from '../lib/prisma.js';
import * as customerService from '../services/customer.service.js';
import * as authService from '../services/auth.service.js';
import { logChange } from '../services/audit.service.js';
import { getConfiguredEmailDomains, emailUsesDomain } from '../services/emailProvider/emailProviderService.js';
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import {
@@ -24,6 +26,23 @@ import {
} from '../utils/accessControl.js';
import { ApiError } from '../utils/apiError.js';
// Mass-Assignment-Schutz für Bankkarten (Pentest R155): nur diese Felder dürfen
// aus dem Request-Body übernommen werden NICHT customerId/id/createdAt/… Nur
// tatsächlich gesendete Keys landen im Ergebnis, damit Partial-Updates und das
// Änderungs-Audit korrekt bleiben.
const BANK_CARD_ALLOWED_FIELDS = [
'accountHolder', 'iban', 'cardNumber', 'bic', 'bankName', 'expiryDate', 'description', 'isActive',
] as const;
function pickBankCardFields(body: unknown): Record<string, unknown> {
const src = (body && typeof body === 'object') ? (body as Record<string, unknown>) : {};
const out: Record<string, unknown> = {};
for (const key of BANK_CARD_ALLOWED_FIELDS) {
if (key in src) out[key] = src[key];
}
return out;
}
// Customer CRUD
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
try {
@@ -82,12 +101,23 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen
const data: any = pickCustomerCreate(req.body);
// E-Mail ist Pflichtfeld.
if (!data.email || !String(data.email).trim()) {
res.status(400).json({ success: false, error: 'E-Mail-Adresse ist ein Pflichtfeld.' } as ApiResponse);
return;
}
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
if (data.email && !isValidEmail(data.email)) {
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
// Private E-Mail darf keine unserer verwalteten Provider-/Weiterleitungs-
// Domains verwenden.
if (emailUsesDomain(String(data.email), await getConfiguredEmailDomains())) {
res.status(400).json({ success: false, error: 'Diese E-Mail liegt auf einer verwalteten (Weiterleitungs-/Mailbox-)Domain und ist als private Kunden-E-Mail nicht zulässig.' } as ApiResponse);
return;
}
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
@@ -126,10 +156,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
: sanitizeCustomerStrict(customer as any);
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Kunden',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen des Kunden');
}
}
@@ -142,11 +169,19 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
return;
}
// Private E-Mail darf keine verwaltete Provider-/Weiterleitungs-Domain sein.
if (req.body?.email && emailUsesDomain(String(req.body.email), await getConfiguredEmailDomains())) {
res.status(400).json({ success: false, error: 'Diese E-Mail liegt auf einer verwalteten (Weiterleitungs-/Mailbox-)Domain und ist als private Kunden-E-Mail nicht zulässig.' } as ApiResponse);
return;
}
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
return;
}
const data: any = pickCustomerUpdate(req.body);
// E-Mail ist nur beim ANLEGEN Pflicht beim Bearbeiten darf ein
// Bestandskunde ohne E-Mail bleiben (die Domain-Prüfung oben greift aber
// weiterhin, falls doch eine gesetzt wird).
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
@@ -239,10 +274,7 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
res.json({ success: true, data: sanitized } as ApiResponse);
} catch (error) {
console.error('Update customer error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Kunden',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Kunden');
}
}
@@ -259,10 +291,7 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
});
res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Kunden',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Kunden');
}
}
@@ -291,10 +320,7 @@ export async function createAddress(req: AuthRequest, res: Response): Promise<vo
});
res.status(201).json({ success: true, data: address } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Adresse',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Adresse');
}
}
@@ -355,10 +381,7 @@ export async function updateAddress(req: AuthRequest, res: Response): Promise<vo
res.json({ success: true, data: address } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Adresse',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Adresse');
}
}
@@ -377,10 +400,7 @@ export async function deleteAddress(req: AuthRequest, res: Response): Promise<vo
});
res.json({ success: true, message: 'Adresse gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Adresse',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Adresse');
}
}
@@ -401,7 +421,8 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const card = await customerService.createBankCard(customerId, req.body);
const data = pickBankCardFields(req.body);
const card = await customerService.createBankCard(customerId, data as any);
await logChange({
req, action: 'CREATE', resourceType: 'BankCard',
resourceId: card.id.toString(),
@@ -410,10 +431,7 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
});
res.status(201).json({ success: true, data: card } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Bankkarte',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Bankkarte');
}
}
@@ -421,7 +439,10 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
try {
const cardId = parseInt(req.params.id);
if (!(await canAccessBankCard(req, res, cardId))) return;
const data = req.body;
// Mass-Assignment-Schutz (Pentest R155): nur erlaubte Felder aus dem Body,
// kein customerId/id/Fremdfeld. Das Audit unten läuft über diese saubere
// Menge (keine Phantom-Einträge). Enforcement zusätzlich im Service.
const data = pickBankCardFields(req.body);
// Vorherigen Stand laden für Audit
const before = await prisma.bankCard.findUnique({ where: { id: cardId } });
@@ -433,7 +454,7 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
if (before) {
const changes: Record<string, { von: unknown; nach: unknown }> = {};
const fieldLabels: Record<string, string> = {
iban: 'IBAN', bic: 'BIC', bankName: 'Bank',
iban: 'IBAN', bic: 'BIC', bankName: 'Bank', cardNumber: 'Kartennummer',
accountHolder: 'Kontoinhaber', isActive: 'Aktiv',
};
for (const [key, newVal] of Object.entries(data)) {
@@ -469,10 +490,7 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
res.json({ success: true, data: card } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Bankkarte',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Bankkarte');
}
}
@@ -491,10 +509,7 @@ export async function deleteBankCard(req: AuthRequest, res: Response): Promise<v
});
res.json({ success: true, message: 'Bankkarte gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Bankkarte',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Bankkarte');
}
}
@@ -524,10 +539,7 @@ export async function createDocument(req: AuthRequest, res: Response): Promise<v
});
res.status(201).json({ success: true, data: doc } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Ausweises',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen des Ausweises');
}
}
@@ -589,10 +601,7 @@ export async function updateDocument(req: AuthRequest, res: Response): Promise<v
res.json({ success: true, data: doc } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Ausweises',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Ausweises');
}
}
@@ -611,10 +620,7 @@ export async function deleteDocument(req: AuthRequest, res: Response): Promise<v
});
res.json({ success: true, message: 'Ausweis gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Ausweises',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Ausweises');
}
}
@@ -647,10 +653,7 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
});
res.status(201).json({ success: true, data: meter } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Zählers',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen des Zählers');
}
}
@@ -705,10 +708,7 @@ export async function updateMeter(req: AuthRequest, res: Response): Promise<void
res.json({ success: true, data: meter } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählers',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählers');
}
}
@@ -724,10 +724,7 @@ export async function deleteMeter(req: AuthRequest, res: Response): Promise<void
});
res.json({ success: true, message: 'Zähler gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählers',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Zählers');
}
}
@@ -773,10 +770,7 @@ export async function addMeterReading(req: AuthRequest, res: Response): Promise<
res.status(201).json({ success: true, data: reading } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Zählerstands',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Zählerstands');
}
}
@@ -804,10 +798,7 @@ export async function updateMeterReading(req: AuthRequest, res: Response): Promi
});
res.json({ success: true, data: reading } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählerstands',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählerstands');
}
}
@@ -824,10 +815,7 @@ export async function deleteMeterReading(req: AuthRequest, res: Response): Promi
});
res.json({ success: true, data: null } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählerstands',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Zählerstands');
}
}
@@ -882,10 +870,7 @@ export async function reportMeterReading(req: AuthRequest, res: Response): Promi
res.status(201).json({ success: true, data: reading } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Melden des Zählerstands',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Melden des Zählerstands');
}
}
@@ -937,10 +922,7 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
res.json({ success: true, data: reading } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
}
}
@@ -1050,10 +1032,7 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
res.json({ success: true, data: settings } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Portal-Einstellungen',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Portal-Einstellungen');
}
}
@@ -1067,10 +1046,7 @@ export async function generatePortalPassword(req: Request, res: Response): Promi
const password = generateSecurePassword({ length: 16 });
res.json({ success: true, data: { password } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Generieren des Passworts', 500);
}
}
@@ -1119,20 +1095,28 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr
}
const loginEmail = customer.portalEmail || customer.email!;
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
if (!plaintextPassword) {
const pwResult = await authService.getCustomerPortalPassword(customerId);
if (pwResult.status === 'none') {
res.status(400).json({
success: false,
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld bitte neu setzen)',
} as ApiResponse);
return;
}
// Desync: kein falsches Passwort versenden.
if (pwResult.status === 'desync') {
res.status(409).json({
success: false,
error: 'Das gespeicherte Passwort passt nicht zum Login-Passwort (Dateninkonsistenz). Bitte erst ein neues Passwort setzen/generieren, dann erneut senden.',
} as ApiResponse);
return;
}
await authService.sendPortalCredentialsEmail({
to: targetEmail,
customer,
loginEmail,
password: plaintextPassword,
password: pwResult.password,
});
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
@@ -1150,10 +1134,7 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Versenden der Zugangsdaten', 500);
}
}
@@ -1179,10 +1160,7 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
});
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
}
}
@@ -1190,7 +1168,7 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
const password = await authService.getCustomerPortalPassword(customerId);
const result = await authService.getCustomerPortalPassword(customerId);
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
// + Insider-Threat-Erkennung.
@@ -1199,10 +1177,19 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis
action: 'READ',
resourceType: 'PortalPassword',
resourceId: customerId.toString(),
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt`,
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt (${result.status})`,
customerId,
});
res.json({ success: true, data: { password } } as ApiResponse);
// Desync (gespeichertes Passwort passt nicht zum Login-Hash): NICHT ausgeben
// sonst liest der Support ein Passwort vor, das beim Login scheitert.
if (result.status === 'desync') {
res.status(409).json({
success: false,
error: 'Das gespeicherte Portal-Passwort stimmt nicht mit dem Login-Passwort überein (Dateninkonsistenz). Bitte über „Passwort setzen/neu generieren" ein neues Passwort vergeben.',
} as ApiResponse);
return;
}
res.json({ success: true, data: { password: result.status === 'ok' ? result.password : null } } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
@@ -1246,10 +1233,7 @@ export async function addRepresentative(req: AuthRequest, res: Response): Promis
});
res.status(201).json({ success: true, data: representative } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Vertreters',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Vertreters');
}
}
@@ -1268,10 +1252,7 @@ export async function removeRepresentative(req: AuthRequest, res: Response): Pro
});
res.json({ success: true, message: 'Vertreter entfernt' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Entfernen des Vertreters',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Entfernen des Vertreters');
}
}
@@ -1296,11 +1277,7 @@ export async function getSalutationPreference(req: AuthRequest, res: Response):
} catch (error) {
// Pentest R104.1: ApiError-Statuscodes durchreichen (404 statt 500
// bei nicht-existierendem Kunden).
const status = error instanceof ApiError ? error.statusCode : 500;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Laden der Anrede-Präferenz',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Laden der Anrede-Präferenz', 500);
}
}
@@ -1329,11 +1306,7 @@ export async function setSalutationPreference(req: AuthRequest, res: Response):
const result = await customerService.getSalutationPreference(userId, customerId);
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
const status = error instanceof ApiError ? error.statusCode : 500;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Anrede-Präferenz',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Speichern der Anrede-Präferenz', 500);
}
}
@@ -1354,11 +1327,7 @@ export async function clearSalutationPreference(req: AuthRequest, res: Response)
const result = await customerService.getSalutationPreference(userId, customerId);
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
const status = error instanceof ApiError ? error.statusCode : 500;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen der Anrede-Präferenz',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen der Anrede-Präferenz', 500);
}
}
@@ -1,4 +1,5 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.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);
} catch (error) {
const status = (error as referralService.ReferralError)?.status ?? 500;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Anlegen der Werbe-Beziehung',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Anlegen der Werbe-Beziehung', 500);
}
}
@@ -153,11 +150,7 @@ export async function updateReferral(req: AuthRequest, res: Response): Promise<v
res.json({ success: true, data: updated } as ApiResponse);
} catch (error) {
const status = (error as referralService.ReferralError)?.status ?? 500;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Ändern der Werbe-Beziehung',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Ändern der Werbe-Beziehung', 500);
}
}
@@ -1,6 +1,7 @@
// ==================== EMAIL PROVIDER CONTROLLER ====================
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
import { logChange } from '../services/audit.service.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Email-Providers',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen des Email-Providers');
}
}
@@ -80,10 +78,7 @@ export async function updateProviderConfig(req: Request, res: Response): Promise
});
res.json({ success: true, data: config } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Email-Providers',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Email-Providers');
}
}
@@ -99,10 +94,7 @@ export async function deleteProviderConfig(req: Request, res: Response): Promise
});
res.json({ success: true, message: 'Email-Provider gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Email-Providers',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Email-Providers');
}
}
@@ -158,10 +150,7 @@ export async function testConnection(req: Request, res: Response): Promise<void>
const result = await emailProviderService.testProviderConnection({ id, testData });
res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Verbindungstest fehlgeschlagen',
} as ApiResponse);
antworteAufFehler(res, error, 'Verbindungstest fehlgeschlagen', 500);
}
}
@@ -342,10 +331,7 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
} as ApiResponse);
} catch (error) {
console.error('testMailAccess error:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Test',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Test', 500);
}
}
@@ -355,10 +341,7 @@ export async function checkEmailExists(req: Request, res: Response): Promise<voi
const result = await emailProviderService.checkEmailExists(localPart);
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Prüfung',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler bei der E-Mail-Prüfung', 500);
}
}
@@ -377,10 +360,7 @@ export async function provisionEmail(req: Request, res: Response): Promise<void>
const result = await emailProviderService.provisionEmail(localPart, customerEmail);
res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Provisionierung',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler bei der E-Mail-Provisionierung', 500);
}
}
@@ -390,10 +370,7 @@ export async function deprovisionEmail(req: Request, res: Response): Promise<voi
const result = await emailProviderService.deprovisionEmail(localPart);
res.json({ success: result.success, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der E-Mail',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der E-Mail', 500);
}
}
@@ -1,4 +1,5 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { AuthRequest } from '../types/index.js';
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
import { createAuditLog } from '../services/audit.service.js';
@@ -31,10 +32,7 @@ export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
res.send(buffer);
} catch (error) {
console.error('Fehler beim Factory-Defaults-Export:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Export',
});
antworteAufFehler(res, error, 'Fehler beim Export', 500);
}
}
@@ -93,9 +91,6 @@ export async function importFactoryDefaults(req: AuthRequest, res: Response) {
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Factory-Defaults-Import:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Import',
});
antworteAufFehler(res, error, 'Fehler beim Import');
}
}
@@ -48,6 +48,13 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
} else if (owner.kind === 'contract') {
if (!(await canAccessContract(req, res, owner.contractId))) return;
} else if (owner.kind === 'contract-staff') {
// Nur Mitarbeiter/Admin Portal-Kunden nie (z.B. Gutschrift-
// Überweisungsbelege). Staff dürfen wie bei allen Vertrags-Files.
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
return;
}
} else if (owner.kind === 'admin') {
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
const perms = req.user?.permissions || [];
+11 -40
View File
@@ -1,4 +1,5 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { AuthRequest } from '../types/index.js';
import * as gdprService from '../services/gdpr.service.js';
import * as consentService from '../services/consent.service.js';
@@ -54,10 +55,7 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
}
} catch (error) {
console.error('Fehler beim Datenexport:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Datenexport',
});
antworteAufFehler(res, error, 'Fehler beim Datenexport', 500);
}
}
@@ -98,10 +96,7 @@ export async function createDeletionRequest(req: AuthRequest, res: Response) {
res.status(201).json({ success: true, data: request });
} catch (error) {
console.error('Fehler beim Erstellen der Löschanfrage:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
});
antworteAufFehler(res, error, 'Fehler beim Erstellen');
}
}
@@ -183,10 +178,7 @@ export async function processDeletionRequest(req: AuthRequest, res: Response) {
res.json({ success: true, data: result });
} catch (error) {
console.error('Fehler beim Bearbeiten der Löschanfrage:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Bearbeiten',
});
antworteAufFehler(res, error, 'Fehler beim Bearbeiten');
}
}
@@ -346,10 +338,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
res.json({ success: true, data: consent });
} catch (error) {
console.error('Fehler beim Aktualisieren der Einwilligung:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
});
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
}
}
@@ -697,10 +686,7 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
});
} catch (error) {
console.error('Fehler beim Senden des Consent-Links:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
}
}
@@ -819,10 +805,7 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
});
} catch (error) {
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Senden',
});
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
}
}
@@ -875,10 +858,7 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Erteilen der Vollmacht:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erteilen der Vollmacht',
});
antworteAufFehler(res, error, 'Fehler beim Erteilen der Vollmacht');
}
}
@@ -904,10 +884,7 @@ export async function withdrawAuthorization(req: AuthRequest, res: Response) {
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Widerrufen der Vollmacht:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Widerrufen',
});
antworteAufFehler(res, error, 'Fehler beim Widerrufen');
}
}
@@ -1012,10 +989,7 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Upload des Vollmacht-Dokuments:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Upload',
});
antworteAufFehler(res, error, 'Fehler beim Upload');
}
}
@@ -1039,10 +1013,7 @@ export async function deleteAuthorizationDocument(req: AuthRequest, res: Respons
res.json({ success: true, data: auth });
} catch (error) {
console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
});
antworteAufFehler(res, error, 'Fehler beim Löschen');
}
}
+5 -16
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as invoiceService from '../services/invoice.service.js';
import { logChange } from '../services/audit.service.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);
} catch (error) {
console.error('addInvoice error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen der Rechnung',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Hinzufügen der Rechnung');
}
}
@@ -136,10 +134,7 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
res.json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
console.error('updateInvoice error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rechnung',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Rechnung');
}
}
@@ -163,10 +158,7 @@ export async function deleteInvoice(req: AuthRequest, res: Response): Promise<vo
res.json({ success: true, data: null } as ApiResponse);
} catch (error) {
console.error('deleteInvoice error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rechnung',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Rechnung');
}
}
@@ -208,9 +200,6 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
});
res.status(201).json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Hinzufügen');
}
}
@@ -1,4 +1,5 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import prisma from '../lib/prisma.js';
import { AuthRequest, ApiResponse } from '../types/index.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);
}
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
} as ApiResponse);
antworteAufFehler(res, error, 'Test-Alert fehlgeschlagen', 500);
}
}
@@ -201,9 +199,6 @@ export async function runDigestNow(_req: AuthRequest, res: Response): Promise<vo
const result = await sendDigest({ force: true });
res.json({ success: true, data: result } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
} as ApiResponse);
antworteAufFehler(res, error, 'Digest fehlgeschlagen', 500);
}
}
@@ -1,4 +1,5 @@
import { Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import { AuthRequest } from '../types/index.js';
import * as pdfTemplateService from '../services/pdfTemplate.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 } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
});
antworteAufFehler(res, error, 'Fehler beim Erstellen');
}
}
@@ -87,10 +85,7 @@ export async function updateTemplate(req: AuthRequest, res: Response) {
res.json({ success: true, data: template });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
});
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
}
}
@@ -119,10 +114,7 @@ export async function extractFields(req: AuthRequest, res: Response) {
const result = await pdfTemplateService.extractPdfFields(template.templatePath);
res.json({ success: true, data: result.fields, totalPages: result.totalPages });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Auslesen der PDF-Felder',
});
antworteAufFehler(res, error, '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.send(pdfBuffer);
} 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);
res.json({ success: true, data: inputs });
} 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);
} catch (error) {
console.error('PDF generate error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren',
});
antworteAufFehler(res, error, 'Fehler beim Generieren');
}
}
+4 -12
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as platformService from '../services/platform.service.js';
import { logChange } from '../services/audit.service.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertriebsplattform',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertriebsplattform');
}
}
@@ -64,10 +62,7 @@ export async function updatePlatform(req: Request, res: Response): Promise<void>
});
res.json({ success: true, data: platform } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertriebsplattform',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertriebsplattform');
}
}
@@ -83,9 +78,6 @@ export async function deletePlatform(req: Request, res: Response): Promise<void>
});
res.json({ success: true, message: 'Vertriebsplattform gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertriebsplattform',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Vertriebsplattform');
}
}
+4 -12
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import bcrypt from 'bcryptjs';
import prisma from '../lib/prisma.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Anbieters',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen des Anbieters');
}
}
@@ -136,10 +134,7 @@ export async function updateProvider(req: Request, res: Response): Promise<void>
});
res.json({ success: true, data: provider } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Anbieters',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Anbieters');
}
}
@@ -155,9 +150,6 @@ export async function deleteProvider(req: Request, res: Response): Promise<void>
});
res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Anbieters',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Anbieters');
}
}
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
import { logChange } from '../services/audit.service.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);
} catch (error) {
const status = error instanceof ApiError ? error.statusCode : 400;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse', 400);
}
}
@@ -119,11 +116,7 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
});
res.json({ success: true, data: email } as ApiResponse);
} catch (error) {
const status = error instanceof ApiError ? error.statusCode : 400;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse', 400);
}
}
@@ -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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Stressfrei-Wechseln Adresse',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen der Stressfrei-Wechseln Adresse');
}
}
@@ -180,10 +170,7 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
message: 'Weiterleitungen aktualisiert',
} as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Synchronisieren der Weiterleitungen',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Synchronisieren der Weiterleitungen', 500);
}
}
@@ -228,11 +215,7 @@ export async function updateAdditionalForwards(req: AuthRequest, res: Response):
message: 'Weiterleitungen aktualisiert',
} as ApiResponse);
} catch (error) {
const status = error instanceof ApiError ? error.statusCode : 500;
res.status(status).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Weiterleitungen',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Weiterleitungen', 500);
}
}
@@ -255,9 +238,6 @@ export async function resetPassword(req: AuthRequest, res: Response): Promise<vo
message: 'Passwort wurde zurückgesetzt',
} as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen des Passworts', 500);
}
}
+4 -12
View File
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import * as tariffService from '../services/tariff.service.js';
import { logChange } from '../services/audit.service.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Tarifs',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Erstellen des Tarifs');
}
}
@@ -66,10 +64,7 @@ export async function updateTariff(req: Request, res: Response): Promise<void> {
});
res.json({ success: true, data: tariff } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Tarifs',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Tarifs');
}
}
@@ -85,9 +80,6 @@ export async function deleteTariff(req: Request, res: Response): Promise<void> {
});
res.json({ success: true, message: 'Tarif gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Tarifs',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Tarifs');
}
}
+454 -45
View File
@@ -1,10 +1,14 @@
import { Request, Response } from 'express';
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
import bcrypt from 'bcryptjs';
import prisma from '../lib/prisma.js';
import * as userService from '../services/user.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 { 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';
// Users
@@ -54,6 +58,11 @@ export async function createUser(req: Request, res: Response): Promise<void> {
try {
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
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
// (Pentest 29.4 SMTP-Header-Injection).
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);
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({
req, action: 'CREATE', resourceType: 'User',
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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers',
} as ApiResponse);
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen des Benutzers');
}
}
export async function updateUser(req: Request, res: Response): Promise<void> {
export async function updateUser(req: AuthRequest, res: Response): Promise<void> {
try {
const userId = parseInt(req.params.id);
// `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)
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).
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
if (data?.email !== undefined && !isValidEmail(data.email)) {
@@ -161,10 +177,129 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
...beforeUser,
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE),
}
: 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) {
// Audit: Geänderte Felder ermitteln und loggen
if (before) {
@@ -172,6 +307,8 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
const fieldLabels: Record<string, string> = {
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
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)) {
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
@@ -191,9 +328,102 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
await logChange({
req, action: 'UPDATE', resourceType: 'User',
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,
});
// 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 {
await logChange({
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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers',
} as ApiResponse);
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren des Benutzers');
}
}
@@ -264,7 +491,15 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
} as ApiResponse);
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) {
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
return;
@@ -286,10 +521,7 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
});
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
}
}
@@ -305,14 +537,70 @@ export async function deleteUser(req: Request, res: Response): Promise<void> {
});
res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Benutzers',
} as ApiResponse);
antworteAufFehler(res, error, 'Fehler beim Löschen des Benutzers');
}
}
// 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> {
try {
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 {
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({
req, action: 'CREATE', resourceType: 'Role',
resourceId: role.id.toString(),
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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Rolle',
} as ApiResponse);
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen der Rolle');
}
}
export async function updateRole(req: Request, res: Response): Promise<void> {
export async function updateRole(req: AuthRequest, res: Response): Promise<void> {
try {
const role = await userService.updateRole(parseInt(req.params.id), req.body);
if (role) {
await logChange({
req, action: 'UPDATE', resourceType: 'Role',
resourceId: role.id.toString(),
label: `Rolle ${role.name} aktualisiert`,
});
const daten = pickRoleUpdate(req.body);
const fehler = pruefeRollenEingabe(daten, false);
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.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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rolle',
} as ApiResponse);
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren der Rolle');
}
}
export async function deleteRole(req: Request, res: Response): Promise<void> {
export async function deleteRole(req: AuthRequest, res: Response): Promise<void> {
try {
const roleId = parseInt(req.params.id);
const role = await userService.getRoleById(roleId);
@@ -390,12 +799,12 @@ export async function deleteRole(req: Request, res: Response): Promise<void> {
resourceId: roleId.toString(),
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);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rolle',
} as ApiResponse);
antworteAufRollenFehler(res, error, 'Fehler beim Löschen der Rolle');
}
}
+80 -52
View File
@@ -1,4 +1,4 @@
import express from 'express';
import express, { Router } from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import helmet from 'helmet';
@@ -30,6 +30,7 @@ if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
}
import { apiBackstopRateLimiter } from './middleware/rateLimit.js';
import authRoutes from './routes/auth.routes.js';
import customerRoutes from './routes/customer.routes.js';
import addressRoutes from './routes/address.routes.js';
@@ -38,6 +39,8 @@ import documentRoutes from './routes/document.routes.js';
import meterRoutes from './routes/meter.routes.js';
import stressfreiEmailRoutes from './routes/stressfreiEmail.routes.js';
import contractRoutes from './routes/contract.routes.js';
import creditNoteRoutes from './routes/creditNote.routes.js';
import companyProfileRoutes from './routes/companyProfile.routes.js';
import platformRoutes from './routes/platform.routes.js';
import cancellationPeriodRoutes from './routes/cancellation-period.routes.js';
import contractDurationRoutes from './routes/contract-duration.routes.js';
@@ -63,10 +66,14 @@ import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
import { downloadFile } from './controllers/fileDownload.controller.js';
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
import monitoringRoutes from './routes/monitoring.routes.js';
import { registriereIdPruefung } from './middleware/routeIds.js';
import { auditContextMiddleware } from './middleware/auditContext.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';
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
@@ -321,62 +328,76 @@ app.use('/api', (_req, res, next) => {
next();
});
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
// wurde kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` also `6abc`,
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
// 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
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$`
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
// nicht geblockt.
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
app.use('/api', (req, res, next) => {
for (const seg of req.path.split('/')) {
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
return;
}
}
next();
});
// Genau das loest `mounte()` weiter unten die Pruefung wird an jedem Router
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
// zwar in beide Richtungen:
//
// Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
// ID-Parameter und laesst nur kanonische Zahlen zu.
// Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
//
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt zwei Antworten
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
// 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.
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)
app.use('/api/public/consent', consentPublicRoutes);
mounte('/api/public/consent', consentPublicRoutes);
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/customers', customerRoutes);
app.use('/api/addresses', addressRoutes);
app.use('/api/bank-cards', bankcardRoutes);
app.use('/api/documents', documentRoutes);
app.use('/api/meters', meterRoutes);
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
app.use('/api/contracts', contractRoutes);
app.use('/api/platforms', platformRoutes);
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
app.use('/api/contract-durations', contractDurationRoutes);
app.use('/api/providers', providerRoutes);
app.use('/api/tariffs', tariffRoutes);
app.use('/api/users', userRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/developer', developerRoutes);
app.use('/api/contract-categories', contractCategoryRoutes);
app.use('/api', contractTaskRoutes);
app.use('/api/settings', appSettingRoutes);
app.use('/api/email-providers', emailProviderRoutes);
app.use('/api', cachedEmailRoutes);
app.use('/api/energy-details', invoiceRoutes);
app.use('/api', contractHistoryRoutes);
app.use('/api/audit-logs', auditLogRoutes);
app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
app.use('/api/birthdays', birthdayRoutes);
app.use('/api/factory-defaults', factoryDefaultsRoutes);
app.use('/api/monitoring', monitoringRoutes);
mounte('/api/auth', authRoutes);
mounte('/api/customers', customerRoutes);
mounte('/api/addresses', addressRoutes);
mounte('/api/bank-cards', bankcardRoutes);
mounte('/api/documents', documentRoutes);
mounte('/api/meters', meterRoutes);
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
mounte('/api/contracts', contractRoutes);
mounte('/api/credit-notes', creditNoteRoutes);
mounte('/api/company-profile', companyProfileRoutes);
mounte('/api/platforms', platformRoutes);
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
mounte('/api/contract-durations', contractDurationRoutes);
mounte('/api/providers', providerRoutes);
mounte('/api/tariffs', tariffRoutes);
mounte('/api/users', userRoutes);
mounte('/api/upload', uploadRoutes);
mounte('/api/developer', developerRoutes);
mounte('/api/contract-categories', contractCategoryRoutes);
mounte('/api', contractTaskRoutes);
mounte('/api/settings', appSettingRoutes);
mounte('/api/email-providers', emailProviderRoutes);
mounte('/api', cachedEmailRoutes);
mounte('/api/energy-details', invoiceRoutes);
mounte('/api', contractHistoryRoutes);
mounte('/api/audit-logs', auditLogRoutes);
mounte('/api/gdpr', gdprRoutes);
mounte('/api/email-logs', emailLogRoutes);
mounte('/api/pdf-templates', pdfTemplateRoutes);
mounte('/api/birthdays', birthdayRoutes);
mounte('/api/factory-defaults', factoryDefaultsRoutes);
mounte('/api/monitoring', monitoringRoutes);
// Health check BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
@@ -476,10 +497,17 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
const LISTEN_ADDR = process.env.LISTEN_ADDR
|| (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, () => {
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
startBirthdayScheduler();
startContractStatusScheduler();
startSecurityMonitorScheduler();
startBlzUpdateScheduler();
});
+84 -13
View File
@@ -49,6 +49,13 @@ function determineAction(method: string, path: string, success: boolean): AuditA
if (path.includes('/auth/logout')) {
return 'LOGOUT';
}
// Stiller Token-Refresh (Cookie) kein interaktiver Login, eigene Action.
// Erfolg vs. Fehlschlag wird NICHT über die Action getrennt (semantisch beides
// ein Refresh), sondern downstream über success-Flag + Sensitivität (LOW/HIGH)
// und den TOKEN_REJECTED-SecurityEvent im Controller (Pentest R164-01).
if (path.includes('/auth/refresh')) {
return 'TOKEN_REFRESH';
}
// Standard CRUD-Aktionen
switch (method.toUpperCase()) {
@@ -88,10 +95,10 @@ function findResourceMapping(path: string): { type: string; extractId?: (req: Au
/**
* Extrahiert die betroffene Kunden-ID für DSGVO-Tracking
*/
function extractDataSubjectId(req: AuthRequest): number | undefined {
function extractDataSubjectId(req: AuthRequest, fullPath: string): number | undefined {
// Aus Route-Parameter
const customerId = req.params.customerId || req.params.id;
if (customerId && req.path.includes('/customers')) {
if (customerId && fullPath.includes('/customers')) {
return parseInt(customerId);
}
@@ -157,8 +164,40 @@ const ACTION_LABELS: Record<string, string> = {
LOGIN: 'angemeldet',
LOGOUT: 'abgemeldet',
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
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).
*
* "Kein Cookie vorhanden" ist KEIN Ablehnungsfall: Der Frontend-Interceptor
* ruft /refresh beim App-Start auch dann, wenn nie ein Token gesetzt war
* (normaler Erstbesuch). Das als HIGH/"abgelehnt" zu fuehren erzeugt genau das
* Rauschen, das mit der Entrauschung beseitigt werden sollte.
*/
function refreshOutcome(responseBody: unknown, success: boolean): 'ok' | 'no-token' | 'rejected' {
if (success) return 'ok';
const err = responseBody && typeof responseBody === 'object' && 'error' in responseBody
? String((responseBody as { error?: unknown }).error ?? '')
: '';
return /kein refresh-token/i.test(err) ? 'no-token' : 'rejected';
}
/**
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
*/
@@ -166,7 +205,8 @@ function generateHumanLabel(
action: AuditAction,
resourceType: string,
req: AuthRequest,
responseBody: unknown
responseBody: unknown,
fullPath: string
): string {
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
const actionName = ACTION_LABELS[action] || action;
@@ -188,16 +228,28 @@ function generateHumanLabel(
}
// Spezial-Labels für bestimmte Endpunkte
const path = req.path;
// (fullPath statt req.path siehe Hinweis in auditMiddleware, Pentest R165)
const path = fullPath;
// Auth
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
const email = req.body?.email || '';
if (action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, true)) {
return `Dienstkonto ${email} angemeldet (planmäßig)`;
}
return action === 'LOGIN'
? `Benutzer ${email} hat sich angemeldet`
: `Anmeldung fehlgeschlagen für ${email}`;
}
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
if (path.includes('/auth/refresh')) {
const success = !(responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false);
switch (refreshOutcome(responseBody, success)) {
case 'ok': return 'Sitzung verlängert (Token erneuert)';
case 'no-token': return 'Token-Refresh ohne vorliegenden Token (kein Cookie)';
default: return 'Token-Refresh abgelehnt (ungültig/abgelaufen)';
}
}
// Kunden-Operationen
if (resourceType === 'Customer') {
@@ -359,14 +411,23 @@ function generateHumanLabel(
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
const startTime = Date.now();
// WICHTIG (Pentest R165): `req.path` ist im res.on('finish')-Handler NICHT mehr der
// volle Pfad. Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url`
// und stellt ihn nur beim `next()`-Durchlauf wieder her ein Handler, der die
// Response terminiert (res.json()), ruft nie `next()`, also bleibt `req.path`
// router-relativ (`/refresh` statt `/api/auth/refresh`). Deshalb den vollen Pfad
// EINMAL hier synchron festhalten und downstream ausschliesslich diesen nutzen
// sonst matchen alle Pfad-Checks (/auth/login, /auth/refresh, …) ins Leere.
const fullPath = req.originalUrl?.split('?')[0] || req.path;
// Ausgeschlossene Routen überspringen
if (EXCLUDED_ROUTES.some((route) => req.path.startsWith(route))) {
if (EXCLUDED_ROUTES.some((route) => fullPath.startsWith(route))) {
next();
return;
}
// Resource-Mapping finden
const mapping = findResourceMapping(req.path);
const mapping = findResourceMapping(fullPath);
if (!mapping) {
// Unbekannte Route - trotzdem loggen mit generischem Typ
next();
@@ -402,7 +463,7 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
setImmediate(async () => {
try {
const durationMs = Date.now() - startTime;
const action = determineAction(req.method, req.path, responseSuccess);
const action = determineAction(req.method, fullPath, responseSuccess);
// READ-Aktionen nicht loggen (nur Änderungen, Logins und Exporte)
if (action === 'READ') return;
@@ -415,19 +476,19 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
'/api/gdpr',
'/api/upload',
];
// Login/Logout immer loggen
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED') {
if (manuallyLoggedPaths.some(p => req.originalUrl?.startsWith(p) || req.baseUrl?.startsWith(p))) return;
// Login/Logout/Refresh immer loggen
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED' && action !== 'TOKEN_REFRESH') {
if (manuallyLoggedPaths.some(p => fullPath.startsWith(p))) return;
}
const resourceId = mapping.extractId?.(req);
const dataSubjectId = extractDataSubjectId(req);
const dataSubjectId = extractDataSubjectId(req, fullPath);
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
const auditContext = capturedAuditContext;
// Menschenlesbares Label generieren
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody, fullPath);
await createAuditLog({
userId: req.user?.userId,
@@ -436,10 +497,20 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
customerId: req.user?.customerId,
isCustomerPortal: req.user?.isCustomerPortal,
action,
// Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
// Abgelehnter Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in der
// Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01).
// "Kein Cookie vorhanden" ist dagegen der normale Erstbesuch → LOW (R166-03).
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
sensitivity: action === 'TOKEN_REFRESH'
? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW')
: action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, responseSuccess)
? 'LOW'
: undefined,
resourceType: mapping.type,
resourceId,
resourceLabel,
endpoint: req.path,
endpoint: fullPath,
httpMethod: req.method,
ipAddress: getClientIp(req),
userAgent: req.headers['user-agent'],
+13 -1
View File
@@ -1,4 +1,5 @@
import { Response, NextFunction } from 'express';
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma.js';
import { AuthRequest, JwtPayload } from '../types/index.js';
@@ -128,7 +129,18 @@ export function requirePermission(...requiredPermissions: string[]) {
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
const hasPermission = requiredPermissions.some((perm) =>
+41
View File
@@ -132,6 +132,47 @@ export const staffPasswordReAuthLimiter = rateLimit({
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
* für legitime Kunden weit aus.
*/
/**
* Globaler Backstop-Limiter für ALLE /api-Requests (Pentest R148).
*
* Hintergrund: Es gab bislang KEINEN generellen /api-Limiter nur die
* dedizierten oben (Login, Passwort-Reset, Staff-Re-Auth, Consent). Damit war
* jeder authentifizierte Endpoint gegen Enumeration/Scripted-Abuse/DoS
* ungedrosselt. Dieser Limiter ist eine großzügige Obergrenze, KEIN Ersatz für
* die feineren Limiter (die feuern früher und bleiben aktiv).
*
* Key = NUR die (IPv6-/56-normalisierte) IP. Bewusst NICHT (IP+User): den
* User-Claim könnten wir hier nur unverifiziert aus dem Token lesen (die volle
* `authenticate`-Prüfung inkl. DB läuft erst pro Route). Ein Angreifer könnte
* dann mit gefälschten userId-Claims beliebig frische Buckets erzeugen und den
* Backstop umgehen. Per-Account-Präzision liefern ohnehin die Login-Limiter.
*
* Limit per Env `API_RATE_LIMIT_PER_MIN` (Default 1200/min ≈ 20/s pro IP)
* für legitime Nutzung (auch mehrere Nutzer hinter NAT) weit ausreichend,
* bremst aber Flooding massiv. Healthcheck (`/api/health`) ist ausgenommen.
*
* KEIN SecurityEvent pro geblocktem Request: Bei einem Flood würde das den
* Security-/Audit-Store selbst zumüllen (Amplification). Die 429 stehen im
* Access-Log; die feineren Limiter melden weiterhin an das Monitoring.
*/
const API_RATE_LIMIT_PER_MIN = Math.max(
parseInt(process.env.API_RATE_LIMIT_PER_MIN || '', 10) || 1200,
60,
);
export const apiBackstopRateLimiter = rateLimit({
windowMs: 60 * 1000,
limit: API_RATE_LIMIT_PER_MIN,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: {
success: false,
error: 'Zu viele Anfragen in kurzer Zeit. Bitte einen Moment warten.',
},
keyGenerator: (req): string => ipKeyGenerator(req.ip || 'unknown'),
skip: (req) => req.path === '/health',
});
export const publicConsentRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 30,
+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;
}
+39 -2
View File
@@ -3,6 +3,7 @@ import multer from 'multer';
import * as appSettingController from '../controllers/appSetting.controller.js';
import * as backupController from '../controllers/backup.controller.js';
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
import * as blzDataController from '../controllers/blzData.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
// Multer für Backup-Upload (in Memory speichern)
@@ -60,11 +61,17 @@ router.post(
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(
'/backup/:name/restore',
authenticate,
requirePermission('settings:update'),
requirePermission('roles:manage'),
backupController.restoreBackup
);
@@ -93,11 +100,23 @@ router.post(
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(
'/factory-reset',
authenticate,
requirePermission('settings:update'),
requirePermission('roles:manage'),
backupController.factoryReset
);
@@ -115,6 +134,24 @@ router.get(
backupController.getBackupLogDetail
);
// ==================== BLZ-/BANKDATEN ====================
// Status (installierte Version, Quelle, Auto-Update-Stand)
router.get(
'/blz',
authenticate,
requirePermission('settings:read'),
blzDataController.getStatus,
);
// Jetzt prüfen/aktualisieren (?force=1 lädt auch bei gleicher Version)
router.post(
'/blz/update-now',
authenticate,
requirePermission('settings:update'),
blzDataController.updateNow,
);
// Rate-Limit-Verwaltung (Admin)
router.get(
'/rate-limits/active',
+29 -8
View File
@@ -7,29 +7,50 @@ const router = Router();
// Alle Routen erfordern Authentifizierung
router.use(authenticate);
// ACHTUNG Reihenfolge: Alle konkreten Pfade MÜSSEN vor der Parameter-Route
// '/:id' stehen, sonst schluckt diese sie und antwortet mit
// "Ungültige Audit-Log-ID". Genau so war GET /retention-policies unerreichbar
// (Pentest R166-04).
// Audit-Logs abrufen
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
// Audit-Logs exportieren (muss VOR /:id stehen!)
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs);
// Audit-Logs exportieren
//
// 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
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
// Kontrollwert für das externe Gegenbuch (nur lesend, ohne Geheimnisse)
router.get('/checkpoint', requirePermission('audit:read'), auditLogController.getCheckpoint);
// Audit-Logs für einen Kunden (DSGVO)
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
// Einzelnes Audit-Log abrufen
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
// Hash-Ketten-Integrität prüfen
router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity);
// Hash-Kette reparieren
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
// Retention-Policies
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
// Altbestand versiegeln (nicht destruktiv)
router.post('/seal-backlog', requirePermission('audit:admin'), auditLogController.sealBacklog);
// Retention-Cleanup manuell ausführen
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
// Einzelnes Audit-Log abrufen als LETZTE GET-Route, siehe Hinweis oben
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
export default router;
+5
View File
@@ -1,9 +1,14 @@
import { Router } from 'express';
import * as customerController from '../controllers/customer.controller.js';
import * as bankLookupController from '../controllers/bankLookup.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// IBAN-Prüfung + BIC/Bank-Nachschlag (offline, Bundesbank-BLZ). Liefert nur
// öffentliche Bankverzeichnis-Daten reicht daher ein eingeloggter Nutzer.
router.post('/iban-lookup', authenticate, bankLookupController.lookupIban);
router.put('/:id', authenticate, requirePermission('customers:update'), customerController.updateBankCard);
router.delete('/:id', authenticate, requirePermission('customers:delete'), customerController.deleteBankCard);
@@ -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 * as cancellationPeriodController from '../controllers/cancellation-period.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
router.get('/', authenticate, cancellationPeriodController.getCancellationPeriods);
router.post('/', authenticate, requirePermission('platforms:create'), cancellationPeriodController.createCancellationPeriod);
router.get('/:id', authenticate, cancellationPeriodController.getCancellationPeriod);
router.put('/:id', authenticate, requirePermission('platforms:update'), cancellationPeriodController.updateCancellationPeriod);
router.delete('/:id', authenticate, requirePermission('platforms:delete'), cancellationPeriodController.deleteCancellationPeriod);
router.get('/', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriods);
router.post('/', authenticate, requirePermission('cancellation-periods:create'), cancellationPeriodController.createCancellationPeriod);
router.get('/:id', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriod);
router.put('/:id', authenticate, requirePermission('cancellation-periods:update'), cancellationPeriodController.updateCancellationPeriod);
router.delete('/:id', authenticate, requirePermission('cancellation-periods:delete'), cancellationPeriodController.deleteCancellationPeriod);
export default router;
@@ -0,0 +1,10 @@
import { Router } from 'express';
import * as companyProfileController from '../controllers/companyProfile.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
router.get('/', authenticate, requirePermission('settings:read'), companyProfileController.getProfile);
router.put('/', authenticate, requirePermission('settings:update'), companyProfileController.updateProfile);
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 * as contractDurationController from '../controllers/contract-duration.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
router.get('/', authenticate, contractDurationController.getContractDurations);
router.post('/', authenticate, requirePermission('platforms:create'), contractDurationController.createContractDuration);
router.get('/:id', authenticate, contractDurationController.getContractDuration);
router.put('/:id', authenticate, requirePermission('platforms:update'), contractDurationController.updateContractDuration);
router.delete('/:id', authenticate, requirePermission('platforms:delete'), contractDurationController.deleteContractDuration);
router.get('/', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDurations);
router.post('/', authenticate, requirePermission('contract-durations:create'), contractDurationController.createContractDuration);
router.get('/:id', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDuration);
router.put('/:id', authenticate, requirePermission('contract-durations:update'), contractDurationController.updateContractDuration);
router.delete('/:id', authenticate, requirePermission('contract-durations:delete'), contractDurationController.deleteContractDuration);
export default router;
+7
View File
@@ -4,6 +4,7 @@ import path from 'path';
import fs from 'fs';
import * as contractController from '../controllers/contract.controller.js';
import * as invoiceController from '../controllers/invoice.controller.js';
import * as creditNoteController from '../controllers/creditNote.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
@@ -57,6 +58,12 @@ router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'),
router.get('/:id/invoices', authenticate, requirePermission('contracts:read'), invoiceController.getInvoicesByContract);
router.post('/:id/invoices', authenticate, requirePermission('contracts:update'), invoiceController.addInvoiceByContract);
// Gutschriften (Subventionen) am Vertrag Mitarbeiter/Admin (Controller
// blockt Portal-Token zusätzlich explizit).
router.get('/:contractId/credit-notes', authenticate, requirePermission('contracts:read'), creditNoteController.listByContract);
router.get('/:contractId/credit-notes/defaults', authenticate, requirePermission('contracts:read'), creditNoteController.formDefaults);
router.post('/:contractId/credit-notes', authenticate, requirePermission('contracts:update'), creditNoteController.create);
// Vertragsdokumente
router.get('/:id/documents', authenticate, requirePermission('contracts:read'), contractController.getContractDocuments);
router.post('/:id/documents', authenticate, requirePermission('contracts:update'), docUpload.single('file'), validateUploadedFile, contractController.uploadContractDocument);
@@ -4,9 +4,12 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Lesen für alle authentifizierten Benutzer
router.get('/', authenticate, contractCategoryController.getContractCategories);
router.get('/:id', authenticate, contractCategoryController.getContractCategory);
// Lesen verlangt jetzt `contract-categories:read`, vorher genuegte
// Angemeldetsein. Das Recht stand im Katalog und bewachte nichts; die
// 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-
// Rollen vergeben. Vorher stand hier `developer:access` mit dem
@@ -22,6 +22,14 @@ router.get(
contractTaskController.getTaskStats
);
// Allgemeine Aufgabe OHNE Vertrag/Kunde anlegen (nur Mitarbeiter)
router.post(
'/tasks',
authenticate,
requirePermission('contracts:update'),
contractTaskController.createGeneralTask
);
// ==================== TASKS BY CONTRACT ====================
// Alle Aufgaben eines Vertrags abrufen (auch für Kundenportal, aber nur sichtbare)
+24
View File
@@ -0,0 +1,24 @@
import { Router } from 'express';
import * as creditNoteController from '../controllers/creditNote.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Gesamtübersicht aller Belege (Hauptmenü) portal-scoped (nicht staff-only).
// VOR /:id, damit die Wurzel nicht als ID interpretiert wird.
router.get('/', authenticate, requirePermission('contracts:read'), creditNoteController.listAll);
// Nummernkreis-Verwaltung (Einstellungen). VOR /:id, damit "number-range"
// nicht als ID interpretiert wird.
router.get('/number-range', authenticate, requirePermission('settings:read'), creditNoteController.getNumberRange);
router.put('/number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateNumberRange);
router.get('/delivery-note-number-range', authenticate, requirePermission('settings:read'), creditNoteController.getDeliveryNoteNumberRange);
router.put('/delivery-note-number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateDeliveryNoteNumberRange);
// Einzelne Gutschrift
router.post('/:id/pdf', authenticate, requirePermission('contracts:update'), creditNoteController.generatePdf);
router.get('/:id', authenticate, requirePermission('contracts:read'), creditNoteController.getOne);
router.put('/:id', authenticate, requirePermission('contracts:update'), creditNoteController.update);
router.delete('/:id', authenticate, requirePermission('contracts:delete'), creditNoteController.remove);
export default router;
+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 ====================
import { Router } from 'express';
@@ -7,15 +18,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Provider Config CRUD (Admin-only)
router.get('/configs', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfigs);
router.get('/configs/:id', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfig);
router.post('/configs', authenticate, requirePermission('settings:update'), emailProviderController.createProviderConfig);
router.put('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.updateProviderConfig);
router.delete('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.deleteProviderConfig);
router.get('/configs', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfigs);
router.get('/configs/:id', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfig);
router.post('/configs', authenticate, requirePermission('email-providers:create'), emailProviderController.createProviderConfig);
router.put('/configs/:id', authenticate, requirePermission('email-providers:update'), emailProviderController.updateProviderConfig);
router.delete('/configs/:id', authenticate, requirePermission('email-providers:delete'), emailProviderController.deleteProviderConfig);
// Email Operations
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess);
router.post('/test-connection', authenticate, requirePermission('email-providers:update'), emailProviderController.testConnection);
router.post('/test-mail-access', authenticate, requirePermission('email-providers:update'), emailProviderController.testMailAccess);
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
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 * as platformController from '../controllers/platform.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
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.get('/:id', authenticate, platformController.getPlatform);
router.get('/:id', authenticate, requirePermission('platforms:read'), platformController.getPlatform);
router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform);
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.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
// Nested tariff routes
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
// Tarife unter dem Anbieter - eigene Rechte, siehe tariff.routes.ts
router.get('/:providerId/tariffs', authenticate, requirePermission('tariffs:read'), tariffController.getTariffs);
router.post('/:providerId/tariffs', authenticate, requirePermission('tariffs:create'), tariffController.createTariff);
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 * as tariffController from '../controllers/tariff.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
@@ -5,8 +11,8 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
const router = Router();
// Standalone tariff routes (for update/delete by tariff id)
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
router.get('/:id', authenticate, requirePermission('tariffs:read'), tariffController.getTariff);
router.put('/:id', authenticate, requirePermission('tariffs:update'), tariffController.updateTariff);
router.delete('/:id', authenticate, requirePermission('tariffs:delete'), tariffController.deleteTariff);
export default router;
+107 -34
View File
@@ -14,6 +14,8 @@ import {
} from '../utils/accessControl.js';
import { validateOptionalIsoDate } from '../utils/sanitize.js';
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
import { maybeCancelOnCancellationConfirmation, maybeMarkAwaitingConfirmationOnLetter } from '../services/contractStatusScheduler.service.js';
import { deleteFileAbsolute, deleteUploadByRelativePath } from '../utils/fileCleanup.js';
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
@@ -29,11 +31,6 @@ async function resolveInvoiceContractId(invoiceId: number): Promise<number | nul
return invoice?.contractId ?? invoice?.energyContractDetails?.contractId ?? null;
}
function cleanupFile(filePath?: string) {
if (!filePath) return;
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
const router = Router();
// Uploads-Verzeichnis erstellen falls nicht vorhanden
@@ -121,12 +118,12 @@ router.post(
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } });
if (!card) {
cleanupFile(req.file.path);
deleteFileAbsolute(req.file.path);
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
return;
}
if (!(await canAccessBankCard(req, res, bankCardId))) {
cleanupFile(req.file.path);
deleteFileAbsolute(req.file.path);
return;
}
@@ -148,7 +145,7 @@ router.post(
});
} catch (error) {
console.error('Upload error:', error);
cleanupFile(req.file?.path);
deleteFileAbsolute(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
@@ -173,12 +170,12 @@ router.post(
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } });
if (!doc) {
cleanupFile(req.file.path);
deleteFileAbsolute(req.file.path);
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
return;
}
if (!(await canAccessIdentityDocument(req, res, documentId))) {
cleanupFile(req.file.path);
deleteFileAbsolute(req.file.path);
return;
}
@@ -200,7 +197,7 @@ router.post(
});
} catch (error) {
console.error('Upload error:', error);
cleanupFile(req.file?.path);
deleteFileAbsolute(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
@@ -318,7 +315,7 @@ router.post(
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
deleteFileAbsolute(req.file.path);
return;
}
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
@@ -372,7 +369,7 @@ router.post(
const customerId = parseInt(req.params.id);
// Pentest 56.1: Ownership-Check.
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
deleteFileAbsolute(req.file.path);
return;
}
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
@@ -515,7 +512,7 @@ router.post(
// jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen
// (DSGVO-Eskalation).
if (!(await canAccessCustomer(req, res, customerId))) {
cleanupFile(req.file.path);
deleteFileAbsolute(req.file.path);
return;
}
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
@@ -673,7 +670,7 @@ async function handleContractDocumentUpload(
try {
provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate');
} catch (err) {
cleanupFile(req.file?.path);
deleteFileAbsolute(req.file?.path);
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Bestätigungsdatum' });
return;
}
@@ -694,23 +691,16 @@ async function handleContractDocumentUpload(
data: updateData,
});
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
await prisma.contract.update({
where: { id: contractId },
data: { status: 'CANCELLED' },
});
await logChange({
req,
action: 'UPDATE',
resourceType: 'Contract',
resourceId: contractId.toString(),
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
customerId: contract.customerId,
});
// Kündigungsbestätigung (nicht "Optionen") hochgeladen → Vertrag auf
// "Gekündigt / bestätigt" (CANCELLED_CONFIRMED) + Vertragsende = Kündigungs-
// datum. "Optionen" ist für Vertrags-Änderungen gedacht, nicht für Kündigungen.
if (fieldName === 'cancellationConfirmationPath') {
await maybeCancelOnCancellationConfirmation(contractId, req);
}
// Kündigungsschreiben hochgeladen → Vertrag auf "Gekündigt / Bestätigung
// abwarten" (CANCELLED), sofern noch nicht (bestätigt) gekündigt.
if (fieldName === 'cancellationLetterPath') {
await maybeMarkAwaitingConfirmationOnLetter(contractId, req);
}
res.json({
@@ -862,13 +852,13 @@ router.post(
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
if (!invoice) {
cleanupFile(req.file.path);
deleteFileAbsolute(req.file.path);
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
return;
}
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) {
cleanupFile(req.file.path);
deleteFileAbsolute(req.file.path);
return;
}
@@ -946,4 +936,87 @@ router.delete(
}
);
// Upload für Gutschrift-Überweisungsbeleg. Interner Bereich → Portal blocken.
router.post(
'/credit-notes/:id/receipt',
authenticate,
requirePermission('contracts:update'),
setUploadDir('credit-note-receipts'),
upload.single('document'),
validateUploadedFile,
async (req: AuthRequest, res: Response) => {
try {
if (req.user?.isCustomerPortal) {
deleteFileAbsolute(req.file?.path);
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return;
}
if (!req.file) {
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
return;
}
const id = parseInt(req.params.id);
const cn = await prisma.creditNote.findUnique({
where: { id },
select: { contractId: true, receiptPath: true },
});
if (!cn) {
deleteFileAbsolute(req.file.path);
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
return;
}
if (!(await canAccessContract(req, res, cn.contractId))) {
deleteFileAbsolute(req.file.path);
return;
}
const relativePath = `/uploads/credit-note-receipts/${req.file.filename}`;
deleteUploadByRelativePath(cn.receiptPath);
await prisma.creditNote.update({ where: { id }, data: { receiptPath: relativePath } });
await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
label: 'Überweisungsbeleg zur Gutschrift hochgeladen',
});
res.json({ success: true, data: { receiptPath: relativePath } });
} catch (error) {
console.error('Credit-note receipt upload error:', error);
deleteFileAbsolute(req.file?.path);
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
}
}
);
router.delete(
'/credit-notes/:id/receipt',
authenticate,
requirePermission('contracts:update'),
async (req: AuthRequest, res: Response) => {
try {
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Kein Zugriff' });
return;
}
const id = parseInt(req.params.id);
const cn = await prisma.creditNote.findUnique({
where: { id },
select: { contractId: true, receiptPath: true },
});
if (!cn) {
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
return;
}
if (!(await canAccessContract(req, res, cn.contractId))) return;
deleteUploadByRelativePath(cn.receiptPath);
await prisma.creditNote.update({ where: { id }, data: { receiptPath: null } });
await logChange({
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
label: 'Überweisungsbeleg zur Gutschrift entfernt',
});
res.json({ success: true });
} catch (error) {
console.error('Credit-note receipt delete error:', error);
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
}
}
);
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.
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
// Roles
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
router.post('/roles', authenticate, requirePermission('users:create'), userController.createRole);
router.get('/roles/:id', authenticate, requirePermission('users:read'), userController.getRole);
router.put('/roles/:id', authenticate, requirePermission('users:update'), userController.updateRole);
router.delete('/roles/:id', authenticate, requirePermission('users:delete'), userController.deleteRole);
// Rollen und Rechte.
//
// Die Schreibwege haengen an `roles:manage`, nicht mehr an `users:*`:
// "Konten anlegen" und "festlegen, was ein Konto darf" sind zwei
// verschiedene Befugnisse. Wer beides hatte, konnte sich eine Rolle mit
// `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
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;
+19 -1
View File
@@ -12,6 +12,10 @@ const DEFAULT_SETTINGS: Record<string, string> = {
// Ausweis-Ablauf: Fristenschwellen (in Tagen)
documentExpiryCriticalDays: '30', // Rot: Kritisch (Standard 30 Tage)
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
// BLZ-/Bankdaten-Auto-Update (Bundesbank-Bankleitzahlen via bankdata-germany).
// Der Updater lädt nur eine öffentliche Datendatei keine Kundendaten.
blzAutoUpdateEnabled: 'true', // Auto-Update an/aus
blzUpdateIntervalDays: '30', // Prüf-/Update-Intervall in Tagen
};
// Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE
@@ -90,8 +94,22 @@ export function validateSettingValue(key: string, rawValue: string): { ok: true;
return { ok: true, value: trimmed };
}
// BLZ-Update-Intervall: mind. 1 Tag, höchstens 365 (verhindert Dauer-Polling
// bzw. faktisch nie).
if (key === 'blzUpdateIntervalDays') {
const trimmed = rawValue.trim();
if (!/^\d+$/.test(trimmed)) {
return { ok: false, error: 'Das Intervall muss eine positive ganze Zahl (Tage) sein.' };
}
const n = parseInt(trimmed, 10);
if (n < 1 || n > 365) {
return { ok: false, error: 'Das Intervall muss zwischen 1 und 365 Tagen liegen.' };
}
return { ok: true, value: String(n) };
}
// Bool-Settings
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled') {
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled' || key === 'blzAutoUpdateEnabled') {
const trimmed = rawValue.trim().toLowerCase();
if (trimmed !== 'true' && trimmed !== 'false') {
return { ok: false, error: `${key} muss 'true' oder 'false' sein.` };
File diff suppressed because it is too large Load Diff
+269 -23
View File
@@ -1,4 +1,5 @@
import prisma from '../lib/prisma.js';
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
@@ -23,10 +24,194 @@ export function signAccessToken(payload: JwtPayload): string {
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
});
}
export function signRefreshToken(payload: JwtPayload): string {
return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
export function signRefreshToken(payload: JwtPayload, jti?: string, familyId?: string): string {
return jwt.sign(
{ ...payload, type: 'refresh', ...(jti ? { jti } : {}), ...(familyId ? { fam: familyId } : {}) },
process.env.JWT_SECRET as string,
{ expiresIn: REFRESH_TOKEN_EXPIRES_IN },
);
}
// ===================== Refresh-Token-Replay-Schutz (R164-02) =====================
//
// Kulanzfenster: Mehrere Tabs teilen sich denselben Cookie und koennen einen
// Refresh praktisch gleichzeitig ausloesen. Ohne Toleranz wuerde der zweite
// legitime Aufruf als Replay gewertet und die Sitzung gesprengt. Die Toleranz
// ist bewusst eng (Sekunden UND Anzahl begrenzt): ein Burst mit vielen
// Wiederverwendungen - das typische Bild beim Missbrauch eines gestohlenen
// Tokens - schlaegt weiterhin an.
const REFRESH_REUSE_GRACE_MS = 15_000;
const REFRESH_REUSE_MAX = 3;
/** Legt einen neuen Sitzungs-Refresh-Token an (neue Familie). */
export async function issueRefreshToken(
payload: JwtPayload,
opts: {
userId?: number;
customerId?: number;
isCustomerPortal: boolean;
familyId?: string;
/** Beim Rotieren bereits beim Einloesen reservierte jti des Nachfolgers. */
jti?: string;
},
): Promise<string> {
const jti = opts.jti || crypto.randomUUID();
const familyId = opts.familyId || crypto.randomUUID();
const token = signRefreshToken(payload, jti, familyId);
const decoded: any = jwt.decode(token);
await prisma.refreshTokenRecord.create({
data: {
jti,
familyId,
userId: opts.userId ?? null,
customerId: opts.customerId ?? null,
isCustomerPortal: opts.isCustomerPortal,
expiresAt: new Date((decoded?.exp ?? 0) * 1000),
},
});
return token;
}
/** Widerruft eine komplette Sitzungsfamilie. */
async function revokeFamily(familyId: string, reason: string): Promise<void> {
await prisma.refreshTokenRecord.updateMany({
where: { familyId, revokedAt: null },
data: { revokedAt: new Date(), revokedReason: reason },
});
}
/** Widerruft alle Familien eines Subjekts (Logout, Rechteaenderung). */
export async function revokeAllRefreshTokens(
subject: { userId?: number; customerId?: number },
reason: string,
): Promise<void> {
const where = subject.userId != null ? { userId: subject.userId } : { customerId: subject.customerId };
await prisma.refreshTokenRecord.updateMany({
where: { ...where, revokedAt: null },
data: { revokedAt: new Date(), revokedReason: reason },
});
}
/** Abgelaufene Datensaetze entfernen (beim Login, seltener als beim Refresh). */
async function pruneExpiredRefreshTokens(): Promise<void> {
try {
await prisma.refreshTokenRecord.deleteMany({ where: { expiresAt: { lt: new Date() } } });
} catch {
// Aufraeumen darf den Login nie blockieren.
}
}
/**
* Prueft die jti eines vorgelegten Refresh-Tokens und verbraucht sie.
* Wirft mit `code = 'REFRESH_REPLAY'`, wenn ein bereits eingeloester Token
* erneut auftaucht - der Controller meldet das als Sicherheitsvorfall.
*/
async function consumeRefreshJti(decoded: any): Promise<{
familyId: string;
/** Frische Rotation: Datensatz fuer diese jti muss noch angelegt werden. */
issueJti?: string;
/** Kulanz: dieser bereits ausgestellte Nachfolger wird erneut ausgegeben. */
reuseJti?: string;
}> {
const jti: string | undefined = decoded?.jti;
const fam: string | undefined = decoded?.fam;
if (!jti || !fam) {
// Token aus der Zeit vor dem Replay-Schutz: bewusst fail-closed, damit
// kein Altbestand die Einmalverwendung umgehen kann. Kostet einmalig
// eine Neuanmeldung.
const err: any = new Error('Sitzung muss erneuert werden bitte neu anmelden');
err.code = 'REFRESH_LEGACY';
throw err;
}
const rec = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
if (!rec) {
const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
err.code = 'REFRESH_INVALID';
throw err;
}
if (rec.revokedAt) {
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
err.code = 'REFRESH_REVOKED';
throw err;
}
// Einloesen MUSS atomar sein. Ein Lesen-dann-Schreiben wuerde bei parallelen
// Anfragen nichts verhindern: alle wuerden den Token als "noch unbenutzt"
// sehen und durchgelassen (im Test kamen 90 gleichzeitige Requests
// ausnahmslos durch). Deshalb wird der Zustandswechsel als bedingtes UPDATE
// ausgefuehrt - die Datenbank entscheidet, wer zuerst war.
// Die jti des Nachfolgers wird SCHON HIER festgelegt und im selben UPDATE
// hinterlegt. Dadurch weiss eine spaetere Wiedervorlage, welcher Nachfolger
// bereits ausgestellt wurde - Grundlage der idempotenten Kulanz (R168-01).
const nachfolgerJti = crypto.randomUUID();
const beansprucht = await prisma.refreshTokenRecord.updateMany({
where: { jti, usedAt: null, revokedAt: null },
data: { usedAt: new Date(), replacedByJti: nachfolgerJti },
});
if (beansprucht.count === 1) {
return { familyId: rec.familyId, issueJti: nachfolgerJti };
}
// Bereits eingeloest. Innerhalb des engen Kulanzfensters begrenzt tolerieren
// (parallele Tabs) - als bedingtes UPDATE, damit die Obergrenze unter Last
// wirklich haelt.
const fensterAb = new Date(Date.now() - REFRESH_REUSE_GRACE_MS);
const toleriert = await prisma.refreshTokenRecord.updateMany({
where: {
jti,
revokedAt: null,
usedAt: { gte: fensterAb },
reuseCount: { lt: REFRESH_REUSE_MAX },
},
data: { reuseCount: { increment: 1 } },
});
if (toleriert.count === 1) {
// IDEMPOTENT: denselben, bereits ausgestellten Nachfolger zurueckgeben -
// NICHT erneut rotieren (Pentest R168-01).
//
// Vorher entstand bei jeder Kulanz-Wiedervorlage eine frische Linie mit
// eigenem, zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token
// konnte damit aus dem erkennbaren Replay-Zustand in eine eigene,
// sauber weiterrotierende Sitzung entkommen, die nie wieder mit der des
// Opfers kollidiert - also dauerhaft unsichtbar blieb.
//
// Jetzt laufen parallele Tabs auf DIESELBE Linie zusammen. Wer den Token
// spaeter erneut vorlegt (also ausserhalb des Fensters), kollidiert
// zwangslaeufig und loest den Familien-Widerruf aus.
const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
if (aktuell?.replacedByJti) {
const nachfolger = await prisma.refreshTokenRecord.findUnique({
where: { jti: aktuell.replacedByJti },
});
if (nachfolger?.revokedAt) {
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
err.code = 'REFRESH_REVOKED';
throw err;
}
return { familyId: rec.familyId, reuseJti: aktuell.replacedByJti };
}
// Kein Nachfolger hinterlegt: nicht ersatzweise rotieren (das waere genau
// der Fork). Fail-closed als Replay behandeln.
}
// Weder frei noch tolerierbar: War der Token zwischenzeitlich widerrufen
// (z. B. durch einen parallelen Replay-Fund), ist das kein neuer Vorfall.
const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
if (aktuell?.revokedAt) {
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
err.code = 'REFRESH_REVOKED';
throw err;
}
// Replay: gesamte Familie widerrufen - der Angreifer verliert den Zugang,
// der legitime Nutzer muss sich neu anmelden und merkt es dadurch.
await revokeFamily(rec.familyId, 'Replay eines bereits eingelösten Refresh-Tokens');
const err: any = new Error('Refresh-Token wurde bereits verwendet Sitzung aus Sicherheitsgründen beendet');
err.code = 'REFRESH_REPLAY';
throw err;
}
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
@@ -133,8 +318,10 @@ export async function login(email: string, password: string) {
isCustomerPortal: false,
};
await pruneExpiredRefreshTokens();
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
// Neue Sitzungsfamilie (Replay-Schutz, R164-02)
const refreshToken = await issueRefreshToken(payload, { userId: user.id, isCustomerPortal: false });
return {
accessToken,
@@ -147,6 +334,9 @@ export async function login(email: string, password: string) {
permissions: Array.from(permissions),
customerId: user.customerId,
isCustomerPortal: false,
// Wird von der Audit-Middleware gelesen, um planmaessige
// Dienstkonto-Anmeldungen als Routine einzustufen (R182).
isServiceAccount: user.isServiceAccount,
},
};
}
@@ -231,10 +421,9 @@ export async function customerLogin(email: string, password: string) {
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
// Kundenportal-Berechtigungen (eingeschränkt)
const customerPermissions = [
'contracts:read', // Eigene Verträge lesen
'customers:read', // Eigene Kundendaten lesen
];
// Feste Portal-Rechte aus config/rechte-katalog.ts. Bewusst getrennt vom
// Rollensystem - siehe den Kommentar dort.
const customerPermissions = [...PORTAL_RECHTE];
const payload: JwtPayload = {
email: customer.portalEmail!,
@@ -244,8 +433,10 @@ export async function customerLogin(email: string, password: string) {
representedCustomerIds,
};
await pruneExpiredRefreshTokens();
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
// Neue Sitzungsfamilie (Replay-Schutz, R164-02)
const refreshToken = await issueRefreshToken(payload, { customerId: customer.id, isCustomerPortal: true });
return {
accessToken,
@@ -286,12 +477,32 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
algorithms: ['HS256'],
});
} catch {
throw new Error('Refresh-Token ungültig oder abgelaufen');
} catch (e) {
// Code erhalten, damit der Controller abgelaufen (benigne, LOW) von
// manipuliert/ungültiger Signatur (verdächtig, HIGH) trennen kann.
const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
err.code = e instanceof jwt.TokenExpiredError ? 'REFRESH_EXPIRED' : 'REFRESH_INVALID';
throw err;
}
if (decoded.type !== 'refresh') {
throw new Error('Falscher Token-Typ');
}
// Einmalverwendung durchsetzen und Sitzungsfamilie bestimmen (R164-02).
// Wirft bei Replay danach ist die gesamte Familie widerrufen.
const rotation = await consumeRefreshJti(decoded);
// Bei Kulanz wird derselbe, bereits ausgestellte Nachfolger erneut signiert -
// ohne neuen Datensatz, damit keine zweite Linie entsteht (R168-01).
const naechsterRefreshToken = async (
payload: JwtPayload,
subjekt: { userId?: number; customerId?: number; isCustomerPortal: boolean },
): Promise<string> =>
rotation.reuseJti
? signRefreshToken(payload, rotation.reuseJti, rotation.familyId)
: issueRefreshToken(payload, {
...subjekt,
familyId: rotation.familyId,
jti: rotation.issueJti,
});
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
// Mitarbeiter
@@ -321,7 +532,9 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
// Nachfolger bleibt in derselben Familie ein Replay des Vorgaengers
// sprengt damit auch alle daraus entstandenen Tokens.
refreshToken: await naechsterRefreshToken(payload, { userId: user.id, isCustomerPortal: false }),
user: {
id: user.id,
email: user.email,
@@ -352,7 +565,10 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
};
return {
accessToken: signAccessToken(payload),
refreshToken: signRefreshToken(payload),
refreshToken: await naechsterRefreshToken(payload, {
customerId: customer.id,
isCustomerPortal: true,
}),
user: portalUser,
};
}
@@ -408,23 +624,56 @@ export async function markPortalPasswordForChange(customerId: number) {
});
}
// Kundenportal-Passwort im Klartext abrufen
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
// Ergebnis des Klartext-Passwort-Abrufs.
// - 'ok' → password ist das gültige Klartext-Passwort
// - 'none' → kein verschlüsseltes Passwort hinterlegt (z.B. self-service
// gesetzt) → nichts zum Anzeigen/Versenden
// - 'desync' → verschlüsseltes Passwort vorhanden, passt aber NICHT zum
// Login-Hash (oder Entschlüsselung fehlgeschlagen). Darf NIE
// ausgegeben werden sonst liest der Support ein Passwort vor,
// das beim Login scheitert (Pentest-Hinweis).
export type PortalPasswordReveal =
| { status: 'ok'; password: string }
| { status: 'none' }
| { status: 'desync' };
// Kundenportal-Passwort im Klartext abrufen mit Konsistenzprüfung gegen den
// Login-Hash, damit niemals ein „falsches" (out-of-sync) Passwort herausgeht.
export async function getCustomerPortalPassword(customerId: number): Promise<PortalPasswordReveal> {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { portalPasswordEncrypted: true },
select: { portalPasswordEncrypted: true, portalPasswordHash: true },
});
if (!customer?.portalPasswordEncrypted) {
return null;
return { status: 'none' };
}
let decrypted: string;
try {
return decrypt(customer.portalPasswordEncrypted);
decrypted = decrypt(customer.portalPasswordEncrypted);
} catch (error) {
console.error('Fehler beim Entschlüsseln des Passworts:', error);
return null;
return { status: 'desync' };
}
// Ohne Login-Hash ist ein Login gar nicht möglich ein gespeichertes
// Klartext-Passwort wäre irreführend (Support würde etwas vorlesen, das
// nirgends funktioniert). Als inkonsistent behandeln (Pentest R143).
if (!customer.portalPasswordHash) {
console.warn(`[getCustomerPortalPassword] Desync: verschlüsseltes Passwort ohne Login-Hash (Kunde #${customerId})`);
return { status: 'desync' };
}
// Muss zum Login-Hash passen sonst ist der gespeicherte Klartext veraltet
// und würde beim Login fehlschlagen.
const matches = await bcrypt.compare(decrypted, customer.portalPasswordHash);
if (!matches) {
console.warn(`[getCustomerPortalPassword] Desync: gespeichertes Passwort passt nicht zum Login-Hash (Kunde #${customerId})`);
return { status: 'desync' };
}
return { status: 'ok', password: decrypted };
}
export async function createUser(data: {
@@ -542,10 +791,7 @@ export async function getCustomerPortalUser(customerId: number) {
if (!customer || !customer.portalEnabled) return null;
const customerPermissions = [
'contracts:read',
'customers:read',
];
const customerPermissions = [...PORTAL_RECHTE];
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
// 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 AdmZip from 'adm-zip';
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
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)
// Berechtigungen - muss mit seed.ts übereinstimmen!
const resourcePermissions: Record<string, string[]> = {
// Haupt-Ressourcen (CRUD)
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'],
// Lookup-Tabellen (nur lesen)
'contract-categories': ['read'],
'cancellation-periods': ['read'],
'contract-durations': ['read'],
// Einstellungen (nur lesen/ändern)
settings: ['read', 'update'],
// Spezial-Permissions
developer: ['access'],
emails: ['delete'],
};
// ==================== RECHTE UND ROLLEN ====================
// Aus derselben Definition wie Seed und Container-Start
// (config/rechte-katalog.ts).
//
// Hier stand bis 09/2026 eine DRITTE Kopie des Katalogs, mit dem
// Kommentar "muss mit seed.ts uebereinstimmen!" darueber - und sie stimmte
// nicht: Die Lookup-Tabellen hatten nur `read`, `email-providers`,
// `audit` und `gdpr` fehlten ganz, und von den Rollen wurden nur fuenf
// angelegt. DSGVO, Audit-Betrieb und Gegenbuch gab es nach einem
// Werksreset nicht mehr.
//
// Die Folge war kein Schoenheitsfehler: Ohne die DSGVO-Rolle kann
// niemand eine Auskunft nach Art. 15 oder eine Loeschung nach Art. 17
// ausfuehren. Ein Werksreset setzte damit stillschweigend die
// Handlungsfaehigkeit fuer Betroffenenrechte aus, und aufgefallen waere
// es erst, wenn eine Frist laeuft.
await synchronisiereRechteUndRollen(prisma, (zeile) =>
console.log(`[FactoryReset] ${zeile}`),
);
for (const [resource, actions] of Object.entries(resourcePermissions)) {
for (const action of actions) {
await prisma.permission.create({
data: { resource, action },
});
}
}
console.log('[FactoryReset] Berechtigungen erstellt');
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
console.log('[FactoryReset] Rechte und Rollen erstellt');
// Admin-Rolle mit allen Berechtigungen (außer developer:access)
const allPermissions = await prisma.permission.findMany();
const adminRole = await prisma.role.create({
data: {
name: 'Admin',
description: 'Voller Zugriff auf alle Funktionen',
permissions: {
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
// Standard Admin-Benutzer erstellen.
//
// Das Kennwort war hier bis 09/2026 fest auf "admin" verdrahtet, mit
// bcrypt-Cost 10. Genau das verbietet seed.ts seit Pentest Runde 12
// ausdruecklich - und wieder war die Haertung nur in einer der beiden
// Kopien angekommen. Wer einen Werksreset ausloest, bekommt jetzt ein
// zufaelliges Kennwort, das genau einmal im Log erscheint.
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
const hashedPassword = await bcrypt.hash('admin', 10);
console.log('[FactoryReset] Passwort gehasht, Admin-Rolle ID:', adminRole.id);
const adminPlainPassword = erzeugeInitialKennwort();
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
const adminUser = await prisma.user.create({
data: {
@@ -1298,11 +1253,21 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
password: hashedPassword,
firstName: 'Admin',
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: {
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);
// Standard Kündigungsfristen (wie in seed.ts)
+390
View File
@@ -0,0 +1,390 @@
import fs from 'fs';
import path from 'path';
import { getSetting, getSettingBool, setSetting } from './appSetting.service.js';
/**
* BLZ-/Bankdaten-Service.
*
* Liefert BIC + Banknamen zu einer deutschen BLZ/IBAN. Zwei Datenquellen,
* in dieser Reihenfolge:
* 1. VOLUME ein zur Laufzeit aktualisierter Datensatz unter BANKDATA_DIR
* (Bind-Mount, siehe docker-compose). Wird vom Auto-Updater
* befüllt.
* 2. BUILTIN die ins Image gebackenen JSON-Daten des npm-Pakets
* `bankdata-germany` (Fallback, immer vorhanden).
*
* Es wird KEIN Paketcode ausgeführt nur die reinen JSON-Datendateien
* (current.json = { "<BLZ>": ["Name","BIC"] }, next.json = Delta der nächsten
* Periode) werden gelesen und mit eigener Logik indiziert.
*
* Datenschutz: Beim Lookup verlässt KEINE IBAN den Server. Nur der
* Auto-Updater macht ausgehende Requests und lädt dabei lediglich eine
* öffentliche Datendatei (keine Kundendaten).
*/
// Kompiliert nach CommonJS das native `require` ist zur Laufzeit verfügbar
// und wird nur genutzt, um den Pfad der gebackenen Paketdaten aufzulösen.
declare const require: NodeRequire;
// ---- Pfade / Quellen (per Env überschreibbar) ----
const BANKDATA_DIR = process.env.BANKDATA_DIR || path.join(process.cwd(), 'bankdata');
const DATASET_FILE = path.join(BANKDATA_DIR, 'blz-dataset.json');
const PACKAGE = 'bankdata-germany';
const CDN_BASE = process.env.BLZ_CDN_BASE || 'https://cdn.jsdelivr.net/npm';
const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.org';
const FETCH_TIMEOUT_MS = 20_000;
// ---- Typen ----
type BankTuple = [string, string?]; // [bankName, bic?] BIC fehlt bei manchen Banken
type CurrentData = Record<string, BankTuple>;
interface NextData {
valid: string;
upsert: Record<string, BankTuple>;
remove: string[];
}
interface RawDataset {
current: CurrentData;
next: NextData;
}
interface LoadedDataset extends RawDataset {
source: 'volume' | 'builtin';
version: string;
fetchedAt: string | null;
}
export interface BankInfo {
bankName: string;
bic: string;
blz: string;
}
// ---- In-Memory-Cache (per mtime invalidiert) ----
let cache: { dataset: LoadedDataset; combined: CurrentData; key: string } | null = null;
function builtinDataDir(): string {
// require.resolve liefert .../dist/cjs/main.js → data/ liegt daneben.
const main = require.resolve(PACKAGE);
return path.join(path.dirname(main), 'data');
}
function builtinVersion(): string {
try {
const main = require.resolve(PACKAGE);
// main = <root>/dist/cjs/main.js → package.json zwei Ebenen höher.
const pkgPath = path.join(path.dirname(main), '..', '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
return typeof pkg.version === 'string' ? pkg.version : 'unbekannt';
} catch {
return 'unbekannt';
}
}
function readJson(file: string): any {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/**
* Lädt den aktiven Datensatz (Volume bevorzugt, sonst Builtin). Cached anhand
* der mtime der Volume-Datei bzw. eines statischen Keys für Builtin.
*/
function loadDataset(): LoadedDataset {
// Volume vorhanden?
let volumeMtime: number | null = null;
try {
volumeMtime = fs.statSync(DATASET_FILE).mtimeMs;
} catch {
volumeMtime = null;
}
const key = volumeMtime !== null ? `volume:${volumeMtime}` : 'builtin';
if (cache && cache.key === key) return cache.dataset;
let dataset: LoadedDataset;
if (volumeMtime !== null) {
try {
const raw: any = readJson(DATASET_FILE);
// Metadaten VOR der Assertion lesen (die narrowt raw auf RawDataset).
const version = typeof raw.version === 'string' ? raw.version : 'unbekannt';
const fetchedAt = typeof raw.fetchedAt === 'string' ? raw.fetchedAt : null;
assertValidRawDataset(raw);
dataset = {
source: 'volume',
version,
fetchedAt,
current: raw.current,
next: raw.next,
};
} catch (err) {
console.error('[BLZ] Volume-Datensatz unlesbar, nutze Builtin:', err);
dataset = loadBuiltin();
}
} else {
dataset = loadBuiltin();
}
const combined = combine(dataset);
cache = { dataset, combined, key };
return dataset;
}
function loadBuiltin(): LoadedDataset {
const dir = builtinDataDir();
const current = readJson(path.join(dir, 'current.json')) as CurrentData;
const next = readJson(path.join(dir, 'next.json')) as NextData;
return { source: 'builtin', version: builtinVersion(), fetchedAt: null, current, next };
}
/**
* Kombiniert current + next-Delta, wenn das aktuelle Datum den Gültig-ab-
* Zeitpunkt der nächsten Periode erreicht hat (identische Logik wie das
* npm-Paket).
*/
function combine(ds: RawDataset): CurrentData {
const validFrom = new Date(ds.next?.valid);
if (ds.next && !Number.isNaN(validFrom.getTime()) && new Date() >= validFrom) {
const merged: CurrentData = { ...ds.current, ...ds.next.upsert };
for (const blz of ds.next.remove || []) delete merged[blz];
return merged;
}
return ds.current;
}
function combinedData(): CurrentData {
loadDataset();
return cache!.combined;
}
// ---- Lookup ----
export function lookupBlz(blz: string): BankInfo | null {
if (!/^[1-9]\d{7}$/.test(blz)) return null;
const entry = combinedData()[blz];
if (!entry) return null;
return { bankName: entry[0], bic: entry[1] ?? '', blz };
}
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
export function lookupByIban(iban: string): BankInfo | null {
if (!/^DE\d{20}$/i.test(iban)) return null;
// IBAN: DE + 2 Prüfziffern + 8 BLZ + 10 Kontonummer
return lookupBlz(iban.slice(4, 12));
}
// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ----
// Prüft eine BLZ→[Name,BIC]-Map vollständig: JEDER Schlüssel eine 8-stellige
// BLZ, JEDER Wert genau [string, string] (BIC darf leer sein). Nicht nur die
// erste Zeile (Pentest R148, Code-Note) sonst käme ein Set mit 1 echten +
// 999 Fake-Einträgen durch.
function assertValidBankMap(map: Record<string, unknown>, label: string): void {
for (const [blz, entry] of Object.entries(map)) {
if (!/^\d{8}$/.test(blz)) {
throw new Error(`${label}: ungültige BLZ "${blz}"`);
}
// Wert ist [Name] (Bank ohne BIC) ODER [Name, BIC]. Beides ist in der
// echten Bundesbank-Datei vorhanden (z.B. reine Zahlungsverkehr-BLZ).
if (
!Array.isArray(entry) ||
entry.length < 1 ||
entry.length > 2 ||
typeof entry[0] !== 'string' ||
(entry.length === 2 && typeof entry[1] !== 'string')
) {
throw new Error(`${label}: Eintrag ${blz} hat unerwartetes Format`);
}
}
}
function assertValidRawDataset(raw: any): asserts raw is RawDataset {
if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
const cur = raw.current;
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
const keys = Object.keys(cur);
if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
// VOLLSTÄNDIGE Formatprüfung aller Einträge, nicht nur des ersten.
assertValidBankMap(cur, 'current');
const next = raw.next;
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || Array.isArray(next.upsert) || !Array.isArray(next.remove)) {
throw new Error('next fehlt/ungültig');
}
// Datum plausibel?
if (Number.isNaN(new Date(next.valid).getTime())) {
throw new Error('next.valid ist kein gültiges Datum');
}
// upsert-Einträge ebenso streng; remove muss aus 8-stelligen BLZ bestehen.
assertValidBankMap(next.upsert, 'next.upsert');
for (const blz of next.remove) {
if (typeof blz !== 'string' || !/^\d{8}$/.test(blz)) {
throw new Error(`next.remove enthält ungültige BLZ "${blz}"`);
}
}
}
// ---- Ausgehende Requests (nur Updater) ----
async function fetchJson(url: string): Promise<any> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: { Accept: 'application/json', 'User-Agent': 'OpenCRM-BLZ-Updater' },
});
if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`);
return await res.json();
} finally {
clearTimeout(t);
}
}
// Erlaubtes Versionsformat (semver-Kern, rein numerisch). Die Version wird in
// die CDN-URL interpoliert ohne strikte Prüfung könnte eine manipulierte
// Registry-Antwort (`../`, Slashes, Query-Zeichen) den Pfad verbiegen. Der Host
// bleibt zwar fix (CDN_BASE), aber wir lassen nur `1.2.3` zu.
const VERSION_RE = /^\d{1,6}\.\d{1,6}\.\d{1,6}$/;
/** Ermittelt die neueste verfügbare Paket-Version (npm dist-tag latest). */
export async function fetchLatestVersion(): Promise<string> {
const manifest = await fetchJson(`${REGISTRY_BASE}/${PACKAGE}/latest`);
if (!manifest || typeof manifest.version !== 'string') throw new Error('Registry lieferte keine Version');
if (!VERSION_RE.test(manifest.version)) {
throw new Error(`Unerwartetes Versionsformat: ${manifest.version}`);
}
return manifest.version;
}
// ---- Versionsvergleich (numerische Segmente, z.B. 2.2603.0) ----
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
function activeVersion(): string {
return loadDataset().version;
}
// ---- Update-Durchführung ----
let updating = false;
export interface UpdateResult {
changed: boolean;
version: string;
message: string;
}
/**
* Lädt (falls neuer) den aktuellen Datensatz von der CDN ins Volume.
* `force` lädt auch bei gleicher Version neu.
*/
export async function runUpdate(force = false): Promise<UpdateResult> {
if (updating) {
return { changed: false, version: activeVersion(), message: 'Update läuft bereits.' };
}
updating = true;
const nowIso = new Date().toISOString();
try {
const latest = await fetchLatestVersion();
await setSetting('blzLatestVersion', latest);
await setSetting('blzLastCheckedAt', nowIso);
const current = activeVersion();
if (!force && current !== 'unbekannt' && compareVersions(latest, current) <= 0) {
await setSetting('blzLastError', '');
return { changed: false, version: current, message: `Bereits aktuell (${current}).` };
}
// Datendateien der Zielversion laden.
const base = `${CDN_BASE}/${PACKAGE}@${latest}/dist/cjs/data`;
const [currentData, nextData] = await Promise.all([
fetchJson(`${base}/current.json`),
fetchJson(`${base}/next.json`),
]);
const raw = { current: currentData, next: nextData };
assertValidRawDataset(raw); // wirft bei Müll → kein Überschreiben
const payload = JSON.stringify({
version: latest,
fetchedAt: nowIso,
source: 'cdn',
current: currentData,
next: nextData,
});
fs.mkdirSync(BANKDATA_DIR, { recursive: true });
const tmp = `${DATASET_FILE}.tmp`;
fs.writeFileSync(tmp, payload, 'utf8');
fs.renameSync(tmp, DATASET_FILE); // atomar
cache = null; // Cache invalidieren → nächster Lookup lädt neu
await setSetting('blzLastUpdatedAt', nowIso);
await setSetting('blzLastError', '');
console.log(`[BLZ] Datensatz aktualisiert auf ${latest} (${Object.keys(currentData).length} Einträge).`);
return { changed: true, version: latest, message: `Aktualisiert auf ${latest}.` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await setSetting('blzLastError', msg).catch(() => {});
await setSetting('blzLastCheckedAt', nowIso).catch(() => {});
console.error('[BLZ] Update fehlgeschlagen:', msg);
throw err instanceof Error ? err : new Error(msg);
} finally {
updating = false;
}
}
// ---- Status für die Einstellungen-Seite ----
export interface BlzStatus {
activeSource: 'volume' | 'builtin';
activeVersion: string;
builtinVersion: string;
entryCount: number;
nextValidFrom: string | null;
fetchedAt: string | null;
autoUpdateEnabled: boolean;
intervalDays: number;
lastCheckedAt: string | null;
lastUpdatedAt: string | null;
latestVersion: string | null;
updateAvailable: boolean;
lastError: string | null;
}
export async function getStatus(): Promise<BlzStatus> {
const ds = loadDataset();
const combined = combinedData();
const autoUpdateEnabled = await getSettingBool('blzAutoUpdateEnabled');
const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
const lastCheckedAt = (await getSetting('blzLastCheckedAt')) || null;
const lastUpdatedAt = (await getSetting('blzLastUpdatedAt')) || null;
const latestVersion = (await getSetting('blzLatestVersion')) || null;
const lastErrorRaw = (await getSetting('blzLastError')) || '';
const updateAvailable =
!!latestVersion && ds.version !== 'unbekannt' && compareVersions(latestVersion, ds.version) > 0;
return {
activeSource: ds.source,
activeVersion: ds.version,
builtinVersion: builtinVersion(),
entryCount: Object.keys(combined).length,
nextValidFrom: ds.next?.valid || null,
fetchedAt: ds.fetchedAt,
autoUpdateEnabled,
intervalDays,
lastCheckedAt,
lastUpdatedAt,
latestVersion,
updateAvailable,
lastError: lastErrorRaw || null,
};
}
@@ -0,0 +1,51 @@
/**
* Scheduler für das automatische BLZ-/Bankdaten-Update.
*
* Prüft täglich (03:30) sowie 90s nach Start, ob ein Update fällig ist:
* Auto-Update aktiv UND (noch nie aktualisiert ODER letzte Aktualisierung
* länger her als das eingestellte Intervall in Tagen). Trifft das zu, wird
* der neueste Datensatz geladen (siehe blzData.service).
*
* Das eigentliche Intervall ist in den Einstellungen konfigurierbar
* (`blzUpdateIntervalDays`); der Cron-Tick prüft nur die Fälligkeit.
*/
import cron from 'node-cron';
import { getSetting, getSettingBool } from './appSetting.service.js';
import { runUpdate } from './blzData.service.js';
async function maybeRun(): Promise<void> {
const enabled = await getSettingBool('blzAutoUpdateEnabled');
if (!enabled) {
return;
}
const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
const lastUpdatedAt = await getSetting('blzLastUpdatedAt');
if (lastUpdatedAt) {
const last = new Date(lastUpdatedAt).getTime();
if (!Number.isNaN(last)) {
const ageDays = (Date.now() - last) / 86_400_000;
if (ageDays < intervalDays) {
return; // noch nicht fällig
}
}
}
console.log('[BLZ-Scheduler] Update fällig prüfe auf neueren Datensatz…');
await runUpdate(false);
}
export function startBlzUpdateScheduler(): void {
// Täglich um 03:30 (Server-Zeit) Fälligkeit prüfen.
cron.schedule('30 3 * * *', () => {
maybeRun().catch((err) => console.error('[BLZ-Scheduler] Lauf fehlgeschlagen:', err));
});
// Catch-up 90s nach Start.
setTimeout(() => {
maybeRun().catch((err) => console.error('[BLZ-Scheduler] Catch-up fehlgeschlagen:', err));
}, 90_000);
console.log('[BLZ-Scheduler] Gestartet tägliche Fälligkeitsprüfung + Catch-up nach 90s');
}
@@ -0,0 +1,28 @@
// ==================== FIRMENSTAMMDATEN (ABSENDER) ====================
// Einzel-Zeile. Fließt in Gutschrift-PDF + ZUGFeRD-XML (Verkäufer) ein.
import prisma from '../lib/prisma.js';
import { Prisma } from '@prisma/client';
const EDITABLE_STRING_FIELDS = [
'name', 'street', 'houseNumber', 'postalCode', 'city', 'country',
'vatId', 'taxNumber', 'commercialRegister', 'managingDirector',
'email', 'phone', 'website', 'iban', 'bic', 'bankName',
] as const;
export async function getOrCreateProfile() {
const existing = await prisma.companyProfile.findFirst();
if (existing) return existing;
return prisma.companyProfile.create({ data: {} });
}
export async function updateProfile(input: Record<string, unknown>) {
const profile = await getOrCreateProfile();
const data: Prisma.CompanyProfileUpdateInput = {};
for (const field of EDITABLE_STRING_FIELDS) {
if (typeof input[field] === 'string') {
(data as Record<string, unknown>)[field] = (input[field] as string).slice(0, 191).trim();
}
}
return prisma.companyProfile.update({ where: { id: profile.id }, data });
}

Some files were not shown because too many files have changed in this diff Show More