Compare commits
86
Commits
a28c8355fd
..
main
+5
-1
@@ -12,7 +12,7 @@ backend/dist
|
|||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
*.md
|
*.md
|
||||||
!docker/README.md
|
|
||||||
|
|
||||||
# Environment files (use Docker environment instead)
|
# Environment files (use Docker environment instead)
|
||||||
.env
|
.env
|
||||||
@@ -37,6 +37,10 @@ Thumbs.db
|
|||||||
__tests__
|
__tests__
|
||||||
coverage
|
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 files in wrong location
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,51 @@ JWT_REFRESH_EXPIRES_IN=7d
|
|||||||
# Generieren: openssl rand -hex 32
|
# Generieren: openssl rand -hex 32
|
||||||
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
|
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
|
# Server
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
|
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
|
||||||
|
|||||||
+12
@@ -36,6 +36,18 @@ data/factory-defaults/*
|
|||||||
data/backups/*
|
data/backups/*
|
||||||
!data/backups/.gitkeep
|
!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-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
|
||||||
factory-exports/*
|
factory-exports/*
|
||||||
!factory-exports/.gitkeep
|
!factory-exports/.gitkeep
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,6 +229,42 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
|||||||
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren –
|
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren –
|
||||||
> siehe [Production-Deployment](#production-deployment).
|
> 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
|
## Production-Deployment
|
||||||
|
|
||||||
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
|
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
|
# Bei separatem Frontend-Host: erlaubte Origins
|
||||||
CORS_ORIGINS=https://crm.deine-domain.de
|
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
|
### Deployment-Modus: On-Prem vs. Cloud
|
||||||
@@ -262,6 +302,279 @@ SSRF-Schalter:
|
|||||||
SSRF_BLOCK_PRIVATE_IPS=true
|
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.)
|
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
|
||||||
sind UNABHÄNGIG vom Flag **immer** geblockt – das ist Mindestschutz gegen
|
sind UNABHÄNGIG vom Flag **immer** geblockt – das ist Mindestschutz gegen
|
||||||
AWS/GCP/Azure-IMDS-Diebstahl.
|
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-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
|
```bash
|
||||||
cd docker
|
docker-compose up -d # startet jetzt zusätzlich Caddy
|
||||||
|
|
||||||
# Image bauen
|
|
||||||
docker-compose build
|
|
||||||
|
|
||||||
# Container starten
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Logs anzeigen
|
|
||||||
docker-compose logs -f app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Komponenten:**
|
Solange `COMPOSE_PROFILES` leer ist, wird der Caddy-Dienst **gar nicht erst
|
||||||
- **MariaDB 10.11**: Datenbank
|
angelegt** – der Stack verhält sich exakt wie ohne ihn. Der Schalter wirkt auch
|
||||||
- **App**: Backend + Frontend in einem Container
|
bei `down`, `logs` und `ps`, im Gegensatz zum Flag `--profile caddy`, das man
|
||||||
- **Caddy**: Reverse-Proxy mit automatischem SSL
|
bei Folgebefehlen leicht vergisst.
|
||||||
|
|
||||||
**Umgebungsvariablen (`docker/.env`):**
|
**Was dabei zu beachten ist:**
|
||||||
```env
|
|
||||||
MYSQL_ROOT_PASSWORD=sicheres-root-passwort
|
- Die Zertifikate liegen unter `./data/caddy`. Diesen Ordner **nicht löschen**,
|
||||||
MYSQL_DATABASE=opencrm
|
sonst werden bei jedem Start neue Zertifikate angefordert und du läufst in
|
||||||
MYSQL_USER=opencrm
|
die Mengenbegrenzung von Let's Encrypt.
|
||||||
MYSQL_PASSWORD=sicheres-passwort
|
- Die Domain muss vorher per DNS auf den Server zeigen, sonst schlägt die
|
||||||
JWT_SECRET=sehr-langer-zufaelliger-string
|
Zertifikatsausstellung fehl.
|
||||||
ENCRYPTION_KEY=64-zeichen-hex-string
|
- `HTTPS_ENABLED=true` setzen – das aktiviert das `Secure`-Attribut am
|
||||||
DOMAIN=crm.example.com
|
Refresh-Cookie und die korrekte Proxy-Erkennung.
|
||||||
RUN_SEED=true # Nur beim ersten Start
|
- 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
|
## Projektstruktur
|
||||||
|
|
||||||
@@ -652,7 +975,8 @@ opencrm/
|
|||||||
│ │ ├── types/ # TypeScript-Typen
|
│ │ ├── types/ # TypeScript-Typen
|
||||||
│ │ └── App.tsx # Haupt-Komponente
|
│ │ └── App.tsx # Haupt-Komponente
|
||||||
│ └── package.json
|
│ └── 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
|
│ ├── Dockerfile # Multi-Stage Build
|
||||||
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
|
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
|
||||||
│ ├── Caddyfile # Reverse-Proxy mit SSL
|
│ ├── Caddyfile # Reverse-Proxy mit SSL
|
||||||
|
|||||||
@@ -19,3 +19,110 @@ ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
|
|||||||
# Server
|
# Server
|
||||||
PORT=3001
|
PORT=3001
|
||||||
NODE_ENV=development
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
+15
@@ -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();
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -76,6 +76,11 @@ model User {
|
|||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
/// Dienstkonto (z. B. das Gegenbuch). Meldet sich planmaessig und haeufig an.
|
||||||
|
/// Solche Anmeldungen werden im Audit-Log als Routine gefuehrt statt als
|
||||||
|
/// CRITICAL - sonst trainiert das vorhersagbarste Ereignis im System den
|
||||||
|
/// Betreiber darauf, die hoechste Stufe wegzuklicken (Pentest R182).
|
||||||
|
isServiceAccount Boolean @default(false)
|
||||||
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
||||||
|
|
||||||
// Passwort-Reset
|
// Passwort-Reset
|
||||||
@@ -101,6 +106,16 @@ model Role {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
description String?
|
description String?
|
||||||
|
/// Von der Anwendung gepflegte Rolle (config/rechte-katalog.ts). Über die
|
||||||
|
/// API sichtbar, aber nicht umbenennbar, nicht löschbar, Rechte nicht
|
||||||
|
/// änderbar. Ohne diese Sperre liess sich die Admin-Rolle über die
|
||||||
|
/// Rollen-CRUD leeren oder umbenennen, und die Namens-Schlüssel, an denen
|
||||||
|
/// die versteckten Rollen hängen, waren nur scheinbar stabil.
|
||||||
|
isSystem Boolean @default(false)
|
||||||
|
/// Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden über
|
||||||
|
/// die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff,
|
||||||
|
/// Audit-Betrieb) oder gehören zu einem technischen Konto.
|
||||||
|
isHidden Boolean @default(false)
|
||||||
permissions RolePermission[]
|
permissions RolePermission[]
|
||||||
users UserRole[]
|
users UserRole[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -327,6 +342,10 @@ model Address {
|
|||||||
city String
|
city String
|
||||||
country String @default("Deutschland")
|
country String @default("Deutschland")
|
||||||
isDefault Boolean @default(false)
|
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)
|
// Eigentümer (leer = Kunde ist selbst Eigentümer)
|
||||||
ownerCompany String?
|
ownerCompany String?
|
||||||
ownerFirstName String?
|
ownerFirstName String?
|
||||||
@@ -353,6 +372,7 @@ model BankCard {
|
|||||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
accountHolder String
|
accountHolder String
|
||||||
iban String
|
iban String
|
||||||
|
cardNumber String? // Karten-/Kontokartennummer (optional, z.B. Debitkarte)
|
||||||
bic String?
|
bic String?
|
||||||
bankName String?
|
bankName String?
|
||||||
expiryDate DateTime?
|
expiryDate DateTime?
|
||||||
@@ -702,7 +722,10 @@ enum ContractStatus {
|
|||||||
DRAFT
|
DRAFT
|
||||||
PENDING
|
PENDING
|
||||||
ACTIVE
|
ACTIVE
|
||||||
|
// Kündigungsschreiben raus, Bestätigung des Anbieters steht noch aus.
|
||||||
CANCELLED
|
CANCELLED
|
||||||
|
// Kündigung vom Anbieter bestätigt (Bestätigungsdatum/-dokument liegt vor).
|
||||||
|
CANCELLED_CONFIRMED
|
||||||
EXPIRED
|
EXPIRED
|
||||||
DEACTIVATED
|
DEACTIVATED
|
||||||
// "Läuft weiter auf unbestimmte Zeit" – Vertrag ist unbefristet aktiv.
|
// "Läuft weiter auf unbestimmte Zeit" – Vertrag ist unbefristet aktiv.
|
||||||
@@ -862,8 +885,10 @@ enum ContractTaskStatus {
|
|||||||
|
|
||||||
model ContractTask {
|
model ContractTask {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
contractId Int
|
// Nullable: Aufgaben können auch OHNE Vertrag/Kunde angelegt werden
|
||||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
// (allgemeine interne Aufgabe). Ohne Vertrag → kein Kunde → nie im Portal.
|
||||||
|
contractId Int?
|
||||||
|
contract Contract? @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||||
title String
|
title String
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
status ContractTaskStatus @default(OPEN)
|
status ContractTaskStatus @default(OPEN)
|
||||||
@@ -915,6 +940,7 @@ model EnergyContractDetails {
|
|||||||
// `instantBonus` (Annahme: meistgenutzte Variante).
|
// `instantBonus` (Annahme: meistgenutzte Variante).
|
||||||
instantBonus Float? // Sofort-Bonus
|
instantBonus Float? // Sofort-Bonus
|
||||||
newCustomerBonus Float? // Neukunden-Bonus
|
newCustomerBonus Float? // Neukunden-Bonus
|
||||||
|
noBonusDesired Boolean @default(false) // Kunde wünscht keine Bonis
|
||||||
previousProviderName String?
|
previousProviderName String?
|
||||||
previousCustomerNumber String?
|
previousCustomerNumber String?
|
||||||
invoices Invoice[] // Rechnungen
|
invoices Invoice[] // Rechnungen
|
||||||
@@ -1210,6 +1236,76 @@ model CarInsuranceDetails {
|
|||||||
|
|
||||||
// ==================== AUDIT LOGGING (DSGVO) ====================
|
// ==================== 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 {
|
enum AuditAction {
|
||||||
CREATE
|
CREATE
|
||||||
READ
|
READ
|
||||||
@@ -1220,6 +1316,7 @@ enum AuditAction {
|
|||||||
LOGIN
|
LOGIN
|
||||||
LOGOUT
|
LOGOUT
|
||||||
LOGIN_FAILED
|
LOGIN_FAILED
|
||||||
|
TOKEN_REFRESH // stiller Access-Token-Refresh (Cookie), kein interaktiver Login
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuditSensitivity {
|
enum AuditSensitivity {
|
||||||
@@ -1272,6 +1369,9 @@ model AuditLog {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
hash String? // SHA-256 Hash des Eintrags
|
hash String? // SHA-256 Hash des Eintrags
|
||||||
previousHash String? // Hash des vorherigen 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([userId])
|
||||||
@@index([customerId])
|
@@index([customerId])
|
||||||
@@ -1392,6 +1492,7 @@ enum SecurityEventType {
|
|||||||
LOGOUT // expliziter Logout
|
LOGOUT // expliziter Logout
|
||||||
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
||||||
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
||||||
|
AUDIT_SEAL_CHANGED // Bestandssiegel gesetzt oder ersetzt (Beweis-Grundlage)
|
||||||
SUSPICIOUS // generischer Catch-All
|
SUSPICIOUS // generischer Catch-All
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-213
@@ -1,225 +1,28 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
|
||||||
|
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../src/config/rechte-katalog.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('Seeding database...');
|
console.log('Seeding database...');
|
||||||
|
|
||||||
// ==================== PERMISSIONS ====================
|
// ==================== RECHTE UND ROLLEN ====================
|
||||||
// Ressourcen mit ihren erlaubten Aktionen
|
// Katalog und Systemrollen kommen aus einer einzigen Definition
|
||||||
const resourcePermissions: Record<string, string[]> = {
|
// (src/config/rechte-katalog.ts), aufgeloest von rollen-sync.service.ts.
|
||||||
// Haupt-Ressourcen (CRUD)
|
//
|
||||||
customers: ['create', 'read', 'update', 'delete'],
|
// Bis 09/2026 stand hier eine zweite, eigene Kopie - und sie wich ab: Die
|
||||||
contracts: ['create', 'read', 'update', 'delete'],
|
// DSGVO-Rolle bekam `audit:*` komplett, also auch `audit:admin`. Gerettet
|
||||||
users: ['create', 'read', 'update', 'delete'],
|
// hat das nur die Reihenfolge im Container-Start (sync-roles lief danach
|
||||||
platforms: ['create', 'read', 'update', 'delete'],
|
// und raeumte Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte
|
||||||
providers: ['create', 'read', 'update', 'delete'],
|
// die Buendelung zurueck. Drei Beschreibungen desselben Sachverhalts sind
|
||||||
tariffs: ['create', 'read', 'update', 'delete'],
|
// zwei zu viel.
|
||||||
// Konfiguration (CRUD)
|
await synchronisiereRechteUndRollen(prisma);
|
||||||
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-durations': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-categories': ['create', 'read', 'update', 'delete'],
|
|
||||||
'email-providers': ['create', 'read', 'update', 'delete'],
|
|
||||||
// Einstellungen (nur lesen/ändern)
|
|
||||||
settings: ['read', 'update'],
|
|
||||||
// Spezial-Permissions
|
|
||||||
developer: ['access'],
|
|
||||||
emails: ['delete'],
|
|
||||||
// DSGVO & Audit
|
|
||||||
audit: ['read', 'export', 'admin'],
|
|
||||||
gdpr: ['export', 'delete', 'admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const permissions: { resource: string; action: string }[] = [];
|
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
|
||||||
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
|
||||||
for (const action of actions) {
|
|
||||||
permissions.push({ resource, action });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const perm of permissions) {
|
|
||||||
await prisma.permission.upsert({
|
|
||||||
where: { resource_action: perm },
|
|
||||||
update: {},
|
|
||||||
create: perm,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Permissions created (${permissions.length} total)`);
|
|
||||||
|
|
||||||
// Get all permissions
|
|
||||||
const allPermissions = await prisma.permission.findMany();
|
|
||||||
const customerReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'customers' && p.action === 'read'
|
|
||||||
);
|
|
||||||
const contractReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'contracts' && p.action === 'read'
|
|
||||||
);
|
|
||||||
const platformReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'platforms' && p.action === 'read'
|
|
||||||
);
|
|
||||||
const providerReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'providers' && p.action === 'read'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper: Sync permissions for a role (adds missing, removes excess)
|
|
||||||
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
|
||||||
const existing = await prisma.rolePermission.findMany({
|
|
||||||
where: { roleId },
|
|
||||||
select: { permissionId: true },
|
|
||||||
});
|
|
||||||
const existingIds = new Set(existing.map((e) => e.permissionId));
|
|
||||||
const targetIds = new Set(permissionIds);
|
|
||||||
|
|
||||||
// Add missing permissions
|
|
||||||
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
await prisma.rolePermission.createMany({
|
|
||||||
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
console.log(` → ${missing.length} Permissions hinzugefügt für Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove excess permissions
|
|
||||||
const excess = existing.filter((e) => !targetIds.has(e.permissionId)).map((e) => e.permissionId);
|
|
||||||
if (excess.length > 0) {
|
|
||||||
await prisma.rolePermission.deleteMany({
|
|
||||||
where: { roleId, permissionId: { in: excess } },
|
|
||||||
});
|
|
||||||
console.log(` → ${excess.length} Permissions entfernt für Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create roles
|
|
||||||
// Admin - all permissions EXCEPT developer:access and audit/gdpr (controlled separately via checkboxes)
|
|
||||||
const adminPermissions = allPermissions.filter(
|
|
||||||
(p) =>
|
|
||||||
!(p.resource === 'developer' && p.action === 'access') &&
|
|
||||||
p.resource !== 'audit' &&
|
|
||||||
p.resource !== 'gdpr'
|
|
||||||
);
|
|
||||||
const adminRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Admin' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Admin',
|
|
||||||
description: 'Voller Zugriff auf alle Funktionen',
|
|
||||||
permissions: {
|
|
||||||
create: adminPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(adminRole.id, adminPermissions.map((p) => p.id));
|
|
||||||
|
|
||||||
// Developer - ALL permissions (developer:access + alles andere)
|
|
||||||
const developerPermissions = allPermissions;
|
|
||||||
const developerRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Developer' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Developer',
|
|
||||||
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
|
||||||
permissions: {
|
|
||||||
create: developerPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id));
|
|
||||||
|
|
||||||
// DSGVO - audit and gdpr permissions (hidden role, controlled via hasGdprAccess)
|
|
||||||
const gdprPermissions = allPermissions.filter(
|
|
||||||
(p) => p.resource === 'audit' || p.resource === 'gdpr'
|
|
||||||
);
|
|
||||||
const gdprRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'DSGVO' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'DSGVO',
|
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
|
||||||
permissions: {
|
|
||||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(gdprRole.id, gdprPermissions.map((p) => p.id));
|
|
||||||
|
|
||||||
// Employee - full access to customers, contracts, read access to lookup tables
|
|
||||||
const employeePermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.resource === 'customers' ||
|
|
||||||
p.resource === 'contracts' ||
|
|
||||||
// Read-only Zugriff auf Stammdaten und Konfiguration
|
|
||||||
(p.action === 'read' && [
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
].includes(p.resource))
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
const employeeRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Mitarbeiter' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Mitarbeiter',
|
|
||||||
description: 'Kann Kunden und Verträge verwalten',
|
|
||||||
permissions: {
|
|
||||||
create: employeePermIds.map((id) => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(employeeRole.id, employeePermIds);
|
|
||||||
|
|
||||||
// Read-only employee - read access to main entities and lookup tables
|
|
||||||
const readOnlyResources = [
|
|
||||||
'customers',
|
|
||||||
'contracts',
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
];
|
|
||||||
const readOnlyPermIds = allPermissions
|
|
||||||
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
const readOnlyRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Mitarbeiter (Nur-Lesen)' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Mitarbeiter (Nur-Lesen)',
|
|
||||||
description: 'Kann nur lesen, keine Änderungen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(readOnlyRole.id, readOnlyPermIds);
|
|
||||||
|
|
||||||
// Customer role - read own data only (handled in middleware)
|
|
||||||
const customerRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Kunde' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Kunde',
|
|
||||||
description: 'Kann nur eigene Daten lesen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(customerRole.id, readOnlyPermIds);
|
|
||||||
|
|
||||||
console.log('Roles created');
|
|
||||||
|
|
||||||
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
||||||
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
|
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
|
||||||
@@ -265,8 +68,19 @@ async function main() {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
firstName: 'Admin',
|
firstName: 'Admin',
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
|
// Zusaetzlich die DSGVO-Rolle (Pentest R189-01).
|
||||||
|
//
|
||||||
|
// Ohne sie kann nach einem frischen Seed NIEMAND eine Auskunft nach
|
||||||
|
// Art. 15 oder eine Loeschung nach Art. 17 ausfuehren - die Rechte
|
||||||
|
// haengen an DSGVO und Developer, und beide waren keinem Konto
|
||||||
|
// zugewiesen. Ein Ausfall mit Fristwirkung, ausgeloest durch nichts
|
||||||
|
// weiter als eine Neuinstallation.
|
||||||
|
//
|
||||||
|
// Die Trennung bleibt: Die Admin-ROLLE bekommt diese Rechte weiterhin
|
||||||
|
// nicht. Nur dieses eine Bootstrap-Konto traegt beide, damit ueberhaupt
|
||||||
|
// jemand handlungsfaehig ist.
|
||||||
roles: {
|
roles: {
|
||||||
create: [{ roleId: adminRole.id }],
|
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -497,6 +311,16 @@ async function main() {
|
|||||||
description: 'Allgemeine Einstellungen',
|
description: 'Allgemeine Einstellungen',
|
||||||
legalBasis: 'Verjährungsfrist (BGB §195)',
|
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) {
|
for (const policy of specificPolicies) {
|
||||||
|
|||||||
+20
-148
@@ -1,160 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
|
* Bringt Rechtekatalog und Systemrollen beim Container-Start auf Stand.
|
||||||
*
|
*
|
||||||
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
|
* Hintergrund: seed.ts laeuft nur auf leeren Datenbanken (USER_COUNT=0). Wer
|
||||||
* System schon installiert hat, bekommt nachträglich hinzugefügte
|
* das System schon installiert hat, bekommt nachtraeglich hinzugefuegte
|
||||||
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
|
* Rechte oder geaenderte Rollenzuordnungen sonst NICHT.
|
||||||
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
|
|
||||||
*
|
*
|
||||||
* Dieses Skript synchronisiert ausschließlich:
|
* Die Definition selbst steht in `src/config/rechte-katalog.ts`, die Logik in
|
||||||
* - Permission-Katalog (resource/action-Paare aus dem Code)
|
* `src/services/rollen-sync.service.ts`. Dieses Skript ist nur noch der
|
||||||
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
|
* Einstiegspunkt fuer die Kommandozeile - bis 09/2026 trug es eine eigene
|
||||||
* Mitarbeiter (Nur-Lesen), Kunde)
|
* Kopie des Katalogs, und es war nicht die einzige.
|
||||||
*
|
*
|
||||||
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
|
* KEINE Stammdaten, KEINE Benutzer, KEINE Vertraege - auf laufenden
|
||||||
* laufenden Prod-DBs sicher.
|
* Produktionsdatenbanken sicher.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
|
||||||
|
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
|
||||||
|
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
|
||||||
|
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
|
||||||
|
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
|
||||||
|
// "Environment variable not found: DATABASE_URL".
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
|
synchronisiereRechteUndRollen(prisma)
|
||||||
customers: ['create', 'read', 'update', 'delete'],
|
|
||||||
contracts: ['create', 'read', 'update', 'delete'],
|
|
||||||
users: ['create', 'read', 'update', 'delete'],
|
|
||||||
platforms: ['create', 'read', 'update', 'delete'],
|
|
||||||
providers: ['create', 'read', 'update', 'delete'],
|
|
||||||
tariffs: ['create', 'read', 'update', 'delete'],
|
|
||||||
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-durations': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-categories': ['create', 'read', 'update', 'delete'],
|
|
||||||
'email-providers': ['create', 'read', 'update', 'delete'],
|
|
||||||
settings: ['read', 'update'],
|
|
||||||
developer: ['access'],
|
|
||||||
emails: ['delete'],
|
|
||||||
audit: ['read', 'export', 'admin'],
|
|
||||||
gdpr: ['export', 'delete', 'admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
|
||||||
const existing = await prisma.rolePermission.findMany({
|
|
||||||
where: { roleId },
|
|
||||||
select: { permissionId: true },
|
|
||||||
});
|
|
||||||
const existingIds = new Set(existing.map((e) => e.permissionId));
|
|
||||||
const targetIds = new Set(permissionIds);
|
|
||||||
|
|
||||||
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
await prisma.rolePermission.createMany({
|
|
||||||
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const excess = existing
|
|
||||||
.filter((e) => !targetIds.has(e.permissionId))
|
|
||||||
.map((e) => e.permissionId);
|
|
||||||
if (excess.length > 0) {
|
|
||||||
await prisma.rolePermission.deleteMany({
|
|
||||||
where: { roleId, permissionId: { in: excess } },
|
|
||||||
});
|
|
||||||
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('[sync-roles] Permissions-Katalog upserten…');
|
|
||||||
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
|
|
||||||
for (const action of actions) {
|
|
||||||
await prisma.permission.upsert({
|
|
||||||
where: { resource_action: { resource, action } },
|
|
||||||
update: {},
|
|
||||||
create: { resource, action },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const allPermissions = await prisma.permission.findMany();
|
|
||||||
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
|
|
||||||
|
|
||||||
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
|
|
||||||
// sind separate hidden roles, über Checkboxen zugewiesen)
|
|
||||||
const adminPermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
!(p.resource === 'developer' && p.action === 'access') &&
|
|
||||||
p.resource !== 'audit' &&
|
|
||||||
p.resource !== 'gdpr'
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Developer: alles
|
|
||||||
const developerPermIds = allPermissions.map((p) => p.id);
|
|
||||||
|
|
||||||
// DSGVO: audit + gdpr komplett
|
|
||||||
const gdprPermIds = allPermissions
|
|
||||||
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Mitarbeiter: customers + contracts + read auf Stammdaten
|
|
||||||
const employeePermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.resource === 'customers' ||
|
|
||||||
p.resource === 'contracts' ||
|
|
||||||
(p.action === 'read' &&
|
|
||||||
[
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
].includes(p.resource))
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
|
|
||||||
const readOnlyResources = [
|
|
||||||
'customers',
|
|
||||||
'contracts',
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
];
|
|
||||||
const readOnlyPermIds = allPermissions
|
|
||||||
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
|
||||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
|
||||||
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
|
||||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
|
||||||
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
|
||||||
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
|
||||||
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const r of rolesSpec) {
|
|
||||||
const role = await prisma.role.upsert({
|
|
||||||
where: { name: r.name },
|
|
||||||
update: { description: r.description },
|
|
||||||
create: { name: r.name, description: r.description },
|
|
||||||
});
|
|
||||||
await syncRolePermissions(role.id, r.permIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[sync-roles] fertig.');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error('[sync-roles] Fehler:', e);
|
console.error('[rollen-sync] Fehler:', e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -3,46 +3,119 @@ import { AuthRequest } from '../types/index.js';
|
|||||||
import * as auditService from '../services/audit.service.js';
|
import * as auditService from '../services/audit.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { AuditAction, AuditSensitivity } from '@prisma/client';
|
import { AuditAction, AuditSensitivity } from '@prisma/client';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filterwerte aus der Query pruefen (Pentest R185-02).
|
||||||
|
*
|
||||||
|
* Vorher gingen `action` und `sensitivity` als roher String an die Enum-Spalte.
|
||||||
|
* Ein ungueltiger Wert liess Prisma auflaufen und der Handler antwortete 500.
|
||||||
|
* Das ist zweierlei: eine fehlende Validierung – und ein Fehler-Orakel. Wer
|
||||||
|
* 200 gegen 500 vergleicht, liest die Enum-Mitglieder aus, ohne sie zu kennen.
|
||||||
|
* Ein ungueltiger Filter ist eine schlechte ANFRAGE, keine Server-Panne: 400.
|
||||||
|
*
|
||||||
|
* Dasselbe gilt fuer Datumsangaben (`new Date('foo')` ergibt Invalid Date und
|
||||||
|
* sprengt die Query erst in der Datenbank) und fuer Zahlen (`parseInt('x')`
|
||||||
|
* ergibt NaN).
|
||||||
|
*/
|
||||||
|
class FilterFehler extends Error {}
|
||||||
|
|
||||||
|
const AUDIT_ACTIONS = Object.values(AuditAction) as string[];
|
||||||
|
const AUDIT_SENSITIVITIES = Object.values(AuditSensitivity) as string[];
|
||||||
|
|
||||||
|
function pruefeEnum<T extends string>(
|
||||||
|
wert: unknown, erlaubt: string[], feld: string,
|
||||||
|
): T | undefined {
|
||||||
|
if (wert === undefined || wert === '') return undefined;
|
||||||
|
if (typeof wert !== 'string' || !erlaubt.includes(wert)) {
|
||||||
|
// Die erlaubten Werte stehen ohnehin in der Oberflaeche und im Schema –
|
||||||
|
// sie zu nennen verraet nichts und erspart Rateversuche.
|
||||||
|
throw new FilterFehler(
|
||||||
|
`Ungültiger Wert für "${feld}". Erlaubt: ${erlaubt.join(', ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return wert as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefeDatum(wert: unknown, feld: string): Date | undefined {
|
||||||
|
if (wert === undefined || wert === '') return undefined;
|
||||||
|
const d = new Date(wert as string);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
throw new FilterFehler(`Ungültiges Datum für "${feld}".`);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefeZahl(wert: unknown, feld: string): number | undefined {
|
||||||
|
if (wert === undefined || wert === '') return undefined;
|
||||||
|
const n = Number(wert);
|
||||||
|
if (!Number.isInteger(n) || n < 0) {
|
||||||
|
throw new FilterFehler(`Ungültige Zahl für "${feld}".`);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freitextfelder begrenzen – unbegrenzte LIKE-Muster sind teuer. */
|
||||||
|
function pruefeText(wert: unknown, feld: string, maxLaenge = 200): string | undefined {
|
||||||
|
if (wert === undefined || wert === '') return undefined;
|
||||||
|
if (typeof wert !== 'string' || wert.length > maxLaenge) {
|
||||||
|
throw new FilterFehler(`Ungültiger Wert für "${feld}" (max. ${maxLaenge} Zeichen).`);
|
||||||
|
}
|
||||||
|
return wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter aus der Query lesen – EINE Stelle fuer Liste und Export (R186-01).
|
||||||
|
*
|
||||||
|
* Vorher pflegte jeder Endpunkt seine eigene Liste, und die des Exports war
|
||||||
|
* kuerzer: `userId`, `customerId`, `dataSubjectId`, `resourceId`, `success` und
|
||||||
|
* `search` wurden dort stillschweigend verworfen. Ein bewusst eingegrenzter
|
||||||
|
* Export – „nur die Spur von Benutzer X“ fuer eine DSGVO-Auskunft oder eine
|
||||||
|
* Innentaeter-Pruefung – lieferte damit das VOLLSTAENDIGE Protokoll aller
|
||||||
|
* Nutzer zurueck. Mit HTTP 200 und ohne jeden Hinweis: ein beruhigendes
|
||||||
|
* Signal ueber einem Ergebnis, das genau das Gegenteil dessen ist, was
|
||||||
|
* angefragt wurde. Auf einem datenminimierungspflichtigen Export ist das
|
||||||
|
* nicht nur ein fehlender Filter, sondern eine Weitergabe.
|
||||||
|
*
|
||||||
|
* Zwei Listen, die dasselbe bedeuten sollen, laufen frueher oder spaeter
|
||||||
|
* auseinander. Deshalb gibt es jetzt nur noch diese eine.
|
||||||
|
*/
|
||||||
|
function leseFilter(req: AuthRequest) {
|
||||||
|
const q = req.query;
|
||||||
|
return {
|
||||||
|
userId: pruefeZahl(q.userId, 'userId'),
|
||||||
|
customerId: pruefeZahl(q.customerId, 'customerId'),
|
||||||
|
dataSubjectId: pruefeZahl(q.dataSubjectId, 'dataSubjectId'),
|
||||||
|
action: pruefeEnum<AuditAction>(q.action, AUDIT_ACTIONS, 'action'),
|
||||||
|
sensitivity: pruefeEnum<AuditSensitivity>(q.sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
|
||||||
|
resourceType: pruefeText(q.resourceType, 'resourceType', 100),
|
||||||
|
resourceId: pruefeText(q.resourceId, 'resourceId', 100),
|
||||||
|
startDate: pruefeDatum(q.startDate, 'startDate'),
|
||||||
|
endDate: pruefeDatum(q.endDate, 'endDate'),
|
||||||
|
success: q.success !== undefined ? q.success === 'true' : undefined,
|
||||||
|
search: pruefeText(q.search, 'search'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit-Logs mit Filtern abrufen
|
* Audit-Logs mit Filtern abrufen
|
||||||
*/
|
*/
|
||||||
export async function getAuditLogs(req: AuthRequest, res: Response) {
|
export async function getAuditLogs(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const {
|
|
||||||
userId,
|
|
||||||
customerId,
|
|
||||||
dataSubjectId,
|
|
||||||
action,
|
|
||||||
sensitivity,
|
|
||||||
resourceType,
|
|
||||||
resourceId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
success,
|
|
||||||
search,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
} = req.query;
|
|
||||||
|
|
||||||
const result = await auditService.searchAuditLogs({
|
const result = await auditService.searchAuditLogs({
|
||||||
userId: userId ? parseInt(userId as string) : undefined,
|
...leseFilter(req),
|
||||||
customerId: customerId ? parseInt(customerId as string) : undefined,
|
page: pruefeZahl(req.query.page, 'page') || 1,
|
||||||
dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined,
|
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
|
||||||
action: action as AuditAction | undefined,
|
// Zug ziehen, an der Seitenlogik vorbei.
|
||||||
sensitivity: sensitivity as AuditSensitivity | undefined,
|
limit: Math.min(pruefeZahl(req.query.limit, 'limit') || 50, 200),
|
||||||
resourceType: resourceType as string | undefined,
|
|
||||||
resourceId: resourceId as string | undefined,
|
|
||||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
|
||||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
|
||||||
success: success !== undefined ? success === 'true' : undefined,
|
|
||||||
search: search as string | undefined,
|
|
||||||
page: page ? parseInt(page as string) : 1,
|
|
||||||
limit: limit ? parseInt(limit as string) : 50,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, ...result });
|
res.json({ success: true, ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof FilterFehler) {
|
||||||
|
res.status(400).json({ success: false, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Fehler beim Abrufen der Audit-Logs:', error);
|
console.error('Fehler beim Abrufen der Audit-Logs:', error);
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
|
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
|
||||||
}
|
}
|
||||||
@@ -96,25 +169,8 @@ export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const format = (req.query.format as 'json' | 'csv') || 'json';
|
const format = req.query.format === 'csv' ? 'csv' : 'json';
|
||||||
const {
|
const content = await auditService.exportAuditLogs(leseFilter(req), format);
|
||||||
action,
|
|
||||||
sensitivity,
|
|
||||||
resourceType,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
} = req.query;
|
|
||||||
|
|
||||||
const content = await auditService.exportAuditLogs(
|
|
||||||
{
|
|
||||||
action: action as AuditAction | undefined,
|
|
||||||
sensitivity: sensitivity as AuditSensitivity | undefined,
|
|
||||||
resourceType: resourceType as string | undefined,
|
|
||||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
|
||||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
|
||||||
},
|
|
||||||
format
|
|
||||||
);
|
|
||||||
|
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
@@ -126,6 +182,10 @@ export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: JSON.parse(content) })
|
res.json({ success: true, data: JSON.parse(content) })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof FilterFehler) {
|
||||||
|
res.status(400).json({ success: false, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Fehler beim Exportieren der Audit-Logs:', error);
|
console.error('Fehler beim Exportieren der Audit-Logs:', error);
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
|
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
|
||||||
}
|
}
|
||||||
@@ -143,15 +203,178 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
toId ? parseInt(toId as string) : undefined
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
valid: result.valid,
|
valid: result.valid,
|
||||||
checkedCount: result.checkedCount,
|
checkedCount: result.checkedCount,
|
||||||
invalidEntries: result.invalidEntries,
|
invalidEntries: result.invalidEntries,
|
||||||
message: result.valid
|
// Ernst: Inhalt einer bestehenden Zeile wurde nachträglich verändert.
|
||||||
? 'Alle Einträge sind valide'
|
tamperedEntries: result.tamperedEntries,
|
||||||
: `${result.invalidEntries.length} manipulierte Einträge gefunden`,
|
// 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) {
|
} catch (error) {
|
||||||
@@ -165,7 +388,26 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function rehashAll(req: AuthRequest, res: Response) {
|
export async function rehashAll(req: AuthRequest, res: Response) {
|
||||||
try {
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
@@ -177,6 +419,131 @@ 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);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Versiegeln',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retention-Policies abrufen
|
* Retention-Policies abrufen
|
||||||
*/
|
*/
|
||||||
@@ -201,6 +568,48 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
|||||||
}
|
}
|
||||||
const { retentionDays, description, legalBasis, isActive } = req.body;
|
const { retentionDays, description, legalBasis, isActive } = req.body;
|
||||||
|
|
||||||
|
// Die Richtlinie bestimmt, WAS der Cleanup vernichtet – sie ist die
|
||||||
|
// geladene Waffe, der Cleanup nur der Abzug (Pentest R183-01). Bisher war
|
||||||
|
// ausgerechnet der Abzug gegatet und die Waffe frei zugaenglich: Ein
|
||||||
|
// `retentionDays: 0` ging ohne Bestaetigung durch und wurde nur als MEDIUM
|
||||||
|
// protokolliert, waehrend seine Wirkung CRITICAL ist. Die ausloesende Tat
|
||||||
|
// war damit leiser als die Folge – und lag unter der Schwelle, bei der
|
||||||
|
// jemand hinsieht.
|
||||||
|
const bisher = (await auditService.getRetentionPolicies()).find((p) => p.id === id);
|
||||||
|
if (!bisher) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Aufbewahrungsrichtlinie nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const neueTage = typeof retentionDays === 'number' ? retentionDays : bisher.retentionDays;
|
||||||
|
|
||||||
|
// Untergrenze: Auth-Eintraege sind der Einbruchsbeleg schlechthin. Eine
|
||||||
|
// Aufbewahrung von faktisch null macht sie mit dem naechsten Cleanup
|
||||||
|
// spurlos entfernbar – ueber einen sanktionierten Pfad.
|
||||||
|
const UNTERGRENZE: Record<string, number> = { Authentication: 30, AuditLog: 30 };
|
||||||
|
const grenze = UNTERGRENZE[bisher.resourceType];
|
||||||
|
if (grenze !== undefined && neueTage < grenze) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`Für ${bisher.resourceType} sind mindestens ${grenze} Tage Aufbewahrung vorgeschrieben ` +
|
||||||
|
`(angefragt: ${neueTage}). Diese Einträge belegen Anmeldungen und Zugriffe – eine ` +
|
||||||
|
`Aufbewahrung nahe null macht sie beim nächsten Cleanup spurlos entfernbar.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absenken ist die gefaehrliche Richtung und braucht dieselbe ausdrueckliche
|
||||||
|
// Bestaetigung wie der Cleanup selbst.
|
||||||
|
const senktAb = neueTage < bisher.retentionDays;
|
||||||
|
if (senktAb && req.body?.confirm !== 'SHORTEN') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`Das verkürzt die Aufbewahrung von ${bisher.retentionDays} auf ${neueTage} Tage. ` +
|
||||||
|
'Beim nächsten Cleanup werden dadurch Einträge endgültig gelöscht, die heute noch ' +
|
||||||
|
'da sind. Zum Bestätigen {"confirm":"SHORTEN"} mitsenden.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const policy = await auditService.updateRetentionPolicy(id, {
|
const policy = await auditService.updateRetentionPolicy(id, {
|
||||||
retentionDays,
|
retentionDays,
|
||||||
description,
|
description,
|
||||||
@@ -211,7 +620,15 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
|||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
|
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
|
||||||
resourceId: id.toString(),
|
resourceId: id.toString(),
|
||||||
label: `Aufbewahrungsrichtlinie aktualisiert`,
|
// Absenkung wird wie ihre Folge eingestuft: CRITICAL, nicht MEDIUM.
|
||||||
|
sensitivity: senktAb ? 'CRITICAL' : undefined,
|
||||||
|
label: senktAb
|
||||||
|
? `Aufbewahrung VERKÜRZT für ${bisher.resourceType}` +
|
||||||
|
(bisher.sensitivity ? `/${bisher.sensitivity}` : '') +
|
||||||
|
`: ${bisher.retentionDays} → ${neueTage} Tage`
|
||||||
|
: `Aufbewahrungsrichtlinie aktualisiert`,
|
||||||
|
before: { retentionDays: bisher.retentionDays },
|
||||||
|
details: { retentionDays: neueTage },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: policy });
|
res.json({ success: true, data: policy });
|
||||||
@@ -226,6 +643,18 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function runRetentionCleanup(req: AuthRequest, res: Response) {
|
export async function runRetentionCleanup(req: AuthRequest, res: Response) {
|
||||||
try {
|
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();
|
const result = await auditService.runRetentionCleanup();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -309,11 +309,16 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
where: { id: user.customerId },
|
where: { id: user.customerId },
|
||||||
data: { portalTokenInvalidatedAt: new Date() },
|
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) {
|
} else if (user.userId) {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.userId },
|
where: { id: user.userId },
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
await authService.revokeAllRefreshTokens({ userId: user.userId }, 'Logout');
|
||||||
}
|
}
|
||||||
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
|
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
|
||||||
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
|
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
|
||||||
@@ -362,9 +367,42 @@ export async function refresh(req: Request, res: Response): Promise<void> {
|
|||||||
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
||||||
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
||||||
clearRefreshCookie(res);
|
clearRefreshCookie(res);
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
|
error: msg,
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,7 +374,12 @@ export async function factoryReset(req: Request, res: Response) {
|
|||||||
label: `Werkseinstellungen wiederhergestellt`,
|
label: `Werkseinstellungen wiederhergestellt`,
|
||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Werkseinstellungen wiederhergestellt. Bitte melden Sie sich mit admin@admin.com / admin an.',
|
// Das Kennwort ist nicht mehr "admin", sondern zufaellig und steht
|
||||||
|
// genau einmal im Server-Log. Die alte Meldung hier nannte es noch
|
||||||
|
// im Klartext - sie haette nach der Haertung ins Leere gefuehrt.
|
||||||
|
message:
|
||||||
|
'Werkseinstellungen wiederhergestellt. Das neue Kennwort für admin@admin.com ' +
|
||||||
|
'steht einmalig im Server-Log (docker compose logs backend).',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
||||||
|
|||||||
@@ -259,8 +259,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
|
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
|
||||||
|
|
||||||
// Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
|
// Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
|
||||||
// datum gesetzt wurde: Vertrag ACTIVE → CANCELLED + Vertragsende =
|
// datum gesetzt wurde: Vertrag → CANCELLED_CONFIRMED ("Gekündigt / bestätigt")
|
||||||
// Kündigungsdatum. Nur bei tatsächlicher Änderung des Datums auslösen,
|
// + Vertragsende = Kündigungsdatum. Nur bei tatsächlicher Änderung auslösen,
|
||||||
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
|
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
|
||||||
{
|
{
|
||||||
const asDay = (d: unknown) =>
|
const asDay = (d: unknown) =>
|
||||||
|
|||||||
@@ -118,6 +118,41 @@ 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) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createTask(req: AuthRequest, res: Response): Promise<void> {
|
export async function createTask(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
const contractId = parseInt(req.params.contractId);
|
||||||
@@ -361,6 +396,12 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
|||||||
return;
|
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
|
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
|
||||||
// via hasAuthorization, Pentest Runde 6 – HOCH-04). Damit kann ein
|
// via hasAuthorization, Pentest Runde 6 – HOCH-04). Damit kann ein
|
||||||
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
|
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma.js';
|
|||||||
import * as customerService from '../services/customer.service.js';
|
import * as customerService from '../services/customer.service.js';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { logChange } from '../services/audit.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 { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,23 @@ import {
|
|||||||
} from '../utils/accessControl.js';
|
} from '../utils/accessControl.js';
|
||||||
import { ApiError } from '../utils/apiError.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
|
// Customer CRUD
|
||||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -82,12 +100,23 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
||||||
const data: any = pickCustomerCreate(req.body);
|
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
|
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
|
||||||
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
|
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
|
||||||
if (data.email && !isValidEmail(data.email)) {
|
if (data.email && !isValidEmail(data.email)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
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)) {
|
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
@@ -142,11 +171,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);
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
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)) {
|
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data: any = pickCustomerUpdate(req.body);
|
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
|
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
|
||||||
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
|
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
|
||||||
@@ -401,7 +438,8 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
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({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'BankCard',
|
req, action: 'CREATE', resourceType: 'BankCard',
|
||||||
resourceId: card.id.toString(),
|
resourceId: card.id.toString(),
|
||||||
@@ -421,7 +459,10 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
try {
|
try {
|
||||||
const cardId = parseInt(req.params.id);
|
const cardId = parseInt(req.params.id);
|
||||||
if (!(await canAccessBankCard(req, res, cardId))) return;
|
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
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.bankCard.findUnique({ where: { id: cardId } });
|
const before = await prisma.bankCard.findUnique({ where: { id: cardId } });
|
||||||
@@ -433,7 +474,7 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
if (before) {
|
if (before) {
|
||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
iban: 'IBAN', bic: 'BIC', bankName: 'Bank',
|
iban: 'IBAN', bic: 'BIC', bankName: 'Bank', cardNumber: 'Kartennummer',
|
||||||
accountHolder: 'Kontoinhaber', isActive: 'Aktiv',
|
accountHolder: 'Kontoinhaber', isActive: 'Aktiv',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import bcrypt from 'bcryptjs';
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { AUDIT_OPS_ROLLE } from '../services/user.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
import { pickUserCreate, pickUserUpdate, pickRoleUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||||
|
import { RechteEskalationError, RollenSperrError } from '../services/rechte.service.js';
|
||||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
@@ -84,7 +87,12 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = await userService.createUser(data);
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = await userService.createUser(data, handelnder);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'User',
|
req, action: 'CREATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
@@ -92,14 +100,11 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: user } as ApiResponse);
|
res.status(201).json({ success: true, data: user } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen des Benutzers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(req: Request, res: Response): Promise<void> {
|
export async function updateUser(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = parseInt(req.params.id);
|
const userId = parseInt(req.params.id);
|
||||||
// `permissions` und `password` darf der generische Update nicht
|
// `permissions` und `password` darf der generische Update nicht
|
||||||
@@ -161,10 +166,129 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
...beforeUser,
|
...beforeUser,
|
||||||
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
||||||
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
||||||
|
hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const user = await userService.updateUser(userId, data as any);
|
// Die drei Haken vergeben versteckte Rollen: DSGVO, Developer und
|
||||||
|
// Audit-Betrieb. Sie sind kein gewoehnliches Benutzerfeld - "Developer"
|
||||||
|
// traegt ALLE Rechte, "Audit-Betrieb" entscheidet, wer die
|
||||||
|
// Beweisgrundlage ersetzen darf.
|
||||||
|
//
|
||||||
|
// Bis 09/2026 stand hier, eine Rechte-Huerde scheitere am
|
||||||
|
// Henne-Ei-Problem: Nach der Aufteilung haelt zunaechst niemand
|
||||||
|
// `audit:admin`, also koennte ihn auch niemand vergeben. Das Argument war
|
||||||
|
// richtig, aber der Schluss zu weit. Ohne Huerde genuegte `users:create`,
|
||||||
|
// um sich zum Vollzugriff zu befoerdern - nicht einmal am eigenen Konto,
|
||||||
|
// sondern ueber ein frisch angelegtes zweites mit gesetztem Haken.
|
||||||
|
//
|
||||||
|
// Die Huerde steckt jetzt in der Teilmengenregel (rechte.service.ts):
|
||||||
|
// Wer einen Haken setzt, muss die dahinterliegenden Rechte selbst
|
||||||
|
// halten. Das Henne-Ei-Problem loest nicht die Weboberflaeche, sondern
|
||||||
|
// die Kommandozeile - `npx tsx prisma/rolle-zuweisen.ts` auf der
|
||||||
|
// Maschine. Das ist die richtige Grenze: "Datenbankzugriff" und
|
||||||
|
// "Protokoll neu berechnen" sollten Shell-Zugang voraussetzen, nicht ein
|
||||||
|
// Haekchen im Browser. Ein gestohlener Admin-Zugang hat den Container
|
||||||
|
// nicht.
|
||||||
|
const setztAuditBetrieb =
|
||||||
|
data.hasAuditOpsAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
|
||||||
|
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
|
||||||
|
|
||||||
|
const setztDsgvo =
|
||||||
|
data.hasGdprAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasGdprAccess !== (before as any).hasGdprAccess;
|
||||||
|
const setztDeveloper =
|
||||||
|
data.hasDeveloperAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasDeveloperAccess !== (before as any).hasDeveloperAccess;
|
||||||
|
|
||||||
|
// Keine Selbstbedienung. Die Teilmengenregel allein reicht dafuer nicht:
|
||||||
|
// Wer die Rechte bereits haelt, koennte sie sich formal selbst erneut
|
||||||
|
// zuweisen - und ein Vorgang, bei dem Antragsteller und Genehmigender
|
||||||
|
// dieselbe Person sind, hinterlaesst keine ueberpruefbare Spur.
|
||||||
|
if ((setztAuditBetrieb || setztDsgvo || setztDeveloper) && req.user?.userId === userId) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'DSGVO-Zugriff, Audit-Betrieb und Entwicklerzugriff lassen sich nicht am eigenen ' +
|
||||||
|
'Konto ändern. Bitte von einer anderen Person mit den entsprechenden Rechten ' +
|
||||||
|
'vornehmen lassen.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
|
||||||
|
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
|
||||||
|
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
|
||||||
|
// Routine erscheinen. Es bekommt deshalb dieselbe Behandlung wie das
|
||||||
|
// Absenken einer Aufbewahrungsfrist – Bestaetigung, laute Protokollierung,
|
||||||
|
// und kein Selbstbedienen.
|
||||||
|
const setztDienstkonto =
|
||||||
|
typeof (data as any).isServiceAccount === 'boolean' &&
|
||||||
|
(data as any).isServiceAccount !== before?.isServiceAccount;
|
||||||
|
const aktiviertDienstkonto = setztDienstkonto && (data as any).isServiceAccount === true;
|
||||||
|
|
||||||
|
// R184-02: Das Kennzeichen ist ein Audit-Governance-Eingriff, kein
|
||||||
|
// gewoehnliches Benutzerfeld – es ist Geschwister von retention-shorten,
|
||||||
|
// seal-backlog, rehash und cleanup, die alle `audit:admin` verlangen. Es
|
||||||
|
// haengt aber an der Route mit `users:update`. Heute deckungsgleich, weil
|
||||||
|
// nur die Administratorrolle beides hat – aber jede kuenftige Rolle mit
|
||||||
|
// „Benutzer bearbeiten“ wuerde still „Login-Alarme herunterstufen“ erben.
|
||||||
|
// Deshalb hier zusaetzlich auf die Domaene pruefen, in der der Schaden
|
||||||
|
// liegt.
|
||||||
|
if (setztDienstkonto && !(req.user?.permissions || []).includes('audit:admin')) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Das Dienstkonto-Kennzeichen ändert, wie Anmeldungen im Audit-Log bewertet werden. ' +
|
||||||
|
'Dafür genügt „Benutzer bearbeiten“ nicht – es erfordert die Berechtigung ' +
|
||||||
|
'audit:admin, wie das Verkürzen von Aufbewahrungsfristen auch.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setztDienstkonto && req.user?.userId === userId) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Das Dienstkonto-Kennzeichen lässt sich nicht am eigenen Konto setzen oder entfernen. ' +
|
||||||
|
'Es stuft Anmeldungen dieses Kontos als Routine ein – wer das für sich selbst täte, ' +
|
||||||
|
'könnte die eigenen Anmeldungen unauffällig machen. Bitte von einem anderen ' +
|
||||||
|
'Administrator vornehmen lassen.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate in BEIDE Richtungen (Pentest R184-01). Das Entfernen war ungegatet –
|
||||||
|
// und es ist der gefaehrlichere Weg: Ein un-geflaggtes Konto faellt aus der
|
||||||
|
// Heartbeat-Wache, weil diese am Live-Kennzeichen haengt. Genau das
|
||||||
|
// „stilllegen und auf Stille setzen“, gegen das der Wachhund gebaut wurde.
|
||||||
|
if (setztDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: aktiviertDienstkonto
|
||||||
|
? `Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im ` +
|
||||||
|
'Audit-Log als Routine geführt statt als kritisches Ereignis. Das ist für ' +
|
||||||
|
'planmäßig arbeitende Dienste gedacht (etwa das Gegenbuch) – für ein Konto, das ' +
|
||||||
|
'ein Mensch benutzt, wäre es eine Tarnung. ' +
|
||||||
|
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.'
|
||||||
|
: `Damit fällt ${before?.email ?? 'dieses Konto'} aus der Überwachung heraus: Es wird ` +
|
||||||
|
'dann nicht mehr gemeldet, wenn es sich nicht mehr anmeldet. Bei einem laufenden ' +
|
||||||
|
'Dienst wie dem Gegenbuch hieße das, sein Ausfall bliebe unbemerkt. ' +
|
||||||
|
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = await userService.updateUser(userId, data as any, handelnder);
|
||||||
if (user) {
|
if (user) {
|
||||||
// Audit: Geänderte Felder ermitteln und loggen
|
// Audit: Geänderte Felder ermitteln und loggen
|
||||||
if (before) {
|
if (before) {
|
||||||
@@ -172,6 +296,8 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||||
|
hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)',
|
||||||
|
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||||
@@ -191,9 +317,102 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
label: changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
label: setztDienstkonto
|
||||||
|
? `Dienstkonto-Kennzeichen ${aktiviertDienstkonto ? 'GESETZT' : 'entfernt'} für ` +
|
||||||
|
`${user.email} – Anmeldungen werden künftig ` +
|
||||||
|
`${aktiviertDienstkonto ? 'als Routine' : 'wieder als kritisch'} geführt`
|
||||||
|
: changeList
|
||||||
|
? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}`
|
||||||
|
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||||
|
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
||||||
|
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
||||||
|
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
|
||||||
details: Object.keys(changes).length > 0 ? changes : undefined,
|
details: Object.keys(changes).length > 0 ? changes : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Zusaetzlich in den Alarmkanal (Pentest R184-01). Eine CRITICAL-Zeile
|
||||||
|
// im Audit-Log muss jemand LESEN – das ist die R183-02-Klasse. Das
|
||||||
|
// Entfernen des Kennzeichens nimmt das Konto aus der Heartbeat-Wache;
|
||||||
|
// ab dann faellt sein Ausbleiben nicht mehr auf. Diese Aenderung
|
||||||
|
// gehoert deshalb dorthin, wo automatisch reagiert wird.
|
||||||
|
if (setztDienstkonto) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: aktiviertDienstkonto
|
||||||
|
? `Konto ${user.email} als Dienstkonto markiert – seine Anmeldungen gelten ab ` +
|
||||||
|
'jetzt als Routine statt als kritisches Ereignis.'
|
||||||
|
: `Konto ${user.email} ist KEIN Dienstkonto mehr – es fällt damit aus der ` +
|
||||||
|
'Überwachung heraus: Ein Ausbleiben seiner Anmeldungen wird nicht mehr gemeldet.',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSGVO und Entwicklerzugriff bekamen bis 09/2026 KEINEN Eintrag im
|
||||||
|
// Alarmkanal - nur Audit-Betrieb und das Dienstkonto-Kennzeichen.
|
||||||
|
// Dabei traegt die Developer-Rolle saemtliche Rechte des Systems:
|
||||||
|
// Der weitreichendste Haken war der leiseste.
|
||||||
|
if (setztDeveloper) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: data.hasDeveloperAccess === true
|
||||||
|
? `Konto ${user.email} hat ab jetzt Entwicklerzugriff – das schliesst die ` +
|
||||||
|
'Datenbankwerkzeuge und damit saemtliche Rechte des Systems ein.'
|
||||||
|
: `Konto ${user.email} hat keinen Entwicklerzugriff mehr.`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { betroffenesKonto: user.email, aktiviert: data.hasDeveloperAccess === true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setztDsgvo) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: data.hasGdprAccess === true
|
||||||
|
? `Konto ${user.email} darf ab jetzt das Audit-Protokoll lesen und exportieren ` +
|
||||||
|
'sowie Auskunft und Loeschung nach DSGVO ausfuehren.'
|
||||||
|
: `Konto ${user.email} hat keinen DSGVO-Zugriff mehr – es kann damit keine ` +
|
||||||
|
'Auskunft nach Art. 15 und keine Loeschung nach Art. 17 mehr ausfuehren.',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { betroffenesKonto: user.email, aktiviert: data.hasGdprAccess === true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setztAuditBetrieb) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: aktiviertAuditBetrieb
|
||||||
|
? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` +
|
||||||
|
'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' +
|
||||||
|
'ersetzen, gegen die Manipulation nachgewiesen wird.'
|
||||||
|
: `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: {
|
||||||
|
betroffenesKonto: user.email,
|
||||||
|
aktiviert: aktiviertAuditBetrieb,
|
||||||
|
selbstvergabe: user.id === req.user?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
@@ -204,10 +423,7 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
res.json({ success: true, data: user } as ApiResponse);
|
res.json({ success: true, data: user } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren des Benutzers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +480,15 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = await userService.updateUser(userId, { password } as any);
|
const handelnderPw = handelnderOderNull(req);
|
||||||
|
if (handelnderPw === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reines Kennwort-Setzen: Der Zuwachs ist leer, die Teilmengenregel
|
||||||
|
// laeuft ins Leere. Der Handelnde wird trotzdem uebergeben, damit es
|
||||||
|
// keine Signatur gibt, die ihn weglassen darf.
|
||||||
|
const user = await userService.updateUser(userId, { password } as any, handelnderPw);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
@@ -313,6 +537,45 @@ export async function deleteUser(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Roles
|
// Roles
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet jede Aenderung am Rollenmodell in den Alarmkanal.
|
||||||
|
*
|
||||||
|
* Die Rollenpflege war bis 09/2026 der einzige Eingriff in die Rechtevergabe
|
||||||
|
* ohne SecurityEvent - Aenderungen an einzelnen Konten wurden gemeldet, das
|
||||||
|
* Umschreiben einer Rolle, die an zwanzig Konten haengt, nicht. Damit war
|
||||||
|
* ausgerechnet der wirksamste Weg der leiseste.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Die ID des Handelnden - oder null.
|
||||||
|
*
|
||||||
|
* `userId` fehlt bei Kundenportal-Anmeldungen. Ohne sie laesst sich die
|
||||||
|
* Teilmengenregel nicht auswerten, und "nicht auswertbar" darf nicht
|
||||||
|
* stillschweigend zu "erlaubt" werden. Deshalb wird hier abgelehnt statt
|
||||||
|
* durchgewinkt.
|
||||||
|
*/
|
||||||
|
function handelnderOderNull(req: AuthRequest): number | null {
|
||||||
|
return typeof req.user?.userId === 'number' ? req.user.userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function meldeRollenAenderung(
|
||||||
|
req: AuthRequest,
|
||||||
|
nachricht: string,
|
||||||
|
details: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: nachricht,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRoles(req: Request, res: Response): Promise<void> {
|
export async function getRoles(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const roles = await userService.getAllRoles();
|
const roles = await userService.getAllRoles();
|
||||||
@@ -344,43 +607,138 @@ 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;
|
||||||
|
}
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : fallback,
|
||||||
|
} as ApiResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRole(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const role = await userService.createRole(req.body);
|
const daten = pickRoleUpdate(req.body);
|
||||||
|
const fehler = pruefeRollenEingabe(daten, true);
|
||||||
|
if (fehler) {
|
||||||
|
res.status(400).json({ success: false, error: fehler } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await userService.createRole(
|
||||||
|
{
|
||||||
|
name: daten.name as string,
|
||||||
|
description: daten.description as string | undefined,
|
||||||
|
permissionIds: daten.permissionIds as number[],
|
||||||
|
},
|
||||||
|
handelnder,
|
||||||
|
);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Role',
|
req, action: 'CREATE', resourceType: 'Role',
|
||||||
resourceId: role.id.toString(),
|
resourceId: role.id.toString(),
|
||||||
label: `Rolle ${role.name} angelegt`,
|
label: `Rolle ${role.name} angelegt`,
|
||||||
});
|
});
|
||||||
|
meldeRollenAenderung(req, `Rolle "${role.name}" angelegt`, {
|
||||||
|
rolle: role.name,
|
||||||
|
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||||
|
});
|
||||||
res.status(201).json({ success: true, data: role } as ApiResponse);
|
res.status(201).json({ success: true, data: role } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen der Rolle');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Rolle',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRole(req: Request, res: Response): Promise<void> {
|
export async function updateRole(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const role = await userService.updateRole(parseInt(req.params.id), req.body);
|
const daten = pickRoleUpdate(req.body);
|
||||||
if (role) {
|
const fehler = pruefeRollenEingabe(daten, false);
|
||||||
await logChange({
|
if (fehler) {
|
||||||
req, action: 'UPDATE', resourceType: 'Role',
|
res.status(400).json({ success: false, error: fehler } as ApiResponse);
|
||||||
resourceId: role.id.toString(),
|
return;
|
||||||
label: `Rolle ${role.name} aktualisiert`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await userService.updateRole(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
{
|
||||||
|
name: daten.name as string | undefined,
|
||||||
|
description: daten.description as string | undefined,
|
||||||
|
permissionIds: daten.permissionIds as number[] | undefined,
|
||||||
|
},
|
||||||
|
handelnder,
|
||||||
|
);
|
||||||
|
if (!role) {
|
||||||
|
res.status(404).json({ success: false, error: 'Rolle nicht gefunden' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await logChange({
|
||||||
|
req, action: 'UPDATE', resourceType: 'Role',
|
||||||
|
resourceId: role.id.toString(),
|
||||||
|
label: `Rolle ${role.name} aktualisiert`,
|
||||||
|
});
|
||||||
|
meldeRollenAenderung(req, `Rechte der Rolle "${role.name}" geändert`, {
|
||||||
|
rolle: role.name,
|
||||||
|
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||||
|
traegerAbgemeldet: daten.permissionIds !== undefined,
|
||||||
|
});
|
||||||
res.json({ success: true, data: role } as ApiResponse);
|
res.json({ success: true, data: role } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren der Rolle');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rolle',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(req: Request, res: Response): Promise<void> {
|
export async function deleteRole(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const roleId = parseInt(req.params.id);
|
const roleId = parseInt(req.params.id);
|
||||||
const role = await userService.getRoleById(roleId);
|
const role = await userService.getRoleById(roleId);
|
||||||
@@ -390,12 +748,12 @@ export async function deleteRole(req: Request, res: Response): Promise<void> {
|
|||||||
resourceId: roleId.toString(),
|
resourceId: roleId.toString(),
|
||||||
label: `Rolle ${role?.name || roleId} gelöscht`,
|
label: `Rolle ${role?.name || roleId} gelöscht`,
|
||||||
});
|
});
|
||||||
|
meldeRollenAenderung(req, `Rolle "${role?.name || roleId}" gelöscht`, {
|
||||||
|
rolle: role?.name,
|
||||||
|
});
|
||||||
res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Löschen der Rolle');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rolle',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+76
-54
@@ -1,4 +1,4 @@
|
|||||||
import express from 'express';
|
import express, { Router } from 'express';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
@@ -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}`;
|
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 authRoutes from './routes/auth.routes.js';
|
||||||
import customerRoutes from './routes/customer.routes.js';
|
import customerRoutes from './routes/customer.routes.js';
|
||||||
import addressRoutes from './routes/address.routes.js';
|
import addressRoutes from './routes/address.routes.js';
|
||||||
@@ -68,8 +69,11 @@ import { startContractStatusScheduler } from './services/contractStatusScheduler
|
|||||||
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
||||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||||
|
import { registriereIdPruefung } from './middleware/routeIds.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
|
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
||||||
|
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
|
||||||
import { authenticate } from './middleware/auth.js';
|
import { authenticate } from './middleware/auth.js';
|
||||||
|
|
||||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||||
@@ -324,64 +328,76 @@ app.use('/api', (_req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
|
||||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` – also `6abc`,
|
||||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
|
||||||
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
|
// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
|
||||||
|
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
|
||||||
//
|
//
|
||||||
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
|
// Genau das loest `mounte()` weiter unten – die Pruefung wird an jedem Router
|
||||||
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
|
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
|
||||||
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
|
// zwar in beide Richtungen:
|
||||||
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
|
//
|
||||||
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
|
// – Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
|
||||||
// nicht geblockt.
|
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
|
||||||
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
|
// ID-Parameter und laesst nur kanonische Zahlen zu.
|
||||||
app.use('/api', (req, res, next) => {
|
// – Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
|
||||||
for (const seg of req.path.split('/')) {
|
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
|
||||||
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
|
//
|
||||||
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
|
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt – zwei Antworten
|
||||||
return;
|
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
|
||||||
}
|
|
||||||
}
|
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
||||||
next();
|
// 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)
|
// Öffentliche Routes (OHNE Authentifizierung)
|
||||||
app.use('/api/public/consent', consentPublicRoutes);
|
mounte('/api/public/consent', consentPublicRoutes);
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
mounte('/api/auth', authRoutes);
|
||||||
app.use('/api/customers', customerRoutes);
|
mounte('/api/customers', customerRoutes);
|
||||||
app.use('/api/addresses', addressRoutes);
|
mounte('/api/addresses', addressRoutes);
|
||||||
app.use('/api/bank-cards', bankcardRoutes);
|
mounte('/api/bank-cards', bankcardRoutes);
|
||||||
app.use('/api/documents', documentRoutes);
|
mounte('/api/documents', documentRoutes);
|
||||||
app.use('/api/meters', meterRoutes);
|
mounte('/api/meters', meterRoutes);
|
||||||
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
|
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||||
app.use('/api/contracts', contractRoutes);
|
mounte('/api/contracts', contractRoutes);
|
||||||
app.use('/api/credit-notes', creditNoteRoutes);
|
mounte('/api/credit-notes', creditNoteRoutes);
|
||||||
app.use('/api/company-profile', companyProfileRoutes);
|
mounte('/api/company-profile', companyProfileRoutes);
|
||||||
app.use('/api/platforms', platformRoutes);
|
mounte('/api/platforms', platformRoutes);
|
||||||
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
|
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||||
app.use('/api/contract-durations', contractDurationRoutes);
|
mounte('/api/contract-durations', contractDurationRoutes);
|
||||||
app.use('/api/providers', providerRoutes);
|
mounte('/api/providers', providerRoutes);
|
||||||
app.use('/api/tariffs', tariffRoutes);
|
mounte('/api/tariffs', tariffRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
mounte('/api/users', userRoutes);
|
||||||
app.use('/api/upload', uploadRoutes);
|
mounte('/api/upload', uploadRoutes);
|
||||||
app.use('/api/developer', developerRoutes);
|
mounte('/api/developer', developerRoutes);
|
||||||
app.use('/api/contract-categories', contractCategoryRoutes);
|
mounte('/api/contract-categories', contractCategoryRoutes);
|
||||||
app.use('/api', contractTaskRoutes);
|
mounte('/api', contractTaskRoutes);
|
||||||
app.use('/api/settings', appSettingRoutes);
|
mounte('/api/settings', appSettingRoutes);
|
||||||
app.use('/api/email-providers', emailProviderRoutes);
|
mounte('/api/email-providers', emailProviderRoutes);
|
||||||
app.use('/api', cachedEmailRoutes);
|
mounte('/api', cachedEmailRoutes);
|
||||||
app.use('/api/energy-details', invoiceRoutes);
|
mounte('/api/energy-details', invoiceRoutes);
|
||||||
app.use('/api', contractHistoryRoutes);
|
mounte('/api', contractHistoryRoutes);
|
||||||
app.use('/api/audit-logs', auditLogRoutes);
|
mounte('/api/audit-logs', auditLogRoutes);
|
||||||
app.use('/api/gdpr', gdprRoutes);
|
mounte('/api/gdpr', gdprRoutes);
|
||||||
app.use('/api/email-logs', emailLogRoutes);
|
mounte('/api/email-logs', emailLogRoutes);
|
||||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
mounte('/api/pdf-templates', pdfTemplateRoutes);
|
||||||
app.use('/api/birthdays', birthdayRoutes);
|
mounte('/api/birthdays', birthdayRoutes);
|
||||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
mounte('/api/factory-defaults', factoryDefaultsRoutes);
|
||||||
app.use('/api/monitoring', monitoringRoutes);
|
mounte('/api/monitoring', monitoringRoutes);
|
||||||
|
|
||||||
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
||||||
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
||||||
@@ -481,6 +497,12 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
|
|||||||
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
||||||
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
||||||
|
|
||||||
|
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
||||||
|
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
||||||
|
starteHeartbeatMonitor();
|
||||||
|
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
|
||||||
|
void pruefePflichtrechte();
|
||||||
|
|
||||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ function determineAction(method: string, path: string, success: boolean): AuditA
|
|||||||
if (path.includes('/auth/logout')) {
|
if (path.includes('/auth/logout')) {
|
||||||
return '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
|
// Standard CRUD-Aktionen
|
||||||
switch (method.toUpperCase()) {
|
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
|
* 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
|
// Aus Route-Parameter
|
||||||
const customerId = req.params.customerId || req.params.id;
|
const customerId = req.params.customerId || req.params.id;
|
||||||
if (customerId && req.path.includes('/customers')) {
|
if (customerId && fullPath.includes('/customers')) {
|
||||||
return parseInt(customerId);
|
return parseInt(customerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +164,40 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
LOGIN: 'angemeldet',
|
LOGIN: 'angemeldet',
|
||||||
LOGOUT: 'abgemeldet',
|
LOGOUT: 'abgemeldet',
|
||||||
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
|
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
|
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
|
||||||
*/
|
*/
|
||||||
@@ -166,7 +205,8 @@ function generateHumanLabel(
|
|||||||
action: AuditAction,
|
action: AuditAction,
|
||||||
resourceType: string,
|
resourceType: string,
|
||||||
req: AuthRequest,
|
req: AuthRequest,
|
||||||
responseBody: unknown
|
responseBody: unknown,
|
||||||
|
fullPath: string
|
||||||
): string {
|
): string {
|
||||||
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
|
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
|
||||||
const actionName = ACTION_LABELS[action] || action;
|
const actionName = ACTION_LABELS[action] || action;
|
||||||
@@ -188,16 +228,28 @@ function generateHumanLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spezial-Labels für bestimmte Endpunkte
|
// Spezial-Labels für bestimmte Endpunkte
|
||||||
const path = req.path;
|
// (fullPath statt req.path – siehe Hinweis in auditMiddleware, Pentest R165)
|
||||||
|
const path = fullPath;
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
|
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
|
||||||
const email = req.body?.email || '';
|
const email = req.body?.email || '';
|
||||||
|
if (action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, true)) {
|
||||||
|
return `Dienstkonto ${email} angemeldet (planmäßig)`;
|
||||||
|
}
|
||||||
return action === 'LOGIN'
|
return action === 'LOGIN'
|
||||||
? `Benutzer ${email} hat sich angemeldet`
|
? `Benutzer ${email} hat sich angemeldet`
|
||||||
: `Anmeldung fehlgeschlagen für ${email}`;
|
: `Anmeldung fehlgeschlagen für ${email}`;
|
||||||
}
|
}
|
||||||
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
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
|
// Kunden-Operationen
|
||||||
if (resourceType === 'Customer') {
|
if (resourceType === 'Customer') {
|
||||||
@@ -359,14 +411,23 @@ function generateHumanLabel(
|
|||||||
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||||
const startTime = Date.now();
|
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
|
// Ausgeschlossene Routen überspringen
|
||||||
if (EXCLUDED_ROUTES.some((route) => req.path.startsWith(route))) {
|
if (EXCLUDED_ROUTES.some((route) => fullPath.startsWith(route))) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resource-Mapping finden
|
// Resource-Mapping finden
|
||||||
const mapping = findResourceMapping(req.path);
|
const mapping = findResourceMapping(fullPath);
|
||||||
if (!mapping) {
|
if (!mapping) {
|
||||||
// Unbekannte Route - trotzdem loggen mit generischem Typ
|
// Unbekannte Route - trotzdem loggen mit generischem Typ
|
||||||
next();
|
next();
|
||||||
@@ -402,7 +463,7 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
|||||||
setImmediate(async () => {
|
setImmediate(async () => {
|
||||||
try {
|
try {
|
||||||
const durationMs = Date.now() - startTime;
|
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)
|
// READ-Aktionen nicht loggen (nur Änderungen, Logins und Exporte)
|
||||||
if (action === 'READ') return;
|
if (action === 'READ') return;
|
||||||
@@ -415,19 +476,19 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
|||||||
'/api/gdpr',
|
'/api/gdpr',
|
||||||
'/api/upload',
|
'/api/upload',
|
||||||
];
|
];
|
||||||
// Login/Logout immer loggen
|
// Login/Logout/Refresh immer loggen
|
||||||
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED') {
|
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED' && action !== 'TOKEN_REFRESH') {
|
||||||
if (manuallyLoggedPaths.some(p => req.originalUrl?.startsWith(p) || req.baseUrl?.startsWith(p))) return;
|
if (manuallyLoggedPaths.some(p => fullPath.startsWith(p))) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceId = mapping.extractId?.(req);
|
const resourceId = mapping.extractId?.(req);
|
||||||
const dataSubjectId = extractDataSubjectId(req);
|
const dataSubjectId = extractDataSubjectId(req, fullPath);
|
||||||
|
|
||||||
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
|
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
|
||||||
const auditContext = capturedAuditContext;
|
const auditContext = capturedAuditContext;
|
||||||
|
|
||||||
// Menschenlesbares Label generieren
|
// Menschenlesbares Label generieren
|
||||||
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
|
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody, fullPath);
|
||||||
|
|
||||||
await createAuditLog({
|
await createAuditLog({
|
||||||
userId: req.user?.userId,
|
userId: req.user?.userId,
|
||||||
@@ -436,10 +497,20 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
|||||||
customerId: req.user?.customerId,
|
customerId: req.user?.customerId,
|
||||||
isCustomerPortal: req.user?.isCustomerPortal,
|
isCustomerPortal: req.user?.isCustomerPortal,
|
||||||
action,
|
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,
|
resourceType: mapping.type,
|
||||||
resourceId,
|
resourceId,
|
||||||
resourceLabel,
|
resourceLabel,
|
||||||
endpoint: req.path,
|
endpoint: fullPath,
|
||||||
httpMethod: req.method,
|
httpMethod: req.method,
|
||||||
ipAddress: getClientIp(req),
|
ipAddress: getClientIp(req),
|
||||||
userAgent: req.headers['user-agent'],
|
userAgent: req.headers['user-agent'],
|
||||||
|
|||||||
@@ -132,6 +132,47 @@ export const staffPasswordReAuthLimiter = rateLimit({
|
|||||||
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
|
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
|
||||||
* für legitime Kunden weit aus.
|
* 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({
|
export const publicConsentRateLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -61,11 +61,17 @@ router.post(
|
|||||||
backupController.createBackup
|
backupController.createBackup
|
||||||
);
|
);
|
||||||
|
|
||||||
// Backup wiederherstellen
|
// Backup wiederherstellen.
|
||||||
|
//
|
||||||
|
// Ebenfalls UND-verknuepft: Ein Restore ersetzt auch Benutzer, Rollen und
|
||||||
|
// Rechtezuordnungen. Wer eine aeltere Sicherung einspielt, in der er selbst
|
||||||
|
// mehr durfte, hat sich damit befoerdert - derselbe Umweg wie beim
|
||||||
|
// Werksreset, nur leiser.
|
||||||
router.post(
|
router.post(
|
||||||
'/backup/:name/restore',
|
'/backup/:name/restore',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings:update'),
|
requirePermission('settings:update'),
|
||||||
|
requirePermission('roles:manage'),
|
||||||
backupController.restoreBackup
|
backupController.restoreBackup
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,11 +100,23 @@ router.post(
|
|||||||
backupController.uploadBackup
|
backupController.uploadBackup
|
||||||
);
|
);
|
||||||
|
|
||||||
// Werkseinstellungen (alles löschen)
|
// Werkseinstellungen (alles löschen).
|
||||||
|
//
|
||||||
|
// Verlangt UND-verknuepft `settings:update` und `roles:manage` - zwei
|
||||||
|
// requirePermission hintereinander, weil ein einzelner Aufruf mit mehreren
|
||||||
|
// Rechten ODER bedeutet.
|
||||||
|
//
|
||||||
|
// Grund: Der Werksreset loescht alle Rollen und Rechtezuordnungen und legt
|
||||||
|
// ein frisches admin@admin.com an. Eine Rolle mit nur `settings:update`
|
||||||
|
// haette damit die gesamte Rechtevergabe zuruecksetzen und sich anschliessend
|
||||||
|
// ueber das neue Konto anmelden koennen - eine Rechteerhoehung ueber den
|
||||||
|
// Umweg "alles wegwerfen". Wer das Rechtemodell platt machen darf, muss es
|
||||||
|
// auch pflegen duerfen.
|
||||||
router.post(
|
router.post(
|
||||||
'/factory-reset',
|
'/factory-reset',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings:update'),
|
requirePermission('settings:update'),
|
||||||
|
requirePermission('roles:manage'),
|
||||||
backupController.factoryReset
|
backupController.factoryReset
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,29 +7,50 @@ const router = Router();
|
|||||||
// Alle Routen erfordern Authentifizierung
|
// Alle Routen erfordern Authentifizierung
|
||||||
router.use(authenticate);
|
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
|
// Audit-Logs abrufen
|
||||||
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
|
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
|
||||||
|
|
||||||
// Audit-Logs exportieren (muss VOR /:id stehen!)
|
// Audit-Logs exportieren
|
||||||
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs);
|
//
|
||||||
|
// Verlangt `audit:export`, nicht `audit:read`. Die Berechtigung stand im
|
||||||
|
// Katalog und in der Rollenverwaltung, gatete aber NICHTS - jeder Leser
|
||||||
|
// konnte das vollstaendige Protokoll in einem Zug herausziehen.
|
||||||
|
//
|
||||||
|
// Das ist etwas anderes als Blaettern: Der Export liefert `changesBefore` und
|
||||||
|
// `changesAfter`, also die vollstaendigen Vorher/Nachher-Datensaetze, dazu
|
||||||
|
// `resourceLabel` mit Klartextnamen, IP-Adressen und User-Agents. Aufgefallen
|
||||||
|
// am Dienstkonto des Gegenbuchs: Es soll ausschliesslich Pruefwerte lesen -
|
||||||
|
// und konnte Personendaten exportieren.
|
||||||
|
router.get('/export', requirePermission('audit:export'), auditLogController.exportAuditLogs);
|
||||||
|
|
||||||
|
// Retention-Policies
|
||||||
|
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)
|
// Audit-Logs für einen Kunden (DSGVO)
|
||||||
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
|
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
|
// Hash-Ketten-Integrität prüfen
|
||||||
router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity);
|
router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity);
|
||||||
|
|
||||||
// Hash-Kette reparieren
|
// Hash-Kette reparieren
|
||||||
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
|
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
|
||||||
|
|
||||||
// Retention-Policies
|
// Altbestand versiegeln (nicht destruktiv)
|
||||||
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
|
router.post('/seal-backlog', requirePermission('audit:admin'), auditLogController.sealBacklog);
|
||||||
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
|
|
||||||
|
|
||||||
// Retention-Cleanup manuell ausführen
|
// Retention-Cleanup manuell ausführen
|
||||||
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
// Kuendigungsfristen haben eigene Rechte (`cancellation-periods:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
|
||||||
|
// nicht. `cancellation-periods:*` stand im Katalog und bewachte nichts.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js';
|
import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', authenticate, cancellationPeriodController.getCancellationPeriods);
|
router.get('/', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriods);
|
||||||
router.post('/', authenticate, requirePermission('platforms:create'), cancellationPeriodController.createCancellationPeriod);
|
router.post('/', authenticate, requirePermission('cancellation-periods:create'), cancellationPeriodController.createCancellationPeriod);
|
||||||
router.get('/:id', authenticate, cancellationPeriodController.getCancellationPeriod);
|
router.get('/:id', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriod);
|
||||||
router.put('/:id', authenticate, requirePermission('platforms:update'), cancellationPeriodController.updateCancellationPeriod);
|
router.put('/:id', authenticate, requirePermission('cancellation-periods:update'), cancellationPeriodController.updateCancellationPeriod);
|
||||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), cancellationPeriodController.deleteCancellationPeriod);
|
router.delete('/:id', authenticate, requirePermission('cancellation-periods:delete'), cancellationPeriodController.deleteCancellationPeriod);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
// Vertragslaufzeiten haben eigene Rechte (`contract-durations:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
|
||||||
|
// nicht. `contract-durations:*` stand im Katalog und bewachte nichts.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as contractDurationController from '../controllers/contract-duration.controller.js';
|
import * as contractDurationController from '../controllers/contract-duration.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', authenticate, contractDurationController.getContractDurations);
|
router.get('/', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDurations);
|
||||||
router.post('/', authenticate, requirePermission('platforms:create'), contractDurationController.createContractDuration);
|
router.post('/', authenticate, requirePermission('contract-durations:create'), contractDurationController.createContractDuration);
|
||||||
router.get('/:id', authenticate, contractDurationController.getContractDuration);
|
router.get('/:id', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDuration);
|
||||||
router.put('/:id', authenticate, requirePermission('platforms:update'), contractDurationController.updateContractDuration);
|
router.put('/:id', authenticate, requirePermission('contract-durations:update'), contractDurationController.updateContractDuration);
|
||||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), contractDurationController.deleteContractDuration);
|
router.delete('/:id', authenticate, requirePermission('contract-durations:delete'), contractDurationController.deleteContractDuration);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Lesen für alle authentifizierten Benutzer
|
// Lesen verlangt jetzt `contract-categories:read`, vorher genuegte
|
||||||
router.get('/', authenticate, contractCategoryController.getContractCategories);
|
// Angemeldetsein. Das Recht stand im Katalog und bewachte nichts; die
|
||||||
router.get('/:id', authenticate, contractCategoryController.getContractCategory);
|
// Migration vergibt es an jede Rolle, die die Anwendung benutzt, damit
|
||||||
|
// niemand seine Listen verliert.
|
||||||
|
router.get('/', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategories);
|
||||||
|
router.get('/:id', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategory);
|
||||||
|
|
||||||
// Ändern/Löschen: `contract-categories:*` – wird per seed.ts an Admin-
|
// Ändern/Löschen: `contract-categories:*` – wird per seed.ts an Admin-
|
||||||
// Rollen vergeben. Vorher stand hier `developer:access` mit dem
|
// Rollen vergeben. Vorher stand hier `developer:access` mit dem
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ router.get(
|
|||||||
contractTaskController.getTaskStats
|
contractTaskController.getTaskStats
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Allgemeine Aufgabe OHNE Vertrag/Kunde anlegen (nur Mitarbeiter)
|
||||||
|
router.post(
|
||||||
|
'/tasks',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:update'),
|
||||||
|
contractTaskController.createGeneralTask
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== TASKS BY CONTRACT ====================
|
// ==================== TASKS BY CONTRACT ====================
|
||||||
|
|
||||||
// Alle Aufgaben eines Vertrags abrufen (auch für Kundenportal, aber nur sichtbare)
|
// Alle Aufgaben eines Vertrags abrufen (auch für Kundenportal, aber nur sichtbare)
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
// Die Providerkonfiguration hat eigene Rechte (`email-providers:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten diese Routen auf `settings:read`/`settings:update` -
|
||||||
|
// wer irgendeine Einstellung aendern durfte, konnte damit auch die
|
||||||
|
// Zugangsdaten des Mailservers auslesen und aendern. `email-providers:*`
|
||||||
|
// stand derweil im Katalog und bewachte nichts.
|
||||||
|
//
|
||||||
|
// BEWUSST NICHT umgestellt: /domain und /public-settings (ungegatet, werden
|
||||||
|
// vom Kundenportal beim Postfach-Antrag gebraucht) sowie check/provision/
|
||||||
|
// deprovision - das sind kundenbezogene Vorgaenge auf `customers:*`, keine
|
||||||
|
// Providerkonfiguration. `email-providers:*` waere dort die falsche Domaene.
|
||||||
// ==================== EMAIL PROVIDER ROUTES ====================
|
// ==================== EMAIL PROVIDER ROUTES ====================
|
||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
@@ -7,15 +18,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Provider Config CRUD (Admin-only)
|
// Provider Config CRUD (Admin-only)
|
||||||
router.get('/configs', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfigs);
|
router.get('/configs', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfigs);
|
||||||
router.get('/configs/:id', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfig);
|
router.get('/configs/:id', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfig);
|
||||||
router.post('/configs', authenticate, requirePermission('settings:update'), emailProviderController.createProviderConfig);
|
router.post('/configs', authenticate, requirePermission('email-providers:create'), emailProviderController.createProviderConfig);
|
||||||
router.put('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.updateProviderConfig);
|
router.put('/configs/:id', authenticate, requirePermission('email-providers:update'), emailProviderController.updateProviderConfig);
|
||||||
router.delete('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.deleteProviderConfig);
|
router.delete('/configs/:id', authenticate, requirePermission('email-providers:delete'), emailProviderController.deleteProviderConfig);
|
||||||
|
|
||||||
// Email Operations
|
// Email Operations
|
||||||
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
|
router.post('/test-connection', authenticate, requirePermission('email-providers:update'), emailProviderController.testConnection);
|
||||||
router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess);
|
router.post('/test-mail-access', authenticate, requirePermission('email-providers:update'), emailProviderController.testMailAccess);
|
||||||
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
|
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
|
||||||
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
|
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
|
||||||
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
// Lesen verlangt jetzt `platforms:read`, vorher genuegte Angemeldetsein.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as platformController from '../controllers/platform.controller.js';
|
import * as platformController from '../controllers/platform.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', authenticate, platformController.getPlatforms);
|
router.get('/', authenticate, requirePermission('platforms:read'), platformController.getPlatforms);
|
||||||
router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform);
|
router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform);
|
||||||
router.get('/:id', authenticate, platformController.getPlatform);
|
router.get('/:id', authenticate, requirePermission('platforms:read'), platformController.getPlatform);
|
||||||
router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform);
|
router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform);
|
||||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform);
|
router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform);
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ router.get('/:id', authenticate, requirePermission('providers:read'), providerCo
|
|||||||
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
||||||
|
|
||||||
// Nested tariff routes
|
// Tarife unter dem Anbieter - eigene Rechte, siehe tariff.routes.ts
|
||||||
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
|
router.get('/:providerId/tariffs', authenticate, requirePermission('tariffs:read'), tariffController.getTariffs);
|
||||||
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
|
router.post('/:providerId/tariffs', authenticate, requirePermission('tariffs:create'), tariffController.createTariff);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
// Tarife haben eigene Rechte (`tariffs:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten diese Routen auf `providers:*`, waehrend `tariffs:*`
|
||||||
|
// im Katalog stand und nichts bewachte - man konnte "Tarife bearbeiten"
|
||||||
|
// anhaken, ohne dass es etwas bewirkte, und wer Anbieter pflegen durfte,
|
||||||
|
// durfte automatisch auch alle Tarife aendern.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as tariffController from '../controllers/tariff.controller.js';
|
import * as tariffController from '../controllers/tariff.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
@@ -5,8 +11,8 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Standalone tariff routes (for update/delete by tariff id)
|
// Standalone tariff routes (for update/delete by tariff id)
|
||||||
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
|
router.get('/:id', authenticate, requirePermission('tariffs:read'), tariffController.getTariff);
|
||||||
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
|
router.put('/:id', authenticate, requirePermission('tariffs:update'), tariffController.updateTariff);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
|
router.delete('/:id', authenticate, requirePermission('tariffs:delete'), tariffController.deleteTariff);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '../utils/accessControl.js';
|
} from '../utils/accessControl.js';
|
||||||
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||||
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
||||||
import { maybeCancelOnCancellationConfirmation } from '../services/contractStatusScheduler.service.js';
|
import { maybeCancelOnCancellationConfirmation, maybeMarkAwaitingConfirmationOnLetter } from '../services/contractStatusScheduler.service.js';
|
||||||
import { deleteFileAbsolute, deleteUploadByRelativePath } from '../utils/fileCleanup.js';
|
import { deleteFileAbsolute, deleteUploadByRelativePath } from '../utils/fileCleanup.js';
|
||||||
|
|
||||||
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
||||||
@@ -691,12 +691,17 @@ async function handleContractDocumentUpload(
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde:
|
// Kündigungsbestätigung (nicht "Optionen") hochgeladen → Vertrag auf
|
||||||
// Vertrag ACTIVE → CANCELLED und Vertragsende = Kündigungsdatum.
|
// "Gekündigt / bestätigt" (CANCELLED_CONFIRMED) + Vertragsende = Kündigungs-
|
||||||
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
|
// datum. "Optionen" ist für Vertrags-Änderungen gedacht, nicht für Kündigungen.
|
||||||
if (fieldName === 'cancellationConfirmationPath') {
|
if (fieldName === 'cancellationConfirmationPath') {
|
||||||
await maybeCancelOnCancellationConfirmation(contractId, req);
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -16,14 +16,26 @@ router.delete('/:id', authenticate, requirePermission('users:delete'), userContr
|
|||||||
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
|
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
|
||||||
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
|
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
|
||||||
|
|
||||||
// Roles
|
// Rollen und Rechte.
|
||||||
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
|
//
|
||||||
router.post('/roles', authenticate, requirePermission('users:create'), userController.createRole);
|
// Die Schreibwege haengen an `roles:manage`, nicht mehr an `users:*`:
|
||||||
router.get('/roles/:id', authenticate, requirePermission('users:read'), userController.getRole);
|
// "Konten anlegen" und "festlegen, was ein Konto darf" sind zwei
|
||||||
router.put('/roles/:id', authenticate, requirePermission('users:update'), userController.updateRole);
|
// verschiedene Befugnisse. Wer beides hatte, konnte sich eine Rolle mit
|
||||||
router.delete('/roles/:id', authenticate, requirePermission('users:delete'), userController.deleteRole);
|
// `developer:access` bauen und zuweisen - die Rollenverwaltung war damit
|
||||||
|
// faktisch eine Rechteerhoehung mit Zwischenschritt. Die zweite Haelfte des
|
||||||
|
// Riegels ist die Teilmengenregel in rechte.service.ts.
|
||||||
|
//
|
||||||
|
// Die LESEwege bleiben zusaetzlich mit `users:read` erreichbar: Das
|
||||||
|
// Benutzerformular braucht die Rollenliste zum Zuweisen, ohne dass der
|
||||||
|
// Bearbeiter Rollen pflegen koennen muss. `requirePermission` ist
|
||||||
|
// ODER-verknuepft, das traegt ohne Zusatzcode.
|
||||||
|
router.get('/roles/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRoles);
|
||||||
|
router.post('/roles', authenticate, requirePermission('roles:manage'), userController.createRole);
|
||||||
|
router.get('/roles/:id', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRole);
|
||||||
|
router.put('/roles/:id', authenticate, requirePermission('roles:manage'), userController.updateRole);
|
||||||
|
router.delete('/roles/:id', authenticate, requirePermission('roles:manage'), userController.deleteRole);
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
router.get('/permissions/list', authenticate, requirePermission('users:read'), userController.getPermissions);
|
router.get('/permissions/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getPermissions);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,10 +23,194 @@ export function signAccessToken(payload: JwtPayload): string {
|
|||||||
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function signRefreshToken(payload: JwtPayload): string {
|
export function signRefreshToken(payload: JwtPayload, jti?: string, familyId?: string): string {
|
||||||
return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, {
|
return jwt.sign(
|
||||||
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
{ ...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
|
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
|
||||||
@@ -133,8 +317,10 @@ export async function login(email: string, password: string) {
|
|||||||
isCustomerPortal: false,
|
isCustomerPortal: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await pruneExpiredRefreshTokens();
|
||||||
const accessToken = signAccessToken(payload);
|
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 {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -147,6 +333,9 @@ export async function login(email: string, password: string) {
|
|||||||
permissions: Array.from(permissions),
|
permissions: Array.from(permissions),
|
||||||
customerId: user.customerId,
|
customerId: user.customerId,
|
||||||
isCustomerPortal: false,
|
isCustomerPortal: false,
|
||||||
|
// Wird von der Audit-Middleware gelesen, um planmaessige
|
||||||
|
// Dienstkonto-Anmeldungen als Routine einzustufen (R182).
|
||||||
|
isServiceAccount: user.isServiceAccount,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -244,8 +433,10 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
representedCustomerIds,
|
representedCustomerIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await pruneExpiredRefreshTokens();
|
||||||
const accessToken = signAccessToken(payload);
|
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 {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -286,12 +477,32 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
|||||||
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
|
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
|
||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (e) {
|
||||||
throw new Error('Refresh-Token ungültig oder abgelaufen');
|
// 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') {
|
if (decoded.type !== 'refresh') {
|
||||||
throw new Error('Falscher Token-Typ');
|
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;
|
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
|
||||||
|
|
||||||
// Mitarbeiter
|
// Mitarbeiter
|
||||||
@@ -321,7 +532,9 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
accessToken: signAccessToken(payload),
|
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: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -352,7 +565,10 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
accessToken: signAccessToken(payload),
|
accessToken: signAccessToken(payload),
|
||||||
refreshToken: signRefreshToken(payload),
|
refreshToken: await naechsterRefreshToken(payload, {
|
||||||
|
customerId: customer.id,
|
||||||
|
isCustomerPortal: true,
|
||||||
|
}),
|
||||||
user: portalUser,
|
user: portalUser,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,31 @@ import * as path from 'path';
|
|||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { synchronisiereRechteUndRollen } from './rollen-sync.service.js';
|
||||||
|
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zufaelliges Initial-Kennwort fuer den Bootstrap-Admin nach einem
|
||||||
|
* Werksreset. Gleiche Regeln wie in prisma/seed.ts: 28 Zeichen, mindestens
|
||||||
|
* eines aus jeder Klasse, kryptografisch sichere Auswahl - Math.random() ist
|
||||||
|
* vorhersagbar und reicht dafuer nicht (Pentest 2026-05-20).
|
||||||
|
*/
|
||||||
|
function erzeugeInitialKennwort(): string {
|
||||||
|
const gross = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||||
|
const klein = 'abcdefghijkmnopqrstuvwxyz';
|
||||||
|
const ziffern = '23456789';
|
||||||
|
const sonder = '!@#$%&*+=?';
|
||||||
|
const alle = gross + klein + ziffern + sonder;
|
||||||
|
const waehle = (s: string) => s[crypto.randomInt(0, s.length)];
|
||||||
|
const zeichen = [waehle(gross), waehle(klein), waehle(ziffern), waehle(sonder)];
|
||||||
|
for (let i = zeichen.length; i < 28; i++) zeichen.push(waehle(alle));
|
||||||
|
for (let i = zeichen.length - 1; i > 0; i--) {
|
||||||
|
const j = crypto.randomInt(0, i + 1);
|
||||||
|
[zeichen[i], zeichen[j]] = [zeichen[j], zeichen[i]];
|
||||||
|
}
|
||||||
|
return zeichen.join('');
|
||||||
|
}
|
||||||
|
|
||||||
// Verzeichnisse
|
// Verzeichnisse
|
||||||
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
|
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
|
||||||
@@ -1187,110 +1212,40 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grundlegende Stammdaten neu anlegen (aus Seed)
|
// ==================== RECHTE UND ROLLEN ====================
|
||||||
// Berechtigungen - muss mit seed.ts übereinstimmen!
|
// Aus derselben Definition wie Seed und Container-Start
|
||||||
const resourcePermissions: Record<string, string[]> = {
|
// (config/rechte-katalog.ts).
|
||||||
// Haupt-Ressourcen (CRUD)
|
//
|
||||||
customers: ['create', 'read', 'update', 'delete'],
|
// Hier stand bis 09/2026 eine DRITTE Kopie des Katalogs, mit dem
|
||||||
contracts: ['create', 'read', 'update', 'delete'],
|
// Kommentar "muss mit seed.ts uebereinstimmen!" darueber - und sie stimmte
|
||||||
users: ['create', 'read', 'update', 'delete'],
|
// nicht: Die Lookup-Tabellen hatten nur `read`, `email-providers`,
|
||||||
platforms: ['create', 'read', 'update', 'delete'],
|
// `audit` und `gdpr` fehlten ganz, und von den Rollen wurden nur fuenf
|
||||||
providers: ['create', 'read', 'update', 'delete'],
|
// angelegt. DSGVO, Audit-Betrieb und Gegenbuch gab es nach einem
|
||||||
tariffs: ['create', 'read', 'update', 'delete'],
|
// Werksreset nicht mehr.
|
||||||
// Lookup-Tabellen (nur lesen)
|
//
|
||||||
'contract-categories': ['read'],
|
// Die Folge war kein Schoenheitsfehler: Ohne die DSGVO-Rolle kann
|
||||||
'cancellation-periods': ['read'],
|
// niemand eine Auskunft nach Art. 15 oder eine Loeschung nach Art. 17
|
||||||
'contract-durations': ['read'],
|
// ausfuehren. Ein Werksreset setzte damit stillschweigend die
|
||||||
// Einstellungen (nur lesen/ändern)
|
// Handlungsfaehigkeit fuer Betroffenenrechte aus, und aufgefallen waere
|
||||||
settings: ['read', 'update'],
|
// es erst, wenn eine Frist laeuft.
|
||||||
// Spezial-Permissions
|
await synchronisiereRechteUndRollen(prisma, (zeile) =>
|
||||||
developer: ['access'],
|
console.log(`[FactoryReset] ${zeile}`),
|
||||||
emails: ['delete'],
|
);
|
||||||
};
|
|
||||||
|
|
||||||
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
|
||||||
for (const action of actions) {
|
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
|
||||||
await prisma.permission.create({
|
console.log('[FactoryReset] Rechte und Rollen erstellt');
|
||||||
data: { resource, action },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('[FactoryReset] Berechtigungen erstellt');
|
|
||||||
|
|
||||||
// Admin-Rolle mit allen Berechtigungen (außer developer:access)
|
// Standard Admin-Benutzer erstellen.
|
||||||
const allPermissions = await prisma.permission.findMany();
|
//
|
||||||
const adminRole = await prisma.role.create({
|
// Das Kennwort war hier bis 09/2026 fest auf "admin" verdrahtet, mit
|
||||||
data: {
|
// bcrypt-Cost 10. Genau das verbietet seed.ts seit Pentest Runde 12
|
||||||
name: 'Admin',
|
// ausdruecklich - und wieder war die Haertung nur in einer der beiden
|
||||||
description: 'Voller Zugriff auf alle Funktionen',
|
// Kopien angekommen. Wer einen Werksreset ausloest, bekommt jetzt ein
|
||||||
permissions: {
|
// zufaelliges Kennwort, das genau einmal im Log erscheint.
|
||||||
create: allPermissions
|
|
||||||
.filter(p => !(p.resource === 'developer' && p.action === 'access'))
|
|
||||||
.map(p => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Developer-Rolle - ALLE Berechtigungen inkl. developer:access
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Developer',
|
|
||||||
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
|
||||||
permissions: {
|
|
||||||
create: allPermissions.map(p => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mitarbeiter-Rolle - customers, contracts + read-only auf Stammdaten
|
|
||||||
const employeePermIds = allPermissions
|
|
||||||
.filter(p =>
|
|
||||||
p.resource === 'customers' ||
|
|
||||||
p.resource === 'contracts' ||
|
|
||||||
(p.action === 'read' && ['platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'].includes(p.resource))
|
|
||||||
)
|
|
||||||
.map(p => p.id);
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Mitarbeiter',
|
|
||||||
description: 'Kann Kunden und Verträge verwalten',
|
|
||||||
permissions: {
|
|
||||||
create: employeePermIds.map(id => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nur-Lesen Rolle
|
|
||||||
const readOnlyResources = ['customers', 'contracts', 'platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'];
|
|
||||||
const readOnlyPermIds = allPermissions
|
|
||||||
.filter(p => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
||||||
.map(p => p.id);
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Mitarbeiter (Nur-Lesen)',
|
|
||||||
description: 'Kann nur lesen, keine Änderungen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kunden-Rolle
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Kunde',
|
|
||||||
description: 'Kann nur eigene Daten lesen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('[FactoryReset] Rollen erstellt');
|
|
||||||
|
|
||||||
// Standard Admin-Benutzer erstellen
|
|
||||||
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
|
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
|
||||||
const hashedPassword = await bcrypt.hash('admin', 10);
|
const adminPlainPassword = erzeugeInitialKennwort();
|
||||||
console.log('[FactoryReset] Passwort gehasht, Admin-Rolle ID:', adminRole.id);
|
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
|
||||||
|
|
||||||
const adminUser = await prisma.user.create({
|
const adminUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1298,11 +1253,21 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
firstName: 'Admin',
|
firstName: 'Admin',
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
|
// Admin UND DSGVO (Pentest R189-01): Die Admin-Rolle traegt die
|
||||||
|
// Datenschutzrechte bewusst nicht, also braucht dieses eine
|
||||||
|
// Bootstrap-Konto beide - sonst ist nach dem Reset niemand
|
||||||
|
// handlungsfaehig.
|
||||||
roles: {
|
roles: {
|
||||||
create: [{ roleId: adminRole.id }],
|
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log('========================================================');
|
||||||
|
console.log(' Admin-User: admin@admin.com');
|
||||||
|
console.log(` Initial-Passwort: ${adminPlainPassword}`);
|
||||||
|
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
|
||||||
|
console.log(' Bitte sofort nach dem ersten Login ändern.');
|
||||||
|
console.log('========================================================');
|
||||||
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
|
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
|
||||||
|
|
||||||
// Standard Kündigungsfristen (wie in seed.ts)
|
// Standard Kündigungsfristen (wie in seed.ts)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.o
|
|||||||
const FETCH_TIMEOUT_MS = 20_000;
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
// ---- Typen ----
|
// ---- Typen ----
|
||||||
type BankTuple = [string, string]; // [bankName, bic]
|
type BankTuple = [string, string?]; // [bankName, bic?] – BIC fehlt bei manchen Banken
|
||||||
type CurrentData = Record<string, BankTuple>;
|
type CurrentData = Record<string, BankTuple>;
|
||||||
interface NextData {
|
interface NextData {
|
||||||
valid: string;
|
valid: string;
|
||||||
@@ -159,7 +159,7 @@ export function lookupBlz(blz: string): BankInfo | null {
|
|||||||
if (!/^[1-9]\d{7}$/.test(blz)) return null;
|
if (!/^[1-9]\d{7}$/.test(blz)) return null;
|
||||||
const entry = combinedData()[blz];
|
const entry = combinedData()[blz];
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
return { bankName: entry[0], bic: entry[1], blz };
|
return { bankName: entry[0], bic: entry[1] ?? '', blz };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
|
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
|
||||||
@@ -171,20 +171,53 @@ export function lookupByIban(iban: string): BankInfo | null {
|
|||||||
|
|
||||||
// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ----
|
// ---- 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 {
|
function assertValidRawDataset(raw: any): asserts raw is RawDataset {
|
||||||
if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
|
if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
|
||||||
const cur = raw.current;
|
const cur = raw.current;
|
||||||
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
|
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
|
||||||
const keys = Object.keys(cur);
|
const keys = Object.keys(cur);
|
||||||
if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
|
if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
|
||||||
const sample = cur[keys[0]];
|
// VOLLSTÄNDIGE Formatprüfung aller Einträge, nicht nur des ersten.
|
||||||
if (!Array.isArray(sample) || sample.length !== 2 || typeof sample[0] !== 'string' || typeof sample[1] !== 'string') {
|
assertValidBankMap(cur, 'current');
|
||||||
throw new Error('current-Eintrag hat unerwartetes Format');
|
|
||||||
}
|
|
||||||
const next = raw.next;
|
const next = raw.next;
|
||||||
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || !Array.isArray(next.remove)) {
|
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');
|
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) ----
|
// ---- Ausgehende Requests (nur Updater) ----
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export async function getAllContracts(filters: ContractFilters) {
|
|||||||
{ mobileDetails: { deviceImei: { contains: search } } },
|
{ mobileDetails: { deviceImei: { contains: search } } },
|
||||||
{ mobileDetails: { simCards: { some: { phoneNumber: { contains: search } } } } },
|
{ mobileDetails: { simCards: { some: { phoneNumber: { contains: search } } } } },
|
||||||
{ mobileDetails: { simCards: { some: { simCardNumber: { contains: search } } } } },
|
{ mobileDetails: { simCards: { some: { simCardNumber: { contains: search } } } } },
|
||||||
|
{ mobileDetails: { simCards: { some: { cardUser: { contains: search } } } } },
|
||||||
// Energie-Vertragsdetails (Zählernummer)
|
// Energie-Vertragsdetails (Zählernummer)
|
||||||
{ energyDetails: { meter: { meterNumber: { contains: search } } } },
|
{ energyDetails: { meter: { meterNumber: { contains: search } } } },
|
||||||
// TV-Vertragsdetails
|
// TV-Vertragsdetails
|
||||||
@@ -105,7 +106,8 @@ export async function getAllContracts(filters: ContractFilters) {
|
|||||||
mobileDetails: {
|
mobileDetails: {
|
||||||
select: {
|
select: {
|
||||||
phoneNumber: true,
|
phoneNumber: true,
|
||||||
simCards: { select: { phoneNumber: true, isMain: true } },
|
mobileNetwork: true,
|
||||||
|
simCards: { select: { phoneNumber: true, isMain: true, cardUser: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 2026-07-04: für Vertragsauswahl-Labels brauchen wir überall
|
// 2026-07-04: für Vertragsauswahl-Labels brauchen wir überall
|
||||||
@@ -253,6 +255,7 @@ interface ContractCreateData {
|
|||||||
unitPrice?: number;
|
unitPrice?: number;
|
||||||
instantBonus?: number;
|
instantBonus?: number;
|
||||||
newCustomerBonus?: number;
|
newCustomerBonus?: number;
|
||||||
|
noBonusDesired?: boolean;
|
||||||
previousProviderName?: string;
|
previousProviderName?: string;
|
||||||
previousCustomerNumber?: string;
|
previousCustomerNumber?: string;
|
||||||
};
|
};
|
||||||
@@ -339,6 +342,73 @@ function normalizeMobileNetwork(value: unknown): string | null {
|
|||||||
return ALLOWED_MOBILE_NETWORKS.has(v) ? v : null;
|
return ALLOWED_MOBILE_NETWORKS.has(v) ? v : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelist für die Contract-Top-Level-Scalars (Mass-Assignment-Schutz,
|
||||||
|
// Pentest R158-Hygiene, konsistent zur R156-Härtung von BankCard/Address/
|
||||||
|
// Document). Nur diese Felder gehen aus dem (bereits um Nested-Details +
|
||||||
|
// portalPassword bereinigten) `contractData` an Prisma – NICHT id/contractNumber/
|
||||||
|
// createdAt/updatedAt oder sonstige untergeschobene Keys. `contractNumber` wird
|
||||||
|
// bei create serverseitig gesetzt, nie aus dem Body.
|
||||||
|
// Autoritativ aus den DB-Spalten abgeleitet (nicht aus dem unvollständigen
|
||||||
|
// ContractCreateData-Typ!). BEWUSST NICHT enthalten:
|
||||||
|
// - id / contractNumber / createdAt / updatedAt → nie client-setzbar
|
||||||
|
// - portalPasswordEncrypted → via portalPassword-Verschlüsselung
|
||||||
|
// - cancellation*Path (Schreiben/Bestätigung/Optionen) → nur über die
|
||||||
|
// dedizierten Upload-/Delete-Endpunkte, nicht per Form-Update
|
||||||
|
const CONTRACT_SCALAR_FIELDS = [
|
||||||
|
'customerId', 'type', 'contractCategoryId', 'status', 'addressId', 'billingAddressId',
|
||||||
|
'bankCardId', 'identityDocumentId', 'salesPlatformId', 'previousContractId', 'providerId',
|
||||||
|
'tariffId', 'providerName', 'tariffName', 'customerNumberAtProvider', 'contractNumberAtProvider',
|
||||||
|
'orderNumberAtSalesPlatform',
|
||||||
|
'customerNumberAtSalesPlatform', 'contractNumberAtSalesPlatform', 'priceFirst12Months',
|
||||||
|
'priceFrom13Months', 'priceAfter24Months', 'startDate', 'endDate', 'cancellationPeriodId',
|
||||||
|
'contractDurationId', 'commission', 'portalUsername', 'stressfreiEmailId',
|
||||||
|
'portalCredentialsNotRequired', 'notes', 'cancellationConfirmationDate',
|
||||||
|
'cancellationConfirmationOptionsDate', 'wasSpecialCancellation',
|
||||||
|
// In ContractCreateData nicht typisiert, aber echte Spalten, die das Formular
|
||||||
|
// sendet (liefen bisher nur über den rohen Spread durch):
|
||||||
|
'previousProviderId', 'previousContractNumber', 'previousCustomerNumber', 'nextReviewDate',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickContractScalars(data: unknown): Record<string, unknown> {
|
||||||
|
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of CONTRACT_SCALAR_FIELDS) {
|
||||||
|
if (key in src) out[key] = src[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelists für die Nested-Detail-Modelle (Mass-Assignment-Schutz, Pentest
|
||||||
|
// R162-01). Autoritativ aus den DB-Spalten, jeweils OHNE id/contractId (PK/FK
|
||||||
|
// nie client-setzbar) und ohne Nested-Relationen (phoneNumbers/simCards/
|
||||||
|
// contractMeters) bzw. verschlüsselte Felder (internetPasswordEncrypted), die
|
||||||
|
// separat behandelt werden. Verhindert Reparenting/Orphaning per injizierter
|
||||||
|
// id/contractId im energy/mobile/tv/carInsurance-Objekt.
|
||||||
|
function makePick(fields: readonly string[]) {
|
||||||
|
return (data: unknown): Record<string, unknown> => {
|
||||||
|
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of fields) if (key in src) out[key] = src[key];
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickEnergyScalars = makePick([
|
||||||
|
'meterId', 'annualConsumption', 'basePrice', 'unitPrice', 'previousProviderName',
|
||||||
|
'previousCustomerNumber', 'annualConsumptionKwh', 'maloId', 'unitPriceNt',
|
||||||
|
'instantBonus', 'newCustomerBonus', 'noBonusDesired',
|
||||||
|
]);
|
||||||
|
const pickMobileScalars = makePick([
|
||||||
|
'phoneNumber', 'simCardNumber', 'dataVolume', 'includedMinutes', 'includedSMS',
|
||||||
|
'deviceModel', 'deviceImei', 'requiresMultisim', 'mobileNetwork',
|
||||||
|
]);
|
||||||
|
const pickTvScalars = makePick(['receiverModel', 'smartcardNumber', 'package']);
|
||||||
|
const pickCarInsuranceScalars = makePick([
|
||||||
|
'licensePlate', 'hsn', 'tsn', 'vin', 'vehicleType', 'firstRegistration',
|
||||||
|
'noClaimsClass', 'insuranceType', 'deductiblePartial', 'deductibleFull',
|
||||||
|
'policyNumber', 'previousInsurer',
|
||||||
|
]);
|
||||||
|
|
||||||
export async function createContract(data: ContractCreateData) {
|
export async function createContract(data: ContractCreateData) {
|
||||||
const {
|
const {
|
||||||
energyDetails,
|
energyDetails,
|
||||||
@@ -357,11 +427,11 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
|
|
||||||
const contract = await prisma.contract.create({
|
const contract = await prisma.contract.create({
|
||||||
data: {
|
data: {
|
||||||
...contractData,
|
...(pickContractScalars(contractData) as any),
|
||||||
contractNumber: generateContractNumber(data.type),
|
contractNumber: generateContractNumber(data.type),
|
||||||
portalPasswordEncrypted,
|
portalPasswordEncrypted,
|
||||||
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
|
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
|
||||||
? { energyDetails: { create: energyDetails } }
|
? { energyDetails: { create: pickEnergyScalars(energyDetails) as any } }
|
||||||
: {}),
|
: {}),
|
||||||
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
|
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
|
||||||
? {
|
? {
|
||||||
@@ -431,10 +501,10 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(tvDetails && data.type === 'TV'
|
...(tvDetails && data.type === 'TV'
|
||||||
? { tvDetails: { create: tvDetails } }
|
? { tvDetails: { create: pickTvScalars(tvDetails) as any } }
|
||||||
: {}),
|
: {}),
|
||||||
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
|
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
|
||||||
? { carInsuranceDetails: { create: carInsuranceDetails } }
|
? { carInsuranceDetails: { create: pickCarInsuranceScalars(carInsuranceDetails) as any } }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -487,11 +557,12 @@ export async function updateContract(
|
|||||||
// in diesem Update wirklich auf true gesetzt wird (nicht bei jedem PUT).
|
// in diesem Update wirklich auf true gesetzt wird (nicht bei jedem PUT).
|
||||||
const clearCredentials = contractData.portalCredentialsNotRequired === true;
|
const clearCredentials = contractData.portalCredentialsNotRequired === true;
|
||||||
|
|
||||||
// Update main contract
|
// Update main contract – nur Whitelist-Felder (kein roher `...contractData`
|
||||||
|
// mehr → kein Mass-Assignment von id/customerId-PK/contractNumber/Timestamps).
|
||||||
await prisma.contract.update({
|
await prisma.contract.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...contractData,
|
...(pickContractScalars(contractData) as any),
|
||||||
...(clearCredentials
|
...(clearCredentials
|
||||||
? {
|
? {
|
||||||
portalUsername: null,
|
portalUsername: null,
|
||||||
@@ -513,8 +584,8 @@ export async function updateContract(
|
|||||||
|
|
||||||
await prisma.energyContractDetails.upsert({
|
await prisma.energyContractDetails.upsert({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
update: energyDetails,
|
update: pickEnergyScalars(energyDetails) as any,
|
||||||
create: { contractId: id, ...energyDetails },
|
create: { contractId: id, ...(pickEnergyScalars(energyDetails) as any) },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
||||||
@@ -639,11 +710,12 @@ export async function updateContract(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mobileDetails) {
|
if (mobileDetails) {
|
||||||
const { simCards, ...mobileData } = mobileDetails;
|
const { simCards } = mobileDetails;
|
||||||
// Whitelist auf das Netz anwenden, bevor mobileData per Spread an
|
// Feld-Whitelist (Pentest R162-01): nur Scalars an Prisma – kein injiziertes
|
||||||
// Prisma geht (Update-Pfad reicht sonst beliebige Strings durch).
|
// id/contractId. Danach Netz normalisieren.
|
||||||
|
const mobileData: Record<string, unknown> = pickMobileScalars(mobileDetails);
|
||||||
if ('mobileNetwork' in mobileData) {
|
if ('mobileNetwork' in mobileData) {
|
||||||
mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork);
|
mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork as string | null | undefined);
|
||||||
}
|
}
|
||||||
const existing = await prisma.mobileContractDetails.findUnique({
|
const existing = await prisma.mobileContractDetails.findUnique({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
@@ -652,7 +724,7 @@ export async function updateContract(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.mobileContractDetails.update({
|
await prisma.mobileContractDetails.update({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
data: mobileData,
|
data: mobileData as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (simCards) {
|
if (simCards) {
|
||||||
@@ -690,7 +762,7 @@ export async function updateContract(
|
|||||||
await prisma.mobileContractDetails.create({
|
await prisma.mobileContractDetails.create({
|
||||||
data: {
|
data: {
|
||||||
contractId: id,
|
contractId: id,
|
||||||
...mobileData,
|
...(mobileData as any),
|
||||||
simCards: simCards
|
simCards: simCards
|
||||||
? {
|
? {
|
||||||
create: simCards.map((sc) => ({
|
create: simCards.map((sc) => ({
|
||||||
@@ -713,16 +785,16 @@ export async function updateContract(
|
|||||||
if (tvDetails) {
|
if (tvDetails) {
|
||||||
await prisma.tvContractDetails.upsert({
|
await prisma.tvContractDetails.upsert({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
update: tvDetails,
|
update: pickTvScalars(tvDetails) as any,
|
||||||
create: { contractId: id, ...tvDetails },
|
create: { contractId: id, ...(pickTvScalars(tvDetails) as any) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (carInsuranceDetails) {
|
if (carInsuranceDetails) {
|
||||||
await prisma.carInsuranceDetails.upsert({
|
await prisma.carInsuranceDetails.upsert({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
update: carInsuranceDetails,
|
update: pickCarInsuranceScalars(carInsuranceDetails) as any,
|
||||||
create: { contractId: id, ...carInsuranceDetails },
|
create: { contractId: id, ...(pickCarInsuranceScalars(carInsuranceDetails) as any) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1223,6 +1295,7 @@ export interface ContractTreeNode {
|
|||||||
status: ContractStatus;
|
status: ContractStatus;
|
||||||
startDate: Date | null;
|
startDate: Date | null;
|
||||||
endDate: Date | null;
|
endDate: Date | null;
|
||||||
|
cancellationConfirmationDate: Date | null;
|
||||||
providerName: string | null;
|
providerName: string | null;
|
||||||
tariffName: string | null;
|
tariffName: string | null;
|
||||||
previousContractId: number | null;
|
previousContractId: number | null;
|
||||||
@@ -1233,7 +1306,8 @@ export interface ContractTreeNode {
|
|||||||
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
||||||
mobileDetails?: {
|
mobileDetails?: {
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
simCards: { phoneNumber: string | null; isMain: boolean }[];
|
mobileNetwork: string | null;
|
||||||
|
simCards: { phoneNumber: string | null; isMain: boolean; cardUser: string | null }[];
|
||||||
} | null;
|
} | null;
|
||||||
carInsuranceDetails?: { licensePlate: string | null } | null;
|
carInsuranceDetails?: { licensePlate: string | null } | null;
|
||||||
};
|
};
|
||||||
@@ -1268,6 +1342,7 @@ export async function getContractTreeForCustomer(
|
|||||||
status: true,
|
status: true,
|
||||||
startDate: true,
|
startDate: true,
|
||||||
endDate: true,
|
endDate: true,
|
||||||
|
cancellationConfirmationDate: true,
|
||||||
providerName: true,
|
providerName: true,
|
||||||
tariffName: true,
|
tariffName: true,
|
||||||
previousContractId: true,
|
previousContractId: true,
|
||||||
@@ -1279,7 +1354,8 @@ export async function getContractTreeForCustomer(
|
|||||||
mobileDetails: {
|
mobileDetails: {
|
||||||
select: {
|
select: {
|
||||||
phoneNumber: true,
|
phoneNumber: true,
|
||||||
simCards: { select: { phoneNumber: true, isMain: true } },
|
mobileNetwork: true,
|
||||||
|
simCards: { select: { phoneNumber: true, isMain: true, cardUser: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
carInsuranceDetails: { select: { licensePlate: true } },
|
carInsuranceDetails: { select: { licensePlate: true } },
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
const contracts = await prisma.contract.findMany({
|
const contracts = await prisma.contract.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: {
|
status: {
|
||||||
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED', 'ONGOING'],
|
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'DEACTIVATED', 'EXPIRED', 'ONGOING'],
|
||||||
},
|
},
|
||||||
...customerScopeFilter,
|
...customerScopeFilter,
|
||||||
},
|
},
|
||||||
@@ -363,8 +363,10 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
// Fristen semantisch keinen Sinn ergeben:
|
// Fristen semantisch keinen Sinn ergeben:
|
||||||
// - ONGOING : "Läuft weiter auf unbestimmte Zeit" → kein Ende
|
// - ONGOING : "Läuft weiter auf unbestimmte Zeit" → kein Ende
|
||||||
// - DEACTIVATED : manuell beendet/archiviert → nichts mehr zu erinnern
|
// - DEACTIVATED : manuell beendet/archiviert → nichts mehr zu erinnern
|
||||||
// - CANCELLED : Kündigung schon eingereicht → Vertragsende ist
|
// - CANCELLED_CONFIRMED : Kündigung vom Anbieter bestätigt →
|
||||||
// die Konsequenz der Kündigung, keine Frist mehr
|
// Vertragsende steht fest, keine Frist mehr.
|
||||||
|
// (CANCELLED = nur "Bestätigung abwarten" skippt NICHT,
|
||||||
|
// damit die "Kündigungsbestätigung fehlt"-Warnung greift.)
|
||||||
// Die Schlussrechnung-Fehlt-Warnung (Block 13a) bleibt aktiv, weil
|
// Die Schlussrechnung-Fehlt-Warnung (Block 13a) bleibt aktiv, weil
|
||||||
// sie WEGEN dieser Status feuert (kein separater Guard). DSGVO-/
|
// sie WEGEN dieser Status feuert (kein separater Guard). DSGVO-/
|
||||||
// Consent-Checks (Block 8) sind pro Kunde, nicht pro Vertrag, und
|
// Consent-Checks (Block 8) sind pro Kunde, nicht pro Vertrag, und
|
||||||
@@ -375,7 +377,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
const skipDeadlines =
|
const skipDeadlines =
|
||||||
contract.status === 'ONGOING'
|
contract.status === 'ONGOING'
|
||||||
|| contract.status === 'DEACTIVATED'
|
|| contract.status === 'DEACTIVATED'
|
||||||
|| contract.status === 'CANCELLED';
|
|| contract.status === 'CANCELLED_CONFIRMED';
|
||||||
|
|
||||||
// 1. KÜNDIGUNGSFRIST (nur wenn kein aktiver Folgevertrag UND Snooze nicht aktiv)
|
// 1. KÜNDIGUNGSFRIST (nur wenn kein aktiver Folgevertrag UND Snooze nicht aktiv)
|
||||||
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
|
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
|
||||||
@@ -628,9 +630,11 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setHours(0, 0, 0, 0);
|
now.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag gekündigt/deaktiviert ist)
|
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag bestätigt gekündigt/deaktiviert)
|
||||||
// "Beendet" = CANCELLED oder DEACTIVATED (nicht nur Laufzeit abgelaufen!)
|
// "Beendet" = CANCELLED_CONFIRMED (Kündigung bestätigt) oder DEACTIVATED
|
||||||
const isContractTerminated = contract.status === 'CANCELLED' || contract.status === 'DEACTIVATED';
|
// (nicht nur Laufzeit abgelaufen; NICHT "Bestätigung abwarten"/CANCELLED,
|
||||||
|
// da dort das Vertragsende noch nicht feststeht).
|
||||||
|
const isContractTerminated = contract.status === 'CANCELLED_CONFIRMED' || contract.status === 'DEACTIVATED';
|
||||||
|
|
||||||
if (isContractTerminated) {
|
if (isContractTerminated) {
|
||||||
const hasFinalInvoice = invoices.some(inv => inv.invoiceType === 'FINAL');
|
const hasFinalInvoice = invoices.some(inv => inv.invoiceType === 'FINAL');
|
||||||
@@ -783,7 +787,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
// CANCELLED/EXPIRED. Eigene Liste, weil solche Verträge (v.a. bereits
|
// CANCELLED/EXPIRED. Eigene Liste, weil solche Verträge (v.a. bereits
|
||||||
// CANCELLED/EXPIRED) sonst mangels „Issue" nicht in der Cockpit-Liste
|
// CANCELLED/EXPIRED) sonst mangels „Issue" nicht in der Cockpit-Liste
|
||||||
// auftauchen.
|
// auftauchen.
|
||||||
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED', 'EXPIRED'];
|
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'EXPIRED'];
|
||||||
const cancellationConfirmations: CockpitContract[] = contracts
|
const cancellationConfirmations: CockpitContract[] = contracts
|
||||||
.filter(
|
.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
|
|||||||
@@ -195,9 +195,13 @@ export async function maybeCancelOnCancellationConfirmation(
|
|||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
|
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
|
||||||
|
|
||||||
if (c.status === 'ACTIVE') {
|
// Kündigungsbestätigung liegt vor (Datum/Dokument) → "Gekündigt / bestätigt".
|
||||||
updateData.status = 'CANCELLED';
|
// Von jedem noch "lebenden" Status heben – auch aus "Gekündigt / Bestätigung
|
||||||
changes.status = { vorher: 'ACTIVE', nachher: 'CANCELLED' };
|
// abwarten" (CANCELLED) – aber NIE aus DRAFT (Vorlage) oder DEACTIVATED
|
||||||
|
// (archiviert). Ein bereits bestätigter Vertrag bleibt bestätigt (No-op).
|
||||||
|
if (['ACTIVE', 'PENDING', 'ONGOING', 'EXPIRED', 'CANCELLED'].includes(c.status)) {
|
||||||
|
updateData.status = 'CANCELLED_CONFIRMED';
|
||||||
|
changes.status = { vorher: c.status, nachher: 'CANCELLED_CONFIRMED' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertragsende = Kündigungsdatum (Bestätigungsdatum), falls vorhanden.
|
// Vertragsende = Kündigungsdatum (Bestätigungsdatum), falls vorhanden.
|
||||||
@@ -227,6 +231,45 @@ export async function maybeCancelOnCancellationConfirmation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt einen Vertrag automatisch auf "Gekündigt / Bestätigung abwarten"
|
||||||
|
* (CANCELLED), sobald ein Kündigungsschreiben hinterlegt wurde. Hebt nur noch
|
||||||
|
* "lebende" Verträge (ACTIVE/PENDING/ONGOING/EXPIRED) an – ein bereits bestätigt
|
||||||
|
* gekündigter Vertrag (CANCELLED_CONFIRMED) wird NICHT zurückgestuft, ein
|
||||||
|
* Entwurf (DRAFT) oder archivierter (DEACTIVATED) nicht angefasst.
|
||||||
|
*/
|
||||||
|
export async function maybeMarkAwaitingConfirmationOnLetter(
|
||||||
|
contractId: number,
|
||||||
|
req: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
const c = await prisma.contract.findUnique({
|
||||||
|
where: { id: contractId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
contractNumber: true,
|
||||||
|
customerId: true,
|
||||||
|
cancellationLetterPath: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!c || !c.cancellationLetterPath) return;
|
||||||
|
if (!['ACTIVE', 'PENDING', 'ONGOING', 'EXPIRED'].includes(c.status)) return;
|
||||||
|
|
||||||
|
await prisma.contract.update({
|
||||||
|
where: { id: contractId },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'UPDATE',
|
||||||
|
resourceType: 'Contract',
|
||||||
|
resourceId: contractId.toString(),
|
||||||
|
label: `Vertrag ${c.contractNumber} automatisch auf "Gekündigt / Bestätigung abwarten" gesetzt (Kündigungsschreiben)`,
|
||||||
|
details: { status: { vorher: c.status, nachher: 'CANCELLED' }, trigger: 'Kündigungsschreiben' },
|
||||||
|
customerId: c.customerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function maybeActivateOnDeliveryConfirmation(
|
export async function maybeActivateOnDeliveryConfirmation(
|
||||||
contractId: number,
|
contractId: number,
|
||||||
documentType: string,
|
documentType: string,
|
||||||
|
|||||||
@@ -50,18 +50,21 @@ export async function getTaskById(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createTask(data: {
|
export async function createTask(data: {
|
||||||
contractId: number;
|
contractId?: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
visibleInPortal?: boolean;
|
visibleInPortal?: boolean;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
}) {
|
}) {
|
||||||
|
// Ohne Vertrag (allgemeine Aufgabe) gibt es keinen Kunden → nie im Portal
|
||||||
|
// sichtbar, egal was der Client schickt.
|
||||||
|
const hasContract = data.contractId != null;
|
||||||
return prisma.contractTask.create({
|
return prisma.contractTask.create({
|
||||||
data: {
|
data: {
|
||||||
contractId: data.contractId,
|
contractId: data.contractId ?? null,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
visibleInPortal: data.visibleInPortal ?? false,
|
visibleInPortal: hasContract ? (data.visibleInPortal ?? false) : false,
|
||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -218,6 +218,27 @@ export async function getCustomerAddresses(customerId: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelist für Address-Create/Update (Mass-Assignment-Schutz, konsistent
|
||||||
|
// zu R156; autoritativ aus den DB-Spalten abgeleitet). BEWUSST NICHT enthalten:
|
||||||
|
// id/customerId/createdAt/updatedAt. Enthält die Eigentümer-Felder (owner*) –
|
||||||
|
// die waren beim ersten R156-Umbau versehentlich rausgefallen (Regression) –
|
||||||
|
// sowie die neuen MaLo-IDs.
|
||||||
|
const ADDRESS_SCALAR_FIELDS = [
|
||||||
|
'type', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'isDefault',
|
||||||
|
'maloIdElectricity', 'maloIdGas',
|
||||||
|
'ownerCompany', 'ownerFirstName', 'ownerLastName', 'ownerStreet', 'ownerHouseNumber',
|
||||||
|
'ownerPostalCode', 'ownerCity', 'ownerPhone', 'ownerMobile', 'ownerEmail',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickAddressScalars(data: unknown): Record<string, unknown> {
|
||||||
|
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of ADDRESS_SCALAR_FIELDS) {
|
||||||
|
if (key in src) out[key] = src[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAddress(
|
export async function createAddress(
|
||||||
customerId: number,
|
customerId: number,
|
||||||
data: {
|
data: {
|
||||||
@@ -238,10 +259,12 @@ export async function createAddress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe
|
||||||
|
// ADDRESS_SCALAR_FIELDS. customerId kommt ausschließlich aus dem Parameter.
|
||||||
return prisma.address.create({
|
return prisma.address.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
...(pickAddressScalars(data) as any),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -272,9 +295,11 @@ export async function updateAddress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe
|
||||||
|
// ADDRESS_SCALAR_FIELDS.
|
||||||
return prisma.address.update({
|
return prisma.address.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: pickAddressScalars(data) as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,16 +327,25 @@ export async function createBankCard(
|
|||||||
data: {
|
data: {
|
||||||
accountHolder: string;
|
accountHolder: string;
|
||||||
iban: string;
|
iban: string;
|
||||||
|
cardNumber?: string;
|
||||||
bic?: string;
|
bic?: string;
|
||||||
bankName?: string;
|
bankName?: string;
|
||||||
expiryDate?: Date;
|
expiryDate?: Date;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist statt `...data`-Spread: verhindert Mass-Assignment
|
||||||
|
// (Pentest R155). Sonst könnte roher req.body u.a. `customerId`/`id` setzen.
|
||||||
return prisma.bankCard.create({
|
return prisma.bankCard.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
accountHolder: data.accountHolder,
|
||||||
|
iban: data.iban,
|
||||||
|
cardNumber: data.cardNumber,
|
||||||
|
bic: data.bic,
|
||||||
|
bankName: data.bankName,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
description: data.description,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -322,6 +356,7 @@ export async function updateBankCard(
|
|||||||
data: {
|
data: {
|
||||||
accountHolder?: string;
|
accountHolder?: string;
|
||||||
iban?: string;
|
iban?: string;
|
||||||
|
cardNumber?: string;
|
||||||
bic?: string;
|
bic?: string;
|
||||||
bankName?: string;
|
bankName?: string;
|
||||||
expiryDate?: Date;
|
expiryDate?: Date;
|
||||||
@@ -329,9 +364,21 @@ export async function updateBankCard(
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist statt `data`-Passthrough: verhindert Mass-
|
||||||
|
// Assignment (Pentest R155 – roher req.body konnte `customerId`/`id`
|
||||||
|
// mutieren). Undefined-Felder ignoriert Prisma → Partial-Update bleibt.
|
||||||
return prisma.bankCard.update({
|
return prisma.bankCard.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: {
|
||||||
|
accountHolder: data.accountHolder,
|
||||||
|
iban: data.iban,
|
||||||
|
cardNumber: data.cardNumber,
|
||||||
|
bic: data.bic,
|
||||||
|
bankName: data.bankName,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
description: data.description,
|
||||||
|
isActive: data.isActive,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,10 +418,17 @@ export async function createDocument(
|
|||||||
licenseIssueDate?: Date;
|
licenseIssueDate?: Date;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||||||
return prisma.identityDocument.create({
|
return prisma.identityDocument.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
type: data.type,
|
||||||
|
documentNumber: data.documentNumber,
|
||||||
|
issuingAuthority: data.issuingAuthority,
|
||||||
|
issueDate: data.issueDate,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
licenseClasses: data.licenseClasses,
|
||||||
|
licenseIssueDate: data.licenseIssueDate,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -393,9 +447,19 @@ export async function updateDocument(
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||||||
return prisma.identityDocument.update({
|
return prisma.identityDocument.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: {
|
||||||
|
type: data.type,
|
||||||
|
documentNumber: data.documentNumber,
|
||||||
|
issuingAuthority: data.issuingAuthority,
|
||||||
|
issueDate: data.issueDate,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
licenseClasses: data.licenseClasses,
|
||||||
|
licenseIssueDate: data.licenseIssueDate,
|
||||||
|
isActive: data.isActive,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,32 @@ export async function getActiveProviderConfig() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle konfigurierten Provider-Domains (lowercase, dedupliziert). Das sind
|
||||||
|
* UNSERE verwalteten Mailbox-/Weiterleitungs-Domains (z.B. stressfrei-wechseln.de)
|
||||||
|
* – eine private Kunden-E-Mail darf darauf NICHT liegen, sonst trägt man
|
||||||
|
* versehentlich eine unserer Weiterleitungsadressen als private Adresse ein.
|
||||||
|
*/
|
||||||
|
export async function getConfiguredEmailDomains(): Promise<string[]> {
|
||||||
|
const configs = await prisma.emailProviderConfig.findMany({ select: { domain: true } });
|
||||||
|
const domains = configs
|
||||||
|
.map((c) => (c.domain || '').trim().toLowerCase())
|
||||||
|
.filter((d) => d.length > 0);
|
||||||
|
return [...new Set(domains)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* true, wenn die E-Mail auf einer der übergebenen Domains (oder einer Subdomain
|
||||||
|
* davon) liegt. Vergleich case-insensitiv.
|
||||||
|
*/
|
||||||
|
export function emailUsesDomain(email: string, domains: string[]): boolean {
|
||||||
|
const at = email.lastIndexOf('@');
|
||||||
|
if (at < 0) return false;
|
||||||
|
const host = email.slice(at + 1).trim().toLowerCase();
|
||||||
|
if (!host) return false;
|
||||||
|
return domains.some((d) => host === d || host.endsWith('.' + d));
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateProviderConfigData {
|
export interface CreateProviderConfigData {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wachhund auf den AUSBLEIBENDEN Heartbeat (Pentest R182/R183).
|
||||||
|
*
|
||||||
|
* Dienstkonten – allen voran das Gegenbuch – melden sich in festem Takt an.
|
||||||
|
* Diese Anmeldungen sind bewusst als Routine eingestuft, damit sie die
|
||||||
|
* CRITICAL-Stufe nicht entwerten. Damit fehlt aber die andere Haelfte: Wer das
|
||||||
|
* Gegenbuch stilllegt, setzt genau darauf, dass STILLE nicht auffaellt. Ein
|
||||||
|
* gestopptes Gegenbuch ist sonst nicht von „laeuft ruhig“ zu unterscheiden.
|
||||||
|
*
|
||||||
|
* Das Sicherheitssignal ist deshalb nicht die Anwesenheit der Anmeldung,
|
||||||
|
* sondern ihre Abweichung: bleibt sie aus, wird es gemeldet.
|
||||||
|
*
|
||||||
|
* Bewusste Zurueckhaltung: Ohne jemals gesehene Anmeldung gibt es keine
|
||||||
|
* Grundlinie – dann wird geschwiegen statt geraten. Und pro Ausfall wird nur
|
||||||
|
* einmal gemeldet, nicht bei jedem Durchlauf.
|
||||||
|
*/
|
||||||
|
const PRUEFTAKT_MS = 15 * 60 * 1000;
|
||||||
|
const MAX_ALTER_MINUTEN = Number.parseInt(process.env.SERVICE_ACCOUNT_MAX_SILENCE_MINUTES || '180', 10);
|
||||||
|
|
||||||
|
export async function pruefeHeartbeats(): Promise<void> {
|
||||||
|
const konten = await prisma.user.findMany({
|
||||||
|
where: { isServiceAccount: true, isActive: true },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
});
|
||||||
|
if (konten.length === 0) return;
|
||||||
|
|
||||||
|
const grenze = new Date(Date.now() - MAX_ALTER_MINUTEN * 60 * 1000);
|
||||||
|
|
||||||
|
for (const konto of konten) {
|
||||||
|
const letzte = await prisma.auditLog.findFirst({
|
||||||
|
where: { userId: konto.id, action: 'LOGIN' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nie angemeldet = keine Grundlinie. Schweigen statt raten.
|
||||||
|
if (!letzte) continue;
|
||||||
|
if (letzte.createdAt >= grenze) continue;
|
||||||
|
|
||||||
|
// Pro Ausfall nur einmal melden.
|
||||||
|
const schonGemeldet = await prisma.securityEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
userEmail: konto.email,
|
||||||
|
createdAt: { gte: letzte.createdAt },
|
||||||
|
message: { contains: 'Dienstkonto' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (schonGemeldet) continue;
|
||||||
|
|
||||||
|
const stillSeit = Math.round((Date.now() - letzte.createdAt.getTime()) / 60000);
|
||||||
|
await emitSecurityEvent({
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message:
|
||||||
|
`Dienstkonto ${konto.email} meldet sich seit ${stillSeit} Minuten nicht mehr ` +
|
||||||
|
`(erwartet mindestens alle ${MAX_ALTER_MINUTEN} Minuten). ` +
|
||||||
|
'Entweder steht der zugehörige Dienst – etwa das Gegenbuch – oder er wurde stillgelegt. ' +
|
||||||
|
'Stille ist hier kein guter Zustand: Ohne laufendes Gegenbuch fällt eine nachträgliche ' +
|
||||||
|
'Änderung am Audit-Log nicht mehr auf.',
|
||||||
|
userEmail: konto.email,
|
||||||
|
userId: konto.id,
|
||||||
|
endpoint: 'heartbeat-monitor',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function starteHeartbeatMonitor(): void {
|
||||||
|
if (!Number.isFinite(MAX_ALTER_MINUTEN) || MAX_ALTER_MINUTEN <= 0) {
|
||||||
|
console.warn('[Heartbeat] SERVICE_ACCOUNT_MAX_SILENCE_MINUTES ungültig – Wachhund bleibt aus.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lauf = () => {
|
||||||
|
pruefeHeartbeats().catch((e) => console.error('[Heartbeat] Fehler:', e));
|
||||||
|
};
|
||||||
|
// Nicht sofort beim Start: Nach einem Neustart darf ein kurz zurueckliegender
|
||||||
|
// Ausfall nicht doppelt melden, und die DB soll erst oben sein.
|
||||||
|
setTimeout(lauf, 60_000);
|
||||||
|
setInterval(lauf, PRUEFTAKT_MS).unref();
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { SYSTEMROLLEN } from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
|
||||||
|
* ausueben kann (Pentest R189-01).
|
||||||
|
*
|
||||||
|
* Hintergrund: `gdpr:export`, `gdpr:delete` und `gdpr:admin` haengen an den
|
||||||
|
* Rollen DSGVO und Developer – nicht an der Admin-Rolle. Nach einem frischen
|
||||||
|
* Seed war keine der beiden einem Konto zugewiesen. Ergebnis: Eine Auskunft
|
||||||
|
* nach Art. 15 oder eine Loeschung nach Art. 17 konnte NIEMAND ausfuehren,
|
||||||
|
* obwohl mehrere „Admin"-Konten existierten. Ein Ausfall mit Fristwirkung,
|
||||||
|
* ausgeloest durch nichts weiter als eine Neuinstallation.
|
||||||
|
*
|
||||||
|
* Der Seed weist das Recht jetzt zu – aber nur bei NEUINSTALLATION. Auf einer
|
||||||
|
* laufenden Datenbank aendert er nichts, und niemand merkt es, bis es darauf
|
||||||
|
* ankommt. Deshalb diese Wache: Sie erkennt die ABWESENHEIT einer Faehigkeit,
|
||||||
|
* so wie der Heartbeat das Ausbleiben eines Dienstkontos erkennt.
|
||||||
|
*
|
||||||
|
* Bewusst nur eine Meldung, keine automatische Vergabe: Rechte zu verteilen,
|
||||||
|
* ohne dass ein Mensch es veranlasst hat, waere der groessere Fehler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rechte, deren Fehlen ein rechtliches und kein technisches Problem ist. */
|
||||||
|
const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }> = [
|
||||||
|
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
|
||||||
|
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
|
||||||
|
{ resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' },
|
||||||
|
// Ohne dieses Recht laesst sich keine Rolle mehr anlegen oder aendern - die
|
||||||
|
// Rechtevergabe waere eingefroren. Kein Rechtsproblem wie die beiden
|
||||||
|
// darueber, aber dieselbe Bauart: eine Faehigkeit, deren Fehlen erst
|
||||||
|
// auffaellt, wenn man sie braucht.
|
||||||
|
{ resource: 'roles', action: 'manage', wofuer: 'Pflege der Rollen und Rechte' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function pruefePflichtrechte(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fehlend: string[] = [];
|
||||||
|
|
||||||
|
for (const recht of PFLICHTRECHTE) {
|
||||||
|
const traeger = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
roles: {
|
||||||
|
some: {
|
||||||
|
role: {
|
||||||
|
permissions: {
|
||||||
|
some: {
|
||||||
|
permission: { resource: recht.resource, action: recht.action },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (traeger === 0) {
|
||||||
|
fehlend.push(`${recht.resource}:${recht.action} (${recht.wofuer})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zweite Wache: Stimmen die Systemrollen noch?
|
||||||
|
//
|
||||||
|
// Die versteckten Rollen werden ueber ihren NAMEN gefunden
|
||||||
|
// (`findFirst({ where: { name: 'DSGVO' } })`). Fehlt eine, oder ist ihr
|
||||||
|
// isSystem-Flag von Hand entfernt worden, laeuft der Notfallpfad ins
|
||||||
|
// Leere - lautlos. Das hier ist die Meldung, die es dann geben soll.
|
||||||
|
const rollenHinweise: string[] = [];
|
||||||
|
for (const spec of SYSTEMROLLEN) {
|
||||||
|
const rolle = await prisma.role.findUnique({ where: { name: spec.name } });
|
||||||
|
if (!rolle) {
|
||||||
|
rollenHinweise.push(`Systemrolle „${spec.name}" fehlt.`);
|
||||||
|
} else if (!rolle.isSystem) {
|
||||||
|
rollenHinweise.push(
|
||||||
|
`Systemrolle „${spec.name}" ist nicht als Systemrolle gekennzeichnet – ` +
|
||||||
|
'sie ist damit über die Rollenverwaltung änderbar.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rollenHinweise.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
'\n' +
|
||||||
|
'========================================================================\n' +
|
||||||
|
' ACHTUNG: Die Systemrollen stimmen nicht mit dem Katalog überein:\n' +
|
||||||
|
rollenHinweise.map((h) => ` – ${h}\n`).join('') +
|
||||||
|
'\n' +
|
||||||
|
' Beheben: npx tsx prisma/sync-roles.ts\n' +
|
||||||
|
'========================================================================\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fehlend.length === 0) return;
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
'\n' +
|
||||||
|
'========================================================================\n' +
|
||||||
|
' ACHTUNG: Für folgende Rechte gibt es KEIN aktives Konto:\n' +
|
||||||
|
fehlend.map((f) => ` – ${f}\n`).join('') +
|
||||||
|
'\n' +
|
||||||
|
' Das ist kein Fehler der Anwendung, sondern eine Lücke in der\n' +
|
||||||
|
' Rechtevergabe – und sie fällt erst auf, wenn eine Frist läuft.\n' +
|
||||||
|
'\n' +
|
||||||
|
' Beheben: In der Benutzerverwaltung bei einem verantwortlichen Konto\n' +
|
||||||
|
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
|
||||||
|
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll – versiegeln,\n' +
|
||||||
|
' aufräumen, Aufbewahrung ändern – zusätzlich „Audit-Betrieb".\n' +
|
||||||
|
'\n' +
|
||||||
|
' Geht das nicht, weil niemand mehr die nötigen Rechte hat: Rechte\n' +
|
||||||
|
' lassen sich über die Oberfläche nur weitergeben, nicht erschaffen.\n' +
|
||||||
|
' Für die Erstvergabe gibt es den Weg über die Kommandozeile:\n' +
|
||||||
|
' docker compose exec backend \\\n' +
|
||||||
|
' npx tsx prisma/rolle-zuweisen.ts <e-mail> DSGVO\n' +
|
||||||
|
' („--liste" zeigt die vorhandenen Rollen.)\n' +
|
||||||
|
'========================================================================\n',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Eine Wache darf den Start nicht verhindern. Aber schweigen darf sie
|
||||||
|
// auch nicht: „nicht geprüft" ist nicht dasselbe wie „nichts gefunden".
|
||||||
|
console.warn(
|
||||||
|
'[Pflichtrechte] Prüfung nicht möglich – der Zustand der Rechtevergabe ist ' +
|
||||||
|
'damit UNBEKANNT, nicht in Ordnung:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Rechteaufloesung und die Regel, die Selbst-Erhoehung verhindert.
|
||||||
|
*
|
||||||
|
* Die Regel lautet: Niemand kann ein Recht weitergeben, das er selbst nicht
|
||||||
|
* besitzt. Ohne sie genuegte `users:create`, um sich zum Vollzugriff zu
|
||||||
|
* befoerdern - eine Rolle mit `developer:access` anlegen und sich zuweisen,
|
||||||
|
* oder gleich ein zweites Konto mit dem Haken "Entwicklerzugriff" erzeugen.
|
||||||
|
* Die Rollenverwaltung war damit faktisch eine Rechteerhoehung mit
|
||||||
|
* Zwischenschritt.
|
||||||
|
*
|
||||||
|
* Zwei Dinge sind hier bewusst so gebaut:
|
||||||
|
*
|
||||||
|
* 1. Die effektiven Rechte werden IMMER frisch aus der Datenbank gelesen,
|
||||||
|
* nie aus `req.user.permissions`. Der JWT-Claim ist bis zu 15 Minuten alt
|
||||||
|
* (JWT_EXPIRES_IN). Fuer ein Gate ist das vertretbar - fuer die Frage
|
||||||
|
* "darf dieser Mensch dieses Recht weitergeben" nicht: Ein Konto, dem
|
||||||
|
* gerade `gdpr:admin` entzogen wurde, koennte es im Fenster noch
|
||||||
|
* weiterreichen und damit den Entzug ueberdauern.
|
||||||
|
*
|
||||||
|
* 2. Geprueft wird immer nur der ZUWACHS. Rechte entziehen bleibt jederzeit
|
||||||
|
* erlaubt, auch solche, die der Handelnde selbst nicht hat - sonst
|
||||||
|
* koennte ein Admin einen uebernommenen Developer-Zugang nicht mehr
|
||||||
|
* entschaerfen, und die Regel wuerde den Angreifer schuetzen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import {
|
||||||
|
ROLLE_DEVELOPER,
|
||||||
|
ROLLE_DSGVO,
|
||||||
|
ROLLE_AUDIT_BETRIEB,
|
||||||
|
rechteDerSystemrolle,
|
||||||
|
} from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
/** Der Handelnde wollte Rechte vergeben, die er selbst nicht besitzt. */
|
||||||
|
export class RechteEskalationError extends Error {
|
||||||
|
constructor(public readonly fehlend: string[]) {
|
||||||
|
super(
|
||||||
|
'Sie können nur Rechte vergeben, die Sie selbst besitzen. ' +
|
||||||
|
`Nicht vergeben werden können: ${fehlend.join(', ')}`,
|
||||||
|
);
|
||||||
|
this.name = 'RechteEskalationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Vorgang zielte auf eine Systemrolle, die von der Anwendung gepflegt wird. */
|
||||||
|
export class RollenSperrError extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = 'RollenSperrError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die effektiven Rechte eines Kontos, frisch aus der Datenbank.
|
||||||
|
*
|
||||||
|
* Das ist dieselbe Aufloesung, die auth.service.ts beim Anmelden und beim
|
||||||
|
* Erneuern des Tokens vornimmt - hier an einer Stelle, statt in vier Kopien.
|
||||||
|
*/
|
||||||
|
export async function effektiveRechte(userId: number): Promise<Set<string>> {
|
||||||
|
const konto = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
roles: {
|
||||||
|
include: { role: { include: { permissions: { include: { permission: true } } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
if (!konto) return rechte;
|
||||||
|
for (const ur of konto.roles) {
|
||||||
|
for (const rp of ur.role.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vereinigung der Rechte mehrerer Rollen. */
|
||||||
|
export async function rechteVonRollen(roleIds: number[]): Promise<Set<string>> {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
if (roleIds.length === 0) return rechte;
|
||||||
|
const rollen = await prisma.role.findMany({
|
||||||
|
where: { id: { in: roleIds } },
|
||||||
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
});
|
||||||
|
for (const r of rollen) {
|
||||||
|
for (const rp of r.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loest Rechte-IDs auf. Wirft bei unbekannter ID einen sprechenden Fehler -
|
||||||
|
* bisher lief eine erfundene ID in einen Fremdschluesselfehler und kam als
|
||||||
|
* HTTP 500 zurueck, also als "unser Fehler" statt "Ihre Eingabe".
|
||||||
|
*/
|
||||||
|
export async function rechteVonPermissionIds(ids: number[]): Promise<Set<string>> {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
if (ids.length === 0) return rechte;
|
||||||
|
const gefunden = await prisma.permission.findMany({ where: { id: { in: ids } } });
|
||||||
|
if (gefunden.length !== new Set(ids).size) {
|
||||||
|
const bekannt = new Set(gefunden.map((p) => p.id));
|
||||||
|
const unbekannt = [...new Set(ids)].filter((id) => !bekannt.has(id));
|
||||||
|
throw new Error(`Unbekannte Rechte-ID: ${unbekannt.join(', ')}`);
|
||||||
|
}
|
||||||
|
for (const p of gefunden) rechte.add(`${p.resource}:${p.action}`);
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kernregel. Wirft RechteEskalationError, wenn der Handelnde etwas
|
||||||
|
* weitergeben will, das er selbst nicht haelt.
|
||||||
|
*/
|
||||||
|
export async function pruefeTeilmenge(
|
||||||
|
handelnderId: number,
|
||||||
|
benoetigt: Iterable<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const zuPruefen = [...benoetigt];
|
||||||
|
if (zuPruefen.length === 0) return;
|
||||||
|
|
||||||
|
const eigene = await effektiveRechte(handelnderId);
|
||||||
|
const fehlend = zuPruefen.filter((r) => !eigene.has(r)).sort();
|
||||||
|
if (fehlend.length > 0) throw new RechteEskalationError(fehlend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet die Sitzungen aller Traeger einer Rolle.
|
||||||
|
*
|
||||||
|
* Noetig, weil die Rechte im Zugangstoken stehen: Ohne das behielte jeder
|
||||||
|
* Traeger bis zu 15 Minuten lang die alten Rechte, und ein Entzug waere
|
||||||
|
* genau so lange wirkungslos. `updateUser` machte das laengst - die
|
||||||
|
* Rollenpflege nicht, und dort wiegt es schwerer, weil sie viele Konten auf
|
||||||
|
* einmal betrifft.
|
||||||
|
*
|
||||||
|
* Muss bei Loeschungen VOR dem Loeschen laufen: Danach sind die Traeger
|
||||||
|
* durch den Cascade nicht mehr ermittelbar.
|
||||||
|
*/
|
||||||
|
export async function meldeTraegerAb(roleId: number): Promise<number> {
|
||||||
|
const traeger = await prisma.userRole.findMany({
|
||||||
|
where: { roleId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (traeger.length === 0) return 0;
|
||||||
|
await prisma.user.updateMany({
|
||||||
|
where: { id: { in: traeger.map((t) => t.userId) } },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
return traeger.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Rechte, die hinter den drei Haken im Benutzerformular stehen.
|
||||||
|
*
|
||||||
|
* Die Haken sind keine Datenbankspalten, sondern Kurzschrift fuer die
|
||||||
|
* versteckten Rollen DSGVO, Developer und Audit-Betrieb. Sie muessen unter
|
||||||
|
* dieselbe Teilmengenregel wie die Rollenzuweisung fallen, sonst ist der
|
||||||
|
* Rest Theater: Die Umgehung braucht nur `users:create` - ein zweites Konto
|
||||||
|
* mit dem Haken "Entwicklerzugriff" anlegen und sich damit anmelden. Die
|
||||||
|
* Developer-Rolle traegt ALLE Rechte. Eine reine Selbstvergabe-Sperre
|
||||||
|
* griffe dagegen nicht, denn der Angreifer vergibt sich nichts selbst.
|
||||||
|
*
|
||||||
|
* Nur das EINSCHALTEN wird geprueft. Wer einen Haken entfernt, nimmt Rechte
|
||||||
|
* weg - das darf jeder duerfen, der das Konto verwalten darf.
|
||||||
|
*/
|
||||||
|
export function rechteDerHaken(haken: {
|
||||||
|
hasDeveloperAccess?: boolean;
|
||||||
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
|
}): string[] {
|
||||||
|
const rechte: string[] = [];
|
||||||
|
if (haken.hasDeveloperAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DEVELOPER));
|
||||||
|
if (haken.hasGdprAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DSGVO));
|
||||||
|
if (haken.hasAuditOpsAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_AUDIT_BETRIEB));
|
||||||
|
return [...new Set(rechte)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Bringt Rechtekatalog und Systemrollen in der Datenbank auf den Stand des
|
||||||
|
* Codes. Idempotent - laeuft bei jedem Container-Start.
|
||||||
|
*
|
||||||
|
* Verbraucher: `prisma/sync-roles.ts` (Container-Start), `prisma/seed.ts`
|
||||||
|
* (Erstinstallation) und `factoryReset` in `backup.service.ts`. Alle drei
|
||||||
|
* benutzen dieselbe Definition aus `config/rechte-katalog.ts`; vorher hatte
|
||||||
|
* jeder seine eigene, und die dritte wich ab.
|
||||||
|
*
|
||||||
|
* Was hier NICHT passiert: Stammdaten, Benutzer, Vertraege. Das Skript ist
|
||||||
|
* auf einer laufenden Produktionsdatenbank sicher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
RECHTE_KATALOG,
|
||||||
|
SYSTEMROLLEN,
|
||||||
|
alsRechtString,
|
||||||
|
} from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
type Protokoll = (zeile: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Rechte einer Rolle exakt auf `permissionIds` - fehlende kommen
|
||||||
|
* dazu, ueberzaehlige fliegen raus.
|
||||||
|
*
|
||||||
|
* Der Vollersatz ist Absicht: Er ist der Grund, warum eine per Adminer an
|
||||||
|
* einer Systemrolle vorgenommene Aenderung den naechsten Container-Start
|
||||||
|
* nicht ueberlebt.
|
||||||
|
*/
|
||||||
|
async function synchronisiereRollenrechte(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
roleId: number,
|
||||||
|
permissionIds: number[],
|
||||||
|
log: Protokoll,
|
||||||
|
): Promise<void> {
|
||||||
|
const vorhanden = await prisma.rolePermission.findMany({
|
||||||
|
where: { roleId },
|
||||||
|
select: { permissionId: true },
|
||||||
|
});
|
||||||
|
const vorhandenIds = new Set(vorhanden.map((e) => e.permissionId));
|
||||||
|
const zielIds = new Set(permissionIds);
|
||||||
|
|
||||||
|
const fehlend = permissionIds.filter((id) => !vorhandenIds.has(id));
|
||||||
|
if (fehlend.length > 0) {
|
||||||
|
await prisma.rolePermission.createMany({
|
||||||
|
data: fehlend.map((permissionId) => ({ roleId, permissionId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
log(` → +${fehlend.length} Rechte an Rolle #${roleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ueberzaehlig = vorhanden
|
||||||
|
.filter((e) => !zielIds.has(e.permissionId))
|
||||||
|
.map((e) => e.permissionId);
|
||||||
|
if (ueberzaehlig.length > 0) {
|
||||||
|
await prisma.rolePermission.deleteMany({
|
||||||
|
where: { roleId, permissionId: { in: ueberzaehlig } },
|
||||||
|
});
|
||||||
|
log(` → -${ueberzaehlig.length} Rechte von Rolle #${roleId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt alle Rechte aus dem Katalog an und bringt die Systemrollen auf Stand.
|
||||||
|
* Setzt dabei auch `isSystem` und `isHidden` - das ist die eigentliche
|
||||||
|
* Absicherung gegen eine von Hand verstellte Datenbank, die Migration setzt
|
||||||
|
* die Flags nur einmalig.
|
||||||
|
*/
|
||||||
|
export async function synchronisiereRechteUndRollen(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
log: Protokoll = (z) => console.log(z),
|
||||||
|
): Promise<void> {
|
||||||
|
log('[rollen-sync] Rechtekatalog upserten…');
|
||||||
|
for (const recht of RECHTE_KATALOG) {
|
||||||
|
await prisma.permission.upsert({
|
||||||
|
where: { resource_action: { resource: recht.resource, action: recht.action } },
|
||||||
|
update: {},
|
||||||
|
create: { resource: recht.resource, action: recht.action },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleRechte = await prisma.permission.findMany();
|
||||||
|
log(`[rollen-sync] ${alleRechte.length} Rechte in der Datenbank`);
|
||||||
|
|
||||||
|
// Auflösung Katalog → Datenbank-IDs. Rechte, die in der Datenbank stehen,
|
||||||
|
// aber nicht im Katalog, bleiben unangetastet und werden auch keiner Rolle
|
||||||
|
// zugeteilt: Der Katalog ist die Wahrheit, nicht der Altbestand.
|
||||||
|
const idFuerRecht = new Map<string, number>();
|
||||||
|
for (const p of alleRechte) idFuerRecht.set(alsRechtString(p), p.id);
|
||||||
|
|
||||||
|
for (const spec of SYSTEMROLLEN) {
|
||||||
|
const rechteIds = RECHTE_KATALOG.filter(spec.rechte)
|
||||||
|
.map((r) => idFuerRecht.get(alsRechtString(r)))
|
||||||
|
.filter((id): id is number => id !== undefined);
|
||||||
|
|
||||||
|
const rolle = await prisma.role.upsert({
|
||||||
|
where: { name: spec.name },
|
||||||
|
update: {
|
||||||
|
description: spec.description,
|
||||||
|
isSystem: true,
|
||||||
|
isHidden: spec.isHidden,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: spec.name,
|
||||||
|
description: spec.description,
|
||||||
|
isSystem: true,
|
||||||
|
isHidden: spec.isHidden,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await synchronisiereRollenrechte(prisma, rolle.id, rechteIds, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('[rollen-sync] fertig.');
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { paginate, buildPaginationResponse } from '../utils/helpers.js';
|
import { paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||||||
|
import {
|
||||||
|
pruefeTeilmenge,
|
||||||
|
rechteVonPermissionIds,
|
||||||
|
rechteVonRollen,
|
||||||
|
rechteDerHaken,
|
||||||
|
effektiveRechte,
|
||||||
|
meldeTraegerAb,
|
||||||
|
RollenSperrError,
|
||||||
|
} from './rechte.service.js';
|
||||||
|
import { istSystemrollenName, RECHTE_KATALOG } from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
export interface UserFilters {
|
export interface UserFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -44,6 +54,7 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
isServiceAccount: true,
|
||||||
customerId: true,
|
customerId: true,
|
||||||
whatsappNumber: true,
|
whatsappNumber: true,
|
||||||
telegramUsername: true,
|
telegramUsername: true,
|
||||||
@@ -64,9 +75,10 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Get hidden role IDs
|
// Get hidden role IDs
|
||||||
const [developerRole, gdprRole] = await Promise.all([
|
const [developerRole, gdprRole, auditOpsRole] = await Promise.all([
|
||||||
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
||||||
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
||||||
|
prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -77,11 +89,15 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
const hasGdprAccess = gdprRole
|
const hasGdprAccess = gdprRole
|
||||||
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
||||||
: false;
|
: false;
|
||||||
|
const hasAuditOpsAccess = auditOpsRole
|
||||||
|
? u.roles.some((ur) => ur.roleId === auditOpsRole.id)
|
||||||
|
: false;
|
||||||
return {
|
return {
|
||||||
...u,
|
...u,
|
||||||
roles: u.roles.map((r) => r.role),
|
roles: u.roles.map((r) => r.role),
|
||||||
hasDeveloperAccess,
|
hasDeveloperAccess,
|
||||||
hasGdprAccess,
|
hasGdprAccess,
|
||||||
|
hasAuditOpsAccess,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
pagination: buildPaginationResponse(page, limit, total),
|
pagination: buildPaginationResponse(page, limit, total),
|
||||||
@@ -97,6 +113,7 @@ export async function getUserById(id: number) {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
isServiceAccount: true,
|
||||||
customerId: true,
|
customerId: true,
|
||||||
whatsappNumber: true,
|
whatsappNumber: true,
|
||||||
telegramUsername: true,
|
telegramUsername: true,
|
||||||
@@ -144,10 +161,21 @@ export async function createUser(data: {
|
|||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
}) {
|
},
|
||||||
|
handelnderId: number,
|
||||||
|
) {
|
||||||
|
// Was das neue Konto koennen wird - aus den Rollen UND aus den Haken.
|
||||||
|
// `handelnderId` ist Pflichtparameter, damit kein Aufrufer die Pruefung
|
||||||
|
// vergessen kann; der Compiler erzwingt sie.
|
||||||
|
await pruefeTeilmenge(handelnderId, [
|
||||||
|
...(await rechteVonRollen(data.roleIds)),
|
||||||
|
...rechteDerHaken(data),
|
||||||
|
]);
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
@@ -170,6 +198,7 @@ export async function createUser(data: {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
isServiceAccount: true,
|
||||||
customerId: true,
|
customerId: true,
|
||||||
roles: {
|
roles: {
|
||||||
include: { role: true },
|
include: { role: true },
|
||||||
@@ -187,6 +216,10 @@ export async function createUser(data: {
|
|||||||
await setUserGdprAccess(user.id, true);
|
await setUserGdprAccess(user.id, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.hasAuditOpsAccess) {
|
||||||
|
await setUserAuditOpsAccess(user.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,16 +231,39 @@ export async function updateUser(
|
|||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
isServiceAccount?: boolean;
|
||||||
roleIds?: number[];
|
roleIds?: number[];
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
}
|
},
|
||||||
|
handelnderId: number,
|
||||||
) {
|
) {
|
||||||
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data;
|
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
|
||||||
|
|
||||||
|
// Teilmengenregel auf dem ZUWACHS, nicht auf dem Endzustand.
|
||||||
|
//
|
||||||
|
// Wer nur den Nachnamen eines hoeher privilegierten Kollegen korrigiert,
|
||||||
|
// schickt das Formular unveraendert mit - inklusive gesetzter Haken. Wuerde
|
||||||
|
// hier der Endzustand geprueft, waere jede solche Korrektur ein 403.
|
||||||
|
// Geprueft wird deshalb nur, was das Zielkonto NEU dazubekommt.
|
||||||
|
{
|
||||||
|
const bisher = await effektiveRechte(id);
|
||||||
|
const zuwachs = new Set<string>();
|
||||||
|
if (roleIds !== undefined) {
|
||||||
|
for (const r of await rechteVonRollen(roleIds)) {
|
||||||
|
if (!bisher.has(r)) zuwachs.add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of rechteDerHaken({ hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess })) {
|
||||||
|
if (!bisher.has(r)) zuwachs.add(r);
|
||||||
|
}
|
||||||
|
await pruefeTeilmenge(handelnderId, zuwachs);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this would remove the last admin
|
// Check if this would remove the last admin
|
||||||
const isBeingDeactivated = userData.isActive === false;
|
const isBeingDeactivated = userData.isActive === false;
|
||||||
@@ -344,6 +400,10 @@ export async function updateUser(
|
|||||||
await setUserGdprAccess(id, hasGdprAccess);
|
await setUserGdprAccess(id, hasGdprAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasAuditOpsAccess !== undefined) {
|
||||||
|
await setUserAuditOpsAccess(id, hasAuditOpsAccess);
|
||||||
|
}
|
||||||
|
|
||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +463,16 @@ async function setUserDeveloperAccess(userId: number, enabled: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name der versteckten Rolle fuer eingreifende Audit-Rechte.
|
||||||
|
*
|
||||||
|
* Getrennt von `DSGVO`, weil Aufsicht und Eingriff nicht dieselbe Rolle sein
|
||||||
|
* duerfen: Wer das Protokoll beaufsichtigt, darf seine Beweisgrundlage nicht
|
||||||
|
* ersetzen koennen (Pentest R186). Die Rolle wird von `sync-roles.ts` beim
|
||||||
|
* Containerstart angelegt.
|
||||||
|
*/
|
||||||
|
export const AUDIT_OPS_ROLLE = 'Audit-Betrieb';
|
||||||
|
|
||||||
// Helper to set GDPR access for a user
|
// Helper to set GDPR access for a user
|
||||||
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
||||||
// Get or create DSGVO role
|
// Get or create DSGVO role
|
||||||
@@ -411,17 +481,24 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!gdprRole) {
|
if (!gdprRole) {
|
||||||
// Create DSGVO role with all audit:* and gdpr:* permissions
|
// Rechte-Satz identisch zu sync-roles.ts: gdpr komplett, vom Audit-
|
||||||
|
// Protokoll nur LESEN und EXPORTIEREN. Ohne `audit:admin` - sonst
|
||||||
|
// brächte dieser Notfallpfad genau die Bündelung zurück, die wir gerade
|
||||||
|
// aufgelöst haben (zwei Listen, die dasselbe bedeuten sollen, laufen
|
||||||
|
// auseinander).
|
||||||
const gdprPermissions = await prisma.permission.findMany({
|
const gdprPermissions = await prisma.permission.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
|
OR: [
|
||||||
|
{ resource: 'gdpr' },
|
||||||
|
{ resource: 'audit', action: { in: ['read', 'export'] } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
gdprRole = await prisma.role.create({
|
gdprRole = await prisma.role.create({
|
||||||
data: {
|
data: {
|
||||||
name: 'DSGVO',
|
name: 'DSGVO',
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
|
||||||
permissions: {
|
permissions: {
|
||||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
||||||
},
|
},
|
||||||
@@ -453,6 +530,50 @@ async function setUserGdprAccess(userId: number, enabled: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingreifende Audit-Rechte setzen: versiegeln, neu berechnen, aufraeumen,
|
||||||
|
* Aufbewahrung aendern. Bewusst getrennt vom DSGVO-Haken (siehe
|
||||||
|
* `AUDIT_OPS_ROLLE`).
|
||||||
|
*/
|
||||||
|
async function setUserAuditOpsAccess(userId: number, enabled: boolean) {
|
||||||
|
let rolle = await prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } });
|
||||||
|
|
||||||
|
if (!rolle) {
|
||||||
|
// Rechte-Satz identisch zu sync-roles.ts.
|
||||||
|
const rechte = await prisma.permission.findMany({
|
||||||
|
where: { resource: 'audit', action: { in: ['read', 'admin'] } },
|
||||||
|
});
|
||||||
|
rolle = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
name: AUDIT_OPS_ROLLE,
|
||||||
|
description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern',
|
||||||
|
permissions: { create: rechte.map((p) => ({ permissionId: p.id })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hatRolle = await prisma.userRole.findFirst({
|
||||||
|
where: { userId, roleId: rolle.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (enabled && !hatRolle) {
|
||||||
|
await prisma.userRole.create({ data: { userId, roleId: rolle.id } });
|
||||||
|
// Token invalidieren bei Rechteaenderung
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
} else if (!enabled && hatRolle) {
|
||||||
|
await prisma.userRole.delete({
|
||||||
|
where: { userId_roleId: { userId, roleId: rolle.id } },
|
||||||
|
});
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteUser(id: number) {
|
export async function deleteUser(id: number) {
|
||||||
// Check if user is an admin
|
// Check if user is an admin
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -542,17 +663,42 @@ export async function getRoleById(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRole(data: {
|
/**
|
||||||
name: string;
|
* Legt eine Rolle an.
|
||||||
description?: string;
|
*
|
||||||
permissionIds: number[];
|
* `handelnderId` ist Pflichtparameter, nicht optional: So kann ein Controller
|
||||||
}) {
|
* die Eskalationspruefung nicht vergessen, und der Compiler erzwingt sie bei
|
||||||
|
* jedem kuenftigen Aufrufer. Bis 09/2026 ging hier `req.body` ungefiltert
|
||||||
|
* durch - wer `users:create` hatte, konnte eine Rolle mit `developer:access`
|
||||||
|
* bauen und sie sich anschliessend selbst zuweisen.
|
||||||
|
*/
|
||||||
|
export async function createRole(
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissionIds: number[];
|
||||||
|
},
|
||||||
|
handelnderId: number,
|
||||||
|
) {
|
||||||
|
const name = data.name.trim();
|
||||||
|
|
||||||
|
// Kein zweites "Admin". Getrimmt und ohne Ruecksicht auf Gross-/
|
||||||
|
// Kleinschreibung verglichen, sonst liesse sich " admin " anlegen, das in
|
||||||
|
// einer Liste wie das Original aussieht.
|
||||||
|
if (istSystemrollenName(name)) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${name}" ist der Name einer Systemrolle und kann nicht neu vergeben werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pruefeTeilmenge(handelnderId, await rechteVonPermissionIds(data.permissionIds));
|
||||||
|
|
||||||
return prisma.role.create({
|
return prisma.role.create({
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
permissions: {
|
permissions: {
|
||||||
create: data.permissionIds.map((permissionId) => ({ permissionId })),
|
create: [...new Set(data.permissionIds)].map((permissionId) => ({ permissionId })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -563,15 +709,50 @@ export async function createRole(data: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aendert eine Rolle. Systemrollen sind gesperrt.
|
||||||
|
*
|
||||||
|
* Geprueft wird nur der ZUWACHS gegenueber dem bisherigen Rechtesatz der
|
||||||
|
* Rolle - wer Rechte wegnimmt, braucht sie nicht selbst zu besitzen.
|
||||||
|
*/
|
||||||
export async function updateRole(
|
export async function updateRole(
|
||||||
id: number,
|
id: number,
|
||||||
data: {
|
data: {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
permissionIds?: number[];
|
permissionIds?: number[];
|
||||||
}
|
},
|
||||||
|
handelnderId: number,
|
||||||
) {
|
) {
|
||||||
|
const bestehend = await prisma.role.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
});
|
||||||
|
if (!bestehend) return null;
|
||||||
|
|
||||||
|
if (bestehend.isSystem) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${bestehend.name}" ist eine Systemrolle. Sie wird von der Anwendung ` +
|
||||||
|
'gepflegt und kann hier nicht geändert werden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.name !== undefined && istSystemrollenName(data.name)) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${data.name.trim()}" ist der Name einer Systemrolle und kann nicht vergeben werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { permissionIds, ...roleData } = data;
|
const { permissionIds, ...roleData } = data;
|
||||||
|
if (roleData.name !== undefined) roleData.name = roleData.name.trim();
|
||||||
|
|
||||||
|
if (permissionIds) {
|
||||||
|
const bisher = new Set(
|
||||||
|
bestehend.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||||
|
);
|
||||||
|
const kuenftig = await rechteVonPermissionIds(permissionIds);
|
||||||
|
const zuwachs = [...kuenftig].filter((r) => !bisher.has(r));
|
||||||
|
await pruefeTeilmenge(handelnderId, zuwachs);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.role.update({
|
await prisma.role.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -581,28 +762,69 @@ export async function updateRole(
|
|||||||
if (permissionIds) {
|
if (permissionIds) {
|
||||||
await prisma.rolePermission.deleteMany({ where: { roleId: id } });
|
await prisma.rolePermission.deleteMany({ where: { roleId: id } });
|
||||||
await prisma.rolePermission.createMany({
|
await prisma.rolePermission.createMany({
|
||||||
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })),
|
// Dedupliziert: Doppelte IDs im Body liefen vorher in einen
|
||||||
|
// Primaerschluesselkonflikt und kamen als HTTP 500 zurueck.
|
||||||
|
data: [...new Set(permissionIds)].map((permissionId) => ({ roleId: id, permissionId })),
|
||||||
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens.
|
||||||
|
await meldeTraegerAb(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getRoleById(id);
|
return getRoleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(id: number) {
|
export async function deleteRole(id: number) {
|
||||||
// Check if role is assigned to any users
|
const bestehend = await prisma.role.findUnique({ where: { id } });
|
||||||
|
if (!bestehend) {
|
||||||
|
throw new Error('Rolle nicht gefunden');
|
||||||
|
}
|
||||||
|
if (bestehend.isSystem) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${bestehend.name}" ist eine Systemrolle und kann nicht gelöscht werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vor dem Loeschen abmelden - danach sind die Traeger durch den Cascade
|
||||||
|
// nicht mehr ermittelbar. Greift heute nur theoretisch, weil zugewiesene
|
||||||
|
// Rollen ohnehin abgelehnt werden; die Reihenfolge bleibt trotzdem die
|
||||||
|
// richtige, falls diese Sperre je gelockert wird.
|
||||||
const count = await prisma.userRole.count({ where: { roleId: id } });
|
const count = await prisma.userRole.count({ where: { roleId: id } });
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist`
|
`Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await meldeTraegerAb(id);
|
||||||
|
|
||||||
return prisma.role.delete({ where: { id } });
|
return prisma.role.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission operations
|
// Permission operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die vergebbaren Rechte - angereichert um Klartext und Gruppe.
|
||||||
|
*
|
||||||
|
* Gefiltert auf den Katalog: In der Datenbank koennen Rechte aus frueheren
|
||||||
|
* Schemata liegen (`customers:access`, `settings:create` und aehnliche), die
|
||||||
|
* nirgends geprueft werden. Ungefiltert wuerden sie in der Rollenoberflaeche
|
||||||
|
* als anhakbare Kaestchen erscheinen, die nichts bewirken - genau der
|
||||||
|
* Zustand, den dieser Umbau beseitigt. Geloescht werden sie nicht: Ein
|
||||||
|
* Lesefilter ist die kleinere Behauptung als ein DELETE, und der
|
||||||
|
* Rechte-Report meldet sie ohnehin.
|
||||||
|
*/
|
||||||
export async function getAllPermissions() {
|
export async function getAllPermissions() {
|
||||||
return prisma.permission.findMany({
|
const alle = await prisma.permission.findMany({
|
||||||
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
|
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
|
||||||
});
|
});
|
||||||
|
const beschreibung = new Map(
|
||||||
|
RECHTE_KATALOG.map((r) => [`${r.resource}:${r.action}`, r]),
|
||||||
|
);
|
||||||
|
return alle
|
||||||
|
.filter((p) => beschreibung.has(`${p.resource}:${p.action}`))
|
||||||
|
.map((p) => {
|
||||||
|
const k = beschreibung.get(`${p.resource}:${p.action}`)!;
|
||||||
|
return { ...p, bezeichnung: k.bezeichnung, gruppe: k.gruppe };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -640,6 +640,10 @@ const USER_UPDATABLE_FIELDS = [
|
|||||||
'firstName',
|
'firstName',
|
||||||
'lastName',
|
'lastName',
|
||||||
'isActive',
|
'isActive',
|
||||||
|
// Kennzeichnet planmaessig arbeitende Konten (z. B. das Gegenbuch). Ihre
|
||||||
|
// Anmeldungen werden als Routine statt CRITICAL gefuehrt, dafuer wacht der
|
||||||
|
// Heartbeat-Monitor ueber ihr AUSBLEIBEN (Pentest R182/R183).
|
||||||
|
'isServiceAccount',
|
||||||
'whatsappNumber',
|
'whatsappNumber',
|
||||||
'telegramUsername',
|
'telegramUsername',
|
||||||
'signalNumber',
|
'signalNumber',
|
||||||
@@ -650,6 +654,10 @@ const USER_UPDATABLE_FIELDS = [
|
|||||||
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
||||||
'hasGdprAccess',
|
'hasGdprAccess',
|
||||||
'hasDeveloperAccess',
|
'hasDeveloperAccess',
|
||||||
|
// Eingreifende Audit-Rechte (versiegeln/aufraeumen/Aufbewahrung), bewusst
|
||||||
|
// getrennt von hasGdprAccess: Aufsicht und Eingriff sind zwei Rollen
|
||||||
|
// (Pentest R186). Mappt auf die versteckte Rolle 'Audit-Betrieb'.
|
||||||
|
'hasAuditOpsAccess',
|
||||||
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
||||||
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
||||||
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
||||||
@@ -837,6 +845,18 @@ export function pickUserCreate(body: unknown): Partial<Record<string, unknown>>
|
|||||||
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rollen-Whitelist.
|
||||||
|
//
|
||||||
|
// `createRole`/`updateRole` reichten `req.body` bis 09/2026 ungefiltert an
|
||||||
|
// Prisma durch - dasselbe Muster wie R110, nur an der Stelle, an der
|
||||||
|
// festgelegt wird, wer was darf. `isSystem` und `isHidden` duerfen NIEMALS
|
||||||
|
// aus dem Request kommen: Sie sind die Sperre selbst, nicht ihr Gegenstand.
|
||||||
|
const ROLE_UPDATABLE_FIELDS = ['name', 'description', 'permissionIds'] as const;
|
||||||
|
|
||||||
|
export function pickRoleUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||||||
|
return pick((body as object) || {}, ROLE_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ====================
|
// ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ====================
|
||||||
// Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten
|
// Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten
|
||||||
// `req.body` ungefiltert an Prisma durch – gleiches Muster wie das
|
// `req.body` ungefiltert an Prisma durch – gleiches Muster wie das
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ services:
|
|||||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m}
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m}
|
||||||
JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d}
|
JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
# Siegel für das Audit-Log: signiert jeden Eintrag mit einem Schlüssel,
|
||||||
|
# der außerhalb der Datenbank liegt. Ohne Wert läuft alles normal weiter,
|
||||||
|
# nur ungesiegelt (fail-safe). AUDIT_HMAC_KEY_OLD nur beim Wechsel füllen
|
||||||
|
# (kommagetrennte Liste früherer Schlüssel).
|
||||||
|
AUDIT_HMAC_KEY: ${AUDIT_HMAC_KEY:-}
|
||||||
|
AUDIT_HMAC_KEY_OLD: ${AUDIT_HMAC_KEY_OLD:-}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
LISTEN_ADDR: 0.0.0.0
|
LISTEN_ADDR: 0.0.0.0
|
||||||
@@ -84,6 +90,34 @@ services:
|
|||||||
# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen)
|
# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen)
|
||||||
- ${BANKDATA_DIR:-./data/bankdata}:/app/bankdata
|
- ${BANKDATA_DIR:-./data/bankdata}:/app/bankdata
|
||||||
|
|
||||||
|
# Optionaler Reverse-Proxy mit automatischem SSL – nur fuer Betreiber OHNE
|
||||||
|
# eigenen Proxy. Wird standardmaessig NICHT angelegt.
|
||||||
|
#
|
||||||
|
# Einschalten ueber die .env (kein Auskommentieren noetig):
|
||||||
|
# COMPOSE_PROFILES=caddy
|
||||||
|
# Danach reicht wie gewohnt `docker-compose up -d`. Alternativ einmalig per
|
||||||
|
# Flag: `docker-compose --profile caddy up -d`.
|
||||||
|
# Wer nginx / Nginx Proxy Manager / Traefik davor hat, laesst das Feld leer.
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: opencrm-caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["caddy"]
|
||||||
|
depends_on:
|
||||||
|
- opencrm
|
||||||
|
environment:
|
||||||
|
DOMAIN: ${DOMAIN:-localhost}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
# Zertifikate liegen wie alle Daten unter ./data/ – nicht loeschen,
|
||||||
|
# sonst werden bei jedem Start neue Zertifikate angefordert
|
||||||
|
# (Let's-Encrypt-Limits!).
|
||||||
|
- ${CADDY_DIR:-./data/caddy}:/data
|
||||||
|
- ${CADDY_CONFIG_DIR:-./data/caddy-config}:/config
|
||||||
|
|
||||||
adminer:
|
adminer:
|
||||||
image: adminer:latest
|
image: adminer:latest
|
||||||
container_name: opencrm-adminer
|
container_name: opencrm-adminer
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# OpenCRM Docker Environment
|
|
||||||
# ============================
|
|
||||||
# Copy this file to .env and adjust the values
|
|
||||||
|
|
||||||
# Domain (for Caddy SSL certificate)
|
|
||||||
# Use your actual domain, e.g., crm.example.com
|
|
||||||
DOMAIN=localhost
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_ROOT_PASSWORD=change-this-root-password
|
|
||||||
DB_NAME=opencrm
|
|
||||||
DB_USER=opencrm
|
|
||||||
DB_PASSWORD=change-this-password
|
|
||||||
|
|
||||||
# JWT Authentication
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
JWT_SECRET=change-this-to-a-secure-random-string
|
|
||||||
# Access-Token kurz (XSS-Schutz, Token lebt nur im JS-Memory).
|
|
||||||
# Refresh-Token lang im httpOnly-Cookie.
|
|
||||||
JWT_EXPIRES_IN=15m
|
|
||||||
JWT_REFRESH_EXPIRES_IN=7d
|
|
||||||
|
|
||||||
# Encryption Key (for portal credentials)
|
|
||||||
# Generate with: openssl rand -hex 32
|
|
||||||
ENCRYPTION_KEY=change-this-to-a-32-byte-hex-key
|
|
||||||
|
|
||||||
# First Install: Set to "true" to seed database on first startup
|
|
||||||
# After first successful start, set back to "false"
|
|
||||||
RUN_SEED=true
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# OpenCRM Caddyfile
|
|
||||||
# ===================
|
|
||||||
# Replace {$DOMAIN} with your actual domain or use environment variable
|
|
||||||
# For local development without SSL, use localhost:80
|
|
||||||
|
|
||||||
{$DOMAIN:localhost} {
|
|
||||||
# Reverse proxy to OpenCRM app
|
|
||||||
reverse_proxy app: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
|
|
||||||
encode gzip
|
|
||||||
|
|
||||||
# Handle file uploads (increase body limit)
|
|
||||||
request_body {
|
|
||||||
max_size 50MB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# ===================================
|
|
||||||
# Stage 1: Build Frontend
|
|
||||||
# ===================================
|
|
||||||
FROM node:20-alpine AS frontend-builder
|
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY frontend/package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy frontend source
|
|
||||||
COPY frontend/ ./
|
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# Stage 2: Build Backend
|
|
||||||
# ===================================
|
|
||||||
FROM node:20-alpine AS backend-builder
|
|
||||||
|
|
||||||
WORKDIR /app/backend
|
|
||||||
|
|
||||||
# Install OpenSSL for Prisma
|
|
||||||
RUN apk add --no-cache openssl
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY backend/package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies (including dev for build)
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy backend source
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
# Generate Prisma client
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
# Build TypeScript
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# Stage 3: Production Runtime
|
|
||||||
# ===================================
|
|
||||||
FROM node:20-alpine AS production
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install OpenSSL for Prisma runtime and netcat for DB health check
|
|
||||||
RUN apk add --no-cache openssl netcat-openbsd
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S opencrm -u 1001 -G nodejs
|
|
||||||
|
|
||||||
# Copy backend package files
|
|
||||||
COPY backend/package*.json ./
|
|
||||||
|
|
||||||
# Install production dependencies + tsx for seed script
|
|
||||||
RUN npm ci --omit=dev && npm install tsx
|
|
||||||
|
|
||||||
# Copy Prisma schema and generate client
|
|
||||||
COPY backend/prisma ./prisma
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
# Copy built backend
|
|
||||||
COPY --from=backend-builder /app/backend/dist ./dist
|
|
||||||
|
|
||||||
# Copy built frontend to public directory
|
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
|
||||||
|
|
||||||
# Copy seed file for factory reset
|
|
||||||
COPY backend/prisma/seed.ts ./prisma/
|
|
||||||
|
|
||||||
# Copy entrypoint script
|
|
||||||
COPY docker/entrypoint.sh ./entrypoint.sh
|
|
||||||
|
|
||||||
# Create directories for uploads and backups
|
|
||||||
RUN mkdir -p uploads backups && \
|
|
||||||
chmod +x entrypoint.sh && \
|
|
||||||
chown -R opencrm:nodejs /app
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER opencrm
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3001
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
|
|
||||||
|
|
||||||
# Start the application with entrypoint
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# OpenCRM Docker Deployment
|
|
||||||
|
|
||||||
## Schnellstart
|
|
||||||
|
|
||||||
1. **Umgebungsvariablen konfigurieren:**
|
|
||||||
```bash
|
|
||||||
cd docker
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env # Sichere Werte setzen
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Container starten (erster Start mit RUN_SEED=true):**
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Beim ersten Start wird automatisch:
|
|
||||||
- Auf die Datenbank gewartet
|
|
||||||
- Migrationen ausgeführt
|
|
||||||
- Seed-Daten geladen (wenn `RUN_SEED=true`)
|
|
||||||
|
|
||||||
3. **Nach erfolgreicher Installation:**
|
|
||||||
```bash
|
|
||||||
# RUN_SEED in .env auf false setzen
|
|
||||||
sed -i 's/RUN_SEED=true/RUN_SEED=false/' .env
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Anwendung aufrufen:**
|
|
||||||
- Mit Domain: `https://your-domain.com`
|
|
||||||
- Lokal: `http://localhost`
|
|
||||||
|
|
||||||
5. **Login:**
|
|
||||||
- E-Mail: `admin@admin.com`
|
|
||||||
- Passwort: `admin`
|
|
||||||
|
|
||||||
## Architektur
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐
|
|
||||||
│ Caddy │
|
|
||||||
│ (SSL/TLS) │
|
|
||||||
│ :80/:443 │
|
|
||||||
└──────┬──────┘
|
|
||||||
│
|
|
||||||
┌──────▼──────┐
|
|
||||||
│ OpenCRM │
|
|
||||||
│ (Node.js) │
|
|
||||||
│ :3001 │
|
|
||||||
└──────┬──────┘
|
|
||||||
│
|
|
||||||
┌──────▼──────┐
|
|
||||||
│ MariaDB │
|
|
||||||
│ :3306 │
|
|
||||||
└─────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Befehle
|
|
||||||
|
|
||||||
### Container verwalten
|
|
||||||
```bash
|
|
||||||
# Starten
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Stoppen
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Logs anzeigen
|
|
||||||
docker compose logs -f app
|
|
||||||
|
|
||||||
# Neustart
|
|
||||||
docker compose restart app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Datenbank
|
|
||||||
```bash
|
|
||||||
# Migration ausführen
|
|
||||||
docker compose exec app npx prisma migrate deploy
|
|
||||||
|
|
||||||
# Seed-Daten laden
|
|
||||||
docker compose exec app npx tsx prisma/seed.ts
|
|
||||||
|
|
||||||
# Prisma Studio (Datenbank-UI)
|
|
||||||
docker compose exec app npx prisma studio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backup & Restore
|
|
||||||
```bash
|
|
||||||
# Backup-Verzeichnis ist unter /app/backups gemountet
|
|
||||||
# Backups werden über die Anwendung erstellt/wiederhergestellt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update
|
|
||||||
```bash
|
|
||||||
# Image neu bauen und Container aktualisieren
|
|
||||||
docker compose build --no-cache
|
|
||||||
docker compose up -d
|
|
||||||
# Migrationen werden automatisch beim Start ausgeführt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Volumes
|
|
||||||
|
|
||||||
| Volume | Beschreibung |
|
|
||||||
|--------|--------------|
|
|
||||||
| `mariadb_data` | Datenbank-Dateien |
|
|
||||||
| `uploads_data` | Hochgeladene Dokumente |
|
|
||||||
| `backups_data` | Backup-Dateien |
|
|
||||||
| `caddy_data` | SSL-Zertifikate |
|
|
||||||
| `caddy_config` | Caddy-Konfiguration |
|
|
||||||
|
|
||||||
## SSL-Zertifikat
|
|
||||||
|
|
||||||
Caddy holt automatisch ein Let's Encrypt Zertifikat wenn:
|
|
||||||
- Die Domain in `.env` korrekt gesetzt ist
|
|
||||||
- Port 80 und 443 von außen erreichbar sind
|
|
||||||
- DNS auf den Server zeigt
|
|
||||||
|
|
||||||
Für lokale Entwicklung mit `DOMAIN=localhost` wird ein selbstsigniertes Zertifikat verwendet.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container startet nicht
|
|
||||||
```bash
|
|
||||||
docker compose logs app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Datenbank-Verbindung fehlgeschlagen
|
|
||||||
```bash
|
|
||||||
# Warten bis MariaDB bereit ist
|
|
||||||
docker compose logs db
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSL-Zertifikat Probleme
|
|
||||||
```bash
|
|
||||||
docker compose logs caddy
|
|
||||||
# Caddy-Daten zurücksetzen
|
|
||||||
docker compose down
|
|
||||||
docker volume rm opencrm_caddy_data opencrm_caddy_config
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
# ===================================
|
|
||||||
# MariaDB Database
|
|
||||||
# ===================================
|
|
||||||
db:
|
|
||||||
image: mariadb:10.11
|
|
||||||
container_name: opencrm-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpassword}
|
|
||||||
MYSQL_DATABASE: ${DB_NAME:-opencrm}
|
|
||||||
MYSQL_USER: ${DB_USER:-opencrm}
|
|
||||||
MYSQL_PASSWORD: ${DB_PASSWORD:-opencrm123}
|
|
||||||
volumes:
|
|
||||||
- mariadb_data:/var/lib/mysql
|
|
||||||
networks:
|
|
||||||
- opencrm-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
|
||||||
start_period: 10s
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# OpenCRM Application
|
|
||||||
# ===================================
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
container_name: opencrm-app
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3001
|
|
||||||
DATABASE_URL: mysql://${DB_USER:-opencrm}:${DB_PASSWORD:-opencrm123}@db:3306/${DB_NAME:-opencrm}
|
|
||||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
|
||||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}
|
|
||||||
RUN_SEED: ${RUN_SEED:-false}
|
|
||||||
volumes:
|
|
||||||
- uploads_data:/app/uploads
|
|
||||||
- backups_data:/app/backups
|
|
||||||
networks:
|
|
||||||
- opencrm-network
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
|
|
||||||
start_period: 60s
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# Caddy Reverse Proxy (with auto SSL)
|
|
||||||
# ===================================
|
|
||||||
caddy:
|
|
||||||
image: caddy:2-alpine
|
|
||||||
container_name: opencrm-caddy
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
||||||
- caddy_data:/data
|
|
||||||
- caddy_config:/config
|
|
||||||
networks:
|
|
||||||
- opencrm-network
|
|
||||||
depends_on:
|
|
||||||
app:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
networks:
|
|
||||||
opencrm-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mariadb_data:
|
|
||||||
uploads_data:
|
|
||||||
backups_data:
|
|
||||||
caddy_data:
|
|
||||||
caddy_config:
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== OpenCRM Startup ==="
|
|
||||||
|
|
||||||
# Wait for database to be ready
|
|
||||||
echo "Waiting for database connection..."
|
|
||||||
MAX_RETRIES=30
|
|
||||||
RETRY_COUNT=0
|
|
||||||
|
|
||||||
while ! nc -z db 3306 2>/dev/null; do
|
|
||||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
|
||||||
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
|
|
||||||
echo "Error: Database not available after $MAX_RETRIES attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " Attempt $RETRY_COUNT/$MAX_RETRIES - waiting..."
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Database is ready!"
|
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
echo "Running database migrations..."
|
|
||||||
npx prisma migrate deploy
|
|
||||||
|
|
||||||
# Seed database if RUN_SEED is set (first install)
|
|
||||||
if [ "$RUN_SEED" = "true" ]; then
|
|
||||||
echo "Seeding database..."
|
|
||||||
npx tsx prisma/seed.ts
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Einmal-Bereinigung für Pentest-Reste (Runde 12): XSS-Strings aus
|
|
||||||
# Customer/User-Feldern entfernen + unbekannte AppSettings löschen.
|
|
||||||
# Idempotent – läuft bei jedem Container-Start ohne Risiko.
|
|
||||||
echo "Running data cleanup..."
|
|
||||||
npx tsx prisma/cleanup-xss-and-mass-assignment.ts || echo " (Cleanup übersprungen, nicht-kritisch)"
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
echo "Starting OpenCRM server..."
|
|
||||||
exec node dist/index.js
|
|
||||||
+1656
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,8 @@ const statusLabels: Record<ContractStatus, string> = {
|
|||||||
DRAFT: 'Entwurf',
|
DRAFT: 'Entwurf',
|
||||||
PENDING: 'Ausstehend',
|
PENDING: 'Ausstehend',
|
||||||
ACTIVE: 'Aktiv',
|
ACTIVE: 'Aktiv',
|
||||||
CANCELLED: 'Gekündigt',
|
CANCELLED: 'Gekündigt / Bestätigung abwarten',
|
||||||
|
CANCELLED_CONFIRMED: 'Gekündigt / bestätigt',
|
||||||
EXPIRED: 'Abgelaufen',
|
EXPIRED: 'Abgelaufen',
|
||||||
DEACTIVATED: 'Deaktiviert',
|
DEACTIVATED: 'Deaktiviert',
|
||||||
ONGOING: 'Läuft weiter (unbestimmt)',
|
ONGOING: 'Läuft weiter (unbestimmt)',
|
||||||
@@ -30,7 +31,8 @@ const statusLabels: Record<ContractStatus, string> = {
|
|||||||
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
|
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
|
||||||
ACTIVE: 'success',
|
ACTIVE: 'success',
|
||||||
PENDING: 'warning',
|
PENDING: 'warning',
|
||||||
CANCELLED: 'danger',
|
CANCELLED: 'warning',
|
||||||
|
CANCELLED_CONFIRMED: 'danger',
|
||||||
EXPIRED: 'danger',
|
EXPIRED: 'danger',
|
||||||
DRAFT: 'default',
|
DRAFT: 'default',
|
||||||
DEACTIVATED: 'default',
|
DEACTIVATED: 'default',
|
||||||
@@ -132,15 +134,19 @@ export default function ContractDetailModal({ contractId, isOpen, onClose }: Con
|
|||||||
<CopyButton value={c.energyDetails.meter.meterNumber} />
|
<CopyButton value={c.energyDetails.meter.meterNumber} />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{c.energyDetails.maloId && (
|
{(() => {
|
||||||
<div>
|
// MaLo-ID kommt jetzt aus der Lieferadresse (je Sparte).
|
||||||
<dt className="text-sm text-gray-500">MaLo-ID</dt>
|
const malo = c.type === 'ELECTRICITY' ? c.address?.maloIdElectricity : c.address?.maloIdGas;
|
||||||
<dd className="font-mono flex items-center gap-1">
|
return malo ? (
|
||||||
{c.energyDetails.maloId}
|
<div>
|
||||||
<CopyButton value={c.energyDetails.maloId} />
|
<dt className="text-sm text-gray-500">MaLo-ID ({c.type === 'ELECTRICITY' ? 'Strom' : 'Gas'})</dt>
|
||||||
</dd>
|
<dd className="font-mono flex items-center gap-1">
|
||||||
</div>
|
{malo}
|
||||||
)}
|
<CopyButton value={malo} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ export default function AssignToContractModal({
|
|||||||
px-2 py-0.5 text-xs rounded-full
|
px-2 py-0.5 text-xs rounded-full
|
||||||
${contract.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
|
${contract.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
|
||||||
contract.status === 'PENDING' ? 'bg-yellow-100 text-yellow-800' :
|
contract.status === 'PENDING' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
contract.status === 'CANCELLED' ? 'bg-red-100 text-red-800' :
|
contract.status === 'CANCELLED' ? 'bg-orange-100 text-orange-800' :
|
||||||
|
contract.status === 'CANCELLED_CONFIRMED' ? 'bg-red-100 text-red-800' :
|
||||||
'bg-gray-100 text-gray-800'}
|
'bg-gray-100 text-gray-800'}
|
||||||
`}>
|
`}>
|
||||||
{contract.status}
|
{contract.status}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Modal from './Modal';
|
||||||
|
import { viewUrl, fileUrl } from '../../utils/fileUrl';
|
||||||
|
import { ExternalLink, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PdfViewerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
/** Relativer Upload-Pfad des Dokuments (documentPath). */
|
||||||
|
path: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt ein hochgeladenes PDF (Bankkarte/Ausweis) inline in einem Modal.
|
||||||
|
*
|
||||||
|
* Die Datei wird über `viewUrl(path)` geladen → `/api/files/download` mit
|
||||||
|
* `disposition=inline` und Token als Query-Param (iframe/GET, kein
|
||||||
|
* Authorization-Header möglich). Der Backend-Controller macht weiterhin den
|
||||||
|
* Per-File-Ownership-Check – hier wird kein neuer Zugriffspfad geöffnet.
|
||||||
|
*/
|
||||||
|
export default function PdfViewerModal({ isOpen, onClose, title, path }: PdfViewerModalProps) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl">
|
||||||
|
{path ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-end gap-4 text-sm">
|
||||||
|
<a
|
||||||
|
href={viewUrl(path)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" /> In neuem Tab öffnen
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={fileUrl(path)}
|
||||||
|
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" /> Herunterladen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src={viewUrl(path)}
|
||||||
|
title={title}
|
||||||
|
className="w-full h-[75vh] border border-gray-200 rounded bg-gray-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">Kein Dokument hinterlegt.</p>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,28 +26,28 @@ export default function Settings() {
|
|||||||
icon: Clock,
|
icon: Clock,
|
||||||
title: 'Kündigungsfristen',
|
title: 'Kündigungsfristen',
|
||||||
description: 'Konfigurieren Sie die verfügbaren Kündigungsfristen für Verträge.',
|
description: 'Konfigurieren Sie die verfügbaren Kündigungsfristen für Verträge.',
|
||||||
show: hasPermission('platforms:read'),
|
show: hasPermission('cancellation-periods:read'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/contract-durations',
|
to: '/settings/contract-durations',
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
title: 'Vertragslaufzeiten',
|
title: 'Vertragslaufzeiten',
|
||||||
description: 'Konfigurieren Sie die verfügbaren Laufzeiten für Verträge.',
|
description: 'Konfigurieren Sie die verfügbaren Laufzeiten für Verträge.',
|
||||||
show: hasPermission('platforms:read'),
|
show: hasPermission('contract-durations:read'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/providers',
|
to: '/settings/providers',
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
title: 'Anbieter & Tarife',
|
title: 'Anbieter & Tarife',
|
||||||
description: 'Verwalten Sie Anbieter und deren Tarife für Verträge.',
|
description: 'Verwalten Sie Anbieter und deren Tarife für Verträge.',
|
||||||
show: hasPermission('providers:read') || hasPermission('platforms:read'),
|
show: hasPermission('providers:read') || hasPermission('tariffs:read'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/contract-categories',
|
to: '/settings/contract-categories',
|
||||||
icon: FileType,
|
icon: FileType,
|
||||||
title: 'Vertragstypen',
|
title: 'Vertragstypen',
|
||||||
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
|
description: 'Konfigurieren Sie die verfügbaren Vertragstypen (Strom, Gas, Mobilfunk, etc.).',
|
||||||
show: hasPermission('platforms:read'),
|
show: hasPermission('contract-categories:read'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/credit-note-number-range',
|
to: '/settings/credit-note-number-range',
|
||||||
@@ -180,6 +180,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
{hasPermission('email-providers:read') && (
|
||||||
<Link
|
<Link
|
||||||
to="/settings/email-providers"
|
to="/settings/email-providers"
|
||||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||||
@@ -197,6 +198,7 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
to="/settings/database-backup"
|
to="/settings/database-backup"
|
||||||
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
className="block p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md hover:border-blue-300 transition-all group"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Button from '../../components/ui/Button';
|
|||||||
import Badge from '../../components/ui/Badge';
|
import Badge from '../../components/ui/Badge';
|
||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Modal from '../../components/ui/Modal';
|
import Modal from '../../components/ui/Modal';
|
||||||
|
import PdfViewerModal from '../../components/ui/PdfViewerModal';
|
||||||
import FileUpload from '../../components/ui/FileUpload';
|
import FileUpload from '../../components/ui/FileUpload';
|
||||||
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText, Images } from 'lucide-react';
|
import { Edit, Trash2, Copy, Eye, EyeOff, ArrowLeft, ArrowRight, Download, ExternalLink, Plus, ChevronDown, ChevronUp, Gauge, CheckCircle, Circle, ClipboardList, MessageSquare, Calculator, Info, X, BellOff, Lock, Shield, FileText, Images } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@@ -42,7 +43,8 @@ const statusLabels: Record<ContractStatus, string> = {
|
|||||||
DRAFT: 'Entwurf',
|
DRAFT: 'Entwurf',
|
||||||
PENDING: 'Ausstehend',
|
PENDING: 'Ausstehend',
|
||||||
ACTIVE: 'Aktiv',
|
ACTIVE: 'Aktiv',
|
||||||
CANCELLED: 'Gekündigt',
|
CANCELLED: 'Gekündigt / Bestätigung abwarten',
|
||||||
|
CANCELLED_CONFIRMED: 'Gekündigt / bestätigt',
|
||||||
EXPIRED: 'Abgelaufen',
|
EXPIRED: 'Abgelaufen',
|
||||||
DEACTIVATED: 'Deaktiviert',
|
DEACTIVATED: 'Deaktiviert',
|
||||||
ONGOING: 'Läuft weiter (unbestimmt)',
|
ONGOING: 'Läuft weiter (unbestimmt)',
|
||||||
@@ -51,7 +53,8 @@ const statusLabels: Record<ContractStatus, string> = {
|
|||||||
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
|
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
|
||||||
ACTIVE: 'success',
|
ACTIVE: 'success',
|
||||||
PENDING: 'warning',
|
PENDING: 'warning',
|
||||||
CANCELLED: 'danger',
|
CANCELLED: 'warning',
|
||||||
|
CANCELLED_CONFIRMED: 'danger',
|
||||||
EXPIRED: 'danger',
|
EXPIRED: 'danger',
|
||||||
DRAFT: 'default',
|
DRAFT: 'default',
|
||||||
DEACTIVATED: 'default',
|
DEACTIVATED: 'default',
|
||||||
@@ -65,7 +68,8 @@ const statusDescriptions = [
|
|||||||
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||||
{ status: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet – alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
|
{ status: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet – alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
|
||||||
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||||
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
{ status: 'CANCELLED', label: 'Gekündigt / Bestätigung abwarten', description: 'Kündigungsschreiben raus – Bestätigung des Anbieters steht noch aus', color: 'text-orange-600' },
|
||||||
|
{ status: 'CANCELLED_CONFIRMED', label: 'Gekündigt / bestätigt', description: 'Kündigung vom Anbieter bestätigt, Vertrag endet', color: 'text-red-600' },
|
||||||
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1551,6 +1555,9 @@ export default function ContractDetail() {
|
|||||||
// Kunden-Schnellansicht-Modal
|
// Kunden-Schnellansicht-Modal
|
||||||
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
|
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
|
||||||
|
|
||||||
|
// PDF-Viewer-Modal (Bankkarte-/Ausweis-Dokument)
|
||||||
|
const [pdfModal, setPdfModal] = useState<{ title: string; path: string } | null>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['contract', id],
|
queryKey: ['contract', id],
|
||||||
queryFn: () => contractApi.getById(contractId),
|
queryFn: () => contractApi.getById(contractId),
|
||||||
@@ -2658,13 +2665,49 @@ export default function ContractDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{c.bankCard && (
|
{c.bankCard && (
|
||||||
<Card title="Bankkarte">
|
<Card
|
||||||
|
title="Bankkarte"
|
||||||
|
actions={c.bankCard.documentPath ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPdfModal({ title: 'Bankkarte – Dokument', path: c.bankCard!.documentPath! })}
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
title="Hinterlegtes Dokument anzeigen (PDF)"
|
||||||
|
aria-label="Bankkarten-Dokument anzeigen"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
>
|
||||||
<p className="font-medium">{c.bankCard.accountHolder}</p>
|
<p className="font-medium">{c.bankCard.accountHolder}</p>
|
||||||
<p className="font-mono flex items-center gap-1">
|
<p className="font-mono flex items-center gap-1">
|
||||||
{c.bankCard.iban}
|
{c.bankCard.iban}
|
||||||
<CopyButton value={c.bankCard.iban} />
|
<CopyButton value={c.bankCard.iban} />
|
||||||
</p>
|
</p>
|
||||||
{c.bankCard.bankName && <p className="text-gray-500">{c.bankCard.bankName}</p>}
|
{c.bankCard.bic && (
|
||||||
|
<p className="text-sm text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-gray-500">BIC:</span> {c.bankCard.bic}
|
||||||
|
<CopyButton value={c.bankCard.bic} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{c.bankCard.bankName && (
|
||||||
|
<p className="text-sm text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-gray-500">Bank:</span> {c.bankCard.bankName}
|
||||||
|
<CopyButton value={c.bankCard.bankName} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{c.bankCard.cardNumber && (
|
||||||
|
<p className="text-sm text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-gray-500">Kartennummer:</span> {c.bankCard.cardNumber}
|
||||||
|
<CopyButton value={c.bankCard.cardNumber} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{c.bankCard.expiryDate && (
|
||||||
|
<p className="text-sm text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-gray-500">Ablaufdatum:</span> {formatDate(c.bankCard.expiryDate)}
|
||||||
|
<CopyButton value={formatDate(c.bankCard.expiryDate)} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{c.bankCard.description && (
|
{c.bankCard.description && (
|
||||||
<p className="text-sm text-gray-600 mt-1 italic whitespace-pre-line">
|
<p className="text-sm text-gray-600 mt-1 italic whitespace-pre-line">
|
||||||
{c.bankCard.description}
|
{c.bankCard.description}
|
||||||
@@ -2673,12 +2716,55 @@ export default function ContractDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{c.identityDocument && (
|
{c.identityDocument && (
|
||||||
<Card title="Ausweis">
|
<Card
|
||||||
|
title="Ausweis"
|
||||||
|
actions={c.identityDocument.documentPath ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPdfModal({ title: 'Ausweis – Dokument', path: c.identityDocument!.documentPath! })}
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-1 rounded"
|
||||||
|
title="Hinterlegtes Dokument anzeigen (PDF)"
|
||||||
|
aria-label="Ausweis-Dokument anzeigen"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
>
|
||||||
<p className="font-mono flex items-center gap-1">
|
<p className="font-mono flex items-center gap-1">
|
||||||
{c.identityDocument.documentNumber}
|
{c.identityDocument.documentNumber}
|
||||||
<CopyButton value={c.identityDocument.documentNumber} />
|
<CopyButton value={c.identityDocument.documentNumber} />
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500">{c.identityDocument.type}</p>
|
<p className="text-gray-500">{c.identityDocument.type}</p>
|
||||||
|
{c.identityDocument.issuingAuthority && (
|
||||||
|
<p className="text-sm text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-gray-500">Behörde:</span> {c.identityDocument.issuingAuthority}
|
||||||
|
<CopyButton value={c.identityDocument.issuingAuthority} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{c.identityDocument.issueDate && (
|
||||||
|
<p className="text-sm text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-gray-500">Ausstellung:</span> {formatDate(c.identityDocument.issueDate)}
|
||||||
|
<CopyButton value={formatDate(c.identityDocument.issueDate)} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{c.identityDocument.expiryDate && (
|
||||||
|
<p className="text-sm text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-gray-500">Ablaufdatum:</span> {formatDate(c.identityDocument.expiryDate)}
|
||||||
|
<CopyButton value={formatDate(c.identityDocument.expiryDate)} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{c.customer?.birthPlace && (
|
||||||
|
<p className="text-sm text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-gray-500">Geburtsort:</span> {c.customer.birthPlace}
|
||||||
|
<CopyButton value={c.customer.birthPlace} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{c.customer?.birthDate && (
|
||||||
|
<p className="text-sm text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-gray-500">Geburtsdatum:</span> {formatDate(c.customer.birthDate)}
|
||||||
|
<CopyButton value={formatDate(c.customer.birthDate)} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2726,13 +2812,23 @@ export default function ContractDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.energyDetails.maloId && (
|
{(() => {
|
||||||
|
// MaLo-ID kommt jetzt aus der Lieferadresse (je Sparte).
|
||||||
|
const malo = c.type === 'ELECTRICITY' ? c.address?.maloIdElectricity : c.address?.maloIdGas;
|
||||||
|
return malo ? (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">MaLo-ID ({c.type === 'ELECTRICITY' ? 'Strom' : 'Gas'})</dt>
|
||||||
|
<dd className="font-mono flex items-center gap-1">
|
||||||
|
{malo}
|
||||||
|
<CopyButton value={malo} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
{c.energyDetails.noBonusDesired && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">MaLo-ID</dt>
|
<dt className="text-sm text-gray-500">Bonis</dt>
|
||||||
<dd className="font-mono flex items-center gap-1">
|
<dd className="text-orange-700 font-medium">Keine Bonis erwünscht</dd>
|
||||||
{c.energyDetails.maloId}
|
|
||||||
<CopyButton value={c.energyDetails.maloId} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.energyDetails.annualConsumption ? (
|
{c.energyDetails.annualConsumption ? (
|
||||||
@@ -3372,6 +3468,14 @@ export default function ContractDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PDF-Viewer (Bankkarte-/Ausweis-Dokument) */}
|
||||||
|
<PdfViewerModal
|
||||||
|
isOpen={!!pdfModal}
|
||||||
|
onClose={() => setPdfModal(null)}
|
||||||
|
title={pdfModal?.title || 'Dokument'}
|
||||||
|
path={pdfModal?.path}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Vorgängervertrag Modal */}
|
{/* Vorgängervertrag Modal */}
|
||||||
{showPredecessorModal && c.previousContract && (
|
{showPredecessorModal && c.previousContract && (
|
||||||
<ContractDetailModal
|
<ContractDetailModal
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { buildContractLabelParts } from '../../utils/contractLabel';
|
|||||||
import type { ContractType } from '../../types';
|
import type { ContractType } from '../../types';
|
||||||
import { formatDate } from '../../utils/dateFormat';
|
import { formatDate } from '../../utils/dateFormat';
|
||||||
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
import { useProviderSettings } from '../../hooks/useProviderSettings';
|
||||||
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft, ExternalLink, Mail, Copy } from 'lucide-react';
|
import { Plus, Trash2, Eye, EyeOff, Info, X, ArrowLeft, ExternalLink, Mail, Copy, FileText } from 'lucide-react';
|
||||||
|
import PdfViewerModal from '../../components/ui/PdfViewerModal';
|
||||||
|
|
||||||
// Kleine Helper-Komponente: Label + Mini-Link im neuen Tab. Wird neben
|
// Kleine Helper-Komponente: Label + Mini-Link im neuen Tab. Wird neben
|
||||||
// Select-Feldern eingesetzt, deren Stammdaten an anderer Stelle gepflegt
|
// Select-Feldern eingesetzt, deren Stammdaten an anderer Stelle gepflegt
|
||||||
@@ -47,7 +48,8 @@ const statusOptions = [
|
|||||||
{ value: 'PENDING', label: 'Ausstehend' },
|
{ value: 'PENDING', label: 'Ausstehend' },
|
||||||
{ value: 'ACTIVE', label: 'Aktiv' },
|
{ value: 'ACTIVE', label: 'Aktiv' },
|
||||||
{ value: 'ONGOING', label: 'Läuft weiter (unbestimmt)' },
|
{ value: 'ONGOING', label: 'Läuft weiter (unbestimmt)' },
|
||||||
{ value: 'CANCELLED', label: 'Gekündigt' },
|
{ value: 'CANCELLED', label: 'Gekündigt / Bestätigung abwarten' },
|
||||||
|
{ value: 'CANCELLED_CONFIRMED', label: 'Gekündigt / bestätigt' },
|
||||||
{ value: 'EXPIRED', label: 'Abgelaufen' },
|
{ value: 'EXPIRED', label: 'Abgelaufen' },
|
||||||
{ value: 'DEACTIVATED', label: 'Deaktiviert' },
|
{ value: 'DEACTIVATED', label: 'Deaktiviert' },
|
||||||
];
|
];
|
||||||
@@ -59,7 +61,8 @@ const statusDescriptions = [
|
|||||||
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||||
{ status: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet – alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
|
{ status: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet – alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
|
||||||
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||||
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
{ status: 'CANCELLED', label: 'Gekündigt / Bestätigung abwarten', description: 'Kündigungsschreiben raus – Bestätigung des Anbieters steht noch aus', color: 'text-orange-600' },
|
||||||
|
{ status: 'CANCELLED_CONFIRMED', label: 'Gekündigt / bestätigt', description: 'Kündigung vom Anbieter bestätigt, Vertrag endet', color: 'text-red-600' },
|
||||||
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -244,6 +247,8 @@ export default function ContractForm() {
|
|||||||
// Passwort-Sichtbarkeit
|
// Passwort-Sichtbarkeit
|
||||||
const [showPortalPassword, setShowPortalPassword] = useState(false);
|
const [showPortalPassword, setShowPortalPassword] = useState(false);
|
||||||
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
|
const [showCustomerInfo, setShowCustomerInfo] = useState(false);
|
||||||
|
// PDF-Viewer-Modal (Bankkarte-/Ausweis-Dokument der aktuell gewählten Karte)
|
||||||
|
const [pdfModal, setPdfModal] = useState<{ title: string; path: string } | null>(null);
|
||||||
const [showInternetPassword, setShowInternetPassword] = useState(false);
|
const [showInternetPassword, setShowInternetPassword] = useState(false);
|
||||||
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
|
const [showSipPasswords, setShowSipPasswords] = useState<Record<number, boolean>>({});
|
||||||
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
const [showSimPins, setShowSimPins] = useState<Record<number, boolean>>({});
|
||||||
@@ -344,7 +349,6 @@ export default function ContractForm() {
|
|||||||
notes: c.notes || '',
|
notes: c.notes || '',
|
||||||
// Energy details
|
// Energy details
|
||||||
meterId: c.energyDetails?.meterId?.toString() || '',
|
meterId: c.energyDetails?.meterId?.toString() || '',
|
||||||
maloId: c.energyDetails?.maloId || '',
|
|
||||||
annualConsumption: c.energyDetails?.annualConsumption || '',
|
annualConsumption: c.energyDetails?.annualConsumption || '',
|
||||||
annualConsumptionKwh: c.energyDetails?.annualConsumptionKwh || '',
|
annualConsumptionKwh: c.energyDetails?.annualConsumptionKwh || '',
|
||||||
basePrice: c.energyDetails?.basePrice || '',
|
basePrice: c.energyDetails?.basePrice || '',
|
||||||
@@ -352,6 +356,7 @@ export default function ContractForm() {
|
|||||||
unitPriceNt: c.energyDetails?.unitPriceNt || '',
|
unitPriceNt: c.energyDetails?.unitPriceNt || '',
|
||||||
instantBonus: c.energyDetails?.instantBonus || '',
|
instantBonus: c.energyDetails?.instantBonus || '',
|
||||||
newCustomerBonus: c.energyDetails?.newCustomerBonus || '',
|
newCustomerBonus: c.energyDetails?.newCustomerBonus || '',
|
||||||
|
noBonusDesired: c.energyDetails?.noBonusDesired || false,
|
||||||
// Internet details
|
// Internet details
|
||||||
downloadSpeed: c.internetDetails?.downloadSpeed || '',
|
downloadSpeed: c.internetDetails?.downloadSpeed || '',
|
||||||
uploadSpeed: c.internetDetails?.uploadSpeed || '',
|
uploadSpeed: c.internetDetails?.uploadSpeed || '',
|
||||||
@@ -667,7 +672,7 @@ export default function ContractForm() {
|
|||||||
if (['ELECTRICITY', 'GAS'].includes(data.type)) {
|
if (['ELECTRICITY', 'GAS'].includes(data.type)) {
|
||||||
contractData.energyDetails = {
|
contractData.energyDetails = {
|
||||||
meterId: safeParseInt(data.meterId) ?? null,
|
meterId: safeParseInt(data.meterId) ?? null,
|
||||||
maloId: emptyToNull(data.maloId),
|
// maloId wird nicht mehr am Vertrag gepflegt – sie gehört zur Lieferadresse.
|
||||||
annualConsumption: data.annualConsumption ? parseFloat(data.annualConsumption) : null,
|
annualConsumption: data.annualConsumption ? parseFloat(data.annualConsumption) : null,
|
||||||
annualConsumptionKwh: data.annualConsumptionKwh ? parseFloat(data.annualConsumptionKwh) : null,
|
annualConsumptionKwh: data.annualConsumptionKwh ? parseFloat(data.annualConsumptionKwh) : null,
|
||||||
basePrice: data.basePrice ? parseFloat(data.basePrice) : null,
|
basePrice: data.basePrice ? parseFloat(data.basePrice) : null,
|
||||||
@@ -675,6 +680,7 @@ export default function ContractForm() {
|
|||||||
unitPriceNt: data.unitPriceNt ? parseFloat(data.unitPriceNt) : null,
|
unitPriceNt: data.unitPriceNt ? parseFloat(data.unitPriceNt) : null,
|
||||||
instantBonus: data.instantBonus ? parseFloat(data.instantBonus) : null,
|
instantBonus: data.instantBonus ? parseFloat(data.instantBonus) : null,
|
||||||
newCustomerBonus: data.newCustomerBonus ? parseFloat(data.newCustomerBonus) : null,
|
newCustomerBonus: data.newCustomerBonus ? parseFloat(data.newCustomerBonus) : null,
|
||||||
|
noBonusDesired: !!data.noBonusDesired,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,12 +1044,24 @@ export default function ContractForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
<Select
|
<Select
|
||||||
label={
|
label={
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<LabelWithLink to={`/customers/${customerId}?tab=bankcards`} title="Bankkarten des Kunden in neuem Tab öffnen">
|
<LabelWithLink to={`/customers/${customerId}?tab=bankcards`} title="Bankkarten des Kunden in neuem Tab öffnen">
|
||||||
Bankkarte
|
Bankkarte
|
||||||
</LabelWithLink>
|
</LabelWithLink>
|
||||||
|
{selectedBankCard?.documentPath && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setPdfModal({ title: 'Bankkarte – Dokument', path: selectedBankCard.documentPath! }); }}
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-0.5 rounded"
|
||||||
|
title="Hinterlegtes Dokument anzeigen (PDF)"
|
||||||
|
aria-label="Bankkarten-Dokument anzeigen"
|
||||||
|
>
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{selectedBankCard && (
|
{selectedBankCard && (
|
||||||
<CopyButton value={selectedBankCard.iban} title="IBAN kopieren" />
|
<CopyButton value={selectedBankCard.iban} title="IBAN kopieren" />
|
||||||
)}
|
)}
|
||||||
@@ -1063,13 +1081,42 @@ export default function ContractForm() {
|
|||||||
};
|
};
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
{selectedBankCard && (
|
||||||
|
<div className="mt-1.5 space-y-0.5 text-xs text-gray-600">
|
||||||
|
{selectedBankCard.bic && (
|
||||||
|
<div className="flex items-center gap-1"><span className="text-gray-500">BIC:</span> {selectedBankCard.bic}<CopyButton value={selectedBankCard.bic} /></div>
|
||||||
|
)}
|
||||||
|
{selectedBankCard.bankName && (
|
||||||
|
<div className="flex items-center gap-1"><span className="text-gray-500">Bank:</span> {selectedBankCard.bankName}<CopyButton value={selectedBankCard.bankName} /></div>
|
||||||
|
)}
|
||||||
|
{selectedBankCard.cardNumber && (
|
||||||
|
<div className="flex items-center gap-1"><span className="text-gray-500">Kartennummer:</span> {selectedBankCard.cardNumber}<CopyButton value={selectedBankCard.cardNumber} /></div>
|
||||||
|
)}
|
||||||
|
{selectedBankCard.expiryDate && (
|
||||||
|
<div className="flex items-center gap-1"><span className="text-gray-500">Ablaufdatum:</span> {formatDate(selectedBankCard.expiryDate)}<CopyButton value={formatDate(selectedBankCard.expiryDate)} /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<Select
|
<Select
|
||||||
label={
|
label={
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<LabelWithLink to={`/customers/${customerId}?tab=documents`} title="Ausweise des Kunden in neuem Tab öffnen">
|
<LabelWithLink to={`/customers/${customerId}?tab=documents`} title="Ausweise des Kunden in neuem Tab öffnen">
|
||||||
Ausweis
|
Ausweis
|
||||||
</LabelWithLink>
|
</LabelWithLink>
|
||||||
|
{selectedDocument?.documentPath && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setPdfModal({ title: 'Ausweis – Dokument', path: selectedDocument.documentPath! }); }}
|
||||||
|
className="text-gray-400 hover:text-blue-600 p-0.5 rounded"
|
||||||
|
title="Hinterlegtes Dokument anzeigen (PDF)"
|
||||||
|
aria-label="Ausweis-Dokument anzeigen"
|
||||||
|
>
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{selectedDocument && (
|
{selectedDocument && (
|
||||||
<CopyButton value={selectedDocument.documentNumber} title="Ausweisnummer kopieren" />
|
<CopyButton value={selectedDocument.documentNumber} title="Ausweisnummer kopieren" />
|
||||||
)}
|
)}
|
||||||
@@ -1081,6 +1128,26 @@ export default function ContractForm() {
|
|||||||
label: `${d.documentNumber} (${d.type})`,
|
label: `${d.documentNumber} (${d.type})`,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
{selectedDocument && (
|
||||||
|
<div className="mt-1.5 space-y-0.5 text-xs text-gray-600">
|
||||||
|
{selectedDocument.issueDate && (
|
||||||
|
<div className="flex items-center gap-1"><span className="text-gray-500">Ausstellung:</span> {formatDate(selectedDocument.issueDate)}<CopyButton value={formatDate(selectedDocument.issueDate)} /></div>
|
||||||
|
)}
|
||||||
|
{selectedDocument.expiryDate && (
|
||||||
|
<div className="flex items-center gap-1"><span className="text-gray-500">Ablaufdatum:</span> {formatDate(selectedDocument.expiryDate)}<CopyButton value={formatDate(selectedDocument.expiryDate)} /></div>
|
||||||
|
)}
|
||||||
|
{selectedDocument.issuingAuthority && (
|
||||||
|
<div className="flex items-center gap-1"><span className="text-gray-500">Behörde:</span> {selectedDocument.issuingAuthority}<CopyButton value={selectedDocument.issuingAuthority} /></div>
|
||||||
|
)}
|
||||||
|
{customer?.birthPlace && (
|
||||||
|
<div className="flex items-center gap-1"><span className="text-gray-500">Geburtsort:</span> {customer.birthPlace}<CopyButton value={customer.birthPlace} /></div>
|
||||||
|
)}
|
||||||
|
{customer?.birthDate && (
|
||||||
|
<div className="flex items-center gap-1"><span className="text-gray-500">Geburtsdatum:</span> {formatDate(customer.birthDate)}<CopyButton value={formatDate(customer.birthDate)} /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -1382,10 +1449,46 @@ export default function ContractForm() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<Input
|
{(() => {
|
||||||
label="MaLo-ID (Marktlokations-ID)"
|
// MaLo-ID gehört zur Lieferadresse (nicht zum Vertrag) → Lesefeld,
|
||||||
{...register('maloId')}
|
// Wert je nach Sparte aus der gewählten Lieferadresse.
|
||||||
/>
|
const selAddr = addresses.find((a) => String(a.id) === watch('addressId'));
|
||||||
|
const isElectricity = contractType === 'ELECTRICITY';
|
||||||
|
const maloValue = isElectricity ? selAddr?.maloIdElectricity : selAddr?.maloIdGas;
|
||||||
|
const sparte = isElectricity ? 'Strom' : 'Gas';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
MaLo-ID (Marktlokations-ID {sparte})
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={maloValue || ''}
|
||||||
|
readOnly
|
||||||
|
placeholder={!watch('addressId')
|
||||||
|
? 'Erst Lieferadresse wählen…'
|
||||||
|
: `Keine MaLo-ID ${sparte} an der Lieferadresse hinterlegt`}
|
||||||
|
className="block w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-700 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
{maloValue && <CopyButton value={maloValue} />}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Wird aus der Lieferadresse übernommen –{' '}
|
||||||
|
{customerId ? (
|
||||||
|
<a
|
||||||
|
href={`/customers/${customerId}?tab=addresses`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
dort pflegen
|
||||||
|
</a>
|
||||||
|
) : 'in der Kundenakte an der Adresse pflegen'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
label={`Jahresverbrauch (${contractType === 'ELECTRICITY' ? 'kWh' : 'm³'})`}
|
label={`Jahresverbrauch (${contractType === 'ELECTRICITY' ? 'kWh' : 'm³'})`}
|
||||||
@@ -1457,6 +1560,11 @@ export default function ContractForm() {
|
|||||||
<Input label="Neukunden-Bonus (€)" type="number" step="0.01" {...register('newCustomerBonus')} />
|
<Input label="Neukunden-Bonus (€)" type="number" step="0.01" {...register('newCustomerBonus')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 mt-3">
|
||||||
|
<input type="checkbox" {...register('noBonusDesired')} className="rounded" />
|
||||||
|
<span className="text-sm text-gray-700">Keine Bonis erwünscht</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Hinweis für Zählerstände und Rechnungen */}
|
{/* Hinweis für Zählerstände und Rechnungen */}
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
|
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
|
||||||
@@ -2007,6 +2115,14 @@ export default function ContractForm() {
|
|||||||
contractStressfreiEmailId={selectedStressfreiEmailId ? parseInt(selectedStressfreiEmailId) : null}
|
contractStressfreiEmailId={selectedStressfreiEmailId ? parseInt(selectedStressfreiEmailId) : null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PDF-Viewer (Bankkarte-/Ausweis-Dokument der gewählten Karte) */}
|
||||||
|
<PdfViewerModal
|
||||||
|
isOpen={!!pdfModal}
|
||||||
|
onClose={() => setPdfModal(null)}
|
||||||
|
title={pdfModal?.title || 'Dokument'}
|
||||||
|
path={pdfModal?.path}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ const statusLabels: Record<ContractStatus, string> = {
|
|||||||
DRAFT: 'Entwurf',
|
DRAFT: 'Entwurf',
|
||||||
PENDING: 'Ausstehend',
|
PENDING: 'Ausstehend',
|
||||||
ACTIVE: 'Aktiv',
|
ACTIVE: 'Aktiv',
|
||||||
CANCELLED: 'Gekündigt',
|
CANCELLED: 'Gekündigt / Bestätigung abwarten',
|
||||||
|
CANCELLED_CONFIRMED: 'Gekündigt / bestätigt',
|
||||||
EXPIRED: 'Abgelaufen',
|
EXPIRED: 'Abgelaufen',
|
||||||
DEACTIVATED: 'Deaktiviert',
|
DEACTIVATED: 'Deaktiviert',
|
||||||
ONGOING: 'Läuft weiter (unbestimmt)',
|
ONGOING: 'Läuft weiter (unbestimmt)',
|
||||||
@@ -40,7 +41,8 @@ const statusLabels: Record<ContractStatus, string> = {
|
|||||||
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
|
const statusVariants: Record<ContractStatus, 'success' | 'warning' | 'danger' | 'default'> = {
|
||||||
ACTIVE: 'success',
|
ACTIVE: 'success',
|
||||||
PENDING: 'warning',
|
PENDING: 'warning',
|
||||||
CANCELLED: 'danger',
|
CANCELLED: 'warning',
|
||||||
|
CANCELLED_CONFIRMED: 'danger',
|
||||||
EXPIRED: 'danger',
|
EXPIRED: 'danger',
|
||||||
DRAFT: 'default',
|
DRAFT: 'default',
|
||||||
DEACTIVATED: 'default',
|
DEACTIVATED: 'default',
|
||||||
@@ -54,7 +56,8 @@ const statusDescriptions = [
|
|||||||
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||||
{ status: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet – alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
|
{ status: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet – alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
|
||||||
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||||
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
{ status: 'CANCELLED', label: 'Gekündigt / Bestätigung abwarten', description: 'Kündigungsschreiben raus – Bestätigung des Anbieters steht noch aus', color: 'text-orange-600' },
|
||||||
|
{ status: 'CANCELLED_CONFIRMED', label: 'Gekündigt / bestätigt', description: 'Kündigung vom Anbieter bestätigt, Vertrag endet', color: 'text-red-600' },
|
||||||
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -318,6 +321,12 @@ export default function ContractList() {
|
|||||||
return typeInfo ? (
|
return typeInfo ? (
|
||||||
<p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}>
|
<p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}>
|
||||||
<span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value}
|
<span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value}
|
||||||
|
{typeInfo.cardUser && (
|
||||||
|
<> · <span className="font-medium text-gray-700">Karteninhaber:</span> {typeInfo.cardUser}</>
|
||||||
|
)}
|
||||||
|
{typeInfo.network && (
|
||||||
|
<> · <span className="font-medium text-gray-700">Netz:</span> {typeInfo.network}</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
@@ -328,6 +337,11 @@ export default function ContractList() {
|
|||||||
` | Ende: ${formatDate(contract.endDate)}`}
|
` | Ende: ${formatDate(contract.endDate)}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{contract.cancellationConfirmationDate && (
|
||||||
|
<p className={`text-sm font-semibold text-red-600 ${isPredecessor ? 'ml-6' : ''}`}>
|
||||||
|
Kündigungsbestätigung zum {formatDate(contract.cancellationConfirmationDate)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */}
|
{/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */}
|
||||||
@@ -495,6 +509,17 @@ export default function ContractList() {
|
|||||||
{typeInfo && (
|
{typeInfo && (
|
||||||
<div className="text-xs text-gray-500 font-sans mt-0.5">
|
<div className="text-xs text-gray-500 font-sans mt-0.5">
|
||||||
<span className="font-medium text-gray-600">{typeInfo.label}:</span> {typeInfo.value}
|
<span className="font-medium text-gray-600">{typeInfo.label}:</span> {typeInfo.value}
|
||||||
|
{typeInfo.cardUser && (
|
||||||
|
<> · <span className="font-medium text-gray-600">Karteninhaber:</span> {typeInfo.cardUser}</>
|
||||||
|
)}
|
||||||
|
{typeInfo.network && (
|
||||||
|
<> · <span className="font-medium text-gray-600">Netz:</span> {typeInfo.network}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contract.cancellationConfirmationDate && (
|
||||||
|
<div className="text-xs font-semibold text-red-600 font-sans mt-0.5">
|
||||||
|
Kündigungsbestätigung zum {formatDate(contract.cancellationConfirmationDate)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1556,7 +1556,8 @@ function MetersTab({
|
|||||||
)}
|
)}
|
||||||
<Badge variant={
|
<Badge variant={
|
||||||
ct.status === 'ACTIVE' ? 'success'
|
ct.status === 'ACTIVE' ? 'success'
|
||||||
: ct.status === 'CANCELLED' ? 'danger'
|
: ct.status === 'CANCELLED' ? 'warning'
|
||||||
|
: ct.status === 'CANCELLED_CONFIRMED' ? 'danger'
|
||||||
: ct.status === 'EXPIRED' ? 'warning'
|
: ct.status === 'EXPIRED' ? 'warning'
|
||||||
: 'default'
|
: 'default'
|
||||||
}>{ct.status}</Badge>
|
}>{ct.status}</Badge>
|
||||||
@@ -1739,10 +1740,22 @@ function ContractsTab({
|
|||||||
CAR_INSURANCE: 'KFZ-Versicherung',
|
CAR_INSURANCE: 'KFZ-Versicherung',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
DRAFT: 'Entwurf',
|
||||||
|
PENDING: 'Ausstehend',
|
||||||
|
ACTIVE: 'Aktiv',
|
||||||
|
CANCELLED: 'Gekündigt / Bestätigung abwarten',
|
||||||
|
CANCELLED_CONFIRMED: 'Gekündigt / bestätigt',
|
||||||
|
EXPIRED: 'Abgelaufen',
|
||||||
|
DEACTIVATED: 'Deaktiviert',
|
||||||
|
ONGOING: 'Läuft weiter (unbestimmt)',
|
||||||
|
};
|
||||||
|
|
||||||
const statusVariants: Record<string, 'success' | 'warning' | 'danger' | 'default'> = {
|
const statusVariants: Record<string, 'success' | 'warning' | 'danger' | 'default'> = {
|
||||||
ACTIVE: 'success',
|
ACTIVE: 'success',
|
||||||
PENDING: 'warning',
|
PENDING: 'warning',
|
||||||
CANCELLED: 'danger',
|
CANCELLED: 'warning',
|
||||||
|
CANCELLED_CONFIRMED: 'danger',
|
||||||
EXPIRED: 'danger',
|
EXPIRED: 'danger',
|
||||||
DRAFT: 'default',
|
DRAFT: 'default',
|
||||||
DEACTIVATED: 'default',
|
DEACTIVATED: 'default',
|
||||||
@@ -1755,7 +1768,8 @@ function ContractsTab({
|
|||||||
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
{ status: 'ACTIVE', label: 'Aktiv', description: 'Vertrag läuft normal', color: 'text-green-600' },
|
||||||
{ status: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet – alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
|
{ status: 'ONGOING', label: 'Läuft weiter (unbestimmt)', description: 'Vertrag läuft unbefristet – alle Fristen im Cockpit werden ignoriert', color: 'text-green-700' },
|
||||||
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
{ status: 'EXPIRED', label: 'Abgelaufen', description: 'Laufzeit vorbei, läuft aber ohne Kündigung weiter', color: 'text-orange-600' },
|
||||||
{ status: 'CANCELLED', label: 'Gekündigt', description: 'Aktive Kündigung eingereicht, Vertrag endet', color: 'text-red-600' },
|
{ status: 'CANCELLED', label: 'Gekündigt / Bestätigung abwarten', description: 'Kündigungsschreiben raus – Bestätigung des Anbieters steht noch aus', color: 'text-orange-600' },
|
||||||
|
{ status: 'CANCELLED_CONFIRMED', label: 'Gekündigt / bestätigt', description: 'Kündigung vom Anbieter bestätigt, Vertrag endet', color: 'text-red-600' },
|
||||||
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
{ status: 'DEACTIVATED', label: 'Deaktiviert', description: 'Manuell beendet/archiviert', color: 'text-gray-500' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1818,7 +1832,7 @@ function ContractsTab({
|
|||||||
<CopyButton value={contract.contractNumber} />
|
<CopyButton value={contract.contractNumber} />
|
||||||
</Link>
|
</Link>
|
||||||
<Badge>{typeLabels[contract.type] || contract.type}</Badge>
|
<Badge>{typeLabels[contract.type] || contract.type}</Badge>
|
||||||
<Badge variant={statusVariants[contract.status] || 'default'}>{contract.status}</Badge>
|
<Badge variant={statusVariants[contract.status] || 'default'}>{statusLabels[contract.status] || contract.status}</Badge>
|
||||||
{depth === 0 && !isPredecessor && (
|
{depth === 0 && !isPredecessor && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowStatusInfo(true); }}
|
onClick={(e) => { e.stopPropagation(); setShowStatusInfo(true); }}
|
||||||
@@ -1882,6 +1896,12 @@ function ContractsTab({
|
|||||||
return typeInfo ? (
|
return typeInfo ? (
|
||||||
<p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}>
|
<p className={`text-sm text-gray-600 ${isPredecessor ? 'ml-6' : ''}`}>
|
||||||
<span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value}
|
<span className="font-medium text-gray-700">{typeInfo.label}:</span> {typeInfo.value}
|
||||||
|
{typeInfo.cardUser && (
|
||||||
|
<> · <span className="font-medium text-gray-700">Karteninhaber:</span> {typeInfo.cardUser}</>
|
||||||
|
)}
|
||||||
|
{typeInfo.network && (
|
||||||
|
<> · <span className="font-medium text-gray-700">Netz:</span> {typeInfo.network}</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
@@ -1892,6 +1912,11 @@ function ContractsTab({
|
|||||||
` | Ende: ${formatDate(contract.endDate)}`}
|
` | Ende: ${formatDate(contract.endDate)}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{contract.cancellationConfirmationDate && (
|
||||||
|
<p className={`text-sm font-semibold text-red-600 ${isPredecessor ? 'ml-6' : ''}`}>
|
||||||
|
Kündigungsbestätigung zum {formatDate(contract.cancellationConfirmationDate)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */}
|
{/* Vorgänger rekursiv rendern - für Wurzel nur wenn aufgeklappt, für Vorgänger immer */}
|
||||||
@@ -2465,6 +2490,8 @@ function AddressModal({
|
|||||||
city: address?.city || '',
|
city: address?.city || '',
|
||||||
country: address?.country || 'Deutschland',
|
country: address?.country || 'Deutschland',
|
||||||
isDefault: address?.isDefault || false,
|
isDefault: address?.isDefault || false,
|
||||||
|
maloIdElectricity: address?.maloIdElectricity || '',
|
||||||
|
maloIdGas: address?.maloIdGas || '',
|
||||||
ownerCompany: address?.ownerCompany || '',
|
ownerCompany: address?.ownerCompany || '',
|
||||||
ownerFirstName: address?.ownerFirstName || '',
|
ownerFirstName: address?.ownerFirstName || '',
|
||||||
ownerLastName: address?.ownerLastName || '',
|
ownerLastName: address?.ownerLastName || '',
|
||||||
@@ -2508,12 +2535,14 @@ function AddressModal({
|
|||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Beim Öffnen / Wechsel zwischen Adressen aus den Props re-initialisieren.
|
// Beim Öffnen / Wechsel zwischen Adressen aus den Props re-initialisieren.
|
||||||
// Vorher als unbedingte if-setState im Render-Body → Reset bei jedem
|
// Guard `if (!isOpen)`: Modal bleibt dauerhaft gemountet – ohne Reset beim
|
||||||
// Tastendruck, Straße ließ sich nicht eintippen.
|
// Öffnen blieben nach „Abbrechen" die vorher getippten Daten stehen.
|
||||||
|
// Deps nur isOpen + Adress-ID → kein Reset bei jedem Tastendruck.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [address?.id]);
|
}, [isOpen, address?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Adresse bearbeiten' : 'Adresse hinzufügen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Adresse bearbeiten' : 'Adresse hinzufügen'}>
|
||||||
@@ -2578,6 +2607,31 @@ function AddressModal({
|
|||||||
Als Standard setzen
|
Als Standard setzen
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Marktlokations-IDs (nur Lieferadresse) – gehören zur Adresse, nicht
|
||||||
|
zum Vertrag; im Vertrag werden sie je Sparte nur noch angezeigt. */}
|
||||||
|
{formData.type === 'DELIVERY_RESIDENCE' && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-1">Marktlokations-IDs (MaLo)</h4>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Je Sparte getrennt. Wird im Strom-/Gasvertrag automatisch aus dieser Lieferadresse angezeigt.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="MaLo-ID Strom"
|
||||||
|
value={formData.maloIdElectricity}
|
||||||
|
onChange={(e) => setFormData({ ...formData, maloIdElectricity: e.target.value })}
|
||||||
|
placeholder="z.B. 11XXXXXXXXXXX"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="MaLo-ID Gas"
|
||||||
|
value={formData.maloIdGas}
|
||||||
|
onChange={(e) => setFormData({ ...formData, maloIdGas: e.target.value })}
|
||||||
|
placeholder="z.B. 11XXXXXXXXXXX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */}
|
{/* Eigentümer (optional, nur bei Liefer-/Meldeadresse) */}
|
||||||
{formData.type === 'DELIVERY_RESIDENCE' && (
|
{formData.type === 'DELIVERY_RESIDENCE' && (
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-4 border-t">
|
||||||
@@ -2684,6 +2738,7 @@ function BankCardModal({
|
|||||||
const getInitialFormData = () => ({
|
const getInitialFormData = () => ({
|
||||||
accountHolder: bankCard?.accountHolder || '',
|
accountHolder: bankCard?.accountHolder || '',
|
||||||
iban: bankCard?.iban || '',
|
iban: bankCard?.iban || '',
|
||||||
|
cardNumber: bankCard?.cardNumber || '',
|
||||||
bic: bankCard?.bic || '',
|
bic: bankCard?.bic || '',
|
||||||
bankName: bankCard?.bankName || '',
|
bankName: bankCard?.bankName || '',
|
||||||
expiryDate: bankCard?.expiryDate ? new Date(bankCard.expiryDate).toISOString().split('T')[0] : '',
|
expiryDate: bankCard?.expiryDate ? new Date(bankCard.expiryDate).toISOString().split('T')[0] : '',
|
||||||
@@ -2695,11 +2750,6 @@ function BankCardModal({
|
|||||||
const [ibanLookupLoading, setIbanLookupLoading] = useState(false);
|
const [ibanLookupLoading, setIbanLookupLoading] = useState(false);
|
||||||
const [showIbanRequired, setShowIbanRequired] = useState(false);
|
const [showIbanRequired, setShowIbanRequired] = useState(false);
|
||||||
|
|
||||||
// Reset form when bankCard changes
|
|
||||||
useState(() => {
|
|
||||||
setFormData(getInitialFormData());
|
|
||||||
});
|
|
||||||
|
|
||||||
// BIC + Bankname aus der IBAN ableiten (offline über Bundesbank-BLZ im
|
// BIC + Bankname aus der IBAN ableiten (offline über Bundesbank-BLZ im
|
||||||
// Backend). Ist das IBAN-Feld leer, kommt eine OK-Messagebox statt einer
|
// Backend). Ist das IBAN-Feld leer, kommt eine OK-Messagebox statt einer
|
||||||
// Anfrage. Gleichzeitig prüft der Server die IBAN-Prüfziffer (Tippfehler).
|
// Anfrage. Gleichzeitig prüft der Server die IBAN-Prüfziffer (Tippfehler).
|
||||||
@@ -2745,7 +2795,7 @@ function BankCardModal({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||||
onClose();
|
onClose();
|
||||||
setFormData({ accountHolder: '', iban: '', bic: '', bankName: '', expiryDate: '', description: '', isActive: true });
|
setFormData({ accountHolder: '', iban: '', cardNumber: '', bic: '', bankName: '', expiryDate: '', description: '', isActive: true });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2772,12 +2822,19 @@ function BankCardModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Re-Init nur beim Wechsel zur anderen Karte – nicht bei jedem
|
// Formular zurücksetzen, sobald die Modal geöffnet wird (Neuanlage: leer,
|
||||||
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
// Bearbeiten: Kartendaten). Nötig, weil beide Modal-Instanzen dauerhaft
|
||||||
|
// gemountet bleiben und nur per isOpen umgeschaltet werden – ohne diesen
|
||||||
|
// Reset blieben nach „Abbrechen" die vorher getippten Daten stehen.
|
||||||
|
// Läuft NICHT bei jedem Tastendruck (Deps nur isOpen + Karten-ID), sonst
|
||||||
|
// würde jede Eingabe wieder auf den DB-Wert zurückspringen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
|
setIbanLookupLoading(false);
|
||||||
|
setShowIbanRequired(false);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [bankCard?.id]);
|
}, [isOpen, bankCard?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -2815,6 +2872,12 @@ function BankCardModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Kartennummer"
|
||||||
|
value={formData.cardNumber}
|
||||||
|
onChange={(e) => setFormData({ ...formData, cardNumber: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="BIC"
|
label="BIC"
|
||||||
value={formData.bic}
|
value={formData.bic}
|
||||||
@@ -2968,12 +3031,14 @@ function DocumentModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Re-Init nur beim Wechsel zum anderen Ausweis – nicht bei jedem
|
// Reset beim Öffnen (Guard `if (!isOpen)`): Modal bleibt gemountet, sonst
|
||||||
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
// blieben nach „Abbrechen" die vorher getippten Daten stehen. Deps nur
|
||||||
|
// isOpen + Ausweis-ID → kein Reset bei jedem Tastendruck.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [document?.id]);
|
}, [isOpen, document?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
|
||||||
@@ -3180,12 +3245,14 @@ function MeterModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Re-Init nur beim Wechsel zum anderen Zähler – nicht bei jedem
|
// Reset beim Öffnen (Guard `if (!isOpen)`): Modal bleibt gemountet, sonst
|
||||||
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
// blieben nach „Abbrechen" die vorher getippten Daten stehen. Deps nur
|
||||||
|
// isOpen + Zähler-ID → kein Reset bei jedem Tastendruck.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meter?.id]);
|
}, [isOpen, meter?.id]);
|
||||||
|
|
||||||
const noDeliveryAddresses = deliveryAddresses.length === 0;
|
const noDeliveryAddresses = deliveryAddresses.length === 0;
|
||||||
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
|
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
|
||||||
@@ -3419,12 +3486,14 @@ function MeterReadingModal({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Re-Init nur beim Wechsel zum anderen Zählerstand – nicht bei
|
// Reset beim Öffnen (Guard `if (!isOpen)`): Modal bleibt gemountet, sonst
|
||||||
// jedem Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
// blieben nach „Abbrechen" die vorher getippten Daten stehen. Deps nur
|
||||||
|
// isOpen + Zählerstand-ID → kein Reset bei jedem Tastendruck.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
setFormData(getInitialFormData());
|
setFormData(getInitialFormData());
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [reading?.id]);
|
}, [isOpen, reading?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
|
||||||
|
|||||||
@@ -198,11 +198,17 @@ export default function CustomerForm() {
|
|||||||
|
|
||||||
<Card className="mb-6" title="Kontaktdaten">
|
<Card className="mb-6" title="Kontaktdaten">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Input
|
<div>
|
||||||
label="E-Mail"
|
<Input
|
||||||
type="email"
|
label={isEdit ? 'E-Mail' : 'E-Mail *'}
|
||||||
{...register('email')}
|
type="email"
|
||||||
/>
|
{...register('email', isEdit ? {} : { required: 'E-Mail ist erforderlich' })}
|
||||||
|
error={errors.email?.message}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Private E-Mail des Kunden – keine verwaltete Weiterleitungs-/Mailbox-Domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Telefon"
|
label="Telefon"
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { auditLogApi, IntegrityResult } from '../../services/api';
|
||||||
|
import Card from '../../components/ui/Card';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import {
|
||||||
|
ShieldCheck, ShieldAlert, ShieldQuestion, RefreshCw, Loader2, ChevronDown, ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuskarte fuer die Unversehrtheit des Audit-Protokolls.
|
||||||
|
*
|
||||||
|
* Die Prueflast steigt mit der Groesse des Protokolls (die Pruefung liest die
|
||||||
|
* gesamte Kette). Deshalb laeuft sie beim Oeffnen der Seite EINMAL und wird
|
||||||
|
* fuenf Minuten lang wiederverwendet; wer sofort neu pruefen will, nutzt den
|
||||||
|
* Knopf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Ampel = 'ok' | 'befund' | 'hinweis' | 'unbekannt';
|
||||||
|
|
||||||
|
function ampelFuer(r: IntegrityResult): Ampel {
|
||||||
|
if (!r.valid) return 'befund';
|
||||||
|
// Gueltig, aber nicht vollstaendig pruefbar: kein gruenes Licht vortaeuschen.
|
||||||
|
if (r.unverifiableEntries.length > 0) return 'unbekannt';
|
||||||
|
if (r.backlogSealStatus === 'kein_siegel') return 'hinweis';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
const AMPEL_STIL: Record<Ampel, { box: string; titel: string; text: string }> = {
|
||||||
|
ok: { box: 'bg-green-50 border-green-200', titel: 'text-green-900', text: 'text-green-800' },
|
||||||
|
befund: { box: 'bg-red-50 border-red-200', titel: 'text-red-900', text: 'text-red-800' },
|
||||||
|
hinweis: { box: 'bg-yellow-50 border-yellow-200',titel: 'text-yellow-900', text: 'text-yellow-800' },
|
||||||
|
unbekannt: { box: 'bg-gray-50 border-gray-200', titel: 'text-gray-900', text: 'text-gray-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function AmpelIcon({ ampel }: { ampel: Ampel }) {
|
||||||
|
const c = 'w-7 h-7 shrink-0';
|
||||||
|
if (ampel === 'ok') return <ShieldCheck className={`${c} text-green-600`} />;
|
||||||
|
if (ampel === 'befund') return <ShieldAlert className={`${c} text-red-600`} />;
|
||||||
|
if (ampel === 'hinweis') return <ShieldAlert className={`${c} text-yellow-600`} />;
|
||||||
|
return <ShieldQuestion className={`${c} text-gray-500`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AMPEL_TITEL: Record<Ampel, string> = {
|
||||||
|
ok: 'Protokoll unversehrt',
|
||||||
|
befund: 'Befund – das gehört angesehen',
|
||||||
|
hinweis: 'Unversehrt, aber ungeschützter Altbestand',
|
||||||
|
unbekannt: 'Nicht vollständig prüfbar',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIEGEL_TEXT: Record<IntegrityResult['backlogSealStatus'], string> = {
|
||||||
|
intakt: 'versiegelt und intakt',
|
||||||
|
leer: 'Siegel vorhanden, aber ohne Inhalt',
|
||||||
|
kein_siegel: 'nicht versiegelt',
|
||||||
|
nicht_noetig: 'nicht nötig (alles signiert)',
|
||||||
|
gebrochen: 'GEBROCHEN',
|
||||||
|
entfernt: 'ENTFERNT',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Zeigt eine Zahl nur, wenn sie ungleich null ist – sonst bleibt es ruhig. */
|
||||||
|
function Zahl({ label, ids, ton }: { label: string; ids: number[]; ton: string }) {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const gekuerzt = ids.length > 12 ? `${ids.slice(0, 12).join(', ')} … (+${ids.length - 12})` : ids.join(', ');
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-2 text-sm">
|
||||||
|
<span className={`font-medium ${ton}`}>{label}:</span>
|
||||||
|
<span className="text-gray-600">{ids.length}</span>
|
||||||
|
<span className="text-gray-400 font-mono text-xs break-all">({gekuerzt})</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditIntegrityCard() {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const { data, isFetching, isError, error, refetch } = useQuery({
|
||||||
|
queryKey: ['audit-integrity'],
|
||||||
|
queryFn: () => auditLogApi.verifyIntegrity(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ergebnis = data?.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="mb-6"
|
||||||
|
title="Unversehrtheit des Protokolls"
|
||||||
|
actions={
|
||||||
|
<Button variant="secondary" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
{isFetching
|
||||||
|
? <Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
: <RefreshCw className="w-4 h-4 mr-2" />}
|
||||||
|
Neu prüfen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isFetching && !ergebnis && (
|
||||||
|
<div className="flex items-center gap-3 text-gray-500">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
<span>Die Kette wird geprüft …</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="flex items-start gap-4 p-4 rounded-lg border bg-gray-50 border-gray-200">
|
||||||
|
<ShieldQuestion className="w-7 h-7 shrink-0 text-gray-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Prüfung nicht möglich</p>
|
||||||
|
<p className="text-sm text-gray-700 mt-1">
|
||||||
|
{error instanceof Error ? error.message : 'Unbekannter Fehler'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
Das ist <strong>keine</strong> Entwarnung – der Zustand der Kette ist damit
|
||||||
|
schlicht unbekannt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnis && (() => {
|
||||||
|
const ampel = ampelFuer(ergebnis);
|
||||||
|
const stil = AMPEL_STIL[ampel];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`flex items-start gap-4 p-4 rounded-lg border ${stil.box}`}>
|
||||||
|
<AmpelIcon ampel={ampel} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`font-medium ${stil.titel}`}>
|
||||||
|
{AMPEL_TITEL[ampel]}
|
||||||
|
{ampel === 'ok' && ergebnis.rehashes.length > 0 && (
|
||||||
|
<span className="font-normal"> – seit der letzten Neuberechnung</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm mt-1 ${stil.text}`}>{ergebnis.message}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
{ergebnis.checkedCount.toLocaleString('de-DE')} Einträge geprüft ·
|
||||||
|
{' '}Altbestand: {SIEGEL_TEXT[ergebnis.backlogSealStatus]}
|
||||||
|
{ergebnis.backlogSealCount > 1 && ` · ${ergebnis.backlogSealCount}× versiegelt`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOffen(o => !o)}
|
||||||
|
className="mt-3 flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
{offen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
Einzelheiten
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{offen && (
|
||||||
|
<div className="mt-3 space-y-2 border-t pt-3">
|
||||||
|
<Zahl label="Nachträglich verändert" ids={ergebnis.tamperedEntries} ton="text-red-700" />
|
||||||
|
<Zahl label="Gesiegelte Alteinträge verändert" ids={ergebnis.backlogTampered} ton="text-red-700" />
|
||||||
|
<Zahl label="Gesiegelte Alteinträge entfernt" ids={ergebnis.backlogMissing} ton="text-red-700" />
|
||||||
|
<Zahl label="Verkettung unterbrochen" ids={ergebnis.chainGaps} ton="text-yellow-700" />
|
||||||
|
<Zahl label="davon ohne dokumentierte Löschung" ids={ergebnis.unexplainedGaps} ton="text-yellow-700" />
|
||||||
|
<Zahl label="davon vom Siegel beglaubigt" ids={ergebnis.attestedGaps} ton="text-gray-700" />
|
||||||
|
<Zahl label="Ohne Schlüssel nicht prüfbar" ids={ergebnis.unverifiableEntries} ton="text-gray-700" />
|
||||||
|
{ergebnis.tamperedEntries.length === 0 &&
|
||||||
|
ergebnis.chainGaps.length === 0 &&
|
||||||
|
ergebnis.unverifiableEntries.length === 0 &&
|
||||||
|
ergebnis.rehashes.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500 italic">Nichts zu berichten.</p>
|
||||||
|
)}
|
||||||
|
{ergebnis.rehashes.length > 0 && (
|
||||||
|
<div className="pt-2 border-t mt-2">
|
||||||
|
<p className="text-sm font-medium text-gray-700">Neuberechnungen der Kette</p>
|
||||||
|
{ergebnis.rehashes.map((r) => (
|
||||||
|
<p key={r.id} className="text-sm text-gray-600 mt-1">
|
||||||
|
{new Date(r.zeitpunkt).toLocaleString('de-DE')} · Eintrag {r.id}
|
||||||
|
{r.neuBerechnet !== null && ` · ${r.neuBerechnet} Einträge neu verkettet`}
|
||||||
|
{r.vorbefund && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{' '}· davor: {r.vorbefund.manipuliert} beanstandet,{' '}
|
||||||
|
{r.vorbefund.luecken} Lücken
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!r.signiert && (
|
||||||
|
<span className="text-red-700"> · ohne gültige Signatur</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Ein Rehash verknüpft alle Einträge neu. Was davor als Lücke oder
|
||||||
|
Beanstandung sichtbar war, ist danach nicht mehr in der Kette zu sehen –
|
||||||
|
nur noch im Vorbefund des jeweiligen Eintrags. Die Aussage dieser Prüfung
|
||||||
|
reicht deshalb bis zur letzten Neuberechnung zurück, nicht weiter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 pt-2">
|
||||||
|
<strong>Verändert</strong> heißt: der Inhalt einer bestehenden Zeile passt nicht
|
||||||
|
mehr zu ihrer Prüfsumme – das ist ernst.
|
||||||
|
{' '}<strong>Verkettung unterbrochen</strong> heißt: die Zeilen selbst sind
|
||||||
|
unversehrt, aber ein Glied fehlt oder wurde parallel geschrieben.
|
||||||
|
{' '}<strong>Beglaubigt</strong> sind Lücken, die beim Versiegeln des Altbestands
|
||||||
|
bereits bestanden und im signierten Siegel festgehalten sind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { auditLogApi, AuditLogSearchParams, authApi } from '../../services/api';
|
import { auditLogApi, AuditLogSearchParams, authApi } from '../../services/api';
|
||||||
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
|
import type { AuditLog, AuditAction, AuditSensitivity } from '../../types';
|
||||||
import Card from '../../components/ui/Card';
|
import Card from '../../components/ui/Card';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import Input from '../../components/ui/Input';
|
import Input from '../../components/ui/Input';
|
||||||
import Select from '../../components/ui/Select';
|
import Select from '../../components/ui/Select';
|
||||||
|
import AuditIntegrityCard from './AuditIntegrityCard';
|
||||||
import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
import { ArrowLeft, Download, Eye, Shield, ShieldAlert, RefreshCw, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||||
|
|
||||||
const ACTION_OPTIONS = [
|
const ACTION_OPTIONS = [
|
||||||
@@ -20,6 +22,7 @@ const ACTION_OPTIONS = [
|
|||||||
{ value: 'LOGIN', label: 'Login' },
|
{ value: 'LOGIN', label: 'Login' },
|
||||||
{ value: 'LOGOUT', label: 'Logout' },
|
{ value: 'LOGOUT', label: 'Logout' },
|
||||||
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
|
{ value: 'LOGIN_FAILED', label: 'Login fehlgeschlagen' },
|
||||||
|
{ value: 'TOKEN_REFRESH', label: 'Sitzung verlängert' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SENSITIVITY_OPTIONS = [
|
const SENSITIVITY_OPTIONS = [
|
||||||
@@ -68,6 +71,7 @@ function getActionColor(action: AuditAction): string {
|
|||||||
case 'LOGIN': return 'bg-teal-100 text-teal-800';
|
case 'LOGIN': return 'bg-teal-100 text-teal-800';
|
||||||
case 'LOGOUT': return 'bg-gray-100 text-gray-800';
|
case 'LOGOUT': return 'bg-gray-100 text-gray-800';
|
||||||
case 'LOGIN_FAILED': return 'bg-red-200 text-red-900';
|
case 'LOGIN_FAILED': return 'bg-red-200 text-red-900';
|
||||||
|
case 'TOKEN_REFRESH': return 'bg-slate-50 text-slate-500';
|
||||||
default: return 'bg-gray-100 text-gray-800';
|
default: return 'bg-gray-100 text-gray-800';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,6 +280,10 @@ function DetailModal({ log, onClose }: DetailModalProps) {
|
|||||||
|
|
||||||
export default function AuditLogs() {
|
export default function AuditLogs() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
// Export haengt an `audit:export`, Lesen an `audit:read` – ein Konto darf
|
||||||
|
// blaettern duerfen, ohne das gesamte Protokoll herausziehen zu koennen.
|
||||||
|
// Ohne diese Abfrage stuenden hier Knoepfe, die zuverlaessig 403 liefern.
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [filters, setFilters] = useState<AuditLogSearchParams>({
|
const [filters, setFilters] = useState<AuditLogSearchParams>({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -305,11 +313,16 @@ export default function AuditLogs() {
|
|||||||
const downloadToken = await authApi.getDownloadToken();
|
const downloadToken = await authApi.getDownloadToken();
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('format', 'csv');
|
params.set('format', 'csv');
|
||||||
if (filters.action) params.set('action', filters.action);
|
// ALLE aktiven Filter mitgeben (Pentest R186-01). Vorher standen hier
|
||||||
if (filters.sensitivity) params.set('sensitivity', filters.sensitivity);
|
// nur fuenf – wer im Suchfeld eingrenzte und dann CSV klickte, bekam
|
||||||
if (filters.resourceType) params.set('resourceType', filters.resourceType);
|
// stillschweigend das gesamte Protokoll statt seiner Auswahl.
|
||||||
if (filters.startDate) params.set('startDate', filters.startDate);
|
// `page`/`limit` gehoeren nicht dazu: der Export ist bewusst
|
||||||
if (filters.endDate) params.set('endDate', filters.endDate);
|
// vollstaendig ueber die gefilterte Menge.
|
||||||
|
for (const [schluessel, wert] of Object.entries(filters)) {
|
||||||
|
if (schluessel === 'page' || schluessel === 'limit') continue;
|
||||||
|
if (wert === undefined || wert === null || wert === '') continue;
|
||||||
|
params.set(schluessel, String(wert));
|
||||||
|
}
|
||||||
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
|
window.open(`/api/audit-logs/export?${params}&token=${downloadToken ?? ''}`, '_blank');
|
||||||
} else {
|
} else {
|
||||||
const result = await auditLogApi.export({ ...filters, format });
|
const result = await auditLogApi.export({ ...filters, format });
|
||||||
@@ -339,6 +352,8 @@ export default function AuditLogs() {
|
|||||||
<h1 className="text-2xl font-bold">Audit-Protokoll</h1>
|
<h1 className="text-2xl font-bold">Audit-Protokoll</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AuditIntegrityCard />
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||||
@@ -383,14 +398,18 @@ export default function AuditLogs() {
|
|||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => handleExport('json')}>
|
{hasPermission('audit:export') && (
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Button variant="secondary" onClick={() => handleExport('json')}>
|
||||||
JSON
|
<Download className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
JSON
|
||||||
<Button variant="secondary" onClick={() => handleExport('csv')}>
|
</Button>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
)}
|
||||||
CSV
|
{hasPermission('audit:export') && (
|
||||||
</Button>
|
<Button variant="secondary" onClick={() => handleExport('csv')}>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function CancellationPeriodList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold flex-1">Kündigungsfristen</h1>
|
<h1 className="text-2xl font-bold flex-1">Kündigungsfristen</h1>
|
||||||
{hasPermission('platforms:create') && (
|
{hasPermission('cancellation-periods:create') && (
|
||||||
<Button onClick={() => setShowModal(true)}>
|
<Button onClick={() => setShowModal(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Neue Frist
|
Neue Frist
|
||||||
@@ -101,12 +101,12 @@ export default function CancellationPeriodList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-right">
|
<td className="py-3 px-4 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{hasPermission('platforms:update') && (
|
{hasPermission('cancellation-periods:update') && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(period)}>
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(period)}>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasPermission('platforms:delete') && (
|
{hasPermission('cancellation-periods:delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function ContractCategoryList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
|
<h1 className="text-2xl font-bold flex-1">Vertragstypen</h1>
|
||||||
{hasPermission('developer:access') && (
|
{hasPermission('contract-categories:create') && (
|
||||||
<Button onClick={() => setShowModal(true)}>
|
<Button onClick={() => setShowModal(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Neuer Vertragstyp
|
Neuer Vertragstyp
|
||||||
@@ -144,12 +144,12 @@ export default function ContractCategoryList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 ml-4">
|
<div className="flex gap-2 ml-4">
|
||||||
{hasPermission('developer:access') && (
|
{hasPermission('contract-categories:update') && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(category)} title="Bearbeiten">
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasPermission('developer:access') && (
|
{hasPermission('contract-categories:delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function ContractDurationList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold flex-1">Vertragslaufzeiten</h1>
|
<h1 className="text-2xl font-bold flex-1">Vertragslaufzeiten</h1>
|
||||||
{hasPermission('platforms:create') && (
|
{hasPermission('contract-durations:create') && (
|
||||||
<Button onClick={() => setShowModal(true)}>
|
<Button onClick={() => setShowModal(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Neue Laufzeit
|
Neue Laufzeit
|
||||||
@@ -101,12 +101,12 @@ export default function ContractDurationList() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-right">
|
<td className="py-3 px-4 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{hasPermission('platforms:update') && (
|
{hasPermission('contract-durations:update') && (
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(duration)}>
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(duration)}>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasPermission('platforms:delete') && (
|
{hasPermission('contract-durations:delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const TYPE_OPTIONS: { value: SecurityEventType | ''; label: string }[] = [
|
|||||||
{ value: 'LOGOUT', label: 'Logout' },
|
{ value: 'LOGOUT', label: 'Logout' },
|
||||||
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
|
{ value: 'TOKEN_REJECTED', label: 'Token abgelehnt' },
|
||||||
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
|
{ value: 'PERMISSION_CHANGED', label: 'Berechtigung geändert' },
|
||||||
|
{ value: 'AUDIT_SEAL_CHANGED', label: 'Bestandssiegel gesetzt/ersetzt' },
|
||||||
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
|
{ value: 'SUSPICIOUS', label: 'Verdächtig (Threshold)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ function ProviderRow({
|
|||||||
<div className="border-t bg-gray-50 p-4">
|
<div className="border-t bg-gray-50 p-4">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<h4 className="font-medium text-gray-700">Tarife</h4>
|
<h4 className="font-medium text-gray-700">Tarife</h4>
|
||||||
{hasPermission('providers:create') && (
|
{hasPermission('tariffs:create') && (
|
||||||
<Button size="sm" onClick={() => setShowTariffModal(true)}>
|
<Button size="sm" onClick={() => setShowTariffModal(true)}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
Tarif hinzufügen
|
Tarif hinzufügen
|
||||||
@@ -227,7 +227,7 @@ function ProviderRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{hasPermission('providers:update') && (
|
{hasPermission('tariffs:update') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -240,7 +240,7 @@ function ProviderRow({
|
|||||||
<Edit className="w-3 h-3" />
|
<Edit className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasPermission('providers:delete') && (
|
{hasPermission('tariffs:delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export default function TaskList() {
|
|||||||
|
|
||||||
const contractDisplay = task.contract
|
const contractDisplay = task.contract
|
||||||
? `${task.contract.contractNumber} - ${task.contract.provider?.name || task.contract.providerName || 'Kein Anbieter'}`
|
? `${task.contract.contractNumber} - ${task.contract.provider?.name || task.contract.providerName || 'Kein Anbieter'}`
|
||||||
: `Vertrag #${task.contractId}`;
|
: (task.contractId != null ? `Vertrag #${task.contractId}` : 'Allgemeine Aufgabe (ohne Vertrag)');
|
||||||
|
|
||||||
const customerDisplay = task.contract?.customer
|
const customerDisplay = task.contract?.customer
|
||||||
? (task.contract.customer.companyName || `${task.contract.customer.firstName} ${task.contract.customer.lastName}`)
|
? (task.contract.customer.companyName || `${task.contract.customer.firstName} ${task.contract.customer.lastName}`)
|
||||||
@@ -242,13 +242,17 @@ export default function TaskList() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
<div className="text-sm text-gray-500 mt-1 flex items-center gap-2">
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
<Link
|
{task.contractId != null ? (
|
||||||
to={`/contracts/${task.contractId}`}
|
<Link
|
||||||
className="text-blue-600 hover:underline"
|
to={`/contracts/${task.contractId}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="text-blue-600 hover:underline"
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
{contractDisplay}
|
>
|
||||||
</Link>
|
{contractDisplay}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500 italic">{contractDisplay}</span>
|
||||||
|
)}
|
||||||
{showCustomer && customerDisplay && (
|
{showCustomer && customerDisplay && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-400">|</span>
|
<span className="text-gray-400">|</span>
|
||||||
@@ -296,17 +300,19 @@ export default function TaskList() {
|
|||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{task.contractId != null && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={(e) => {
|
size="sm"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
navigate(`/contracts/${task.contractId}`);
|
e.stopPropagation();
|
||||||
}}
|
navigate(`/contracts/${task.contractId}`);
|
||||||
title="Zum Vertrag"
|
}}
|
||||||
>
|
title="Zum Vertrag"
|
||||||
<Eye className="w-4 h-4" />
|
>
|
||||||
</Button>
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -763,6 +769,7 @@ function CreateTaskModal({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
|
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
|
||||||
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
|
const [selectedContractId, setSelectedContractId] = useState<number | null>(null);
|
||||||
|
const [noCustomer, setNoCustomer] = useState(false);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [visibleInPortal, setVisibleInPortal] = useState(false);
|
const [visibleInPortal, setVisibleInPortal] = useState(false);
|
||||||
@@ -810,15 +817,25 @@ function CreateTaskModal({
|
|||||||
}, [contractsData?.data, contractSearch]);
|
}, [contractsData?.data, contractSearch]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedContractId || !title.trim()) return;
|
if (!title.trim()) return;
|
||||||
|
if (!noCustomer && !selectedContractId) return;
|
||||||
|
|
||||||
|
const contractId = selectedContractId;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await contractTaskApi.create(selectedContractId, {
|
if (noCustomer) {
|
||||||
title: title.trim(),
|
// Allgemeine Aufgabe ohne Vertrag/Kunde – nie im Portal sichtbar.
|
||||||
description: description.trim() || undefined,
|
await contractTaskApi.createGeneral({
|
||||||
visibleInPortal,
|
title: title.trim(),
|
||||||
});
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await contractTaskApi.create(contractId!, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
visibleInPortal,
|
||||||
|
});
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
queryClient.invalidateQueries({ queryKey: ['all-tasks'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
|
queryClient.invalidateQueries({ queryKey: ['task-stats'] });
|
||||||
onClose();
|
onClose();
|
||||||
@@ -828,8 +845,9 @@ function CreateTaskModal({
|
|||||||
setVisibleInPortal(false);
|
setVisibleInPortal(false);
|
||||||
setSelectedContractId(null);
|
setSelectedContractId(null);
|
||||||
setSelectedCustomerId(null);
|
setSelectedCustomerId(null);
|
||||||
// Navigate to the contract
|
setNoCustomer(false);
|
||||||
navigate(`/contracts/${selectedContractId}`);
|
// Bei vertragsgebundener Aufgabe zum Vertrag springen.
|
||||||
|
if (!noCustomer && contractId) navigate(`/contracts/${contractId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Erstellen der Aufgabe:', error);
|
console.error('Fehler beim Erstellen der Aufgabe:', error);
|
||||||
alert('Fehler beim Erstellen der Aufgabe. Bitte versuchen Sie es erneut.');
|
alert('Fehler beim Erstellen der Aufgabe. Bitte versuchen Sie es erneut.');
|
||||||
@@ -844,6 +862,7 @@ function CreateTaskModal({
|
|||||||
setVisibleInPortal(false);
|
setVisibleInPortal(false);
|
||||||
setSelectedContractId(null);
|
setSelectedContractId(null);
|
||||||
setSelectedCustomerId(null);
|
setSelectedCustomerId(null);
|
||||||
|
setNoCustomer(false);
|
||||||
setCustomerSearch('');
|
setCustomerSearch('');
|
||||||
setContractSearch('');
|
setContractSearch('');
|
||||||
onClose();
|
onClose();
|
||||||
@@ -861,7 +880,27 @@ function CreateTaskModal({
|
|||||||
title="Neue Aufgabe"
|
title="Neue Aufgabe"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Ohne Kunde (allgemeine Aufgabe) */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={noCustomer}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNoCustomer(e.target.checked);
|
||||||
|
if (e.target.checked) {
|
||||||
|
// Ohne Kunde → Kunden-/Vertragsauswahl + Portal-Sichtbarkeit weg.
|
||||||
|
setSelectedCustomerId(null);
|
||||||
|
setSelectedContractId(null);
|
||||||
|
setVisibleInPortal(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Ohne Kunde (allgemeine Aufgabe)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Kundenauswahl */}
|
{/* Kundenauswahl */}
|
||||||
|
{!noCustomer && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Kunde *
|
Kunde *
|
||||||
@@ -896,6 +935,7 @@ function CreateTaskModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Vertragsauswahl (nur wenn Kunde ausgewählt) */}
|
{/* Vertragsauswahl (nur wenn Kunde ausgewählt) */}
|
||||||
{selectedCustomerId && (
|
{selectedCustomerId && (
|
||||||
@@ -969,7 +1009,8 @@ function CreateTaskModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Im Kundenportal sichtbar */}
|
{/* Im Kundenportal sichtbar – nur wenn ein Kunde/Vertrag zugeordnet ist */}
|
||||||
|
{!noCustomer && (
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -981,6 +1022,7 @@ function CreateTaskModal({
|
|||||||
<span className="text-sm text-gray-700">Im Kundenportal sichtbar</span>
|
<span className="text-sm text-gray-700">Im Kundenportal sichtbar</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
@@ -989,7 +1031,7 @@ function CreateTaskModal({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!selectedContractId || !title.trim() || isSubmitting}
|
disabled={(!noCustomer && !selectedContractId) || !title.trim() || isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Wird erstellt...' : 'Aufgabe erstellen'}
|
{isSubmitting ? 'Wird erstellt...' : 'Aufgabe erstellen'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function UserList() {
|
|||||||
<td className="py-3 px-4">{user.email}</td>
|
<td className="py-3 px-4">{user.email}</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<div className="flex gap-1 flex-wrap">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role: any) => (
|
{user.roles?.filter((role: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role: any) => (
|
||||||
<Badge key={role.id || role.name} variant="info">
|
<Badge key={role.id || role.name} variant="info">
|
||||||
{role.name}
|
{role.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -246,7 +246,9 @@ function UserModal({
|
|||||||
roleIds: [] as number[],
|
roleIds: [] as number[],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasDeveloperAccess: false,
|
hasDeveloperAccess: false,
|
||||||
|
hasAuditOpsAccess: false,
|
||||||
hasGdprAccess: false,
|
hasGdprAccess: false,
|
||||||
|
isServiceAccount: false,
|
||||||
whatsappNumber: '',
|
whatsappNumber: '',
|
||||||
telegramUsername: '',
|
telegramUsername: '',
|
||||||
signalNumber: '',
|
signalNumber: '',
|
||||||
@@ -263,10 +265,12 @@ function UserModal({
|
|||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [],
|
roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(r.name)).map((r: any) => r.id) || [],
|
||||||
isActive: (user as any).isActive ?? true,
|
isActive: (user as any).isActive ?? true,
|
||||||
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
hasDeveloperAccess: (user as any).hasDeveloperAccess ?? false,
|
||||||
|
hasAuditOpsAccess: (user as any).hasAuditOpsAccess ?? false,
|
||||||
hasGdprAccess: (user as any).hasGdprAccess ?? false,
|
hasGdprAccess: (user as any).hasGdprAccess ?? false,
|
||||||
|
isServiceAccount: (user as any).isServiceAccount ?? false,
|
||||||
whatsappNumber: (user as any).whatsappNumber || '',
|
whatsappNumber: (user as any).whatsappNumber || '',
|
||||||
telegramUsername: (user as any).telegramUsername || '',
|
telegramUsername: (user as any).telegramUsername || '',
|
||||||
signalNumber: (user as any).signalNumber || '',
|
signalNumber: (user as any).signalNumber || '',
|
||||||
@@ -281,7 +285,9 @@ function UserModal({
|
|||||||
roleIds: [],
|
roleIds: [],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasDeveloperAccess: false,
|
hasDeveloperAccess: false,
|
||||||
|
hasAuditOpsAccess: false,
|
||||||
hasGdprAccess: false,
|
hasGdprAccess: false,
|
||||||
|
isServiceAccount: false,
|
||||||
whatsappNumber: '',
|
whatsappNumber: '',
|
||||||
telegramUsername: '',
|
telegramUsername: '',
|
||||||
signalNumber: '',
|
signalNumber: '',
|
||||||
@@ -323,7 +329,16 @@ function UserModal({
|
|||||||
roleIds: formData.roleIds,
|
roleIds: formData.roleIds,
|
||||||
isActive: formData.isActive,
|
isActive: formData.isActive,
|
||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
|
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
|
isServiceAccount: formData.isServiceAccount,
|
||||||
|
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||||
|
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
|
||||||
|
// im Formular IST diese Bestätigung.
|
||||||
|
// Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses
|
||||||
|
// Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche
|
||||||
|
// Bestätigung; der Haken im Formular ist diese Bestätigung.
|
||||||
|
confirm: 'SERVICE_ACCOUNT',
|
||||||
whatsappNumber: formData.whatsappNumber || undefined,
|
whatsappNumber: formData.whatsappNumber || undefined,
|
||||||
telegramUsername: formData.telegramUsername || undefined,
|
telegramUsername: formData.telegramUsername || undefined,
|
||||||
signalNumber: formData.signalNumber || undefined,
|
signalNumber: formData.signalNumber || undefined,
|
||||||
@@ -358,7 +373,16 @@ function UserModal({
|
|||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
roleIds: formData.roleIds,
|
roleIds: formData.roleIds,
|
||||||
hasDeveloperAccess: formData.hasDeveloperAccess,
|
hasDeveloperAccess: formData.hasDeveloperAccess,
|
||||||
|
hasAuditOpsAccess: formData.hasAuditOpsAccess,
|
||||||
hasGdprAccess: formData.hasGdprAccess,
|
hasGdprAccess: formData.hasGdprAccess,
|
||||||
|
isServiceAccount: formData.isServiceAccount,
|
||||||
|
// Das Kennzeichen senkt die Alarmstufe der Anmeldungen dieses Kontos.
|
||||||
|
// Der Server verlangt dafür eine ausdrückliche Bestätigung; der Haken
|
||||||
|
// im Formular IST diese Bestätigung.
|
||||||
|
// Das Kennzeichen senkt bzw. hebt die Bewertung der Anmeldungen dieses
|
||||||
|
// Kontos. Der Server verlangt in BEIDE Richtungen eine ausdrückliche
|
||||||
|
// Bestätigung; der Haken im Formular ist diese Bestätigung.
|
||||||
|
confirm: 'SERVICE_ACCOUNT',
|
||||||
whatsappNumber: formData.whatsappNumber || undefined,
|
whatsappNumber: formData.whatsappNumber || undefined,
|
||||||
telegramUsername: formData.telegramUsername || undefined,
|
telegramUsername: formData.telegramUsername || undefined,
|
||||||
signalNumber: formData.signalNumber || undefined,
|
signalNumber: formData.signalNumber || undefined,
|
||||||
@@ -470,7 +494,7 @@ function UserModal({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Rollen</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO'].includes(role.name)).map((role) => (
|
{roles.filter((role) => !['Developer', 'Kunde', 'DSGVO', 'Audit-Betrieb'].includes(role.name)).map((role) => (
|
||||||
<label key={role.id} className="flex items-center gap-2">
|
<label key={role.id} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -487,6 +511,22 @@ function UserModal({
|
|||||||
</div>
|
</div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mt-4 mb-2">Zusätzliche Berechtigungen</label>
|
<label className="block text-sm font-medium text-gray-700 mt-4 mb-2">Zusätzliche Berechtigungen</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.isServiceAccount}
|
||||||
|
onChange={(e) => setFormData({ ...formData, isServiceAccount: e.target.checked })}
|
||||||
|
className="mt-1 rounded border-blue-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">Dienstkonto</span>
|
||||||
|
<span className="block text-sm text-gray-500">
|
||||||
|
Für Konten, die sich planmäßig und regelmäßig anmelden – etwa das Gegenbuch.
|
||||||
|
Ihre Anmeldungen erscheinen im Audit-Log als Routine statt als kritisches
|
||||||
|
Ereignis. Dafür wird gemeldet, wenn ein solches Konto <em>ausbleibt</em>.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -498,7 +538,28 @@ function UserModal({
|
|||||||
<Shield className="w-4 h-4 text-blue-600" />
|
<Shield className="w-4 h-4 text-blue-600" />
|
||||||
DSGVO-Zugriff
|
DSGVO-Zugriff
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">(Audit-Logs, Datenschutz)</span>
|
<span className="text-sm text-gray-500">(Audit-Protokoll lesen, Datenschutz)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.hasAuditOpsAccess}
|
||||||
|
onChange={(e) => setFormData({ ...formData, hasAuditOpsAccess: e.target.checked })}
|
||||||
|
className="mt-1 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Shield className="w-4 h-4 text-amber-600" />
|
||||||
|
Audit-Betrieb
|
||||||
|
<span className="text-sm text-gray-500">(versiegeln, aufräumen, Aufbewahrung)</span>
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm text-gray-500 mt-0.5">
|
||||||
|
Erlaubt Eingriffe am Audit-Protokoll: Bestandssiegel setzen oder
|
||||||
|
ersetzen, aufräumen, Aufbewahrungsfristen ändern. Bewusst getrennt
|
||||||
|
vom DSGVO-Zugriff – wer das Protokoll beaufsichtigt, soll seine
|
||||||
|
eigene Beweisgrundlage nicht ersetzen können.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -913,6 +913,7 @@ export interface ContractTreeNodeContract {
|
|||||||
status: string;
|
status: string;
|
||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
endDate: string | null;
|
endDate: string | null;
|
||||||
|
cancellationConfirmationDate?: string | null;
|
||||||
providerName: string | null;
|
providerName: string | null;
|
||||||
tariffName: string | null;
|
tariffName: string | null;
|
||||||
previousContractId: number | null;
|
previousContractId: number | null;
|
||||||
@@ -923,7 +924,8 @@ export interface ContractTreeNodeContract {
|
|||||||
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
||||||
mobileDetails?: {
|
mobileDetails?: {
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
simCards: { phoneNumber: string | null; isMain: boolean }[];
|
mobileNetwork?: string | null;
|
||||||
|
simCards: { phoneNumber: string | null; isMain: boolean; cardUser?: string | null }[];
|
||||||
} | null;
|
} | null;
|
||||||
carInsuranceDetails?: { licensePlate: string | null } | null;
|
carInsuranceDetails?: { licensePlate: string | null } | null;
|
||||||
}
|
}
|
||||||
@@ -1070,6 +1072,11 @@ export const contractTaskApi = {
|
|||||||
const res = await api.post<ApiResponse<ContractTask>>(`/contracts/${contractId}/tasks`, data);
|
const res = await api.post<ApiResponse<ContractTask>>(`/contracts/${contractId}/tasks`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
// Allgemeine Aufgabe ohne Vertrag/Kunde (nur Mitarbeiter).
|
||||||
|
createGeneral: async (data: { title: string; description?: string }) => {
|
||||||
|
const res = await api.post<ApiResponse<ContractTask>>(`/tasks`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
update: async (taskId: number, data: { title?: string; description?: string; visibleInPortal?: boolean }) => {
|
update: async (taskId: number, data: { title?: string; description?: string; visibleInPortal?: boolean }) => {
|
||||||
const res = await api.put<ApiResponse<ContractTask>>(`/tasks/${taskId}`, data);
|
const res = await api.put<ApiResponse<ContractTask>>(`/tasks/${taskId}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -1541,11 +1548,12 @@ export const userApi = {
|
|||||||
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
const res = await api.get<ApiResponse<User>>(`/users/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
create: async (data: { email: string; password: string; firstName: string; lastName: string; roleIds: number[]; customerId?: number; hasDeveloperAccess?: boolean; hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean; isServiceAccount?: boolean; confirm?: string; whatsappNumber?: string; telegramUsername?: string; signalNumber?: string }) => {
|
||||||
const res = await api.post<ApiResponse<User>>('/users', data);
|
const res = await api.post<ApiResponse<User>>('/users', data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[] }) => {
|
update: async (id: number, data: Partial<User> & { password?: string; roleIds?: number[]; isServiceAccount?: boolean; hasGdprAccess?: boolean; hasDeveloperAccess?: boolean; hasAuditOpsAccess?: boolean; confirm?: string }) => {
|
||||||
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
const res = await api.put<ApiResponse<User>>(`/users/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -1727,6 +1735,42 @@ export interface AuditLogSearchParams {
|
|||||||
search?: string;
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ergebnis der Integritaetspruefung des Audit-Protokolls. */
|
||||||
|
export interface IntegrityResult {
|
||||||
|
valid: boolean;
|
||||||
|
checkedCount: number;
|
||||||
|
/** Noch offene Beanstandungen – nur hieran haengt `valid`. */
|
||||||
|
invalidEntries: number[];
|
||||||
|
/** ERNST: Inhalt einer bestehenden Zeile wurde nachtraeglich veraendert. */
|
||||||
|
tamperedEntries: number[];
|
||||||
|
/** Verkettung unterbrochen; die Zeilen selbst koennen unversehrt sein. */
|
||||||
|
chainGaps: number[];
|
||||||
|
/** Luecken ohne protokolliertes Loeschungs-Manifest. */
|
||||||
|
unexplainedGaps: number[];
|
||||||
|
/** Alt-Luecken, die das Bestandssiegel als bereits vorhanden beglaubigt. */
|
||||||
|
attestedGaps: number[];
|
||||||
|
/**
|
||||||
|
* Protokollierte Neuberechnungen der Kette. Nach einem Rehash ist die Kette
|
||||||
|
* zwangslaeufig stimmig - auch ueber Loeschungen hinweg. Die Aussage der
|
||||||
|
* Pruefung reicht dann nur bis zur letzten Neuberechnung zurueck.
|
||||||
|
*/
|
||||||
|
rehashes: Array<{
|
||||||
|
id: number;
|
||||||
|
zeitpunkt: string;
|
||||||
|
neuBerechnet: number | null;
|
||||||
|
signiert: boolean;
|
||||||
|
vorbefund: { manipuliert: number; luecken: number } | null;
|
||||||
|
}>;
|
||||||
|
/** Signierte Zeilen, die ohne AUDIT_HMAC_KEY nicht pruefbar sind. */
|
||||||
|
unverifiableEntries: number[];
|
||||||
|
backlogSealStatus: 'kein_siegel' | 'intakt' | 'leer' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
||||||
|
backlogTampered: number[];
|
||||||
|
backlogMissing: number[];
|
||||||
|
backlogSealCount: number;
|
||||||
|
tampered: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const auditLogApi = {
|
export const auditLogApi = {
|
||||||
search: async (params?: AuditLogSearchParams) => {
|
search: async (params?: AuditLogSearchParams) => {
|
||||||
const res = await api.get<ApiResponse<AuditLog[]>>('/audit-logs', { params });
|
const res = await api.get<ApiResponse<AuditLog[]>>('/audit-logs', { params });
|
||||||
@@ -1745,7 +1789,7 @@ export const auditLogApi = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
verifyIntegrity: async () => {
|
verifyIntegrity: async () => {
|
||||||
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; message: string }>>('/audit-logs/verify');
|
const res = await api.post<ApiResponse<IntegrityResult>>('/audit-logs/verify');
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
rehash: async () => {
|
rehash: async () => {
|
||||||
@@ -1841,7 +1885,7 @@ export interface EmailLog {
|
|||||||
export type SecurityEventType =
|
export type SecurityEventType =
|
||||||
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
|
| 'LOGIN_FAILED' | 'LOGIN_SUCCESS' | 'RATE_LIMIT_HIT' | 'ACCESS_DENIED'
|
||||||
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
|
| 'SSRF_BLOCKED' | 'PASSWORD_RESET_REQUEST' | 'PASSWORD_RESET_CONFIRM'
|
||||||
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'SUSPICIOUS';
|
| 'LOGOUT' | 'TOKEN_REJECTED' | 'PERMISSION_CHANGED' | 'AUDIT_SEAL_CHANGED' | 'SUSPICIOUS';
|
||||||
|
|
||||||
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
export type SecuritySeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ export interface Address {
|
|||||||
city: string;
|
city: string;
|
||||||
country: string;
|
country: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
// Marktlokations-IDs (MaLo) je Sparte – gehören zur (Liefer-)Adresse
|
||||||
|
maloIdElectricity?: string;
|
||||||
|
maloIdGas?: string;
|
||||||
// Eigentümer (leer = Kunde ist selbst Eigentümer)
|
// Eigentümer (leer = Kunde ist selbst Eigentümer)
|
||||||
ownerCompany?: string;
|
ownerCompany?: string;
|
||||||
ownerFirstName?: string;
|
ownerFirstName?: string;
|
||||||
@@ -278,6 +281,7 @@ export interface BankCard {
|
|||||||
customerId: number;
|
customerId: number;
|
||||||
accountHolder: string;
|
accountHolder: string;
|
||||||
iban: string;
|
iban: string;
|
||||||
|
cardNumber?: string;
|
||||||
bic?: string;
|
bic?: string;
|
||||||
bankName?: string;
|
bankName?: string;
|
||||||
expiryDate?: string;
|
expiryDate?: string;
|
||||||
@@ -467,7 +471,7 @@ export interface ContractTaskContract {
|
|||||||
|
|
||||||
export interface ContractTask {
|
export interface ContractTask {
|
||||||
id: number;
|
id: number;
|
||||||
contractId: number;
|
contractId: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status: ContractTaskStatus;
|
status: ContractTaskStatus;
|
||||||
@@ -557,7 +561,7 @@ export interface Tariff {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ContractType = 'ELECTRICITY' | 'GAS' | 'DSL' | 'CABLE' | 'FIBER' | 'MOBILE' | 'TV' | 'CAR_INSURANCE';
|
export type ContractType = 'ELECTRICITY' | 'GAS' | 'DSL' | 'CABLE' | 'FIBER' | 'MOBILE' | 'TV' | 'CAR_INSURANCE';
|
||||||
export type ContractStatus = 'DRAFT' | 'PENDING' | 'ACTIVE' | 'CANCELLED' | 'EXPIRED' | 'DEACTIVATED' | 'ONGOING';
|
export type ContractStatus = 'DRAFT' | 'PENDING' | 'ACTIVE' | 'CANCELLED' | 'CANCELLED_CONFIRMED' | 'EXPIRED' | 'DEACTIVATED' | 'ONGOING';
|
||||||
|
|
||||||
export interface Contract {
|
export interface Contract {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -657,6 +661,7 @@ export interface EnergyContractDetails {
|
|||||||
unitPriceNt?: number; // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
unitPriceNt?: number; // €/kWh NT-Preis (nur bei Zweitarifzähler)
|
||||||
instantBonus?: number; // Sofort-Bonus
|
instantBonus?: number; // Sofort-Bonus
|
||||||
newCustomerBonus?: number; // Neukunden-Bonus
|
newCustomerBonus?: number; // Neukunden-Bonus
|
||||||
|
noBonusDesired?: boolean; // Kunde wünscht keine Bonis
|
||||||
previousProviderName?: string;
|
previousProviderName?: string;
|
||||||
previousCustomerNumber?: string;
|
previousCustomerNumber?: string;
|
||||||
invoices?: Invoice[]; // Rechnungen
|
invoices?: Invoice[]; // Rechnungen
|
||||||
@@ -872,7 +877,7 @@ export interface CockpitResult {
|
|||||||
|
|
||||||
// ==================== AUDIT-LOGGING & DSGVO ====================
|
// ==================== AUDIT-LOGGING & DSGVO ====================
|
||||||
|
|
||||||
export type AuditAction = 'CREATE' | 'READ' | 'UPDATE' | 'DELETE' | 'EXPORT' | 'ANONYMIZE' | 'LOGIN' | 'LOGOUT' | 'LOGIN_FAILED';
|
export type AuditAction = 'CREATE' | 'READ' | 'UPDATE' | 'DELETE' | 'EXPORT' | 'ANONYMIZE' | 'LOGIN' | 'LOGOUT' | 'LOGIN_FAILED' | 'TOKEN_REFRESH';
|
||||||
export type AuditSensitivity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
export type AuditSensitivity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
export type ConsentType = 'DATA_PROCESSING' | 'MARKETING_EMAIL' | 'MARKETING_PHONE' | 'DATA_SHARING_PARTNER';
|
export type ConsentType = 'DATA_PROCESSING' | 'MARKETING_EMAIL' | 'MARKETING_PHONE' | 'DATA_SHARING_PARTNER';
|
||||||
export type ConsentStatus = 'GRANTED' | 'WITHDRAWN' | 'PENDING';
|
export type ConsentStatus = 'GRANTED' | 'WITHDRAWN' | 'PENDING';
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ interface ContractInfoData {
|
|||||||
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
||||||
mobileDetails?: {
|
mobileDetails?: {
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
simCards: { phoneNumber: string | null; isMain: boolean }[];
|
mobileNetwork?: string | null;
|
||||||
|
simCards: { phoneNumber: string | null; isMain: boolean; cardUser?: string | null }[];
|
||||||
} | null;
|
} | null;
|
||||||
carInsuranceDetails?: { licensePlate: string | null } | null;
|
carInsuranceDetails?: { licensePlate: string | null } | null;
|
||||||
}
|
}
|
||||||
@@ -11,6 +12,21 @@ interface ContractInfoData {
|
|||||||
export interface ContractTypeInfo {
|
export interface ContractTypeInfo {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
// Nur bei Mobilfunk zusätzlich befüllt:
|
||||||
|
cardUser?: string; // wem die Karte gehört (SimCard.cardUser)
|
||||||
|
network?: string; // Mobilfunknetz, menschenlesbar (Telekom/Vodafone/…)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Physisches Mobilfunknetz → Anzeigename.
|
||||||
|
const MOBILE_NETWORK_LABELS: Record<string, string> = {
|
||||||
|
TELEKOM: 'Telekom',
|
||||||
|
VODAFONE: 'Vodafone',
|
||||||
|
TELEFONICA: 'Telefónica (o2)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mobileNetworkLabel(net?: string | null): string | undefined {
|
||||||
|
if (!net) return undefined;
|
||||||
|
return MOBILE_NETWORK_LABELS[net] ?? net;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContractTypeInfo(contract: ContractInfoData): ContractTypeInfo | null {
|
export function getContractTypeInfo(contract: ContractInfoData): ContractTypeInfo | null {
|
||||||
@@ -39,9 +55,16 @@ export function getContractTypeInfo(contract: ContractInfoData): ContractTypeInf
|
|||||||
if (!md) return null;
|
if (!md) return null;
|
||||||
const mainSim = md.simCards?.find((s) => s.isMain && s.phoneNumber);
|
const mainSim = md.simCards?.find((s) => s.isMain && s.phoneNumber);
|
||||||
const anySim = md.simCards?.find((s) => s.phoneNumber);
|
const anySim = md.simCards?.find((s) => s.phoneNumber);
|
||||||
const phone = mainSim?.phoneNumber || anySim?.phoneNumber || md.phoneNumber;
|
const sim = mainSim || anySim;
|
||||||
|
const phone = sim?.phoneNumber || md.phoneNumber;
|
||||||
if (!phone) return null;
|
if (!phone) return null;
|
||||||
return { label: 'Rufnummer', value: phone };
|
return {
|
||||||
|
label: 'Rufnummer',
|
||||||
|
value: phone,
|
||||||
|
// Karteninhaber der angezeigten SIM (kann vom Vertragsinhaber abweichen).
|
||||||
|
cardUser: sim?.cardUser?.trim() || undefined,
|
||||||
|
network: mobileNetworkLabel(md.mobileNetwork),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'CAR_INSURANCE') {
|
if (type === 'CAR_INSURANCE') {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Gegenbuch für OpenCRM
|
||||||
|
# ============================================================
|
||||||
|
# Diese Datei gehört auf den Gegenbuch-Rechner – NICHT auf den CRM-Server.
|
||||||
|
#
|
||||||
|
# Was hier passiert: Der Rechner holt regelmäßig einen kurzen Kontrollwert von
|
||||||
|
# OpenCRM ab und schreibt ihn in ein Buch, das nur hier liegt. Wird später im
|
||||||
|
# CRM etwas nachträglich verändert, widerspricht das dem Buch.
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Welche Bücher sollen laufen?
|
||||||
|
# ============================================================
|
||||||
|
# Gültige Werte: prod | staging | prod,staging | (leer = keins)
|
||||||
|
# Genau so geschrieben – nicht "production" oder "test".
|
||||||
|
#
|
||||||
|
# Nur Staging testen: COMPOSE_PROFILES=staging
|
||||||
|
# Später Prod dazunehmen: COMPOSE_PROFILES=prod,staging
|
||||||
|
# Danach: docker compose up -d (der laufende Dienst bleibt unberührt)
|
||||||
|
COMPOSE_PROFILES=staging
|
||||||
|
|
||||||
|
# Steht in den Einträgen des Buchs. Beliebiger Text, nur Kosmetik.
|
||||||
|
NOTAR_EMAIL=gegenbuch@example.de
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Produktion
|
||||||
|
# ============================================================
|
||||||
|
# Adresse der OpenCRM-Instanz, von der geholt wird.
|
||||||
|
# Gültig: vollständige URL mit https:// und OHNE Schrägstrich am Ende.
|
||||||
|
PROD_CRM_URL=https://crm.example.de
|
||||||
|
|
||||||
|
# Zugang: ein eigenes Benutzerkonto im CRM, das NUR das Recht "audit:read" hat.
|
||||||
|
# Wie man es anlegt, steht in der README unter "Zugang einrichten".
|
||||||
|
#
|
||||||
|
# Das Gegenbuch meldet sich damit bei jedem Durchlauf selbst an. Ein fest
|
||||||
|
# hinterlegtes Token gibt es bewusst nicht – Zugangstoken laufen nach
|
||||||
|
# 15 Minuten ab und wären beim nächsten Durchlauf längst ungültig.
|
||||||
|
PROD_CRM_EMAIL=gegenbuch@deine-domain.de
|
||||||
|
PROD_CRM_PASSWORD=
|
||||||
|
|
||||||
|
# Wie oft geprüft wird, in Sekunden.
|
||||||
|
# Gültig: ganze Zahl > 0. Üblich: 3600 (stündlich), 900 (viertelstündlich)
|
||||||
|
# Kürzer heißt: kleineres Zeitfenster, in dem eine Änderung unbemerkt bliebe.
|
||||||
|
PROD_INTERVAL=3600
|
||||||
|
|
||||||
|
# Beim ALLERERSTEN Start einmalig setzen, danach wieder leeren.
|
||||||
|
# Gültige Werte: true | (leer)
|
||||||
|
# Grund: Die erste Eintragung legt fest, was als Ausgangszustand gilt – das
|
||||||
|
# soll nicht versehentlich passieren.
|
||||||
|
PROD_GENESIS_ACK=
|
||||||
|
|
||||||
|
# Normalerweise leer lassen.
|
||||||
|
# Gültige Werte: true | (leer)
|
||||||
|
# Nur nötig, wenn das Datenverzeichnis verlorenging (z. B. gelöscht) UND du
|
||||||
|
# geklärt hast, warum. Siehe README, Abschnitt "Wenn das Gedächtnis fehlt".
|
||||||
|
PROD_ADOPT_ACK=
|
||||||
|
|
||||||
|
# Normalerweise leer lassen.
|
||||||
|
# Gültige Werte: die neue Siegelwurzel (mind. 16 Zeichen) | (leer)
|
||||||
|
# Das Gegenbuch schlägt Alarm, wenn sich die Wurzel des Bestandssiegels
|
||||||
|
# ändert – denn ein erneutes Siegeln ersetzt die Grundlage, gegen die
|
||||||
|
# Manipulation nachgewiesen wird. War der Wechsel gewollt, hier die Wurzel
|
||||||
|
# eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
|
||||||
|
# Bewusst KEIN "true": ein stehen gelassener Wert passt beim nächsten
|
||||||
|
# Wechsel nicht mehr und kann darum keinen weiteren stillschweigend
|
||||||
|
# durchwinken.
|
||||||
|
PROD_SEAL_ACK=
|
||||||
|
|
||||||
|
# Normalerweise leer lassen.
|
||||||
|
# Gültige Werte: die ID des Rehash-Eintrags | (leer)
|
||||||
|
# Das Gegenbuch schlägt Alarm, wenn die Hash-Kette neu berechnet wurde. Ein
|
||||||
|
# Rehash verknüpft alle Einträge neu – Lücken, die eine Löschung sichtbar
|
||||||
|
# gemacht hätten, verschwinden dabei aus der Kette, und die Prüfung im CRM
|
||||||
|
# meldet danach wieder „lückenlos". War die Neuberechnung geplant, hier die
|
||||||
|
# ID eintragen, die der Alarm nennt, einmal laufen lassen und wieder leeren.
|
||||||
|
PROD_REHASH_ACK=
|
||||||
|
|
||||||
|
# Wo das Buch liegt – relativ zu diesem Verzeichnis.
|
||||||
|
# DIESES VERZEICHNIS GEHÖRT INS BACKUP (enthält Buch und Signaturschlüssel).
|
||||||
|
PROD_DIR=./data/prod
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Test / Staging
|
||||||
|
# ============================================================
|
||||||
|
# Gleiche Regeln wie oben, eigenes Konto und eigenes Verzeichnis.
|
||||||
|
STAGING_CRM_URL=https://staging.example.de
|
||||||
|
STAGING_CRM_EMAIL=gegenbuch@deine-domain.de
|
||||||
|
STAGING_CRM_PASSWORD=
|
||||||
|
STAGING_INTERVAL=3600
|
||||||
|
STAGING_GENESIS_ACK=
|
||||||
|
STAGING_ADOPT_ACK=
|
||||||
|
STAGING_SEAL_ACK=
|
||||||
|
STAGING_REHASH_ACK=
|
||||||
|
STAGING_DIR=./data/staging
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Gegenbuch – läuft auf einem EIGENEN Rechner, nicht dort, wo OpenCRM läuft.
|
||||||
|
#
|
||||||
|
# Der Container holt lesend einen Kontrollwert vom CRM (HTTPS + Token mit
|
||||||
|
# audit:read) und legt seine Einträge in einem Git-Repository auf DIESER
|
||||||
|
# Maschine ab. Er braucht keinen SSH-Zugang zum CRM, keine Datenbank, keine
|
||||||
|
# Shell dort. Umgekehrt kennt OpenCRM dieses Gegenbuch überhaupt nicht.
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# git für das Buch, openssh-client für die Signaturen (ssh-keygen).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git openssh-client ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY notary.mjs /opt/notary/notary.mjs
|
||||||
|
COPY entrypoint.sh /opt/notary/entrypoint.sh
|
||||||
|
RUN chmod +x /opt/notary/entrypoint.sh
|
||||||
|
|
||||||
|
# Der Container STARTET als root – aber nur, um die Rechte auf dem
|
||||||
|
# Datenverzeichnis geradezuziehen. Danach gibt der entrypoint die Privilegien
|
||||||
|
# ab und arbeitet als unprivilegierter Benutzer weiter.
|
||||||
|
#
|
||||||
|
# Grund: Das Datenverzeichnis kommt als Bind-Mount vom Host. Wer das Projekt
|
||||||
|
# als root geklont hat, hat dort root-eigene Verzeichnisse – ein Container, der
|
||||||
|
# direkt als UID 1000 startet, kann darin nichts anlegen.
|
||||||
|
WORKDIR /gegenbuch
|
||||||
|
|
||||||
|
ENTRYPOINT ["/opt/notary/entrypoint.sh"]
|
||||||
@@ -0,0 +1,737 @@
|
|||||||
|
# Gegenbuch (externe Notarisierung)
|
||||||
|
|
||||||
|
Alle Schutzmechanismen im CRM selbst – Hash-Kette, Versionsgrenze,
|
||||||
|
Löschungs-Manifest, Bestandssiegel – liegen in **derselben Datenbank**, die sie
|
||||||
|
absichern sollen. Wer dort schreiben kann, sitzt am Ende immer schon auf der
|
||||||
|
Ebene, die den Beweis führt. Genau das hat der Pentest über mehrere Runden
|
||||||
|
Schicht für Schicht gezeigt.
|
||||||
|
|
||||||
|
Das Gegenbuch durchbricht das: Ein **zweiter Rechner** holt regelmäßig einen
|
||||||
|
kurzen Kontrollwert vom CRM, prüft ihn gegen seine eigene Historie und schreibt
|
||||||
|
ihn signiert fort. Wird später im CRM etwas nachträglich verändert,
|
||||||
|
widerspricht das dem Gegenbuch.
|
||||||
|
|
||||||
|
## Was du hier suchst
|
||||||
|
|
||||||
|
**Einrichten**
|
||||||
|
[Betriebsart wählen](#zwei-betriebsarten--erst-hier-entscheiden) ·
|
||||||
|
[mit Docker](#einrichten-mit-docker-empfohlen) ·
|
||||||
|
[erster Start](#erster-start--drei-schritte) ·
|
||||||
|
[Zugangskonto im CRM](#zugang-einrichten-das-brauchst-du-vorher) ·
|
||||||
|
[ohne Docker](#einrichten-ohne-docker)
|
||||||
|
|
||||||
|
**Im Betrieb**
|
||||||
|
[**Altbestand versiegeln**](#den-altbestand-versiegeln-einmalig-im-crm) ·
|
||||||
|
[Rückgabecodes](#rückgabecodes) ·
|
||||||
|
[Überwachung](#überwachung) ·
|
||||||
|
[wo die Daten liegen](#wo-die-daten-liegen)
|
||||||
|
|
||||||
|
**Wenn Alarm kommt**
|
||||||
|
[Siegelwurzel hat gewechselt](#wenn-sich-die-siegelwurzel-ändert-alarm-und-warum) ·
|
||||||
|
[Kette wurde neu berechnet](#wenn-die-kette-neu-berechnet-wurde-ebenfalls-alarm) ·
|
||||||
|
[Zurückspulen erkannt](#der-rewind-wächter) ·
|
||||||
|
[Gedächtnis fehlt](#wenn-das-gedächtnis-trotzdem-fehlt)
|
||||||
|
|
||||||
|
**Zum Nachlesen**
|
||||||
|
[was erkannt wird](#was-das-skript-erkennt) ·
|
||||||
|
[was nicht – ehrlich](#was-es-nicht-leistet--ehrlich) ·
|
||||||
|
[Prüfmodus für Auditoren](#prüfmodus-für-auditoren)
|
||||||
|
|
||||||
|
> **Nur schnell versiegeln?** Der Abschnitt
|
||||||
|
> [Altbestand versiegeln](#den-altbestand-versiegeln-einmalig-im-crm) ist
|
||||||
|
> eigenständig und gilt **auch ohne Gegenbuch** – es ist ein Vorgang im CRM,
|
||||||
|
> nicht hier.
|
||||||
|
|
||||||
|
## Zwei Betriebsarten – erst hier entscheiden
|
||||||
|
|
||||||
|
| | **Lokal** (Normalfall) | **Mit externem Repository** |
|
||||||
|
|---|---|---|
|
||||||
|
| Das Buch liegt | auf dem Gegenbuch-Rechner | zusätzlich auf einem dritten Server |
|
||||||
|
| Aufwand | Docker starten, fertig | privates Git-Repo, Schlüssel, Server-Regeln |
|
||||||
|
| Schützt gegen | jemand verändert Daten **im CRM** | zusätzlich: jemand übernimmt den **Gegenbuch-Rechner** |
|
||||||
|
|
||||||
|
**Für die allermeisten Installationen ist „lokal" die richtige Wahl.** Der
|
||||||
|
Schutz, um den es geht – nachträgliche Änderungen im CRM auffliegen zu lassen –
|
||||||
|
steht damit vollständig. Die zweite Variante deckt einen Angreifer ab, der
|
||||||
|
zusätzlich den Gegenbuch-Rechner übernimmt; sie kostet spürbar mehr Einrichtung
|
||||||
|
und laufende Aufmerksamkeit.
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt zuerst den lokalen Betrieb. Alles zur zweiten
|
||||||
|
Variante steht gesammelt unter **„Zusätzliche Härtung"** weiter unten – wer
|
||||||
|
lokal betreibt, kann diesen ganzen Teil überspringen.
|
||||||
|
|
||||||
|
## Die eine nicht verhandelbare Bedingung
|
||||||
|
|
||||||
|
**Der Signaturschlüssel und das Push-Recht dürfen nicht in den Deploy-Secrets
|
||||||
|
des CRM liegen.** Sonst ist der zweite Schlüssel im selben Zugriffsbereich wie
|
||||||
|
der erste – es sieht nach doppeltem Boden aus und ist keiner. Das CRM braucht
|
||||||
|
für dieses Verfahren **gar nichts** zu wissen: Es liefert nur einen lesbaren
|
||||||
|
Kontrollwert, der keine Geheimnisse enthält.
|
||||||
|
|
||||||
|
## Einrichten mit Docker (empfohlen)
|
||||||
|
|
||||||
|
Auf dem **Gegenbuch-Rechner** – nicht auf dem CRM-Server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <dieses Repository> opencrm
|
||||||
|
cd opencrm/tools/audit-notary
|
||||||
|
cp .env.example .env
|
||||||
|
# .env ausfüllen: CRM-Adresse und Token eintragen
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erster Start – drei Schritte
|
||||||
|
|
||||||
|
Beim allerersten Lauf meldet der Container `exit=4` und verlangt eine
|
||||||
|
Bestätigung. Das ist Absicht: Der erste Eintrag legt fest, was als
|
||||||
|
Ausgangszustand gilt.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. In der .env freigeben
|
||||||
|
STAGING_GENESIS_ACK=true
|
||||||
|
|
||||||
|
# 2. Neu starten (kein --build nötig, nur die Umgebung ändert sich)
|
||||||
|
docker compose up -d && docker compose logs -f
|
||||||
|
# Erwartet: "OK: Checkpoint 1 erstellt" und exit=3
|
||||||
|
# exit=3 ist hier richtig – beim ersten Mal gibt es nichts zu vergleichen.
|
||||||
|
|
||||||
|
# 3. Wieder leeren und erneut starten
|
||||||
|
STAGING_GENESIS_ACK=
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Ab dem nächsten Durchlauf steht dort `exit=0 (in Ordnung)`.
|
||||||
|
|
||||||
|
**Warum Schritt 3 wichtig ist:** Bleibt die Zeile auf `true`, würde der
|
||||||
|
Container nach einem Verlust des Datenverzeichnisses stillschweigend eine neue
|
||||||
|
Grundlage setzen, statt zu fragen – genau davor schützt die Abfrage.
|
||||||
|
|
||||||
|
Für Produktion später dasselbe mit `PROD_GENESIS_ACK`. Schnell nachsehen ohne
|
||||||
|
Logs: `cat data/staging/status.txt`.
|
||||||
|
|
||||||
|
Beim ersten Start einmalig `PROD_GENESIS_ACK=true` setzen (und danach wieder
|
||||||
|
leeren) – die erste Eintragung legt fest, was als Ausgangszustand gilt, und das
|
||||||
|
soll nicht versehentlich passieren.
|
||||||
|
|
||||||
|
**Zwei Bücher auf einer Maschine** sind vorgesehen: `prod` und `staging` sind
|
||||||
|
getrennte Dienste mit getrennten Verzeichnissen und getrennten Schlüsseln.
|
||||||
|
Welche laufen, steuert `COMPOSE_PROFILES` in der `.env`.
|
||||||
|
|
||||||
|
### Den Altbestand versiegeln (einmalig, im CRM)
|
||||||
|
|
||||||
|
Ein CRM, das schon länger läuft, hat fast immer einen **Altbestand** – Einträge
|
||||||
|
aus der Zeit, bevor das Protokoll signiert wurde. Solange der nicht versiegelt
|
||||||
|
ist, meldet die Prüfung `valid: false`, das Gegenbuch schlägt zu Recht Alarm –
|
||||||
|
und, wichtig: **es beglaubigt so lange gar nichts.** Es bricht vor dem Anhängen
|
||||||
|
ab, weil ein Checkpoint über einen ungeklärten Zustand diesen mitbeglaubigen
|
||||||
|
würde. Ein unversiegeltes CRM ist also nicht „bewacht mit Warnung", sondern
|
||||||
|
unbewacht.
|
||||||
|
|
||||||
|
Das Siegeln ist **einmalig** und passiert im CRM, nicht hier.
|
||||||
|
|
||||||
|
#### Wer darf das
|
||||||
|
|
||||||
|
Das Recht `audit:admin`. Das bekommt man über den Haken **„Audit-Betrieb"** in
|
||||||
|
der Benutzerverwaltung – **nicht** über die Admin-Rolle (die hat bewusst keine
|
||||||
|
Audit-Rechte) und **nicht** über den DSGVO-Haken (der darf lesen, nicht
|
||||||
|
eingreifen). Das Gegenbuch-Dienstkonto hat es erst recht nicht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CRM=https://<crm>
|
||||||
|
read -s -p "Passwort: " PASS; echo
|
||||||
|
AT=$(curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.token')
|
||||||
|
curl -s -X POST $CRM/api/auth/login -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"…\",\"password\":\"$PASS\"}" | jq -r '.data.user.permissions[]' | grep audit
|
||||||
|
```
|
||||||
|
|
||||||
|
Muss `audit:read` **und** `audit:admin` zeigen.
|
||||||
|
|
||||||
|
#### Erst nachsehen, was du festschreibst
|
||||||
|
|
||||||
|
Das Siegel hält den **aktuellen** Zustand fest, samt aller vorhandenen Lücken.
|
||||||
|
Wer blind siegelt, beglaubigt womöglich eine Lücke, die von einer Löschung
|
||||||
|
stammt. Der Schritt ist praktisch einwegs: Danach ist die Beglaubigung tragend,
|
||||||
|
und ein Siegel wieder zu entfernen erzeugt den Zustand `entfernt` – also selbst
|
||||||
|
einen Befund.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
B="Authorization: Bearer $AT"
|
||||||
|
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
|
||||||
|
| jq '.data | {valid, checkedCount, rehashes, chainGaps, unexplainedGaps,
|
||||||
|
tamperedEntries, backlogSealStatus}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Vier Dinge müssen stimmen:
|
||||||
|
|
||||||
|
| Feld | Erwartung | Warum |
|
||||||
|
|---|---|---|
|
||||||
|
| `rehashes` | `[]` | Eine Neuberechnung verknüpft alles neu – danach ist die Kette *zwangsläufig* stimmig, auch über Löschungen hinweg. Steht hier etwas, sagt `chainGaps` nichts über die Zeit davor aus. |
|
||||||
|
| `tamperedEntries` | `[]` | Veränderter Inhalt gehört geklärt, nicht beglaubigt. |
|
||||||
|
| `chainGaps` | erklärbar | siehe unten |
|
||||||
|
| `backlogSealStatus` | `kein_siegel` | sonst ist es kein Erst-Siegeln (siehe nächster Abschnitt) |
|
||||||
|
|
||||||
|
**Lücken einordnen.** Sieh dir die betroffenen IDs und ihre Nachbarn an:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for id in <lücke-1> <nachbar> …; do
|
||||||
|
printf '%-6s ' "$id"
|
||||||
|
curl -s -o /tmp/r.json -w 'HTTP %{http_code} ' "$CRM/api/audit-logs/$id" -H "$B"
|
||||||
|
jq -r 'if .success then (.data|"\(.createdAt[0:19]) v\(.hashVersion) \(.action)/\(.resourceType)") else .error end' /tmp/r.json
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Den HTTP-Code mit ausgeben, immer.** Zugangstoken laufen nach 15 Minuten ab.
|
||||||
|
> Ohne den Code liest sich eine abgelehnte Anfrage (401) wie ein fehlender
|
||||||
|
> Eintrag – ein Prüfwerkzeug, das ein verweigertes Lesen als Löschung meldet.
|
||||||
|
> Uns ist genau das passiert, mitten in der Vorbereitung eines Prod-Siegels.
|
||||||
|
|
||||||
|
Liegen die Lücken jeweils **innerhalb einer Sekunde** zusammen mit ihren
|
||||||
|
Nachbarn, betreffen nur `Authentication` und fehlt kein Eintrag, sind es
|
||||||
|
Schreibkollisionen aus parallelen Anfragen – harmlos, historisch, nicht mehr
|
||||||
|
reproduzierbar. Fehlt dagegen ein Eintrag oder passt eine Lücke nicht in dieses
|
||||||
|
Bild: **erst klären, dann siegeln.**
|
||||||
|
|
||||||
|
*(Ein abgeschnittener **Anfang** des Protokolls wird seit dem Anfangs-Detektor
|
||||||
|
mitgeprüft: Die erste Zeile eines Protokolls trägt einen leeren `previousHash`;
|
||||||
|
trägt die erste vorhandene Zeile einen Wert, fehlt eine Vorgängerin. Das
|
||||||
|
erscheint als ganz niedrige Lücke und kippt `valid` – du musst es nicht selbst
|
||||||
|
suchen.)*
|
||||||
|
|
||||||
|
#### Siegeln
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST $CRM/api/audit-logs/seal-backlog -H "$B" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"confirm":"SEAL"}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
`SEAL` ist das Erst-Siegeln. Besteht bereits ein Siegel, verlangt der Endpunkt
|
||||||
|
stattdessen `RESEAL` – siehe nächster Abschnitt. Notiere `root` aus der Antwort.
|
||||||
|
|
||||||
|
#### Danach: drei Punkte gegenprüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST $CRM/api/audit-logs/verify -H "$B" \
|
||||||
|
| jq '.data | {valid, chainGaps, attestedGaps, backlogSealStatus, backlogSealCount}'
|
||||||
|
|
||||||
|
SID=$(curl -s "$CRM/api/audit-logs?resourceType=AuditBacklogSeal&limit=1" -H "$B" | jq -r '.data[0].id')
|
||||||
|
curl -s "$CRM/api/audit-logs/$SID" -H "$B" \
|
||||||
|
| jq '{vorbefund:.data.changesBefore.befund.ketten_luecken, siegel:.data.changesAfter}'
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `valid: true`, und `chainGaps` = `attestedGaps` = die bekannten Lücken
|
||||||
|
2. `backlogSealStatus: "intakt"` – **nicht** `leer`. `leer` heißt: Das Siegel
|
||||||
|
umschließt nichts, es gab keinen Altbestand. Kein Fehler, aber auch keine
|
||||||
|
Zusage.
|
||||||
|
3. Die Lücken stehen im `changesBefore.befund.ketten_luecken` des Markers –
|
||||||
|
genau daran hängt die Beglaubigung.
|
||||||
|
|
||||||
|
Die Lücken verschwinden also **nicht** aus dem Bericht. Sie zählen nur nicht
|
||||||
|
mehr als offener Befund. Jede **neue** Lücke, jede veränderte oder entfernte
|
||||||
|
Altzeile und jedes gebrochene Siegel lösen weiterhin sofort Alarm aus.
|
||||||
|
|
||||||
|
#### Zuletzt: das Gegenbuch quittieren
|
||||||
|
|
||||||
|
Der nächste Lauf meldet **einmal** `exit=2` („Erstmals ein Bestandssiegel
|
||||||
|
gesetzt") – erwartet, kein Befund. Wurzel aus `data/<instanz>/status.txt` in
|
||||||
|
`PROD_SEAL_ACK` bzw. `STAGING_SEAL_ACK` eintragen, `docker compose up -d`, nach
|
||||||
|
dem Lauf mit `exit=0` wieder leeren. Details im nächsten Abschnitt.
|
||||||
|
|
||||||
|
Ab da schreibt das Gegenbuch wieder Checkpoints fort – und setzt im selben Lauf
|
||||||
|
die Grundlage für die Rehash-Überwachung.
|
||||||
|
|
||||||
|
### Wenn sich die Siegelwurzel ändert: Alarm, und warum
|
||||||
|
|
||||||
|
Erneutes Siegeln **ersetzt** die Grundlage, gegen die Manipulation nachgewiesen
|
||||||
|
wird. Wer eine Altzeile per Datenbankzugriff entfernt und danach neu siegelt,
|
||||||
|
bekommt eine passende Wurzel und eine beglaubigte Lücke — und die Prüfung im
|
||||||
|
CRM meldet wieder `valid: true`. Der Wechsel der Wurzel ist die einzige Spur
|
||||||
|
davon, die eine Maschine sehen kann.
|
||||||
|
|
||||||
|
Deshalb ist er ein **Alarm** (exit 2), kein Hinweis. Vorher stand hier eine
|
||||||
|
Zeile Prosa, während der Rückgabecode auf 0 blieb — also genau das Muster, das
|
||||||
|
wir dem CRM selbst zweimal angekreidet haben.
|
||||||
|
|
||||||
|
Der Alarm nennt die alte und die neue Wurzel samt Blattzahl. Ein Sprung von
|
||||||
|
`10 Blätter` auf `9 Blätter` sagt sofort, was passiert ist.
|
||||||
|
|
||||||
|
**War der Wechsel gewollt** (typisch: dein einmaliges Erstsiegeln), bestätigst
|
||||||
|
du ihn mit der Wurzel, die der Alarm ausgibt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in der .env
|
||||||
|
PROD_SEAL_ACK=72062c88a8b6e2b53b30496b483885cd
|
||||||
|
|
||||||
|
docker compose up -d # ein Lauf – die neue Wurzel wird beglaubigt
|
||||||
|
PROD_SEAL_ACK= # danach wieder leeren
|
||||||
|
```
|
||||||
|
|
||||||
|
Bestätigt wird bewusst **nicht** mit `true`, sondern mit der Wurzel selbst.
|
||||||
|
Ein versehentlich stehen gelassener Wert passt beim nächsten Wechsel nicht mehr
|
||||||
|
und kann deshalb keinen weiteren Austausch stillschweigend durchwinken.
|
||||||
|
|
||||||
|
**War er nicht gewollt**, sieh im CRM nach: das Sicherheits-Ereignis
|
||||||
|
`AUDIT_SEAL_CHANGED` (Einstellungen → Monitoring) und die CRITICAL-Zeile zu
|
||||||
|
`/api/audit-logs/seal-backlog` im Audit-Protokoll nennen Konto, Zeitpunkt und
|
||||||
|
den Befund, der vor dem Siegeln galt.
|
||||||
|
|
||||||
|
Im CRM selbst ist erneutes Siegeln zusätzlich gegatet: es verlangt
|
||||||
|
`{"confirm":"RESEAL"}` statt `{"confirm":"SEAL"}` — ein Austausch der
|
||||||
|
Beweisgrundlage soll nicht dasselbe Wort haben wie das Einrichten.
|
||||||
|
|
||||||
|
### Wenn die Kette neu berechnet wurde: ebenfalls Alarm
|
||||||
|
|
||||||
|
Ein **Rehash** verknüpft alle Einträge neu. Danach ist die Kette
|
||||||
|
zwangsläufig stimmig — auch über Löschungen hinweg, die vorher als Lücken
|
||||||
|
sichtbar gewesen wären. Die Reihenfolge `cleanup` → `rehash` macht aus einem
|
||||||
|
beschnittenen Protokoll ein scheinbar makelloses, und die Prüfung im CRM meldet
|
||||||
|
danach wieder „lückenlos verkettet". Beides braucht nur `audit:admin`, keinen
|
||||||
|
Datenbankzugriff.
|
||||||
|
|
||||||
|
Genau das ist im Betrieb vorgekommen: Auf einer Testinstanz wurden 3.155
|
||||||
|
Einträge gelöscht und anschließend neu berechnet. Die Prüfung war danach grün,
|
||||||
|
die 656 Kettenlücken standen nur noch im Vorbefund des Rehash-Eintrags — den
|
||||||
|
niemand liest. Das Gegenbuch war der einzige Zeuge.
|
||||||
|
|
||||||
|
Deshalb ist eine neue Neuberechnung seit der letzten Beglaubigung ein **Alarm**
|
||||||
|
(exit 2). Er nennt Zeitpunkt, Zahl der betroffenen Zeilen und den Befund, der
|
||||||
|
unmittelbar davor galt:
|
||||||
|
|
||||||
|
```
|
||||||
|
ALARM: Die Hash-Kette wurde neu berechnet (1 neuer Vorgang seit der letzten Beglaubigung).
|
||||||
|
zuletzt: 2026-08-26T16:28:25.467Z (Eintrag 13, 9 Zeilen)
|
||||||
|
Befund unmittelbar davor: 1 beanstandet, 1 Lücken
|
||||||
|
…
|
||||||
|
NOTARY_REHASH_ACK=13
|
||||||
|
```
|
||||||
|
|
||||||
|
**War die Neuberechnung gewollt**, bestätigst du sie mit der genannten ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in der .env
|
||||||
|
PROD_REHASH_ACK=13
|
||||||
|
docker compose up -d
|
||||||
|
PROD_REHASH_ACK= # danach wieder leeren
|
||||||
|
```
|
||||||
|
|
||||||
|
Auch hier wird nicht mit `true` bestätigt, sondern mit einem Wert, der zum
|
||||||
|
Vorgang gehört. IDs steigen streng — ein stehen gelassener Wert passt bei der
|
||||||
|
nächsten Neuberechnung nicht mehr.
|
||||||
|
|
||||||
|
**Warum ein eigener Melder, wo doch schon der Kettenkopf verglichen wird?**
|
||||||
|
Eine Neuberechnung ändert jeden Hash, der beglaubigte Kopf stimmt also
|
||||||
|
ohnehin nicht mehr — der Alarm käme auch so. Aber als *Nebenwirkung*, nicht als
|
||||||
|
gebaute Warnung: Verschöbe sich der Anker irgendwann, wäre der Melder lautlos
|
||||||
|
weg. Und die Meldung hieße „Eintrag wurde verändert" statt „die Kette wurde neu
|
||||||
|
berechnet" — die Wirkung statt der Ursache. Deshalb hängt der Alarm an der
|
||||||
|
Sache selbst und steht **vor** dem Kopf-Vergleich; nach einer bestätigten
|
||||||
|
Neuberechnung wird dieser übersprungen, weil der veränderte Kopf dann die
|
||||||
|
erwartete Folge ist.
|
||||||
|
|
||||||
|
Ein fehlender beglaubigter Eintrag bleibt davon unberührt und alarmiert immer:
|
||||||
|
Bestätigt wird die Neuberechnung, nicht das Verschwinden von Zeilen.
|
||||||
|
|
||||||
|
### Zugang einrichten (das brauchst du vorher)
|
||||||
|
|
||||||
|
Das Gegenbuch braucht ein **eigenes Benutzerkonto** im CRM – kein Token. Der
|
||||||
|
Grund: Zugangstoken laufen nach 15 Minuten ab und wären beim nächsten
|
||||||
|
stündlichen Durchlauf längst ungültig. Das Gegenbuch meldet sich deshalb bei
|
||||||
|
jedem Lauf selbst an.
|
||||||
|
|
||||||
|
Im CRM, als Administrator:
|
||||||
|
|
||||||
|
1. **Benutzer anlegen**, z. B. `gegenbuch@deine-domain.de`, mit einem langen,
|
||||||
|
zufälligen Passwort.
|
||||||
|
2. Ihm die Rolle **`Gegenbuch`** geben – **nur diese**. Sie bringt genau ein
|
||||||
|
Recht mit: `audit:read`.
|
||||||
|
3. Zusätzlich **„Dienstkonto"** ankreuzen. Dann gelten seine Anmeldungen als
|
||||||
|
Routine statt als kritisches Ereignis – und sein *Ausbleiben* wird gemeldet.
|
||||||
|
4. E-Mail und Passwort in die `.env` des Gegenbuchs eintragen
|
||||||
|
(`PROD_CRM_EMAIL` / `PROD_CRM_PASSWORD`).
|
||||||
|
|
||||||
|
> **Weder den DSGVO- noch den Audit-Betrieb-Haken setzen.** Der DSGVO-Haken
|
||||||
|
> gibt zusätzlich Leserechte auf personenbezogene Daten und den Export; der
|
||||||
|
> Haken „Audit-Betrieb" gibt `audit:admin` mit `seal-backlog`, `rehash` und
|
||||||
|
> `cleanup`. Ein Einbruch auf dieser Maschine hätte damit nicht nur den
|
||||||
|
> Wächter, sondern gleich die Mittel, das Bewachte umzuschreiben. Das Passwort
|
||||||
|
> steht hier im Klartext in der `.env`; es muss so wenig wert sein wie möglich.
|
||||||
|
|
||||||
|
Mit `audit:read` allein kann dieses Konto **nur Prüfwerte lesen** – keine
|
||||||
|
Kundendaten, keine Verträge, nichts ändern und nichts versiegeln. Selbst wenn
|
||||||
|
die Zugangsdaten abhandenkommen, ist damit nichts anzufangen.
|
||||||
|
|
||||||
|
Gegenprobe nach dem Einrichten – **200, dann dreimal 403**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
B="Authorization: Bearer $TOKEN"
|
||||||
|
curl -s -o /dev/null -w 'checkpoint %{http_code}\n' https://<crm>/api/audit-logs/checkpoint -H "$B"
|
||||||
|
curl -s -o /dev/null -w 'export %{http_code}\n' https://<crm>/api/audit-logs/export -H "$B"
|
||||||
|
curl -s -o /dev/null -w 'seal-backlog %{http_code}\n' -X POST https://<crm>/api/audit-logs/seal-backlog -H "$B" -H 'Content-Type: application/json' -d '{}'
|
||||||
|
curl -s -o /dev/null -w 'kunden %{http_code}\n' https://<crm>/api/customers -H "$B"
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Export gehört ausdrücklich dazu: Er liefert `changesBefore`/`changesAfter`,
|
||||||
|
also die vollständigen Vorher/Nachher-Datensätze samt Klartextnamen. Prüfwerte
|
||||||
|
lesen und das Protokoll herausziehen sind zwei verschiedene Dinge – deshalb
|
||||||
|
hängt der Export an `audit:export`, das die Rolle `Gegenbuch` nicht hat.
|
||||||
|
|
||||||
|
Für Produktion und Test jeweils ein eigenes Konto in der jeweiligen Instanz.
|
||||||
|
|
||||||
|
> Jede Anmeldung erscheint im Audit-Log der jeweiligen Instanz. Das ist so
|
||||||
|
> gewollt: Man sieht, dass das Gegenbuch arbeitet – und wenn es aufhört, fällt
|
||||||
|
> auch das auf.
|
||||||
|
|
||||||
|
### Wer redet mit wem
|
||||||
|
|
||||||
|
```
|
||||||
|
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
|
||||||
|
OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist der eigentliche Schutz. Das Gegenbuch **holt** – es lässt sich nichts
|
||||||
|
schicken. OpenCRM kennt weder Adresse noch Schlüssel des Gegenbuchs. Wer
|
||||||
|
OpenCRM übernimmt, kommt hier nicht heran.
|
||||||
|
|
||||||
|
Das Token kann ausschließlich Prüfwerte lesen: keine Kundendaten, keine
|
||||||
|
Änderungen. Selbst wenn es abhandenkommt, ist damit nichts anzufangen.
|
||||||
|
|
||||||
|
Der Signaturschlüssel wird beim ersten Start **auf dem Gegenbuch-Rechner
|
||||||
|
erzeugt** und verlässt ihn nie.
|
||||||
|
|
||||||
|
### Wo die Daten liegen
|
||||||
|
|
||||||
|
Im Projektverzeichnis, nicht in Docker-Volumes:
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/audit-notary/data/prod/
|
||||||
|
buch/ das Gegenbuch selbst (ein Git-Repository)
|
||||||
|
schluessel/ Signaturschlüssel – wird beim ersten Start erzeugt
|
||||||
|
beobachtungen.jsonl was zuletzt gesehen wurde
|
||||||
|
status.txt Ergebnis des letzten Durchlaufs
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Inhalt ist vom Repository ausgenommen – der Signaturschlüssel gehört dort
|
||||||
|
nicht hinein.
|
||||||
|
|
||||||
|
**Zu Dateirechten:** Der Container startet kurz als `root`, setzt das
|
||||||
|
Datenverzeichnis auf den Arbeitsbenutzer um und gibt die Privilegien dann ab.
|
||||||
|
Das ist nötig, weil das Verzeichnis vom Host kommt – wer das Projekt als `root`
|
||||||
|
geklont hat, hätte sonst ein Verzeichnis, in das der Container nicht schreiben
|
||||||
|
darf. Passiert automatisch, du musst nichts tun. Ein anderer Zielbenutzer geht
|
||||||
|
über `PUID`/`PGID`. Die Verzeichnisse selbst sind über `.gitkeep` vorhanden, damit
|
||||||
|
sie nach einem `git clone` schon existieren und Docker sie nicht als `root`
|
||||||
|
anlegt.
|
||||||
|
|
||||||
|
**Ins Backup gehört genau dieses Verzeichnis.** Geht es verloren, beginnt die
|
||||||
|
Beobachtung von vorn – und der nächste Lauf sagt das ausdrücklich, statt „alles
|
||||||
|
gut" zu melden.
|
||||||
|
|
||||||
|
### Überwachung
|
||||||
|
|
||||||
|
Jeder Durchlauf schreibt seinen Stand nach `data/<instanz>/status.txt`:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-08-22T16:49:50+00:00 exit=0 in Ordnung
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alles außer `exit=0` gehört angesehen.** Wer eine Überwachung hat, greift
|
||||||
|
diese Datei ab; wer keine hat, schaut regelmäßig mit `docker compose logs`
|
||||||
|
hinein. Ein Alarm, den niemand liest, ist keiner.
|
||||||
|
|
||||||
|
### Was dieser Betrieb abdeckt – und was nicht
|
||||||
|
|
||||||
|
**Abgedeckt:** Jemand verändert oder löscht nachträglich Einträge im CRM –
|
||||||
|
auch mit direktem Datenbankzugriff. Das widerspricht dem Gegenbuch und fällt
|
||||||
|
beim nächsten Durchlauf auf.
|
||||||
|
|
||||||
|
**Nicht abgedeckt:** Jemand übernimmt den Gegenbuch-Rechner selbst. Dagegen
|
||||||
|
hülfe nur eine zusätzliche Ablage außerhalb (z. B. ein privates Git-Repository
|
||||||
|
auf einem dritten Server) – das ist vorbereitet, aber für die meisten
|
||||||
|
Installationen mehr Aufwand als Nutzen. Die Erfolgsmeldung benennt diese Grenze
|
||||||
|
bei jedem Lauf, damit sie nicht in Vergessenheit gerät.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Zusätzliche Härtung: externes Repository
|
||||||
|
|
||||||
|
> **Alles ab hier gilt nur für die zweite Betriebsart.** Wer das Gegenbuch
|
||||||
|
> lokal auf einer eigenen Maschine betreibt – der Normalfall, siehe oben – kann
|
||||||
|
> diesen gesamten Abschnitt überspringen. Die Anforderungen darin (Git-Server,
|
||||||
|
> Rewind-Sperre, geschützte Refs) beziehen sich auf ein zusätzliches Repository
|
||||||
|
> auf einem dritten Server und existieren im lokalen Betrieb nicht.
|
||||||
|
|
||||||
|
Sinn der Variante: Beim lokalen Betrieb liegt das Buch auf demselben Rechner
|
||||||
|
wie der Signaturschlüssel. Wer diesen Rechner übernimmt, kann beides
|
||||||
|
manipulieren. Ein zusätzliches Repository auf einem dritten Server, das nur
|
||||||
|
Anhängen erlaubt, schließt auch das – vorausgesetzt, dessen Regeln stimmen.
|
||||||
|
|
||||||
|
## Einrichten ohne Docker
|
||||||
|
|
||||||
|
Auf einem **anderen** Rechner als dem CRM-Server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@host:dein/audit-gegenbuch.git
|
||||||
|
cd audit-gegenbuch
|
||||||
|
|
||||||
|
export CRM_URL=https://crm.example.de
|
||||||
|
export CRM_TOKEN=<Access-Token eines Benutzers mit audit:read>
|
||||||
|
|
||||||
|
node /pfad/zu/notary.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Als Cronjob, stündlich:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 * * * * cd /pfad/zum/gegenbuch && CRM_URL=https://crm.example.de \
|
||||||
|
CRM_TOKEN=... node /pfad/notary.mjs >> notary.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Force-Push serverseitig sperren – das ist Pflicht, nicht Empfehlung.**
|
||||||
|
|
||||||
|
Konkret muss der Server **non-fast-forward-Pushes verbieten** (Rewind-Schutz),
|
||||||
|
nicht nur „irgendeine" Branch-Protection. Der Grund ist nicht offensichtlich:
|
||||||
|
|
||||||
|
Das Skript prüft jede Signatur. Aber ein Angreifer mit Force-Push-Recht muss
|
||||||
|
gar nichts fälschen – er kann die Reihe schlicht auf einen **älteren, echt
|
||||||
|
signierten Stand zurückspulen** und die Datenbank passend kürzen. Alle
|
||||||
|
Signaturen bleiben gültig, der Fingerabdruck stimmt, die Nummerierung ist
|
||||||
|
lückenlos. Kryptographisch ist daran nichts auszusetzen; es fehlt nur das Ende.
|
||||||
|
|
||||||
|
Ein Notar-Rechner, der die höhere Nummer noch lokal kennt, merkt es. Ein
|
||||||
|
**frischer Klon merkt es nicht** – und das ist ausgerechnet der Auditoren-Fall.
|
||||||
|
|
||||||
|
Deshalb zusätzlich:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Die zuletzt bekannte Nummer mitgeben – dann fällt ein Rewind auch ohne
|
||||||
|
# lokalen Zustand auf. Das Skript nennt sie am Ende jedes Laufs.
|
||||||
|
NOTARY_MIN_SEQ=42 node notary.mjs --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Der Rewind-Wächter
|
||||||
|
|
||||||
|
Das Skript merkt sich bei jedem Lauf den beobachteten Remote-Kopf in einer
|
||||||
|
Datei **außerhalb des Klons** (Standard `~/.opencrm-notary/beobachtungen.jsonl`,
|
||||||
|
per `NOTARY_STATE_FILE` änderbar). Beim nächsten Lauf muss der neue Kopf ein
|
||||||
|
Nachfahre des alten sein. Ist er das nicht, wurde zurückgespult – und zwar
|
||||||
|
**unabhängig davon, ob es einen serverseitigen Schutz gibt**.
|
||||||
|
|
||||||
|
Das ist der Grund, warum hier *kein* Probe-Push stattfindet: Ein solcher Test
|
||||||
|
würde die eigene Push-Identität messen, nicht die des Angreifers (Bypass-Rechte
|
||||||
|
für Administratoren gehen genau dann auseinander, wenn es zählt), er gälte nur
|
||||||
|
für den geprobten Ref, und die bestätigende Beobachtung wäre derselbe Vorgang
|
||||||
|
wie der Schaden. Deshalb wird das **Ereignis** erkannt statt die Regel
|
||||||
|
abgefragt.
|
||||||
|
|
||||||
|
### Das Gedächtnis liegt im Server, nicht nur auf diesem Rechner
|
||||||
|
|
||||||
|
Jeder verankerte Checkpoint bekommt zusätzlich einen eigenen unveränderlichen
|
||||||
|
Ref `refs/notary/seq-N`. Der überlebt einen Force-Push auf `main`, weil er ein
|
||||||
|
**anderer Ref** ist. Damit lässt sich die höchste je existierende Nummer aus dem
|
||||||
|
Server rekonstruieren – auch wenn der lokale Beobachtungsspeicher fehlt.
|
||||||
|
|
||||||
|
Warum das nötig ist: Läge das Gedächtnis nur lokal, müsste ein Angreifer nach
|
||||||
|
einem Zurückspulen bloß diese Datei löschen. Der nächste Lauf meldete dann
|
||||||
|
„erste Beobachtung" – **ein einziges** Code-3, das im Alltag neben echten
|
||||||
|
Remote-Ausfällen untergeht – und würde anschließend den zurückgespulten Stand
|
||||||
|
als Ausgangspunkt übernehmen. Ab da wäre alles dauerhaft grün. Ein gutartiger
|
||||||
|
Standardzustand, den ein Angreifer gezielt herstellen kann, ist kein sicherer
|
||||||
|
Standardzustand.
|
||||||
|
|
||||||
|
> ### ⚠ Ohne serverseitigen Schutz dieser Refs bringt das Verfahren nichts
|
||||||
|
>
|
||||||
|
> `refs/notary/*` muss auf dem Server **gegen Löschen und Überschreiben**
|
||||||
|
> gesperrt sein. Das ist keine Empfehlung, sondern die tragende Voraussetzung –
|
||||||
|
> genauso wie die Rewind-Sperre auf `main`.
|
||||||
|
>
|
||||||
|
> Der Grund ist eine unangenehme Asymmetrie: Das Skript kennt nur den
|
||||||
|
> **höchsten noch vorhandenen** Anker, nicht den höchsten je erzeugten. Wer
|
||||||
|
> *alle* Anker löscht, fliegt auf (Code 4). Wer nur die **obersten** löscht und
|
||||||
|
> einen niedrigeren stehen lässt, senkt damit den Vergleichswert selbst – und
|
||||||
|
> in-repo ist das von „gab es nie" nicht zu unterscheiden.
|
||||||
|
>
|
||||||
|
> **Die Notar-Identität braucht dort eine eng gefasste Ausnahme:** Sie muss
|
||||||
|
> `refs/notary/*` **anlegen** dürfen, aber ausdrücklich **nicht löschen oder
|
||||||
|
> überschreiben**. Sonst hebelt eine kompromittierte Notar-Identität genau den
|
||||||
|
> Schutz aus, um den es hier geht. Server-seitig ist das unterscheidbar – ein
|
||||||
|
> `pre-receive`-Hook sieht beim Anlegen die Null-OID als alten Wert:
|
||||||
|
>
|
||||||
|
> ```sh
|
||||||
|
> # refs/notary/*: nur anlegen, niemals ändern oder löschen
|
||||||
|
> case "$ref" in refs/notary/*)
|
||||||
|
> [ "$old" = "0000000000000000000000000000000000000000" ] || exit 1 ;;
|
||||||
|
> esac
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Deshalb begründen Anker **keine** Unversehrtheit. Sie können ein
|
||||||
|
> Zurückspulen *widerlegen* (wenn ein hoher Anker nicht mehr im Kopf steckt),
|
||||||
|
> aber nie belegen, dass nichts fehlt. Ein `--check` aus frischem Klon erkennt
|
||||||
|
> eine Anker-Löschung **nicht** – es meldet dann Code 3 mit Vorbehalt, nicht 0.
|
||||||
|
|
||||||
|
### Wenn das Gedächtnis trotzdem fehlt
|
||||||
|
|
||||||
|
Das Skript unterscheidet zwei Fälle – der Unterschied entscheidet alles:
|
||||||
|
|
||||||
|
| Lage | Bedeutung | Verhalten |
|
||||||
|
|---|---|---|
|
||||||
|
| kein Speicher, **keine** notarisierte Historie | echte Erstinbetriebnahme | einmalig `NOTARY_GENESIS_ACK=true`, sonst Code 4 |
|
||||||
|
| kein Speicher, **aber** Historie vorhanden | Gedächtnis ging *nach* der Etablierung verloren | **Code 4**, keine stille Übernahme; erst nach `NOTARY_ADOPT_ACK=true` |
|
||||||
|
|
||||||
|
Eine Baseline zu setzen heißt „ich verbürge mich, *das* ist der wahre Kopf".
|
||||||
|
Dieser Akt wird nicht automatisch wiederholt.
|
||||||
|
|
||||||
|
Zwei Konsequenzen für den Betrieb:
|
||||||
|
|
||||||
|
- **Die Datei gehört nicht in den Klon** und sollte möglichst auf getrenntem
|
||||||
|
Speicher liegen. Geht sie verloren, beginnt die Beobachtung von vorn – der
|
||||||
|
erste Lauf danach meldet ehrlich „erste Beobachtung", nicht „alles gut".
|
||||||
|
- **Der allererste Lauf endet mit Code 3.** Was man nie gesehen hat, kann man
|
||||||
|
nicht vergleichen. Das ist kein Fehler, sondern die ehrliche Auskunft.
|
||||||
|
|
||||||
|
**`NOTARY_MIN_SEQ` ersetzt die serverseitige Sperre nicht.** Der Wert ist eine
|
||||||
|
*Untergrenze* und immer nur so frisch wie deine letzte Beobachtung. Wer
|
||||||
|
stündlich beglaubigt, aber wöchentlich prüft, läuft mit einem Wert herum, der
|
||||||
|
um rund 168 hinterherhinkt – ein Zurückspulen in dieses Fenster bliebe
|
||||||
|
unbemerkt und der gewaschene Stand würde sogar ausdrücklich bestätigt.
|
||||||
|
|
||||||
|
Ist der Rewind-Schutz auf dem Server tatsächlich eingerichtet, bestätige das:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NOTARY_REWIND_PROTECTED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nur diese Zusicherung** blendet den Vorbehalt aus – nicht die bloße Angabe
|
||||||
|
einer Zahl. Solange sie fehlt, endet jeder Lauf mit **Code 3** statt 0:
|
||||||
|
„nicht abschließend feststellbar". Das ist Absicht, denn ein Cronjob liest den
|
||||||
|
Rückgabecode, nicht den Fließtext – ein Hinweis, der nur in der Ausgabe steht,
|
||||||
|
erreicht die Überwachung nie.
|
||||||
|
|
||||||
|
## Der entscheidende Punkt: es wird tatsächlich geprüft
|
||||||
|
|
||||||
|
Die erste Fassung dieses Skripts hat signiert – aber **nie eine Signatur
|
||||||
|
geprüft**. Es las seine Wahrheit aus der lokalen Arbeitsdatei. Wer den
|
||||||
|
Notar-Rechner beschreiben konnte, hat ihm damit beliebige „beglaubigte"
|
||||||
|
Zeilen untergeschoben und einen abgeschnittenen Datenbestand als neue Wahrheit
|
||||||
|
festgeschrieben, ohne Alarm. Signieren ohne prüfenden Leser ist wertlos.
|
||||||
|
|
||||||
|
Deshalb gilt jetzt:
|
||||||
|
|
||||||
|
- Wahrheitsquelle ist der **signierte Commit-Baum** (bevorzugt der Remote-Kopf),
|
||||||
|
nicht die Arbeitsdatei
|
||||||
|
- **Jeder** Commit, der das Gegenbuch ändert, muss eine gültige Signatur tragen
|
||||||
|
- Weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen
|
||||||
|
- Geschrieben wird erst nach erfolgreichem, signiertem Commit – scheitert er,
|
||||||
|
wird die Datei zurückgerollt
|
||||||
|
|
||||||
|
## Voraussetzungen, die wirklich Voraussetzungen sind
|
||||||
|
|
||||||
|
- **`allowed_signers` muss den Notar-Schlüssel enthalten.** Das Skript
|
||||||
|
akzeptiert ausschließlich Signaturen, die git als `G` bewertet – bei
|
||||||
|
SSH-Signaturen heißt das wörtlich „Schlüssel steht in `allowed_signers`".
|
||||||
|
Ein fremder, selbst erzeugter Schlüssel liefert `U` und wird abgelehnt.
|
||||||
|
*(Bei GPG statt SSH liefert auch ein legitimer Schlüssel ohne Ownertrust ein
|
||||||
|
`U` – dann Ownertrust setzen, nicht `U` wieder zulassen.)*
|
||||||
|
- **Ein Remote muss erreichbar sein.** Ist er es nicht, bricht das Skript mit
|
||||||
|
Code 3 ab, statt dem lokalen Stand zu glauben. Ein Gegenbuch, dessen
|
||||||
|
beglaubigter Stand sich nicht feststellen lässt, gibt kein grünes Licht.
|
||||||
|
- **Genau ein Schlüssel zählt, nicht die ganze Liste.** `allowed_signers` kann
|
||||||
|
mehrere Principals enthalten – ein Kollege, ein Alt-Schlüssel, eine global
|
||||||
|
geteilte Datei. Jeder davon würde sonst als „gültig" durchgehen und könnte
|
||||||
|
das Gegenbuch umschreiben. Deshalb prüft das Skript zusätzlich den
|
||||||
|
**Fingerabdruck** des Signierschlüssels.
|
||||||
|
|
||||||
|
Er wird automatisch aus `user.signingkey` abgeleitet. Lässt er sich nicht
|
||||||
|
bestimmen, bricht das Skript ab – statt stillschweigend die ganze Liste zu
|
||||||
|
akzeptieren. Explizit setzen geht auch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NOTARY_SIGNER_FINGERPRINT=$(git log -1 --format=%GF)
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtig: exakt der `%GF`-Wert (`SHA256:…`), nicht die vollständige Ausgabe
|
||||||
|
von `ssh-keygen -lf`.
|
||||||
|
|
||||||
|
## Rückgabecodes
|
||||||
|
|
||||||
|
| Code | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| 0 | alles in Ordnung, Checkpoint angehängt (bzw. Prüfung bestanden) |
|
||||||
|
| 1 | Betriebsfehler (Konfiguration, Commit oder Push fehlgeschlagen) |
|
||||||
|
| 2 | **Befund** – Widerspruch zwischen CRM und Gegenbuch, ungültige Signatur, oder die Wurzel des Bestandssiegels hat sich geändert (`NOTARY_SEAL_ACK`) |
|
||||||
|
| 5 | **Anker unvollständig** – die Kette ist gültig, aber `refs/notary/seq-N` fehlt. Reparierbar durch einen Notar-Schreiblauf |
|
||||||
|
| 4 | **Wächter-Gedächtnis fehlt** – Erstinbetriebnahme unbestätigt, oder Speicher nach der Etablierung verloren |
|
||||||
|
| 3 | beglaubigter Stand nicht abschließend feststellbar – Remote fehlt/unerreichbar, erste Beobachtung, Zurückspulen nicht ausschließbar, **oder** Checkpoint erstellt aber nicht verankert |
|
||||||
|
|
||||||
|
Für Cron gilt: **jeder** Code außer 0 gehört gemeldet. Code 2 ist der Alarm,
|
||||||
|
Code 3 heißt „ich weiß es nicht" – und das ist ausdrücklich kein Freibrief.
|
||||||
|
|
||||||
|
## Prüfmodus für Auditoren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node notary.mjs --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Führt alle Kontrollen aus, **verändert nichts** und braucht kein Schreibrecht –
|
||||||
|
auch dann nicht, wenn dabei ein fehlender Anker auffällt. Der wird gemeldet
|
||||||
|
(Code 5), nicht repariert; Reparieren ist Sache des Notar-Laufs.
|
||||||
|
|
||||||
|
Das ist kein Detail: Die Serversperre für `refs/notary/*` lehnt Schreibzugriffe
|
||||||
|
ja gerade ab. Würde der Prüfmodus dort nachtragen wollen, bekäme jeder
|
||||||
|
read-only prüfende Auditor dauerhaft einen Fehler auf einer völlig gültigen
|
||||||
|
Kette – und könnte ihn nicht beheben.
|
||||||
|
|
||||||
|
## Anhängen ist selbst ein Beglaubigungsakt
|
||||||
|
|
||||||
|
Ein Schreiblauf erweitert nicht nur die Kette – er signiert damit zugleich über
|
||||||
|
alles darunter. Deshalb prüft das Skript **vor** dem Anhängen und verweigert
|
||||||
|
es, wenn die Grundlage nicht feststeht. Sonst wäre das Anhängen selbst das
|
||||||
|
Waschmittel: eine frische Signatur über einen ungeklärten Vorzustand beglaubigt
|
||||||
|
diesen mit.
|
||||||
|
|
||||||
|
Ebenso gilt: **erstellt ist nicht verankert.** Klappt der Push nicht, endet der
|
||||||
|
Lauf mit Code 3 und der ausdrücklichen Auskunft „erstellt, aber NICHT
|
||||||
|
verankert" – niemals mit 0. Der nächste Lauf hält den ungepushten Commit dann
|
||||||
|
an, bis er geklärt ist.
|
||||||
|
|
||||||
|
## Was das Skript erkennt
|
||||||
|
|
||||||
|
| Angriff | Erkennung |
|
||||||
|
|---|---|
|
||||||
|
| Beglaubigter Eintrag nachträglich verändert | Hash an der beglaubigten Position stimmt nicht mehr |
|
||||||
|
| Einträge am Ende abgeschnitten | aktuelle höchste ID kleiner als die beglaubigte |
|
||||||
|
| Bestandssiegel verschwunden | vorher beglaubigt, jetzt nicht mehr vorhanden |
|
||||||
|
| Gegenbuch selbst gekürzt | Lücke in der fortlaufenden Nummer |
|
||||||
|
| **Reihe zurückgespult (Force-Push)** | **Remote-Kopf ist kein Nachfahre des zuletzt beobachteten** |
|
||||||
|
| Gegenbuch lokal manipuliert | Arbeitsdatei weicht vom signierten Stand ab |
|
||||||
|
| Untergeschobener Commit | Commit ohne gültige Signatur in der Historie |
|
||||||
|
| Nie gepushte lokale Commits | Abgleich gegen den Remote-Kopf |
|
||||||
|
| Bestandssiegel-Blätter entfernt | beglaubigte Blattzahl auf null gefallen |
|
||||||
|
| **Altzeile gelöscht und neu gesiegelt** (Wäsche) | **Wurzel des Bestandssiegels hat gewechselt – `valid` allein bleibt dabei `true`** |
|
||||||
|
| Erstmals gesiegelt, ohne dass es jemand veranlasst hat | vorher keine Wurzel beglaubigt, jetzt eine |
|
||||||
|
| **`cleanup` + `rehash` ohne erneutes Siegeln** (Wäsche ohne Datenbankzugriff) | **neue Neuberechnung seit der letzten Beglaubigung – `valid` und Kette sind danach makellos** |
|
||||||
|
|
||||||
|
Bei jedem dieser Fälle bricht das Skript mit **Exit-Code 2** ab und **hängt
|
||||||
|
nichts an** – der manipulierte Zustand wird also nicht als neue Wahrheit
|
||||||
|
festgeschrieben. Für Cron heißt das: Exit-Code überwachen, sonst geht der Alarm
|
||||||
|
im Log unter.
|
||||||
|
|
||||||
|
## Was es nicht leistet – ehrlich
|
||||||
|
|
||||||
|
- **Restfenster:** Einträge zwischen dem letzten Kontrollwert und jetzt sind
|
||||||
|
noch nicht beglaubigt und am Ende fälsch- oder kürzbar. Häufigerer Lauf
|
||||||
|
verkleinert das Fenster, beseitigt es aber nicht. Das gilt für jedes
|
||||||
|
periodische Verfahren dieser Art.
|
||||||
|
- **Stiller Ausfall:** Läuft der Cronjob nicht mehr, entsteht keine Warnung im
|
||||||
|
CRM – dort ist ja nichts konfiguriert. Der Ausfall muss auf dem
|
||||||
|
Gegenbuch-Rechner überwacht werden (z. B. Alarm, wenn der jüngste Commit
|
||||||
|
älter als zwei Intervalle ist).
|
||||||
|
- **`NOTARY_SIGN=false`** existiert nur für Tests ohne hinterlegten Schlüssel
|
||||||
|
und verlangt zusätzlich `NOTARY_INSECURE_ACK=…`. Der Reflex „Cron hakt, also
|
||||||
|
Signatur abschalten" soll bewusst nicht ausreichen – er würde eine
|
||||||
|
ungeschützte Kette festschreiben.
|
||||||
|
- **Der Remote-Server muss Force-Push verhindern.** Das Skript prüft die
|
||||||
|
Signaturen, aber wenn jemand die Historie am Server überschreiben kann,
|
||||||
|
ersetzt er auch die signierten Commits durch eigene. Branch-Protection ist
|
||||||
|
Teil des Schutzes, nicht Zubehör.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Hier liegen die Gegenbuch-Daten (je Instanz ein Unterverzeichnis):
|
||||||
|
|
||||||
|
data/prod/ Buch, Signaturschlüssel, Beobachtungsspeicher, status.txt
|
||||||
|
data/staging/ dasselbe für die Testinstanz
|
||||||
|
|
||||||
|
Der Inhalt wird bewusst NICHT im Repository geführt – er enthält den
|
||||||
|
Signaturschlüssel. Dieses Verzeichnis gehört ins Backup.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user