# 📋 OpenCRM – Todo-Liste --- ## 🔜 Offen ### Manuelle Tests (vor Release durchklicken) Checklisten fĂŒr Security + Email-Log-System stehen in **[TESTING.md](./TESTING.md)**. Einmal komplett durchlaufen vor v1.0.0-Release. ### 🚀 SaaS-Ausbau: Instance-per-Customer + Admin-Portal + GoCardless **Vision:** OpenCRM als SaaS anbieten. Jeder Kunde bekommt seine eigene isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ĂŒber ein zentrales Admin-Portal. **Architektur-Entscheidung:** Weg C (Instance-per-Customer) - Pro Kunde eine eigene Docker-Instanz mit eigener DB - Keine `tenantId` im CRM-Code → keine Security-Risiken durch vergessene Filter - Komplette Datenisolation (DSGVO-freundlich) - Updates können gestaffelt ausgerollt werden (erst 10% testen) - Bei KĂŒndigung: Docker-Image + DB-Export als "Mitnehm-Paket" **Bewusst NICHT dabei:** eigener Mailserver. Stattdessen Plesk-Integration (die wir schon haben) – Kunde bekommt Mail-Zugang ĂŒber unseren Plesk bei Bedarf. --- **Admin-Portal (separate App, neben den CRM-Instanzen):** - Kundenverwaltung: wer hat welchen Plan, Status (Trial/Active/Suspended/Cancelled) - "Neuen Kunden anlegen" → Provisioning-Script - DB anlegen (Master-DB kennt die Mapping) - Docker-Container starten - Subdomain konfigurieren (`kundenname.deincrm.de` via Caddy/Traefik) - Initial-Admin-Account erstellen + Einladungs-Email senden - Optional: Factory-Defaults fĂŒr Stammdaten einspielen - GoCardless-Integration (Webhook + Dashboard) - Instanz-Management: Pause/Resume bei Zahlungsproblemen - Logs & Metriken pro Instanz (optional) - Support-Bereich (Tickets? oder einfach E-Mail) --- **Abrechnung mit GoCardless (gocardless.com):** - Zahlungsmethoden: SEPA-Lastschrift (Hauptfokus) + Kreditkarte (ĂŒber GoCardless Embedded/Success) - 30 Tage kostenlose Testphase ohne Zahlungsmittel - Nach Trial: Mandats-Erfassung → regelmĂ€ĂŸige Abbuchung - Mehrere PlĂ€ne (z.B. Basic / Pro / Enterprise) mit unterschiedlichen Features - Webhook-Endpoint im Admin-Portal: - `payment_confirmed` → Instanz aktiv lassen - `payment_failed` → Banner im CRM, nach X Tagen pausieren - `mandate_cancelled` → KĂŒndigungs-Flow - Rechnungsstellung: GoCardless liefert Zahlungsbelege, aber **echte Rechnungen** (mit USt-ID, Rechnungsnummer etc.) mĂŒssen wir selbst generieren (evtl. ĂŒber das existierende PDF-Template-System aus dem CRM nutzen) --- **Provisioning-Flow (grober Entwurf):** 1. Kunde registriert sich auf Landing Page (Name, Firma, E-Mail, Wunsch-Subdomain) 2. Admin-Portal: Trial-Instanz starten - DB erstellen, Docker-Container hochfahren, Caddy-Config fĂŒr Subdomain - Einladungs-Email mit Admin-Login + Passwort-Reset-Link 3. Tag 25: Erinnerungs-Email "Deine Trial lĂ€uft bald ab" 4. Tag 30: Banner im CRM "Jetzt bezahlen oder pausieren" 5. Kunde erfasst GoCardless-Mandat im Admin-Portal-Login 6. Bei erfolgreicher Zahlung: Instanz bleibt aktiv 7. Bei fehlender Zahlung nach 7 Tagen: Instanz pausiert (DB bleibt, UI zeigt Hinweis) --- **Technische Bausteine fĂŒr spĂ€ter:** - Master-DB mit Tenant-Tabelle (Name, Subdomain, DB-Name, Plan, Status, GoCardlessIDs) - Caddy oder Traefik als Reverse-Proxy mit Auto-SSL (Let's Encrypt) - Docker-Orchestrierung: einzelne `docker-compose.yml` pro Kunde oder Docker-Swarm/K8s - Backup-Strategie: pro Tenant separate Backups + zentrale Master-DB-Backups - Monitoring: ein Fail macht nicht alle down, aber wir mĂŒssen es mitbekommen - Logs zentral: z.B. Loki + Grafana fĂŒr aggregierte Logs aller Instanzen --- **Grobe ZeitschĂ€tzung:** - Admin-Portal (MVP): ~1 Woche - GoCardless-Integration + Webhooks: ~3-5 Tage - Provisioning-Automatisierung (Docker + Caddy): ~1 Woche - Landing Page + Checkout: ~3-5 Tage - Tests + Polishing: ~1 Woche - **Gesamt: ~3-4 Wochen** **Vorbereitung JETZT (einfach, macht spĂ€ter Arbeit leichter):** - ✅ Factory-Defaults System (schon erledigt, hilft beim Provisioning) - ✅ Domain/Label dynamisch per Provider (schon erledigt) - Docker-Compose aufrĂ€umen, Env-Variablen dokumentieren (klein, ein Tag) - Backup-Script robust + wiederherstellbar (haben wir schon weitgehend) --- ## ✅ Erledigt - [x] **🚹 Siegel-Entfernung wird erkannt (Pentest R174-01, HIGH)** (2026-08-18) - Fund: 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 11 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`, „Alle EintrĂ€ge unverĂ€ndert und lĂŒckenlos verkettet“, **null Spur**. Meine Antwort auf seine Frage (b) war damit falsch: die Luecke reisst nur, solange der Marker NICHT am Ende steht. - Fix (sein Vorschlag): Gegen-Check „Blaetter vorhanden, aber kein gueltiger Marker“ → neuer Status **`entfernt`** statt `kein_siegel`, mit ausdruecklicher Meldung („Das ist KEIN Normalzustand“). Zusaetzlich kippt ein gebrochenes ODER entferntes Siegel jetzt `valid` auf `false`, auch wenn keine einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust ohne Wirkung. Und der beruhigende Einstiegssatz entfaellt bei Siegelproblemen, damit er die Warnung nicht entwertet. - Verifiziert ueber den echten HTTP-Pfad, exakt sein Szenario 4: Marker war nachweislich das Ketten-Ende, Marker + Middleware-Decoy geloescht, **0 Ketten-Luecken** – Ergebnis jetzt `valid: false`, Status `entfernt`, Klartext-Warnung. Zusaetzlich der Nicht-Tail-Fall geprueft. Zwei Wegwerf-Datenbanken, beide danach geloescht; Dev unberuehrt. `tsc` + `vite build` gruen. - Seine Gegenchecks bestaetigt: Frage (a) Middleware kann keine Marker-/ Manifest-Signatur mit auswertbarem Inhalt nachbilden; Frage (c) Re-Insert mit derselben id umgeht nichts, weil das Blatt alle Spalten deckt. - [x] **🔧 Bestandssiegel betriebstauglich gemacht (Pentest R173-01 HIGH, R173-02/-03 MEDIUM)** (2026-08-18) - **R173-01 (HIGH) – Siegel war ueber HTTP tot.** Die generische `auditMiddleware` protokolliert den `POST /seal-backlog`-Request SELBST als `AuditLog / CREATE / endpoint=/api/audit-logs/seal-backlog` – exakt die Signatur, mit der ich den Marker suchte, nur mit hoeherer id und ohne `changesAfter`. Der Selektor griff diese Zeile, `root` war `undefined`, Ergebnis: **dauerhaft „gebrochen“ bei null manipulierten Zeilen.** Das komplette R171-02-Deliverable war damit im Betrieb wertlos. **Mein Testfehler:** Ich hatte `sealBacklog()`/`verifyIntegrity()` direkt aufgerufen, nie ueber HTTP – die Middleware lief nie mit. Dieselbe Fehlerklasse wie R165 (Code korrekt, im Betrieb nicht erreichbar). Fix: eigener Ressourcentyp `AuditBacklogSeal`, den die Middleware nie vergibt (sie leitet ihn aus dem Pfad ab → `AuditLog`), zusaetzlich muss der Marker auswertbares `{toId, root}` tragen. - **R173-02 (MEDIUM) – Siegel war blind fuers Loeschen.** `if (!zeile) continue` uebersprang fehlende Zeilen; das Loeschen eines gesiegelten Einbruchsbelegs erschien nur als „unerklaerte Luecke“, waehrend der dedizierte Indikator „intakt“ meldete – Entwarnung, waehrend Beweismaterial verschwindet. Fix: fehlende gesiegelte Zeilen sind ein Siegelbruch mit eigener Liste (`backlogMissing`) und werden namentlich gemeldet. Deckt zugleich seine Frage (a) ab: geprueft wird jetzt die gesiegelte Menge gegen die Live-Population, nicht nur der Inhalt vorhandener Zeilen. - **R173-03 (MEDIUM) – Re-Seal wusch mit weniger Spur als ein Rehash.** Die R170-01-Haertung (Vorbefund im Marker) war auf `seal-backlog` nie angewandt. Fix: Der Siegel-Marker haelt jetzt den Befund VOR dem Siegeln fest (manipulierte/fehlende Zeilen, Ketten-Luecken) samt Status und Wurzel des vorherigen Siegels; `/verify` weist die Anzahl gueltiger Siegel aus und weist bei >1 ausdruecklich darauf hin. - Verifiziert **ueber den echten HTTP-Pfad inkl. Middleware** (separate Wegwerf-DB, Mischbestand 6×V1/4×V2/4×V3): Middleware-Protokollzeile ist vorhanden, Status trotzdem `intakt`; Loeschen einer gesiegelten Zeile → `gebrochen`, `backlogMissing:[2]`, namentlich in der Meldung; erneutes Siegeln → Anzahl 2 gemeldet, Marker enthaelt „vorheriges Siegel: gebrochen, Befund fehlend [2]“. Wegwerf-DB geloescht, Dev unberuehrt. `tsc` + `vite build` gruen. - [x] **🧿 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02, HIGH)** (2026-08-18) - Der Pentester hat den Fund live reproduziert: 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 auf „
hat sich angemeldet“ aendern und `errorMessage` leeren – **alles Nicht-Hash-Felder, Hash unveraendert** → `/verify` meldete `valid=true`. Ein Einbruchsversuch war unsichtbar in einen Erfolg umschreibbar. - Rueckwirkend signieren geht nicht; ein Rehash waere die falsche Medizin. Stattdessen **Bestandssiegel**: einmalig, nicht destruktiv, schreibt nichts um. Je Altzeile ein Blattwert, die Wurzel darueber in einem HMAC-signierten Marker. - Umgesetzt exakt nach seinen vier Bedingungen: 1. **Blaetter ueber den vollen Zeileninhalt** (alle Spalten inkl. `id` und `hashVersion`), NICHT ueber den 7-Feld-V1-Hash – sonst lebte die Luecke im Siegel weiter. 2. **Wurzel signiert** – sie steht im Marker, der selbst Version 3 und HMAC-signiert ist. Ohne `AUDIT_HMAC_KEY` wird das Siegeln abgelehnt. 3. **Bereich fix auf `[1 
 v3FromId-1]`** gebunden statt per Live-Abfrage `hashVersion < 3`. Sein Umgehungsweg: ein Up-Flip der Grenzzeile (V1→V2, schluessellos nachrechenbar) haette sie sonst aus der geprueften Menge gedraengt – sie waere nur als harmlose Luecke erschienen. 4. **Pruefung je id**: vorhanden, weiterhin Altbestand, Inhalt == Blatt. Zusaetzlich Wurzelabgleich, damit auch ein veraenderter Blattbestand auffliegt. - Naht geschlossen: Zeilen ab `v3FromId` MUESSEN laut Versionsgrenze V3 sein, es bleibt also keine ungesiegelte Zone dazwischen. - Neuer Endpunkt `POST /audit-logs/seal-backlog` (`audit:admin`, `{"confirm":"SEAL"}`). `/verify` meldet den Siegelzustand im Klartext – auch wenn **kein** Siegel existiert („Aenderungen daran waeren nicht erkennbar“). - Verifiziert in einer separaten Wegwerf-Datenbank mit realistischem Mischbestand (6×V1, 5×V2, 5×V3): ohne Siegel ist der Angriff unsichtbar (`valid=true`) – mit Siegel wird er erkannt und die Zeile benannt (`[3]`); sein Up-Flip der Grenzzeile wird ebenfalls erkannt (`[6]`). Wegwerf-DB danach geloescht, Dev-Daten unberuehrt. `tsc` + `vite build` gruen. - [x] **🔒 Manifest-Kanal abgesichert (Pentest R171-01, HIGH; R171-03, LOW)** (2026-08-18) - **R171-01:** `verifyIntegrity` vertraute Loeschungs-Manifesten bedingungslos – ohne zu pruefen, ob die Traegerzeile ueberhaupt 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 veraendern, und damit eigene Loeschungen als „erklaert“ ausweisen – womit zugleich die Eskalation an signierten Zeilen ausfiel. Damit waren **Anker UND Versionsgrenze** umgangen. Der Pentester hat es live gezeigt; es war genau die Stelle, die ich beim Uebergeben als meine schwaechste benannt hatte. - Fix: Ein Manifest zaehlt nur noch, wenn seine Traegerzeile (1) laut Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert und (2) ihre HMAC-Signatur mit einem konfigurierten Schluessel aufgeht. Ohne Schluessel gibt es keine gueltigen Traeger – dann bleiben Luecken unerklaert, was die sichere Richtung ist. - **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 und die Traegerzeile wird selbst beanstandet; **legitimes signiertes Manifest erklaert die Luecke weiterhin** (Funktion nicht kaputt). `tsc` gruen. - Hinweis: Auf der Dev-DB existiert kein V1-Bestand mehr (der Rehash-Test aus R170-01 hat alles auf V3 gehoben), der Angriff wurde daher mit einer gezielt herabgestuften Traegerzeile nachgestellt. Auf Staging/Prod ist der V1-Altbestand real vorhanden. - [x] **đŸ§Ÿ rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)** (2026-08-18) - Fund: `POST /audit-logs/rehash` (audit:admin) rechnet die Kette MIT dem HMAC-Schluessel neu und macht sie damit wieder stimmig – auch wenn sie vorher berechtigte Beanstandungen enthielt. Der Anker schuetzt also 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 – **Vorzustand im Marker**: Vor dem Rehash wird `verifyIntegrity()` erhoben und samt Kettenkopf (id, hash, hashVersion) im Marker gesichert: Anzahl geprueft, Listen der manipulierten Zeilen, der Ketten-Luecken, der Luecken ohne dokumentierte Loeschung und der nicht pruefbaren. Dazu ausloesender Benutzer und IP (bisher stand dort pauschal „system“). Der Marker entsteht nach dem Rehash, ist Teil der neuen Kette und signiert – entfernen ginge nur unter Hinterlassung einer Luecke. - Fix 2 – **Bestaetigung verlangen**: `rehash` erfordert `{"confirm":"REHASH"}`, `cleanup` erfordert `{"confirm":"CLEANUP"}`. Der Pentester hatte beide bei blinder Methoden-Erkundung per POST unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400. - Verifiziert: blinder POST auf beide Endpunkte → 400 ohne Wirkung; mit Bestaetigung → Rehash laeuft, Marker enthaelt Ausloeser, Vorbefund (1 manipuliert, 7 Luecken mit exakten IDs) und Kettenkopf. - **Offengelegt:** Dieser Test hat auf der DEV-Datenbank real rehasht – die dortigen historischen Beanstandungen sind rechnerisch geglaettet (`valid = true`). Genau der beschriebene Effekt; der Vorzustand steht jetzt aber im Marker. Staging/Prod unberuehrt. - Offen (Betreiber-Entscheidung): Off-Site-Notarisierung des Kettenkopfes. Erst sie deckt den Fall „Admin mit audit:admin“ vollstaendig ab. - [x] **🔒 Refresh-Kulanz idempotent: stiller Session-Fork geschlossen (Pentest R168-01, HIGH)** (2026-08-18) - Der Pentester hat genau die Frage beantwortet, die ich beim Uebergeben gestellt hatte („laesst sich das Kulanzfenster ausnutzen?“) – und zwar nicht per Timing, sondern per **Linien-Fork**: 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**. Damit war die Kern-Garantie von R164-02 (Diebstahl faellt bei der naechsten Nutzung auf) ausgehebelt. - Fix (sein Vorschlag): **Kulanz idempotent**. Die jti des Nachfolgers wird schon 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 (ausserhalb des Fensters) vorlegt, kollidiert zwangslaeufig und loest den Familien-Widerruf aus. Ist kein Nachfolger hinterlegt, wird bewusst NICHT ersatzweise rotiert (das waere wieder der Fork), sondern fail-closed als Replay gewertet. - Verifiziert – sein PoC nachgebaut: T0 legit → TA, T0 replayt → TC; **TA.jti == TC.jti**, also kein Fork mehr. Ueber HTTP: zwei parallele Tabs beide erfolgreich und auf derselben Linie; gestohlener Token nach Ablauf des Fensters → 401, Familie widerrufen, Angreifer-Linie tot, `SUSPICIOUS/CRITICAL` gemeldet. - Regression der in R168 bestaetigten Faelle: 40 parallel → 4 erfolgreich (1 + Kulanz 3) auf **einer** Linie, Rest abgewiesen; serielles Replay 1–4 ok, 5. → Replay; Token ohne jti → fail-closed; Logout widerruft. `tsc` gruen. - [x] **🔁 Refresh-Token: Replay-Schutz mit Familien-Widerruf (Pentest R164-02)** (2026-08-18) - Die Rotation war bisher 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 Sitzungs-`familyId` (neue Tabelle `RefreshTokenRecord`, Migration `20260818160000`). Beim Einloesen wird die `jti` verbraucht; taucht sie erneut auf, wird die **gesamte Familie** widerrufen – Angreifer und legitimer Nutzer fliegen raus, der Nutzer merkt es und der Vorfall wird als `SUSPICIOUS / CRITICAL` gemeldet. - Der Token selbst wird NICHT gespeichert (die Signatur authentifiziert ihn bereits); ein DB-Leck gibt damit keine nutzbaren Sitzungen preis. - **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. - **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 „noch unbenutzt“ lasen. Jetzt entscheidet die Datenbank, wer zuerst war. - Verifiziert: Rotation vergibt neue `jti` in derselben Familie; 90 parallele Requests → nur 4 erfolgreich (1 + Kulanz 3), 27 als Replay erkannt, Rest widerrufen, alle daraus entstandenen Tokens tot; 2 parallele Tabs weiterhin erfolgreich; gestohlener Token spaeter erneut → abgewiesen; Logout widerruft die Familie; ueber HTTP kommt `SUSPICIOUS/CRITICAL` an. Audit-Regression unveraendert (25/25, 0 Forks, alle V3). `tsc` + `vite build` gruen. - **Deploy-Hinweis:** Refresh-Tokens ohne `jti` (Bestand vor dem Deploy) werden bewusst **fail-closed** abgewiesen (`REFRESH_LEGACY`, als LOW gemeldet, kein Angriffsindiz). Alle angemeldeten Nutzer muessen sich nach dem Deploy **einmalig neu anmelden**. - [x] **⚓ Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)** (2026-08-18) - 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, der NICHT in der Datenbank liegt. Ohne ihn laesst sich keine gueltige Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr. - **Fail-safe:** Ohne Schluessel schreibt das Audit-Log weiter Version 2, es faellt nichts aus. Signierte Zeilen gelten dann als **nicht pruefbar** (eigener Topf `unverifiableEntries`) und ausdruecklich NICHT als manipuliert – eine fehlende Konfiguration darf kein Fehlalarm ueber das gesamte Log sein. - **Schluesselwechsel ohne Rehash:** `AUDIT_HMAC_KEY_OLD` wird bei der Pruefung zusaetzlich akzeptiert. Verifiziert: mit KEY_OLD 0 Befunde, ohne KEY_OLD werden die alt signierten Zeilen erwartungsgemaess auffaellig. - **Restluecke der Versionsgrenze geschlossen (selbst gefunden):** Wird die FRUEHESTE Zeile einer Stufe herabgestuft, wandert `MIN(id)` mit – die Grenze allein haette den Downgrade durchgewunken (mein erster Testlauf fiel genau darauf durch). Der Nachfolger ist jedoch HMAC-signiert, sein `previousHash` ist ohne Schluessel nicht faelschbar. Eine unerklaerte Luecke vor einer signierten Zeile gilt deshalb jetzt als Befund, nicht als struktureller Zufall. - Verifiziert: Inhalt geaendert + Hash beliebig → erkannt; Downgrade 3→2 mit gueltigem V2-Hash 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. - **Betrieb:** `AUDIT_HMAC_KEY` in `.env.example` dokumentiert (`openssl rand -hex 32`). Schluessel sichern – geht er verloren, sind die damit signierten Eintraege dauerhaft nicht mehr pruefbar (aber nicht als manipuliert gemeldet). Der Dev-Schluessel liegt nur lokal in `.env` (gitignored) und ist NICHT der Prod-Schluessel. - [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18) - Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02 selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen Live-Code + Live-Daten): `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 → Fehl-Login als Erfolg getarnt, Pruefung meldet „gueltig“. Besonders kritisch an der **letzten Zeile** der Kette: dort entsteht 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 von der erwarteten ab, gilt die Zeile selbst als manipuliert. Geprueft wird immer mit dem ERWARTETEN Verfahren. Die Grenze laesst sich durch das Herabstufen einzelner Zeilen nicht verschieben (Minimum bleibt) – ein Angreifer muesste alle V2-Zeilen ab der Grenze herabstufen und die gesamte Kette neu rechnen (= vollstaendiger Rehash, bekannter Grenzfall). - Zusaetzlich: Die Pruefung konsultiert 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-Angriff auf Zeile mit Nachfolger → **erkannt**; auf die Tail-Zeile (erzeugt keinen Gap) → **erkannt**; Gegenrichtung (V1-Altzeile faelschlich als V2 deklariert) → **erkannt**; Ausgangslage und Zustand nach Wiederherstellung jeweils 0 manipuliert (keine Falschmeldungen auf Bestandsdaten). `tsc` + `vite build` gruen. - Betriebshinweis dokumentiert: Bei einem rollierenden Deploy mit kurzzeitig parallel schreibender Alt-Instanz koennen echte V1-Zeilen nach der Grenze entstehen und wuerden angezeigt. Beim hier ueblichen Deploy (pull + rebuild + restart) tritt das nicht auf. - [x] **đŸ›Ąïž Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18) - **R166-01 (HIGH) – Kette forkte weiter.** Mein 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 am selben Vorgaenger. Meine „100 parallel → 0 Brueche“-Messung war zu schwach: sie suchte Luecken zwischen Nachbarn, nicht Forks, und das Fenster ist lokal sehr schmal. Fix: einzeiliger Mutex `AuditChainLock` + `FOR UPDATE`; InnoDB-Zeilensperren fallen erst beim COMMIT. Dazu `isolationLevel: ReadCommitted`, damit der Lesevorgang den frisch festgeschriebenen Stand sieht. **Belegt** im Direktvergleich mit kuenstlich geweitetem Fenster: Release-vor-Commit → Fork, Zeilensperre → kein Fork. - **R166-02 (MEDIUM) – Hash deckte nur 7 Felder.** `changesBefore/After` (die eigentliche Nutzlast), `success`, `ipAddress`, `resourceLabel`, `dataSubjectId`, `userId`/`customerId` waren NICHT gehasht – ein Einzeledit dort blieb unsichtbar. Fix: `hashVersion` (Migration `20260818150000`) + `generateHashV2` ueber alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash gueltig. `rehashAll` schreibt V2. Verifiziert: Manipulation an success/changesAfter/ipAddress/resourceLabel/ dataSubjectId wird jetzt **5/5 erkannt**, Altbestand weiter gueltig. - **R166-03 (LOW)** – „kein Cookie“ (normaler Erstbesuch) wurde als `HIGH / abgelehnt` gefuehrt. Jetzt eigener Ausgang: LOW + Label „ohne vorliegenden Token“. Nur echte Ablehnung bleibt HIGH. - **R166-04 (LOW, pre-existing)** – `GET /retention-policies` wurde von `GET /:id` verschluckt. Konkrete Routen jetzt konsequent vor der Parameter-Route, mit Warnhinweis im Code. - **Design-Empfehlungen umgesetzt:** `runRetentionCleanup` schreibt ein Loeschungs-Manifest (Bereich `fromId`–`toId`, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag – Luecken ausserhalb bleiben damit erklaerungsbeduerftig; `rehashAll` schreibt einen Marker, dass die Beweiskraft der Vergangenheit zurueckgesetzt wurde. - Verifiziert: 50 parallele Schreiber → 50/50, 0 Forks, alle V2; manipuliert 0, Luecken unveraendert 7. `tsc` + `vite build` gruen. - **Offen (bewusst nicht umgesetzt):** externer Anker (HMAC mit Schluessel ausserhalb der DB). Adressiert Full-DB-Compromise, erfordert aber Schluesselverwaltung/Rotation im Deployment → Entscheidung des Betreibers. - [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18) - **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 wurde dadurch wertlos (dasselbe Muster wie beim Refresh-Rauschen). - Fix: Rueckgabe um `tamperedEntries` (Inhalt einer Zeile nachtraeglich veraendert – **ernst**) und `chainGaps` (Verkettung unterbrochen durch parallele Schreibvorgaenge oder geloeschte Zeilen – **meist harmlos**) erweitert. `invalidEntries` bleibt als Summe erhalten (Abwaertskompatibilitaet). Controller formuliert die Meldung entsprechend eindeutig; Frontend-API-Typ nachgezogen. - **Problem 2 (Aufbewahrung):** Seit der Entrauschung landen Token-Refreshes 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 (730 Tage). - Fix: Regel `Authentication / LOW` → 90 Tage. Als Migration (`20260818130000`, idempotent per `ON DUPLICATE KEY`) **und** im Seed, damit sie sowohl bestehende Installationen als auch Neuinstallationen erreicht. - Hinweis zur Sensitivitaet: Sie steuert die Aufbewahrung, ist also **keine** Alarmstufe. Normale Logins/Logouts sowie Zugriffe auf Bankdaten/Ausweise bleiben bewusst CRITICAL. Ein Herabstufen „fuer eine ruhigere Liste“ wuerde still die Aufbewahrungsfrist verlaengern – daher unterlassen. - Verifiziert: Live-Test gegen Dev-DB – echte Manipulation einer Zeile (`UPDATE 
 SET userEmail`) wird als **manipuliert** erkannt und nicht mit Luecken verwechselt; Ketten-Luecken bleiben bei 7; Originalzustand exakt wiederhergestellt (0 manipuliert danach). `tsc` + `vite build` gruen. - [x] **🔗 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)** (2026-08-18) - `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 → Kette zerrissen (echte Bruchstellen im Bestand: 05.05./07.05.2026). - Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen benannten MySQL-Lock (`GET_LOCK('opencrm_audit_chain')`). Liegt in der DB, wirkt daher auch ueber mehrere App-Instanzen hinweg. Release im `finally`, da benannte Locks nicht transaktional sind (sonst wandert die Sperre mit der Verbindung zurueck in den Pool und blockiert alle Schreiber). - **Verworfener erster Ansatz – wichtig:** `SELECT 
 FOR UPDATE` auf das Kettenende nimmt Gap-/Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren. Gemessen: **38 von 40** parallelen Eintraegen gingen durch Deadlocks verloren (vom `catch` still verschluckt). Ein FEHLENDER Audit-Eintrag ist unsichtbar und damit gefaehrlicher als ein sichtbarer Kettenbruch – deshalb der Umbau auf den benannten Lock. - Verifiziert: 100 parallele Schreiber → **100/100 geschrieben, 0 neue Brueche** (444 ms); Folge-Schreiber danach in 6 ms, `IS_FREE_LOCK` = frei (kein Lock-Leak). Gesamtzahl ungueltiger Zeilen bleibt bei den 7 historischen. Rechenintensives (Serialisieren/Verschluesseln) liegt bewusst VOR der Transaktion, damit die Sperre kurz bleibt. `tsc` gruen. - [x] **đŸ›Ąïž Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben** (2026-08-18) - Beim Nachpruefen aufgefallen: `verifyIntegrity` meldete **3107 von 4630** Zeilen als „manipuliert“. Davon waren **3100 Fehlalarme** – eingegrenzt auf exakt die Zeilen mit `resourceId = NULL` aus dem Zeitraum 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. - Warum das sicherheitsrelevant ist: Ein Alarm, der staendig grundlos ausloest, wird ignoriert – **echte** Manipulation ginge im LĂ€rm unter (gleiches Muster wie beim Refresh-Rauschen, [R165]). - Fix: `generateHashLegacy()` reproduziert das alte Schreibverhalten; `verifyIntegrity` akzeptiert Altbestand ueber diesen Fallback (nur geprueft, wenn die aktuelle Variante nicht passt). **Kein Rehash** – der waere der naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der Vergangenheit aber unwiederbringlich zerstoeren. Gespeicherte Hashes bleiben unangetastet. - Verifiziert: ungueltige Zeilen **3107 → 7** (die 7 sind echte Ketten-Brueche, siehe naechster Punkt). Adversarial gegengetestet: Manipulation an `userEmail`/`action`/`endpoint`/`createdAt`/`resourceId` wird bei ALTEN wie NEUEN Zeilen weiterhin zu 100 % erkannt (10/10), unveraenderte Zeilen akzeptiert. `tsc` gruen. - [x] **🐛 Audit-Log: Pfad-Matching kaputt – Auth-Actions generisch (Pentest R165-01)** (2026-08-18) - Pentester meldete: Entrauschung (`de0d6bd`) live **nicht wirksam** – jeder `/refresh` weiter `CREATE / CRITICAL / „Anmeldung erstellt“`. Zusatzbefund: auch `/login` und `/logout` liefen als generisches `CREATE`. - **Kein Deploy-Miss** (Alerting aus `d599eb3` lief ja live), sondern **toter Code**: `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`). Alle `path.includes('/auth/...')`-Checks liefen ins Leere → Fallback POST→CREATE + Default-SensitivitĂ€t CRITICAL. Empirisch nachgestellt (Mini-Express: ENTRY `/api/auth/refresh` → FINISH `/refresh`). - Betraf **nicht nur** den neuen `TOKEN_REFRESH`: `LOGIN`/`LOGOUT`/`LOGIN_FAILED` waren im Audit-Stream **seit jeher** kaputt (pre-existing), ebenso das `endpoint`-Feld (router-relativ statt voll). Der SecurityEvent-Stream war nie betroffen (eigene `emit()`-Calls) – daher funktionierte das Alerting korrekt. - Fix: vollen Pfad **einmal synchron beim Eintritt** festhalten (`req.originalUrl.split('?')[0]`, wird von Express nie mutiert) und downstream ausschließlich diesen nutzen – in `determineAction`, `generateHumanLabel`, `extractDataSubjectId`, `manuallyLoggedPaths` und `endpoint`. `TOKEN_REFRESH` zusĂ€tzlich in die „immer loggen“-Ausnahme aufgenommen. - Verifiziert (E2E mit echter Middleware gegen Dev-DB, 5 Requests): `TOKEN_REFRESH/LOW` (Erfolg), `TOKEN_REFRESH/HIGH` (Fehlschlag), `LOGIN/CRITICAL`, `LOGIN_FAILED/CRITICAL`, `LOGOUT/CRITICAL`, alle mit vollem `endpoint`-Pfad und korrekten Labels. `tsc` grĂŒn. - [x] **đŸ›Ąïž Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18) - Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos `TOKEN_REFRESH`/LOW → ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf geraubte/geratene Refresh-Tokens) rutschte als LOW durch und entging der Alarmierung (Angreifer weicht von `/login` auf `/refresh` aus, um unter CRITICAL zu bleiben). - **Wichtig:** Audit-Actions speisen die Alert-Engine NICHT (die zĂ€hlt `SecurityEvent`-Zeilen via `emit()`). Der Tester-Minimalvorschlag (Action → `LOGIN_FAILED`) hĂ€tte also keinen Alert ausgelöst. Echter Fix an 2 Ebenen: - **Detection:** `refresh()`-Catch emittiert jetzt `TOKEN_REJECTED` → greift die bestehende Schwelle `≄3 TOKEN_REJECTED/5min/IP → CRITICAL` (securityAlert.service, kein Severity-Filter). Severity wie Access-Token: abgelaufen/revoked → LOW (benigne, kein Sofort-Alert), ungĂŒltige Signatur/Manipulation → HIGH (Sofort-Alert). `auth.service` reicht dafĂŒr `err.code` REFRESH_EXPIRED/REFRESH_INVALID durch. „Kein Cookie" emittiert NICHT (normaler Erstbesuch). - **Audit-Triage:** fehlgeschlagener Refresh → SensitivitĂ€t HIGH statt LOW + Label „Token-Refresh abgelehnt (ungĂŒltig/abgelaufen)". Action bleibt bewusst `TOKEN_REFRESH` (semantisch ein Refresh, kein Login). - Verifiziert: tsx-Test — abgelaufen→LOW, manipuliert/garbage→HIGH; `tsc` grĂŒn. - Nebenbefund R164-02 (pre-existing, kein Commit von uns): Refresh-Rotation bietet keinen Replay-Schutz (alter Token bis exp gĂŒltig), aber fail-closed nach Logout. Ggf. spĂ€ter: Refresh-Token-Jti-Blacklist / One-Time-Use. - [x] **🔇 Audit-Log: stiller Token-Refresh entrauscht (`TOKEN_REFRESH`)** (2026-08-18) - Automatische `POST /auth/refresh`-Aufrufe (Interceptor bei 401 / nach Seiten-Reload, da Access-Token nur im Speicher) wurden als `CREATE` / „Anmeldung erstellt" / **CRITICAL** / User `anonymous` geloggt → sah aus wie anonyme Login-Flut, war aber die eigene Session. - Neuer `AuditAction`-Wert **`TOKEN_REFRESH`** (Enum-Migration `20260818120000_audit_token_refresh_action`, idempotentes `MODIFY COLUMN`). `determineAction()` erkennt `/auth/refresh` → eigene Action; Label „Sitzung verlĂ€ngert (Token erneuert)"; SensitivitĂ€t in der Middleware explizit auf **LOW** (statt Default `Authentication → CRITICAL`). - Frontend `AuditLogs.tsx`: Filter-Option „Sitzung verlĂ€ngert" + dezente Badge-Farbe (slate); Typ-Union ergĂ€nzt. `anonymous` bleibt (Endpoint lĂ€uft ohne `authenticate`-Middleware, authentifiziert per Cookie im Service) – bewusst nicht geĂ€ndert (Option 1). - Verifiziert: Migration auf Dev-DB aktiv, `tsc` + `vite build` grĂŒn. - [x] **🔒 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)** (2026-08-18) - `createContract`/`updateContract` spreadeten `energyDetails`/`tvDetails`/ `carInsuranceDetails`/`mobileDetails` (via `...mobileData`) roh an Prisma → injizierte `id`/`contractId` konnten ein Detail-Objekt **reparenten** (auf Fremdvertrag umhĂ€ngen) oder den **PK frei setzen** (stilles 200 statt 400). MEDIUM (IntegritĂ€t; kein Cross-Tenant, staff-only, Portal 403). - **Fix:** Feld-Whitelists (`pickEnergyScalars`/`pickMobileScalars`/`pickTvScalars`/ `pickCarInsuranceScalars`, analog R158) an allen Spread-Stellen. `internet` war bereits explizit (safe). Whitelists programmatisch gegen die DB-Spalten abgeglichen (minus id/contractId/verschlĂŒsselt) – alle Diffs leer. - Verifiziert: `energyDetails:{basePrice:99.99, id:999999, contractId:fremd}` → basePrice aktualisiert, ecd.id + contractId **unverĂ€ndert** (kein Reparenting). - [x] **📋 Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18) - `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint `POST /tasks` (staff-only, `contracts:update`) fĂŒr allgemeine Aufgaben ohne Vertrag/Kunde. Ohne Vertrag → **kein Kunde → nie im Portal sichtbar** (`visibleInPortal` serverseitig erzwungen false; Portal-Reply-Endpoint 403 bei contractloser Aufgabe; getAllTasks-Portal-Filter schließt sie automatisch aus). - 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)" (kein Vertrags-Link). - [x] **📧 Kunde: E-Mail Pflichtfeld + keine verwaltete Provider-Domain** (2026-08-18) - Private Kunden-E-Mail (`Customer.email`) darf nicht auf einer bei den E-Mail- Providern konfigurierten Domain (oder Subdomain) liegen → man trĂ€gt so keine verwaltete Weiterleitungs-/Mailbox-Adresse als private Adresse ein. E-Mail ist **nur beim Anlegen** Pflicht (Bestandskunden ohne E-Mail bleiben editierbar); die Domain-PrĂŒfung greift aber bei create UND update, falls eine gesetzt wird. - Helper `getConfiguredEmailDomains`/`emailUsesDomain` im emailProvider-Service. - [x] **⚡ Energievertrag: Ankreuzfeld „Keine Bonis erwĂŒnscht"** (2026-08-18) - `EnergyContractDetails.noBonusDesired` (Boolean, Migration `20260818100000`). Checkbox im Vertragsformular (Strom/Gas), Anzeige im Vertragsdetail. - [x] **🔌 MaLo-ID (Marktlokation) an die Lieferadresse verschoben (Strom/Gas)** (2026-08-14) - MaLo-ID gehört zur **(Liefer-)Adresse**, nicht zum Vertrag. Adresse bekommt **zwei Felder**: `maloIdElectricity` (Strom) + `maloIdGas` (Gas) – im AddressModal (nur Lieferadresse) pflegbar. - **Im Vertrag** ist die MaLo-ID jetzt ein **Lesefeld**, das je nach Sparte (ELECTRICITY→Strom, GAS→Gas) die MaLo der gewĂ€hlten Lieferadresse zeigt (mit Copy + „dort pflegen"-Link). ContractDetail/-Modal zeigen sie ebenso aus der Adresse. - **Schema + Migration** `20260814100000_address_malo_ids`: 2 Spalten (idempotent) **+ Daten-Migration** (bestehende `EnergyContractDetails.maloId` → jeweilige Lieferadresse, Strom→maloIdElectricity / Gas→maloIdGas). Verifiziert. - **Nebenbei einen selbst verursachten Regressions-Bug gefixt:** Beim R156- Mass-Assignment-Umbau waren die **10 `owner*`-Adressfelder** aus der Address- Whitelist gefallen → die EigentĂŒmer-Sektion speicherte seit `cb21a2c` nicht mehr. Address-Whitelist jetzt per Pick-Helper **programmatisch gegen alle DB-Spalten** abgeglichen (owner* + MaLo drin, id/customerId/Timestamps raus). - [x] **🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13) - Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen `...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent zur R156-HĂ€rtung (BankCard/Address/Document). `id`/`contractNumber`/`createdAt`/ `updatedAt`/`portalPasswordEncrypted` und alle `cancellation*Path`-Felder sind damit **nicht** mehr per Form-Update setzbar (Pfade nur noch ĂŒber die Upload-Endpunkte). - **Whitelist autoritativ aus den DB-Spalten** abgeleitet (nicht aus dem unvollstĂ€ndigen `ContractCreateData`-Typ!) – dabei fielen 4 echte, vom Formular gesendete Felder auf, die NICHT im Typ standen und sonst still gebrochen wĂ€ren: `previousProviderId`, `previousContractNumber`, `previousCustomerNumber`, `nextReviewDate`. - Verifiziert: legit Felder (inkl. der 4) persistieren; injizierte `id`/`contractNumber`/`cancellationLetterPath` werden ignoriert. - [x] **đŸ—‚ïž Neuer Vertragsstatus „GekĂŒndigt / bestĂ€tigt" + KĂŒndigungs-Workflow** (2026-08-13) - Bisheriges **„GekĂŒndigt"** umbenannt in **„GekĂŒndigt / BestĂ€tigung abwarten"** (Status `CANCELLED`) – wird jetzt automatisch gesetzt, sobald ein **KĂŒndigungsschreiben** (`cancellationLetterPath`) hochgeladen wird (aus ACTIVE/PENDING/ONGOING/EXPIRED; nie DRAFT/DEACTIVATED/bereits bestĂ€tigt). - **Neuer Status „GekĂŒndigt / bestĂ€tigt"** (`CANCELLED_CONFIRMED`) – automatisch, sobald ein **KĂŒndigungsbestĂ€tigungsdatum** vorliegt (per BestĂ€tigungsdokument, das das Datum fĂŒllt, ODER manuell) + Vertragsende = KĂŒndigungsdatum. Hebt auch aus „BestĂ€tigung abwarten" hoch. - **Schema:** Enum-Wert `CANCELLED_CONFIRMED`, Migration `20260813200000_contract_status_cancelled_confirmed` (idempotentes `MODIFY COLUMN`). **Daten-Migration:** bestehende `CANCELLED` (unter alter Logik nur bei vorhandener BestĂ€tigung gesetzt) → `CANCELLED_CONFIRMED`. - **Cockpit-Semantik mitgewandert:** Fristen-Skip + „beendet" (Schlussrechnung) gelten jetzt fĂŒr `CANCELLED_CONFIRMED` (nicht mehr das reine „abwarten"); „KĂŒndigungsbestĂ€tigung fehlt"-Warnung greift dadurch weiter fĂŒr CANCELLED. `CANCELLED_CONFIRMED` in Ladeliste + KĂŒndigungsbestĂ€tigungs-Filter aufgenommen. - **Frontend:** Labels/Farben/Status-ErklĂ€rungen + Status-Dropdown in ContractList, ContractDetail, ContractForm, ContractDetailModal, CustomerDetail (CANCELLED = orange „abwarten", CANCELLED_CONFIRMED = rot). Verifiziert: Schreiben→CANCELLED, BestĂ€tigung→CANCELLED_CONFIRMED+Enddatum. - [x] **🔒 Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)** (2026-08-13) - Controller reichten rohen `req.body` an Prisma durch → `customerId` (Owner) und `id` (PK) waren per Update mutierbar (staff-only, kein Cross-Tenant, aber IntegritĂ€tsschwĂ€che – und mit `cardNumber` liegt Finanz-PII drauf). - **Fix:** explizite Feld-Whitelist im Service (create+update) fĂŒr **BankCard, Address, IdentityDocument** – nur benannte Felder gehen an Prisma, kein `...data`/`req.body`-Spread mehr. ZusĂ€tzlich Controller-`pickBankCardFields` fĂŒr saubere Audit-Logs (keine Phantom-EintrĂ€ge injizierter Keys). - Verifiziert: Update mit `{customerId:99999, id:88888, bogusField, cardNumber}` → id+customerId **unverĂ€ndert**, nur cardNumber gesetzt, Fremdfelder ignoriert. - [x] **đŸȘȘ Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer** (2026-08-13) - **Schema:** neues Feld `BankCard.cardNumber` (String?, optional). Migration `20260813100000_bank_card_number` (`ADD COLUMN IF NOT EXISTS`), auf Dev angewandt + `prisma generate`. Prod zieht via `migrate deploy` im Entrypoint. Eingabefeld „Kartennummer" im Bankkarten-Modal (Kundenakte) ergĂ€nzt. - **Vertragsansicht (ContractDetail)** – Karten „Bankkarte"/„Ausweis" zeigen zusĂ€tzlich (jeweils mit Copy-Button, nur wenn gesetzt): - Bankkarte: BIC, Bank, Kartennummer, Ablaufdatum - Ausweis: Behörde, Ausstellung, Ablaufdatum + **Geburtsort/Geburtsdatum vom Kunden** - **Vertrag bearbeiten (ContractForm)** – unter den Bankkarte-/Ausweis- Dropdowns dieselben Detailfelder der aktuell gewĂ€hlten Karte/Ausweis (Copy-Buttons). Selects dafĂŒr je in eigenem `
` gewrappt (Grid-Alignment). - Datenquelle war bereits vorhanden: `getContractById` (bankCard/identityDocument/ customer) + `getCustomerById` (bankCards/identityDocuments) liefern alle Felder. - [x] **📄 PDF-Viewer-Modal fĂŒr Bankkarte-/Ausweis-Dokument** (2026-08-13) - Neue wiederverwendbare Komponente `PdfViewerModal` (Modal + `