Compare commits
121
Commits
3580c51cb1
..
main
+5
-1
@@ -12,7 +12,7 @@ backend/dist
|
||||
.vscode
|
||||
.idea
|
||||
*.md
|
||||
!docker/README.md
|
||||
|
||||
|
||||
# Environment files (use Docker environment instead)
|
||||
.env
|
||||
@@ -37,6 +37,10 @@ Thumbs.db
|
||||
__tests__
|
||||
coverage
|
||||
|
||||
# Pentester-/Dev-Helferscripte gehören NICHT ins Prod-Image (nur lokal/Staging
|
||||
# via Repo-Checkout ausführen). Erzeugen sonst nur toten Ballast im Container.
|
||||
backend/scripts/seed-magic-byte-test.ts
|
||||
|
||||
# Docker files in wrong location
|
||||
docker-compose.yml
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ DB_DATA_DIR=./data/db
|
||||
UPLOADS_DIR=./data/uploads
|
||||
FACTORY_DEFAULTS_DIR=./data/factory-defaults
|
||||
BACKUPS_DIR=./data/backups
|
||||
# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen)
|
||||
BANKDATA_DIR=./data/bankdata
|
||||
|
||||
# ============== DATENBANK ==============
|
||||
# Der App-User (DB_USER) wird beim ersten Start automatisch von MariaDB
|
||||
@@ -51,6 +53,51 @@ JWT_REFRESH_EXPIRES_IN=7d
|
||||
# Generieren: openssl rand -hex 32
|
||||
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
|
||||
|
||||
# ---------- Optionaler Reverse-Proxy (Caddy) ----------
|
||||
# Brauchst du nur, wenn du KEINEN eigenen Reverse-Proxy hast (nginx, Nginx
|
||||
# Proxy Manager, Traefik) und Caddy das SSL übernehmen soll.
|
||||
#
|
||||
# EIN/AUS über diesen Schalter – nichts auskommentieren:
|
||||
# leer -> Caddy AUS, wird nicht einmal angelegt ← Standard
|
||||
# caddy -> Caddy startet bei jedem `docker-compose up -d` mit
|
||||
# Der Schalter wirkt auch bei `down`, `logs` usw. – im Gegensatz zum
|
||||
# Kommandozeilen-Flag `--profile caddy`, das man leicht vergisst.
|
||||
COMPOSE_PROFILES= # leer lassen = Caddy aus
|
||||
|
||||
# Die folgenden drei Werte werden NUR gebraucht, wenn oben `caddy` steht.
|
||||
# Ohne Caddy kannst du sie ignorieren.
|
||||
DOMAIN=crm.example.com
|
||||
CADDY_DIR=./data/caddy
|
||||
CADDY_CONFIG_DIR=./data/caddy-config
|
||||
|
||||
# ---------- Siegel für das Audit-Log ----------
|
||||
# OpenCRM protokolliert, wer wann welche Daten gesehen oder geändert hat – die
|
||||
# Beweisspur bei Streit oder Datenschutz-Nachfragen. Dieser Schlüssel versieht
|
||||
# jeden neuen Eintrag mit einem Siegel, das sich nur damit erzeugen lässt. Er
|
||||
# liegt AUSSERHALB der Datenbank; wer nur die Datenbank in die Hände bekommt,
|
||||
# kann damit nichts fälschen, ohne dass es auffliegt.
|
||||
#
|
||||
# Generieren: openssl rand -hex 32 (pro Umgebung ein eigener Wert)
|
||||
#
|
||||
# Ohne Wert läuft alles normal weiter, nur eben ungesiegelt – nichts fällt aus.
|
||||
# Nachträglich setzen ist jederzeit möglich: bestehende Einträge bleiben gültig,
|
||||
# ab dem Neustart werden neue gesiegelt (keine Fehlalarme dadurch).
|
||||
#
|
||||
# ACHTUNG – einmal gesetzt, nicht wieder entfernen: Einträge, die ohne Schlüssel
|
||||
# entstehen, werden beanstandet, sobald er zurück ist (ein ungesiegelter Eintrag
|
||||
# inmitten gesiegelter ist von einer Fälschung nicht zu unterscheiden).
|
||||
# Schlüssel sichern wie ein Passwort – geht er verloren, sind die damit
|
||||
# gesiegelten Einträge dauerhaft nicht mehr prüfbar.
|
||||
# Ausführlich erklärt in der README, Abschnitt "Audit-Siegel".
|
||||
AUDIT_HMAC_KEY=
|
||||
|
||||
# Nur beim Schlüsselwechsel füllen: die FRÜHEREN Schlüssel, kommagetrennt und
|
||||
# jüngster zuerst. Damit bleiben die bisherigen Einträge prüfbar, während neue
|
||||
# schon mit dem neuen Schlüssel gesiegelt werden. Nicht voreilig leeren – sonst
|
||||
# werden die alten Einträge als "manipuliert" gemeldet.
|
||||
# Beispiel: AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>
|
||||
AUDIT_HMAC_KEY_OLD=
|
||||
|
||||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
|
||||
|
||||
+12
@@ -36,6 +36,18 @@ data/factory-defaults/*
|
||||
data/backups/*
|
||||
!data/backups/.gitkeep
|
||||
|
||||
# Gegenbuch (laeuft auf eigener Maschine): enthaelt das Buch selbst, den
|
||||
# Signaturschluessel und den Beobachtungsspeicher. Nichts davon gehoert ins
|
||||
# Repository - der Schluessel schon gar nicht.
|
||||
tools/audit-notary/data/*
|
||||
!tools/audit-notary/data/.gitkeep
|
||||
!tools/audit-notary/data/prod/
|
||||
tools/audit-notary/data/prod/*
|
||||
!tools/audit-notary/data/prod/.gitkeep
|
||||
!tools/audit-notary/data/staging/
|
||||
tools/audit-notary/data/staging/*
|
||||
!tools/audit-notary/data/staging/.gitkeep
|
||||
|
||||
# Factory-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
|
||||
factory-exports/*
|
||||
!factory-exports/.gitkeep
|
||||
|
||||
@@ -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 –
|
||||
> siehe [Production-Deployment](#production-deployment).
|
||||
|
||||
### Zugangsdaten auf bestehender DB neu setzen (Reseed)
|
||||
|
||||
Nützlich z.B. auf **Staging/Test nach einem DB-Reset**, wenn Admin-/Portal-
|
||||
Passwörter randomisiert sind und man wieder **bekannte** Zugangsdaten braucht
|
||||
(etwa für einen Pentester).
|
||||
|
||||
**Der Seed ist datenerhaltend:** Er `upsert`et nur die Rollen und den
|
||||
Admin-User (`admin@admin.com`) und synchronisiert die Rollen-Berechtigungen.
|
||||
**Kundendaten, Verträge, Bankkarten usw. werden NICHT angefasst** (das einzige
|
||||
`deleteMany` betrifft ausschließlich Rollen↔Permission-Zuordnungen). Ein
|
||||
erneuter Seed setzt also faktisch nur das Admin-Passwort neu.
|
||||
|
||||
**Admin-Passwort deterministisch neu setzen:**
|
||||
|
||||
```bash
|
||||
# 1) In der .env / docker-compose environment setzen:
|
||||
SEED_ADMIN_PASSWORD=<ein dir bekanntes Passwort, MIND. 25 Zeichen>
|
||||
RUN_SEED=true # erzwingt den Seed auch bei nicht-leerer DB
|
||||
|
||||
# 2) Container neu starten – der Entrypoint seedet erneut (upsert):
|
||||
docker compose up -d
|
||||
|
||||
# 3) Login: admin@admin.com / <SEED_ADMIN_PASSWORD>
|
||||
# 4) Danach RUN_SEED wieder auf false (sonst re-seedet jeder Restart – harmlos,
|
||||
# aber unnötig). SEED_ADMIN_PASSWORD kann gesetzt bleiben.
|
||||
```
|
||||
|
||||
> ⚠️ `SEED_ADMIN_PASSWORD` **muss ≥ 25 Zeichen** sein, sonst wird es ignoriert
|
||||
> (Passwort-Policy) und der Seed generiert wieder ein Zufallspasswort (→ Logs,
|
||||
> siehe oben).
|
||||
|
||||
**Portal-Login (Kunde):** Portal-Passwörter werden **nicht** vom Seed gesetzt.
|
||||
Nach dem Admin-Login lässt sich in der Kundenakte für einen (Test-)Kunden das
|
||||
Portal-Passwort im UI **anzeigen/neu setzen** – damit hat man wieder einen
|
||||
bekannten Portal-Zugang für Scoping-Tests.
|
||||
|
||||
## Production-Deployment
|
||||
|
||||
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
|
||||
@@ -245,6 +281,10 @@ LISTEN_ADDR=127.0.0.1
|
||||
|
||||
# Bei separatem Frontend-Host: erlaubte Origins
|
||||
CORS_ORIGINS=https://crm.deine-domain.de
|
||||
|
||||
# Siegel fuer das Audit-Log – schuetzt die Beweisspur vor nachtraeglicher
|
||||
# Faelschung. Siehe Abschnitt "Audit-Siegel" weiter unten.
|
||||
AUDIT_HMAC_KEY=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
### Deployment-Modus: On-Prem vs. Cloud
|
||||
@@ -262,6 +302,279 @@ SSRF-Schalter:
|
||||
SSRF_BLOCK_PRIVATE_IPS=true
|
||||
```
|
||||
|
||||
### Audit-Siegel (`AUDIT_HMAC_KEY`)
|
||||
|
||||
**Worum geht es?**
|
||||
OpenCRM führt ein Audit-Log: eine lückenlose Aufzeichnung, wer wann welche
|
||||
Daten gesehen oder geändert hat. Das ist die Beweisspur, wenn es Streit gibt,
|
||||
etwas verschwindet oder der Datenschutz nachfragt.
|
||||
|
||||
Damit diese Aufzeichnung etwas wert ist, muss man ihr ansehen können, ob
|
||||
jemand nachträglich daran herumgeschrieben hat. Dafür trägt jeder Eintrag
|
||||
einen Fingerabdruck, der auch den Fingerabdruck des vorherigen Eintrags
|
||||
enthält – wie eine Kette. Ändert jemand einen alten Eintrag, passen die
|
||||
Fingerabdrücke nicht mehr und es fällt auf.
|
||||
|
||||
**Und wozu dann noch ein Schlüssel?**
|
||||
Weil jemand mit Zugriff auf die Datenbank die ganze Kette neu berechnen
|
||||
könnte. Er ändert also einen Eintrag – zum Beispiel einen fehlgeschlagenen
|
||||
Login-Versuch in einen erfolgreichen – und zieht anschließend alle
|
||||
Fingerabdrücke glatt. Danach sieht die Fälschung echt aus.
|
||||
|
||||
Mit `AUDIT_HMAC_KEY` bekommt jeder Eintrag zusätzlich ein **Siegel**, das sich
|
||||
nur mit diesem Schlüssel erzeugen lässt. Der Schlüssel liegt in der
|
||||
`.env`-Datei, also **außerhalb der Datenbank**. Wer nur die Datenbank in die
|
||||
Hände bekommt, kann damit nichts fälschen, ohne dass es auffliegt.
|
||||
|
||||
> Bildlich: Die Fingerabdruck-Kette ist die fortlaufende Nummerierung der
|
||||
> Aktenseiten. Der Schlüssel ist der Stempel des Notars. Die Akte steht zwar
|
||||
> im Schrank – aber den Stempel hat nur der Notar.
|
||||
|
||||
**Einrichten**
|
||||
|
||||
```bash
|
||||
# Einmalig pro Umgebung einen Schlüssel erzeugen
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**In welche Datei?** Das hängt davon ab, wie du OpenCRM betreibst:
|
||||
|
||||
| Betrieb | Datei |
|
||||
|---|---|
|
||||
| **Docker** (Standard) | **`.env` im Projektstamm** |
|
||||
| Backend direkt, ohne Container (Entwicklung) | `backend/.env` |
|
||||
|
||||
Beim Docker-Betrieb reicht der Eintrag in der Stamm-`.env` – `docker-compose.yml`
|
||||
reicht die Variable an den Container weiter. Ein Eintrag in `backend/.env` wirkt
|
||||
dort **nicht**, weil der Container diese Datei nicht liest.
|
||||
|
||||
Wichtig: **pro Umgebung ein eigener Schlüssel** (Entwicklung, Test, Produktion)
|
||||
– und niemals ins Git-Repository.
|
||||
|
||||
**Bestehende Installation: bisher kein Schlüssel gesetzt?**
|
||||
|
||||
Genau so ist es gedacht – der Schlüssel kam mit einem Update dazu. Du kannst
|
||||
ihn jederzeit nachträglich setzen:
|
||||
|
||||
1. Schlüssel erzeugen (`openssl rand -hex 32`), in die `.env` eintragen
|
||||
2. Backend neu starten
|
||||
3. Fertig – mehr ist nicht nötig
|
||||
|
||||
Was dabei mit deinen **bereits vorhandenen** Einträgen passiert:
|
||||
|
||||
- Sie bleiben **unverändert** und weiterhin gültig. Es wird nichts neu
|
||||
berechnet und nichts nachträglich gesiegelt.
|
||||
- Ab dem Neustart werden **neue** Einträge gesiegelt. Alt und neu liegen
|
||||
friedlich nebeneinander, die Prüfung erkennt für jeden Eintrag automatisch,
|
||||
nach welchem Verfahren er zu prüfen ist.
|
||||
- Es gibt **keine** Fehlalarme dadurch. (Nachgemessen auf einer Installation
|
||||
mit 4903 alten, 57 mittleren und 67 gesiegelten Einträgen: 0 Beanstandungen.)
|
||||
|
||||
Rückwirkend lässt sich der Schutz nicht herstellen – ältere Einträge bleiben
|
||||
ungesiegelt. Das ist kein Fehler, sondern liegt in der Natur der Sache: Ein
|
||||
Siegel kann man nur beim Anlegen vergeben, nicht nachträglich. Je früher du
|
||||
den Schlüssel setzt, desto größer der geschützte Zeitraum.
|
||||
|
||||
> ⚠️ **Einmal gesetzt, nicht wieder abschalten.** Fehlt der Schlüssel später
|
||||
> (z. B. weil er beim Container-Rebuild aus der `.env` verlorenging), läuft die
|
||||
> Anwendung normal weiter – aber die in dieser Zeit entstandenen Einträge sind
|
||||
> ungesiegelt und werden von der Prüfung **beanstandet**, sobald der Schlüssel
|
||||
> wieder da ist. Das ist Absicht: Ein ungesiegelter Eintrag inmitten
|
||||
> gesiegelter ist von einer Fälschung nicht zu unterscheiden. Nimm den
|
||||
> Schlüssel deshalb in deine Deploy-Checkliste auf.
|
||||
|
||||
Ein noch nicht ersetzter Platzhalter (`<hier-eigenen-wert-eintragen>`) zählt
|
||||
bewusst **nicht** als Schlüssel – sonst würde mit einem öffentlich bekannten
|
||||
Wert gesiegelt. Das Backend schreibt in diesem Fall eine Warnung ins Log und
|
||||
lässt das Siegel aus.
|
||||
|
||||
**Häufige Fragen**
|
||||
|
||||
| Frage | Antwort |
|
||||
|---|---|
|
||||
| Was, wenn ich den Schlüssel gar nicht setze? | Nichts fällt aus. Das Audit-Log läuft normal weiter, nur ohne dieses zusätzliche Siegel. |
|
||||
| Was, wenn ich ihn verliere? | Lässt du das Feld dann **leer**, gelten die gesiegelten Einträge als **„nicht prüfbar"** – ausdrücklich nicht als gefälscht, also kein Fehlalarm. Der Nachweis für diesen Zeitraum ist aber weg. Deshalb: sichern wie ein Passwort. |
|
||||
| Und wenn ich stattdessen einen **neuen** Schlüssel eintrage? | Dann werden die alten Einträge als **„manipuliert" gemeldet** – das System kann einen falschen Schlüssel nicht von einer echten Fälschung unterscheiden. Trage den alten Schlüssel deshalb zusätzlich unter `AUDIT_HMAC_KEY_OLD` ein (siehe unten). |
|
||||
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
|
||||
| Verlangsamt das etwas? | Nein, spürbar nicht. |
|
||||
|
||||
**Wichtig bei einer bestehenden Installation: den Altbestand versiegeln**
|
||||
|
||||
Das Siegel gilt nur für Einträge, die **ab** dem Setzen des Schlüssels
|
||||
geschrieben werden. Alles, was vorher im Protokoll steht, bleibt ungeschützt –
|
||||
Änderungen daran wären nicht erkennbar. Die Integritätsprüfung sagt das auch:
|
||||
|
||||
Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären
|
||||
nicht erkennbar. Behebbar mit POST /api/audit-logs/seal-backlog
|
||||
{"confirm":"SEAL"}.
|
||||
|
||||
Das ist ein **einmaliger** Schritt: Er zieht ein Siegel über den vorhandenen
|
||||
Bestand, sodass spätere Änderungen daran auffallen. Der Ablauf – wer es darf
|
||||
(Haken **„Audit-Betrieb"** in der Benutzerverwaltung), was vorher zu prüfen ist
|
||||
und wie man das Ergebnis gegenprüft – steht Schritt für Schritt unter
|
||||
**[Den Altbestand versiegeln](tools/audit-notary/README.md#den-altbestand-versiegeln-einmalig-im-crm)**.
|
||||
Die Anleitung liegt beim Gegenbuch, gilt aber **auch ohne** – es ist ein
|
||||
Vorgang im CRM.
|
||||
|
||||
Den Zustand siehst du jederzeit unter **Einstellungen → Audit-Protokoll**, ganz
|
||||
oben.
|
||||
|
||||
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
|
||||
|
||||
Möchtest du den Schlüssel austauschen – etwa weil du vermutest, dass er in
|
||||
falsche Hände geraten ist – geht das ohne Datenverlust:
|
||||
|
||||
```env
|
||||
AUDIT_HMAC_KEY=<neuer Schlüssel>
|
||||
AUDIT_HMAC_KEY_OLD=<bisheriger Schlüssel>
|
||||
```
|
||||
|
||||
Neue Einträge werden ab sofort mit dem neuen Schlüssel gesiegelt, die
|
||||
bisherigen bleiben über den alten Schlüssel weiterhin prüfbar.
|
||||
|
||||
> ⚠️ **`AUDIT_HMAC_KEY_OLD` nicht voreilig leeren.** Solange Einträge
|
||||
> existieren, die mit einem alten Schlüssel gesiegelt wurden, muss dieser dort
|
||||
> stehen bleiben. Entfernst du ihn, werden diese Einträge als **„manipuliert"
|
||||
> gemeldet** – nicht als „nicht prüfbar". Das System kann einen falschen
|
||||
> Schlüssel nicht von einer echten Fälschung unterscheiden. Leeren kannst du
|
||||
> das Feld gefahrlos erst, wenn die betroffenen Einträge durch die
|
||||
> Aufbewahrungsfristen ohnehin gelöscht sind.
|
||||
|
||||
**Mehrfach gewechselt?** Das Feld nimmt mehrere Schlüssel kommagetrennt auf –
|
||||
vom jüngsten zum ältesten:
|
||||
|
||||
```env
|
||||
AUDIT_HMAC_KEY=<aktueller Schlüssel>
|
||||
AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>,<ganz alter>
|
||||
```
|
||||
|
||||
Ohne das würde beim zweiten Wechsel der zuerst genutzte Schlüssel verloren
|
||||
gehen – und alle damit gesiegelten Einträge fälschlich als manipuliert
|
||||
erscheinen.
|
||||
|
||||
**Prüfen, ob alles in Ordnung ist**
|
||||
|
||||
Die Prüfung läuft derzeit nur über die API (eine Schaltfläche in der Oberfläche
|
||||
gibt es dafür noch nicht) – als angemeldeter Benutzer mit dem Recht
|
||||
`audit:read`:
|
||||
|
||||
```bash
|
||||
curl -X POST https://crm.deine-domain.de/api/audit-logs/verify \
|
||||
-H "Authorization: Bearer <Access-Token>"
|
||||
```
|
||||
|
||||
Die Antwort enthält einen Klartext-Satz im Feld `message` und unterscheidet
|
||||
zwei Dinge:
|
||||
|
||||
- **„Manipulierte Einträge"** – jemand hat einen bestehenden Eintrag
|
||||
nachträglich verändert. Das ist ernst.
|
||||
- **„Strukturelle Lücken"** – die Kette hat eine Unterbrechung, die Inhalte
|
||||
sind aber unverändert. Meist harmlos (z. B. gelöschte alte Einträge).
|
||||
Notiere dir die Zahl nach dem ersten Deploy: Bleibt sie konstant, ist alles
|
||||
in Ordnung. Steigt sie, lohnt ein Blick.
|
||||
|
||||
### Gegenbuch – optionaler Zusatzschutz auf zweitem Rechner
|
||||
|
||||
Das Audit-Siegel schützt die Einträge **innerhalb** der Anwendung. Es liegt aber
|
||||
in derselben Datenbank, die es absichert: Wer vollen Zugriff auf den Server hat,
|
||||
kommt am Ende auch an das Siegel.
|
||||
|
||||
Dagegen gibt es das **Gegenbuch**. Ein zweiter Rechner holt regelmäßig einen
|
||||
kurzen Kontrollwert vom CRM und schreibt ihn mit. Wird später im CRM etwas
|
||||
nachträglich verändert oder gelöscht, widerspricht das dem Gegenbuch und fällt
|
||||
beim nächsten Durchlauf auf.
|
||||
|
||||
Die Richtung ist dabei entscheidend:
|
||||
|
||||
```
|
||||
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
|
||||
OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
|
||||
```
|
||||
|
||||
Wer OpenCRM übernimmt, kommt damit nicht an das Gegenbuch. Das CRM braucht
|
||||
dafür **keinerlei Konfiguration** – es liefert nur einen lesbaren Prüfwert, der
|
||||
keine Geheimnisse enthält.
|
||||
|
||||
**Einrichten** (auf einem anderen Rechner als dem CRM):
|
||||
|
||||
```bash
|
||||
git clone <dieses Repository> opencrm
|
||||
cd opencrm/tools/audit-notary
|
||||
cp .env.example .env # CRM-Adresse und Dienstkonto eintragen
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Vorher im CRM anlegen:** eine Rolle mit ausschließlich dem Recht
|
||||
`audit:read` und einen Benutzer damit. Das Gegenbuch meldet sich mit diesem
|
||||
Konto bei jedem Durchlauf selbst an – ein dauerhaftes Token gibt es nicht, weil
|
||||
Zugangstoken nach 15 Minuten ablaufen.
|
||||
|
||||
Zwei Bücher auf einer Maschine – etwa für Produktion und Test – sind
|
||||
vorgesehen. Alles Weitere in
|
||||
[tools/audit-notary/README.md](tools/audit-notary/README.md).
|
||||
|
||||
> **Optional.** Ohne Gegenbuch bleibt der Schutz innerhalb der Anwendung
|
||||
> vollständig erhalten. Es deckt zusätzlich den Fall ab, dass jemand den
|
||||
> CRM-Server selbst übernimmt.
|
||||
|
||||
<details>
|
||||
<summary><b>Technische Details</b> (für Entwickler/Admins)</summary>
|
||||
|
||||
**Verfahren.** Jeder Audit-Eintrag trägt einen Hash über seine Inhaltsspalten
|
||||
plus den Hash des Vorgängers (`previousHash`) – daraus entsteht die Kette. Es
|
||||
gibt drei Hash-Versionen, die Spalte `hashVersion` hält fest, welche verwendet
|
||||
wurde:
|
||||
|
||||
| Version | Verfahren | Abdeckung |
|
||||
|---|---|---|
|
||||
| 1 | SHA-256 | 7 Felder – Altbestand vor der Härtung |
|
||||
| 2 | SHA-256 | alle 24 Inhaltsspalten, unsigniert |
|
||||
| 3 | **HMAC-SHA256** mit `AUDIT_HMAC_KEY` | alle 24 Inhaltsspalten, signiert |
|
||||
|
||||
Bestandsdaten werden **nicht** nachträglich neu berechnet – ein Rehash würde die
|
||||
Beweiskraft der Vergangenheit überschreiben. Alte Einträge bleiben mit ihrer
|
||||
Version gültig.
|
||||
|
||||
**Version-Floor.** Die zu erwartende Version wird aus der Kette abgeleitet
|
||||
(erste je mit Version *n* geschriebene Zeile), **nicht** aus der Selbstauskunft
|
||||
der Zeile. Sonst ließe sich per Downgrade (`hashVersion` 3 → 1) die schwächere
|
||||
Prüfung erzwingen und anschließend ein gültiger Hash über die wenigen
|
||||
abgedeckten Felder nachziehen. Zusätzlich gilt: Eine unerklärte Lücke direkt vor
|
||||
einer signierten Zeile ist ein Befund, kein struktureller Zufall – deren
|
||||
`previousHash` lässt sich ohne Schlüssel nicht fälschen.
|
||||
|
||||
**Löschungen.** `POST /api/audit-logs/cleanup` schreibt ein Manifest
|
||||
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag. Die
|
||||
Prüfung liest es aus und meldet nur Lücken **ohne** dokumentierte Löschung als
|
||||
erklärungsbedürftig (`unexplainedGaps`) – sonst könnte sich eine böswillige
|
||||
Löschung als harmlose Lücke tarnen.
|
||||
|
||||
**Rückgabe von `POST /api/audit-logs/verify`:**
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|---|---|
|
||||
| `tamperedEntries` | Inhalt nachträglich verändert, Versions-Downgrade oder gebrochener Anker – **ernst** |
|
||||
| `chainGaps` | Verkettung unterbrochen (alle) |
|
||||
| `unexplainedGaps` | Teilmenge davon ohne dokumentierte Löschung |
|
||||
| `unverifiableEntries` | signiert, aber kein Schlüssel konfiguriert – **kein** Manipulationsverdacht |
|
||||
|
||||
**Fail-safe.** Ohne `AUDIT_HMAC_KEY` schreibt der Dienst weiter Version 2; das
|
||||
Logging fällt nie wegen fehlender Konfiguration aus.
|
||||
|
||||
**Bekannte Grenze.** Der Anker schützt gegen reinen Datenbank-Schreibzugriff.
|
||||
Wer Schlüssel *und* Datenbank kontrolliert, kann die Kette konsistent neu
|
||||
rechnen. Eine Off-Site-Notarisierung (regelmäßiger Export/Versiegelung des
|
||||
Kettenkopfes außerhalb des Systems) wäre die nächste Stufe.
|
||||
|
||||
**Achtung – zwei Endpunkte mit Nebenwirkung:**
|
||||
`POST /api/audit-logs/rehash` berechnet alle Hashes neu; danach meldet die
|
||||
Prüfung überall „gültig", aber eine bestehende Fälschung würde mitbesiegelt.
|
||||
`POST /api/audit-logs/cleanup` löscht nach Aufbewahrungsregeln, jede gelöschte
|
||||
Zeile erzeugt eine Lücke. Beide laufen nur manuell und schreiben einen Marker
|
||||
ins Log.
|
||||
|
||||
</details>
|
||||
|
||||
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
|
||||
sind UNABHÄNGIG vom Flag **immer** geblockt – das ist Mindestschutz gegen
|
||||
AWS/GCP/Azure-IMDS-Diebstahl.
|
||||
@@ -568,39 +881,49 @@ docker-compose down -v # Container stoppen + Daten löschen
|
||||
docker-compose logs -f # Logs anzeigen
|
||||
```
|
||||
|
||||
### Docker (Produktion)
|
||||
### Betrieb mit eigenem SSL (optionaler Caddy-Dienst)
|
||||
|
||||
Im `docker/` Verzeichnis liegt ein komplettes Produktions-Setup:
|
||||
Es gibt **eine** `docker-compose.yml` und **eine** `.env` im Projektstamm.
|
||||
Standardmässig bringt der Stack **keinen** Reverse-Proxy mit – die Annahme ist,
|
||||
dass du bereits einen hast (nginx, Nginx Proxy Manager, Traefik) und OpenCRM
|
||||
dahinter hängst.
|
||||
|
||||
Hast du **keinen**, kannst du Caddy als optionalen Dienst dazuschalten. Er holt
|
||||
und erneuert das SSL-Zertifikat automatisch:
|
||||
|
||||
Dafür gibt es einen Schalter in der `.env` – nichts muss auskommentiert werden:
|
||||
|
||||
```env
|
||||
COMPOSE_PROFILES=caddy # leer = kein Caddy (Standard)
|
||||
DOMAIN=crm.example.de
|
||||
HTTPS_ENABLED=true
|
||||
```
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
|
||||
# Image bauen
|
||||
docker-compose build
|
||||
|
||||
# Container starten
|
||||
docker-compose up -d
|
||||
|
||||
# Logs anzeigen
|
||||
docker-compose logs -f app
|
||||
docker-compose up -d # startet jetzt zusätzlich Caddy
|
||||
```
|
||||
|
||||
**Komponenten:**
|
||||
- **MariaDB 10.11**: Datenbank
|
||||
- **App**: Backend + Frontend in einem Container
|
||||
- **Caddy**: Reverse-Proxy mit automatischem SSL
|
||||
Solange `COMPOSE_PROFILES` leer ist, wird der Caddy-Dienst **gar nicht erst
|
||||
angelegt** – der Stack verhält sich exakt wie ohne ihn. Der Schalter wirkt auch
|
||||
bei `down`, `logs` und `ps`, im Gegensatz zum Flag `--profile caddy`, das man
|
||||
bei Folgebefehlen leicht vergisst.
|
||||
|
||||
**Umgebungsvariablen (`docker/.env`):**
|
||||
```env
|
||||
MYSQL_ROOT_PASSWORD=sicheres-root-passwort
|
||||
MYSQL_DATABASE=opencrm
|
||||
MYSQL_USER=opencrm
|
||||
MYSQL_PASSWORD=sicheres-passwort
|
||||
JWT_SECRET=sehr-langer-zufaelliger-string
|
||||
ENCRYPTION_KEY=64-zeichen-hex-string
|
||||
DOMAIN=crm.example.com
|
||||
RUN_SEED=true # Nur beim ersten Start
|
||||
```
|
||||
**Was dabei zu beachten ist:**
|
||||
|
||||
- Die Zertifikate liegen unter `./data/caddy`. Diesen Ordner **nicht löschen**,
|
||||
sonst werden bei jedem Start neue Zertifikate angefordert und du läufst in
|
||||
die Mengenbegrenzung von Let's Encrypt.
|
||||
- Die Domain muss vorher per DNS auf den Server zeigen, sonst schlägt die
|
||||
Zertifikatsausstellung fehl.
|
||||
- `HTTPS_ENABLED=true` setzen – das aktiviert das `Secure`-Attribut am
|
||||
Refresh-Cookie und die korrekte Proxy-Erkennung.
|
||||
- Der direkte App-Port (`OPENCRM_PORT`, Standard 3010) bleibt weiterhin offen.
|
||||
Wenn nur Caddy von aussen erreichbar sein soll, schliesse ihn per Firewall
|
||||
oder setze ihn in der `.env` auf einen Wert, den du nicht freigibst.
|
||||
|
||||
Die Konfiguration liegt in der `Caddyfile` im Projektstamm. Sie liefert
|
||||
Sicherheits-Header mit und komprimiert bewusst **nicht** die API-Antworten
|
||||
(BREACH-Schutz, siehe Abschnitt zum Reverse-Proxy oben).
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
@@ -652,7 +975,8 @@ opencrm/
|
||||
│ │ ├── types/ # TypeScript-Typen
|
||||
│ │ └── App.tsx # Haupt-Komponente
|
||||
│ └── package.json
|
||||
├── docker/ # Docker-Deployment
|
||||
├── Caddyfile # Optionaler Reverse-Proxy (nur mit --profile caddy)
|
||||
├── tools/audit-notary/ # Gegenbuch – läuft auf einem zweiten Rechner
|
||||
│ ├── Dockerfile # Multi-Stage Build
|
||||
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
|
||||
│ ├── Caddyfile # Reverse-Proxy mit SSL
|
||||
|
||||
@@ -19,3 +19,110 @@ ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# ==================== AUDIT-SIEGEL ====================
|
||||
# Was ist das Audit-Log?
|
||||
# OpenCRM protokolliert luekenlos, wer wann welche Daten gesehen oder
|
||||
# geaendert hat. Das ist die Beweisspur, wenn es Streit gibt oder der
|
||||
# Datenschutz nachfragt.
|
||||
#
|
||||
# Wogegen schuetzt dieser Schluessel?
|
||||
# Ohne ihn koennte jemand mit Zugriff auf die Datenbank einen Eintrag
|
||||
# nachtraeglich umschreiben - zum Beispiel einen fehlgeschlagenen
|
||||
# Login-Versuch in einen erfolgreichen verwandeln - und die Faelschung so
|
||||
# glattziehen, dass die Pruefung sie fuer echt haelt.
|
||||
# Mit dem Schluessel bekommt jeder Eintrag ein Siegel, das sich nur mit
|
||||
# diesem Schluessel erzeugen laesst. Wer ihn nicht hat, kann nichts
|
||||
# faelschen, ohne dass es auffliegt. Bildlich: Der Schluessel ist der
|
||||
# Stempel des Notars - die Akte liegt zwar im Schrank, aber den Stempel
|
||||
# hat nur der Notar.
|
||||
#
|
||||
# Wie einrichten?
|
||||
# Einmalig einen Schluessel erzeugen und hier eintragen:
|
||||
# openssl rand -hex 32
|
||||
# Pro Umgebung ein EIGENER Schluessel (Entwicklung, Test, Produktion).
|
||||
#
|
||||
# Was passiert, wenn ich ihn weglasse?
|
||||
# Nichts faellt aus. Das Audit-Log laeuft normal weiter, nur eben ohne
|
||||
# dieses zusaetzliche Siegel.
|
||||
#
|
||||
# Ich habe bisher keinen Schluessel - kann ich ihn nachtraeglich setzen?
|
||||
# Ja. Schluessel erzeugen, eintragen, Backend neu starten - fertig.
|
||||
# Bestehende Eintraege bleiben unveraendert gueltig, ab dem Neustart
|
||||
# werden neue gesiegelt. Alt und neu liegen nebeneinander, es gibt
|
||||
# dadurch KEINE Fehlalarme. Rueckwirkend siegeln geht nicht - je frueher
|
||||
# gesetzt, desto groesser der geschuetzte Zeitraum.
|
||||
#
|
||||
# ACHTUNG: einmal gesetzt, nicht wieder abschalten.
|
||||
# Fehlt der Schluessel spaeter (z. B. beim Container-Rebuild verlorengegangen),
|
||||
# laeuft alles weiter - aber die in dieser Zeit entstandenen Eintraege sind
|
||||
# ungesiegelt und werden beanstandet, sobald der Schluessel wieder da ist.
|
||||
# Ein ungesiegelter Eintrag inmitten gesiegelter ist von einer Faelschung
|
||||
# nicht zu unterscheiden. Also: in die Deploy-Checkliste aufnehmen.
|
||||
#
|
||||
# Was passiert, wenn ich ihn verliere?
|
||||
# Laesst du das Feld LEER, gelten die gesiegelten Eintraege als
|
||||
# "nicht pruefbar" - NICHT als gefaelscht, also kein Fehlalarm. Der
|
||||
# Nachweis fuer diesen Zeitraum ist aber weg.
|
||||
# Traegst du stattdessen einen NEUEN Schluessel ein, ohne den alten unten
|
||||
# zu hinterlegen, werden die alten Eintraege als "manipuliert" gemeldet -
|
||||
# ein falscher Schluessel ist von einer Faelschung nicht zu unterscheiden.
|
||||
# Deshalb: Schluessel sichern, so wie ein Passwort.
|
||||
#
|
||||
# Eigenen Wert erzeugen mit: openssl rand -hex 32
|
||||
# Der Platzhalter unten zaehlt NICHT als Schluessel - laesst du ihn stehen,
|
||||
# bleibt das Siegel aus (es wird also nicht versehentlich mit einem oeffentlich
|
||||
# bekannten Wert gesiegelt). Leer lassen ist ebenfalls in Ordnung.
|
||||
AUDIT_HMAC_KEY=<hier-eigenen-wert-eintragen>
|
||||
|
||||
# Frueher verwendete Schluessel - beim Wechsel hier eintragen.
|
||||
# Moechtest du den Schluessel oben austauschen (z. B. weil du vermutest,
|
||||
# dass er in falsche Haende geraten ist), trage den ALTEN Schluessel hier
|
||||
# ein und den NEUEN oben. Dann bleiben die bisherigen Eintraege pruefbar,
|
||||
# waehrend neue schon mit dem neuen Schluessel gesiegelt werden.
|
||||
#
|
||||
# ACHTUNG: Dieses Feld NICHT voreilig leeren. Solange Eintraege existieren,
|
||||
# die mit einem alten Schluessel gesiegelt wurden, muessen sie hier stehen.
|
||||
# Sonst werden diese Eintraege als "manipuliert" gemeldet (nicht als
|
||||
# "nicht pruefbar"). Gefahrlos leeren kannst du erst, wenn die betroffenen
|
||||
# Eintraege durch die Aufbewahrungsfristen ohnehin geloescht sind.
|
||||
#
|
||||
# Mehrfach gewechselt? Mehrere Schluessel kommagetrennt, juengster zuerst:
|
||||
# AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>,<ganz alter>
|
||||
#
|
||||
# Beim ersten Einrichten leer lassen - erst beim Schluesselwechsel noetig.
|
||||
AUDIT_HMAC_KEY_OLD=
|
||||
|
||||
# --- Technisch (fuer Entwickler/Admins) ---------------------------------
|
||||
# Verfahren : HMAC-SHA256 ueber alle Inhaltsspalten eines Audit-Eintrags,
|
||||
# inkl. previousHash (Verkettung). Entspricht Hash-Version 3.
|
||||
# Abdeckung : Version 1 = 7 Felder (Altbestand), Version 2 = alle 24
|
||||
# Inhaltsspalten (SHA-256, unsigniert), Version 3 = wie 2, aber
|
||||
# HMAC-signiert. Die erwartete Version wird aus der Kette
|
||||
# abgeleitet (Version-Floor), nicht aus der Selbstauskunft der
|
||||
# Zeile - sonst liesse sich per Downgrade die schwaechere
|
||||
# Pruefung erzwingen.
|
||||
# Fail-safe : Ohne Schluessel schreibt der Dienst weiter Version 2. Bereits
|
||||
# signierte Zeilen landen dann in `unverifiableEntries`,
|
||||
# ausdruecklich NICHT in `tamperedEntries`.
|
||||
# Rotation : AUDIT_HMAC_KEY_OLD (kommagetrennte Liste) wird bei der
|
||||
# Pruefung zusaetzlich akzeptiert - Wechsel ohne Rehash. Ein
|
||||
# falscher/fehlender Alt-Schluessel liefert einen HMAC-Mismatch
|
||||
# und damit einen Manipulations-Befund; das ist nicht von einer
|
||||
# echten Faelschung unterscheidbar und daher Absicht.
|
||||
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
|
||||
# UND Datenbank hat, kann die Kette konsistent neu rechnen.
|
||||
# Erzeugung : openssl rand -hex 32 (256 Bit)
|
||||
|
||||
# ==================== DIENSTKONTO-WACHHUND ====================
|
||||
# Nach wie vielen Minuten Stille eines Dienstkontos (z. B. des Gegenbuchs)
|
||||
# ein Sicherheitsereignis erzeugt wird.
|
||||
#
|
||||
# Hintergrund: Die planmaessigen Anmeldungen selbst sind bewusst als Routine
|
||||
# eingestuft, damit sie die CRITICAL-Stufe nicht entwerten. Das Signal ist
|
||||
# deshalb die ABWESENHEIT: Wer das Gegenbuch stilllegt, setzt darauf, dass
|
||||
# Stille nicht auffaellt.
|
||||
#
|
||||
# Faustregel: etwa das Dreifache des Gegenbuch-Takts. Bei stuendlichem Takt
|
||||
# also 180. Ohne Dienstkonten im System passiert nichts.
|
||||
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES=180
|
||||
|
||||
@@ -36,3 +36,4 @@ npm-debug.log*
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
bankdata/
|
||||
|
||||
+5
-1
@@ -53,6 +53,10 @@ COPY backend/prisma ./prisma
|
||||
COPY --from=backend-builder /build/backend/src ./src
|
||||
COPY backend/tsconfig.json ./tsconfig.json
|
||||
|
||||
# Statische Assets (eingebettete Fonts für PDF/A + sRGB-ICC-Profil für den
|
||||
# ZUGFeRD-OutputIntent). Werden zur Laufzeit aus process.cwd()/assets gelesen.
|
||||
COPY backend/assets ./assets
|
||||
|
||||
# Frontend-Build ins public/-Verzeichnis (wird in production-Mode statisch ausgeliefert)
|
||||
COPY --from=frontend-builder /build/frontend/dist ./public
|
||||
|
||||
@@ -63,7 +67,7 @@ COPY backend/factory-defaults /app/factory-defaults-builtin
|
||||
COPY backend/scripts /app/scripts
|
||||
|
||||
# Daten-Verzeichnisse (werden via Bind-Mount überlagert; hier nur als Fallback)
|
||||
RUN mkdir -p uploads factory-defaults prisma/backups
|
||||
RUN mkdir -p uploads factory-defaults prisma/backups bankdata
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Generated
+17
@@ -12,6 +12,7 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"bankdata-germany": "^2.2602.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@@ -21,6 +22,7 @@
|
||||
"express-rate-limit": "^8.4.0",
|
||||
"express-validator": "^7.2.0",
|
||||
"helmet": "^8.1.0",
|
||||
"ibantools": "^4.5.4",
|
||||
"imapflow": "^1.2.8",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.9.3",
|
||||
@@ -984,6 +986,15 @@
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"node_modules/bankdata-germany": {
|
||||
"version": "2.2602.0",
|
||||
"resolved": "https://registry.npmjs.org/bankdata-germany/-/bankdata-germany-2.2602.0.tgz",
|
||||
"integrity": "sha512-3iBD/cIKHZsmAaQIYEMsORhvMjILDTJ4gDQJYNVm+YXCzA5U96mUoB7KBy01rtwpFt/rKrNuCVNc8KZVx+jwpQ==",
|
||||
"license": "MIT OR MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-events": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
@@ -1992,6 +2003,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ibantools": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/ibantools/-/ibantools-4.5.4.tgz",
|
||||
"integrity": "sha512-6jX1gh4aH6XH+o0ey+wtkMTzkcvsEta7DakIOZSng9voZYpMw3U+gK1+tZChk3aRcPcloEt0NOzksjaRZiqXbw==",
|
||||
"license": "MIT or MPL-2.0"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"bankdata-germany": "^2.2602.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@@ -33,6 +34,7 @@
|
||||
"express-rate-limit": "^8.4.0",
|
||||
"express-validator": "^7.2.0",
|
||||
"helmet": "^8.1.0",
|
||||
"ibantools": "^4.5.4",
|
||||
"imapflow": "^1.2.8",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.9.3",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Absender-/Firmenstammdaten (Einzel-Zeile) fuer Gutschrift-PDF + ZUGFeRD.
|
||||
CREATE TABLE IF NOT EXISTS `CompanyProfile` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`street` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`houseNumber` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`postalCode` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`city` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`country` VARCHAR(191) NOT NULL DEFAULT 'DE',
|
||||
`vatId` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`taxNumber` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`commercialRegister` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`managingDirector` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`email` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`phone` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`website` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`iban` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`bic` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`bankName` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`logoPath` VARCHAR(191) NULL,
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Auszahlungskonto (Bankkarte des Kunden) fuer Geld-Gutschriften.
|
||||
-- ON DELETE SET NULL: Loeschen der Bankkarte reisst die Gutschrift nicht mit.
|
||||
ALTER TABLE `CreditNote` ADD COLUMN IF NOT EXISTS `payoutBankCardId` INT NULL;
|
||||
|
||||
-- FK nur anlegen, wenn noch nicht vorhanden (idempotent via information_schema).
|
||||
SET @fk := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote'
|
||||
AND CONSTRAINT_NAME = 'CreditNote_payoutBankCardId_fkey');
|
||||
SET @sql := IF(@fk = 0,
|
||||
'ALTER TABLE `CreditNote` ADD CONSTRAINT `CreditNote_payoutBankCardId_fkey` FOREIGN KEY (`payoutBankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote'
|
||||
AND INDEX_NAME = 'CreditNote_payoutBankCardId_idx');
|
||||
SET @sql2 := IF(@idx = 0,
|
||||
'CREATE INDEX `CreditNote_payoutBankCardId_idx` ON `CreditNote`(`payoutBankCardId`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt2 FROM @sql2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Gutschrift-Nummer nullbar: betragslose Sachwert-Uebergaben (Lieferschein)
|
||||
-- verbrauchen keine Gutschriftsnummer. UNIQUE bleibt (MySQL erlaubt mehrere
|
||||
-- NULLs in einem UNIQUE-Index).
|
||||
ALTER TABLE `CreditNote` MODIFY COLUMN `number` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Lieferschein-Nummerierung fuer betragslose Sachwert-Uebergaben:
|
||||
-- eigene Nummer am Beleg + eigener Nummernkreis (getrennt von Gutschriften).
|
||||
|
||||
ALTER TABLE `CreditNote` ADD COLUMN IF NOT EXISTS `deliveryNoteNumber` VARCHAR(191) NULL;
|
||||
|
||||
-- UNIQUE-Index auf deliveryNoteNumber (idempotent).
|
||||
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote'
|
||||
AND INDEX_NAME = 'CreditNote_deliveryNoteNumber_key');
|
||||
SET @sql := IF(@idx = 0,
|
||||
'CREATE UNIQUE INDEX `CreditNote_deliveryNoteNumber_key` ON `CreditNote`(`deliveryNoteNumber`)',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `DeliveryNoteNumberRange` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`prefix` VARCHAR(191) NOT NULL DEFAULT 'LS-',
|
||||
`includeYear` BOOLEAN NOT NULL DEFAULT true,
|
||||
`separator` VARCHAR(191) NOT NULL DEFAULT '-',
|
||||
`padding` INT NOT NULL DEFAULT 4,
|
||||
`nextNumber` INT NOT NULL DEFAULT 1,
|
||||
`resetYearly` BOOLEAN NOT NULL DEFAULT true,
|
||||
`currentYear` INT NULL,
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Kartennummer (Debit-/Kontokarte) als optionales Feld an der Bankkarte.
|
||||
ALTER TABLE `BankCard` ADD COLUMN IF NOT EXISTS `cardNumber` VARCHAR(191) NULL;
|
||||
+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
|
||||
lastName String
|
||||
isActive Boolean @default(true)
|
||||
/// Dienstkonto (z. B. das Gegenbuch). Meldet sich planmaessig und haeufig an.
|
||||
/// Solche Anmeldungen werden im Audit-Log als Routine gefuehrt statt als
|
||||
/// CRITICAL - sonst trainiert das vorhersagbarste Ereignis im System den
|
||||
/// Betreiber darauf, die hoechste Stufe wegzuklicken (Pentest R182).
|
||||
isServiceAccount Boolean @default(false)
|
||||
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
||||
|
||||
// Passwort-Reset
|
||||
@@ -101,6 +106,16 @@ model Role {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
description String?
|
||||
/// Von der Anwendung gepflegte Rolle (config/rechte-katalog.ts). Über die
|
||||
/// API sichtbar, aber nicht umbenennbar, nicht löschbar, Rechte nicht
|
||||
/// änderbar. Ohne diese Sperre liess sich die Admin-Rolle über die
|
||||
/// Rollen-CRUD leeren oder umbenennen, und die Namens-Schlüssel, an denen
|
||||
/// die versteckten Rollen hängen, waren nur scheinbar stabil.
|
||||
isSystem Boolean @default(false)
|
||||
/// Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden über
|
||||
/// die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff,
|
||||
/// Audit-Betrieb) oder gehören zu einem technischen Konto.
|
||||
isHidden Boolean @default(false)
|
||||
permissions RolePermission[]
|
||||
users UserRole[]
|
||||
createdAt DateTime @default(now())
|
||||
@@ -327,6 +342,10 @@ model Address {
|
||||
city String
|
||||
country String @default("Deutschland")
|
||||
isDefault Boolean @default(false)
|
||||
// Marktlokations-IDs (MaLo) je Sparte – die MaLo gehört zur (Liefer-)Adresse,
|
||||
// nicht zum Vertrag. Strom- und Gas-Marktlokation sind getrennt.
|
||||
maloIdElectricity String? // MaLo-ID Strom
|
||||
maloIdGas String? // MaLo-ID Gas
|
||||
// Eigentümer (leer = Kunde ist selbst Eigentümer)
|
||||
ownerCompany String?
|
||||
ownerFirstName String?
|
||||
@@ -353,6 +372,7 @@ model BankCard {
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
accountHolder String
|
||||
iban String
|
||||
cardNumber String? // Karten-/Kontokartennummer (optional, z.B. Debitkarte)
|
||||
bic String?
|
||||
bankName String?
|
||||
expiryDate DateTime?
|
||||
@@ -360,6 +380,7 @@ model BankCard {
|
||||
description String? @db.Text // Freitext-Notiz (z.B. "Geschäftskonto", "gemeinsames Konto mit Partner")
|
||||
isActive Boolean @default(true)
|
||||
contracts Contract[]
|
||||
creditNotePayouts CreditNote[] @relation("CreditNotePayoutBankCard")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -701,7 +722,10 @@ enum ContractStatus {
|
||||
DRAFT
|
||||
PENDING
|
||||
ACTIVE
|
||||
// Kündigungsschreiben raus, Bestätigung des Anbieters steht noch aus.
|
||||
CANCELLED
|
||||
// Kündigung vom Anbieter bestätigt (Bestätigungsdatum/-dokument liegt vor).
|
||||
CANCELLED_CONFIRMED
|
||||
EXPIRED
|
||||
DEACTIVATED
|
||||
// "Läuft weiter auf unbestimmte Zeit" – Vertrag ist unbefristet aktiv.
|
||||
@@ -861,8 +885,10 @@ enum ContractTaskStatus {
|
||||
|
||||
model ContractTask {
|
||||
id Int @id @default(autoincrement())
|
||||
contractId Int
|
||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
// Nullable: Aufgaben können auch OHNE Vertrag/Kunde angelegt werden
|
||||
// (allgemeine interne Aufgabe). Ohne Vertrag → kein Kunde → nie im Portal.
|
||||
contractId Int?
|
||||
contract Contract? @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
description String? @db.Text
|
||||
status ContractTaskStatus @default(OPEN)
|
||||
@@ -914,6 +940,7 @@ model EnergyContractDetails {
|
||||
// `instantBonus` (Annahme: meistgenutzte Variante).
|
||||
instantBonus Float? // Sofort-Bonus
|
||||
newCustomerBonus Float? // Neukunden-Bonus
|
||||
noBonusDesired Boolean @default(false) // Kunde wünscht keine Bonis
|
||||
previousProviderName String?
|
||||
previousCustomerNumber String?
|
||||
invoices Invoice[] // Rechnungen
|
||||
@@ -983,7 +1010,14 @@ model CreditNote {
|
||||
contractId Int
|
||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
|
||||
number String @unique // aus Nummernkreis, z.B. GS-2026-0001
|
||||
// Gutschrift-Nummer aus dem Gutschrift-Nummernkreis (z.B. GS-2026-0001).
|
||||
// NULL bei betragslosen Sachwert-Übergaben (= Lieferschein, s.u.).
|
||||
number String? @unique
|
||||
// Lieferschein-/Subventionsnummer aus dem separaten Lieferschein-
|
||||
// Nummernkreis (z.B. LS-2026-0001) – nur bei betragslosen Sachwerten.
|
||||
// Beide Nummern werden lazy pro Serie vergeben und NIE wieder freigegeben
|
||||
// (keine Lücken); angezeigt wird je nach aktuellem Typ die passende.
|
||||
deliveryNoteNumber String? @unique
|
||||
|
||||
type CreditNoteType
|
||||
// Pflicht bei SACHWERT: was wird gewährt (z.B. "Smartphone Samsung A55").
|
||||
@@ -1008,6 +1042,13 @@ model CreditNote {
|
||||
// Sachwert: Bestätigung "Ware erhalten".
|
||||
goodsReceived Boolean @default(false)
|
||||
|
||||
// Auszahlungskonto (nur GELD): Bankkarte des Kunden, auf die überwiesen
|
||||
// wird. Kann ein anderes Konto sein als das Abbuchkonto des Vertrags.
|
||||
// ON DELETE SET NULL, damit das Löschen einer Bankkarte die Gutschrift
|
||||
// (historischer Beleg) nicht mitreißt.
|
||||
payoutBankCardId Int?
|
||||
payoutBankCard BankCard? @relation("CreditNotePayoutBankCard", fields: [payoutBankCardId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Überweisungsbeleg (nur GELD) – hochgeladene Datei.
|
||||
receiptPath String?
|
||||
// Generiertes Gutschrift-Dokument (PDF bzw. ZUGFeRD-Hybrid) – Phase 3.
|
||||
@@ -1021,6 +1062,30 @@ model CreditNote {
|
||||
@@index([contractId])
|
||||
}
|
||||
|
||||
// Absender-/Firmenstammdaten (Einzel-Zeile). Fließen in Gutschrift-PDF und
|
||||
// ZUGFeRD-XML (Verkäufer/Seller-Party) sowie in die Bankangaben ein.
|
||||
model CompanyProfile {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @default("")
|
||||
street String @default("")
|
||||
houseNumber String @default("")
|
||||
postalCode String @default("")
|
||||
city String @default("")
|
||||
country String @default("DE") // ISO-2, für ZUGFeRD countryID
|
||||
vatId String @default("") // USt-IdNr (DE...)
|
||||
taxNumber String @default("") // Steuernummer
|
||||
commercialRegister String @default("") // z.B. HRB 12345, Amtsgericht
|
||||
managingDirector String @default("") // Geschäftsführer/Inhaber
|
||||
email String @default("")
|
||||
phone String @default("")
|
||||
website String @default("")
|
||||
iban String @default("")
|
||||
bic String @default("")
|
||||
bankName String @default("")
|
||||
logoPath String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Nummernkreis für Gutschriften (Einzel-Zeile, in den Einstellungen
|
||||
// verwaltbar). Nummer wird transaktional vergeben, damit keine Lücken/
|
||||
// Doppelvergaben entstehen.
|
||||
@@ -1036,6 +1101,20 @@ model CreditNoteNumberRange {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Separater Nummernkreis für Lieferscheine (betragslose Sachwert-Übergaben).
|
||||
// Eigene Serie, damit die Gutschrift-Nummerierung sauber/lückenlos bleibt.
|
||||
model DeliveryNoteNumberRange {
|
||||
id Int @id @default(autoincrement())
|
||||
prefix String @default("LS-")
|
||||
includeYear Boolean @default(true)
|
||||
separator String @default("-")
|
||||
padding Int @default(4)
|
||||
nextNumber Int @default(1)
|
||||
resetYearly Boolean @default(true)
|
||||
currentYear Int?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ==================== INTERNET CONTRACT DETAILS ====================
|
||||
|
||||
model InternetContractDetails {
|
||||
@@ -1157,6 +1236,76 @@ model CarInsuranceDetails {
|
||||
|
||||
// ==================== AUDIT LOGGING (DSGVO) ====================
|
||||
|
||||
/// Einzeiliger Mutex fuer die Audit-Hash-Kette (genau eine Zeile, id = 1).
|
||||
///
|
||||
/// Warum eine eigene Tabelle statt GET_LOCK oder `SELECT … FOR UPDATE` auf
|
||||
/// AuditLog selbst:
|
||||
/// - GET_LOCK muss auf derselben Verbindung freigegeben werden. Innerhalb des
|
||||
/// Prisma-Transaktions-Callbacks faellt das Release damit VOR den COMMIT –
|
||||
/// der naechste Schreiber liest das Kettenende, bevor die Vorgaengerzeile
|
||||
/// sichtbar ist, und haengt sich an denselben Vorgaenger (Fork, R166-01).
|
||||
/// - `FOR UPDATE` auf das Kettenende von AuditLog nimmt Gap-/Next-Key-Locks,
|
||||
/// die mit den gleichzeitigen INSERTs kollidieren (Deadlocks, dabei gingen
|
||||
/// 38 von 40 Eintraegen verloren).
|
||||
/// InnoDB-Zeilensperren werden erst beim COMMIT freigegeben – genau das
|
||||
/// schliesst das Fenster.
|
||||
model AuditChainLock {
|
||||
id Int @id
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Ausgegebene Refresh-Tokens (Pentest R164-02).
|
||||
///
|
||||
/// Vorher war die Rotation wirkungslos: Der alte Token blieb bis `exp`
|
||||
/// gueltig, ein gestohlener Token also bis zu 7 Tage parallel nutzbar.
|
||||
/// Jetzt traegt jeder Refresh-Token eine `jti` und gehoert zu einer
|
||||
/// Sitzungs-`familyId`. Beim Einloesen wird die `jti` verbraucht; taucht sie
|
||||
/// danach erneut auf, gilt das als Replay und die GESAMTE Familie wird
|
||||
/// widerrufen (Angreifer und legitimer Nutzer fliegen raus, der Vorfall wird
|
||||
/// gemeldet) - das uebliche Vorgehen aus der OAuth-Sicherheits-BCP.
|
||||
///
|
||||
/// Der Token selbst wird NICHT gespeichert - die Signatur authentifiziert ihn
|
||||
/// bereits, und ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.
|
||||
model RefreshTokenRecord {
|
||||
id Int @id @default(autoincrement())
|
||||
jti String @unique
|
||||
familyId String
|
||||
userId Int?
|
||||
customerId Int?
|
||||
isCustomerPortal Boolean @default(false)
|
||||
issuedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
/// Gesetzt, sobald der Token eingeloest wurde (Einmalverwendung).
|
||||
usedAt DateTime?
|
||||
replacedByJti String?
|
||||
/// Wiederverwendungen innerhalb des Kulanzfensters (parallele Tabs).
|
||||
reuseCount Int @default(0)
|
||||
revokedAt DateTime?
|
||||
revokedReason String?
|
||||
|
||||
@@index([familyId])
|
||||
@@index([expiresAt])
|
||||
@@index([userId])
|
||||
@@index([customerId])
|
||||
}
|
||||
|
||||
/// Blattwerte des Bestandssiegels (Pentest R171-02 / R172).
|
||||
///
|
||||
/// Der Altbestand (Hash-Version 1/2) laesst sich nicht rueckwirkend signieren –
|
||||
/// ein Siegel vergibt man beim Anlegen. Bei V1 sind nur 7 von 24 Spalten
|
||||
/// gehasht, ein DB-Schreiber kann dort z. B. `success` von false auf true
|
||||
/// setzen, ohne den Hash zu beruehren: die Pruefung meldet weiterhin "gueltig".
|
||||
///
|
||||
/// Das Bestandssiegel schliesst das, ohne irgendetwas umzuschreiben: Zum
|
||||
/// Zeitpunkt X wird je Altzeile ein Blattwert ueber den VOLLEN Inhalt
|
||||
/// (alle Spalten inkl. id und hashVersion) gebildet; die Wurzel darueber
|
||||
/// steht in einem HMAC-signierten Marker. Jede spaetere Aenderung an einer
|
||||
/// Altzeile bricht Blatt und Wurzel.
|
||||
model AuditBacklogSeal {
|
||||
auditLogId Int @id
|
||||
leafHash String
|
||||
}
|
||||
|
||||
enum AuditAction {
|
||||
CREATE
|
||||
READ
|
||||
@@ -1167,6 +1316,7 @@ enum AuditAction {
|
||||
LOGIN
|
||||
LOGOUT
|
||||
LOGIN_FAILED
|
||||
TOKEN_REFRESH // stiller Access-Token-Refresh (Cookie), kein interaktiver Login
|
||||
}
|
||||
|
||||
enum AuditSensitivity {
|
||||
@@ -1219,6 +1369,9 @@ model AuditLog {
|
||||
createdAt DateTime @default(now())
|
||||
hash String? // SHA-256 Hash des Eintrags
|
||||
previousHash String? // Hash des vorherigen Eintrags
|
||||
/// 1 = Alt-Hash ueber 7 Felder, 2 = Hash ueber alle Inhaltsspalten
|
||||
/// (Pentest R166-02). Bestandszeilen bleiben mit Version 1 gueltig.
|
||||
hashVersion Int @default(1)
|
||||
|
||||
@@index([userId])
|
||||
@@index([customerId])
|
||||
@@ -1339,6 +1492,7 @@ enum SecurityEventType {
|
||||
LOGOUT // expliziter Logout
|
||||
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
||||
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
||||
AUDIT_SEAL_CHANGED // Bestandssiegel gesetzt oder ersetzt (Beweis-Grundlage)
|
||||
SUSPICIOUS // generischer Catch-All
|
||||
}
|
||||
|
||||
|
||||
+37
-213
@@ -1,225 +1,28 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
|
||||
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../src/config/rechte-katalog.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding database...');
|
||||
|
||||
// ==================== PERMISSIONS ====================
|
||||
// Ressourcen mit ihren erlaubten Aktionen
|
||||
const resourcePermissions: Record<string, string[]> = {
|
||||
// Haupt-Ressourcen (CRUD)
|
||||
customers: ['create', 'read', 'update', 'delete'],
|
||||
contracts: ['create', 'read', 'update', 'delete'],
|
||||
users: ['create', 'read', 'update', 'delete'],
|
||||
platforms: ['create', 'read', 'update', 'delete'],
|
||||
providers: ['create', 'read', 'update', 'delete'],
|
||||
tariffs: ['create', 'read', 'update', 'delete'],
|
||||
// Konfiguration (CRUD)
|
||||
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
||||
'contract-durations': ['create', 'read', 'update', 'delete'],
|
||||
'contract-categories': ['create', 'read', 'update', 'delete'],
|
||||
'email-providers': ['create', 'read', 'update', 'delete'],
|
||||
// Einstellungen (nur lesen/ändern)
|
||||
settings: ['read', 'update'],
|
||||
// Spezial-Permissions
|
||||
developer: ['access'],
|
||||
emails: ['delete'],
|
||||
// DSGVO & Audit
|
||||
audit: ['read', 'export', 'admin'],
|
||||
gdpr: ['export', 'delete', 'admin'],
|
||||
};
|
||||
// ==================== RECHTE UND ROLLEN ====================
|
||||
// Katalog und Systemrollen kommen aus einer einzigen Definition
|
||||
// (src/config/rechte-katalog.ts), aufgeloest von rollen-sync.service.ts.
|
||||
//
|
||||
// Bis 09/2026 stand hier eine zweite, eigene Kopie - und sie wich ab: Die
|
||||
// DSGVO-Rolle bekam `audit:*` komplett, also auch `audit:admin`. Gerettet
|
||||
// hat das nur die Reihenfolge im Container-Start (sync-roles lief danach
|
||||
// und raeumte Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte
|
||||
// die Buendelung zurueck. Drei Beschreibungen desselben Sachverhalts sind
|
||||
// zwei zu viel.
|
||||
await synchronisiereRechteUndRollen(prisma);
|
||||
|
||||
const permissions: { resource: string; action: string }[] = [];
|
||||
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
||||
for (const action of actions) {
|
||||
permissions.push({ resource, action });
|
||||
}
|
||||
}
|
||||
|
||||
for (const perm of permissions) {
|
||||
await prisma.permission.upsert({
|
||||
where: { resource_action: perm },
|
||||
update: {},
|
||||
create: perm,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Permissions created (${permissions.length} total)`);
|
||||
|
||||
// Get all permissions
|
||||
const allPermissions = await prisma.permission.findMany();
|
||||
const customerReadPerm = allPermissions.find(
|
||||
(p) => p.resource === 'customers' && p.action === 'read'
|
||||
);
|
||||
const contractReadPerm = allPermissions.find(
|
||||
(p) => p.resource === 'contracts' && p.action === 'read'
|
||||
);
|
||||
const platformReadPerm = allPermissions.find(
|
||||
(p) => p.resource === 'platforms' && p.action === 'read'
|
||||
);
|
||||
const providerReadPerm = allPermissions.find(
|
||||
(p) => p.resource === 'providers' && p.action === 'read'
|
||||
);
|
||||
|
||||
// Helper: Sync permissions for a role (adds missing, removes excess)
|
||||
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
||||
const existing = await prisma.rolePermission.findMany({
|
||||
where: { roleId },
|
||||
select: { permissionId: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((e) => e.permissionId));
|
||||
const targetIds = new Set(permissionIds);
|
||||
|
||||
// Add missing permissions
|
||||
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
await prisma.rolePermission.createMany({
|
||||
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
console.log(` → ${missing.length} Permissions hinzugefügt für Rolle #${roleId}`);
|
||||
}
|
||||
|
||||
// Remove excess permissions
|
||||
const excess = existing.filter((e) => !targetIds.has(e.permissionId)).map((e) => e.permissionId);
|
||||
if (excess.length > 0) {
|
||||
await prisma.rolePermission.deleteMany({
|
||||
where: { roleId, permissionId: { in: excess } },
|
||||
});
|
||||
console.log(` → ${excess.length} Permissions entfernt für Rolle #${roleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create roles
|
||||
// Admin - all permissions EXCEPT developer:access and audit/gdpr (controlled separately via checkboxes)
|
||||
const adminPermissions = allPermissions.filter(
|
||||
(p) =>
|
||||
!(p.resource === 'developer' && p.action === 'access') &&
|
||||
p.resource !== 'audit' &&
|
||||
p.resource !== 'gdpr'
|
||||
);
|
||||
const adminRole = await prisma.role.upsert({
|
||||
where: { name: 'Admin' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Admin',
|
||||
description: 'Voller Zugriff auf alle Funktionen',
|
||||
permissions: {
|
||||
create: adminPermissions.map((p) => ({ permissionId: p.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
await syncRolePermissions(adminRole.id, adminPermissions.map((p) => p.id));
|
||||
|
||||
// Developer - ALL permissions (developer:access + alles andere)
|
||||
const developerPermissions = allPermissions;
|
||||
const developerRole = await prisma.role.upsert({
|
||||
where: { name: 'Developer' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Developer',
|
||||
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
||||
permissions: {
|
||||
create: developerPermissions.map((p) => ({ permissionId: p.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id));
|
||||
|
||||
// DSGVO - audit and gdpr permissions (hidden role, controlled via hasGdprAccess)
|
||||
const gdprPermissions = allPermissions.filter(
|
||||
(p) => p.resource === 'audit' || p.resource === 'gdpr'
|
||||
);
|
||||
const gdprRole = await prisma.role.upsert({
|
||||
where: { name: 'DSGVO' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'DSGVO',
|
||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
||||
permissions: {
|
||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
await syncRolePermissions(gdprRole.id, gdprPermissions.map((p) => p.id));
|
||||
|
||||
// Employee - full access to customers, contracts, read access to lookup tables
|
||||
const employeePermIds = allPermissions
|
||||
.filter(
|
||||
(p) =>
|
||||
p.resource === 'customers' ||
|
||||
p.resource === 'contracts' ||
|
||||
// Read-only Zugriff auf Stammdaten und Konfiguration
|
||||
(p.action === 'read' && [
|
||||
'platforms',
|
||||
'providers',
|
||||
'tariffs',
|
||||
'cancellation-periods',
|
||||
'contract-durations',
|
||||
'contract-categories',
|
||||
].includes(p.resource))
|
||||
)
|
||||
.map((p) => p.id);
|
||||
|
||||
const employeeRole = await prisma.role.upsert({
|
||||
where: { name: 'Mitarbeiter' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Mitarbeiter',
|
||||
description: 'Kann Kunden und Verträge verwalten',
|
||||
permissions: {
|
||||
create: employeePermIds.map((id) => ({ permissionId: id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
await syncRolePermissions(employeeRole.id, employeePermIds);
|
||||
|
||||
// Read-only employee - read access to main entities and lookup tables
|
||||
const readOnlyResources = [
|
||||
'customers',
|
||||
'contracts',
|
||||
'platforms',
|
||||
'providers',
|
||||
'tariffs',
|
||||
'cancellation-periods',
|
||||
'contract-durations',
|
||||
'contract-categories',
|
||||
];
|
||||
const readOnlyPermIds = allPermissions
|
||||
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
||||
.map((p) => p.id);
|
||||
|
||||
const readOnlyRole = await prisma.role.upsert({
|
||||
where: { name: 'Mitarbeiter (Nur-Lesen)' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Mitarbeiter (Nur-Lesen)',
|
||||
description: 'Kann nur lesen, keine Änderungen',
|
||||
permissions: {
|
||||
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
await syncRolePermissions(readOnlyRole.id, readOnlyPermIds);
|
||||
|
||||
// Customer role - read own data only (handled in middleware)
|
||||
const customerRole = await prisma.role.upsert({
|
||||
where: { name: 'Kunde' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Kunde',
|
||||
description: 'Kann nur eigene Daten lesen',
|
||||
permissions: {
|
||||
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
await syncRolePermissions(customerRole.id, readOnlyPermIds);
|
||||
|
||||
console.log('Roles created');
|
||||
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
|
||||
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
|
||||
|
||||
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
||||
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
|
||||
@@ -265,8 +68,19 @@ async function main() {
|
||||
password: hashedPassword,
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
// Zusaetzlich die DSGVO-Rolle (Pentest R189-01).
|
||||
//
|
||||
// Ohne sie kann nach einem frischen Seed NIEMAND eine Auskunft nach
|
||||
// Art. 15 oder eine Loeschung nach Art. 17 ausfuehren - die Rechte
|
||||
// haengen an DSGVO und Developer, und beide waren keinem Konto
|
||||
// zugewiesen. Ein Ausfall mit Fristwirkung, ausgeloest durch nichts
|
||||
// weiter als eine Neuinstallation.
|
||||
//
|
||||
// Die Trennung bleibt: Die Admin-ROLLE bekommt diese Rechte weiterhin
|
||||
// nicht. Nur dieses eine Bootstrap-Konto traegt beide, damit ueberhaupt
|
||||
// jemand handlungsfaehig ist.
|
||||
roles: {
|
||||
create: [{ roleId: adminRole.id }],
|
||||
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -497,6 +311,16 @@ async function main() {
|
||||
description: 'Allgemeine Einstellungen',
|
||||
legalBasis: 'Verjährungsfrist (BGB §195)',
|
||||
},
|
||||
{
|
||||
// Stiller Token-Refresh (Routine). Ohne eigene Regel fiele diese
|
||||
// Kombination in die Auffangregel `*` mit 3650 Tagen – das Rauschen
|
||||
// wäre dann länger aufbewahrt als echte Logins (730 Tage).
|
||||
resourceType: 'Authentication',
|
||||
sensitivity: 'LOW' as const,
|
||||
retentionDays: 90,
|
||||
description: 'Routine-Auth (stiller Token-Refresh)',
|
||||
legalBasis: 'Betriebsnotwendigkeit / Datenminimierung (DSGVO Art. 5)',
|
||||
},
|
||||
];
|
||||
|
||||
for (const policy of specificPolicies) {
|
||||
|
||||
+20
-148
@@ -1,160 +1,32 @@
|
||||
/**
|
||||
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
|
||||
* Bringt Rechtekatalog und Systemrollen beim Container-Start auf Stand.
|
||||
*
|
||||
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
|
||||
* System schon installiert hat, bekommt nachträglich hinzugefügte
|
||||
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
|
||||
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
|
||||
* Hintergrund: seed.ts laeuft nur auf leeren Datenbanken (USER_COUNT=0). Wer
|
||||
* das System schon installiert hat, bekommt nachtraeglich hinzugefuegte
|
||||
* Rechte oder geaenderte Rollenzuordnungen sonst NICHT.
|
||||
*
|
||||
* Dieses Skript synchronisiert ausschließlich:
|
||||
* - Permission-Katalog (resource/action-Paare aus dem Code)
|
||||
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
|
||||
* Mitarbeiter (Nur-Lesen), Kunde)
|
||||
* Die Definition selbst steht in `src/config/rechte-katalog.ts`, die Logik in
|
||||
* `src/services/rollen-sync.service.ts`. Dieses Skript ist nur noch der
|
||||
* Einstiegspunkt fuer die Kommandozeile - bis 09/2026 trug es eine eigene
|
||||
* Kopie des Katalogs, und es war nicht die einzige.
|
||||
*
|
||||
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
|
||||
* laufenden Prod-DBs sicher.
|
||||
* KEINE Stammdaten, KEINE Benutzer, KEINE Vertraege - auf laufenden
|
||||
* Produktionsdatenbanken sicher.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
|
||||
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
|
||||
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
|
||||
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
|
||||
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
|
||||
// "Environment variable not found: DATABASE_URL".
|
||||
import prisma from '../src/lib/prisma.js';
|
||||
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
|
||||
customers: ['create', 'read', 'update', 'delete'],
|
||||
contracts: ['create', 'read', 'update', 'delete'],
|
||||
users: ['create', 'read', 'update', 'delete'],
|
||||
platforms: ['create', 'read', 'update', 'delete'],
|
||||
providers: ['create', 'read', 'update', 'delete'],
|
||||
tariffs: ['create', 'read', 'update', 'delete'],
|
||||
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
||||
'contract-durations': ['create', 'read', 'update', 'delete'],
|
||||
'contract-categories': ['create', 'read', 'update', 'delete'],
|
||||
'email-providers': ['create', 'read', 'update', 'delete'],
|
||||
settings: ['read', 'update'],
|
||||
developer: ['access'],
|
||||
emails: ['delete'],
|
||||
audit: ['read', 'export', 'admin'],
|
||||
gdpr: ['export', 'delete', 'admin'],
|
||||
};
|
||||
|
||||
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
||||
const existing = await prisma.rolePermission.findMany({
|
||||
where: { roleId },
|
||||
select: { permissionId: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((e) => e.permissionId));
|
||||
const targetIds = new Set(permissionIds);
|
||||
|
||||
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
||||
if (missing.length > 0) {
|
||||
await prisma.rolePermission.createMany({
|
||||
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
|
||||
}
|
||||
|
||||
const excess = existing
|
||||
.filter((e) => !targetIds.has(e.permissionId))
|
||||
.map((e) => e.permissionId);
|
||||
if (excess.length > 0) {
|
||||
await prisma.rolePermission.deleteMany({
|
||||
where: { roleId, permissionId: { in: excess } },
|
||||
});
|
||||
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('[sync-roles] Permissions-Katalog upserten…');
|
||||
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
|
||||
for (const action of actions) {
|
||||
await prisma.permission.upsert({
|
||||
where: { resource_action: { resource, action } },
|
||||
update: {},
|
||||
create: { resource, action },
|
||||
});
|
||||
}
|
||||
}
|
||||
const allPermissions = await prisma.permission.findMany();
|
||||
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
|
||||
|
||||
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
|
||||
// sind separate hidden roles, über Checkboxen zugewiesen)
|
||||
const adminPermIds = allPermissions
|
||||
.filter(
|
||||
(p) =>
|
||||
!(p.resource === 'developer' && p.action === 'access') &&
|
||||
p.resource !== 'audit' &&
|
||||
p.resource !== 'gdpr'
|
||||
)
|
||||
.map((p) => p.id);
|
||||
|
||||
// Developer: alles
|
||||
const developerPermIds = allPermissions.map((p) => p.id);
|
||||
|
||||
// DSGVO: audit + gdpr komplett
|
||||
const gdprPermIds = allPermissions
|
||||
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
|
||||
.map((p) => p.id);
|
||||
|
||||
// Mitarbeiter: customers + contracts + read auf Stammdaten
|
||||
const employeePermIds = allPermissions
|
||||
.filter(
|
||||
(p) =>
|
||||
p.resource === 'customers' ||
|
||||
p.resource === 'contracts' ||
|
||||
(p.action === 'read' &&
|
||||
[
|
||||
'platforms',
|
||||
'providers',
|
||||
'tariffs',
|
||||
'cancellation-periods',
|
||||
'contract-durations',
|
||||
'contract-categories',
|
||||
].includes(p.resource))
|
||||
)
|
||||
.map((p) => p.id);
|
||||
|
||||
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
|
||||
const readOnlyResources = [
|
||||
'customers',
|
||||
'contracts',
|
||||
'platforms',
|
||||
'providers',
|
||||
'tariffs',
|
||||
'cancellation-periods',
|
||||
'contract-durations',
|
||||
'contract-categories',
|
||||
];
|
||||
const readOnlyPermIds = allPermissions
|
||||
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
||||
.map((p) => p.id);
|
||||
|
||||
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
||||
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
||||
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
||||
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
||||
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
|
||||
];
|
||||
|
||||
for (const r of rolesSpec) {
|
||||
const role = await prisma.role.upsert({
|
||||
where: { name: r.name },
|
||||
update: { description: r.description },
|
||||
create: { name: r.name, description: r.description },
|
||||
});
|
||||
await syncRolePermissions(role.id, r.permIds);
|
||||
}
|
||||
|
||||
console.log('[sync-roles] fertig.');
|
||||
}
|
||||
|
||||
main()
|
||||
synchronisiereRechteUndRollen(prisma)
|
||||
.catch((e) => {
|
||||
console.error('[sync-roles] Fehler:', e);
|
||||
console.error('[rollen-sync] Fehler:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Diagnose: findet Portal-Kunden, deren gespeichertes (verschlüsseltes)
|
||||
* Passwort NICHT zum Login-Hash (bcrypt) passt. Solche „Desyncs" führen dazu,
|
||||
* dass der Reveal/Send-Credentials ein Passwort ausgibt, das beim Login
|
||||
* scheitert. Seit dem Reveal-Konsistenzcheck werden sie nicht mehr ausgegeben –
|
||||
* dieses Script findet bestehende Altlasten (z.B. aus manuellen DB-Eingriffen
|
||||
* oder alten Code-Versionen), damit man sie gezielt neu setzen kann.
|
||||
*
|
||||
* Aufruf (im Backend-Container/-Verzeichnis):
|
||||
* npx tsx scripts/check-portal-password-sync.ts
|
||||
* Nur Diagnose – ändert NICHTS.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { decrypt } from '../src/utils/encryption.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const customers = await prisma.customer.findMany({
|
||||
where: { portalPasswordEncrypted: { not: null }, portalPasswordHash: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
customerNumber: true,
|
||||
portalEmail: true,
|
||||
portalPasswordEncrypted: true,
|
||||
portalPasswordHash: true,
|
||||
},
|
||||
});
|
||||
|
||||
let desync = 0;
|
||||
for (const c of customers) {
|
||||
let decrypted: string;
|
||||
try {
|
||||
decrypted = decrypt(c.portalPasswordEncrypted!);
|
||||
} catch {
|
||||
desync++;
|
||||
console.log(`DESYNC (Entschlüsselung fehlgeschlagen): Kunde #${c.id} ${c.customerNumber}`);
|
||||
continue;
|
||||
}
|
||||
const matches = await bcrypt.compare(decrypted, c.portalPasswordHash!);
|
||||
if (!matches) {
|
||||
desync++;
|
||||
console.log(`DESYNC: Kunde #${c.id} ${c.customerNumber} (${c.portalEmail ?? '-'}) – gespeichertes Passwort passt nicht zum Login-Hash`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nGeprüft: ${customers.length} Portal-Kunden mit beiden Feldern. Desync: ${desync}.`);
|
||||
if (desync > 0) {
|
||||
console.log('→ Für diese Kunden bitte ein neues Portal-Passwort setzen/generieren.');
|
||||
} else {
|
||||
console.log('→ Alles konsistent.');
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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,343 @@
|
||||
/**
|
||||
* Der Rechte- und Rollenkatalog. Eine Quelle, drei Verbraucher.
|
||||
*
|
||||
* Bis hierher stand derselbe Katalog dreimal im Code: in `prisma/seed.ts`,
|
||||
* in `prisma/sync-roles.ts` und - abweichend - in `factoryReset`
|
||||
* (`services/backup.service.ts`). Die dritte Kopie kannte weder `audit:*`
|
||||
* noch `gdpr:*` und legte die Rollen DSGVO, Audit-Betrieb und Gegenbuch gar
|
||||
* nicht erst an: Nach einem Werksreset konnte niemand mehr eine Auskunft
|
||||
* nach Art. 15 DSGVO ausfuehren, und gemerkt haette man es erst, wenn eine
|
||||
* Frist laeuft.
|
||||
*
|
||||
* Drei Beschreibungen desselben Sachverhalts sind zwei zu viel. Welche davon
|
||||
* gilt, entschied bisher die Reihenfolge im Container-Start.
|
||||
*
|
||||
* Dieses Modul liegt bewusst unter `src/` und nicht unter `prisma/`: Es wird
|
||||
* sowohl vom kompilierten Backend (`dist/`) als auch von den CLI-Skripten
|
||||
* unter `prisma/` gebraucht, die per `tsx` laufen. Das Dockerfile kopiert
|
||||
* `src/` zusaetzlich ins Runtime-Image, genau dafuer.
|
||||
*
|
||||
* KEIN Prisma-Import hier - reine Daten. Die Aufloesung gegen die Datenbank
|
||||
* macht `services/rollen-sync.service.ts`.
|
||||
*/
|
||||
|
||||
export interface RechtDefinition {
|
||||
resource: string;
|
||||
action: string;
|
||||
/** Klartext fuer die Oberflaeche. "cancellation-periods:update" sagt einem
|
||||
* Sachbearbeiter nichts. */
|
||||
bezeichnung: string;
|
||||
/** Ueberschrift, unter der das Recht in der Rechtematrix steht. */
|
||||
gruppe: string;
|
||||
}
|
||||
|
||||
export const GRUPPE = {
|
||||
KUNDEN: 'Kunden',
|
||||
VERTRAEGE: 'Verträge',
|
||||
EMAILS: 'E-Mails',
|
||||
STAMMDATEN: 'Stammdaten',
|
||||
EINSTELLUNGEN: 'Einstellungen',
|
||||
BENUTZER: 'Benutzer und Rechte',
|
||||
AUFSICHT: 'Aufsicht und Datenschutz',
|
||||
SYSTEM: 'System',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Der vollstaendige Katalog. Was hier nicht steht, existiert nicht.
|
||||
*
|
||||
* Bis 09/2026 standen hier 18 Rechte, die nichts bewachten: `tariffs:*`,
|
||||
* `cancellation-periods:*`, `contract-durations:*` und `email-providers:*`
|
||||
* waren anhakbar, aber die Routen prueften in Wahrheit `providers:*`,
|
||||
* `platforms:*` und `settings:*`. Ein Haken, der nichts tut, ist schlimmer
|
||||
* als ein fehlender: Er behauptet eine Trennung, die es nicht gibt.
|
||||
* Seit dem granularen Gaten stimmt jede Zeile hier mit mindestens einer
|
||||
* Route ueberein.
|
||||
*/
|
||||
export const RECHTE_KATALOG: RechtDefinition[] = [
|
||||
// ---- Kunden -------------------------------------------------------------
|
||||
{ resource: 'customers', action: 'create', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden anlegen' },
|
||||
{ resource: 'customers', action: 'read', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden sehen' },
|
||||
{ resource: 'customers', action: 'update', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden bearbeiten' },
|
||||
{ resource: 'customers', action: 'delete', gruppe: GRUPPE.KUNDEN, bezeichnung: 'Kunden löschen' },
|
||||
|
||||
// ---- Verträge -----------------------------------------------------------
|
||||
{ resource: 'contracts', action: 'create', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge anlegen' },
|
||||
{ resource: 'contracts', action: 'read', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge sehen' },
|
||||
{ resource: 'contracts', action: 'update', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge bearbeiten' },
|
||||
{ resource: 'contracts', action: 'delete', gruppe: GRUPPE.VERTRAEGE, bezeichnung: 'Verträge löschen' },
|
||||
|
||||
// ---- E-Mails ------------------------------------------------------------
|
||||
{ resource: 'emails', action: 'delete', gruppe: GRUPPE.EMAILS, bezeichnung: 'E-Mails löschen' },
|
||||
|
||||
// ---- Stammdaten ---------------------------------------------------------
|
||||
{ resource: 'platforms', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen anlegen' },
|
||||
{ resource: 'platforms', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen sehen' },
|
||||
{ resource: 'platforms', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen bearbeiten' },
|
||||
{ resource: 'platforms', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertriebsplattformen löschen' },
|
||||
|
||||
{ resource: 'providers', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter anlegen' },
|
||||
{ resource: 'providers', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter sehen' },
|
||||
{ resource: 'providers', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter bearbeiten' },
|
||||
{ resource: 'providers', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Anbieter löschen' },
|
||||
|
||||
{ resource: 'tariffs', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife anlegen' },
|
||||
{ resource: 'tariffs', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife sehen' },
|
||||
{ resource: 'tariffs', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife bearbeiten' },
|
||||
{ resource: 'tariffs', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Tarife löschen' },
|
||||
|
||||
{ resource: 'cancellation-periods', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen anlegen' },
|
||||
{ resource: 'cancellation-periods', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen sehen' },
|
||||
{ resource: 'cancellation-periods', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen bearbeiten' },
|
||||
{ resource: 'cancellation-periods', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Kündigungsfristen löschen' },
|
||||
|
||||
{ resource: 'contract-durations', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten anlegen' },
|
||||
{ resource: 'contract-durations', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten sehen' },
|
||||
{ resource: 'contract-durations', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten bearbeiten' },
|
||||
{ resource: 'contract-durations', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragslaufzeiten löschen' },
|
||||
|
||||
{ resource: 'contract-categories', action: 'create', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen anlegen' },
|
||||
{ resource: 'contract-categories', action: 'read', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen sehen' },
|
||||
{ resource: 'contract-categories', action: 'update', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen bearbeiten' },
|
||||
{ resource: 'contract-categories', action: 'delete', gruppe: GRUPPE.STAMMDATEN, bezeichnung: 'Vertragstypen löschen' },
|
||||
|
||||
// ---- Einstellungen ------------------------------------------------------
|
||||
{ resource: 'settings', action: 'read', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'Einstellungen sehen' },
|
||||
{ resource: 'settings', action: 'update', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'Einstellungen ändern' },
|
||||
|
||||
{ resource: 'email-providers', action: 'create', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider anlegen' },
|
||||
{ resource: 'email-providers', action: 'read', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider sehen' },
|
||||
{ resource: 'email-providers', action: 'update', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider bearbeiten' },
|
||||
{ resource: 'email-providers', action: 'delete', gruppe: GRUPPE.EINSTELLUNGEN, bezeichnung: 'E-Mail-Provider löschen' },
|
||||
|
||||
// ---- Benutzer und Rechte ------------------------------------------------
|
||||
{ resource: 'users', action: 'create', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer anlegen' },
|
||||
{ resource: 'users', action: 'read', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer sehen' },
|
||||
{ resource: 'users', action: 'update', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer bearbeiten' },
|
||||
{ resource: 'users', action: 'delete', gruppe: GRUPPE.BENUTZER, bezeichnung: 'Benutzer löschen' },
|
||||
{
|
||||
resource: 'roles',
|
||||
action: 'manage',
|
||||
gruppe: GRUPPE.BENUTZER,
|
||||
// Getrennt von `users:*`, weil "Konten anlegen" und "festlegen, was ein
|
||||
// Konto darf" zwei verschiedene Befugnisse sind (Pentest, Rollenmodell).
|
||||
bezeichnung: 'Rollen und Rechte verwalten',
|
||||
},
|
||||
|
||||
// ---- Aufsicht und Datenschutz -------------------------------------------
|
||||
{ resource: 'audit', action: 'read', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll lesen' },
|
||||
{ resource: 'audit', action: 'export', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll exportieren' },
|
||||
{ resource: 'audit', action: 'admin', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Audit-Protokoll versiegeln, aufräumen, Aufbewahrung ändern' },
|
||||
|
||||
{ resource: 'gdpr', action: 'export', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Auskunft nach Art. 15 DSGVO erteilen' },
|
||||
{ resource: 'gdpr', action: 'delete', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Löschung nach Art. 17 DSGVO ausführen' },
|
||||
{ resource: 'gdpr', action: 'admin', gruppe: GRUPPE.AUFSICHT, bezeichnung: 'Datenschutz-Verwaltung' },
|
||||
|
||||
// ---- System -------------------------------------------------------------
|
||||
{ resource: 'developer', action: 'access', gruppe: GRUPPE.SYSTEM, bezeichnung: 'Entwicklerwerkzeuge und Datenbankzugriff' },
|
||||
];
|
||||
|
||||
/** `resource:action` in der Schreibweise, die `requirePermission` erwartet. */
|
||||
export function alsRechtString(r: { resource: string; action: string }): string {
|
||||
return `${r.resource}:${r.action}`;
|
||||
}
|
||||
|
||||
export const ALLE_RECHTE: string[] = RECHTE_KATALOG.map(alsRechtString);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rollen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Die Namen der von der Anwendung gepflegten Rollen. Sie werden an mehreren
|
||||
* Stellen als Schluessel benutzt (Checkbox-Zuweisung, Notfallpfade in
|
||||
* `user.service.ts`, Anzeigefilter im Frontend). Ab der Systemrollen-Sperre
|
||||
* sind sie stabil: Eine Rolle mit `isSystem` laesst sich nicht mehr
|
||||
* umbenennen, deshalb traegt der Name jetzt, was er vorher nur zu tragen
|
||||
* schien.
|
||||
*/
|
||||
export const ROLLE_ADMIN = 'Admin';
|
||||
export const ROLLE_DEVELOPER = 'Developer';
|
||||
export const ROLLE_DSGVO = 'DSGVO';
|
||||
export const ROLLE_AUDIT_BETRIEB = 'Audit-Betrieb';
|
||||
export const ROLLE_GEGENBUCH = 'Gegenbuch';
|
||||
export const ROLLE_MITARBEITER = 'Mitarbeiter';
|
||||
export const ROLLE_MITARBEITER_LESEND = 'Mitarbeiter (Nur-Lesen)';
|
||||
export const ROLLE_KUNDE = 'Kunde';
|
||||
|
||||
export interface SystemrollenSpec {
|
||||
name: string;
|
||||
description: string;
|
||||
/**
|
||||
* Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden ueber
|
||||
* die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff,
|
||||
* Audit-Betrieb) oder gehoeren zu einem technischen Konto.
|
||||
*/
|
||||
isHidden: boolean;
|
||||
/** Praedikat ueber den Katalog. */
|
||||
rechte: (r: RechtDefinition) => boolean;
|
||||
}
|
||||
|
||||
/** Stammdaten, die ein Sachbearbeiter zum Arbeiten lesen koennen muss. */
|
||||
const LESE_STAMMDATEN = [
|
||||
'platforms',
|
||||
'providers',
|
||||
'tariffs',
|
||||
'cancellation-periods',
|
||||
'contract-durations',
|
||||
'contract-categories',
|
||||
];
|
||||
|
||||
export const SYSTEMROLLEN: SystemrollenSpec[] = [
|
||||
{
|
||||
name: ROLLE_ADMIN,
|
||||
description:
|
||||
'Voller Zugriff auf alle Fachfunktionen (ohne Audit & Datenschutz – dafür die separaten Rollen DSGVO und Audit-Betrieb)',
|
||||
isHidden: false,
|
||||
// Alles ausser den Entwicklerwerkzeugen und der Aufsicht. Die Trennung
|
||||
// ist Absicht: Wer verwaltet, beaufsichtigt sich nicht selbst.
|
||||
rechte: (r) =>
|
||||
r.resource !== 'developer' && r.resource !== 'audit' && r.resource !== 'gdpr',
|
||||
},
|
||||
{
|
||||
name: ROLLE_DEVELOPER,
|
||||
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
||||
isHidden: true,
|
||||
rechte: () => true,
|
||||
},
|
||||
{
|
||||
name: ROLLE_DSGVO,
|
||||
description: 'DSGVO-Zugriff: Audit-Logs lesen und Datenschutz-Verwaltung',
|
||||
isHidden: true,
|
||||
// Bewusst OHNE `audit:admin`. Diese Rolle beaufsichtigt das Protokoll -
|
||||
// sie darf es nicht umschreiben koennen. Mit `audit:admin` haette ein
|
||||
// DSGVO-Beauftragter seal-backlog, rehash und cleanup, also die Mittel,
|
||||
// seine eigene Beweisgrundlage zu ersetzen (Pentest R186).
|
||||
rechte: (r) =>
|
||||
r.resource === 'gdpr' ||
|
||||
(r.resource === 'audit' && (r.action === 'read' || r.action === 'export')),
|
||||
},
|
||||
{
|
||||
name: ROLLE_AUDIT_BETRIEB,
|
||||
description: 'Darf das Audit-Protokoll versiegeln, aufräumen und die Aufbewahrung ändern',
|
||||
isHidden: true,
|
||||
// Die eingreifenden Rechte am Protokoll, plus `audit:read` - siegeln zu
|
||||
// duerfen, ohne das Ergebnis sehen zu koennen, waere unbrauchbar.
|
||||
rechte: (r) => r.resource === 'audit' && (r.action === 'read' || r.action === 'admin'),
|
||||
},
|
||||
{
|
||||
name: ROLLE_GEGENBUCH,
|
||||
description: 'Darf das Audit-Protokoll nur lesen und prüfen – sonst nichts',
|
||||
isHidden: true,
|
||||
// Der externe Notar ruft genau zwei Endpunkte auf, /audit-logs/checkpoint
|
||||
// und /verify, und beide verlangen `audit:read`. Das Kennwort des
|
||||
// Dienstkontos liegt auf der Gegenbuch-Maschine im Klartext in der .env;
|
||||
// es muss deshalb so wenig wert sein wie moeglich (Pentest R185-01).
|
||||
rechte: (r) => r.resource === 'audit' && r.action === 'read',
|
||||
},
|
||||
{
|
||||
name: ROLLE_MITARBEITER,
|
||||
description: 'Kann Kunden und Verträge verwalten',
|
||||
isHidden: false,
|
||||
rechte: (r) =>
|
||||
r.resource === 'customers' ||
|
||||
r.resource === 'contracts' ||
|
||||
(r.action === 'read' && LESE_STAMMDATEN.includes(r.resource)),
|
||||
},
|
||||
{
|
||||
name: ROLLE_MITARBEITER_LESEND,
|
||||
description: 'Kann nur lesen, keine Änderungen',
|
||||
isHidden: false,
|
||||
rechte: (r) =>
|
||||
r.action === 'read' &&
|
||||
(r.resource === 'customers' ||
|
||||
r.resource === 'contracts' ||
|
||||
LESE_STAMMDATEN.includes(r.resource)),
|
||||
},
|
||||
{
|
||||
name: ROLLE_KUNDE,
|
||||
description: 'Kann nur eigene Daten lesen',
|
||||
isHidden: true,
|
||||
// Hinweis: Portal-Kunden bekommen ihre Rechte NICHT aus dieser Rolle,
|
||||
// sondern aus einem festen Array in `auth.service.ts`. Die Rolle existiert
|
||||
// fuer CRM-seitig angelegte Kundenkonten.
|
||||
rechte: (r) =>
|
||||
r.action === 'read' &&
|
||||
(r.resource === 'customers' ||
|
||||
r.resource === 'contracts' ||
|
||||
LESE_STAMMDATEN.includes(r.resource)),
|
||||
},
|
||||
];
|
||||
|
||||
export const SYSTEMROLLEN_NAMEN: string[] = SYSTEMROLLEN.map((r) => r.name);
|
||||
|
||||
/** Die ueber Haken im Benutzerformular vergebenen Rollen. */
|
||||
export const VERSTECKTE_ROLLEN_NAMEN: string[] = SYSTEMROLLEN.filter((r) => r.isHidden).map(
|
||||
(r) => r.name,
|
||||
);
|
||||
|
||||
/** Rechte einer Systemrolle als `resource:action`-Liste. */
|
||||
export function rechteDerSystemrolle(name: string): string[] {
|
||||
const spec = SYSTEMROLLEN.find((r) => r.name === name);
|
||||
if (!spec) return [];
|
||||
return RECHTE_KATALOG.filter(spec.rechte).map(alsRechtString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleicht Rollennamen so, wie die Sperre sie vergleichen muss: getrimmt
|
||||
* und ohne Ruecksicht auf Gross-/Kleinschreibung. Sonst liesse sich eine
|
||||
* zweite Rolle " admin " anlegen, die in Listen wie die echte aussieht.
|
||||
*/
|
||||
export function istSystemrollenName(name: string): boolean {
|
||||
const normalisiert = name.trim().toLocaleLowerCase('de-DE');
|
||||
return SYSTEMROLLEN_NAMEN.some((n) => n.toLocaleLowerCase('de-DE') === normalisiert);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kundenportal - bewusst NICHT Teil des Rollensystems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Die Rechte eines angemeldeten Portal-Kunden. Fest, kurz, abschliessend.
|
||||
*
|
||||
* DIESE TRENNUNG IST ABSICHT UND SOLL BLEIBEN.
|
||||
*
|
||||
* Kunden im Portal ziehen ihre Rechte NICHT aus Rollen, sondern aus dieser
|
||||
* Liste. Das ist kein Altbestand, den man noch migrieren muesste - es ist
|
||||
* die Sicherung dagegen, dass eine Aenderung am Rollenmodell versehentlich
|
||||
* auf die Kundenseite durchschlaegt. Ein Kunde, der ueber eine Rolle
|
||||
* ploetzlich `contracts:create` haelt, koennte in fremdem Namen Vertraege
|
||||
* anlegen; die Portalansicht ist fuer so etwas nicht gebaut und pruefte es
|
||||
* nicht.
|
||||
*
|
||||
* Wer hier etwas hinzufuegen will, aendert nicht eine Liste, sondern eine
|
||||
* Vertrauensgrenze. `pruefePortalRechte()` weist beim Start alles ab, was
|
||||
* nicht Lesen ist - absichtlich als Alarm und nicht als stiller Default.
|
||||
*
|
||||
* Die Durchsetzung liegt zusaetzlich im Gate (`requirePermission`): Ein
|
||||
* Portal-Zugang bekommt dort nur, was auch hier steht, unabhaengig davon,
|
||||
* was in seinem Token behauptet wird.
|
||||
*/
|
||||
export const PORTAL_RECHTE: readonly string[] = [
|
||||
'contracts:read', // eigene Vertraege lesen
|
||||
'customers:read', // eigene Kundendaten lesen
|
||||
] as const;
|
||||
|
||||
const PORTAL_ERLAUBTE_AKTIONEN = ['read'];
|
||||
|
||||
/**
|
||||
* Wacht darueber, dass die Portal-Liste eine Leseliste bleibt.
|
||||
*
|
||||
* Wird beim Start aufgerufen. Ein schreibendes Recht hier waere kein
|
||||
* Schoenheitsfehler, sondern eine stille Ausweitung der Kundenrechte - und
|
||||
* genau die soll nicht durch eine unbedachte Zeile passieren koennen.
|
||||
*/
|
||||
export function pruefePortalRechte(): string[] {
|
||||
const beanstandet: string[] = [];
|
||||
for (const recht of PORTAL_RECHTE) {
|
||||
const [, aktion] = recht.split(':');
|
||||
if (!PORTAL_ERLAUBTE_AKTIONEN.includes(aktion)) {
|
||||
beanstandet.push(recht);
|
||||
}
|
||||
}
|
||||
return beanstandet;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import * as appSettingService from '../services/appSetting.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
@@ -81,10 +82,7 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
|
||||
});
|
||||
res.json({ success: true, message: 'Einstellung gespeichert' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellung',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellung');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +144,6 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
|
||||
|
||||
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellungen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellungen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +1,127 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { FachlicherFehler } from '../utils/apiError.js';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import * as auditService from '../services/audit.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { AuditAction, AuditSensitivity } from '@prisma/client';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
|
||||
/**
|
||||
* Filterwerte aus der Query pruefen (Pentest R185-02).
|
||||
*
|
||||
* Vorher gingen `action` und `sensitivity` als roher String an die Enum-Spalte.
|
||||
* Ein ungueltiger Wert liess Prisma auflaufen und der Handler antwortete 500.
|
||||
* Das ist zweierlei: eine fehlende Validierung – und ein Fehler-Orakel. Wer
|
||||
* 200 gegen 500 vergleicht, liest die Enum-Mitglieder aus, ohne sie zu kennen.
|
||||
* Ein ungueltiger Filter ist eine schlechte ANFRAGE, keine Server-Panne: 400.
|
||||
*
|
||||
* Dasselbe gilt fuer Datumsangaben (`new Date('foo')` ergibt Invalid Date und
|
||||
* sprengt die Query erst in der Datenbank) und fuer Zahlen (`parseInt('x')`
|
||||
* ergibt NaN).
|
||||
*/
|
||||
class FilterFehler extends FachlicherFehler {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const AUDIT_ACTIONS = Object.values(AuditAction) as string[];
|
||||
const AUDIT_SENSITIVITIES = Object.values(AuditSensitivity) as string[];
|
||||
|
||||
function pruefeEnum<T extends string>(
|
||||
wert: unknown, erlaubt: string[], feld: string,
|
||||
): T | undefined {
|
||||
if (wert === undefined || wert === '') return undefined;
|
||||
if (typeof wert !== 'string' || !erlaubt.includes(wert)) {
|
||||
// Die erlaubten Werte stehen ohnehin in der Oberflaeche und im Schema –
|
||||
// sie zu nennen verraet nichts und erspart Rateversuche.
|
||||
throw new FilterFehler(
|
||||
`Ungültiger Wert für "${feld}". Erlaubt: ${erlaubt.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return wert as T;
|
||||
}
|
||||
|
||||
function pruefeDatum(wert: unknown, feld: string): Date | undefined {
|
||||
if (wert === undefined || wert === '') return undefined;
|
||||
const d = new Date(wert as string);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
throw new FilterFehler(`Ungültiges Datum für "${feld}".`);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
function pruefeZahl(wert: unknown, feld: string): number | undefined {
|
||||
if (wert === undefined || wert === '') return undefined;
|
||||
const n = Number(wert);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new FilterFehler(`Ungültige Zahl für "${feld}".`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Freitextfelder begrenzen – unbegrenzte LIKE-Muster sind teuer. */
|
||||
function pruefeText(wert: unknown, feld: string, maxLaenge = 200): string | undefined {
|
||||
if (wert === undefined || wert === '') return undefined;
|
||||
if (typeof wert !== 'string' || wert.length > maxLaenge) {
|
||||
throw new FilterFehler(`Ungültiger Wert für "${feld}" (max. ${maxLaenge} Zeichen).`);
|
||||
}
|
||||
return wert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter aus der Query lesen – EINE Stelle fuer Liste und Export (R186-01).
|
||||
*
|
||||
* Vorher pflegte jeder Endpunkt seine eigene Liste, und die des Exports war
|
||||
* kuerzer: `userId`, `customerId`, `dataSubjectId`, `resourceId`, `success` und
|
||||
* `search` wurden dort stillschweigend verworfen. Ein bewusst eingegrenzter
|
||||
* Export – „nur die Spur von Benutzer X“ fuer eine DSGVO-Auskunft oder eine
|
||||
* Innentaeter-Pruefung – lieferte damit das VOLLSTAENDIGE Protokoll aller
|
||||
* Nutzer zurueck. Mit HTTP 200 und ohne jeden Hinweis: ein beruhigendes
|
||||
* Signal ueber einem Ergebnis, das genau das Gegenteil dessen ist, was
|
||||
* angefragt wurde. Auf einem datenminimierungspflichtigen Export ist das
|
||||
* nicht nur ein fehlender Filter, sondern eine Weitergabe.
|
||||
*
|
||||
* Zwei Listen, die dasselbe bedeuten sollen, laufen frueher oder spaeter
|
||||
* auseinander. Deshalb gibt es jetzt nur noch diese eine.
|
||||
*/
|
||||
function leseFilter(req: AuthRequest) {
|
||||
const q = req.query;
|
||||
return {
|
||||
userId: pruefeZahl(q.userId, 'userId'),
|
||||
customerId: pruefeZahl(q.customerId, 'customerId'),
|
||||
dataSubjectId: pruefeZahl(q.dataSubjectId, 'dataSubjectId'),
|
||||
action: pruefeEnum<AuditAction>(q.action, AUDIT_ACTIONS, 'action'),
|
||||
sensitivity: pruefeEnum<AuditSensitivity>(q.sensitivity, AUDIT_SENSITIVITIES, 'sensitivity'),
|
||||
resourceType: pruefeText(q.resourceType, 'resourceType', 100),
|
||||
resourceId: pruefeText(q.resourceId, 'resourceId', 100),
|
||||
startDate: pruefeDatum(q.startDate, 'startDate'),
|
||||
endDate: pruefeDatum(q.endDate, 'endDate'),
|
||||
success: q.success !== undefined ? q.success === 'true' : undefined,
|
||||
search: pruefeText(q.search, 'search'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit-Logs mit Filtern abrufen
|
||||
*/
|
||||
export async function getAuditLogs(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const {
|
||||
userId,
|
||||
customerId,
|
||||
dataSubjectId,
|
||||
action,
|
||||
sensitivity,
|
||||
resourceType,
|
||||
resourceId,
|
||||
startDate,
|
||||
endDate,
|
||||
success,
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
} = req.query;
|
||||
|
||||
const result = await auditService.searchAuditLogs({
|
||||
userId: userId ? parseInt(userId as string) : undefined,
|
||||
customerId: customerId ? parseInt(customerId as string) : undefined,
|
||||
dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined,
|
||||
action: action as AuditAction | undefined,
|
||||
sensitivity: sensitivity as AuditSensitivity | undefined,
|
||||
resourceType: resourceType as string | undefined,
|
||||
resourceId: resourceId as string | undefined,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
success: success !== undefined ? success === 'true' : undefined,
|
||||
search: search as string | undefined,
|
||||
page: page ? parseInt(page as string) : 1,
|
||||
limit: limit ? parseInt(limit as string) : 50,
|
||||
...leseFilter(req),
|
||||
page: pruefeZahl(req.query.page, 'page') || 1,
|
||||
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
|
||||
// Zug ziehen, an der Seitenlogik vorbei.
|
||||
limit: Math.min(pruefeZahl(req.query.limit, 'limit') || 50, 200),
|
||||
});
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
if (error instanceof FilterFehler) {
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Fehler beim Abrufen der Audit-Logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
|
||||
}
|
||||
@@ -96,25 +175,8 @@ export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
|
||||
*/
|
||||
export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const format = (req.query.format as 'json' | 'csv') || 'json';
|
||||
const {
|
||||
action,
|
||||
sensitivity,
|
||||
resourceType,
|
||||
startDate,
|
||||
endDate,
|
||||
} = req.query;
|
||||
|
||||
const content = await auditService.exportAuditLogs(
|
||||
{
|
||||
action: action as AuditAction | undefined,
|
||||
sensitivity: sensitivity as AuditSensitivity | undefined,
|
||||
resourceType: resourceType as string | undefined,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
},
|
||||
format
|
||||
);
|
||||
const format = req.query.format === 'csv' ? 'csv' : 'json';
|
||||
const content = await auditService.exportAuditLogs(leseFilter(req), format);
|
||||
|
||||
if (format === 'csv') {
|
||||
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
@@ -126,6 +188,10 @@ export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
||||
res.json({ success: true, data: JSON.parse(content) })
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof FilterFehler) {
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Fehler beim Exportieren der Audit-Logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
|
||||
}
|
||||
@@ -143,15 +209,178 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
||||
toId ? parseInt(toId as string) : undefined
|
||||
);
|
||||
|
||||
// Zwei sehr unterschiedliche Befunde sauber trennen – vorher wurde beides
|
||||
// pauschal als "manipuliert" gemeldet, was strukturelle Lücken wie einen
|
||||
// echten Angriff aussehen liess (und damit die Meldung entwertete).
|
||||
const tampered = result.tamperedEntries.length;
|
||||
const gaps = result.chainGaps.length;
|
||||
const unexplained = result.unexplainedGaps.length;
|
||||
// Beglaubigte Alt-Luecken sind kein offener Befund mehr, verschwinden aber
|
||||
// auch nicht aus dem Bericht - sie werden eigens benannt.
|
||||
const beglaubigt = result.attestedGaps.length;
|
||||
const offeneGaps = gaps - beglaubigt;
|
||||
const offeneUnexplained = result.unexplainedGaps.filter(
|
||||
(id) => !result.attestedGaps.includes(id),
|
||||
).length;
|
||||
|
||||
const luecken = offeneGaps > 0
|
||||
? `${offeneGaps} strukturelle Lücke${offeneGaps === 1 ? '' : 'n'}` +
|
||||
(offeneUnexplained === 0
|
||||
? ' (alle durch protokollierte Löschungen erklärt)'
|
||||
: offeneUnexplained < offeneGaps
|
||||
? `, davon ${offeneUnexplained} ohne dokumentierte Löschung`
|
||||
: ' ohne dokumentierte Löschung')
|
||||
: '';
|
||||
|
||||
// Der Satz erscheint IMMER, wenn beglaubigte Luecken existieren - auch
|
||||
// neben einem Befund. Wer den Bericht liest, soll nie den Eindruck
|
||||
// bekommen, die Kette sei lueckenlos, wenn sie es nicht ist.
|
||||
const weitere = tampered > 0 || offeneGaps > 0 ? 'weitere ' : '';
|
||||
const beglaubigtText =
|
||||
beglaubigt === 0
|
||||
? ''
|
||||
: beglaubigt === 1
|
||||
? ` Eine ${weitere}Lücke stammt aus der Zeit vor dem Bestandssiegel und ist darin als ` +
|
||||
`Vorbefund beglaubigt (ID ${result.attestedGaps[0]}); der betroffene Eintrag selbst ` +
|
||||
'ist unverändert.'
|
||||
: ` ${beglaubigt} ${weitere}Lücken stammen aus der Zeit vor dem Bestandssiegel und sind ` +
|
||||
`darin als Vorbefund beglaubigt (IDs ${result.attestedGaps.join(', ')}); ` +
|
||||
'die betroffenen Einträge selbst sind unverändert.';
|
||||
|
||||
const unverifiable = result.unverifiableEntries.length;
|
||||
const keinSchluessel = unverifiable > 0
|
||||
? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.`
|
||||
: '';
|
||||
|
||||
const siegel =
|
||||
result.backlogSealStatus === 'gebrochen'
|
||||
? ' ⚠ Bestandssiegel GEBROCHEN:' +
|
||||
(result.backlogTampered.length
|
||||
? ` ${result.backlogTampered.length} Alteinträge nachträglich verändert.`
|
||||
: '') +
|
||||
(result.backlogMissing.length
|
||||
? ` ${result.backlogMissing.length} gesiegelte Einträge wurden ENTFERNT (${result.backlogMissing.join(', ')}).`
|
||||
: '') +
|
||||
(!result.backlogTampered.length && !result.backlogMissing.length
|
||||
? ' Der Blattbestand des Siegels stimmt nicht mehr mit der signierten Wurzel überein.'
|
||||
: '')
|
||||
: result.backlogSealStatus === 'entfernt'
|
||||
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
|
||||
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht – Änderungen am ' +
|
||||
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
|
||||
: result.backlogSealStatus === 'leer'
|
||||
? ' Hinweis: Es besteht ein Bestandssiegel, das aber NICHTS umschließt – ' +
|
||||
'zum Zeitpunkt des Siegelns gab es keine unsignierten Alteinträge. Es sichert ' +
|
||||
'also nichts ab. Das ist kein Fehler, aber auch keine Zusage.'
|
||||
: result.backlogSealStatus === 'nicht_noetig'
|
||||
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
|
||||
: result.backlogSealStatus === 'kein_siegel'
|
||||
? ' Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären nicht erkennbar. ' +
|
||||
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
|
||||
: '';
|
||||
|
||||
// Eine Neuberechnung der Kette gehoert IMMER erwaehnt – auch und gerade,
|
||||
// wenn sonst alles grün ist. Nach einem Rehash ist die Kette
|
||||
// zwangslaeufig stimmig, auch ueber Loeschungen hinweg. „Lueckenlos
|
||||
// verkettet“ heisst dann nur noch „seit dem Rehash“, und wer das nicht
|
||||
// mitliest, nimmt eine Entwarnung mit, die es so nicht gibt.
|
||||
const rehashText = (() => {
|
||||
if (result.rehashes.length === 0) return '';
|
||||
const letzter = result.rehashes[result.rehashes.length - 1];
|
||||
const datum = new Date(letzter.zeitpunkt).toLocaleString('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
const wieOft =
|
||||
result.rehashes.length === 1
|
||||
? 'Die Kette wurde einmal neu berechnet'
|
||||
: `Die Kette wurde ${result.rehashes.length}× neu berechnet, zuletzt`;
|
||||
// Nur benennen, was es zu benennen gibt. Bei sauberem Vorzustand ist die
|
||||
// Auskunft „nichts uebertuencht“ selbst eine nuetzliche Information –
|
||||
// ein Rehash ueber einer unbeanstandeten Kette wiegt anders als einer
|
||||
// ueber 700 Luecken.
|
||||
const vb = letzter.vorbefund;
|
||||
const uebertuencht = !vb
|
||||
? ' Der Zustand vor dieser Neuberechnung ist nicht mehr feststellbar.'
|
||||
: vb.manipuliert === 0 && vb.luecken === 0
|
||||
? ' Die Kette war unmittelbar davor unbeanstandet – es wurde nichts überdeckt.'
|
||||
: ' Unmittelbar davor: ' +
|
||||
`${vb.manipuliert} beanstandete${vb.manipuliert === 1 ? 'r Eintrag' : ' Einträge'}` +
|
||||
` und ${vb.luecken} Lücke${vb.luecken === 1 ? '' : 'n'}. ` +
|
||||
'Diese Spuren sind seitdem nicht mehr in der Kette sichtbar, sondern nur noch im ' +
|
||||
`Vorbefund des Rehash-Eintrags (id ${letzter.id}).`;
|
||||
const ohneSignatur = result.rehashes.some((r) => !r.signiert)
|
||||
? ' Achtung: Mindestens ein Rehash-Eintrag trägt keine gültige Signatur.'
|
||||
: '';
|
||||
return (
|
||||
` ⚠ ${wieOft} am ${datum}` +
|
||||
(letzter.neuBerechnet !== null ? ` (${letzter.neuBerechnet} Einträge)` : '') +
|
||||
'. Ein Rehash verknüpft alle Einträge neu – die Aussage dieser Prüfung ' +
|
||||
'reicht deshalb nur bis dorthin zurück, nicht weiter.' +
|
||||
uebertuencht +
|
||||
ohneSignatur
|
||||
);
|
||||
})();
|
||||
|
||||
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
|
||||
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
|
||||
const mehrfach =
|
||||
result.backlogSealCount > 1
|
||||
? ` Hinweis: Der Altbestand wurde ${result.backlogSealCount}× versiegelt – der jüngste Marker gilt. ` +
|
||||
'Jeder Marker enthält den Befund vor dem Siegeln; bei Zweifeln dort vergleichen.'
|
||||
: '';
|
||||
|
||||
// Bei problematischem Siegel darf KEIN beruhigender Einstiegssatz stehen –
|
||||
// sonst entwertet er die nachfolgende Warnung (Pentest R174-01).
|
||||
const siegelProblem =
|
||||
result.backlogSealStatus === 'gebrochen' || result.backlogSealStatus === 'entfernt';
|
||||
|
||||
// „Keine Manipulation“ darf NICHT dastehen, solange `valid` falsch ist
|
||||
// (Pentest R183-02). Nach einem Cleanup mit abgesenkter Aufbewahrung waren
|
||||
// tausende Anmeldeprotokolle endgueltig geloescht – die Luecken durch
|
||||
// Tombstones „erklaert“, die Meldung las sich beruhigend, und der Befund
|
||||
// stand nur noch im Feld `valid`. Wer die Prosa liest statt des Felds,
|
||||
// klickt genau das weg, was ihn haette warnen sollen.
|
||||
const message = tampered > 0
|
||||
? `${tampered} MANIPULIERTE Einträge gefunden` + (offeneGaps > 0 ? ` (zusätzlich ${luecken})` : '')
|
||||
: siegelProblem
|
||||
? 'Die Kette selbst ist rechnerisch stimmig, ABER:'
|
||||
: offeneGaps > 0
|
||||
? `Die Kette ist nicht mehr lückenlos: ${luecken}. Die verbliebenen Inhalte sind ` +
|
||||
'unverändert – aber gelöschte Einträge lassen sich naturgemäß nicht mehr prüfen. ' +
|
||||
'Dokumentierte Löschungen sind erwartbar; unerwartete gehören nachgegangen.'
|
||||
: beglaubigt > 0
|
||||
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
|
||||
'entstanden.'
|
||||
: result.rehashes.length > 0
|
||||
? 'Alle Einträge sind unverändert und lückenlos verkettet – allerdings erst ' +
|
||||
'seit der letzten Neuberechnung.'
|
||||
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
valid: result.valid,
|
||||
checkedCount: result.checkedCount,
|
||||
invalidEntries: result.invalidEntries,
|
||||
message: result.valid
|
||||
? 'Alle Einträge sind valide'
|
||||
: `${result.invalidEntries.length} manipulierte Einträge gefunden`,
|
||||
// Ernst: Inhalt einer bestehenden Zeile wurde nachträglich verändert.
|
||||
tamperedEntries: result.tamperedEntries,
|
||||
// Meist harmlos: Verkettung unterbrochen, Inhalte selbst unversehrt.
|
||||
chainGaps: result.chainGaps,
|
||||
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
|
||||
unexplainedGaps: result.unexplainedGaps,
|
||||
// Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt.
|
||||
attestedGaps: result.attestedGaps,
|
||||
// Protokollierte Neuberechnungen – begrenzen die Reichweite der Aussage.
|
||||
rehashes: result.rehashes,
|
||||
tampered: tampered > 0,
|
||||
message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach,
|
||||
unverifiableEntries: result.unverifiableEntries,
|
||||
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||||
backlogSealStatus: result.backlogSealStatus,
|
||||
backlogTampered: result.backlogTampered,
|
||||
backlogMissing: result.backlogMissing,
|
||||
backlogSealCount: result.backlogSealCount,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -165,7 +394,26 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
||||
*/
|
||||
export async function rehashAll(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const result = await auditService.rehashAll();
|
||||
// Ausdrueckliche Bestaetigung verlangen (Pentest R170-01).
|
||||
//
|
||||
// Ein Rehash macht die Kette rechnerisch stimmig und setzt damit die
|
||||
// Beweiskraft der Vergangenheit zurueck - das darf kein Nebeneffekt eines
|
||||
// versehentlichen oder tastenden POST sein. Genau so wurde der Endpunkt
|
||||
// bei einer Methoden-Erkundung unbeabsichtigt ausgeloest.
|
||||
if (req.body?.confirm !== 'REHASH') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'Rehash setzt die Beweiskraft der bestehenden Einträge zurück und ist nicht ' +
|
||||
'umkehrbar. Zum Bestätigen {"confirm":"REHASH"} mitsenden.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await auditService.rehashAll({
|
||||
userEmail: req.user?.email,
|
||||
ipAddress: req.ip || (req.socket as any)?.remoteAddress,
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
@@ -177,6 +425,128 @@ export async function rehashAll(req: AuthRequest, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontrollwert fuer das externe Gegenbuch (Notarisierung).
|
||||
*
|
||||
* Bewusst nur lesend und ohne Geheimnisse - die Gegenstelle braucht keinerlei
|
||||
* Schreibrecht auf dieses System, und dieses System kennt den Schluessel der
|
||||
* Gegenstelle nicht. Genau diese Trennung ist der Schutz.
|
||||
*/
|
||||
export async function getCheckpoint(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const roh = req.query.atId as string | undefined;
|
||||
const atId = roh !== undefined ? parseInt(roh, 10) : undefined;
|
||||
if (roh !== undefined && Number.isNaN(atId as number)) {
|
||||
res.status(400).json({ success: false, error: 'atId muss eine Zahl sein' });
|
||||
return;
|
||||
}
|
||||
const data = await auditService.getCheckpoint(atId);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen des Kontrollwerts:', error);
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Erstellen des Kontrollwerts' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Altbestand versiegeln (Pentest R171-02).
|
||||
*
|
||||
* Einmalige, NICHT destruktive Aktion: schreibt nichts um, sondern haelt den
|
||||
* Ist-Inhalt der nicht signierbaren Alteintraege fest.
|
||||
*/
|
||||
export async function sealBacklog(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
// Erst nachsehen, ob schon ein Siegel steht (Pentest R185-01).
|
||||
//
|
||||
// Ein zweites Siegeln ist etwas grundlegend anderes als das erste: Es
|
||||
// ERSETZT die Grundlage, gegen die Manipulation nachgewiesen wird. Wer den
|
||||
// Altbestand per Datenbankzugriff beschneidet und danach neu siegelt,
|
||||
// bekommt eine passende Wurzel und eine ueber den neuen Vorbefund
|
||||
// beglaubigte Luecke - und `valid` steht wieder auf `true`. Deshalb
|
||||
// verlangt das Ersetzen ein eigenes Wort und nicht dasselbe wie das
|
||||
// Einrichten.
|
||||
const vorher = await auditService.verifyIntegrity();
|
||||
const siegelSteht =
|
||||
vorher.backlogSealStatus === 'intakt' ||
|
||||
vorher.backlogSealStatus === 'gebrochen' ||
|
||||
vorher.backlogSealStatus === 'entfernt';
|
||||
|
||||
const erwartet = siegelSteht ? 'RESEAL' : 'SEAL';
|
||||
if (req.body?.confirm !== erwartet) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: siegelSteht
|
||||
? 'Es besteht bereits ein Bestandssiegel. Erneutes Siegeln ERSETZT die ' +
|
||||
'bisherige Beweisgrundlage: bestehende Lücken werden neu beglaubigt und ' +
|
||||
'ein Befund am Altbestand verschwindet aus der Prüfung. Das ist kein ' +
|
||||
'Wartungsschritt. Zum Bestätigen {"confirm":"RESEAL"} mitsenden – und ' +
|
||||
'vorher klären, warum das bisherige Siegel nicht mehr passt.'
|
||||
: 'Versiegelt den aktuellen Stand des Altbestands. Zum Bestätigen ' +
|
||||
'{"confirm":"SEAL"} mitsenden.',
|
||||
aktuellerSiegelzustand: vorher.backlogSealStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await auditService.sealBacklog({
|
||||
userEmail: req.user?.email,
|
||||
ipAddress: req.ip || (req.socket as any)?.remoteAddress,
|
||||
});
|
||||
|
||||
// In den ALARMKANAL, nicht nur ins Audit-Log (Pentest R185-01).
|
||||
//
|
||||
// Die CRITICAL-Zeile im Audit-Log gab es schon - aber die muss jemand
|
||||
// lesen, und genau das ist die R183-02/R184-01-Klasse. Der automatische
|
||||
// Rueckhalt des Gegenbuchs haengt an `valid`, und `valid` ueberlebt ein
|
||||
// ersetzendes Siegel per Konstruktion. Der einzige maschinell erkennbare
|
||||
// Anker ist der Wechsel der Wurzel - der gehoert dorthin, wo etwas von
|
||||
// selbst passiert.
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'AUDIT_SEAL_CHANGED',
|
||||
severity: siegelSteht ? 'CRITICAL' : 'HIGH',
|
||||
message: siegelSteht
|
||||
? `Bestandssiegel ERSETZT (${result.sealedCount} Alteinträge, id ${result.fromId}–` +
|
||||
`${result.toId}). Die bisherige Beweisgrundlage gilt nicht mehr; der Zustand ` +
|
||||
`davor war "${vorher.backlogSealStatus}". Wenn das keine geplante Maßnahme war, ` +
|
||||
'ist es ein Befund.'
|
||||
: `Bestandssiegel erstmals gesetzt (${result.sealedCount} Alteinträge, id ` +
|
||||
`${result.fromId}–${result.toId}). Ab jetzt fallen Änderungen am Altbestand auf.`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: {
|
||||
ersetzt: siegelSteht,
|
||||
zustandVorher: vorher.backlogSealStatus,
|
||||
wurzelVorher: vorher.backlogSealRoot,
|
||||
wurzelNachher: result.root,
|
||||
befundVorher: {
|
||||
manipuliert: vorher.tamperedEntries,
|
||||
luecken: vorher.chainGaps,
|
||||
altbestandVeraendert: vorher.backlogTampered,
|
||||
altbestandFehlend: vorher.backlogMissing,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message:
|
||||
`${result.sealedCount} Alteinträge (id ${result.fromId}–${result.toId}) versiegelt. ` +
|
||||
'Spätere Änderungen an diesen Einträgen fallen ab sofort auf.' +
|
||||
(siegelSteht
|
||||
? ' ACHTUNG: Dies war ein ERSETZENDES Siegel – die vorherige Beweisgrundlage ' +
|
||||
'gilt nicht mehr.'
|
||||
: ''),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Versiegeln des Altbestands:', error);
|
||||
antworteAufFehler(res, error, 'Fehler beim Versiegeln');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention-Policies abrufen
|
||||
*/
|
||||
@@ -201,6 +571,48 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
||||
}
|
||||
const { retentionDays, description, legalBasis, isActive } = req.body;
|
||||
|
||||
// Die Richtlinie bestimmt, WAS der Cleanup vernichtet – sie ist die
|
||||
// geladene Waffe, der Cleanup nur der Abzug (Pentest R183-01). Bisher war
|
||||
// ausgerechnet der Abzug gegatet und die Waffe frei zugaenglich: Ein
|
||||
// `retentionDays: 0` ging ohne Bestaetigung durch und wurde nur als MEDIUM
|
||||
// protokolliert, waehrend seine Wirkung CRITICAL ist. Die ausloesende Tat
|
||||
// war damit leiser als die Folge – und lag unter der Schwelle, bei der
|
||||
// jemand hinsieht.
|
||||
const bisher = (await auditService.getRetentionPolicies()).find((p) => p.id === id);
|
||||
if (!bisher) {
|
||||
return res.status(404).json({ success: false, error: 'Aufbewahrungsrichtlinie nicht gefunden' });
|
||||
}
|
||||
|
||||
const neueTage = typeof retentionDays === 'number' ? retentionDays : bisher.retentionDays;
|
||||
|
||||
// Untergrenze: Auth-Eintraege sind der Einbruchsbeleg schlechthin. Eine
|
||||
// Aufbewahrung von faktisch null macht sie mit dem naechsten Cleanup
|
||||
// spurlos entfernbar – ueber einen sanktionierten Pfad.
|
||||
const UNTERGRENZE: Record<string, number> = { Authentication: 30, AuditLog: 30 };
|
||||
const grenze = UNTERGRENZE[bisher.resourceType];
|
||||
if (grenze !== undefined && neueTage < grenze) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
`Für ${bisher.resourceType} sind mindestens ${grenze} Tage Aufbewahrung vorgeschrieben ` +
|
||||
`(angefragt: ${neueTage}). Diese Einträge belegen Anmeldungen und Zugriffe – eine ` +
|
||||
`Aufbewahrung nahe null macht sie beim nächsten Cleanup spurlos entfernbar.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Absenken ist die gefaehrliche Richtung und braucht dieselbe ausdrueckliche
|
||||
// Bestaetigung wie der Cleanup selbst.
|
||||
const senktAb = neueTage < bisher.retentionDays;
|
||||
if (senktAb && req.body?.confirm !== 'SHORTEN') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
`Das verkürzt die Aufbewahrung von ${bisher.retentionDays} auf ${neueTage} Tage. ` +
|
||||
'Beim nächsten Cleanup werden dadurch Einträge endgültig gelöscht, die heute noch ' +
|
||||
'da sind. Zum Bestätigen {"confirm":"SHORTEN"} mitsenden.',
|
||||
});
|
||||
}
|
||||
|
||||
const policy = await auditService.updateRetentionPolicy(id, {
|
||||
retentionDays,
|
||||
description,
|
||||
@@ -211,7 +623,15 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
|
||||
resourceId: id.toString(),
|
||||
label: `Aufbewahrungsrichtlinie aktualisiert`,
|
||||
// Absenkung wird wie ihre Folge eingestuft: CRITICAL, nicht MEDIUM.
|
||||
sensitivity: senktAb ? 'CRITICAL' : undefined,
|
||||
label: senktAb
|
||||
? `Aufbewahrung VERKÜRZT für ${bisher.resourceType}` +
|
||||
(bisher.sensitivity ? `/${bisher.sensitivity}` : '') +
|
||||
`: ${bisher.retentionDays} → ${neueTage} Tage`
|
||||
: `Aufbewahrungsrichtlinie aktualisiert`,
|
||||
before: { retentionDays: bisher.retentionDays },
|
||||
details: { retentionDays: neueTage },
|
||||
});
|
||||
|
||||
res.json({ success: true, data: policy });
|
||||
@@ -226,6 +646,18 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
||||
*/
|
||||
export async function runRetentionCleanup(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
// Auch hier ausdrueckliche Bestaetigung: Cleanup loescht Audit-Zeilen
|
||||
// endgueltig und reisst dabei die Kette auf (Pentest R170-01).
|
||||
if (req.body?.confirm !== 'CLEANUP') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'Cleanup löscht Audit-Einträge endgültig. Zum Bestätigen ' +
|
||||
'{"confirm":"CLEANUP"} mitsenden.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await auditService.runRetentionCleanup();
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response, CookieOptions } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as authService from '../services/auth.service.js';
|
||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
@@ -281,10 +282,7 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
||||
ipAddress: ctx.ipAddress,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Passwort-Reset fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,11 +307,16 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
||||
where: { id: user.customerId },
|
||||
data: { portalTokenInvalidatedAt: new Date() },
|
||||
});
|
||||
// Ausgegebene Refresh-Tokens zusätzlich hart widerrufen (R164-02):
|
||||
// der Zeitstempel-Vergleich allein greift erst beim nächsten Refresh,
|
||||
// der Widerruf macht die Tokens sofort unbrauchbar.
|
||||
await authService.revokeAllRefreshTokens({ customerId: user.customerId }, 'Logout');
|
||||
} else if (user.userId) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.userId },
|
||||
data: { tokenInvalidatedAt: new Date() },
|
||||
});
|
||||
await authService.revokeAllRefreshTokens({ userId: user.userId }, 'Logout');
|
||||
}
|
||||
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
|
||||
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
|
||||
@@ -362,10 +365,45 @@ export async function refresh(req: Request, res: Response): Promise<void> {
|
||||
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
||||
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
||||
clearRefreshCookie(res);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
// Detection (Pentest R164-01): ein vorgelegter, aber abgelehnter Refresh-Token
|
||||
// ist potenzieller Replay/Brute-Force → TOKEN_REJECTED emittieren, damit die
|
||||
// bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP → CRITICAL) greift.
|
||||
// Severity wie beim Access-Token (auth-Middleware): abgelaufen/revoked = LOW
|
||||
// (benigne, kein Sofort-Alert), ungültige Signatur/Manipulation = HIGH.
|
||||
const ctx = contextFromRequest(req);
|
||||
const code = (error as { code?: string })?.code;
|
||||
const msg = error instanceof Error ? error.message : 'Refresh fehlgeschlagen';
|
||||
|
||||
if (code === 'REFRESH_REPLAY') {
|
||||
// Ein bereits eingelöster Refresh-Token wurde erneut vorgelegt. Das ist
|
||||
// kein Betriebsfehler, sondern der Abdruck eines gestohlenen Tokens –
|
||||
// die Sitzungsfamilie ist bereits widerrufen (R164-02).
|
||||
emitSecurityEvent({
|
||||
type: 'SUSPICIOUS',
|
||||
severity: 'CRITICAL',
|
||||
message: 'Refresh-Token-Replay erkannt – Sitzungsfamilie widerrufen',
|
||||
ipAddress: ctx.ipAddress,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
} else {
|
||||
// REFRESH_LEGACY = Token aus der Zeit vor dem Replay-Schutz; das ist eine
|
||||
// einmalige Neuanmeldung nach dem Deploy, kein Angriffsindiz.
|
||||
const benign = code === 'REFRESH_EXPIRED' || code === 'REFRESH_LEGACY'
|
||||
|| code === 'REFRESH_REVOKED' || /invalidiert/i.test(msg);
|
||||
emitSecurityEvent({
|
||||
type: 'TOKEN_REJECTED',
|
||||
severity: benign ? 'LOW' : 'HIGH',
|
||||
message: `Refresh-Token abgelehnt: ${msg}`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
endpoint: ctx.endpoint,
|
||||
});
|
||||
}
|
||||
// `msg` geht weiterhin in den Alarmkanal (oben), aber nicht mehr an den
|
||||
// Aufrufer: Bei einem abgelehnten Refresh stammt der Wortlaut aus der
|
||||
// JWT-Bibliothek ("jwt malformed", "invalid signature") und sagt einem
|
||||
// Angreifer, WORAN sein Token gescheitert ist. Fuer den berechtigten
|
||||
// Nutzer aendert das nichts - er muss sich so oder so neu anmelden.
|
||||
antworteAufFehler(res, error, 'Refresh fehlgeschlagen', 401);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,9 +525,6 @@ export async function changeInitialPortalPassword(req: AuthRequest, res: Respons
|
||||
clearRefreshCookie(res);
|
||||
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Passwort konnte nicht geändert werden', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +374,12 @@ export async function factoryReset(req: Request, res: Response) {
|
||||
label: `Werkseinstellungen wiederhergestellt`,
|
||||
});
|
||||
res.json({
|
||||
message: 'Werkseinstellungen wiederhergestellt. Bitte melden Sie sich mit admin@admin.com / admin an.',
|
||||
// Das Kennwort ist nicht mehr "admin", sondern zufaellig und steht
|
||||
// genau einmal im Server-Log. Die alte Meldung hier nannte es noch
|
||||
// im Klartext - sie haette nach der Haertung ins Leere gefuehrt.
|
||||
message:
|
||||
'Werkseinstellungen wiederhergestellt. Das neue Kennwort für admin@admin.com ' +
|
||||
'steht einmalig im Server-Log (docker compose logs backend).',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { isValidIBAN } from 'ibantools';
|
||||
import * as blzData from '../services/blzData.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* IBAN-Lookup: prüft die Prüfziffer (mod-97, offline über ibantools) und liefert
|
||||
* – für deutsche IBANs – BIC und Banknamen aus der Bundesbank-Bankleitzahlen-
|
||||
* datei (offline; siehe blzData.service – Volume-Datensatz bevorzugt, sonst die
|
||||
* ins Image gebackenen Daten). Die IBAN verlässt NICHT den Server; es findet
|
||||
* KEIN externer Request statt. Zurückgegeben werden nur öffentliche
|
||||
* Bankverzeichnis-Daten (BIC/Name), keine kundenbezogenen Informationen.
|
||||
*
|
||||
* Antwortformen (immer HTTP 200, sofern eine IBAN übergeben wurde):
|
||||
* { valid: false } → Prüfziffer falsch (Tippfehler)
|
||||
* { valid: true, bic, bankName, blz } → gültig + DE-Bank gefunden
|
||||
* { valid: true, bic: null, bankName: null } → gültig, aber keine DE-Bankdaten
|
||||
* (Nicht-DE-IBAN oder unbekannte BLZ)
|
||||
*/
|
||||
export async function lookupIban(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const raw = typeof req.body?.iban === 'string' ? req.body.iban : '';
|
||||
const iban = raw.replace(/\s+/g, '').toUpperCase();
|
||||
|
||||
if (!iban) {
|
||||
res.status(400).json({ success: false, error: 'Feld IBAN ist erforderlich.' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidIBAN(iban)) {
|
||||
res.json({ success: true, data: { valid: false, iban } } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const country = iban.slice(0, 2);
|
||||
const bank = country === 'DE' ? blzData.lookupByIban(iban) : null;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
valid: true,
|
||||
iban,
|
||||
country,
|
||||
bic: bank?.bic ?? null,
|
||||
bankName: bank?.bankName ?? null,
|
||||
blz: bank?.blz ?? null,
|
||||
},
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
antworteAufFehler(res, error, 'Fehler beim IBAN-Nachschlagen', 500);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import * as birthdayService from '../services/birthday.service.js';
|
||||
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
|
||||
@@ -89,10 +90,7 @@ export async function resetBirthdayGreeting(req: AuthRequest, res: Response) {
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Zurücksetzen:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,9 +178,6 @@ export async function sendBirthdayGreeting(req: AuthRequest, res: Response) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Senden',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import * as blzData from '../services/blzData.service.js';
|
||||
|
||||
// Status der BLZ-/Bankdaten (installierte Version, Quelle, Auto-Update-Stand).
|
||||
export async function getStatus(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const status = await blzData.getStatus();
|
||||
res.json({ success: true, data: status } as ApiResponse);
|
||||
} catch (error) {
|
||||
antworteAufFehler(res, error, 'Fehler beim Laden des BLZ-Status', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Jetzt prüfen/aktualisieren (manueller Trigger). ?force=1 lädt auch bei
|
||||
// gleicher Version neu.
|
||||
export async function updateNow(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const force = req.query.force === '1' || req.query.force === 'true';
|
||||
const result = await blzData.runUpdate(force);
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'AppSetting',
|
||||
resourceId: 'blz-data',
|
||||
label: `BLZ-Bankdaten: ${result.message}`,
|
||||
});
|
||||
const status = await blzData.getStatus();
|
||||
res.json({ success: true, data: { result, status } } as ApiResponse);
|
||||
} catch (error) {
|
||||
antworteAufFehler(res, error, 'BLZ-Aktualisierung fehlgeschlagen', 502);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// ==================== CACHED EMAIL CONTROLLER ====================
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as cachedEmailService from '../services/cachedEmail.service.js';
|
||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||
import * as invoiceService from '../services/invoice.service.js';
|
||||
@@ -272,10 +273,7 @@ export async function assignToContract(req: AuthRequest, res: Response): Promise
|
||||
res.json({ success: true, data: email } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('assignToContract error:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Zuordnen der E-Mail',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Zuordnen der E-Mail');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,10 +288,7 @@ export async function unassignFromContract(req: AuthRequest, res: Response): Pro
|
||||
res.json({ success: true, data: email } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('unassignFromContract error:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aufheben der Zuordnung',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aufheben der Zuordnung');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1660,12 +1655,7 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentTo error:', error);
|
||||
// Detailliertere Fehlermeldung für Debugging
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Speichern des Anhangs: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Speichern des Anhangs', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1909,11 +1899,7 @@ export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<v
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveEmailAsPdf error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Fehler beim Erstellen der PDF: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der PDF', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2036,11 +2022,7 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveEmailAsInvoice error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2145,9 +2127,7 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
||||
console.error('saveEmailAsContractDocument error:', error);
|
||||
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
|
||||
// Lock) statt pauschal 500.
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2332,12 +2312,7 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentAsInvoice error:', error);
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2512,11 +2487,6 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentAsContractDocument error:', error);
|
||||
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as cancellationPeriodService from '../services/cancellation-period.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
@@ -46,10 +47,7 @@ export async function createCancellationPeriod(req: Request, res: Response): Pro
|
||||
});
|
||||
res.status(201).json({ success: true, data: period } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Kündigungsfrist',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Kündigungsfrist');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +62,7 @@ export async function updateCancellationPeriod(req: Request, res: Response): Pro
|
||||
});
|
||||
res.json({ success: true, data: period } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Kündigungsfrist',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Kündigungsfrist');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +78,6 @@ export async function deleteCancellationPeriod(req: Request, res: Response): Pro
|
||||
});
|
||||
res.json({ success: true, message: 'Kündigungsfrist gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Kündigungsfrist',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Kündigungsfrist');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Response } from 'express';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import * as companyProfileService from '../services/companyProfile.service.js';
|
||||
|
||||
export async function getProfile(_req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await companyProfileService.getOrCreateProfile();
|
||||
res.json({ success: true, data } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Firmendaten' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProfile(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const updated = await companyProfileService.updateProfile(req.body ?? {});
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'CompanyProfile',
|
||||
resourceId: updated.id.toString(),
|
||||
label: 'Firmenstammdaten (Absender) geändert',
|
||||
});
|
||||
res.json({ success: true, data: updated } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Speichern der Firmendaten' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as contractDurationService from '../services/contract-duration.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
@@ -46,10 +47,7 @@ export async function createContractDuration(req: Request, res: Response): Promi
|
||||
});
|
||||
res.status(201).json({ success: true, data: duration } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Laufzeit',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Laufzeit');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +62,7 @@ export async function updateContractDuration(req: Request, res: Response): Promi
|
||||
});
|
||||
res.json({ success: true, data: duration } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Laufzeit',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Laufzeit');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +78,6 @@ export async function deleteContractDuration(req: Request, res: Response): Promi
|
||||
});
|
||||
res.json({ success: true, message: 'Laufzeit gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Laufzeit',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Laufzeit');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import fs from 'fs';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import * as contractService from '../services/contract.service.js';
|
||||
@@ -11,7 +12,7 @@ import { logChange } from '../services/audit.service.js';
|
||||
import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
import { canAccessContract, canAccessCustomer } from '../utils/accessControl.js';
|
||||
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||
import { maybeActivateOnDeliveryConfirmation, maybeCancelOnCancellationConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||
|
||||
/**
|
||||
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
|
||||
@@ -96,17 +97,20 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht
|
||||
// Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht.
|
||||
// Fail-closed (Pentest R145): Portal-Token wird IMMER gescoped; fehlt wider
|
||||
// Erwarten die customerId, ergibt das eine leere Menge (nicht alle).
|
||||
let customerIds: number[] | undefined;
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
// Eigene Customer-ID immer
|
||||
customerIds = [req.user.customerId];
|
||||
// Vertretene Kunden nur wenn Vollmacht erteilt
|
||||
const representedIds: number[] = req.user.representedCustomerIds || [];
|
||||
for (const repCustId of representedIds) {
|
||||
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
|
||||
if (hasAuth) {
|
||||
customerIds.push(repCustId);
|
||||
if (req.user?.isCustomerPortal) {
|
||||
customerIds = req.user.customerId ? [req.user.customerId] : [];
|
||||
if (req.user.customerId) {
|
||||
// Vertretene Kunden nur wenn Vollmacht erteilt
|
||||
const representedIds: number[] = req.user.representedCustomerIds || [];
|
||||
for (const repCustId of representedIds) {
|
||||
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
|
||||
if (hasAuth) {
|
||||
customerIds.push(repCustId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +123,7 @@ export async function getContracts(req: AuthRequest, res: Response): Promise<voi
|
||||
search: search as string,
|
||||
page: page ? parseInt(page as string) : undefined,
|
||||
limit: limit ? parseInt(limit as string) : undefined,
|
||||
includeDeactivated: includeDeactivated === 'true',
|
||||
});
|
||||
// Portal-User bekommen die Strict-Variante (ohne commission/notes/
|
||||
// nextReviewDate/portalPasswordEncrypted), Mitarbeiter die normale.
|
||||
@@ -207,10 +212,7 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
|
||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Vertrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Vertrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,8 +232,44 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
|
||||
// HTML/JS-Strip auf allen String-Werten (Pentest 2026-05-24, 31.1)
|
||||
const sanitizedBody = sanitizeContractBody(req.body);
|
||||
|
||||
// Kündigungs-Datumsfelder robust normalisieren: `validateOptionalIsoDate`
|
||||
// akzeptiert Datum-only (YYYY-MM-DD) UND volles ISO und liefert einen
|
||||
// sauberen Date-Wert an Prisma. Ohne das lehnte Prisma ein Datum-only
|
||||
// ab (Pentest R138, Hygiene-Punkt). Konsistent zum Upload-Pfad, der
|
||||
// `confirmationDate` bereits so validiert.
|
||||
for (const df of ['cancellationConfirmationDate', 'cancellationConfirmationOptionsDate'] as const) {
|
||||
if (df in (sanitizedBody as Record<string, unknown>)) {
|
||||
let validated: string | null;
|
||||
try {
|
||||
validated = validateOptionalIsoDate((sanitizedBody as Record<string, unknown>)[df], df);
|
||||
} catch (err) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Ungültiges Datum',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
(sanitizedBody as Record<string, unknown>)[df] = validated ? new Date(validated) : null;
|
||||
}
|
||||
}
|
||||
|
||||
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
|
||||
|
||||
// Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
|
||||
// datum gesetzt wurde: Vertrag → CANCELLED_CONFIRMED ("Gekündigt / bestätigt")
|
||||
// + Vertragsende = Kündigungsdatum. Nur bei tatsächlicher Änderung auslösen,
|
||||
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
|
||||
{
|
||||
const asDay = (d: unknown) =>
|
||||
d ? new Date(d as string).toISOString().split('T')[0] : null;
|
||||
const beforeConf = asDay((before as any)?.cancellationConfirmationDate);
|
||||
const afterConf = asDay((sanitizedBody as any)?.cancellationConfirmationDate);
|
||||
if (afterConf && afterConf !== beforeConf) {
|
||||
await maybeCancelOnCancellationConfirmation(contractId, req);
|
||||
}
|
||||
}
|
||||
|
||||
// Geänderte Felder ermitteln
|
||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||
const fieldLabels: Record<string, string> = {
|
||||
@@ -334,10 +372,7 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
: sanitizeContract(contract as any);
|
||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Vertrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Vertrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,10 +391,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Vertrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,10 +439,7 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Folgevertrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Folgevertrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,10 +491,7 @@ export async function createRenewal(req: AuthRequest, res: Response): Promise<vo
|
||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der VVL');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,14 +619,19 @@ export async function getCockpit(req: AuthRequest, res: Response): Promise<void>
|
||||
// Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen.
|
||||
// Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden
|
||||
// (Pentest Runde 4, 2026-05-16: HOCH).
|
||||
// Fail-closed (Pentest R146): Portal-Token wird IMMER gescoped; fehlt wider
|
||||
// Erwarten die customerId, ergibt das eine leere Menge (nicht alle) – der
|
||||
// Cockpit-Service filtert `customerIds: []` bereits als „keine Treffer".
|
||||
let customerIds: number[] | undefined;
|
||||
if (req.user?.isCustomerPortal && req.user.customerId) {
|
||||
customerIds = [req.user.customerId];
|
||||
const representedIds: number[] = req.user.representedCustomerIds || [];
|
||||
for (const repCustId of representedIds) {
|
||||
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
|
||||
if (hasAuth) {
|
||||
customerIds.push(repCustId);
|
||||
if (req.user?.isCustomerPortal) {
|
||||
customerIds = req.user.customerId ? [req.user.customerId] : [];
|
||||
if (req.user.customerId) {
|
||||
const representedIds: number[] = req.user.representedCustomerIds || [];
|
||||
for (const repCustId of representedIds) {
|
||||
const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId);
|
||||
if (hasAuth) {
|
||||
customerIds.push(repCustId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,10 +779,7 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
|
||||
|
||||
res.json({ success: true, data: contractMeter } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Folgezählers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,10 +796,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
||||
});
|
||||
res.json({ success: true, data: null } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Entfernen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,13 +885,9 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
||||
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
|
||||
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
|
||||
// Schreibfehler.
|
||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||
// Multer hat die Datei schon geschrieben – bei Reject räumen.
|
||||
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Hochladen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,10 +924,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
||||
|
||||
res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as contractCategoryService from '../services/contractCategory.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
@@ -46,10 +47,7 @@ export async function createContractCategory(req: Request, res: Response): Promi
|
||||
});
|
||||
res.status(201).json({ success: true, data: category } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertragskategorie',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertragskategorie');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +62,7 @@ export async function updateContractCategory(req: Request, res: Response): Promi
|
||||
});
|
||||
res.json({ success: true, data: category } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertragskategorie',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertragskategorie');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +78,6 @@ export async function deleteContractCategory(req: Request, res: Response): Promi
|
||||
});
|
||||
res.json({ success: true, message: 'Vertragskategorie gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertragskategorie',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Vertragskategorie');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
@@ -47,10 +48,7 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
|
||||
res.status(201).json({ success: true, data: entry } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Eintrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Eintrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,10 +72,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
|
||||
res.json({ success: true, data: entry } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Eintrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Eintrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,9 +92,6 @@ export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
|
||||
res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Eintrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Eintrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as contractTaskService from '../services/contractTask.service.js';
|
||||
import * as contractService from '../services/contract.service.js';
|
||||
import * as customerService from '../services/customer.service.js';
|
||||
@@ -118,6 +119,38 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Allgemeine Aufgabe OHNE Vertrag/Kunde (nur Mitarbeiter/Admin). Ohne Vertrag
|
||||
// gibt es keinen Kunden → visibleInPortal ist hier bedeutungslos und immer
|
||||
// false (der Service erzwingt das ohnehin).
|
||||
export async function createGeneralTask(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (req.user?.isCustomerPortal) {
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const { title, description } = req.body ?? {};
|
||||
if (!title || !String(title).trim()) {
|
||||
res.status(400).json({ success: false, error: 'Titel ist erforderlich' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const task = await contractTaskService.createTask({
|
||||
contractId: null,
|
||||
title: String(title).trim(),
|
||||
description,
|
||||
visibleInPortal: false,
|
||||
createdBy: req.user?.email,
|
||||
});
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'ContractTask',
|
||||
resourceId: task.id.toString(),
|
||||
label: `Allgemeine Aufgabe "${String(title).trim()}" erstellt (ohne Vertrag)`,
|
||||
});
|
||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTask(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
@@ -152,10 +185,7 @@ export async function createTask(req: AuthRequest, res: Response): Promise<void>
|
||||
|
||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,10 +234,7 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
|
||||
|
||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Support-Anfrage',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Support-Anfrage');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +257,7 @@ export async function updateTask(req: AuthRequest, res: Response): Promise<void>
|
||||
|
||||
res.json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,10 +272,7 @@ export async function completeTask(req: AuthRequest, res: Response): Promise<voi
|
||||
});
|
||||
res.json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Abschließen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,10 +287,7 @@ export async function reopenTask(req: AuthRequest, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,10 +302,7 @@ export async function deleteTask(req: AuthRequest, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,10 +337,7 @@ export async function createSubtask(req: AuthRequest, res: Response): Promise<vo
|
||||
|
||||
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +373,12 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
// Allgemeine Aufgaben (ohne Vertrag/Kunde) sind reine Mitarbeiter-Aufgaben
|
||||
// und für Portal-User nie zugänglich.
|
||||
if (task.contractId == null) {
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
|
||||
// via hasAuthorization, Pentest Runde 6 – HOCH-04). Damit kann ein
|
||||
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
|
||||
@@ -398,10 +416,7 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
||||
|
||||
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Antwort',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Antwort');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,10 +441,7 @@ export async function updateSubtask(req: AuthRequest, res: Response): Promise<vo
|
||||
});
|
||||
res.json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,10 +456,7 @@ export async function completeSubtask(req: AuthRequest, res: Response): Promise<
|
||||
});
|
||||
res.json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Abschließen der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,10 +471,7 @@ export async function reopenSubtask(req: AuthRequest, res: Response): Promise<vo
|
||||
});
|
||||
res.json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,9 +486,6 @@ export async function deleteSubtask(req: AuthRequest, res: Response): Promise<vo
|
||||
});
|
||||
res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
import * as creditNoteService from '../services/creditNote.service.js';
|
||||
import { effectiveNumber } from '../services/creditNote.service.js';
|
||||
import * as numberRangeService from '../services/creditNoteNumberRange.service.js';
|
||||
import * as deliveryRangeService from '../services/deliveryNoteNumberRange.service.js';
|
||||
import * as authorizationService from '../services/authorization.service.js';
|
||||
import { generateCreditNotePdf } from '../services/creditNotePdf.service.js';
|
||||
|
||||
// Gutschriften sind ein reiner Mitarbeiter-/Admin-Bereich (interne
|
||||
// Subventions-Belege). Portal-Kunden dürfen sie weder sehen noch ändern –
|
||||
@@ -32,11 +37,51 @@ function idParam(req: AuthRequest, res: Response, name: string): number | null {
|
||||
}
|
||||
|
||||
function handleError(res: Response, error: unknown, fallback: string) {
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : fallback,
|
||||
} as ApiResponse);
|
||||
// Eigene Huelle beibehalten, damit die Aufrufer unveraendert bleiben - die
|
||||
// Entscheidung, was nach draussen geht, faellt jetzt zentral.
|
||||
antworteAufFehler(res, error, fallback, 500);
|
||||
}
|
||||
|
||||
// ---- Gesamtübersicht (Hauptmenü) ----
|
||||
// Anders als die übrigen Credit-Note-Endpunkte NICHT staff-only: Portal-Kunden
|
||||
// dürfen ihre eigenen (+ vertretene) Belege sehen. Scoping über customerIds
|
||||
// aus dem JWT – eine im Body/Query mitgeschickte customerId hat keinen Effekt.
|
||||
export async function listAll(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
// Portal-User: nur eigene + vertretene Kunden MIT Vollmacht.
|
||||
// Fail-closed (Pentest R144): Ein Portal-Token wird IMMER gescoped. Fehlt
|
||||
// wider Erwarten die customerId (null/0), ergibt das eine LEERE Menge –
|
||||
// nicht den Staff-Zweig (der alle Belege zeigen würde).
|
||||
let customerIds: number[] | undefined;
|
||||
if (req.user?.isCustomerPortal) {
|
||||
customerIds = req.user.customerId ? [req.user.customerId] : [];
|
||||
if (req.user.customerId) {
|
||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||
for (const repCustId of representedIds) {
|
||||
if (await authorizationService.hasAuthorization(repCustId, req.user.customerId)) {
|
||||
customerIds.push(repCustId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const page = Math.max(parseInt((req.query.page as string) || '1') || 1, 1);
|
||||
const limit = Math.min(Math.max(parseInt((req.query.limit as string) || '50') || 50, 1), 200);
|
||||
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
|
||||
|
||||
const result = await creditNoteService.getAllCreditNotes({ customerIds, page, limit, search });
|
||||
|
||||
// Portal-Kunden dürfen keine Überweisungsbelege laden → receiptPath aus der
|
||||
// Response entfernen (Beleg-Download bleibt ohnehin staff-only).
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const items = isPortal
|
||||
? result.items.map((cn) => ({ ...cn, receiptPath: null }))
|
||||
: result.items;
|
||||
|
||||
res.json({ success: true, data: { items, pagination: result.pagination } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Laden der Belegübersicht');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Gutschriften pro Vertrag ----
|
||||
@@ -81,7 +126,7 @@ export async function create(req: AuthRequest, res: Response): Promise<void> {
|
||||
action: 'CREATE',
|
||||
resourceType: 'CreditNote',
|
||||
resourceId: created.id.toString(),
|
||||
label: `Gutschrift ${created.number} angelegt (${created.type}, ${created.amountGross} ${created.currency}) an Vertrag #${contractId}`,
|
||||
label: `Beleg ${effectiveNumber(created) ?? `#${created.id}`} angelegt (${created.type}, ${created.amountGross} ${created.currency}) an Vertrag #${contractId}`,
|
||||
});
|
||||
res.status(201).json({ success: true, data: created } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -116,7 +161,7 @@ export async function update(req: AuthRequest, res: Response): Promise<void> {
|
||||
action: 'UPDATE',
|
||||
resourceType: 'CreditNote',
|
||||
resourceId: updated.id.toString(),
|
||||
label: `Gutschrift ${updated.number} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`,
|
||||
label: `Beleg ${effectiveNumber(updated) ?? `#${updated.id}`} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`,
|
||||
});
|
||||
res.json({ success: true, data: updated } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -135,7 +180,7 @@ export async function remove(req: AuthRequest, res: Response): Promise<void> {
|
||||
action: 'DELETE',
|
||||
resourceType: 'CreditNote',
|
||||
resourceId: id.toString(),
|
||||
label: `Gutschrift ${deleted.number} gelöscht`,
|
||||
label: `Beleg ${effectiveNumber(deleted) ?? `#${deleted.id}`} gelöscht`,
|
||||
});
|
||||
res.json({ success: true } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -143,6 +188,23 @@ export async function remove(req: AuthRequest, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// PDF (Gutschrift-Dokument) erzeugen. ZUGFeRD-Embedding folgt (Teil 2).
|
||||
export async function generatePdf(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const id = idParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
const { pdfPath } = await generateCreditNotePdf(id);
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
|
||||
label: 'Gutschrift-PDF erzeugt',
|
||||
});
|
||||
res.json({ success: true, data: { pdfPath } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Erzeugen des PDF');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Nummernkreis (Einstellungen) ----
|
||||
|
||||
export async function getNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||||
@@ -175,3 +237,36 @@ export async function updateNumberRange(req: AuthRequest, res: Response): Promis
|
||||
handleError(res, error, 'Fehler beim Speichern des Nummernkreises');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Lieferschein-Nummernkreis (betragslose Sachwerte) ----
|
||||
|
||||
export async function getDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const [range, nextNumber] = await Promise.all([
|
||||
deliveryRangeService.getOrCreateRange(),
|
||||
deliveryRangeService.previewNextNumber(),
|
||||
]);
|
||||
res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Laden des Lieferschein-Nummernkreises');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const range = await deliveryRangeService.updateRange(req.body ?? {});
|
||||
const preview = await deliveryRangeService.previewNextNumber();
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'DeliveryNoteNumberRange',
|
||||
resourceId: range.id.toString(),
|
||||
label: `Lieferschein-Nummernkreis geändert (Präfix "${range.prefix}", nächste Nr. ${range.nextNumber})`,
|
||||
});
|
||||
res.json({ success: true, data: { ...range, preview } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Speichern des Lieferschein-Nummernkreises');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import * as customerService from '../services/customer.service.js';
|
||||
import * as authService from '../services/auth.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { getConfiguredEmailDomains, emailUsesDomain } from '../services/emailProvider/emailProviderService.js';
|
||||
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import {
|
||||
@@ -24,6 +26,23 @@ import {
|
||||
} from '../utils/accessControl.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
|
||||
// Mass-Assignment-Schutz für Bankkarten (Pentest R155): nur diese Felder dürfen
|
||||
// aus dem Request-Body übernommen werden – NICHT customerId/id/createdAt/… Nur
|
||||
// tatsächlich gesendete Keys landen im Ergebnis, damit Partial-Updates und das
|
||||
// Änderungs-Audit korrekt bleiben.
|
||||
const BANK_CARD_ALLOWED_FIELDS = [
|
||||
'accountHolder', 'iban', 'cardNumber', 'bic', 'bankName', 'expiryDate', 'description', 'isActive',
|
||||
] as const;
|
||||
|
||||
function pickBankCardFields(body: unknown): Record<string, unknown> {
|
||||
const src = (body && typeof body === 'object') ? (body as Record<string, unknown>) : {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of BANK_CARD_ALLOWED_FIELDS) {
|
||||
if (key in src) out[key] = src[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Customer CRUD
|
||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -82,12 +101,23 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
||||
try {
|
||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
||||
const data: any = pickCustomerCreate(req.body);
|
||||
// E-Mail ist Pflichtfeld.
|
||||
if (!data.email || !String(data.email).trim()) {
|
||||
res.status(400).json({ success: false, error: 'E-Mail-Adresse ist ein Pflichtfeld.' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
|
||||
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
|
||||
if (data.email && !isValidEmail(data.email)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Private E-Mail darf keine unserer verwalteten Provider-/Weiterleitungs-
|
||||
// Domains verwenden.
|
||||
if (emailUsesDomain(String(data.email), await getConfiguredEmailDomains())) {
|
||||
res.status(400).json({ success: false, error: 'Diese E-Mail liegt auf einer verwalteten (Weiterleitungs-/Mailbox-)Domain und ist als private Kunden-E-Mail nicht zulässig.' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||
return;
|
||||
@@ -126,10 +156,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
||||
: sanitizeCustomerStrict(customer as any);
|
||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Kunden',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Kunden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,11 +169,19 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Private E-Mail darf keine verwaltete Provider-/Weiterleitungs-Domain sein.
|
||||
if (req.body?.email && emailUsesDomain(String(req.body.email), await getConfiguredEmailDomains())) {
|
||||
res.status(400).json({ success: false, error: 'Diese E-Mail liegt auf einer verwalteten (Weiterleitungs-/Mailbox-)Domain und ist als private Kunden-E-Mail nicht zulässig.' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const data: any = pickCustomerUpdate(req.body);
|
||||
// E-Mail ist nur beim ANLEGEN Pflicht – beim Bearbeiten darf ein
|
||||
// Bestandskunde ohne E-Mail bleiben (die Domain-Prüfung oben greift aber
|
||||
// weiterhin, falls doch eine gesetzt wird).
|
||||
|
||||
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
|
||||
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
|
||||
@@ -239,10 +274,7 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('Update customer error:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Kunden',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Kunden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,10 +291,7 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Kunden',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Kunden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,10 +320,7 @@ export async function createAddress(req: AuthRequest, res: Response): Promise<vo
|
||||
});
|
||||
res.status(201).json({ success: true, data: address } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Adresse',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Adresse');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,10 +381,7 @@ export async function updateAddress(req: AuthRequest, res: Response): Promise<vo
|
||||
|
||||
res.json({ success: true, data: address } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Adresse',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Adresse');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,10 +400,7 @@ export async function deleteAddress(req: AuthRequest, res: Response): Promise<vo
|
||||
});
|
||||
res.json({ success: true, message: 'Adresse gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Adresse',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Adresse');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +421,8 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const card = await customerService.createBankCard(customerId, req.body);
|
||||
const data = pickBankCardFields(req.body);
|
||||
const card = await customerService.createBankCard(customerId, data as any);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'BankCard',
|
||||
resourceId: card.id.toString(),
|
||||
@@ -410,10 +431,7 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
});
|
||||
res.status(201).json({ success: true, data: card } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Bankkarte',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Bankkarte');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +439,10 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
try {
|
||||
const cardId = parseInt(req.params.id);
|
||||
if (!(await canAccessBankCard(req, res, cardId))) return;
|
||||
const data = req.body;
|
||||
// Mass-Assignment-Schutz (Pentest R155): nur erlaubte Felder aus dem Body,
|
||||
// kein customerId/id/Fremdfeld. Das Audit unten läuft über diese saubere
|
||||
// Menge (keine Phantom-Einträge). Enforcement zusätzlich im Service.
|
||||
const data = pickBankCardFields(req.body);
|
||||
|
||||
// Vorherigen Stand laden für Audit
|
||||
const before = await prisma.bankCard.findUnique({ where: { id: cardId } });
|
||||
@@ -433,7 +454,7 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
if (before) {
|
||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||
const fieldLabels: Record<string, string> = {
|
||||
iban: 'IBAN', bic: 'BIC', bankName: 'Bank',
|
||||
iban: 'IBAN', bic: 'BIC', bankName: 'Bank', cardNumber: 'Kartennummer',
|
||||
accountHolder: 'Kontoinhaber', isActive: 'Aktiv',
|
||||
};
|
||||
for (const [key, newVal] of Object.entries(data)) {
|
||||
@@ -469,10 +490,7 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
|
||||
res.json({ success: true, data: card } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Bankkarte',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Bankkarte');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,10 +509,7 @@ export async function deleteBankCard(req: AuthRequest, res: Response): Promise<v
|
||||
});
|
||||
res.json({ success: true, message: 'Bankkarte gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Bankkarte',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Bankkarte');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,10 +539,7 @@ export async function createDocument(req: AuthRequest, res: Response): Promise<v
|
||||
});
|
||||
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Ausweises',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Ausweises');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,10 +601,7 @@ export async function updateDocument(req: AuthRequest, res: Response): Promise<v
|
||||
|
||||
res.json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Ausweises',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Ausweises');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,10 +620,7 @@ export async function deleteDocument(req: AuthRequest, res: Response): Promise<v
|
||||
});
|
||||
res.json({ success: true, message: 'Ausweis gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Ausweises',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Ausweises');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,10 +653,7 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
|
||||
});
|
||||
res.status(201).json({ success: true, data: meter } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Zählers',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Zählers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,10 +708,7 @@ export async function updateMeter(req: AuthRequest, res: Response): Promise<void
|
||||
|
||||
res.json({ success: true, data: meter } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählers',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,10 +724,7 @@ export async function deleteMeter(req: AuthRequest, res: Response): Promise<void
|
||||
});
|
||||
res.json({ success: true, message: 'Zähler gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählers',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Zählers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,10 +770,7 @@ export async function addMeterReading(req: AuthRequest, res: Response): Promise<
|
||||
|
||||
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Zählerstands',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Zählerstands');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,10 +798,7 @@ export async function updateMeterReading(req: AuthRequest, res: Response): Promi
|
||||
});
|
||||
res.json({ success: true, data: reading } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählerstands',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählerstands');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,10 +815,7 @@ export async function deleteMeterReading(req: AuthRequest, res: Response): Promi
|
||||
});
|
||||
res.json({ success: true, data: null } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählerstands',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Zählerstands');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -882,10 +870,7 @@ export async function reportMeterReading(req: AuthRequest, res: Response): Promi
|
||||
|
||||
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Melden des Zählerstands',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Melden des Zählerstands');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,10 +922,7 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
|
||||
|
||||
res.json({ success: true, data: reading } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1050,10 +1032,7 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
|
||||
|
||||
res.json({ success: true, data: settings } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Portal-Einstellungen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Portal-Einstellungen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,10 +1046,7 @@ export async function generatePortalPassword(req: Request, res: Response): Promi
|
||||
const password = generateSecurePassword({ length: 16 });
|
||||
res.json({ success: true, data: { password } } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Generieren des Passworts', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1119,20 +1095,28 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr
|
||||
}
|
||||
|
||||
const loginEmail = customer.portalEmail || customer.email!;
|
||||
const plaintextPassword = await authService.getCustomerPortalPassword(customerId);
|
||||
if (!plaintextPassword) {
|
||||
const pwResult = await authService.getCustomerPortalPassword(customerId);
|
||||
if (pwResult.status === 'none') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Klartext-Passwort nicht verfügbar (alte Anlage ohne Encrypted-Feld – bitte neu setzen)',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Desync: kein falsches Passwort versenden.
|
||||
if (pwResult.status === 'desync') {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'Das gespeicherte Passwort passt nicht zum Login-Passwort (Dateninkonsistenz). Bitte erst ein neues Passwort setzen/generieren, dann erneut senden.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
await authService.sendPortalCredentialsEmail({
|
||||
to: targetEmail,
|
||||
customer,
|
||||
loginEmail,
|
||||
password: plaintextPassword,
|
||||
password: pwResult.password,
|
||||
});
|
||||
|
||||
// Versendetes Passwort ist ein Einmalpasswort → beim ersten Login muss
|
||||
@@ -1150,10 +1134,7 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr
|
||||
|
||||
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Versenden der Zugangsdaten', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1179,10 +1160,7 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
|
||||
});
|
||||
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1190,7 +1168,7 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const password = await authService.getCustomerPortalPassword(customerId);
|
||||
const result = await authService.getCustomerPortalPassword(customerId);
|
||||
// Klartext-Passwort-Read auditieren (CRITICAL): wer hat wann das Portal-
|
||||
// Passwort eines Kunden entschlüsselt? Wichtig für DSGVO-Nachvollziehbarkeit
|
||||
// + Insider-Threat-Erkennung.
|
||||
@@ -1199,10 +1177,19 @@ export async function getPortalPassword(req: AuthRequest, res: Response): Promis
|
||||
action: 'READ',
|
||||
resourceType: 'PortalPassword',
|
||||
resourceId: customerId.toString(),
|
||||
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt`,
|
||||
label: `Klartext-Portal-Passwort von Kunde #${customerId} entschlüsselt (${result.status})`,
|
||||
customerId,
|
||||
});
|
||||
res.json({ success: true, data: { password } } as ApiResponse);
|
||||
// Desync (gespeichertes Passwort passt nicht zum Login-Hash): NICHT ausgeben
|
||||
// – sonst liest der Support ein Passwort vor, das beim Login scheitert.
|
||||
if (result.status === 'desync') {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'Das gespeicherte Portal-Passwort stimmt nicht mit dem Login-Passwort überein (Dateninkonsistenz). Bitte über „Passwort setzen/neu generieren" ein neues Passwort vergeben.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data: { password: result.status === 'ok' ? result.password : null } } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
@@ -1246,10 +1233,7 @@ export async function addRepresentative(req: AuthRequest, res: Response): Promis
|
||||
});
|
||||
res.status(201).json({ success: true, data: representative } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Vertreters',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Vertreters');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1268,10 +1252,7 @@ export async function removeRepresentative(req: AuthRequest, res: Response): Pro
|
||||
});
|
||||
res.json({ success: true, message: 'Vertreter entfernt' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Entfernen des Vertreters',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Entfernen des Vertreters');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1296,11 +1277,7 @@ export async function getSalutationPreference(req: AuthRequest, res: Response):
|
||||
} catch (error) {
|
||||
// Pentest R104.1: ApiError-Statuscodes durchreichen (404 statt 500
|
||||
// bei nicht-existierendem Kunden).
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Laden der Anrede-Präferenz',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Laden der Anrede-Präferenz', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1329,11 +1306,7 @@ export async function setSalutationPreference(req: AuthRequest, res: Response):
|
||||
const result = await customerService.getSalutationPreference(userId, customerId);
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Anrede-Präferenz',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Speichern der Anrede-Präferenz', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,11 +1327,7 @@ export async function clearSalutationPreference(req: AuthRequest, res: Response)
|
||||
const result = await customerService.getSalutationPreference(userId, customerId);
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen der Anrede-Präferenz',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen der Anrede-Präferenz', 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import * as referralService from '../services/customerReferral.service.js';
|
||||
@@ -98,11 +99,7 @@ export async function createReferral(req: AuthRequest, res: Response): Promise<v
|
||||
|
||||
res.status(201).json({ success: true, data: created } as ApiResponse);
|
||||
} catch (error) {
|
||||
const status = (error as referralService.ReferralError)?.status ?? 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Anlegen der Werbe-Beziehung',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Anlegen der Werbe-Beziehung', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,11 +150,7 @@ export async function updateReferral(req: AuthRequest, res: Response): Promise<v
|
||||
|
||||
res.json({ success: true, data: updated } as ApiResponse);
|
||||
} catch (error) {
|
||||
const status = (error as referralService.ReferralError)?.status ?? 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Ändern der Werbe-Beziehung',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Ändern der Werbe-Beziehung', 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// ==================== EMAIL PROVIDER CONTROLLER ====================
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
@@ -60,10 +61,7 @@ export async function createProviderConfig(req: Request, res: Response): Promise
|
||||
});
|
||||
res.status(201).json({ success: true, data: config } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Email-Providers',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Email-Providers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,10 +78,7 @@ export async function updateProviderConfig(req: Request, res: Response): Promise
|
||||
});
|
||||
res.json({ success: true, data: config } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Email-Providers',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Email-Providers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,10 +94,7 @@ export async function deleteProviderConfig(req: Request, res: Response): Promise
|
||||
});
|
||||
res.json({ success: true, message: 'Email-Provider gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Email-Providers',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Email-Providers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +150,7 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
||||
const result = await emailProviderService.testProviderConnection({ id, testData });
|
||||
res.json({ success: result.success, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Verbindungstest fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Verbindungstest fehlgeschlagen', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,10 +331,7 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('testMailAccess error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Test',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Test', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,10 +341,7 @@ export async function checkEmailExists(req: Request, res: Response): Promise<voi
|
||||
const result = await emailProviderService.checkEmailExists(localPart);
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Prüfung',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler bei der E-Mail-Prüfung', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,10 +360,7 @@ export async function provisionEmail(req: Request, res: Response): Promise<void>
|
||||
const result = await emailProviderService.provisionEmail(localPart, customerEmail);
|
||||
res.json({ success: result.success, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Provisionierung',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler bei der E-Mail-Provisionierung', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,10 +370,7 @@ export async function deprovisionEmail(req: Request, res: Response): Promise<voi
|
||||
const result = await emailProviderService.deprovisionEmail(localPart);
|
||||
res.json({ success: result.success, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der E-Mail',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der E-Mail', 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
|
||||
import { createAuditLog } from '../services/audit.service.js';
|
||||
@@ -31,10 +32,7 @@ export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Factory-Defaults-Export:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Export',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Export', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,9 +91,6 @@ export async function importFactoryDefaults(req: AuthRequest, res: Response) {
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Factory-Defaults-Import:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Import',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Import');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
|
||||
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
|
||||
} else if (owner.kind === 'contract') {
|
||||
if (!(await canAccessContract(req, res, owner.contractId))) return;
|
||||
} else if (owner.kind === 'contract-staff') {
|
||||
// Nur Mitarbeiter/Admin – Portal-Kunden nie (z.B. Gutschrift-
|
||||
// Überweisungsbelege). Staff dürfen wie bei allen Vertrags-Files.
|
||||
if (req.user?.isCustomerPortal) {
|
||||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
} else if (owner.kind === 'admin') {
|
||||
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
|
||||
const perms = req.user?.permissions || [];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import * as gdprService from '../services/gdpr.service.js';
|
||||
import * as consentService from '../services/consent.service.js';
|
||||
@@ -54,10 +55,7 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Datenexport:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Datenexport',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Datenexport', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,10 +96,7 @@ export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
||||
res.status(201).json({ success: true, data: request });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Löschanfrage:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,10 +178,7 @@ export async function processDeletionRequest(req: AuthRequest, res: Response) {
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Bearbeiten der Löschanfrage:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Bearbeiten',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Bearbeiten');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,10 +338,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
||||
res.json({ success: true, data: consent });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren der Einwilligung:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,10 +686,7 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Senden des Consent-Links:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Senden',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,10 +805,7 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Senden',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,10 +858,7 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
|
||||
res.json({ success: true, data: auth });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erteilen der Vollmacht:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erteilen der Vollmacht',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Erteilen der Vollmacht');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,10 +884,7 @@ export async function withdrawAuthorization(req: AuthRequest, res: Response) {
|
||||
res.json({ success: true, data: auth });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Widerrufen der Vollmacht:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Widerrufen',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Widerrufen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1012,10 +989,7 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
|
||||
res.json({ success: true, data: auth });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Upload des Vollmacht-Dokuments:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Upload',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Upload');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,10 +1013,7 @@ export async function deleteAuthorizationDocument(req: AuthRequest, res: Respons
|
||||
res.json({ success: true, data: auth });
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as invoiceService from '../services/invoice.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
@@ -101,10 +102,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
|
||||
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('addInvoice error:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen der Rechnung',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Hinzufügen der Rechnung');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,10 +134,7 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
|
||||
res.json({ success: true, data: invoice } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('updateInvoice error:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rechnung',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Rechnung');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,10 +158,7 @@ export async function deleteInvoice(req: AuthRequest, res: Response): Promise<vo
|
||||
res.json({ success: true, data: null } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('deleteInvoice error:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rechnung',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Rechnung');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,9 +200,6 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
|
||||
});
|
||||
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Hinzufügen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||
import * as appSettingService from '../services/appSetting.service.js';
|
||||
@@ -141,10 +142,7 @@ export async function testAlert(_req: AuthRequest, res: Response): Promise<void>
|
||||
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Test-Alert fehlgeschlagen', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,9 +199,6 @@ export async function runDigestNow(_req: AuthRequest, res: Response): Promise<vo
|
||||
const result = await sendDigest({ force: true });
|
||||
res.json({ success: true, data: result } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Digest fehlgeschlagen', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
@@ -57,10 +58,7 @@ export async function createTemplate(req: AuthRequest, res: Response) {
|
||||
|
||||
res.status(201).json({ success: true, data: { ...template, pdfFields } });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +85,7 @@ export async function updateTemplate(req: AuthRequest, res: Response) {
|
||||
|
||||
res.json({ success: true, data: template });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,10 +114,7 @@ export async function extractFields(req: AuthRequest, res: Response) {
|
||||
const result = await pdfTemplateService.extractPdfFields(template.templatePath);
|
||||
res.json({ success: true, data: result.fields, totalPages: result.totalPages });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Auslesen der PDF-Felder',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Auslesen der PDF-Felder');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +129,7 @@ export async function getAnnotatedPreview(req: AuthRequest, res: Response) {
|
||||
res.setHeader('Content-Disposition', 'inline; filename="preview.pdf"');
|
||||
res.send(pdfBuffer);
|
||||
} catch (error) {
|
||||
res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' });
|
||||
antworteAufFehler(res, error, 'Fehler', 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +146,7 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
|
||||
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
||||
res.json({ success: true, data: inputs });
|
||||
} catch (error) {
|
||||
res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' });
|
||||
antworteAufFehler(res, error, 'Fehler', 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,9 +187,6 @@ export async function generatePdf(req: AuthRequest, res: Response) {
|
||||
res.send(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF generate error:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Generieren',
|
||||
});
|
||||
antworteAufFehler(res, error, 'Fehler beim Generieren');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as platformService from '../services/platform.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
@@ -46,10 +47,7 @@ export async function createPlatform(req: Request, res: Response): Promise<void>
|
||||
});
|
||||
res.status(201).json({ success: true, data: platform } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertriebsplattform',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertriebsplattform');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +62,7 @@ export async function updatePlatform(req: Request, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, data: platform } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertriebsplattform',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertriebsplattform');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +78,6 @@ export async function deletePlatform(req: Request, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, message: 'Vertriebsplattform gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertriebsplattform',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Vertriebsplattform');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import * as providerService from '../services/provider.service.js';
|
||||
@@ -99,10 +100,7 @@ export async function createProvider(req: Request, res: Response): Promise<void>
|
||||
});
|
||||
res.status(201).json({ success: true, data: provider } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Anbieters',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Anbieters');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,10 +134,7 @@ export async function updateProvider(req: Request, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, data: provider } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Anbieters',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Anbieters');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +150,6 @@ export async function deleteProvider(req: Request, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Anbieters',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Anbieters');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
@@ -93,11 +94,7 @@ export async function createEmail(req: Request, res: Response): Promise<void> {
|
||||
});
|
||||
res.status(201).json({ success: true, data: email } as ApiResponse);
|
||||
} catch (error) {
|
||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse', 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +116,7 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
||||
});
|
||||
res.json({ success: true, data: email } as ApiResponse);
|
||||
} catch (error) {
|
||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse', 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,10 +133,7 @@ export async function deleteEmail(req: AuthRequest, res: Response): Promise<void
|
||||
});
|
||||
res.json({ success: true, message: 'Stressfrei-Wechseln Adresse gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Stressfrei-Wechseln Adresse',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Stressfrei-Wechseln Adresse');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,10 +170,7 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
|
||||
message: 'Weiterleitungen aktualisiert',
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Synchronisieren der Weiterleitungen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Synchronisieren der Weiterleitungen', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,11 +215,7 @@ export async function updateAdditionalForwards(req: AuthRequest, res: Response):
|
||||
message: 'Weiterleitungen aktualisiert',
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Weiterleitungen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Weiterleitungen', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,9 +238,6 @@ export async function resetPassword(req: AuthRequest, res: Response): Promise<vo
|
||||
message: 'Passwort wurde zurückgesetzt',
|
||||
} as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen des Passworts', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as tariffService from '../services/tariff.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
@@ -48,10 +49,7 @@ export async function createTariff(req: Request, res: Response): Promise<void> {
|
||||
});
|
||||
res.status(201).json({ success: true, data: tariff } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Tarifs',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Tarifs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +64,7 @@ export async function updateTariff(req: Request, res: Response): Promise<void> {
|
||||
});
|
||||
res.json({ success: true, data: tariff } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Tarifs',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Tarifs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +80,6 @@ export async function deleteTariff(req: Request, res: Response): Promise<void> {
|
||||
});
|
||||
res.json({ success: true, message: 'Tarif gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Tarifs',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Tarifs');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import * as userService from '../services/user.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { AUDIT_OPS_ROLLE } from '../services/user.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
import { pickUserCreate, pickUserUpdate, pickRoleUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||
import { RechteEskalationError, RollenSperrError, UngueltigeEingabeError } from '../services/rechte.service.js';
|
||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||
|
||||
// Users
|
||||
@@ -54,6 +58,11 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||
const data = pickUserCreate(req.body) as any;
|
||||
const boolFehler = pruefeBooleanFelder(data, ['isActive', 'isServiceAccount', 'hasGdprAccess', 'hasDeveloperAccess', 'hasAuditOpsAccess']);
|
||||
if (boolFehler) {
|
||||
res.status(400).json({ success: false, error: boolFehler } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB
|
||||
// (Pentest 29.4 – SMTP-Header-Injection).
|
||||
if (!isValidEmail(data?.email) || !data?.email) {
|
||||
@@ -84,7 +93,12 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const user = await userService.createUser(data);
|
||||
const handelnder = handelnderOderNull(req);
|
||||
if (handelnder === null) {
|
||||
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const user = await userService.createUser(data, handelnder);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'User',
|
||||
resourceId: user.id.toString(),
|
||||
@@ -92,14 +106,11 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
||||
});
|
||||
res.status(201).json({ success: true, data: user } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers',
|
||||
} as ApiResponse);
|
||||
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen des Benutzers');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
export async function updateUser(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
// `permissions` und `password` darf der generische Update nicht
|
||||
@@ -129,6 +140,11 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||
const data = pickUserUpdate(req.body) as Record<string, unknown>;
|
||||
const boolFehler = pruefeBooleanFelder(data, ['isActive', 'isServiceAccount', 'hasGdprAccess', 'hasDeveloperAccess', 'hasAuditOpsAccess']);
|
||||
if (boolFehler) {
|
||||
res.status(400).json({ success: false, error: boolFehler } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
|
||||
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
||||
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
||||
@@ -161,10 +177,129 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
...beforeUser,
|
||||
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
||||
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
||||
hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE),
|
||||
}
|
||||
: null;
|
||||
|
||||
const user = await userService.updateUser(userId, data as any);
|
||||
// Die drei Haken vergeben versteckte Rollen: DSGVO, Developer und
|
||||
// Audit-Betrieb. Sie sind kein gewoehnliches Benutzerfeld - "Developer"
|
||||
// traegt ALLE Rechte, "Audit-Betrieb" entscheidet, wer die
|
||||
// Beweisgrundlage ersetzen darf.
|
||||
//
|
||||
// Bis 09/2026 stand hier, eine Rechte-Huerde scheitere am
|
||||
// Henne-Ei-Problem: Nach der Aufteilung haelt zunaechst niemand
|
||||
// `audit:admin`, also koennte ihn auch niemand vergeben. Das Argument war
|
||||
// richtig, aber der Schluss zu weit. Ohne Huerde genuegte `users:create`,
|
||||
// um sich zum Vollzugriff zu befoerdern - nicht einmal am eigenen Konto,
|
||||
// sondern ueber ein frisch angelegtes zweites mit gesetztem Haken.
|
||||
//
|
||||
// Die Huerde steckt jetzt in der Teilmengenregel (rechte.service.ts):
|
||||
// Wer einen Haken setzt, muss die dahinterliegenden Rechte selbst
|
||||
// halten. Das Henne-Ei-Problem loest nicht die Weboberflaeche, sondern
|
||||
// die Kommandozeile - `npx tsx prisma/rolle-zuweisen.ts` auf der
|
||||
// Maschine. Das ist die richtige Grenze: "Datenbankzugriff" und
|
||||
// "Protokoll neu berechnen" sollten Shell-Zugang voraussetzen, nicht ein
|
||||
// Haekchen im Browser. Ein gestohlener Admin-Zugang hat den Container
|
||||
// nicht.
|
||||
const setztAuditBetrieb =
|
||||
data.hasAuditOpsAccess !== undefined &&
|
||||
before !== null &&
|
||||
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
|
||||
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
|
||||
|
||||
const setztDsgvo =
|
||||
data.hasGdprAccess !== undefined &&
|
||||
before !== null &&
|
||||
data.hasGdprAccess !== (before as any).hasGdprAccess;
|
||||
const setztDeveloper =
|
||||
data.hasDeveloperAccess !== undefined &&
|
||||
before !== null &&
|
||||
data.hasDeveloperAccess !== (before as any).hasDeveloperAccess;
|
||||
|
||||
// Keine Selbstbedienung. Die Teilmengenregel allein reicht dafuer nicht:
|
||||
// Wer die Rechte bereits haelt, koennte sie sich formal selbst erneut
|
||||
// zuweisen - und ein Vorgang, bei dem Antragsteller und Genehmigender
|
||||
// dieselbe Person sind, hinterlaesst keine ueberpruefbare Spur.
|
||||
if ((setztAuditBetrieb || setztDsgvo || setztDeveloper) && req.user?.userId === userId) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error:
|
||||
'DSGVO-Zugriff, Audit-Betrieb und Entwicklerzugriff lassen sich nicht am eigenen ' +
|
||||
'Konto ändern. Bitte von einer anderen Person mit den entsprechenden Rechten ' +
|
||||
'vornehmen lassen.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
|
||||
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
|
||||
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
|
||||
// Routine erscheinen. Es bekommt deshalb dieselbe Behandlung wie das
|
||||
// Absenken einer Aufbewahrungsfrist – Bestaetigung, laute Protokollierung,
|
||||
// und kein Selbstbedienen.
|
||||
const setztDienstkonto =
|
||||
typeof (data as any).isServiceAccount === 'boolean' &&
|
||||
(data as any).isServiceAccount !== before?.isServiceAccount;
|
||||
const aktiviertDienstkonto = setztDienstkonto && (data as any).isServiceAccount === true;
|
||||
|
||||
// R184-02: Das Kennzeichen ist ein Audit-Governance-Eingriff, kein
|
||||
// gewoehnliches Benutzerfeld – es ist Geschwister von retention-shorten,
|
||||
// seal-backlog, rehash und cleanup, die alle `audit:admin` verlangen. Es
|
||||
// haengt aber an der Route mit `users:update`. Heute deckungsgleich, weil
|
||||
// nur die Administratorrolle beides hat – aber jede kuenftige Rolle mit
|
||||
// „Benutzer bearbeiten“ wuerde still „Login-Alarme herunterstufen“ erben.
|
||||
// Deshalb hier zusaetzlich auf die Domaene pruefen, in der der Schaden
|
||||
// liegt.
|
||||
if (setztDienstkonto && !(req.user?.permissions || []).includes('audit:admin')) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error:
|
||||
'Das Dienstkonto-Kennzeichen ändert, wie Anmeldungen im Audit-Log bewertet werden. ' +
|
||||
'Dafür genügt „Benutzer bearbeiten“ nicht – es erfordert die Berechtigung ' +
|
||||
'audit:admin, wie das Verkürzen von Aufbewahrungsfristen auch.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (setztDienstkonto && req.user?.userId === userId) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error:
|
||||
'Das Dienstkonto-Kennzeichen lässt sich nicht am eigenen Konto setzen oder entfernen. ' +
|
||||
'Es stuft Anmeldungen dieses Kontos als Routine ein – wer das für sich selbst täte, ' +
|
||||
'könnte die eigenen Anmeldungen unauffällig machen. Bitte von einem anderen ' +
|
||||
'Administrator vornehmen lassen.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate in BEIDE Richtungen (Pentest R184-01). Das Entfernen war ungegatet –
|
||||
// und es ist der gefaehrlichere Weg: Ein un-geflaggtes Konto faellt aus der
|
||||
// Heartbeat-Wache, weil diese am Live-Kennzeichen haengt. Genau das
|
||||
// „stilllegen und auf Stille setzen“, gegen das der Wachhund gebaut wurde.
|
||||
if (setztDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: aktiviertDienstkonto
|
||||
? `Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im ` +
|
||||
'Audit-Log als Routine geführt statt als kritisches Ereignis. Das ist für ' +
|
||||
'planmäßig arbeitende Dienste gedacht (etwa das Gegenbuch) – für ein Konto, das ' +
|
||||
'ein Mensch benutzt, wäre es eine Tarnung. ' +
|
||||
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.'
|
||||
: `Damit fällt ${before?.email ?? 'dieses Konto'} aus der Überwachung heraus: Es wird ` +
|
||||
'dann nicht mehr gemeldet, wenn es sich nicht mehr anmeldet. Bei einem laufenden ' +
|
||||
'Dienst wie dem Gegenbuch hieße das, sein Ausfall bliebe unbemerkt. ' +
|
||||
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const handelnder = handelnderOderNull(req);
|
||||
if (handelnder === null) {
|
||||
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const user = await userService.updateUser(userId, data as any, handelnder);
|
||||
if (user) {
|
||||
// Audit: Geänderte Felder ermitteln und loggen
|
||||
if (before) {
|
||||
@@ -172,6 +307,8 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
const fieldLabels: Record<string, string> = {
|
||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||
hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)',
|
||||
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
||||
};
|
||||
for (const [key, newVal] of Object.entries(data)) {
|
||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||
@@ -191,9 +328,102 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'User',
|
||||
resourceId: user.id.toString(),
|
||||
label: changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||
label: setztDienstkonto
|
||||
? `Dienstkonto-Kennzeichen ${aktiviertDienstkonto ? 'GESETZT' : 'entfernt'} für ` +
|
||||
`${user.email} – Anmeldungen werden künftig ` +
|
||||
`${aktiviertDienstkonto ? 'als Routine' : 'wieder als kritisch'} geführt`
|
||||
: changeList
|
||||
? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}`
|
||||
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
||||
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
||||
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
|
||||
details: Object.keys(changes).length > 0 ? changes : undefined,
|
||||
});
|
||||
|
||||
// Zusaetzlich in den Alarmkanal (Pentest R184-01). Eine CRITICAL-Zeile
|
||||
// im Audit-Log muss jemand LESEN – das ist die R183-02-Klasse. Das
|
||||
// Entfernen des Kennzeichens nimmt das Konto aus der Heartbeat-Wache;
|
||||
// ab dann faellt sein Ausbleiben nicht mehr auf. Diese Aenderung
|
||||
// gehoert deshalb dorthin, wo automatisch reagiert wird.
|
||||
if (setztDienstkonto) {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'PERMISSION_CHANGED',
|
||||
severity: 'CRITICAL',
|
||||
message: aktiviertDienstkonto
|
||||
? `Konto ${user.email} als Dienstkonto markiert – seine Anmeldungen gelten ab ` +
|
||||
'jetzt als Routine statt als kritisches Ereignis.'
|
||||
: `Konto ${user.email} ist KEIN Dienstkonto mehr – es fällt damit aus der ` +
|
||||
'Überwachung heraus: Ein Ausbleiben seiner Anmeldungen wird nicht mehr gemeldet.',
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
||||
});
|
||||
}
|
||||
|
||||
// DSGVO und Entwicklerzugriff bekamen bis 09/2026 KEINEN Eintrag im
|
||||
// Alarmkanal - nur Audit-Betrieb und das Dienstkonto-Kennzeichen.
|
||||
// Dabei traegt die Developer-Rolle saemtliche Rechte des Systems:
|
||||
// Der weitreichendste Haken war der leiseste.
|
||||
if (setztDeveloper) {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'PERMISSION_CHANGED',
|
||||
severity: 'CRITICAL',
|
||||
message: data.hasDeveloperAccess === true
|
||||
? `Konto ${user.email} hat ab jetzt Entwicklerzugriff – das schliesst die ` +
|
||||
'Datenbankwerkzeuge und damit saemtliche Rechte des Systems ein.'
|
||||
: `Konto ${user.email} hat keinen Entwicklerzugriff mehr.`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: { betroffenesKonto: user.email, aktiviert: data.hasDeveloperAccess === true },
|
||||
});
|
||||
}
|
||||
|
||||
if (setztDsgvo) {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'PERMISSION_CHANGED',
|
||||
severity: 'HIGH',
|
||||
message: data.hasGdprAccess === true
|
||||
? `Konto ${user.email} darf ab jetzt das Audit-Protokoll lesen und exportieren ` +
|
||||
'sowie Auskunft und Loeschung nach DSGVO ausfuehren.'
|
||||
: `Konto ${user.email} hat keinen DSGVO-Zugriff mehr – es kann damit keine ` +
|
||||
'Auskunft nach Art. 15 und keine Loeschung nach Art. 17 mehr ausfuehren.',
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: { betroffenesKonto: user.email, aktiviert: data.hasGdprAccess === true },
|
||||
});
|
||||
}
|
||||
|
||||
if (setztAuditBetrieb) {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'PERMISSION_CHANGED',
|
||||
severity: 'CRITICAL',
|
||||
message: aktiviertAuditBetrieb
|
||||
? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` +
|
||||
'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' +
|
||||
'ersetzen, gegen die Manipulation nachgewiesen wird.'
|
||||
: `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details: {
|
||||
betroffenesKonto: user.email,
|
||||
aktiviert: aktiviertAuditBetrieb,
|
||||
selbstvergabe: user.id === req.user?.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'User',
|
||||
@@ -204,10 +434,7 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
res.json({ success: true, data: user } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers',
|
||||
} as ApiResponse);
|
||||
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren des Benutzers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +491,15 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const user = await userService.updateUser(userId, { password } as any);
|
||||
const handelnderPw = handelnderOderNull(req);
|
||||
if (handelnderPw === null) {
|
||||
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Reines Kennwort-Setzen: Der Zuwachs ist leer, die Teilmengenregel
|
||||
// laeuft ins Leere. Der Handelnde wird trotzdem uebergeben, damit es
|
||||
// keine Signatur gibt, die ihn weglassen darf.
|
||||
const user = await userService.updateUser(userId, { password } as any, handelnderPw);
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
@@ -286,10 +521,7 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
||||
});
|
||||
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,14 +537,70 @@ export async function deleteUser(req: Request, res: Response): Promise<void> {
|
||||
});
|
||||
res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Benutzers',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Benutzers');
|
||||
}
|
||||
}
|
||||
|
||||
// Roles
|
||||
|
||||
/**
|
||||
* Meldet jede Aenderung am Rollenmodell in den Alarmkanal.
|
||||
*
|
||||
* Die Rollenpflege war bis 09/2026 der einzige Eingriff in die Rechtevergabe
|
||||
* ohne SecurityEvent - Aenderungen an einzelnen Konten wurden gemeldet, das
|
||||
* Umschreiben einer Rolle, die an zwanzig Konten haengt, nicht. Damit war
|
||||
* ausgerechnet der wirksamste Weg der leiseste.
|
||||
*/
|
||||
/**
|
||||
* Die ID des Handelnden - oder null.
|
||||
*
|
||||
* `userId` fehlt bei Kundenportal-Anmeldungen. Ohne sie laesst sich die
|
||||
* Teilmengenregel nicht auswerten, und "nicht auswertbar" darf nicht
|
||||
* stillschweigend zu "erlaubt" werden. Deshalb wird hier abgelehnt statt
|
||||
* durchgewinkt.
|
||||
*/
|
||||
/**
|
||||
* Weist Boolean-Felder ab, die keine Booleans sind.
|
||||
*
|
||||
* `isActive` und `isServiceAccount` steuern beide ein Gate, das strikt auf
|
||||
* `boolean` prueft - ein `"ja"` rutschte daran vorbei, ohne das Gate
|
||||
* auszuloesen, und lief danach in einen Prisma-Fehler. Kein Bypass, weil der
|
||||
* Schreibvorgang scheiterte, aber ein 500 fuer eine Eingabe, die schlicht
|
||||
* falsch war. Dieselbe Bauart wie R193-02 bei den drei Haken: Ein Gate, das
|
||||
* strenger liest als der Rest, uebersieht genau das, was dazwischen passt.
|
||||
*/
|
||||
function pruefeBooleanFelder(daten: Record<string, unknown>, felder: string[]): string | null {
|
||||
for (const feld of felder) {
|
||||
const wert = daten[feld];
|
||||
if (wert !== undefined && typeof wert !== 'boolean') {
|
||||
return `${feld} muss true oder false sein (empfangen: ${JSON.stringify(wert)})`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handelnderOderNull(req: AuthRequest): number | null {
|
||||
return typeof req.user?.userId === 'number' ? req.user.userId : null;
|
||||
}
|
||||
|
||||
function meldeRollenAenderung(
|
||||
req: AuthRequest,
|
||||
nachricht: string,
|
||||
details: Record<string, unknown>,
|
||||
): void {
|
||||
const ctx = contextFromRequest(req);
|
||||
emitSecurityEvent({
|
||||
type: 'PERMISSION_CHANGED',
|
||||
severity: 'HIGH',
|
||||
message: nachricht,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userId: req.user?.userId,
|
||||
userEmail: req.user?.email,
|
||||
endpoint: ctx.endpoint,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRoles(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const roles = await userService.getAllRoles();
|
||||
@@ -344,43 +632,164 @@ export async function getRole(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRole(req: Request, res: Response): Promise<void> {
|
||||
/**
|
||||
* Prueft Name und Rechte-IDs aus dem Request.
|
||||
*
|
||||
* Gibt einen Fehlertext zurueck oder null. Bis 09/2026 gab es das gar nicht:
|
||||
* Ein leerer Name landete in der Datenbank, doppelte IDs liefen in einen
|
||||
* Primaerschluesselkonflikt und eine erfundene ID in einen
|
||||
* Fremdschluesselfehler - beides kam als HTTP 500 zurueck und sah damit nach
|
||||
* einem Serverfehler aus, obwohl es eine ungueltige Eingabe war.
|
||||
*/
|
||||
function pruefeRollenEingabe(
|
||||
daten: Partial<Record<string, unknown>>,
|
||||
nameNoetig: boolean,
|
||||
): string | null {
|
||||
if (nameNoetig || daten.name !== undefined) {
|
||||
if (typeof daten.name !== 'string' || daten.name.trim().length === 0) {
|
||||
return 'Name der Rolle fehlt';
|
||||
}
|
||||
if (daten.name.trim().length > 100) {
|
||||
return 'Name der Rolle ist zu lang (maximal 100 Zeichen)';
|
||||
}
|
||||
}
|
||||
if (daten.description !== undefined && typeof daten.description !== 'string') {
|
||||
return 'Beschreibung muss Text sein';
|
||||
}
|
||||
if (nameNoetig || daten.permissionIds !== undefined) {
|
||||
const ids = daten.permissionIds;
|
||||
if (!Array.isArray(ids)) return 'permissionIds muss eine Liste sein';
|
||||
if (!ids.every((id) => Number.isInteger(id) && (id as number) >= 1)) {
|
||||
return 'permissionIds darf nur positive ganze Zahlen enthalten';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Bildet die Fehler aus der Rechteprüfung auf HTTP-Codes ab. */
|
||||
function antworteAufRollenFehler(res: Response, error: unknown, fallback: string): void {
|
||||
// 403 statt 400: "Sie duerfen das nicht" ist etwas anderes als "Ihre
|
||||
// Eingabe ist kaputt". Ohne die Unterscheidung kann die Oberflaeche keinen
|
||||
// brauchbaren Hinweis geben.
|
||||
if (error instanceof RechteEskalationError || error instanceof RollenSperrError) {
|
||||
res.status(403).json({ success: false, error: error.message } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
if (error instanceof UngueltigeEingabeError) {
|
||||
res.status(400).json({ success: false, error: error.message } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ab hier gilt: Nur was wir SELBST formuliert haben, geht nach draussen.
|
||||
//
|
||||
// Vorher wurde jede `error.message` durchgereicht. Damit landete erst der
|
||||
// vollstaendige Prisma-Aufruf samt Serverpfad beim Client, und danach der
|
||||
// Wortlaut eines TypeError ("object is not iterable…") bei
|
||||
// `{"roleIds":{}}` (Pentest R190-01 und R192-01). Beide Male dieselbe
|
||||
// Ursache: eine Fehlermeldung, die fuer Entwickler geschrieben ist, an
|
||||
// einen Empfaenger, fuer den sie nicht gedacht war.
|
||||
//
|
||||
// Die Unterscheidung ist die Fehlerklasse, keine Heuristik auf dem Text:
|
||||
// Ein blankes `Error` werfen wir absichtlich und mit einer Meldung fuer
|
||||
// Menschen ("Der letzte Admin kann nicht…"). TypeError, RangeError und
|
||||
// Verwandte sind Programmierfehler, Prisma-Fehler kommen von aussen -
|
||||
// beides sagt dem Aufrufer nichts Nuetzliches und dem Angreifer zu viel.
|
||||
const istAbsichtlich = error instanceof Error && error.constructor === Error;
|
||||
if (istAbsichtlich) {
|
||||
res.status(400).json({ success: false, error: error.message } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unerwartet: Das ist ein Serverfehler, kein Eingabefehler - und ein 400
|
||||
// waere hier die naechste Meldung, die sich falsch ausgibt. Einzelheiten
|
||||
// ins Protokoll, damit sie nicht verlorengehen.
|
||||
console.error(`[${fallback}] unerwarteter Fehler:`, error);
|
||||
res.status(500).json({ success: false, error: fallback } as ApiResponse);
|
||||
}
|
||||
|
||||
export async function createRole(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const role = await userService.createRole(req.body);
|
||||
const daten = pickRoleUpdate(req.body);
|
||||
const fehler = pruefeRollenEingabe(daten, true);
|
||||
if (fehler) {
|
||||
res.status(400).json({ success: false, error: fehler } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const handelnder = handelnderOderNull(req);
|
||||
if (handelnder === null) {
|
||||
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const role = await userService.createRole(
|
||||
{
|
||||
name: daten.name as string,
|
||||
description: daten.description as string | undefined,
|
||||
permissionIds: daten.permissionIds as number[],
|
||||
},
|
||||
handelnder,
|
||||
);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'Role',
|
||||
resourceId: role.id.toString(),
|
||||
label: `Rolle ${role.name} angelegt`,
|
||||
});
|
||||
meldeRollenAenderung(req, `Rolle "${role.name}" angelegt`, {
|
||||
rolle: role.name,
|
||||
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||
});
|
||||
res.status(201).json({ success: true, data: role } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Rolle',
|
||||
} as ApiResponse);
|
||||
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen der Rolle');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRole(req: Request, res: Response): Promise<void> {
|
||||
export async function updateRole(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const role = await userService.updateRole(parseInt(req.params.id), req.body);
|
||||
if (role) {
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'Role',
|
||||
resourceId: role.id.toString(),
|
||||
label: `Rolle ${role.name} aktualisiert`,
|
||||
});
|
||||
const daten = pickRoleUpdate(req.body);
|
||||
const fehler = pruefeRollenEingabe(daten, false);
|
||||
if (fehler) {
|
||||
res.status(400).json({ success: false, error: fehler } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const handelnder = handelnderOderNull(req);
|
||||
if (handelnder === null) {
|
||||
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const role = await userService.updateRole(
|
||||
parseInt(req.params.id),
|
||||
{
|
||||
name: daten.name as string | undefined,
|
||||
description: daten.description as string | undefined,
|
||||
permissionIds: daten.permissionIds as number[] | undefined,
|
||||
},
|
||||
handelnder,
|
||||
);
|
||||
if (!role) {
|
||||
res.status(404).json({ success: false, error: 'Rolle nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'Role',
|
||||
resourceId: role.id.toString(),
|
||||
label: `Rolle ${role.name} aktualisiert`,
|
||||
});
|
||||
meldeRollenAenderung(req, `Rechte der Rolle "${role.name}" geändert`, {
|
||||
rolle: role.name,
|
||||
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||
traegerAbgemeldet: daten.permissionIds !== undefined,
|
||||
});
|
||||
res.json({ success: true, data: role } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rolle',
|
||||
} as ApiResponse);
|
||||
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren der Rolle');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRole(req: Request, res: Response): Promise<void> {
|
||||
export async function deleteRole(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const roleId = parseInt(req.params.id);
|
||||
const role = await userService.getRoleById(roleId);
|
||||
@@ -390,12 +799,12 @@ export async function deleteRole(req: Request, res: Response): Promise<void> {
|
||||
resourceId: roleId.toString(),
|
||||
label: `Rolle ${role?.name || roleId} gelöscht`,
|
||||
});
|
||||
meldeRollenAenderung(req, `Rolle "${role?.name || roleId}" gelöscht`, {
|
||||
rolle: role?.name,
|
||||
});
|
||||
res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rolle',
|
||||
} as ApiResponse);
|
||||
antworteAufRollenFehler(res, error, 'Fehler beim Löschen der Rolle');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+79
-53
@@ -1,4 +1,4 @@
|
||||
import express from 'express';
|
||||
import express, { Router } from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
@@ -30,6 +30,7 @@ if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD
|
||||
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
|
||||
}
|
||||
|
||||
import { apiBackstopRateLimiter } from './middleware/rateLimit.js';
|
||||
import authRoutes from './routes/auth.routes.js';
|
||||
import customerRoutes from './routes/customer.routes.js';
|
||||
import addressRoutes from './routes/address.routes.js';
|
||||
@@ -39,6 +40,7 @@ import meterRoutes from './routes/meter.routes.js';
|
||||
import stressfreiEmailRoutes from './routes/stressfreiEmail.routes.js';
|
||||
import contractRoutes from './routes/contract.routes.js';
|
||||
import creditNoteRoutes from './routes/creditNote.routes.js';
|
||||
import companyProfileRoutes from './routes/companyProfile.routes.js';
|
||||
import platformRoutes from './routes/platform.routes.js';
|
||||
import cancellationPeriodRoutes from './routes/cancellation-period.routes.js';
|
||||
import contractDurationRoutes from './routes/contract-duration.routes.js';
|
||||
@@ -64,10 +66,14 @@ import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||
import { downloadFile } from './controllers/fileDownload.controller.js';
|
||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
||||
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||
import { registriereIdPruefung } from './middleware/routeIds.js';
|
||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||
import { auditMiddleware } from './middleware/audit.js';
|
||||
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
||||
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
|
||||
import { authenticate } from './middleware/auth.js';
|
||||
|
||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||
@@ -322,63 +328,76 @@ app.use('/api', (_req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
||||
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
|
||||
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
|
||||
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` – also `6abc`,
|
||||
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
|
||||
// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
|
||||
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
|
||||
//
|
||||
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
|
||||
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
|
||||
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
|
||||
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
|
||||
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
|
||||
// nicht geblockt.
|
||||
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
|
||||
app.use('/api', (req, res, next) => {
|
||||
for (const seg of req.path.split('/')) {
|
||||
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
// Genau das loest `mounte()` weiter unten – die Pruefung wird an jedem Router
|
||||
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
|
||||
// zwar in beide Richtungen:
|
||||
//
|
||||
// – Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
|
||||
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
|
||||
// ID-Parameter und laesst nur kanonische Zahlen zu.
|
||||
// – Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
|
||||
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
|
||||
//
|
||||
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt – zwei Antworten
|
||||
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
|
||||
|
||||
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
||||
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
||||
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
||||
app.use('/api', apiBackstopRateLimiter);
|
||||
|
||||
// Router einhängen – IMMER über `mounte`, nie über `app.use` direkt.
|
||||
//
|
||||
// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188)
|
||||
// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie
|
||||
// stillschweigend – deshalb steht die Pruefung im selben Handgriff wie das
|
||||
// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann.
|
||||
const mounte = (pfad: string, router: Router): void => {
|
||||
app.use(pfad, registriereIdPruefung(router));
|
||||
};
|
||||
|
||||
// Öffentliche Routes (OHNE Authentifizierung)
|
||||
app.use('/api/public/consent', consentPublicRoutes);
|
||||
mounte('/api/public/consent', consentPublicRoutes);
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/customers', customerRoutes);
|
||||
app.use('/api/addresses', addressRoutes);
|
||||
app.use('/api/bank-cards', bankcardRoutes);
|
||||
app.use('/api/documents', documentRoutes);
|
||||
app.use('/api/meters', meterRoutes);
|
||||
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||
app.use('/api/contracts', contractRoutes);
|
||||
app.use('/api/credit-notes', creditNoteRoutes);
|
||||
app.use('/api/platforms', platformRoutes);
|
||||
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||
app.use('/api/contract-durations', contractDurationRoutes);
|
||||
app.use('/api/providers', providerRoutes);
|
||||
app.use('/api/tariffs', tariffRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
app.use('/api/developer', developerRoutes);
|
||||
app.use('/api/contract-categories', contractCategoryRoutes);
|
||||
app.use('/api', contractTaskRoutes);
|
||||
app.use('/api/settings', appSettingRoutes);
|
||||
app.use('/api/email-providers', emailProviderRoutes);
|
||||
app.use('/api', cachedEmailRoutes);
|
||||
app.use('/api/energy-details', invoiceRoutes);
|
||||
app.use('/api', contractHistoryRoutes);
|
||||
app.use('/api/audit-logs', auditLogRoutes);
|
||||
app.use('/api/gdpr', gdprRoutes);
|
||||
app.use('/api/email-logs', emailLogRoutes);
|
||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||
app.use('/api/birthdays', birthdayRoutes);
|
||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
||||
app.use('/api/monitoring', monitoringRoutes);
|
||||
mounte('/api/auth', authRoutes);
|
||||
mounte('/api/customers', customerRoutes);
|
||||
mounte('/api/addresses', addressRoutes);
|
||||
mounte('/api/bank-cards', bankcardRoutes);
|
||||
mounte('/api/documents', documentRoutes);
|
||||
mounte('/api/meters', meterRoutes);
|
||||
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||
mounte('/api/contracts', contractRoutes);
|
||||
mounte('/api/credit-notes', creditNoteRoutes);
|
||||
mounte('/api/company-profile', companyProfileRoutes);
|
||||
mounte('/api/platforms', platformRoutes);
|
||||
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||
mounte('/api/contract-durations', contractDurationRoutes);
|
||||
mounte('/api/providers', providerRoutes);
|
||||
mounte('/api/tariffs', tariffRoutes);
|
||||
mounte('/api/users', userRoutes);
|
||||
mounte('/api/upload', uploadRoutes);
|
||||
mounte('/api/developer', developerRoutes);
|
||||
mounte('/api/contract-categories', contractCategoryRoutes);
|
||||
mounte('/api', contractTaskRoutes);
|
||||
mounte('/api/settings', appSettingRoutes);
|
||||
mounte('/api/email-providers', emailProviderRoutes);
|
||||
mounte('/api', cachedEmailRoutes);
|
||||
mounte('/api/energy-details', invoiceRoutes);
|
||||
mounte('/api', contractHistoryRoutes);
|
||||
mounte('/api/audit-logs', auditLogRoutes);
|
||||
mounte('/api/gdpr', gdprRoutes);
|
||||
mounte('/api/email-logs', emailLogRoutes);
|
||||
mounte('/api/pdf-templates', pdfTemplateRoutes);
|
||||
mounte('/api/birthdays', birthdayRoutes);
|
||||
mounte('/api/factory-defaults', factoryDefaultsRoutes);
|
||||
mounte('/api/monitoring', monitoringRoutes);
|
||||
|
||||
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
||||
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
||||
@@ -478,10 +497,17 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
|
||||
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
||||
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
||||
|
||||
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
||||
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
||||
starteHeartbeatMonitor();
|
||||
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
|
||||
void pruefePflichtrechte();
|
||||
|
||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||
startBirthdayScheduler();
|
||||
startContractStatusScheduler();
|
||||
startSecurityMonitorScheduler();
|
||||
startBlzUpdateScheduler();
|
||||
});
|
||||
|
||||
@@ -49,6 +49,13 @@ function determineAction(method: string, path: string, success: boolean): AuditA
|
||||
if (path.includes('/auth/logout')) {
|
||||
return 'LOGOUT';
|
||||
}
|
||||
// Stiller Token-Refresh (Cookie) – kein interaktiver Login, eigene Action.
|
||||
// Erfolg vs. Fehlschlag wird NICHT über die Action getrennt (semantisch beides
|
||||
// ein Refresh), sondern downstream über success-Flag + Sensitivität (LOW/HIGH)
|
||||
// und den TOKEN_REJECTED-SecurityEvent im Controller (Pentest R164-01).
|
||||
if (path.includes('/auth/refresh')) {
|
||||
return 'TOKEN_REFRESH';
|
||||
}
|
||||
|
||||
// Standard CRUD-Aktionen
|
||||
switch (method.toUpperCase()) {
|
||||
@@ -88,10 +95,10 @@ function findResourceMapping(path: string): { type: string; extractId?: (req: Au
|
||||
/**
|
||||
* Extrahiert die betroffene Kunden-ID für DSGVO-Tracking
|
||||
*/
|
||||
function extractDataSubjectId(req: AuthRequest): number | undefined {
|
||||
function extractDataSubjectId(req: AuthRequest, fullPath: string): number | undefined {
|
||||
// Aus Route-Parameter
|
||||
const customerId = req.params.customerId || req.params.id;
|
||||
if (customerId && req.path.includes('/customers')) {
|
||||
if (customerId && fullPath.includes('/customers')) {
|
||||
return parseInt(customerId);
|
||||
}
|
||||
|
||||
@@ -157,8 +164,40 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
LOGIN: 'angemeldet',
|
||||
LOGOUT: 'abgemeldet',
|
||||
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
|
||||
TOKEN_REFRESH: 'Sitzung verlängert',
|
||||
};
|
||||
|
||||
/**
|
||||
* Ist das die planmaessige Anmeldung eines Dienstkontos? (Pentest R182)
|
||||
*
|
||||
* Vorhersagbare Ereignisse sind miserable Alarme und exzellente Grundlinien.
|
||||
* Das Gegenbuch meldet sich stuendlich an – als CRITICAL gefuehrt trainiert
|
||||
* genau diese Routine den Betreiber darauf, die hoechste Stufe wegzuklicken,
|
||||
* und der erste ECHTE Vorfall erbt den Reflex. Der Eintrag bleibt (man soll
|
||||
* sehen, dass das Gegenbuch arbeitet), aber als Routine.
|
||||
*/
|
||||
function istDienstkontoAnmeldung(responseBody: unknown, success: boolean): boolean {
|
||||
if (!success || !responseBody || typeof responseBody !== 'object') return false;
|
||||
const data = (responseBody as { data?: { user?: { isServiceAccount?: boolean } } }).data;
|
||||
return data?.user?.isServiceAccount === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unterscheidet die drei Ausgaenge von POST /auth/refresh (Pentest R166-03).
|
||||
*
|
||||
* "Kein Cookie vorhanden" ist KEIN Ablehnungsfall: Der Frontend-Interceptor
|
||||
* ruft /refresh beim App-Start auch dann, wenn nie ein Token gesetzt war
|
||||
* (normaler Erstbesuch). Das als HIGH/"abgelehnt" zu fuehren erzeugt genau das
|
||||
* Rauschen, das mit der Entrauschung beseitigt werden sollte.
|
||||
*/
|
||||
function refreshOutcome(responseBody: unknown, success: boolean): 'ok' | 'no-token' | 'rejected' {
|
||||
if (success) return 'ok';
|
||||
const err = responseBody && typeof responseBody === 'object' && 'error' in responseBody
|
||||
? String((responseBody as { error?: unknown }).error ?? '')
|
||||
: '';
|
||||
return /kein refresh-token/i.test(err) ? 'no-token' : 'rejected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
|
||||
*/
|
||||
@@ -166,7 +205,8 @@ function generateHumanLabel(
|
||||
action: AuditAction,
|
||||
resourceType: string,
|
||||
req: AuthRequest,
|
||||
responseBody: unknown
|
||||
responseBody: unknown,
|
||||
fullPath: string
|
||||
): string {
|
||||
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
|
||||
const actionName = ACTION_LABELS[action] || action;
|
||||
@@ -188,16 +228,28 @@ function generateHumanLabel(
|
||||
}
|
||||
|
||||
// Spezial-Labels für bestimmte Endpunkte
|
||||
const path = req.path;
|
||||
// (fullPath statt req.path – siehe Hinweis in auditMiddleware, Pentest R165)
|
||||
const path = fullPath;
|
||||
|
||||
// Auth
|
||||
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
|
||||
const email = req.body?.email || '';
|
||||
if (action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, true)) {
|
||||
return `Dienstkonto ${email} angemeldet (planmäßig)`;
|
||||
}
|
||||
return action === 'LOGIN'
|
||||
? `Benutzer ${email} hat sich angemeldet`
|
||||
: `Anmeldung fehlgeschlagen für ${email}`;
|
||||
}
|
||||
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
||||
if (path.includes('/auth/refresh')) {
|
||||
const success = !(responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false);
|
||||
switch (refreshOutcome(responseBody, success)) {
|
||||
case 'ok': return 'Sitzung verlängert (Token erneuert)';
|
||||
case 'no-token': return 'Token-Refresh ohne vorliegenden Token (kein Cookie)';
|
||||
default: return 'Token-Refresh abgelehnt (ungültig/abgelaufen)';
|
||||
}
|
||||
}
|
||||
|
||||
// Kunden-Operationen
|
||||
if (resourceType === 'Customer') {
|
||||
@@ -359,14 +411,23 @@ function generateHumanLabel(
|
||||
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||
const startTime = Date.now();
|
||||
|
||||
// WICHTIG (Pentest R165): `req.path` ist im res.on('finish')-Handler NICHT mehr der
|
||||
// volle Pfad. Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url`
|
||||
// und stellt ihn nur beim `next()`-Durchlauf wieder her – ein Handler, der die
|
||||
// Response terminiert (res.json()), ruft nie `next()`, also bleibt `req.path`
|
||||
// router-relativ (`/refresh` statt `/api/auth/refresh`). Deshalb den vollen Pfad
|
||||
// EINMAL hier synchron festhalten und downstream ausschliesslich diesen nutzen –
|
||||
// sonst matchen alle Pfad-Checks (/auth/login, /auth/refresh, …) ins Leere.
|
||||
const fullPath = req.originalUrl?.split('?')[0] || req.path;
|
||||
|
||||
// Ausgeschlossene Routen überspringen
|
||||
if (EXCLUDED_ROUTES.some((route) => req.path.startsWith(route))) {
|
||||
if (EXCLUDED_ROUTES.some((route) => fullPath.startsWith(route))) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resource-Mapping finden
|
||||
const mapping = findResourceMapping(req.path);
|
||||
const mapping = findResourceMapping(fullPath);
|
||||
if (!mapping) {
|
||||
// Unbekannte Route - trotzdem loggen mit generischem Typ
|
||||
next();
|
||||
@@ -402,7 +463,7 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const action = determineAction(req.method, req.path, responseSuccess);
|
||||
const action = determineAction(req.method, fullPath, responseSuccess);
|
||||
|
||||
// READ-Aktionen nicht loggen (nur Änderungen, Logins und Exporte)
|
||||
if (action === 'READ') return;
|
||||
@@ -415,19 +476,19 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
||||
'/api/gdpr',
|
||||
'/api/upload',
|
||||
];
|
||||
// Login/Logout immer loggen
|
||||
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED') {
|
||||
if (manuallyLoggedPaths.some(p => req.originalUrl?.startsWith(p) || req.baseUrl?.startsWith(p))) return;
|
||||
// Login/Logout/Refresh immer loggen
|
||||
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED' && action !== 'TOKEN_REFRESH') {
|
||||
if (manuallyLoggedPaths.some(p => fullPath.startsWith(p))) return;
|
||||
}
|
||||
|
||||
const resourceId = mapping.extractId?.(req);
|
||||
const dataSubjectId = extractDataSubjectId(req);
|
||||
const dataSubjectId = extractDataSubjectId(req, fullPath);
|
||||
|
||||
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
|
||||
const auditContext = capturedAuditContext;
|
||||
|
||||
// Menschenlesbares Label generieren
|
||||
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
|
||||
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody, fullPath);
|
||||
|
||||
await createAuditLog({
|
||||
userId: req.user?.userId,
|
||||
@@ -436,10 +497,20 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
||||
customerId: req.user?.customerId,
|
||||
isCustomerPortal: req.user?.isCustomerPortal,
|
||||
action,
|
||||
// Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
|
||||
// Abgelehnter Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in der
|
||||
// Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01).
|
||||
// "Kein Cookie vorhanden" ist dagegen der normale Erstbesuch → LOW (R166-03).
|
||||
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
|
||||
sensitivity: action === 'TOKEN_REFRESH'
|
||||
? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW')
|
||||
: action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, responseSuccess)
|
||||
? 'LOW'
|
||||
: undefined,
|
||||
resourceType: mapping.type,
|
||||
resourceId,
|
||||
resourceLabel,
|
||||
endpoint: req.path,
|
||||
endpoint: fullPath,
|
||||
httpMethod: req.method,
|
||||
ipAddress: getClientIp(req),
|
||||
userAgent: req.headers['user-agent'],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||||
@@ -128,7 +129,18 @@ export function requirePermission(...requiredPermissions: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userPermissions = req.user.permissions || [];
|
||||
// Portal-Zugaenge bekommen hier nur, was in PORTAL_RECHTE steht -
|
||||
// unabhaengig davon, was ihr Token behauptet.
|
||||
//
|
||||
// Das ist ein Riegel, kein Filter: Die Trennung zwischen Kundenportal und
|
||||
// Rollensystem ist Absicht (siehe config/rechte-katalog.ts). Ein Kunde
|
||||
// darf niemals operative Rechte bekommen - die Portalansicht ist dafuer
|
||||
// nicht gebaut und prueft es nicht. Bisher haette eine einzige unbedachte
|
||||
// Zeile in der Token-Erzeugung gereicht, um das zu kippen; ab hier
|
||||
// braeuchte es zwei, an zwei Stellen, in Kenntnis dieses Kommentars.
|
||||
const userPermissions = req.user.isCustomerPortal
|
||||
? (req.user.permissions || []).filter((p) => PORTAL_RECHTE.includes(p))
|
||||
: req.user.permissions || [];
|
||||
|
||||
// Check if user has any of the required permissions
|
||||
const hasPermission = requiredPermissions.some((perm) =>
|
||||
|
||||
@@ -132,6 +132,47 @@ export const staffPasswordReAuthLimiter = rateLimit({
|
||||
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
|
||||
* für legitime Kunden weit aus.
|
||||
*/
|
||||
/**
|
||||
* Globaler Backstop-Limiter für ALLE /api-Requests (Pentest R148).
|
||||
*
|
||||
* Hintergrund: Es gab bislang KEINEN generellen /api-Limiter – nur die
|
||||
* dedizierten oben (Login, Passwort-Reset, Staff-Re-Auth, Consent). Damit war
|
||||
* jeder authentifizierte Endpoint gegen Enumeration/Scripted-Abuse/DoS
|
||||
* ungedrosselt. Dieser Limiter ist eine großzügige Obergrenze, KEIN Ersatz für
|
||||
* die feineren Limiter (die feuern früher und bleiben aktiv).
|
||||
*
|
||||
* Key = NUR die (IPv6-/56-normalisierte) IP. Bewusst NICHT (IP+User): den
|
||||
* User-Claim könnten wir hier nur unverifiziert aus dem Token lesen (die volle
|
||||
* `authenticate`-Prüfung inkl. DB läuft erst pro Route). Ein Angreifer könnte
|
||||
* dann mit gefälschten userId-Claims beliebig frische Buckets erzeugen und den
|
||||
* Backstop umgehen. Per-Account-Präzision liefern ohnehin die Login-Limiter.
|
||||
*
|
||||
* Limit per Env `API_RATE_LIMIT_PER_MIN` (Default 1200/min ≈ 20/s pro IP) –
|
||||
* für legitime Nutzung (auch mehrere Nutzer hinter NAT) weit ausreichend,
|
||||
* bremst aber Flooding massiv. Healthcheck (`/api/health`) ist ausgenommen.
|
||||
*
|
||||
* KEIN SecurityEvent pro geblocktem Request: Bei einem Flood würde das den
|
||||
* Security-/Audit-Store selbst zumüllen (Amplification). Die 429 stehen im
|
||||
* Access-Log; die feineren Limiter melden weiterhin an das Monitoring.
|
||||
*/
|
||||
const API_RATE_LIMIT_PER_MIN = Math.max(
|
||||
parseInt(process.env.API_RATE_LIMIT_PER_MIN || '', 10) || 1200,
|
||||
60,
|
||||
);
|
||||
|
||||
export const apiBackstopRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: API_RATE_LIMIT_PER_MIN,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
success: false,
|
||||
error: 'Zu viele Anfragen in kurzer Zeit. Bitte einen Moment warten.',
|
||||
},
|
||||
keyGenerator: (req): string => ipKeyGenerator(req.ip || 'unknown'),
|
||||
skip: (req) => req.path === '/health',
|
||||
});
|
||||
|
||||
export const publicConsentRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
limit: 30,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
||||
import * as appSettingController from '../controllers/appSetting.controller.js';
|
||||
import * as backupController from '../controllers/backup.controller.js';
|
||||
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
|
||||
import * as blzDataController from '../controllers/blzData.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
// Multer für Backup-Upload (in Memory speichern)
|
||||
@@ -60,11 +61,17 @@ router.post(
|
||||
backupController.createBackup
|
||||
);
|
||||
|
||||
// Backup wiederherstellen
|
||||
// Backup wiederherstellen.
|
||||
//
|
||||
// Ebenfalls UND-verknuepft: Ein Restore ersetzt auch Benutzer, Rollen und
|
||||
// Rechtezuordnungen. Wer eine aeltere Sicherung einspielt, in der er selbst
|
||||
// mehr durfte, hat sich damit befoerdert - derselbe Umweg wie beim
|
||||
// Werksreset, nur leiser.
|
||||
router.post(
|
||||
'/backup/:name/restore',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
requirePermission('roles:manage'),
|
||||
backupController.restoreBackup
|
||||
);
|
||||
|
||||
@@ -93,11 +100,23 @@ router.post(
|
||||
backupController.uploadBackup
|
||||
);
|
||||
|
||||
// Werkseinstellungen (alles löschen)
|
||||
// Werkseinstellungen (alles löschen).
|
||||
//
|
||||
// Verlangt UND-verknuepft `settings:update` und `roles:manage` - zwei
|
||||
// requirePermission hintereinander, weil ein einzelner Aufruf mit mehreren
|
||||
// Rechten ODER bedeutet.
|
||||
//
|
||||
// Grund: Der Werksreset loescht alle Rollen und Rechtezuordnungen und legt
|
||||
// ein frisches admin@admin.com an. Eine Rolle mit nur `settings:update`
|
||||
// haette damit die gesamte Rechtevergabe zuruecksetzen und sich anschliessend
|
||||
// ueber das neue Konto anmelden koennen - eine Rechteerhoehung ueber den
|
||||
// Umweg "alles wegwerfen". Wer das Rechtemodell platt machen darf, muss es
|
||||
// auch pflegen duerfen.
|
||||
router.post(
|
||||
'/factory-reset',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
requirePermission('roles:manage'),
|
||||
backupController.factoryReset
|
||||
);
|
||||
|
||||
@@ -115,6 +134,24 @@ router.get(
|
||||
backupController.getBackupLogDetail
|
||||
);
|
||||
|
||||
// ==================== BLZ-/BANKDATEN ====================
|
||||
|
||||
// Status (installierte Version, Quelle, Auto-Update-Stand)
|
||||
router.get(
|
||||
'/blz',
|
||||
authenticate,
|
||||
requirePermission('settings:read'),
|
||||
blzDataController.getStatus,
|
||||
);
|
||||
|
||||
// Jetzt prüfen/aktualisieren (?force=1 lädt auch bei gleicher Version)
|
||||
router.post(
|
||||
'/blz/update-now',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
blzDataController.updateNow,
|
||||
);
|
||||
|
||||
// Rate-Limit-Verwaltung (Admin)
|
||||
router.get(
|
||||
'/rate-limits/active',
|
||||
|
||||
@@ -7,29 +7,50 @@ const router = Router();
|
||||
// Alle Routen erfordern Authentifizierung
|
||||
router.use(authenticate);
|
||||
|
||||
// ACHTUNG Reihenfolge: Alle konkreten Pfade MÜSSEN vor der Parameter-Route
|
||||
// '/:id' stehen, sonst schluckt diese sie und antwortet mit
|
||||
// "Ungültige Audit-Log-ID". Genau so war GET /retention-policies unerreichbar
|
||||
// (Pentest R166-04).
|
||||
|
||||
// Audit-Logs abrufen
|
||||
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
|
||||
|
||||
// Audit-Logs exportieren (muss VOR /:id stehen!)
|
||||
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs);
|
||||
// Audit-Logs exportieren
|
||||
//
|
||||
// Verlangt `audit:export`, nicht `audit:read`. Die Berechtigung stand im
|
||||
// Katalog und in der Rollenverwaltung, gatete aber NICHTS - jeder Leser
|
||||
// konnte das vollstaendige Protokoll in einem Zug herausziehen.
|
||||
//
|
||||
// Das ist etwas anderes als Blaettern: Der Export liefert `changesBefore` und
|
||||
// `changesAfter`, also die vollstaendigen Vorher/Nachher-Datensaetze, dazu
|
||||
// `resourceLabel` mit Klartextnamen, IP-Adressen und User-Agents. Aufgefallen
|
||||
// am Dienstkonto des Gegenbuchs: Es soll ausschliesslich Pruefwerte lesen -
|
||||
// und konnte Personendaten exportieren.
|
||||
router.get('/export', requirePermission('audit:export'), auditLogController.exportAuditLogs);
|
||||
|
||||
// Retention-Policies
|
||||
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
|
||||
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
|
||||
|
||||
// Kontrollwert für das externe Gegenbuch (nur lesend, ohne Geheimnisse)
|
||||
router.get('/checkpoint', requirePermission('audit:read'), auditLogController.getCheckpoint);
|
||||
|
||||
// Audit-Logs für einen Kunden (DSGVO)
|
||||
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
|
||||
|
||||
// Einzelnes Audit-Log abrufen
|
||||
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
|
||||
|
||||
// Hash-Ketten-Integrität prüfen
|
||||
router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity);
|
||||
|
||||
// Hash-Kette reparieren
|
||||
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
|
||||
|
||||
// Retention-Policies
|
||||
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
|
||||
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
|
||||
// Altbestand versiegeln (nicht destruktiv)
|
||||
router.post('/seal-backlog', requirePermission('audit:admin'), auditLogController.sealBacklog);
|
||||
|
||||
// Retention-Cleanup manuell ausführen
|
||||
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
|
||||
|
||||
// Einzelnes Audit-Log abrufen – als LETZTE GET-Route, siehe Hinweis oben
|
||||
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import * as customerController from '../controllers/customer.controller.js';
|
||||
import * as bankLookupController from '../controllers/bankLookup.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// IBAN-Prüfung + BIC/Bank-Nachschlag (offline, Bundesbank-BLZ). Liefert nur
|
||||
// öffentliche Bankverzeichnis-Daten – reicht daher ein eingeloggter Nutzer.
|
||||
router.post('/iban-lookup', authenticate, bankLookupController.lookupIban);
|
||||
|
||||
router.put('/:id', authenticate, requirePermission('customers:update'), customerController.updateBankCard);
|
||||
router.delete('/:id', authenticate, requirePermission('customers:delete'), customerController.deleteBankCard);
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// Kuendigungsfristen haben eigene Rechte (`cancellation-periods:*`).
|
||||
//
|
||||
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
|
||||
// nicht. `cancellation-periods:*` stand im Katalog und bewachte nichts.
|
||||
import { Router } from 'express';
|
||||
import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', authenticate, cancellationPeriodController.getCancellationPeriods);
|
||||
router.post('/', authenticate, requirePermission('platforms:create'), cancellationPeriodController.createCancellationPeriod);
|
||||
router.get('/:id', authenticate, cancellationPeriodController.getCancellationPeriod);
|
||||
router.put('/:id', authenticate, requirePermission('platforms:update'), cancellationPeriodController.updateCancellationPeriod);
|
||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), cancellationPeriodController.deleteCancellationPeriod);
|
||||
router.get('/', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriods);
|
||||
router.post('/', authenticate, requirePermission('cancellation-periods:create'), cancellationPeriodController.createCancellationPeriod);
|
||||
router.get('/:id', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriod);
|
||||
router.put('/:id', authenticate, requirePermission('cancellation-periods:update'), cancellationPeriodController.updateCancellationPeriod);
|
||||
router.delete('/:id', authenticate, requirePermission('cancellation-periods:delete'), cancellationPeriodController.deleteCancellationPeriod);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import * as companyProfileController from '../controllers/companyProfile.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', authenticate, requirePermission('settings:read'), companyProfileController.getProfile);
|
||||
router.put('/', authenticate, requirePermission('settings:update'), companyProfileController.updateProfile);
|
||||
|
||||
export default router;
|
||||
@@ -1,13 +1,17 @@
|
||||
// Vertragslaufzeiten haben eigene Rechte (`contract-durations:*`).
|
||||
//
|
||||
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
|
||||
// nicht. `contract-durations:*` stand im Katalog und bewachte nichts.
|
||||
import { Router } from 'express';
|
||||
import * as contractDurationController from '../controllers/contract-duration.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', authenticate, contractDurationController.getContractDurations);
|
||||
router.post('/', authenticate, requirePermission('platforms:create'), contractDurationController.createContractDuration);
|
||||
router.get('/:id', authenticate, contractDurationController.getContractDuration);
|
||||
router.put('/:id', authenticate, requirePermission('platforms:update'), contractDurationController.updateContractDuration);
|
||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), contractDurationController.deleteContractDuration);
|
||||
router.get('/', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDurations);
|
||||
router.post('/', authenticate, requirePermission('contract-durations:create'), contractDurationController.createContractDuration);
|
||||
router.get('/:id', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDuration);
|
||||
router.put('/:id', authenticate, requirePermission('contract-durations:update'), contractDurationController.updateContractDuration);
|
||||
router.delete('/:id', authenticate, requirePermission('contract-durations:delete'), contractDurationController.deleteContractDuration);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,9 +4,12 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Lesen für alle authentifizierten Benutzer
|
||||
router.get('/', authenticate, contractCategoryController.getContractCategories);
|
||||
router.get('/:id', authenticate, contractCategoryController.getContractCategory);
|
||||
// Lesen verlangt jetzt `contract-categories:read`, vorher genuegte
|
||||
// Angemeldetsein. Das Recht stand im Katalog und bewachte nichts; die
|
||||
// Migration vergibt es an jede Rolle, die die Anwendung benutzt, damit
|
||||
// niemand seine Listen verliert.
|
||||
router.get('/', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategories);
|
||||
router.get('/:id', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategory);
|
||||
|
||||
// Ändern/Löschen: `contract-categories:*` – wird per seed.ts an Admin-
|
||||
// Rollen vergeben. Vorher stand hier `developer:access` mit dem
|
||||
|
||||
@@ -22,6 +22,14 @@ router.get(
|
||||
contractTaskController.getTaskStats
|
||||
);
|
||||
|
||||
// Allgemeine Aufgabe OHNE Vertrag/Kunde anlegen (nur Mitarbeiter)
|
||||
router.post(
|
||||
'/tasks',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
contractTaskController.createGeneralTask
|
||||
);
|
||||
|
||||
// ==================== TASKS BY CONTRACT ====================
|
||||
|
||||
// Alle Aufgaben eines Vertrags abrufen (auch für Kundenportal, aber nur sichtbare)
|
||||
|
||||
@@ -4,12 +4,19 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Gesamtübersicht aller Belege (Hauptmenü) – portal-scoped (nicht staff-only).
|
||||
// VOR /:id, damit die Wurzel nicht als ID interpretiert wird.
|
||||
router.get('/', authenticate, requirePermission('contracts:read'), creditNoteController.listAll);
|
||||
|
||||
// Nummernkreis-Verwaltung (Einstellungen). VOR /:id, damit "number-range"
|
||||
// nicht als ID interpretiert wird.
|
||||
router.get('/number-range', authenticate, requirePermission('settings:read'), creditNoteController.getNumberRange);
|
||||
router.put('/number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateNumberRange);
|
||||
router.get('/delivery-note-number-range', authenticate, requirePermission('settings:read'), creditNoteController.getDeliveryNoteNumberRange);
|
||||
router.put('/delivery-note-number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateDeliveryNoteNumberRange);
|
||||
|
||||
// Einzelne Gutschrift
|
||||
router.post('/:id/pdf', authenticate, requirePermission('contracts:update'), creditNoteController.generatePdf);
|
||||
router.get('/:id', authenticate, requirePermission('contracts:read'), creditNoteController.getOne);
|
||||
router.put('/:id', authenticate, requirePermission('contracts:update'), creditNoteController.update);
|
||||
router.delete('/:id', authenticate, requirePermission('contracts:delete'), creditNoteController.remove);
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
// Die Providerkonfiguration hat eigene Rechte (`email-providers:*`).
|
||||
//
|
||||
// Bis 09/2026 gateten diese Routen auf `settings:read`/`settings:update` -
|
||||
// wer irgendeine Einstellung aendern durfte, konnte damit auch die
|
||||
// Zugangsdaten des Mailservers auslesen und aendern. `email-providers:*`
|
||||
// stand derweil im Katalog und bewachte nichts.
|
||||
//
|
||||
// BEWUSST NICHT umgestellt: /domain und /public-settings (ungegatet, werden
|
||||
// vom Kundenportal beim Postfach-Antrag gebraucht) sowie check/provision/
|
||||
// deprovision - das sind kundenbezogene Vorgaenge auf `customers:*`, keine
|
||||
// Providerkonfiguration. `email-providers:*` waere dort die falsche Domaene.
|
||||
// ==================== EMAIL PROVIDER ROUTES ====================
|
||||
|
||||
import { Router } from 'express';
|
||||
@@ -7,15 +18,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
const router = Router();
|
||||
|
||||
// Provider Config CRUD (Admin-only)
|
||||
router.get('/configs', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfigs);
|
||||
router.get('/configs/:id', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfig);
|
||||
router.post('/configs', authenticate, requirePermission('settings:update'), emailProviderController.createProviderConfig);
|
||||
router.put('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.updateProviderConfig);
|
||||
router.delete('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.deleteProviderConfig);
|
||||
router.get('/configs', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfigs);
|
||||
router.get('/configs/:id', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfig);
|
||||
router.post('/configs', authenticate, requirePermission('email-providers:create'), emailProviderController.createProviderConfig);
|
||||
router.put('/configs/:id', authenticate, requirePermission('email-providers:update'), emailProviderController.updateProviderConfig);
|
||||
router.delete('/configs/:id', authenticate, requirePermission('email-providers:delete'), emailProviderController.deleteProviderConfig);
|
||||
|
||||
// Email Operations
|
||||
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
|
||||
router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess);
|
||||
router.post('/test-connection', authenticate, requirePermission('email-providers:update'), emailProviderController.testConnection);
|
||||
router.post('/test-mail-access', authenticate, requirePermission('email-providers:update'), emailProviderController.testMailAccess);
|
||||
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
|
||||
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
|
||||
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Lesen verlangt jetzt `platforms:read`, vorher genuegte Angemeldetsein.
|
||||
import { Router } from 'express';
|
||||
import * as platformController from '../controllers/platform.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', authenticate, platformController.getPlatforms);
|
||||
router.get('/', authenticate, requirePermission('platforms:read'), platformController.getPlatforms);
|
||||
router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform);
|
||||
router.get('/:id', authenticate, platformController.getPlatform);
|
||||
router.get('/:id', authenticate, requirePermission('platforms:read'), platformController.getPlatform);
|
||||
router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform);
|
||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform);
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ router.get('/:id', authenticate, requirePermission('providers:read'), providerCo
|
||||
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
||||
|
||||
// Nested tariff routes
|
||||
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
|
||||
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
|
||||
// Tarife unter dem Anbieter - eigene Rechte, siehe tariff.routes.ts
|
||||
router.get('/:providerId/tariffs', authenticate, requirePermission('tariffs:read'), tariffController.getTariffs);
|
||||
router.post('/:providerId/tariffs', authenticate, requirePermission('tariffs:create'), tariffController.createTariff);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// Tarife haben eigene Rechte (`tariffs:*`).
|
||||
//
|
||||
// Bis 09/2026 gateten diese Routen auf `providers:*`, waehrend `tariffs:*`
|
||||
// im Katalog stand und nichts bewachte - man konnte "Tarife bearbeiten"
|
||||
// anhaken, ohne dass es etwas bewirkte, und wer Anbieter pflegen durfte,
|
||||
// durfte automatisch auch alle Tarife aendern.
|
||||
import { Router } from 'express';
|
||||
import * as tariffController from '../controllers/tariff.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
@@ -5,8 +11,8 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
const router = Router();
|
||||
|
||||
// Standalone tariff routes (for update/delete by tariff id)
|
||||
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
|
||||
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
|
||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
|
||||
router.get('/:id', authenticate, requirePermission('tariffs:read'), tariffController.getTariff);
|
||||
router.put('/:id', authenticate, requirePermission('tariffs:update'), tariffController.updateTariff);
|
||||
router.delete('/:id', authenticate, requirePermission('tariffs:delete'), tariffController.deleteTariff);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '../utils/accessControl.js';
|
||||
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
||||
import { maybeCancelOnCancellationConfirmation, maybeMarkAwaitingConfirmationOnLetter } from '../services/contractStatusScheduler.service.js';
|
||||
import { deleteFileAbsolute, deleteUploadByRelativePath } from '../utils/fileCleanup.js';
|
||||
|
||||
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
||||
// Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion
|
||||
@@ -29,11 +31,6 @@ async function resolveInvoiceContractId(invoiceId: number): Promise<number | nul
|
||||
return invoice?.contractId ?? invoice?.energyContractDetails?.contractId ?? null;
|
||||
}
|
||||
|
||||
function cleanupFile(filePath?: string) {
|
||||
if (!filePath) return;
|
||||
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Uploads-Verzeichnis erstellen falls nicht vorhanden
|
||||
@@ -121,12 +118,12 @@ router.post(
|
||||
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||
const card = await prisma.bankCard.findUnique({ where: { id: bankCardId } });
|
||||
if (!card) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
res.status(404).json({ success: false, error: 'Bankkarte nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessBankCard(req, res, bankCardId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,7 +145,7 @@ router.post(
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
cleanupFile(req.file?.path);
|
||||
deleteFileAbsolute(req.file?.path);
|
||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
@@ -173,12 +170,12 @@ router.post(
|
||||
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||
const doc = await prisma.identityDocument.findUnique({ where: { id: documentId } });
|
||||
if (!doc) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
res.status(404).json({ success: false, error: 'Ausweis nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessIdentityDocument(req, res, documentId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -200,7 +197,7 @@ router.post(
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
cleanupFile(req.file?.path);
|
||||
deleteFileAbsolute(req.file?.path);
|
||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
@@ -318,7 +315,7 @@ router.post(
|
||||
const customerId = parseInt(req.params.id);
|
||||
// Pentest 56.1: Ownership-Check.
|
||||
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
const relativePath = `/uploads/business-registrations/${req.file.filename}`;
|
||||
@@ -372,7 +369,7 @@ router.post(
|
||||
const customerId = parseInt(req.params.id);
|
||||
// Pentest 56.1: Ownership-Check.
|
||||
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
const relativePath = `/uploads/commercial-registers/${req.file.filename}`;
|
||||
@@ -515,7 +512,7 @@ router.post(
|
||||
// jede beliebige customerId ALLE Einwilligungen auf GRANTED setzen
|
||||
// (DSGVO-Eskalation).
|
||||
if (!(await canAccessCustomer(req, res, customerId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
const relativePath = `/uploads/privacy-policies/${req.file.filename}`;
|
||||
@@ -673,7 +670,7 @@ async function handleContractDocumentUpload(
|
||||
try {
|
||||
provided = validateOptionalIsoDate(req.body?.confirmationDate, 'confirmationDate');
|
||||
} catch (err) {
|
||||
cleanupFile(req.file?.path);
|
||||
deleteFileAbsolute(req.file?.path);
|
||||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Bestätigungsdatum' });
|
||||
return;
|
||||
}
|
||||
@@ -694,23 +691,16 @@ async function handleContractDocumentUpload(
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
|
||||
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
|
||||
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
|
||||
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
|
||||
await prisma.contract.update({
|
||||
where: { id: contractId },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'Contract',
|
||||
resourceId: contractId.toString(),
|
||||
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
|
||||
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
|
||||
customerId: contract.customerId,
|
||||
});
|
||||
// Kündigungsbestätigung (nicht "Optionen") hochgeladen → Vertrag auf
|
||||
// "Gekündigt / bestätigt" (CANCELLED_CONFIRMED) + Vertragsende = Kündigungs-
|
||||
// datum. "Optionen" ist für Vertrags-Änderungen gedacht, nicht für Kündigungen.
|
||||
if (fieldName === 'cancellationConfirmationPath') {
|
||||
await maybeCancelOnCancellationConfirmation(contractId, req);
|
||||
}
|
||||
// Kündigungsschreiben hochgeladen → Vertrag auf "Gekündigt / Bestätigung
|
||||
// abwarten" (CANCELLED), sofern noch nicht (bestätigt) gekündigt.
|
||||
if (fieldName === 'cancellationLetterPath') {
|
||||
await maybeMarkAwaitingConfirmationOnLetter(contractId, req);
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -862,13 +852,13 @@ router.post(
|
||||
// Pentest 56.1: Existenz- und Ownership-Check VOR DB-Update.
|
||||
const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId } });
|
||||
if (!invoice) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
res.status(404).json({ success: false, error: 'Rechnung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
const invoiceContractId = await resolveInvoiceContractId(invoiceId);
|
||||
if (invoiceContractId == null || !(await canAccessContract(req, res, invoiceContractId))) {
|
||||
cleanupFile(req.file.path);
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -946,4 +936,87 @@ router.delete(
|
||||
}
|
||||
);
|
||||
|
||||
// Upload für Gutschrift-Überweisungsbeleg. Interner Bereich → Portal blocken.
|
||||
router.post(
|
||||
'/credit-notes/:id/receipt',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
setUploadDir('credit-note-receipts'),
|
||||
upload.single('document'),
|
||||
validateUploadedFile,
|
||||
async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (req.user?.isCustomerPortal) {
|
||||
deleteFileAbsolute(req.file?.path);
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff' });
|
||||
return;
|
||||
}
|
||||
if (!req.file) {
|
||||
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(req.params.id);
|
||||
const cn = await prisma.creditNote.findUnique({
|
||||
where: { id },
|
||||
select: { contractId: true, receiptPath: true },
|
||||
});
|
||||
if (!cn) {
|
||||
deleteFileAbsolute(req.file.path);
|
||||
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessContract(req, res, cn.contractId))) {
|
||||
deleteFileAbsolute(req.file.path);
|
||||
return;
|
||||
}
|
||||
const relativePath = `/uploads/credit-note-receipts/${req.file.filename}`;
|
||||
deleteUploadByRelativePath(cn.receiptPath);
|
||||
await prisma.creditNote.update({ where: { id }, data: { receiptPath: relativePath } });
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
|
||||
label: 'Überweisungsbeleg zur Gutschrift hochgeladen',
|
||||
});
|
||||
res.json({ success: true, data: { receiptPath: relativePath } });
|
||||
} catch (error) {
|
||||
console.error('Credit-note receipt upload error:', error);
|
||||
deleteFileAbsolute(req.file?.path);
|
||||
res.status(500).json({ success: false, error: 'Upload fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/credit-notes/:id/receipt',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (req.user?.isCustomerPortal) {
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff' });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(req.params.id);
|
||||
const cn = await prisma.creditNote.findUnique({
|
||||
where: { id },
|
||||
select: { contractId: true, receiptPath: true },
|
||||
});
|
||||
if (!cn) {
|
||||
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
if (!(await canAccessContract(req, res, cn.contractId))) return;
|
||||
deleteUploadByRelativePath(cn.receiptPath);
|
||||
await prisma.creditNote.update({ where: { id }, data: { receiptPath: null } });
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
|
||||
label: 'Überweisungsbeleg zur Gutschrift entfernt',
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Credit-note receipt delete error:', error);
|
||||
res.status(500).json({ success: false, error: 'Löschen fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -16,14 +16,26 @@ router.delete('/:id', authenticate, requirePermission('users:delete'), userContr
|
||||
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
|
||||
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
|
||||
|
||||
// Roles
|
||||
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
|
||||
router.post('/roles', authenticate, requirePermission('users:create'), userController.createRole);
|
||||
router.get('/roles/:id', authenticate, requirePermission('users:read'), userController.getRole);
|
||||
router.put('/roles/:id', authenticate, requirePermission('users:update'), userController.updateRole);
|
||||
router.delete('/roles/:id', authenticate, requirePermission('users:delete'), userController.deleteRole);
|
||||
// Rollen und Rechte.
|
||||
//
|
||||
// Die Schreibwege haengen an `roles:manage`, nicht mehr an `users:*`:
|
||||
// "Konten anlegen" und "festlegen, was ein Konto darf" sind zwei
|
||||
// verschiedene Befugnisse. Wer beides hatte, konnte sich eine Rolle mit
|
||||
// `developer:access` bauen und zuweisen - die Rollenverwaltung war damit
|
||||
// faktisch eine Rechteerhoehung mit Zwischenschritt. Die zweite Haelfte des
|
||||
// Riegels ist die Teilmengenregel in rechte.service.ts.
|
||||
//
|
||||
// Die LESEwege bleiben zusaetzlich mit `users:read` erreichbar: Das
|
||||
// Benutzerformular braucht die Rollenliste zum Zuweisen, ohne dass der
|
||||
// Bearbeiter Rollen pflegen koennen muss. `requirePermission` ist
|
||||
// ODER-verknuepft, das traegt ohne Zusatzcode.
|
||||
router.get('/roles/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRoles);
|
||||
router.post('/roles', authenticate, requirePermission('roles:manage'), userController.createRole);
|
||||
router.get('/roles/:id', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRole);
|
||||
router.put('/roles/:id', authenticate, requirePermission('roles:manage'), userController.updateRole);
|
||||
router.delete('/roles/:id', authenticate, requirePermission('roles:manage'), userController.deleteRole);
|
||||
|
||||
// Permissions
|
||||
router.get('/permissions/list', authenticate, requirePermission('users:read'), userController.getPermissions);
|
||||
router.get('/permissions/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getPermissions);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -12,6 +12,10 @@ const DEFAULT_SETTINGS: Record<string, string> = {
|
||||
// Ausweis-Ablauf: Fristenschwellen (in Tagen)
|
||||
documentExpiryCriticalDays: '30', // Rot: Kritisch (Standard 30 Tage)
|
||||
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
|
||||
// BLZ-/Bankdaten-Auto-Update (Bundesbank-Bankleitzahlen via bankdata-germany).
|
||||
// Der Updater lädt nur eine öffentliche Datendatei – keine Kundendaten.
|
||||
blzAutoUpdateEnabled: 'true', // Auto-Update an/aus
|
||||
blzUpdateIntervalDays: '30', // Prüf-/Update-Intervall in Tagen
|
||||
};
|
||||
|
||||
// Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE
|
||||
@@ -90,8 +94,22 @@ export function validateSettingValue(key: string, rawValue: string): { ok: true;
|
||||
return { ok: true, value: trimmed };
|
||||
}
|
||||
|
||||
// BLZ-Update-Intervall: mind. 1 Tag, höchstens 365 (verhindert Dauer-Polling
|
||||
// bzw. faktisch nie).
|
||||
if (key === 'blzUpdateIntervalDays') {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return { ok: false, error: 'Das Intervall muss eine positive ganze Zahl (Tage) sein.' };
|
||||
}
|
||||
const n = parseInt(trimmed, 10);
|
||||
if (n < 1 || n > 365) {
|
||||
return { ok: false, error: 'Das Intervall muss zwischen 1 und 365 Tagen liegen.' };
|
||||
}
|
||||
return { ok: true, value: String(n) };
|
||||
}
|
||||
|
||||
// Bool-Settings
|
||||
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled') {
|
||||
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled' || key === 'blzAutoUpdateEnabled') {
|
||||
const trimmed = rawValue.trim().toLowerCase();
|
||||
if (trimmed !== 'true' && trimmed !== 'false') {
|
||||
return { ok: false, error: `${key} muss 'true' oder 'false' sein.` };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
@@ -23,10 +24,194 @@ export function signAccessToken(payload: JwtPayload): string {
|
||||
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||||
});
|
||||
}
|
||||
export function signRefreshToken(payload: JwtPayload): string {
|
||||
return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, {
|
||||
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
||||
export function signRefreshToken(payload: JwtPayload, jti?: string, familyId?: string): string {
|
||||
return jwt.sign(
|
||||
{ ...payload, type: 'refresh', ...(jti ? { jti } : {}), ...(familyId ? { fam: familyId } : {}) },
|
||||
process.env.JWT_SECRET as string,
|
||||
{ expiresIn: REFRESH_TOKEN_EXPIRES_IN },
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== Refresh-Token-Replay-Schutz (R164-02) =====================
|
||||
//
|
||||
// Kulanzfenster: Mehrere Tabs teilen sich denselben Cookie und koennen einen
|
||||
// Refresh praktisch gleichzeitig ausloesen. Ohne Toleranz wuerde der zweite
|
||||
// legitime Aufruf als Replay gewertet und die Sitzung gesprengt. Die Toleranz
|
||||
// ist bewusst eng (Sekunden UND Anzahl begrenzt): ein Burst mit vielen
|
||||
// Wiederverwendungen - das typische Bild beim Missbrauch eines gestohlenen
|
||||
// Tokens - schlaegt weiterhin an.
|
||||
const REFRESH_REUSE_GRACE_MS = 15_000;
|
||||
const REFRESH_REUSE_MAX = 3;
|
||||
|
||||
/** Legt einen neuen Sitzungs-Refresh-Token an (neue Familie). */
|
||||
export async function issueRefreshToken(
|
||||
payload: JwtPayload,
|
||||
opts: {
|
||||
userId?: number;
|
||||
customerId?: number;
|
||||
isCustomerPortal: boolean;
|
||||
familyId?: string;
|
||||
/** Beim Rotieren bereits beim Einloesen reservierte jti des Nachfolgers. */
|
||||
jti?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const jti = opts.jti || crypto.randomUUID();
|
||||
const familyId = opts.familyId || crypto.randomUUID();
|
||||
const token = signRefreshToken(payload, jti, familyId);
|
||||
const decoded: any = jwt.decode(token);
|
||||
|
||||
await prisma.refreshTokenRecord.create({
|
||||
data: {
|
||||
jti,
|
||||
familyId,
|
||||
userId: opts.userId ?? null,
|
||||
customerId: opts.customerId ?? null,
|
||||
isCustomerPortal: opts.isCustomerPortal,
|
||||
expiresAt: new Date((decoded?.exp ?? 0) * 1000),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Widerruft eine komplette Sitzungsfamilie. */
|
||||
async function revokeFamily(familyId: string, reason: string): Promise<void> {
|
||||
await prisma.refreshTokenRecord.updateMany({
|
||||
where: { familyId, revokedAt: null },
|
||||
data: { revokedAt: new Date(), revokedReason: reason },
|
||||
});
|
||||
}
|
||||
|
||||
/** Widerruft alle Familien eines Subjekts (Logout, Rechteaenderung). */
|
||||
export async function revokeAllRefreshTokens(
|
||||
subject: { userId?: number; customerId?: number },
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const where = subject.userId != null ? { userId: subject.userId } : { customerId: subject.customerId };
|
||||
await prisma.refreshTokenRecord.updateMany({
|
||||
where: { ...where, revokedAt: null },
|
||||
data: { revokedAt: new Date(), revokedReason: reason },
|
||||
});
|
||||
}
|
||||
|
||||
/** Abgelaufene Datensaetze entfernen (beim Login, seltener als beim Refresh). */
|
||||
async function pruneExpiredRefreshTokens(): Promise<void> {
|
||||
try {
|
||||
await prisma.refreshTokenRecord.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
} catch {
|
||||
// Aufraeumen darf den Login nie blockieren.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die jti eines vorgelegten Refresh-Tokens und verbraucht sie.
|
||||
* Wirft mit `code = 'REFRESH_REPLAY'`, wenn ein bereits eingeloester Token
|
||||
* erneut auftaucht - der Controller meldet das als Sicherheitsvorfall.
|
||||
*/
|
||||
async function consumeRefreshJti(decoded: any): Promise<{
|
||||
familyId: string;
|
||||
/** Frische Rotation: Datensatz fuer diese jti muss noch angelegt werden. */
|
||||
issueJti?: string;
|
||||
/** Kulanz: dieser bereits ausgestellte Nachfolger wird erneut ausgegeben. */
|
||||
reuseJti?: string;
|
||||
}> {
|
||||
const jti: string | undefined = decoded?.jti;
|
||||
const fam: string | undefined = decoded?.fam;
|
||||
|
||||
if (!jti || !fam) {
|
||||
// Token aus der Zeit vor dem Replay-Schutz: bewusst fail-closed, damit
|
||||
// kein Altbestand die Einmalverwendung umgehen kann. Kostet einmalig
|
||||
// eine Neuanmeldung.
|
||||
const err: any = new Error('Sitzung muss erneuert werden – bitte neu anmelden');
|
||||
err.code = 'REFRESH_LEGACY';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const rec = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
|
||||
if (!rec) {
|
||||
const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
|
||||
err.code = 'REFRESH_INVALID';
|
||||
throw err;
|
||||
}
|
||||
if (rec.revokedAt) {
|
||||
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
|
||||
err.code = 'REFRESH_REVOKED';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Einloesen MUSS atomar sein. Ein Lesen-dann-Schreiben wuerde bei parallelen
|
||||
// Anfragen nichts verhindern: alle wuerden den Token als "noch unbenutzt"
|
||||
// sehen und durchgelassen (im Test kamen 90 gleichzeitige Requests
|
||||
// ausnahmslos durch). Deshalb wird der Zustandswechsel als bedingtes UPDATE
|
||||
// ausgefuehrt - die Datenbank entscheidet, wer zuerst war.
|
||||
// Die jti des Nachfolgers wird SCHON HIER festgelegt und im selben UPDATE
|
||||
// hinterlegt. Dadurch weiss eine spaetere Wiedervorlage, welcher Nachfolger
|
||||
// bereits ausgestellt wurde - Grundlage der idempotenten Kulanz (R168-01).
|
||||
const nachfolgerJti = crypto.randomUUID();
|
||||
const beansprucht = await prisma.refreshTokenRecord.updateMany({
|
||||
where: { jti, usedAt: null, revokedAt: null },
|
||||
data: { usedAt: new Date(), replacedByJti: nachfolgerJti },
|
||||
});
|
||||
if (beansprucht.count === 1) {
|
||||
return { familyId: rec.familyId, issueJti: nachfolgerJti };
|
||||
}
|
||||
|
||||
// Bereits eingeloest. Innerhalb des engen Kulanzfensters begrenzt tolerieren
|
||||
// (parallele Tabs) - als bedingtes UPDATE, damit die Obergrenze unter Last
|
||||
// wirklich haelt.
|
||||
const fensterAb = new Date(Date.now() - REFRESH_REUSE_GRACE_MS);
|
||||
const toleriert = await prisma.refreshTokenRecord.updateMany({
|
||||
where: {
|
||||
jti,
|
||||
revokedAt: null,
|
||||
usedAt: { gte: fensterAb },
|
||||
reuseCount: { lt: REFRESH_REUSE_MAX },
|
||||
},
|
||||
data: { reuseCount: { increment: 1 } },
|
||||
});
|
||||
if (toleriert.count === 1) {
|
||||
// IDEMPOTENT: denselben, bereits ausgestellten Nachfolger zurueckgeben -
|
||||
// NICHT erneut rotieren (Pentest R168-01).
|
||||
//
|
||||
// Vorher entstand bei jeder Kulanz-Wiedervorlage eine frische Linie mit
|
||||
// eigenem, zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token
|
||||
// konnte damit aus dem erkennbaren Replay-Zustand in eine eigene,
|
||||
// sauber weiterrotierende Sitzung entkommen, die nie wieder mit der des
|
||||
// Opfers kollidiert - also dauerhaft unsichtbar blieb.
|
||||
//
|
||||
// Jetzt laufen parallele Tabs auf DIESELBE Linie zusammen. Wer den Token
|
||||
// spaeter erneut vorlegt (also ausserhalb des Fensters), kollidiert
|
||||
// zwangslaeufig und loest den Familien-Widerruf aus.
|
||||
const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
|
||||
if (aktuell?.replacedByJti) {
|
||||
const nachfolger = await prisma.refreshTokenRecord.findUnique({
|
||||
where: { jti: aktuell.replacedByJti },
|
||||
});
|
||||
if (nachfolger?.revokedAt) {
|
||||
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
|
||||
err.code = 'REFRESH_REVOKED';
|
||||
throw err;
|
||||
}
|
||||
return { familyId: rec.familyId, reuseJti: aktuell.replacedByJti };
|
||||
}
|
||||
// Kein Nachfolger hinterlegt: nicht ersatzweise rotieren (das waere genau
|
||||
// der Fork). Fail-closed als Replay behandeln.
|
||||
}
|
||||
|
||||
// Weder frei noch tolerierbar: War der Token zwischenzeitlich widerrufen
|
||||
// (z. B. durch einen parallelen Replay-Fund), ist das kein neuer Vorfall.
|
||||
const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
|
||||
if (aktuell?.revokedAt) {
|
||||
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
|
||||
err.code = 'REFRESH_REVOKED';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Replay: gesamte Familie widerrufen - der Angreifer verliert den Zugang,
|
||||
// der legitime Nutzer muss sich neu anmelden und merkt es dadurch.
|
||||
await revokeFamily(rec.familyId, 'Replay eines bereits eingelösten Refresh-Tokens');
|
||||
const err: any = new Error('Refresh-Token wurde bereits verwendet – Sitzung aus Sicherheitsgründen beendet');
|
||||
err.code = 'REFRESH_REPLAY';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
|
||||
@@ -133,8 +318,10 @@ export async function login(email: string, password: string) {
|
||||
isCustomerPortal: false,
|
||||
};
|
||||
|
||||
await pruneExpiredRefreshTokens();
|
||||
const accessToken = signAccessToken(payload);
|
||||
const refreshToken = signRefreshToken(payload);
|
||||
// Neue Sitzungsfamilie (Replay-Schutz, R164-02)
|
||||
const refreshToken = await issueRefreshToken(payload, { userId: user.id, isCustomerPortal: false });
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
@@ -147,6 +334,9 @@ export async function login(email: string, password: string) {
|
||||
permissions: Array.from(permissions),
|
||||
customerId: user.customerId,
|
||||
isCustomerPortal: false,
|
||||
// Wird von der Audit-Middleware gelesen, um planmaessige
|
||||
// Dienstkonto-Anmeldungen als Routine einzustufen (R182).
|
||||
isServiceAccount: user.isServiceAccount,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -231,10 +421,9 @@ export async function customerLogin(email: string, password: string) {
|
||||
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
|
||||
|
||||
// Kundenportal-Berechtigungen (eingeschränkt)
|
||||
const customerPermissions = [
|
||||
'contracts:read', // Eigene Verträge lesen
|
||||
'customers:read', // Eigene Kundendaten lesen
|
||||
];
|
||||
// Feste Portal-Rechte aus config/rechte-katalog.ts. Bewusst getrennt vom
|
||||
// Rollensystem - siehe den Kommentar dort.
|
||||
const customerPermissions = [...PORTAL_RECHTE];
|
||||
|
||||
const payload: JwtPayload = {
|
||||
email: customer.portalEmail!,
|
||||
@@ -244,8 +433,10 @@ export async function customerLogin(email: string, password: string) {
|
||||
representedCustomerIds,
|
||||
};
|
||||
|
||||
await pruneExpiredRefreshTokens();
|
||||
const accessToken = signAccessToken(payload);
|
||||
const refreshToken = signRefreshToken(payload);
|
||||
// Neue Sitzungsfamilie (Replay-Schutz, R164-02)
|
||||
const refreshToken = await issueRefreshToken(payload, { customerId: customer.id, isCustomerPortal: true });
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
@@ -286,12 +477,32 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
||||
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Refresh-Token ungültig oder abgelaufen');
|
||||
} catch (e) {
|
||||
// Code erhalten, damit der Controller abgelaufen (benigne, LOW) von
|
||||
// manipuliert/ungültiger Signatur (verdächtig, HIGH) trennen kann.
|
||||
const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
|
||||
err.code = e instanceof jwt.TokenExpiredError ? 'REFRESH_EXPIRED' : 'REFRESH_INVALID';
|
||||
throw err;
|
||||
}
|
||||
if (decoded.type !== 'refresh') {
|
||||
throw new Error('Falscher Token-Typ');
|
||||
}
|
||||
// Einmalverwendung durchsetzen und Sitzungsfamilie bestimmen (R164-02).
|
||||
// Wirft bei Replay – danach ist die gesamte Familie widerrufen.
|
||||
const rotation = await consumeRefreshJti(decoded);
|
||||
// Bei Kulanz wird derselbe, bereits ausgestellte Nachfolger erneut signiert -
|
||||
// ohne neuen Datensatz, damit keine zweite Linie entsteht (R168-01).
|
||||
const naechsterRefreshToken = async (
|
||||
payload: JwtPayload,
|
||||
subjekt: { userId?: number; customerId?: number; isCustomerPortal: boolean },
|
||||
): Promise<string> =>
|
||||
rotation.reuseJti
|
||||
? signRefreshToken(payload, rotation.reuseJti, rotation.familyId)
|
||||
: issueRefreshToken(payload, {
|
||||
...subjekt,
|
||||
familyId: rotation.familyId,
|
||||
jti: rotation.issueJti,
|
||||
});
|
||||
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
|
||||
|
||||
// Mitarbeiter
|
||||
@@ -321,7 +532,9 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
||||
};
|
||||
return {
|
||||
accessToken: signAccessToken(payload),
|
||||
refreshToken: signRefreshToken(payload),
|
||||
// Nachfolger bleibt in derselben Familie – ein Replay des Vorgaengers
|
||||
// sprengt damit auch alle daraus entstandenen Tokens.
|
||||
refreshToken: await naechsterRefreshToken(payload, { userId: user.id, isCustomerPortal: false }),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -352,7 +565,10 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
||||
};
|
||||
return {
|
||||
accessToken: signAccessToken(payload),
|
||||
refreshToken: signRefreshToken(payload),
|
||||
refreshToken: await naechsterRefreshToken(payload, {
|
||||
customerId: customer.id,
|
||||
isCustomerPortal: true,
|
||||
}),
|
||||
user: portalUser,
|
||||
};
|
||||
}
|
||||
@@ -408,23 +624,56 @@ export async function markPortalPasswordForChange(customerId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
// Kundenportal-Passwort im Klartext abrufen
|
||||
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
|
||||
// Ergebnis des Klartext-Passwort-Abrufs.
|
||||
// - 'ok' → password ist das gültige Klartext-Passwort
|
||||
// - 'none' → kein verschlüsseltes Passwort hinterlegt (z.B. self-service
|
||||
// gesetzt) → nichts zum Anzeigen/Versenden
|
||||
// - 'desync' → verschlüsseltes Passwort vorhanden, passt aber NICHT zum
|
||||
// Login-Hash (oder Entschlüsselung fehlgeschlagen). Darf NIE
|
||||
// ausgegeben werden – sonst liest der Support ein Passwort vor,
|
||||
// das beim Login scheitert (Pentest-Hinweis).
|
||||
export type PortalPasswordReveal =
|
||||
| { status: 'ok'; password: string }
|
||||
| { status: 'none' }
|
||||
| { status: 'desync' };
|
||||
|
||||
// Kundenportal-Passwort im Klartext abrufen – mit Konsistenzprüfung gegen den
|
||||
// Login-Hash, damit niemals ein „falsches" (out-of-sync) Passwort herausgeht.
|
||||
export async function getCustomerPortalPassword(customerId: number): Promise<PortalPasswordReveal> {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { portalPasswordEncrypted: true },
|
||||
select: { portalPasswordEncrypted: true, portalPasswordHash: true },
|
||||
});
|
||||
|
||||
if (!customer?.portalPasswordEncrypted) {
|
||||
return null;
|
||||
return { status: 'none' };
|
||||
}
|
||||
|
||||
let decrypted: string;
|
||||
try {
|
||||
return decrypt(customer.portalPasswordEncrypted);
|
||||
decrypted = decrypt(customer.portalPasswordEncrypted);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Entschlüsseln des Passworts:', error);
|
||||
return null;
|
||||
return { status: 'desync' };
|
||||
}
|
||||
|
||||
// Ohne Login-Hash ist ein Login gar nicht möglich – ein gespeichertes
|
||||
// Klartext-Passwort wäre irreführend (Support würde etwas vorlesen, das
|
||||
// nirgends funktioniert). Als inkonsistent behandeln (Pentest R143).
|
||||
if (!customer.portalPasswordHash) {
|
||||
console.warn(`[getCustomerPortalPassword] Desync: verschlüsseltes Passwort ohne Login-Hash (Kunde #${customerId})`);
|
||||
return { status: 'desync' };
|
||||
}
|
||||
|
||||
// Muss zum Login-Hash passen – sonst ist der gespeicherte Klartext veraltet
|
||||
// und würde beim Login fehlschlagen.
|
||||
const matches = await bcrypt.compare(decrypted, customer.portalPasswordHash);
|
||||
if (!matches) {
|
||||
console.warn(`[getCustomerPortalPassword] Desync: gespeichertes Passwort passt nicht zum Login-Hash (Kunde #${customerId})`);
|
||||
return { status: 'desync' };
|
||||
}
|
||||
|
||||
return { status: 'ok', password: decrypted };
|
||||
}
|
||||
|
||||
export async function createUser(data: {
|
||||
@@ -542,10 +791,7 @@ export async function getCustomerPortalUser(customerId: number) {
|
||||
|
||||
if (!customer || !customer.portalEnabled) return null;
|
||||
|
||||
const customerPermissions = [
|
||||
'contracts:read',
|
||||
'customers:read',
|
||||
];
|
||||
const customerPermissions = [...PORTAL_RECHTE];
|
||||
|
||||
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
|
||||
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
|
||||
|
||||
@@ -10,6 +10,31 @@ import * as path from 'path';
|
||||
import archiver from 'archiver';
|
||||
import AdmZip from 'adm-zip';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { synchronisiereRechteUndRollen } from './rollen-sync.service.js';
|
||||
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../config/rechte-katalog.js';
|
||||
|
||||
/**
|
||||
* Zufaelliges Initial-Kennwort fuer den Bootstrap-Admin nach einem
|
||||
* Werksreset. Gleiche Regeln wie in prisma/seed.ts: 28 Zeichen, mindestens
|
||||
* eines aus jeder Klasse, kryptografisch sichere Auswahl - Math.random() ist
|
||||
* vorhersagbar und reicht dafuer nicht (Pentest 2026-05-20).
|
||||
*/
|
||||
function erzeugeInitialKennwort(): string {
|
||||
const gross = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
const klein = 'abcdefghijkmnopqrstuvwxyz';
|
||||
const ziffern = '23456789';
|
||||
const sonder = '!@#$%&*+=?';
|
||||
const alle = gross + klein + ziffern + sonder;
|
||||
const waehle = (s: string) => s[crypto.randomInt(0, s.length)];
|
||||
const zeichen = [waehle(gross), waehle(klein), waehle(ziffern), waehle(sonder)];
|
||||
for (let i = zeichen.length; i < 28; i++) zeichen.push(waehle(alle));
|
||||
for (let i = zeichen.length - 1; i > 0; i--) {
|
||||
const j = crypto.randomInt(0, i + 1);
|
||||
[zeichen[i], zeichen[j]] = [zeichen[j], zeichen[i]];
|
||||
}
|
||||
return zeichen.join('');
|
||||
}
|
||||
|
||||
// Verzeichnisse
|
||||
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
|
||||
@@ -1187,110 +1212,40 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Grundlegende Stammdaten neu anlegen (aus Seed)
|
||||
// Berechtigungen - muss mit seed.ts übereinstimmen!
|
||||
const resourcePermissions: Record<string, string[]> = {
|
||||
// Haupt-Ressourcen (CRUD)
|
||||
customers: ['create', 'read', 'update', 'delete'],
|
||||
contracts: ['create', 'read', 'update', 'delete'],
|
||||
users: ['create', 'read', 'update', 'delete'],
|
||||
platforms: ['create', 'read', 'update', 'delete'],
|
||||
providers: ['create', 'read', 'update', 'delete'],
|
||||
tariffs: ['create', 'read', 'update', 'delete'],
|
||||
// Lookup-Tabellen (nur lesen)
|
||||
'contract-categories': ['read'],
|
||||
'cancellation-periods': ['read'],
|
||||
'contract-durations': ['read'],
|
||||
// Einstellungen (nur lesen/ändern)
|
||||
settings: ['read', 'update'],
|
||||
// Spezial-Permissions
|
||||
developer: ['access'],
|
||||
emails: ['delete'],
|
||||
};
|
||||
// ==================== RECHTE UND ROLLEN ====================
|
||||
// Aus derselben Definition wie Seed und Container-Start
|
||||
// (config/rechte-katalog.ts).
|
||||
//
|
||||
// Hier stand bis 09/2026 eine DRITTE Kopie des Katalogs, mit dem
|
||||
// Kommentar "muss mit seed.ts uebereinstimmen!" darueber - und sie stimmte
|
||||
// nicht: Die Lookup-Tabellen hatten nur `read`, `email-providers`,
|
||||
// `audit` und `gdpr` fehlten ganz, und von den Rollen wurden nur fuenf
|
||||
// angelegt. DSGVO, Audit-Betrieb und Gegenbuch gab es nach einem
|
||||
// Werksreset nicht mehr.
|
||||
//
|
||||
// Die Folge war kein Schoenheitsfehler: Ohne die DSGVO-Rolle kann
|
||||
// niemand eine Auskunft nach Art. 15 oder eine Loeschung nach Art. 17
|
||||
// ausfuehren. Ein Werksreset setzte damit stillschweigend die
|
||||
// Handlungsfaehigkeit fuer Betroffenenrechte aus, und aufgefallen waere
|
||||
// es erst, wenn eine Frist laeuft.
|
||||
await synchronisiereRechteUndRollen(prisma, (zeile) =>
|
||||
console.log(`[FactoryReset] ${zeile}`),
|
||||
);
|
||||
|
||||
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
||||
for (const action of actions) {
|
||||
await prisma.permission.create({
|
||||
data: { resource, action },
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log('[FactoryReset] Berechtigungen erstellt');
|
||||
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
|
||||
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
|
||||
console.log('[FactoryReset] Rechte und Rollen erstellt');
|
||||
|
||||
// Admin-Rolle mit allen Berechtigungen (außer developer:access)
|
||||
const allPermissions = await prisma.permission.findMany();
|
||||
const adminRole = await prisma.role.create({
|
||||
data: {
|
||||
name: 'Admin',
|
||||
description: 'Voller Zugriff auf alle Funktionen',
|
||||
permissions: {
|
||||
create: allPermissions
|
||||
.filter(p => !(p.resource === 'developer' && p.action === 'access'))
|
||||
.map(p => ({ permissionId: p.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Developer-Rolle - ALLE Berechtigungen inkl. developer:access
|
||||
await prisma.role.create({
|
||||
data: {
|
||||
name: 'Developer',
|
||||
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
||||
permissions: {
|
||||
create: allPermissions.map(p => ({ permissionId: p.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mitarbeiter-Rolle - customers, contracts + read-only auf Stammdaten
|
||||
const employeePermIds = allPermissions
|
||||
.filter(p =>
|
||||
p.resource === 'customers' ||
|
||||
p.resource === 'contracts' ||
|
||||
(p.action === 'read' && ['platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'].includes(p.resource))
|
||||
)
|
||||
.map(p => p.id);
|
||||
await prisma.role.create({
|
||||
data: {
|
||||
name: 'Mitarbeiter',
|
||||
description: 'Kann Kunden und Verträge verwalten',
|
||||
permissions: {
|
||||
create: employeePermIds.map(id => ({ permissionId: id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Nur-Lesen Rolle
|
||||
const readOnlyResources = ['customers', 'contracts', 'platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'];
|
||||
const readOnlyPermIds = allPermissions
|
||||
.filter(p => p.action === 'read' && readOnlyResources.includes(p.resource))
|
||||
.map(p => p.id);
|
||||
await prisma.role.create({
|
||||
data: {
|
||||
name: 'Mitarbeiter (Nur-Lesen)',
|
||||
description: 'Kann nur lesen, keine Änderungen',
|
||||
permissions: {
|
||||
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Kunden-Rolle
|
||||
await prisma.role.create({
|
||||
data: {
|
||||
name: 'Kunde',
|
||||
description: 'Kann nur eigene Daten lesen',
|
||||
permissions: {
|
||||
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('[FactoryReset] Rollen erstellt');
|
||||
|
||||
// Standard Admin-Benutzer erstellen
|
||||
// Standard Admin-Benutzer erstellen.
|
||||
//
|
||||
// Das Kennwort war hier bis 09/2026 fest auf "admin" verdrahtet, mit
|
||||
// bcrypt-Cost 10. Genau das verbietet seed.ts seit Pentest Runde 12
|
||||
// ausdruecklich - und wieder war die Haertung nur in einer der beiden
|
||||
// Kopien angekommen. Wer einen Werksreset ausloest, bekommt jetzt ein
|
||||
// zufaelliges Kennwort, das genau einmal im Log erscheint.
|
||||
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
|
||||
const hashedPassword = await bcrypt.hash('admin', 10);
|
||||
console.log('[FactoryReset] Passwort gehasht, Admin-Rolle ID:', adminRole.id);
|
||||
const adminPlainPassword = erzeugeInitialKennwort();
|
||||
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
|
||||
|
||||
const adminUser = await prisma.user.create({
|
||||
data: {
|
||||
@@ -1298,11 +1253,21 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
|
||||
password: hashedPassword,
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
// Admin UND DSGVO (Pentest R189-01): Die Admin-Rolle traegt die
|
||||
// Datenschutzrechte bewusst nicht, also braucht dieses eine
|
||||
// Bootstrap-Konto beide - sonst ist nach dem Reset niemand
|
||||
// handlungsfaehig.
|
||||
roles: {
|
||||
create: [{ roleId: adminRole.id }],
|
||||
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log('========================================================');
|
||||
console.log(' Admin-User: admin@admin.com');
|
||||
console.log(` Initial-Passwort: ${adminPlainPassword}`);
|
||||
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
|
||||
console.log(' Bitte sofort nach dem ersten Login ändern.');
|
||||
console.log('========================================================');
|
||||
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
|
||||
|
||||
// Standard Kündigungsfristen (wie in seed.ts)
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getSetting, getSettingBool, setSetting } from './appSetting.service.js';
|
||||
|
||||
/**
|
||||
* BLZ-/Bankdaten-Service.
|
||||
*
|
||||
* Liefert BIC + Banknamen zu einer deutschen BLZ/IBAN. Zwei Datenquellen,
|
||||
* in dieser Reihenfolge:
|
||||
* 1. VOLUME – ein zur Laufzeit aktualisierter Datensatz unter BANKDATA_DIR
|
||||
* (Bind-Mount, siehe docker-compose). Wird vom Auto-Updater
|
||||
* befüllt.
|
||||
* 2. BUILTIN – die ins Image gebackenen JSON-Daten des npm-Pakets
|
||||
* `bankdata-germany` (Fallback, immer vorhanden).
|
||||
*
|
||||
* Es wird KEIN Paketcode ausgeführt – nur die reinen JSON-Datendateien
|
||||
* (current.json = { "<BLZ>": ["Name","BIC"] }, next.json = Delta der nächsten
|
||||
* Periode) werden gelesen und mit eigener Logik indiziert.
|
||||
*
|
||||
* Datenschutz: Beim Lookup verlässt KEINE IBAN den Server. Nur der
|
||||
* Auto-Updater macht ausgehende Requests – und lädt dabei lediglich eine
|
||||
* öffentliche Datendatei (keine Kundendaten).
|
||||
*/
|
||||
|
||||
// Kompiliert nach CommonJS – das native `require` ist zur Laufzeit verfügbar
|
||||
// und wird nur genutzt, um den Pfad der gebackenen Paketdaten aufzulösen.
|
||||
declare const require: NodeRequire;
|
||||
|
||||
// ---- Pfade / Quellen (per Env überschreibbar) ----
|
||||
const BANKDATA_DIR = process.env.BANKDATA_DIR || path.join(process.cwd(), 'bankdata');
|
||||
const DATASET_FILE = path.join(BANKDATA_DIR, 'blz-dataset.json');
|
||||
const PACKAGE = 'bankdata-germany';
|
||||
const CDN_BASE = process.env.BLZ_CDN_BASE || 'https://cdn.jsdelivr.net/npm';
|
||||
const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.org';
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
// ---- Typen ----
|
||||
type BankTuple = [string, string?]; // [bankName, bic?] – BIC fehlt bei manchen Banken
|
||||
type CurrentData = Record<string, BankTuple>;
|
||||
interface NextData {
|
||||
valid: string;
|
||||
upsert: Record<string, BankTuple>;
|
||||
remove: string[];
|
||||
}
|
||||
interface RawDataset {
|
||||
current: CurrentData;
|
||||
next: NextData;
|
||||
}
|
||||
interface LoadedDataset extends RawDataset {
|
||||
source: 'volume' | 'builtin';
|
||||
version: string;
|
||||
fetchedAt: string | null;
|
||||
}
|
||||
export interface BankInfo {
|
||||
bankName: string;
|
||||
bic: string;
|
||||
blz: string;
|
||||
}
|
||||
|
||||
// ---- In-Memory-Cache (per mtime invalidiert) ----
|
||||
let cache: { dataset: LoadedDataset; combined: CurrentData; key: string } | null = null;
|
||||
|
||||
function builtinDataDir(): string {
|
||||
// require.resolve liefert .../dist/cjs/main.js → data/ liegt daneben.
|
||||
const main = require.resolve(PACKAGE);
|
||||
return path.join(path.dirname(main), 'data');
|
||||
}
|
||||
|
||||
function builtinVersion(): string {
|
||||
try {
|
||||
const main = require.resolve(PACKAGE);
|
||||
// main = <root>/dist/cjs/main.js → package.json zwei Ebenen höher.
|
||||
const pkgPath = path.join(path.dirname(main), '..', '..', 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
return typeof pkg.version === 'string' ? pkg.version : 'unbekannt';
|
||||
} catch {
|
||||
return 'unbekannt';
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(file: string): any {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt den aktiven Datensatz (Volume bevorzugt, sonst Builtin). Cached anhand
|
||||
* der mtime der Volume-Datei bzw. eines statischen Keys für Builtin.
|
||||
*/
|
||||
function loadDataset(): LoadedDataset {
|
||||
// Volume vorhanden?
|
||||
let volumeMtime: number | null = null;
|
||||
try {
|
||||
volumeMtime = fs.statSync(DATASET_FILE).mtimeMs;
|
||||
} catch {
|
||||
volumeMtime = null;
|
||||
}
|
||||
|
||||
const key = volumeMtime !== null ? `volume:${volumeMtime}` : 'builtin';
|
||||
if (cache && cache.key === key) return cache.dataset;
|
||||
|
||||
let dataset: LoadedDataset;
|
||||
if (volumeMtime !== null) {
|
||||
try {
|
||||
const raw: any = readJson(DATASET_FILE);
|
||||
// Metadaten VOR der Assertion lesen (die narrowt raw auf RawDataset).
|
||||
const version = typeof raw.version === 'string' ? raw.version : 'unbekannt';
|
||||
const fetchedAt = typeof raw.fetchedAt === 'string' ? raw.fetchedAt : null;
|
||||
assertValidRawDataset(raw);
|
||||
dataset = {
|
||||
source: 'volume',
|
||||
version,
|
||||
fetchedAt,
|
||||
current: raw.current,
|
||||
next: raw.next,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[BLZ] Volume-Datensatz unlesbar, nutze Builtin:', err);
|
||||
dataset = loadBuiltin();
|
||||
}
|
||||
} else {
|
||||
dataset = loadBuiltin();
|
||||
}
|
||||
|
||||
const combined = combine(dataset);
|
||||
cache = { dataset, combined, key };
|
||||
return dataset;
|
||||
}
|
||||
|
||||
function loadBuiltin(): LoadedDataset {
|
||||
const dir = builtinDataDir();
|
||||
const current = readJson(path.join(dir, 'current.json')) as CurrentData;
|
||||
const next = readJson(path.join(dir, 'next.json')) as NextData;
|
||||
return { source: 'builtin', version: builtinVersion(), fetchedAt: null, current, next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kombiniert current + next-Delta, wenn das aktuelle Datum den Gültig-ab-
|
||||
* Zeitpunkt der nächsten Periode erreicht hat (identische Logik wie das
|
||||
* npm-Paket).
|
||||
*/
|
||||
function combine(ds: RawDataset): CurrentData {
|
||||
const validFrom = new Date(ds.next?.valid);
|
||||
if (ds.next && !Number.isNaN(validFrom.getTime()) && new Date() >= validFrom) {
|
||||
const merged: CurrentData = { ...ds.current, ...ds.next.upsert };
|
||||
for (const blz of ds.next.remove || []) delete merged[blz];
|
||||
return merged;
|
||||
}
|
||||
return ds.current;
|
||||
}
|
||||
|
||||
function combinedData(): CurrentData {
|
||||
loadDataset();
|
||||
return cache!.combined;
|
||||
}
|
||||
|
||||
// ---- Lookup ----
|
||||
|
||||
export function lookupBlz(blz: string): BankInfo | null {
|
||||
if (!/^[1-9]\d{7}$/.test(blz)) return null;
|
||||
const entry = combinedData()[blz];
|
||||
if (!entry) return null;
|
||||
return { bankName: entry[0], bic: entry[1] ?? '', blz };
|
||||
}
|
||||
|
||||
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
|
||||
export function lookupByIban(iban: string): BankInfo | null {
|
||||
if (!/^DE\d{20}$/i.test(iban)) return null;
|
||||
// IBAN: DE + 2 Prüfziffern + 8 BLZ + 10 Kontonummer
|
||||
return lookupBlz(iban.slice(4, 12));
|
||||
}
|
||||
|
||||
// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ----
|
||||
|
||||
// Prüft eine BLZ→[Name,BIC]-Map vollständig: JEDER Schlüssel eine 8-stellige
|
||||
// BLZ, JEDER Wert genau [string, string] (BIC darf leer sein). Nicht nur die
|
||||
// erste Zeile (Pentest R148, Code-Note) – sonst käme ein Set mit 1 echten +
|
||||
// 999 Fake-Einträgen durch.
|
||||
function assertValidBankMap(map: Record<string, unknown>, label: string): void {
|
||||
for (const [blz, entry] of Object.entries(map)) {
|
||||
if (!/^\d{8}$/.test(blz)) {
|
||||
throw new Error(`${label}: ungültige BLZ "${blz}"`);
|
||||
}
|
||||
// Wert ist [Name] (Bank ohne BIC) ODER [Name, BIC]. Beides ist in der
|
||||
// echten Bundesbank-Datei vorhanden (z.B. reine Zahlungsverkehr-BLZ).
|
||||
if (
|
||||
!Array.isArray(entry) ||
|
||||
entry.length < 1 ||
|
||||
entry.length > 2 ||
|
||||
typeof entry[0] !== 'string' ||
|
||||
(entry.length === 2 && typeof entry[1] !== 'string')
|
||||
) {
|
||||
throw new Error(`${label}: Eintrag ${blz} hat unerwartetes Format`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidRawDataset(raw: any): asserts raw is RawDataset {
|
||||
if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
|
||||
const cur = raw.current;
|
||||
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
|
||||
const keys = Object.keys(cur);
|
||||
if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
|
||||
// VOLLSTÄNDIGE Formatprüfung aller Einträge, nicht nur des ersten.
|
||||
assertValidBankMap(cur, 'current');
|
||||
|
||||
const next = raw.next;
|
||||
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || Array.isArray(next.upsert) || !Array.isArray(next.remove)) {
|
||||
throw new Error('next fehlt/ungültig');
|
||||
}
|
||||
// Datum plausibel?
|
||||
if (Number.isNaN(new Date(next.valid).getTime())) {
|
||||
throw new Error('next.valid ist kein gültiges Datum');
|
||||
}
|
||||
// upsert-Einträge ebenso streng; remove muss aus 8-stelligen BLZ bestehen.
|
||||
assertValidBankMap(next.upsert, 'next.upsert');
|
||||
for (const blz of next.remove) {
|
||||
if (typeof blz !== 'string' || !/^\d{8}$/.test(blz)) {
|
||||
throw new Error(`next.remove enthält ungültige BLZ "${blz}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Ausgehende Requests (nur Updater) ----
|
||||
|
||||
async function fetchJson(url: string): Promise<any> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: ctrl.signal,
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'OpenCRM-BLZ-Updater' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`);
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Erlaubtes Versionsformat (semver-Kern, rein numerisch). Die Version wird in
|
||||
// die CDN-URL interpoliert – ohne strikte Prüfung könnte eine manipulierte
|
||||
// Registry-Antwort (`../`, Slashes, Query-Zeichen) den Pfad verbiegen. Der Host
|
||||
// bleibt zwar fix (CDN_BASE), aber wir lassen nur `1.2.3` zu.
|
||||
const VERSION_RE = /^\d{1,6}\.\d{1,6}\.\d{1,6}$/;
|
||||
|
||||
/** Ermittelt die neueste verfügbare Paket-Version (npm dist-tag latest). */
|
||||
export async function fetchLatestVersion(): Promise<string> {
|
||||
const manifest = await fetchJson(`${REGISTRY_BASE}/${PACKAGE}/latest`);
|
||||
if (!manifest || typeof manifest.version !== 'string') throw new Error('Registry lieferte keine Version');
|
||||
if (!VERSION_RE.test(manifest.version)) {
|
||||
throw new Error(`Unerwartetes Versionsformat: ${manifest.version}`);
|
||||
}
|
||||
return manifest.version;
|
||||
}
|
||||
|
||||
// ---- Versionsvergleich (numerische Segmente, z.B. 2.2603.0) ----
|
||||
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
||||
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (d !== 0) return d > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function activeVersion(): string {
|
||||
return loadDataset().version;
|
||||
}
|
||||
|
||||
// ---- Update-Durchführung ----
|
||||
|
||||
let updating = false;
|
||||
|
||||
export interface UpdateResult {
|
||||
changed: boolean;
|
||||
version: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt (falls neuer) den aktuellen Datensatz von der CDN ins Volume.
|
||||
* `force` lädt auch bei gleicher Version neu.
|
||||
*/
|
||||
export async function runUpdate(force = false): Promise<UpdateResult> {
|
||||
if (updating) {
|
||||
return { changed: false, version: activeVersion(), message: 'Update läuft bereits.' };
|
||||
}
|
||||
updating = true;
|
||||
const nowIso = new Date().toISOString();
|
||||
try {
|
||||
const latest = await fetchLatestVersion();
|
||||
await setSetting('blzLatestVersion', latest);
|
||||
await setSetting('blzLastCheckedAt', nowIso);
|
||||
|
||||
const current = activeVersion();
|
||||
if (!force && current !== 'unbekannt' && compareVersions(latest, current) <= 0) {
|
||||
await setSetting('blzLastError', '');
|
||||
return { changed: false, version: current, message: `Bereits aktuell (${current}).` };
|
||||
}
|
||||
|
||||
// Datendateien der Zielversion laden.
|
||||
const base = `${CDN_BASE}/${PACKAGE}@${latest}/dist/cjs/data`;
|
||||
const [currentData, nextData] = await Promise.all([
|
||||
fetchJson(`${base}/current.json`),
|
||||
fetchJson(`${base}/next.json`),
|
||||
]);
|
||||
|
||||
const raw = { current: currentData, next: nextData };
|
||||
assertValidRawDataset(raw); // wirft bei Müll → kein Überschreiben
|
||||
|
||||
const payload = JSON.stringify({
|
||||
version: latest,
|
||||
fetchedAt: nowIso,
|
||||
source: 'cdn',
|
||||
current: currentData,
|
||||
next: nextData,
|
||||
});
|
||||
|
||||
fs.mkdirSync(BANKDATA_DIR, { recursive: true });
|
||||
const tmp = `${DATASET_FILE}.tmp`;
|
||||
fs.writeFileSync(tmp, payload, 'utf8');
|
||||
fs.renameSync(tmp, DATASET_FILE); // atomar
|
||||
cache = null; // Cache invalidieren → nächster Lookup lädt neu
|
||||
|
||||
await setSetting('blzLastUpdatedAt', nowIso);
|
||||
await setSetting('blzLastError', '');
|
||||
|
||||
console.log(`[BLZ] Datensatz aktualisiert auf ${latest} (${Object.keys(currentData).length} Einträge).`);
|
||||
return { changed: true, version: latest, message: `Aktualisiert auf ${latest}.` };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await setSetting('blzLastError', msg).catch(() => {});
|
||||
await setSetting('blzLastCheckedAt', nowIso).catch(() => {});
|
||||
console.error('[BLZ] Update fehlgeschlagen:', msg);
|
||||
throw err instanceof Error ? err : new Error(msg);
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Status für die Einstellungen-Seite ----
|
||||
|
||||
export interface BlzStatus {
|
||||
activeSource: 'volume' | 'builtin';
|
||||
activeVersion: string;
|
||||
builtinVersion: string;
|
||||
entryCount: number;
|
||||
nextValidFrom: string | null;
|
||||
fetchedAt: string | null;
|
||||
autoUpdateEnabled: boolean;
|
||||
intervalDays: number;
|
||||
lastCheckedAt: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
latestVersion: string | null;
|
||||
updateAvailable: boolean;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<BlzStatus> {
|
||||
const ds = loadDataset();
|
||||
const combined = combinedData();
|
||||
|
||||
const autoUpdateEnabled = await getSettingBool('blzAutoUpdateEnabled');
|
||||
const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
|
||||
const lastCheckedAt = (await getSetting('blzLastCheckedAt')) || null;
|
||||
const lastUpdatedAt = (await getSetting('blzLastUpdatedAt')) || null;
|
||||
const latestVersion = (await getSetting('blzLatestVersion')) || null;
|
||||
const lastErrorRaw = (await getSetting('blzLastError')) || '';
|
||||
|
||||
const updateAvailable =
|
||||
!!latestVersion && ds.version !== 'unbekannt' && compareVersions(latestVersion, ds.version) > 0;
|
||||
|
||||
return {
|
||||
activeSource: ds.source,
|
||||
activeVersion: ds.version,
|
||||
builtinVersion: builtinVersion(),
|
||||
entryCount: Object.keys(combined).length,
|
||||
nextValidFrom: ds.next?.valid || null,
|
||||
fetchedAt: ds.fetchedAt,
|
||||
autoUpdateEnabled,
|
||||
intervalDays,
|
||||
lastCheckedAt,
|
||||
lastUpdatedAt,
|
||||
latestVersion,
|
||||
updateAvailable,
|
||||
lastError: lastErrorRaw || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Scheduler für das automatische BLZ-/Bankdaten-Update.
|
||||
*
|
||||
* Prüft täglich (03:30) sowie 90s nach Start, ob ein Update fällig ist:
|
||||
* Auto-Update aktiv UND (noch nie aktualisiert ODER letzte Aktualisierung
|
||||
* länger her als das eingestellte Intervall in Tagen). Trifft das zu, wird
|
||||
* der neueste Datensatz geladen (siehe blzData.service).
|
||||
*
|
||||
* Das eigentliche Intervall ist in den Einstellungen konfigurierbar
|
||||
* (`blzUpdateIntervalDays`); der Cron-Tick prüft nur die Fälligkeit.
|
||||
*/
|
||||
import cron from 'node-cron';
|
||||
import { getSetting, getSettingBool } from './appSetting.service.js';
|
||||
import { runUpdate } from './blzData.service.js';
|
||||
|
||||
async function maybeRun(): Promise<void> {
|
||||
const enabled = await getSettingBool('blzAutoUpdateEnabled');
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
|
||||
const lastUpdatedAt = await getSetting('blzLastUpdatedAt');
|
||||
|
||||
if (lastUpdatedAt) {
|
||||
const last = new Date(lastUpdatedAt).getTime();
|
||||
if (!Number.isNaN(last)) {
|
||||
const ageDays = (Date.now() - last) / 86_400_000;
|
||||
if (ageDays < intervalDays) {
|
||||
return; // noch nicht fällig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[BLZ-Scheduler] Update fällig – prüfe auf neueren Datensatz…');
|
||||
await runUpdate(false);
|
||||
}
|
||||
|
||||
export function startBlzUpdateScheduler(): void {
|
||||
// Täglich um 03:30 (Server-Zeit) Fälligkeit prüfen.
|
||||
cron.schedule('30 3 * * *', () => {
|
||||
maybeRun().catch((err) => console.error('[BLZ-Scheduler] Lauf fehlgeschlagen:', err));
|
||||
});
|
||||
|
||||
// Catch-up 90s nach Start.
|
||||
setTimeout(() => {
|
||||
maybeRun().catch((err) => console.error('[BLZ-Scheduler] Catch-up fehlgeschlagen:', err));
|
||||
}, 90_000);
|
||||
|
||||
console.log('[BLZ-Scheduler] Gestartet – tägliche Fälligkeitsprüfung + Catch-up nach 90s');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// ==================== FIRMENSTAMMDATEN (ABSENDER) ====================
|
||||
// Einzel-Zeile. Fließt in Gutschrift-PDF + ZUGFeRD-XML (Verkäufer) ein.
|
||||
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
const EDITABLE_STRING_FIELDS = [
|
||||
'name', 'street', 'houseNumber', 'postalCode', 'city', 'country',
|
||||
'vatId', 'taxNumber', 'commercialRegister', 'managingDirector',
|
||||
'email', 'phone', 'website', 'iban', 'bic', 'bankName',
|
||||
] as const;
|
||||
|
||||
export async function getOrCreateProfile() {
|
||||
const existing = await prisma.companyProfile.findFirst();
|
||||
if (existing) return existing;
|
||||
return prisma.companyProfile.create({ data: {} });
|
||||
}
|
||||
|
||||
export async function updateProfile(input: Record<string, unknown>) {
|
||||
const profile = await getOrCreateProfile();
|
||||
const data: Prisma.CompanyProfileUpdateInput = {};
|
||||
for (const field of EDITABLE_STRING_FIELDS) {
|
||||
if (typeof input[field] === 'string') {
|
||||
(data as Record<string, unknown>)[field] = (input[field] as string).slice(0, 191).trim();
|
||||
}
|
||||
}
|
||||
return prisma.companyProfile.update({ where: { id: profile.id }, data });
|
||||
}
|
||||
@@ -12,27 +12,33 @@ export interface ContractFilters {
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
includeDeactivated?: boolean; // wenn true: auch DEACTIVATED-Verträge listen
|
||||
}
|
||||
|
||||
export async function getAllContracts(filters: ContractFilters) {
|
||||
const { customerId, customerIds, type, status, search, page = 1, limit = 20 } = filters;
|
||||
const { customerId, customerIds, type, status, search, page = 1, limit = 20, includeDeactivated = false } = filters;
|
||||
const { skip, take } = paginate(page, limit);
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
// Entweder einzelne customerId ODER Liste von customerIds (für Kundenportal)
|
||||
if (customerIds && customerIds.length > 0) {
|
||||
// Entweder Liste von customerIds (Kundenportal, fail-closed) ODER einzelne
|
||||
// customerId (Staff-Filter). Fail-closed (Pentest R145): ist customerIds
|
||||
// gesetzt – auch als LEERES Array – wird strikt darauf gefiltert (`IN ()`
|
||||
// → 0 Treffer). Nur `undefined` (Staff) überspringt den Filter.
|
||||
if (customerIds) {
|
||||
where.customerId = { in: customerIds };
|
||||
} else if (customerId) {
|
||||
where.customerId = customerId;
|
||||
}
|
||||
if (type) where.type = type;
|
||||
|
||||
// Status-Filter: Deaktivierte Verträge standardmäßig ausblenden
|
||||
// Status-Filter: Deaktivierte Verträge standardmäßig ausblenden – außer
|
||||
// ein expliziter Status ist gesetzt oder includeDeactivated=true (z.B. für
|
||||
// das Vorgängervertrag-Dropdown, das auch deaktivierte Verträge als
|
||||
// Vorgänger zulassen muss).
|
||||
if (status) {
|
||||
where.status = status;
|
||||
} else {
|
||||
// Wenn kein Status-Filter gesetzt, alle außer DEACTIVATED anzeigen
|
||||
} else if (!includeDeactivated) {
|
||||
where.status = { not: ContractStatus.DEACTIVATED };
|
||||
}
|
||||
|
||||
@@ -61,6 +67,7 @@ export async function getAllContracts(filters: ContractFilters) {
|
||||
{ mobileDetails: { deviceImei: { contains: search } } },
|
||||
{ mobileDetails: { simCards: { some: { phoneNumber: { contains: search } } } } },
|
||||
{ mobileDetails: { simCards: { some: { simCardNumber: { contains: search } } } } },
|
||||
{ mobileDetails: { simCards: { some: { cardUser: { contains: search } } } } },
|
||||
// Energie-Vertragsdetails (Zählernummer)
|
||||
{ energyDetails: { meter: { meterNumber: { contains: search } } } },
|
||||
// TV-Vertragsdetails
|
||||
@@ -99,7 +106,8 @@ export async function getAllContracts(filters: ContractFilters) {
|
||||
mobileDetails: {
|
||||
select: {
|
||||
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
|
||||
@@ -247,6 +255,7 @@ interface ContractCreateData {
|
||||
unitPrice?: number;
|
||||
instantBonus?: number;
|
||||
newCustomerBonus?: number;
|
||||
noBonusDesired?: boolean;
|
||||
previousProviderName?: string;
|
||||
previousCustomerNumber?: string;
|
||||
};
|
||||
@@ -333,6 +342,73 @@ function normalizeMobileNetwork(value: unknown): string | 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) {
|
||||
const {
|
||||
energyDetails,
|
||||
@@ -351,11 +427,11 @@ export async function createContract(data: ContractCreateData) {
|
||||
|
||||
const contract = await prisma.contract.create({
|
||||
data: {
|
||||
...contractData,
|
||||
...(pickContractScalars(contractData) as any),
|
||||
contractNumber: generateContractNumber(data.type),
|
||||
portalPasswordEncrypted,
|
||||
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
|
||||
? { energyDetails: { create: energyDetails } }
|
||||
? { energyDetails: { create: pickEnergyScalars(energyDetails) as any } }
|
||||
: {}),
|
||||
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
|
||||
? {
|
||||
@@ -425,10 +501,10 @@ export async function createContract(data: ContractCreateData) {
|
||||
}
|
||||
: {}),
|
||||
...(tvDetails && data.type === 'TV'
|
||||
? { tvDetails: { create: tvDetails } }
|
||||
? { tvDetails: { create: pickTvScalars(tvDetails) as any } }
|
||||
: {}),
|
||||
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
|
||||
? { carInsuranceDetails: { create: carInsuranceDetails } }
|
||||
? { carInsuranceDetails: { create: pickCarInsuranceScalars(carInsuranceDetails) as any } }
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
@@ -481,11 +557,12 @@ export async function updateContract(
|
||||
// in diesem Update wirklich auf true gesetzt wird (nicht bei jedem PUT).
|
||||
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({
|
||||
where: { id },
|
||||
data: {
|
||||
...contractData,
|
||||
...(pickContractScalars(contractData) as any),
|
||||
...(clearCredentials
|
||||
? {
|
||||
portalUsername: null,
|
||||
@@ -507,8 +584,8 @@ export async function updateContract(
|
||||
|
||||
await prisma.energyContractDetails.upsert({
|
||||
where: { contractId: id },
|
||||
update: energyDetails,
|
||||
create: { contractId: id, ...energyDetails },
|
||||
update: pickEnergyScalars(energyDetails) as any,
|
||||
create: { contractId: id, ...(pickEnergyScalars(energyDetails) as any) },
|
||||
});
|
||||
|
||||
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
||||
@@ -633,11 +710,12 @@ export async function updateContract(
|
||||
}
|
||||
|
||||
if (mobileDetails) {
|
||||
const { simCards, ...mobileData } = mobileDetails;
|
||||
// Whitelist auf das Netz anwenden, bevor mobileData per Spread an
|
||||
// Prisma geht (Update-Pfad reicht sonst beliebige Strings durch).
|
||||
const { simCards } = mobileDetails;
|
||||
// Feld-Whitelist (Pentest R162-01): nur Scalars an Prisma – kein injiziertes
|
||||
// id/contractId. Danach Netz normalisieren.
|
||||
const mobileData: Record<string, unknown> = pickMobileScalars(mobileDetails);
|
||||
if ('mobileNetwork' in mobileData) {
|
||||
mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork);
|
||||
mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork as string | null | undefined);
|
||||
}
|
||||
const existing = await prisma.mobileContractDetails.findUnique({
|
||||
where: { contractId: id },
|
||||
@@ -646,7 +724,7 @@ export async function updateContract(
|
||||
if (existing) {
|
||||
await prisma.mobileContractDetails.update({
|
||||
where: { contractId: id },
|
||||
data: mobileData,
|
||||
data: mobileData as any,
|
||||
});
|
||||
|
||||
if (simCards) {
|
||||
@@ -684,7 +762,7 @@ export async function updateContract(
|
||||
await prisma.mobileContractDetails.create({
|
||||
data: {
|
||||
contractId: id,
|
||||
...mobileData,
|
||||
...(mobileData as any),
|
||||
simCards: simCards
|
||||
? {
|
||||
create: simCards.map((sc) => ({
|
||||
@@ -707,16 +785,16 @@ export async function updateContract(
|
||||
if (tvDetails) {
|
||||
await prisma.tvContractDetails.upsert({
|
||||
where: { contractId: id },
|
||||
update: tvDetails,
|
||||
create: { contractId: id, ...tvDetails },
|
||||
update: pickTvScalars(tvDetails) as any,
|
||||
create: { contractId: id, ...(pickTvScalars(tvDetails) as any) },
|
||||
});
|
||||
}
|
||||
|
||||
if (carInsuranceDetails) {
|
||||
await prisma.carInsuranceDetails.upsert({
|
||||
where: { contractId: id },
|
||||
update: carInsuranceDetails,
|
||||
create: { contractId: id, ...carInsuranceDetails },
|
||||
update: pickCarInsuranceScalars(carInsuranceDetails) as any,
|
||||
create: { contractId: id, ...(pickCarInsuranceScalars(carInsuranceDetails) as any) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1217,6 +1295,7 @@ export interface ContractTreeNode {
|
||||
status: ContractStatus;
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
cancellationConfirmationDate: Date | null;
|
||||
providerName: string | null;
|
||||
tariffName: string | null;
|
||||
previousContractId: number | null;
|
||||
@@ -1227,7 +1306,8 @@ export interface ContractTreeNode {
|
||||
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
||||
mobileDetails?: {
|
||||
phoneNumber: string | null;
|
||||
simCards: { phoneNumber: string | null; isMain: boolean }[];
|
||||
mobileNetwork: string | null;
|
||||
simCards: { phoneNumber: string | null; isMain: boolean; cardUser: string | null }[];
|
||||
} | null;
|
||||
carInsuranceDetails?: { licensePlate: string | null } | null;
|
||||
};
|
||||
@@ -1262,6 +1342,7 @@ export async function getContractTreeForCustomer(
|
||||
status: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
cancellationConfirmationDate: true,
|
||||
providerName: true,
|
||||
tariffName: true,
|
||||
previousContractId: true,
|
||||
@@ -1273,7 +1354,8 @@ export async function getContractTreeForCustomer(
|
||||
mobileDetails: {
|
||||
select: {
|
||||
phoneNumber: true,
|
||||
simCards: { select: { phoneNumber: true, isMain: true } },
|
||||
mobileNetwork: true,
|
||||
simCards: { select: { phoneNumber: true, isMain: true, cardUser: true } },
|
||||
},
|
||||
},
|
||||
carInsuranceDetails: { select: { licensePlate: true } },
|
||||
|
||||
@@ -102,6 +102,9 @@ export interface ReportedMeterReading {
|
||||
|
||||
export interface CockpitResult {
|
||||
contracts: CockpitContract[];
|
||||
// Verträge mit hinterlegter Kündigungsbestätigung (Dokument und/oder Datum)
|
||||
// in Status ACTIVE/DRAFT/CANCELLED – als eigener Filter im Cockpit.
|
||||
cancellationConfirmations: CockpitContract[];
|
||||
documentAlerts: DocumentAlert[];
|
||||
reportedReadings: ReportedMeterReading[];
|
||||
summary: CockpitSummary;
|
||||
@@ -204,7 +207,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
||||
const contracts = await prisma.contract.findMany({
|
||||
where: {
|
||||
status: {
|
||||
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED', 'ONGOING'],
|
||||
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'DEACTIVATED', 'EXPIRED', 'ONGOING'],
|
||||
},
|
||||
...customerScopeFilter,
|
||||
},
|
||||
@@ -360,8 +363,10 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
||||
// Fristen semantisch keinen Sinn ergeben:
|
||||
// - ONGOING : "Läuft weiter auf unbestimmte Zeit" → kein Ende
|
||||
// - DEACTIVATED : manuell beendet/archiviert → nichts mehr zu erinnern
|
||||
// - CANCELLED : Kündigung schon eingereicht → Vertragsende ist
|
||||
// die Konsequenz der Kündigung, keine Frist mehr
|
||||
// - CANCELLED_CONFIRMED : Kündigung vom Anbieter bestätigt →
|
||||
// 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
|
||||
// sie WEGEN dieser Status feuert (kein separater Guard). DSGVO-/
|
||||
// Consent-Checks (Block 8) sind pro Kunde, nicht pro Vertrag, und
|
||||
@@ -372,7 +377,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
||||
const skipDeadlines =
|
||||
contract.status === 'ONGOING'
|
||||
|| contract.status === 'DEACTIVATED'
|
||||
|| contract.status === 'CANCELLED';
|
||||
|| contract.status === 'CANCELLED_CONFIRMED';
|
||||
|
||||
// 1. KÜNDIGUNGSFRIST (nur wenn kein aktiver Folgevertrag UND Snooze nicht aktiv)
|
||||
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
|
||||
@@ -625,9 +630,11 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag gekündigt/deaktiviert ist)
|
||||
// "Beendet" = CANCELLED oder DEACTIVATED (nicht nur Laufzeit abgelaufen!)
|
||||
const isContractTerminated = contract.status === 'CANCELLED' || contract.status === 'DEACTIVATED';
|
||||
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag bestätigt gekündigt/deaktiviert)
|
||||
// "Beendet" = CANCELLED_CONFIRMED (Kündigung bestätigt) oder 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) {
|
||||
const hasFinalInvoice = invoices.some(inv => inv.invoiceType === 'FINAL');
|
||||
@@ -775,8 +782,56 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
||||
// Gemeldete Zählerstände (REPORTED Status)
|
||||
const reportedReadings = await getReportedMeterReadings(opts?.customerIds);
|
||||
|
||||
// Filter „Kündigungsbestätigung vorhanden": alle Verträge mit
|
||||
// cancellationConfirmationPath und/oder -Date in Status ACTIVE/DRAFT/
|
||||
// CANCELLED/EXPIRED. Eigene Liste, weil solche Verträge (v.a. bereits
|
||||
// CANCELLED/EXPIRED) sonst mangels „Issue" nicht in der Cockpit-Liste
|
||||
// auftauchen.
|
||||
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'EXPIRED'];
|
||||
const cancellationConfirmations: CockpitContract[] = contracts
|
||||
.filter(
|
||||
(c) =>
|
||||
(c.cancellationConfirmationPath || c.cancellationConfirmationDate) &&
|
||||
CANCEL_CONF_STATUSES.includes(c.status),
|
||||
)
|
||||
.map((c) => {
|
||||
const customerName =
|
||||
c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`;
|
||||
const dateStr = c.cancellationConfirmationDate
|
||||
? new Date(c.cancellationConfirmationDate).toLocaleDateString('de-DE')
|
||||
: null;
|
||||
const parts: string[] = [];
|
||||
if (c.cancellationConfirmationPath) parts.push('Dokument');
|
||||
if (dateStr) parts.push(`Datum ${dateStr}`);
|
||||
return {
|
||||
id: c.id,
|
||||
contractNumber: c.contractNumber,
|
||||
type: c.type,
|
||||
status: c.status,
|
||||
customer: {
|
||||
id: c.customer.id,
|
||||
customerNumber: c.customer.customerNumber,
|
||||
name: customerName,
|
||||
},
|
||||
provider: c.provider ? { id: c.provider.id, name: c.provider.name } : undefined,
|
||||
tariff: c.tariff ? { id: c.tariff.id, name: c.tariff.name } : undefined,
|
||||
providerName: c.providerName || undefined,
|
||||
tariffName: c.tariffName || undefined,
|
||||
issues: [
|
||||
{
|
||||
type: 'has_cancellation_confirmation',
|
||||
label: 'Kündigungsbestätigung vorhanden',
|
||||
urgency: 'none' as UrgencyLevel,
|
||||
details: parts.join(' · ') || 'Kündigungsbestätigung hinterlegt',
|
||||
},
|
||||
],
|
||||
highestUrgency: 'none' as UrgencyLevel,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
contracts: cockpitContracts,
|
||||
cancellationConfirmations,
|
||||
documentAlerts,
|
||||
reportedReadings,
|
||||
summary,
|
||||
|
||||
@@ -157,6 +157,119 @@ export async function withContractDocumentLock<T>(
|
||||
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
|
||||
* getrimmt zur Robustheit.
|
||||
*/
|
||||
/**
|
||||
* Wird aufgerufen, wenn zu einem Vertrag eine Kündigungsbestätigung
|
||||
* hinzugefügt wird – entweder als Datum (`cancellationConfirmationDate`,
|
||||
* z.B. über das Vertragsformular) und/oder als Dokument
|
||||
* (`cancellationConfirmationPath`, Upload). Effekt:
|
||||
* - Vertrag ACTIVE → CANCELLED (nur aus ACTIVE; andere Status werden
|
||||
* bewusst nicht angetastet).
|
||||
* - Berechnetes Vertragsende (`endDate`) = Kündigungs(bestätigungs)datum,
|
||||
* sofern eines vorliegt.
|
||||
* Idempotent: läuft nur, wenn tatsächlich eine Bestätigung vorhanden ist,
|
||||
* und schreibt nur bei echten Änderungen.
|
||||
*/
|
||||
export async function maybeCancelOnCancellationConfirmation(
|
||||
contractId: number,
|
||||
req: unknown,
|
||||
): Promise<void> {
|
||||
const c = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
select: {
|
||||
status: true,
|
||||
endDate: true,
|
||||
contractNumber: true,
|
||||
customerId: true,
|
||||
cancellationConfirmationPath: true,
|
||||
cancellationConfirmationDate: true,
|
||||
},
|
||||
});
|
||||
if (!c) return;
|
||||
|
||||
const hasConfirmation = !!c.cancellationConfirmationPath || !!c.cancellationConfirmationDate;
|
||||
if (!hasConfirmation) return;
|
||||
|
||||
const asDay = (d: Date | null | undefined) =>
|
||||
d ? new Date(d).toISOString().split('T')[0] : null;
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
|
||||
|
||||
// Kündigungsbestätigung liegt vor (Datum/Dokument) → "Gekündigt / bestätigt".
|
||||
// Von jedem noch "lebenden" Status heben – auch aus "Gekündigt / Bestätigung
|
||||
// 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.
|
||||
// NICHT bei DRAFT: ein Entwurf ist nur eine Vorlage und bekommt kein
|
||||
// berechnetes Vertragsende (Pentest R138, Hygiene-Punkt).
|
||||
if (
|
||||
c.status !== 'DRAFT' &&
|
||||
c.cancellationConfirmationDate &&
|
||||
asDay(c.endDate) !== asDay(c.cancellationConfirmationDate)
|
||||
) {
|
||||
updateData.endDate = c.cancellationConfirmationDate;
|
||||
changes.endDate = { vorher: asDay(c.endDate), nachher: asDay(c.cancellationConfirmationDate) };
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) return;
|
||||
|
||||
await prisma.contract.update({ where: { id: contractId }, data: updateData });
|
||||
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'Contract',
|
||||
resourceId: contractId.toString(),
|
||||
label: `Vertrag ${c.contractNumber} automatisch aktualisiert (Kündigungsbestätigung)`,
|
||||
details: { ...changes, trigger: 'Kündigungsbestätigung' },
|
||||
customerId: c.customerId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
contractId: number,
|
||||
documentType: string,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user