Compare commits
161
Commits
e743e6795b
..
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",
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- Vertrag: „Portaldaten nicht möglich / nicht benötigt"-Marker.
|
||||
-- Wenn gesetzt, unterdrückt das Cockpit die Warnung „Portal-Zugangsdaten
|
||||
-- fehlen" für diesen Vertrag (Anbieter ohne Portal / Kunde bekommt keine
|
||||
-- Zugangsdaten). Bestehende Verträge bleiben auf FALSE, damit die
|
||||
-- Warnung wie bisher greift.
|
||||
|
||||
ALTER TABLE `Contract`
|
||||
ADD COLUMN IF NOT EXISTS `portalCredentialsNotRequired` BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Mobilfunknetz pro Mobilfunkvertrag (TELEKOM | VODAFONE | TELEFONICA,
|
||||
-- NULL = nicht gewählt). String statt Enum, damit weitere Netze ohne
|
||||
-- Migration ergänzbar sind.
|
||||
|
||||
ALTER TABLE `MobileContractDetails`
|
||||
ADD COLUMN IF NOT EXISTS `mobileNetwork` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- EmailFolder-Enum um SPAM erweitern (Spam-/Junk-Ordner des Postfachs).
|
||||
-- Neuer Wert wird ans Ende gehängt → bestehende INBOX/SENT-Zeilen bleiben
|
||||
-- unverändert, kein Rewrite.
|
||||
|
||||
ALTER TABLE `CachedEmail`
|
||||
MODIFY COLUMN `folder` ENUM('INBOX', 'SENT', 'SPAM') NOT NULL DEFAULT 'INBOX';
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Werbe-Beziehung zwischen Kunden ("Geworben / angeworben").
|
||||
-- recruitedId ist UNIQUE → ein Kunde kann nur von EINER Person geworben sein.
|
||||
-- Beide FKs mit ON DELETE CASCADE: wird ein Kunde gelöscht, verschwinden
|
||||
-- seine Werbe-Verknüpfungen mit.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `CustomerReferral` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`recruiterId` INT NOT NULL,
|
||||
`recruitedId` INT NOT NULL,
|
||||
`relationship` VARCHAR(191) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`createdBy` VARCHAR(191) NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `CustomerReferral_recruitedId_key` (`recruitedId`),
|
||||
INDEX `CustomerReferral_recruiterId_idx` (`recruiterId`),
|
||||
CONSTRAINT `CustomerReferral_recruiterId_fkey` FOREIGN KEY (`recruiterId`)
|
||||
REFERENCES `Customer` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `CustomerReferral_recruitedId_fkey` FOREIGN KEY (`recruitedId`)
|
||||
REFERENCES `Customer` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Gutschriften (Subventionen) zu einem Vertrag: Geldbetrag (Überweisung)
|
||||
-- oder Sachwert. USt pro Gutschrift wählbar (vatRelevant + Basis Netto/Brutto).
|
||||
-- ZUGFeRD-taugliches Datenmodell (Netto/USt/Brutto getrennt).
|
||||
-- ON DELETE CASCADE: wird der Vertrag gelöscht, verschwinden die Gutschriften.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `CreditNote` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`contractId` INT NOT NULL,
|
||||
`number` VARCHAR(191) NOT NULL,
|
||||
`type` ENUM('GELD', 'SACHWERT') NOT NULL,
|
||||
`sachwertDescription` VARCHAR(191) NULL,
|
||||
`customerType` ENUM('PRIVAT', 'FIRMA') NOT NULL,
|
||||
`vatRelevant` BOOLEAN NOT NULL DEFAULT false,
|
||||
`amountBasis` ENUM('NETTO', 'BRUTTO') NOT NULL DEFAULT 'BRUTTO',
|
||||
`vatRate` DOUBLE NOT NULL DEFAULT 19,
|
||||
`amountNet` DOUBLE NOT NULL DEFAULT 0,
|
||||
`amountVat` DOUBLE NOT NULL DEFAULT 0,
|
||||
`amountGross` DOUBLE NOT NULL DEFAULT 0,
|
||||
`currency` VARCHAR(191) NOT NULL DEFAULT 'EUR',
|
||||
`creditDate` DATETIME(3) NOT NULL,
|
||||
`place` VARCHAR(191) NULL,
|
||||
`signedAt` DATETIME(3) NULL,
|
||||
`goodsReceived` BOOLEAN NOT NULL DEFAULT false,
|
||||
`receiptPath` VARCHAR(191) NULL,
|
||||
`pdfPath` VARCHAR(191) NULL,
|
||||
`notes` TEXT NULL,
|
||||
`createdBy` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE INDEX `CreditNote_number_key` (`number`),
|
||||
INDEX `CreditNote_contractId_idx` (`contractId`),
|
||||
CONSTRAINT `CreditNote_contractId_fkey` FOREIGN KEY (`contractId`)
|
||||
REFERENCES `Contract` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Nummernkreis (Einzel-Zeile, in den Einstellungen verwaltbar).
|
||||
CREATE TABLE IF NOT EXISTS `CreditNoteNumberRange` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`prefix` VARCHAR(191) NOT NULL DEFAULT 'GS-',
|
||||
`includeYear` BOOLEAN NOT NULL DEFAULT true,
|
||||
`separator` VARCHAR(191) NOT NULL DEFAULT '-',
|
||||
`padding` INT NOT NULL DEFAULT 4,
|
||||
`nextNumber` INT NOT NULL DEFAULT 1,
|
||||
`resetYearly` BOOLEAN NOT NULL DEFAULT true,
|
||||
`currentYear` INT NULL,
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Kleinunternehmer/USt-befreit-Flag am Kunden. Steuert nur die Vorbelegung
|
||||
-- neuer Gutschriften (Firmenkunde mit vatExempt=true -> keine USt-Vorbelegung).
|
||||
ALTER TABLE `Customer` ADD COLUMN IF NOT EXISTS `vatExempt` BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Absender-/Firmenstammdaten (Einzel-Zeile) fuer Gutschrift-PDF + ZUGFeRD.
|
||||
CREATE TABLE IF NOT EXISTS `CompanyProfile` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`name` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`street` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`houseNumber` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`postalCode` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`city` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`country` VARCHAR(191) NOT NULL DEFAULT 'DE',
|
||||
`vatId` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`taxNumber` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`commercialRegister` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`managingDirector` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`email` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`phone` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`website` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`iban` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`bic` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`bankName` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`logoPath` VARCHAR(191) NULL,
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Auszahlungskonto (Bankkarte des Kunden) fuer Geld-Gutschriften.
|
||||
-- ON DELETE SET NULL: Loeschen der Bankkarte reisst die Gutschrift nicht mit.
|
||||
ALTER TABLE `CreditNote` ADD COLUMN IF NOT EXISTS `payoutBankCardId` INT NULL;
|
||||
|
||||
-- FK nur anlegen, wenn noch nicht vorhanden (idempotent via information_schema).
|
||||
SET @fk := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote'
|
||||
AND CONSTRAINT_NAME = 'CreditNote_payoutBankCardId_fkey');
|
||||
SET @sql := IF(@fk = 0,
|
||||
'ALTER TABLE `CreditNote` ADD CONSTRAINT `CreditNote_payoutBankCardId_fkey` FOREIGN KEY (`payoutBankCardId`) REFERENCES `BankCard`(`id`) ON DELETE SET NULL ON UPDATE CASCADE',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote'
|
||||
AND INDEX_NAME = 'CreditNote_payoutBankCardId_idx');
|
||||
SET @sql2 := IF(@idx = 0,
|
||||
'CREATE INDEX `CreditNote_payoutBankCardId_idx` ON `CreditNote`(`payoutBankCardId`)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt2 FROM @sql2; EXECUTE stmt2; DEALLOCATE PREPARE stmt2;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Gutschrift-Nummer nullbar: betragslose Sachwert-Uebergaben (Lieferschein)
|
||||
-- verbrauchen keine Gutschriftsnummer. UNIQUE bleibt (MySQL erlaubt mehrere
|
||||
-- NULLs in einem UNIQUE-Index).
|
||||
ALTER TABLE `CreditNote` MODIFY COLUMN `number` VARCHAR(191) NULL;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Lieferschein-Nummerierung fuer betragslose Sachwert-Uebergaben:
|
||||
-- eigene Nummer am Beleg + eigener Nummernkreis (getrennt von Gutschriften).
|
||||
|
||||
ALTER TABLE `CreditNote` ADD COLUMN IF NOT EXISTS `deliveryNoteNumber` VARCHAR(191) NULL;
|
||||
|
||||
-- UNIQUE-Index auf deliveryNoteNumber (idempotent).
|
||||
SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'CreditNote'
|
||||
AND INDEX_NAME = 'CreditNote_deliveryNoteNumber_key');
|
||||
SET @sql := IF(@idx = 0,
|
||||
'CREATE UNIQUE INDEX `CreditNote_deliveryNoteNumber_key` ON `CreditNote`(`deliveryNoteNumber`)',
|
||||
'SELECT 1');
|
||||
PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `DeliveryNoteNumberRange` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`prefix` VARCHAR(191) NOT NULL DEFAULT 'LS-',
|
||||
`includeYear` BOOLEAN NOT NULL DEFAULT true,
|
||||
`separator` VARCHAR(191) NOT NULL DEFAULT '-',
|
||||
`padding` INT NOT NULL DEFAULT 4,
|
||||
`nextNumber` INT NOT NULL DEFAULT 1,
|
||||
`resetYearly` BOOLEAN NOT NULL DEFAULT true,
|
||||
`currentYear` INT NULL,
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Kartennummer (Debit-/Kontokarte) als optionales Feld an der Bankkarte.
|
||||
ALTER TABLE `BankCard` ADD COLUMN IF NOT EXISTS `cardNumber` VARCHAR(191) NULL;
|
||||
+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())
|
||||
@@ -156,6 +171,12 @@ model Customer {
|
||||
phone String?
|
||||
mobile String?
|
||||
taxNumber String?
|
||||
// Kleinunternehmer nach §19 UStG bzw. sonst USt-befreit: dann wird der
|
||||
// (Firmen-)Kunde bei Gutschriften wie ein Privatkunde behandelt (keine USt),
|
||||
// bis er ggf. USt-pflichtig wird. Steuert nur die VORBELEGUNG neuer
|
||||
// Gutschriften; bereits erstellte Gutschriften speichern ihren eigenen
|
||||
// Snapshot und bleiben von einem späteren Wechsel unberührt.
|
||||
vatExempt Boolean @default(false)
|
||||
businessRegistrationPath String? // PDF-Pfad zur Gewerbeanmeldung
|
||||
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
|
||||
commercialRegisterNumber String? // Handelsregisternummer (Text)
|
||||
@@ -213,10 +234,34 @@ model Customer {
|
||||
// Persönliche Anrede-Präferenzen aller Mitarbeiter zu diesem Kunden.
|
||||
userSalutations UserCustomerSalutation[]
|
||||
|
||||
// Werbung: wer hat wen an Board geholt.
|
||||
// referralAsRecruited = dieser Kunde WURDE geworben (max. 1 Werber).
|
||||
// referralsAsRecruiter = Kunden, die DIESER Kunde geworben hat (beliebig viele).
|
||||
referralAsRecruited CustomerReferral? @relation("ReferralRecruited")
|
||||
referralsAsRecruiter CustomerReferral[] @relation("ReferralRecruiter")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Werbe-Beziehung zwischen zwei Kunden ("Geworben / angeworben").
|
||||
// Ein Datensatz = ein Werber (recruiter) hat einen Kunden (recruited) an
|
||||
// Board geholt, plus die Beziehungsart. `recruitedId` ist @unique, damit ein
|
||||
// Kunde nur von EINER Person geworben sein kann (Abschnitt 1 in der UI).
|
||||
// Ein Werber kann beliebig viele Kunden werben (Abschnitt 2).
|
||||
model CustomerReferral {
|
||||
id Int @id @default(autoincrement())
|
||||
recruiterId Int
|
||||
recruiter Customer @relation("ReferralRecruiter", fields: [recruiterId], references: [id], onDelete: Cascade)
|
||||
recruitedId Int @unique
|
||||
recruited Customer @relation("ReferralRecruited", fields: [recruitedId], references: [id], onDelete: Cascade)
|
||||
relationship String // Ehepartner/in, Kind, Freund/Kumpel, …
|
||||
createdAt DateTime @default(now())
|
||||
createdBy String?
|
||||
|
||||
@@index([recruiterId])
|
||||
}
|
||||
|
||||
// ==================== USER-CUSTOMER SALUTATION ====================
|
||||
// Per-User-Präferenz, ob der eingeloggte Mitarbeiter mit dem jeweiligen
|
||||
// Kunden per Du oder Sie ist. Fehlender Eintrag → fällt auf den
|
||||
@@ -297,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?
|
||||
@@ -323,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?
|
||||
@@ -330,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
|
||||
}
|
||||
@@ -448,6 +499,7 @@ model StressfreiEmail {
|
||||
enum EmailFolder {
|
||||
INBOX
|
||||
SENT
|
||||
SPAM
|
||||
}
|
||||
|
||||
model CachedEmail {
|
||||
@@ -670,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.
|
||||
@@ -765,6 +820,11 @@ model Contract {
|
||||
stressfreiEmailId Int?
|
||||
stressfreiEmail StressfreiEmail? @relation(fields: [stressfreiEmailId], references: [id])
|
||||
|
||||
// Portaldaten bewusst nicht hinterlegt (Anbieter hat kein Portal oder
|
||||
// Kunde bekommt keine Zugangsdaten). Wenn true, unterdrückt das Cockpit
|
||||
// die „Portal-Zugangsdaten fehlen"-Warnung für diesen Vertrag.
|
||||
portalCredentialsNotRequired Boolean @default(false)
|
||||
|
||||
// Snooze: Vertrag zurückstellen bis Datum (für Cockpit)
|
||||
nextReviewDate DateTime? // Erneute Prüfung am
|
||||
|
||||
@@ -781,6 +841,7 @@ model Contract {
|
||||
historyEntries ContractHistoryEntry[]
|
||||
documents ContractDocument[]
|
||||
invoices Invoice[]
|
||||
creditNotes CreditNote[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -824,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)
|
||||
@@ -877,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
|
||||
@@ -916,6 +980,141 @@ model Invoice {
|
||||
@@index([contractId])
|
||||
}
|
||||
|
||||
// ==================== GUTSCHRIFTEN (CREDIT NOTES / SUBVENTIONEN) ====================
|
||||
|
||||
// Art der Gutschrift: Geldbetrag (per Überweisung, in EUR) oder Sachwert
|
||||
// (Smartphone, Elektrogerät …, näher im Textfeld beschrieben).
|
||||
enum CreditNoteType {
|
||||
GELD
|
||||
SACHWERT
|
||||
}
|
||||
|
||||
// Kundentyp zum Zeitpunkt der Gutschrift – bestimmt die USt-Sicht
|
||||
// (Privat rechnet brutto, Firma zieht Vorsteuer). Wird aus dem Kunden
|
||||
// vorbelegt, ist aber pro Gutschrift überschreibbar.
|
||||
enum CreditNoteCustomerType {
|
||||
PRIVAT
|
||||
FIRMA
|
||||
}
|
||||
|
||||
// Basis, auf die sich der eingegebene Betrag bezieht (nur relevant, wenn
|
||||
// vatRelevant = true). Bei NETTO wird die USt aufgeschlagen, bei BRUTTO
|
||||
// herausgerechnet.
|
||||
enum CreditNoteAmountBasis {
|
||||
NETTO
|
||||
BRUTTO
|
||||
}
|
||||
|
||||
model CreditNote {
|
||||
id Int @id @default(autoincrement())
|
||||
contractId Int
|
||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Gutschrift-Nummer aus dem Gutschrift-Nummernkreis (z.B. GS-2026-0001).
|
||||
// NULL bei betragslosen Sachwert-Übergaben (= Lieferschein, s.u.).
|
||||
number String? @unique
|
||||
// Lieferschein-/Subventionsnummer aus dem separaten Lieferschein-
|
||||
// Nummernkreis (z.B. LS-2026-0001) – nur bei betragslosen Sachwerten.
|
||||
// Beide Nummern werden lazy pro Serie vergeben und NIE wieder freigegeben
|
||||
// (keine Lücken); angezeigt wird je nach aktuellem Typ die passende.
|
||||
deliveryNoteNumber String? @unique
|
||||
|
||||
type CreditNoteType
|
||||
// Pflicht bei SACHWERT: was wird gewährt (z.B. "Smartphone Samsung A55").
|
||||
sachwertDescription String?
|
||||
|
||||
customerType CreditNoteCustomerType
|
||||
// USt-relevant ja/nein (Steuerberater-Auslegung: Vermittlungsleistung vs.
|
||||
// reiner Abschlussbonus/Entgeltminderung). Bei false: net = brutto, USt = 0.
|
||||
vatRelevant Boolean @default(false)
|
||||
amountBasis CreditNoteAmountBasis @default(BRUTTO)
|
||||
vatRate Float @default(19) // Prozent
|
||||
amountNet Float @default(0)
|
||||
amountVat Float @default(0)
|
||||
amountGross Float @default(0)
|
||||
currency String @default("EUR")
|
||||
|
||||
// Datum der Gutschrift (frei änderbar).
|
||||
creditDate DateTime
|
||||
// Ort der Unterschrift + Unterschrift-/Bestätigungszeitpunkt.
|
||||
place String?
|
||||
signedAt DateTime?
|
||||
// Sachwert: Bestätigung "Ware erhalten".
|
||||
goodsReceived Boolean @default(false)
|
||||
|
||||
// Auszahlungskonto (nur GELD): Bankkarte des Kunden, auf die überwiesen
|
||||
// wird. Kann ein anderes Konto sein als das Abbuchkonto des Vertrags.
|
||||
// ON DELETE SET NULL, damit das Löschen einer Bankkarte die Gutschrift
|
||||
// (historischer Beleg) nicht mitreißt.
|
||||
payoutBankCardId Int?
|
||||
payoutBankCard BankCard? @relation("CreditNotePayoutBankCard", fields: [payoutBankCardId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Überweisungsbeleg (nur GELD) – hochgeladene Datei.
|
||||
receiptPath String?
|
||||
// Generiertes Gutschrift-Dokument (PDF bzw. ZUGFeRD-Hybrid) – Phase 3.
|
||||
pdfPath String?
|
||||
|
||||
notes String?
|
||||
createdBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([contractId])
|
||||
}
|
||||
|
||||
// Absender-/Firmenstammdaten (Einzel-Zeile). Fließen in Gutschrift-PDF und
|
||||
// ZUGFeRD-XML (Verkäufer/Seller-Party) sowie in die Bankangaben ein.
|
||||
model CompanyProfile {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @default("")
|
||||
street String @default("")
|
||||
houseNumber String @default("")
|
||||
postalCode String @default("")
|
||||
city String @default("")
|
||||
country String @default("DE") // ISO-2, für ZUGFeRD countryID
|
||||
vatId String @default("") // USt-IdNr (DE...)
|
||||
taxNumber String @default("") // Steuernummer
|
||||
commercialRegister String @default("") // z.B. HRB 12345, Amtsgericht
|
||||
managingDirector String @default("") // Geschäftsführer/Inhaber
|
||||
email String @default("")
|
||||
phone String @default("")
|
||||
website String @default("")
|
||||
iban String @default("")
|
||||
bic String @default("")
|
||||
bankName String @default("")
|
||||
logoPath String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Nummernkreis für Gutschriften (Einzel-Zeile, in den Einstellungen
|
||||
// verwaltbar). Nummer wird transaktional vergeben, damit keine Lücken/
|
||||
// Doppelvergaben entstehen.
|
||||
model CreditNoteNumberRange {
|
||||
id Int @id @default(autoincrement())
|
||||
prefix String @default("GS-")
|
||||
includeYear Boolean @default(true)
|
||||
separator String @default("-")
|
||||
padding Int @default(4)
|
||||
nextNumber Int @default(1)
|
||||
resetYearly Boolean @default(true)
|
||||
currentYear Int?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Separater Nummernkreis für Lieferscheine (betragslose Sachwert-Übergaben).
|
||||
// Eigene Serie, damit die Gutschrift-Nummerierung sauber/lückenlos bleibt.
|
||||
model DeliveryNoteNumberRange {
|
||||
id Int @id @default(autoincrement())
|
||||
prefix String @default("LS-")
|
||||
includeYear Boolean @default(true)
|
||||
separator String @default("-")
|
||||
padding Int @default(4)
|
||||
nextNumber Int @default(1)
|
||||
resetYearly Boolean @default(true)
|
||||
currentYear Int?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ==================== INTERNET CONTRACT DETAILS ====================
|
||||
|
||||
model InternetContractDetails {
|
||||
@@ -965,6 +1164,10 @@ model MobileContractDetails {
|
||||
contractId Int @unique
|
||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
requiresMultisim Boolean @default(false) // Multisim erforderlich?
|
||||
// Physisches Mobilfunknetz, auf dem der Tarif läuft: TELEKOM | VODAFONE
|
||||
// | TELEFONICA (null = nicht gewählt). Als String statt Enum gehalten,
|
||||
// damit weitere Netze ohne Migration ergänzbar sind.
|
||||
mobileNetwork String?
|
||||
dataVolume Float?
|
||||
includedMinutes Int?
|
||||
includedSMS Int?
|
||||
@@ -1033,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
|
||||
@@ -1043,6 +1316,7 @@ enum AuditAction {
|
||||
LOGIN
|
||||
LOGOUT
|
||||
LOGIN_FAILED
|
||||
TOKEN_REFRESH // stiller Access-Token-Refresh (Cookie), kein interaktiver Login
|
||||
}
|
||||
|
||||
enum AuditSensitivity {
|
||||
@@ -1095,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])
|
||||
@@ -1215,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' });
|
||||
}
|
||||
@@ -54,6 +133,12 @@ export async function getAuditLogs(req: AuthRequest, res: Response) {
|
||||
export async function getAuditLogById(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
// Nicht-numerische :id (z.B. GET /audit-logs/verify statt POST /verify)
|
||||
// landete sonst als NaN in Prisma findUnique → 500. Sauberer 400.
|
||||
// (Pentest R120 – gleiche „400 statt 500"-Klasse wie R64.1/R104.1.)
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ success: false, error: 'Ungültige Audit-Log-ID' });
|
||||
}
|
||||
const log = await auditService.getAuditLogById(id);
|
||||
|
||||
if (!log) {
|
||||
@@ -73,6 +158,9 @@ export async function getAuditLogById(req: AuthRequest, res: Response) {
|
||||
export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (Number.isNaN(customerId)) {
|
||||
return res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' });
|
||||
}
|
||||
const logs = await auditService.getAuditLogsByDataSubject(customerId);
|
||||
|
||||
res.json({ success: true, data: logs });
|
||||
@@ -87,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`;
|
||||
@@ -117,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' });
|
||||
}
|
||||
@@ -134,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) {
|
||||
@@ -156,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,
|
||||
@@ -168,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
|
||||
*/
|
||||
@@ -187,8 +566,53 @@ export async function getRetentionPolicies(req: AuthRequest, res: Response) {
|
||||
export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return res.status(400).json({ success: false, error: 'Ungültige Policy-ID' });
|
||||
}
|
||||
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,
|
||||
@@ -199,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 });
|
||||
@@ -214,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,11 +1,12 @@
|
||||
// ==================== 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';
|
||||
import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '../services/smtpService.js';
|
||||
import { fetchAttachment, appendToSent, ImapCredentials, humanizeImapError } from '../services/imapService.js';
|
||||
import { fetchAttachment, appendToSent, ImapCredentials, humanizeImapError, findJunkFolderPath } from '../services/imapService.js';
|
||||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||||
import { decrypt } from '../utils/encryption.js';
|
||||
import { sanitizeNotes, stripHtml, validateContractDocumentType, validateOptionalIsoDate, assertSafePdf } from '../utils/sanitize.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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,7 +735,30 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
||||
};
|
||||
|
||||
// Ordner basierend auf E-Mail-Typ bestimmen (INBOX oder Sent)
|
||||
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
// SPAM-Mails liegen im Junk-Ordner (Name variiert je Server) – IMAP-UIDs
|
||||
// sind pro Ordner vergeben, daher MUSS der echte Junk-Pfad ermittelt
|
||||
// werden, sonst wird der Anhang im falschen Ordner gesucht (404 oder,
|
||||
// bei UID-Kollision, der falsche Anhang aus INBOX). Gleiches Muster wie
|
||||
// cachedEmail.service.ts::moveEmailToTrash.
|
||||
let imapFolder: string;
|
||||
if (email.folder === 'SENT') {
|
||||
imapFolder = 'Sent';
|
||||
} else if (email.folder === 'SPAM') {
|
||||
// Kein stiller Fallback: findet sich der Junk-Ordner nicht (mehr),
|
||||
// lieber ein klarer Fehler als aus dem falschen Ordner (INBOX/'Junk')
|
||||
// die falsche UID zu ziehen.
|
||||
const junkPath = await findJunkFolderPath(credentials);
|
||||
if (!junkPath) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Postfach hat keinen Spam-/Junk-Ordner (mehr) – Anhang nicht abrufbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
imapFolder = junkPath;
|
||||
} else {
|
||||
imapFolder = 'INBOX';
|
||||
}
|
||||
|
||||
// Anhang per IMAP abrufen
|
||||
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||||
@@ -1405,7 +1423,30 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
||||
};
|
||||
|
||||
// Ordner basierend auf E-Mail-Typ bestimmen (INBOX oder Sent)
|
||||
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
// SPAM-Mails liegen im Junk-Ordner (Name variiert je Server) – IMAP-UIDs
|
||||
// sind pro Ordner vergeben, daher MUSS der echte Junk-Pfad ermittelt
|
||||
// werden, sonst wird der Anhang im falschen Ordner gesucht (404 oder,
|
||||
// bei UID-Kollision, der falsche Anhang aus INBOX). Gleiches Muster wie
|
||||
// cachedEmail.service.ts::moveEmailToTrash.
|
||||
let imapFolder: string;
|
||||
if (email.folder === 'SENT') {
|
||||
imapFolder = 'Sent';
|
||||
} else if (email.folder === 'SPAM') {
|
||||
// Kein stiller Fallback: findet sich der Junk-Ordner nicht (mehr),
|
||||
// lieber ein klarer Fehler als aus dem falschen Ordner (INBOX/'Junk')
|
||||
// die falsche UID zu ziehen.
|
||||
const junkPath = await findJunkFolderPath(credentials);
|
||||
if (!junkPath) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Postfach hat keinen Spam-/Junk-Ordner (mehr) – Anhang nicht abrufbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
imapFolder = junkPath;
|
||||
} else {
|
||||
imapFolder = 'INBOX';
|
||||
}
|
||||
|
||||
// Anhang per IMAP abrufen
|
||||
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||||
@@ -1614,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1863,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1990,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2099,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2211,7 +2237,30 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
||||
};
|
||||
|
||||
// IMAP-Ordner bestimmen
|
||||
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
// SPAM-Mails liegen im Junk-Ordner (Name variiert je Server) – IMAP-UIDs
|
||||
// sind pro Ordner vergeben, daher MUSS der echte Junk-Pfad ermittelt
|
||||
// werden, sonst wird der Anhang im falschen Ordner gesucht (404 oder,
|
||||
// bei UID-Kollision, der falsche Anhang aus INBOX). Gleiches Muster wie
|
||||
// cachedEmail.service.ts::moveEmailToTrash.
|
||||
let imapFolder: string;
|
||||
if (email.folder === 'SENT') {
|
||||
imapFolder = 'Sent';
|
||||
} else if (email.folder === 'SPAM') {
|
||||
// Kein stiller Fallback: findet sich der Junk-Ordner nicht (mehr),
|
||||
// lieber ein klarer Fehler als aus dem falschen Ordner (INBOX/'Junk')
|
||||
// die falsche UID zu ziehen.
|
||||
const junkPath = await findJunkFolderPath(credentials);
|
||||
if (!junkPath) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Postfach hat keinen Spam-/Junk-Ordner (mehr) – Anhang nicht abrufbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
imapFolder = junkPath;
|
||||
} else {
|
||||
imapFolder = 'INBOX';
|
||||
}
|
||||
|
||||
// Anhang vom IMAP-Server laden
|
||||
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||||
@@ -2263,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2370,7 +2414,30 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
||||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||||
};
|
||||
|
||||
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||||
// SPAM-Mails liegen im Junk-Ordner (Name variiert je Server) – IMAP-UIDs
|
||||
// sind pro Ordner vergeben, daher MUSS der echte Junk-Pfad ermittelt
|
||||
// werden, sonst wird der Anhang im falschen Ordner gesucht (404 oder,
|
||||
// bei UID-Kollision, der falsche Anhang aus INBOX). Gleiches Muster wie
|
||||
// cachedEmail.service.ts::moveEmailToTrash.
|
||||
let imapFolder: string;
|
||||
if (email.folder === 'SENT') {
|
||||
imapFolder = 'Sent';
|
||||
} else if (email.folder === 'SPAM') {
|
||||
// Kein stiller Fallback: findet sich der Junk-Ordner nicht (mehr),
|
||||
// lieber ein klarer Fehler als aus dem falschen Ordner (INBOX/'Junk')
|
||||
// die falsche UID zu ziehen.
|
||||
const junkPath = await findJunkFolderPath(credentials);
|
||||
if (!junkPath) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Postfach hat keinen Spam-/Junk-Ordner (mehr) – Anhang nicht abrufbar',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
imapFolder = junkPath;
|
||||
} else {
|
||||
imapFolder = 'INBOX';
|
||||
}
|
||||
|
||||
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||||
if (!attachment) {
|
||||
@@ -2420,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';
|
||||
@@ -10,8 +11,8 @@ import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
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 } from '../utils/accessControl.js';
|
||||
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||
import { canAccessContract, canAccessCustomer } from '../utils/accessControl.js';
|
||||
import { maybeActivateOnDeliveryConfirmation, maybeCancelOnCancellationConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||||
|
||||
/**
|
||||
* Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten
|
||||
@@ -74,28 +75,42 @@ function sanitizeContractBody(body: unknown, parentKey?: string): unknown {
|
||||
|
||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { customerId, type, status, search, page, limit, tree } = req.query;
|
||||
const { customerId, type, status, search, page, limit, tree, includeDeactivated } = req.query;
|
||||
|
||||
// Baumstruktur für Kundenansicht
|
||||
if (tree === 'true' && customerId) {
|
||||
const customerIdNum = parseInt(customerId as string);
|
||||
// Pentest R120 (CRITICAL IDOR): Der tree=true-Zweig returnte früh,
|
||||
// BEVOR die Portal-User-customerIds-Filterung unten für die flache
|
||||
// Liste griff. Ein Portal-User konnte damit den vollständigen
|
||||
// Vertragsbaum eines beliebigen Fremdkunden lesen
|
||||
// (?tree=true&customerId=<fremd>). Ownership-Check nachgezogen –
|
||||
// canAccessCustomer prüft eigene ID + vertretene MIT Live-Vollmacht
|
||||
// und sendet selbst die 403-Response. Nicht-Portal-User (Staff)
|
||||
// passieren unverändert.
|
||||
if (!(await canAccessCustomer(req, res, customerIdNum))) return;
|
||||
const treeData = await contractService.getContractTreeForCustomer(
|
||||
parseInt(customerId as string)
|
||||
customerIdNum,
|
||||
includeDeactivated === 'true',
|
||||
);
|
||||
res.json({ success: true, data: treeData } as ApiResponse);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,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.
|
||||
@@ -196,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,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> = {
|
||||
@@ -235,6 +284,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am',
|
||||
identityDocumentId: 'Ausweis', bankCardId: 'Bankverbindung', addressId: 'Adresse',
|
||||
commission: 'Provision', notes: 'Notizen',
|
||||
portalCredentialsNotRequired: 'Portaldaten-Opt-out',
|
||||
stressfreiEmailId: 'Stressfrei-/Corporate-Adresse',
|
||||
};
|
||||
const energyLabels: Record<string, string> = {
|
||||
meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch',
|
||||
@@ -280,6 +331,39 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
||||
customerId: before?.customerId,
|
||||
});
|
||||
|
||||
// Zusätzliches CRITICAL-Event für das „Portaldaten nicht benötigt"-Opt-out.
|
||||
// Landet unter resourceType ContractPassword (sensitivity CRITICAL laut
|
||||
// audit.service determineSensitivity) und liegt damit in derselben
|
||||
// DSGVO-Reihe wie Klartext-Password-Reads. Motivation: das Setzen des
|
||||
// Flags räumt server-seitig portalUsername/portalPasswordEncrypted/
|
||||
// stressfreiEmailId auf NULL – diese Löschung soll unabhängig vom
|
||||
// generischen UPDATE-Diff nachweisbar sein. Rücknahme des Flags wird
|
||||
// ebenfalls geloggt, damit die Kette in beide Richtungen konsistent ist.
|
||||
if (
|
||||
before
|
||||
&& 'portalCredentialsNotRequired' in body
|
||||
&& before.portalCredentialsNotRequired !== body.portalCredentialsNotRequired
|
||||
) {
|
||||
const toggledOn = body.portalCredentialsNotRequired === true;
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'ContractPassword',
|
||||
resourceId: contractId.toString(),
|
||||
label: toggledOn
|
||||
? `Vertrag ${before.contractNumber || contractId}: Portal-Zugangsdaten als „nicht erforderlich" markiert (Username/Passwort/Stressfrei-ID auf NULL gesetzt)`
|
||||
: `Vertrag ${before.contractNumber || contractId}: „Portaldaten nicht erforderlich"-Marker zurückgenommen`,
|
||||
details: {
|
||||
vorher: before.portalCredentialsNotRequired,
|
||||
nachher: body.portalCredentialsNotRequired,
|
||||
hatUsernameVorher: !!before.portalUsername,
|
||||
hatPasswortVorher: !!before.portalPasswordEncrypted,
|
||||
hatStressfreiVorher: !!before.stressfreiEmailId,
|
||||
},
|
||||
customerId: before.customerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Response sanitisieren – sonst leakt portalPasswordEncrypted etc.
|
||||
// (Pentest Runde 15, gleiche Klasse wie 20.3 für Customer).
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
@@ -288,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -702,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,10 +924,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
||||
|
||||
res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as contractCategoryService from '../services/contractCategory.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
@@ -46,10 +47,7 @@ export async function createContractCategory(req: Request, res: Response): Promi
|
||||
});
|
||||
res.status(201).json({ success: true, data: category } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertragskategorie',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertragskategorie');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +62,7 @@ export async function updateContractCategory(req: Request, res: Response): Promi
|
||||
});
|
||||
res.json({ success: true, data: category } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertragskategorie',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertragskategorie');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,9 +78,6 @@ export async function deleteContractCategory(req: Request, res: Response): Promi
|
||||
});
|
||||
res.json({ success: true, message: 'Vertragskategorie gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertragskategorie',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Vertragskategorie');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
@@ -47,10 +48,7 @@ export async function createHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
|
||||
res.status(201).json({ success: true, data: entry } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Eintrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen des Eintrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,10 +72,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
|
||||
res.json({ success: true, data: entry } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Eintrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Eintrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,9 +92,6 @@ export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promi
|
||||
|
||||
res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Eintrags',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen des Eintrags');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import * as contractTaskService from '../services/contractTask.service.js';
|
||||
import * as contractService from '../services/contract.service.js';
|
||||
import * as customerService from '../services/customer.service.js';
|
||||
@@ -118,6 +119,38 @@ export async function getTasks(req: AuthRequest, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Allgemeine Aufgabe OHNE Vertrag/Kunde (nur Mitarbeiter/Admin). Ohne Vertrag
|
||||
// gibt es keinen Kunden → visibleInPortal ist hier bedeutungslos und immer
|
||||
// false (der Service erzwingt das ohnehin).
|
||||
export async function createGeneralTask(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (req.user?.isCustomerPortal) {
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const { title, description } = req.body ?? {};
|
||||
if (!title || !String(title).trim()) {
|
||||
res.status(400).json({ success: false, error: 'Titel ist erforderlich' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const task = await contractTaskService.createTask({
|
||||
contractId: null,
|
||||
title: String(title).trim(),
|
||||
description,
|
||||
visibleInPortal: false,
|
||||
createdBy: req.user?.email,
|
||||
});
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'ContractTask',
|
||||
resourceId: task.id.toString(),
|
||||
label: `Allgemeine Aufgabe "${String(title).trim()}" erstellt (ohne Vertrag)`,
|
||||
});
|
||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTask(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
@@ -152,10 +185,7 @@ export async function createTask(req: AuthRequest, res: Response): Promise<void>
|
||||
|
||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,10 +234,7 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
|
||||
|
||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Support-Anfrage',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Support-Anfrage');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +257,7 @@ export async function updateTask(req: AuthRequest, res: Response): Promise<void>
|
||||
|
||||
res.json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,10 +272,7 @@ export async function completeTask(req: AuthRequest, res: Response): Promise<voi
|
||||
});
|
||||
res.json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Abschließen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,10 +287,7 @@ export async function reopenTask(req: AuthRequest, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, data: task } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,10 +302,7 @@ export async function deleteTask(req: AuthRequest, res: Response): Promise<void>
|
||||
});
|
||||
res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Aufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Aufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,10 +337,7 @@ export async function createSubtask(req: AuthRequest, res: Response): Promise<vo
|
||||
|
||||
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +373,12 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
// Allgemeine Aufgaben (ohne Vertrag/Kunde) sind reine Mitarbeiter-Aufgaben
|
||||
// und für Portal-User nie zugänglich.
|
||||
if (task.contractId == null) {
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
|
||||
// via hasAuthorization, Pentest Runde 6 – HOCH-04). Damit kann ein
|
||||
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
|
||||
@@ -398,10 +416,7 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
||||
|
||||
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Antwort',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Erstellen der Antwort');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,10 +441,7 @@ export async function updateSubtask(req: AuthRequest, res: Response): Promise<vo
|
||||
});
|
||||
res.json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,10 +456,7 @@ export async function completeSubtask(req: AuthRequest, res: Response): Promise<
|
||||
});
|
||||
res.json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Abschließen der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,10 +471,7 @@ export async function reopenSubtask(req: AuthRequest, res: Response): Promise<vo
|
||||
});
|
||||
res.json({ success: true, data: subtask } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,9 +486,6 @@ export async function deleteSubtask(req: AuthRequest, res: Response): Promise<vo
|
||||
});
|
||||
res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Unteraufgabe',
|
||||
} as ApiResponse);
|
||||
antworteAufFehler(res, error, 'Fehler beim Löschen der Unteraufgabe');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { Response } from 'express';
|
||||
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiError } from '../utils/apiError.js';
|
||||
import * as creditNoteService from '../services/creditNote.service.js';
|
||||
import { effectiveNumber } from '../services/creditNote.service.js';
|
||||
import * as numberRangeService from '../services/creditNoteNumberRange.service.js';
|
||||
import * as deliveryRangeService from '../services/deliveryNoteNumberRange.service.js';
|
||||
import * as authorizationService from '../services/authorization.service.js';
|
||||
import { generateCreditNotePdf } from '../services/creditNotePdf.service.js';
|
||||
|
||||
// Gutschriften sind ein reiner Mitarbeiter-/Admin-Bereich (interne
|
||||
// Subventions-Belege). Portal-Kunden dürfen sie weder sehen noch ändern –
|
||||
// Defense-in-Depth: nicht nur die UI blendet aus, der Endpoint blockt
|
||||
// Portal-Token explizit.
|
||||
function blockPortal(req: AuthRequest, res: Response): boolean {
|
||||
if (req.user?.isCustomerPortal) {
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function idParam(req: AuthRequest, res: Response, name: string): number | null {
|
||||
const raw = req.params[name];
|
||||
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) {
|
||||
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
res.status(400).json({ success: false, error: `Ungültige ID: ${raw}` } as ApiResponse);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function handleError(res: Response, error: unknown, fallback: string) {
|
||||
// Eigene Huelle beibehalten, damit die Aufrufer unveraendert bleiben - die
|
||||
// Entscheidung, was nach draussen geht, faellt jetzt zentral.
|
||||
antworteAufFehler(res, error, fallback, 500);
|
||||
}
|
||||
|
||||
// ---- Gesamtübersicht (Hauptmenü) ----
|
||||
// Anders als die übrigen Credit-Note-Endpunkte NICHT staff-only: Portal-Kunden
|
||||
// dürfen ihre eigenen (+ vertretene) Belege sehen. Scoping über customerIds
|
||||
// aus dem JWT – eine im Body/Query mitgeschickte customerId hat keinen Effekt.
|
||||
export async function listAll(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
// Portal-User: nur eigene + vertretene Kunden MIT Vollmacht.
|
||||
// Fail-closed (Pentest R144): Ein Portal-Token wird IMMER gescoped. Fehlt
|
||||
// wider Erwarten die customerId (null/0), ergibt das eine LEERE Menge –
|
||||
// nicht den Staff-Zweig (der alle Belege zeigen würde).
|
||||
let customerIds: number[] | undefined;
|
||||
if (req.user?.isCustomerPortal) {
|
||||
customerIds = req.user.customerId ? [req.user.customerId] : [];
|
||||
if (req.user.customerId) {
|
||||
const representedIds: number[] = (req.user as any).representedCustomerIds || [];
|
||||
for (const repCustId of representedIds) {
|
||||
if (await authorizationService.hasAuthorization(repCustId, req.user.customerId)) {
|
||||
customerIds.push(repCustId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const page = Math.max(parseInt((req.query.page as string) || '1') || 1, 1);
|
||||
const limit = Math.min(Math.max(parseInt((req.query.limit as string) || '50') || 50, 1), 200);
|
||||
const search = typeof req.query.search === 'string' ? req.query.search : undefined;
|
||||
|
||||
const result = await creditNoteService.getAllCreditNotes({ customerIds, page, limit, search });
|
||||
|
||||
// Portal-Kunden dürfen keine Überweisungsbelege laden → receiptPath aus der
|
||||
// Response entfernen (Beleg-Download bleibt ohnehin staff-only).
|
||||
const isPortal = !!req.user?.isCustomerPortal;
|
||||
const items = isPortal
|
||||
? result.items.map((cn) => ({ ...cn, receiptPath: null }))
|
||||
: result.items;
|
||||
|
||||
res.json({ success: true, data: { items, pagination: result.pagination } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Laden der Belegübersicht');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Gutschriften pro Vertrag ----
|
||||
|
||||
export async function listByContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const contractId = idParam(req, res, 'contractId');
|
||||
if (contractId === null) return;
|
||||
const data = await creditNoteService.getCreditNotesByContract(contractId);
|
||||
res.json({ success: true, data } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Laden der Gutschriften');
|
||||
}
|
||||
}
|
||||
|
||||
// Vorbelegung fürs Formular: Default-Kundentyp (Firma/Privat aus Kunde) +
|
||||
// Vorschau der nächsten Gutschrift-Nummer.
|
||||
export async function formDefaults(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const contractId = idParam(req, res, 'contractId');
|
||||
if (contractId === null) return;
|
||||
const [defaults, nextNumber] = await Promise.all([
|
||||
creditNoteService.getCreditNoteDefaults(contractId),
|
||||
numberRangeService.previewNextNumber(),
|
||||
]);
|
||||
res.json({ success: true, data: { ...defaults, nextNumber } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Laden der Vorbelegung');
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const contractId = idParam(req, res, 'contractId');
|
||||
if (contractId === null) return;
|
||||
const created = await creditNoteService.createCreditNote(contractId, req.body ?? {}, req.user?.email);
|
||||
await logChange({
|
||||
req,
|
||||
action: 'CREATE',
|
||||
resourceType: 'CreditNote',
|
||||
resourceId: created.id.toString(),
|
||||
label: `Beleg ${effectiveNumber(created) ?? `#${created.id}`} angelegt (${created.type}, ${created.amountGross} ${created.currency}) an Vertrag #${contractId}`,
|
||||
});
|
||||
res.status(201).json({ success: true, data: created } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Anlegen der Gutschrift');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOne(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const id = idParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
const data = await creditNoteService.getCreditNoteById(id);
|
||||
if (!data) {
|
||||
res.status(404).json({ success: false, error: 'Gutschrift nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Laden der Gutschrift');
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const id = idParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
const updated = await creditNoteService.updateCreditNote(id, req.body ?? {});
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'CreditNote',
|
||||
resourceId: updated.id.toString(),
|
||||
label: `Beleg ${effectiveNumber(updated) ?? `#${updated.id}`} geändert (${updated.type}, ${updated.amountGross} ${updated.currency})`,
|
||||
});
|
||||
res.json({ success: true, data: updated } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Ändern der Gutschrift');
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const id = idParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
const deleted = await creditNoteService.deleteCreditNote(id);
|
||||
await logChange({
|
||||
req,
|
||||
action: 'DELETE',
|
||||
resourceType: 'CreditNote',
|
||||
resourceId: id.toString(),
|
||||
label: `Beleg ${effectiveNumber(deleted) ?? `#${deleted.id}`} gelöscht`,
|
||||
});
|
||||
res.json({ success: true } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Löschen der Gutschrift');
|
||||
}
|
||||
}
|
||||
|
||||
// PDF (Gutschrift-Dokument) erzeugen. ZUGFeRD-Embedding folgt (Teil 2).
|
||||
export async function generatePdf(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const id = idParam(req, res, 'id');
|
||||
if (id === null) return;
|
||||
const { pdfPath } = await generateCreditNotePdf(id);
|
||||
await logChange({
|
||||
req, action: 'UPDATE', resourceType: 'CreditNote', resourceId: id.toString(),
|
||||
label: 'Gutschrift-PDF erzeugt',
|
||||
});
|
||||
res.json({ success: true, data: { pdfPath } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Erzeugen des PDF');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Nummernkreis (Einstellungen) ----
|
||||
|
||||
export async function getNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const [range, nextNumber] = await Promise.all([
|
||||
numberRangeService.getOrCreateRange(),
|
||||
numberRangeService.previewNextNumber(),
|
||||
]);
|
||||
res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Laden des Nummernkreises');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const range = await numberRangeService.updateRange(req.body ?? {});
|
||||
const preview = await numberRangeService.previewNextNumber();
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'CreditNoteNumberRange',
|
||||
resourceId: range.id.toString(),
|
||||
label: `Gutschrift-Nummernkreis geändert (Präfix "${range.prefix}", nächste Nr. ${range.nextNumber})`,
|
||||
});
|
||||
res.json({ success: true, data: { ...range, preview } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Speichern des Nummernkreises');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Lieferschein-Nummernkreis (betragslose Sachwerte) ----
|
||||
|
||||
export async function getDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const [range, nextNumber] = await Promise.all([
|
||||
deliveryRangeService.getOrCreateRange(),
|
||||
deliveryRangeService.previewNextNumber(),
|
||||
]);
|
||||
res.json({ success: true, data: { ...range, preview: nextNumber } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Laden des Lieferschein-Nummernkreises');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDeliveryNoteNumberRange(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const range = await deliveryRangeService.updateRange(req.body ?? {});
|
||||
const preview = await deliveryRangeService.previewNextNumber();
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'DeliveryNoteNumberRange',
|
||||
resourceId: range.id.toString(),
|
||||
label: `Lieferschein-Nummernkreis geändert (Präfix "${range.prefix}", nächste Nr. ${range.nextNumber})`,
|
||||
});
|
||||
res.json({ success: true, data: { ...range, preview } } as ApiResponse);
|
||||
} catch (error) {
|
||||
handleError(res, error, 'Fehler beim Speichern des Lieferschein-Nummernkreises');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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';
|
||||
|
||||
// Der ganze Bereich ist rein für Mitarbeiter/Admins – Portal-Kunden dürfen
|
||||
// Werbe-Beziehungen weder sehen noch ändern. Defense-in-Depth: nicht nur die
|
||||
// UI blendet den Tab aus, der Endpoint blockt Portal-Token explizit.
|
||||
function blockPortal(req: AuthRequest, res: Response): boolean {
|
||||
if (req.user?.isCustomerPortal) {
|
||||
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function getReferrals(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (Number.isNaN(customerId)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const data = await referralService.getReferralsForCustomer(customerId);
|
||||
res.json({ success: true, data } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Werbe-Beziehungen' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (Number.isNaN(customerId)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const search = req.query.search;
|
||||
if (!search || typeof search !== 'string' || search.trim().length < 2) {
|
||||
res.json({ success: true, data: [] } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const customers = await referralService.searchCustomersForReferral(search.trim(), customerId);
|
||||
res.json({ success: true, data: customers } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler bei der Suche' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createReferral(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (Number.isNaN(customerId)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const { direction, otherCustomerId, relationship } = req.body ?? {};
|
||||
const otherId = parseInt(otherCustomerId);
|
||||
if (Number.isNaN(otherId)) {
|
||||
res.status(400).json({ success: false, error: 'Kein Kunde ausgewählt' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// direction bestimmt, wer Werber und wer Geworbener ist:
|
||||
// 'recruitedBy' = dieser Kunde wurde vom anderen geworben (Abschnitt 1)
|
||||
// 'recruited' = dieser Kunde hat den anderen geworben (Abschnitt 2)
|
||||
let recruiterId: number;
|
||||
let recruitedId: number;
|
||||
if (direction === 'recruitedBy') {
|
||||
recruiterId = otherId;
|
||||
recruitedId = customerId;
|
||||
} else if (direction === 'recruited') {
|
||||
recruiterId = customerId;
|
||||
recruitedId = otherId;
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: 'Ungültige Richtung' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await referralService.createReferral({
|
||||
recruiterId,
|
||||
recruitedId,
|
||||
relationship: (relationship ?? '').toString(),
|
||||
createdBy: req.user?.email,
|
||||
});
|
||||
|
||||
await logChange({
|
||||
req,
|
||||
action: 'CREATE',
|
||||
resourceType: 'CustomerReferral',
|
||||
resourceId: created.id.toString(),
|
||||
label: `Werbe-Beziehung angelegt: Kunde #${recruiterId} hat Kunde #${recruitedId} geworben (${created.relationship})`,
|
||||
customerId: recruitedId,
|
||||
});
|
||||
|
||||
res.status(201).json({ success: true, data: created } as ApiResponse);
|
||||
} catch (error) {
|
||||
antworteAufFehler(res, error, 'Fehler beim Anlegen der Werbe-Beziehung', 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateReferral(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
const referralId = parseInt(req.params.referralId);
|
||||
if (Number.isNaN(customerId) || Number.isNaN(referralId)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige ID' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const { direction, otherCustomerId, relationship } = req.body ?? {};
|
||||
const otherId = parseInt(otherCustomerId);
|
||||
if (Number.isNaN(otherId)) {
|
||||
res.status(400).json({ success: false, error: 'Kein Kunde ausgewählt' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
let recruiterId: number;
|
||||
let recruitedId: number;
|
||||
if (direction === 'recruitedBy') {
|
||||
recruiterId = otherId;
|
||||
recruitedId = customerId;
|
||||
} else if (direction === 'recruited') {
|
||||
recruiterId = customerId;
|
||||
recruitedId = otherId;
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: 'Ungültige Richtung' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await referralService.updateReferral({
|
||||
id: referralId,
|
||||
recruiterId,
|
||||
recruitedId,
|
||||
relationship: (relationship ?? '').toString(),
|
||||
});
|
||||
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'CustomerReferral',
|
||||
resourceId: updated.id.toString(),
|
||||
label: `Werbe-Beziehung geändert: Kunde #${recruiterId} hat Kunde #${recruitedId} geworben (${updated.relationship})`,
|
||||
customerId: recruitedId,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: updated } as ApiResponse);
|
||||
} catch (error) {
|
||||
antworteAufFehler(res, error, 'Fehler beim Ändern der Werbe-Beziehung', 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteReferral(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
if (blockPortal(req, res)) return;
|
||||
const referralId = parseInt(req.params.referralId);
|
||||
if (Number.isNaN(referralId)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige ID' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
const ok = await referralService.deleteReferral(referralId);
|
||||
if (!ok) {
|
||||
res.status(404).json({ success: false, error: 'Eintrag nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
}
|
||||
await logChange({
|
||||
req,
|
||||
action: 'DELETE',
|
||||
resourceType: 'CustomerReferral',
|
||||
resourceId: referralId.toString(),
|
||||
label: `Werbe-Beziehung #${referralId} entfernt`,
|
||||
});
|
||||
res.json({ success: true } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Entfernen der Werbe-Beziehung' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+80
-52
@@ -1,4 +1,4 @@
|
||||
import express from 'express';
|
||||
import express, { Router } from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
@@ -30,6 +30,7 @@ if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD
|
||||
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
|
||||
}
|
||||
|
||||
import { apiBackstopRateLimiter } from './middleware/rateLimit.js';
|
||||
import authRoutes from './routes/auth.routes.js';
|
||||
import customerRoutes from './routes/customer.routes.js';
|
||||
import addressRoutes from './routes/address.routes.js';
|
||||
@@ -38,6 +39,8 @@ import documentRoutes from './routes/document.routes.js';
|
||||
import meterRoutes from './routes/meter.routes.js';
|
||||
import stressfreiEmailRoutes from './routes/stressfreiEmail.routes.js';
|
||||
import contractRoutes from './routes/contract.routes.js';
|
||||
import creditNoteRoutes from './routes/creditNote.routes.js';
|
||||
import companyProfileRoutes from './routes/companyProfile.routes.js';
|
||||
import platformRoutes from './routes/platform.routes.js';
|
||||
import cancellationPeriodRoutes from './routes/cancellation-period.routes.js';
|
||||
import contractDurationRoutes from './routes/contract-duration.routes.js';
|
||||
@@ -63,10 +66,14 @@ import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||
import { downloadFile } from './controllers/fileDownload.controller.js';
|
||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
||||
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||
import { registriereIdPruefung } from './middleware/routeIds.js';
|
||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||
import { auditMiddleware } from './middleware/audit.js';
|
||||
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
||||
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
|
||||
import { authenticate } from './middleware/auth.js';
|
||||
|
||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||
@@ -321,62 +328,76 @@ app.use('/api', (_req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
||||
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
|
||||
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
|
||||
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` – also `6abc`,
|
||||
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
|
||||
// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
|
||||
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
|
||||
//
|
||||
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
|
||||
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
|
||||
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
|
||||
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
|
||||
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
|
||||
// nicht geblockt.
|
||||
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
|
||||
app.use('/api', (req, res, next) => {
|
||||
for (const seg of req.path.split('/')) {
|
||||
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
// Genau das loest `mounte()` weiter unten – die Pruefung wird an jedem Router
|
||||
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
|
||||
// zwar in beide Richtungen:
|
||||
//
|
||||
// – Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
|
||||
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
|
||||
// ID-Parameter und laesst nur kanonische Zahlen zu.
|
||||
// – Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
|
||||
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
|
||||
//
|
||||
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt – zwei Antworten
|
||||
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
|
||||
|
||||
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
||||
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
||||
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
||||
app.use('/api', apiBackstopRateLimiter);
|
||||
|
||||
// Router einhängen – IMMER über `mounte`, nie über `app.use` direkt.
|
||||
//
|
||||
// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188)
|
||||
// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie
|
||||
// stillschweigend – deshalb steht die Pruefung im selben Handgriff wie das
|
||||
// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann.
|
||||
const mounte = (pfad: string, router: Router): void => {
|
||||
app.use(pfad, registriereIdPruefung(router));
|
||||
};
|
||||
|
||||
// Öffentliche Routes (OHNE Authentifizierung)
|
||||
app.use('/api/public/consent', consentPublicRoutes);
|
||||
mounte('/api/public/consent', consentPublicRoutes);
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/customers', customerRoutes);
|
||||
app.use('/api/addresses', addressRoutes);
|
||||
app.use('/api/bank-cards', bankcardRoutes);
|
||||
app.use('/api/documents', documentRoutes);
|
||||
app.use('/api/meters', meterRoutes);
|
||||
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||
app.use('/api/contracts', contractRoutes);
|
||||
app.use('/api/platforms', platformRoutes);
|
||||
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||
app.use('/api/contract-durations', contractDurationRoutes);
|
||||
app.use('/api/providers', providerRoutes);
|
||||
app.use('/api/tariffs', tariffRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
app.use('/api/developer', developerRoutes);
|
||||
app.use('/api/contract-categories', contractCategoryRoutes);
|
||||
app.use('/api', contractTaskRoutes);
|
||||
app.use('/api/settings', appSettingRoutes);
|
||||
app.use('/api/email-providers', emailProviderRoutes);
|
||||
app.use('/api', cachedEmailRoutes);
|
||||
app.use('/api/energy-details', invoiceRoutes);
|
||||
app.use('/api', contractHistoryRoutes);
|
||||
app.use('/api/audit-logs', auditLogRoutes);
|
||||
app.use('/api/gdpr', gdprRoutes);
|
||||
app.use('/api/email-logs', emailLogRoutes);
|
||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
||||
app.use('/api/birthdays', birthdayRoutes);
|
||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
||||
app.use('/api/monitoring', monitoringRoutes);
|
||||
mounte('/api/auth', authRoutes);
|
||||
mounte('/api/customers', customerRoutes);
|
||||
mounte('/api/addresses', addressRoutes);
|
||||
mounte('/api/bank-cards', bankcardRoutes);
|
||||
mounte('/api/documents', documentRoutes);
|
||||
mounte('/api/meters', meterRoutes);
|
||||
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||
mounte('/api/contracts', contractRoutes);
|
||||
mounte('/api/credit-notes', creditNoteRoutes);
|
||||
mounte('/api/company-profile', companyProfileRoutes);
|
||||
mounte('/api/platforms', platformRoutes);
|
||||
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||
mounte('/api/contract-durations', contractDurationRoutes);
|
||||
mounte('/api/providers', providerRoutes);
|
||||
mounte('/api/tariffs', tariffRoutes);
|
||||
mounte('/api/users', userRoutes);
|
||||
mounte('/api/upload', uploadRoutes);
|
||||
mounte('/api/developer', developerRoutes);
|
||||
mounte('/api/contract-categories', contractCategoryRoutes);
|
||||
mounte('/api', contractTaskRoutes);
|
||||
mounte('/api/settings', appSettingRoutes);
|
||||
mounte('/api/email-providers', emailProviderRoutes);
|
||||
mounte('/api', cachedEmailRoutes);
|
||||
mounte('/api/energy-details', invoiceRoutes);
|
||||
mounte('/api', contractHistoryRoutes);
|
||||
mounte('/api/audit-logs', auditLogRoutes);
|
||||
mounte('/api/gdpr', gdprRoutes);
|
||||
mounte('/api/email-logs', emailLogRoutes);
|
||||
mounte('/api/pdf-templates', pdfTemplateRoutes);
|
||||
mounte('/api/birthdays', birthdayRoutes);
|
||||
mounte('/api/factory-defaults', factoryDefaultsRoutes);
|
||||
mounte('/api/monitoring', monitoringRoutes);
|
||||
|
||||
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
||||
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
||||
@@ -476,10 +497,17 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
|
||||
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
||||
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
||||
|
||||
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
||||
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
||||
starteHeartbeatMonitor();
|
||||
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
|
||||
void pruefePflichtrechte();
|
||||
|
||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||
startBirthdayScheduler();
|
||||
startContractStatusScheduler();
|
||||
startSecurityMonitorScheduler();
|
||||
startBlzUpdateScheduler();
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* SecurityEvent (RATE_LIMIT_HIT) – damit der Monitoring-View und das
|
||||
* Alert-System sehen, wenn jemand auf die Tür hämmert.
|
||||
*/
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
|
||||
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||
|
||||
function onLimitReached(label: string, severity: 'MEDIUM' | 'HIGH') {
|
||||
@@ -54,7 +54,13 @@ export const loginRateLimiter = rateLimit({
|
||||
skipSuccessfulRequests: true,
|
||||
keyGenerator: (req): string => {
|
||||
const email = (req.body?.email || '').toString().trim().toLowerCase();
|
||||
const ip = req.ip || 'unknown';
|
||||
// IPv6-Härtung: ipKeyGenerator normalisiert IPv6 auf das Subnetz
|
||||
// (Default /56), damit ein Angreifer nicht durch Rotation innerhalb
|
||||
// seines zugeteilten IPv6-Blocks das Per-IP-Limit umgeht. IPv4 bleibt
|
||||
// unverändert. Die Limiter OHNE eigenen keyGenerator (Passwort-Reset,
|
||||
// Consent) machen das schon über den Library-Default – hier holen wir
|
||||
// die Custom-keyGenerator-Limiter auf denselben Stand.
|
||||
const ip = ipKeyGenerator(req.ip || 'unknown');
|
||||
return email ? `${ip}|${email}` : `${ip}|<no-email>`;
|
||||
},
|
||||
handler: (req, res, _next, options) => {
|
||||
@@ -107,7 +113,8 @@ export const staffPasswordReAuthLimiter = rateLimit({
|
||||
},
|
||||
skipSuccessfulRequests: true,
|
||||
keyGenerator: (req): string => {
|
||||
const ip = req.ip || 'unknown';
|
||||
// IPv6-Härtung wie beim Login-Limiter (siehe dort).
|
||||
const ip = ipKeyGenerator(req.ip || 'unknown');
|
||||
const targetUserId = (req.params?.id ?? '<missing>').toString();
|
||||
return `${ip}|staff-pw|${targetUserId}`;
|
||||
},
|
||||
@@ -125,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,6 +4,7 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import * as contractController from '../controllers/contract.controller.js';
|
||||
import * as invoiceController from '../controllers/invoice.controller.js';
|
||||
import * as creditNoteController from '../controllers/creditNote.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
||||
|
||||
@@ -57,6 +58,12 @@ router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'),
|
||||
router.get('/:id/invoices', authenticate, requirePermission('contracts:read'), invoiceController.getInvoicesByContract);
|
||||
router.post('/:id/invoices', authenticate, requirePermission('contracts:update'), invoiceController.addInvoiceByContract);
|
||||
|
||||
// Gutschriften (Subventionen) am Vertrag – Mitarbeiter/Admin (Controller
|
||||
// blockt Portal-Token zusätzlich explizit).
|
||||
router.get('/:contractId/credit-notes', authenticate, requirePermission('contracts:read'), creditNoteController.listByContract);
|
||||
router.get('/:contractId/credit-notes/defaults', authenticate, requirePermission('contracts:read'), creditNoteController.formDefaults);
|
||||
router.post('/:contractId/credit-notes', authenticate, requirePermission('contracts:update'), creditNoteController.create);
|
||||
|
||||
// Vertragsdokumente
|
||||
router.get('/:id/documents', authenticate, requirePermission('contracts:read'), contractController.getContractDocuments);
|
||||
router.post('/:id/documents', authenticate, requirePermission('contracts:update'), docUpload.single('file'), validateUploadedFile, contractController.uploadContractDocument);
|
||||
|
||||
@@ -4,9 +4,12 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Lesen für alle authentifizierten Benutzer
|
||||
router.get('/', authenticate, contractCategoryController.getContractCategories);
|
||||
router.get('/:id', authenticate, contractCategoryController.getContractCategory);
|
||||
// Lesen verlangt jetzt `contract-categories:read`, vorher genuegte
|
||||
// Angemeldetsein. Das Recht stand im Katalog und bewachte nichts; die
|
||||
// Migration vergibt es an jede Rolle, die die Anwendung benutzt, damit
|
||||
// niemand seine Listen verliert.
|
||||
router.get('/', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategories);
|
||||
router.get('/:id', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategory);
|
||||
|
||||
// Ändern/Löschen: `contract-categories:*` – wird per seed.ts an Admin-
|
||||
// Rollen vergeben. Vorher stand hier `developer:access` mit dem
|
||||
|
||||
@@ -22,6 +22,14 @@ router.get(
|
||||
contractTaskController.getTaskStats
|
||||
);
|
||||
|
||||
// Allgemeine Aufgabe OHNE Vertrag/Kunde anlegen (nur Mitarbeiter)
|
||||
router.post(
|
||||
'/tasks',
|
||||
authenticate,
|
||||
requirePermission('contracts:update'),
|
||||
contractTaskController.createGeneralTask
|
||||
);
|
||||
|
||||
// ==================== TASKS BY CONTRACT ====================
|
||||
|
||||
// Alle Aufgaben eines Vertrags abrufen (auch für Kundenportal, aber nur sichtbare)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Router } from 'express';
|
||||
import * as creditNoteController from '../controllers/creditNote.controller.js';
|
||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Gesamtübersicht aller Belege (Hauptmenü) – portal-scoped (nicht staff-only).
|
||||
// VOR /:id, damit die Wurzel nicht als ID interpretiert wird.
|
||||
router.get('/', authenticate, requirePermission('contracts:read'), creditNoteController.listAll);
|
||||
|
||||
// Nummernkreis-Verwaltung (Einstellungen). VOR /:id, damit "number-range"
|
||||
// nicht als ID interpretiert wird.
|
||||
router.get('/number-range', authenticate, requirePermission('settings:read'), creditNoteController.getNumberRange);
|
||||
router.put('/number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateNumberRange);
|
||||
router.get('/delivery-note-number-range', authenticate, requirePermission('settings:read'), creditNoteController.getDeliveryNoteNumberRange);
|
||||
router.put('/delivery-note-number-range', authenticate, requirePermission('settings:update'), creditNoteController.updateDeliveryNoteNumberRange);
|
||||
|
||||
// Einzelne Gutschrift
|
||||
router.post('/:id/pdf', authenticate, requirePermission('contracts:update'), creditNoteController.generatePdf);
|
||||
router.get('/:id', authenticate, requirePermission('contracts:read'), creditNoteController.getOne);
|
||||
router.put('/:id', authenticate, requirePermission('contracts:update'), creditNoteController.update);
|
||||
router.delete('/:id', authenticate, requirePermission('contracts:delete'), creditNoteController.remove);
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import * as customerController from '../controllers/customer.controller.js';
|
||||
import * as customerReferralController from '../controllers/customerReferral.controller.js';
|
||||
import * as stressfreiEmailController from '../controllers/stressfreiEmail.controller.js';
|
||||
import { authenticate, requirePermission, requireCustomerAccess } from '../middleware/auth.js';
|
||||
|
||||
@@ -53,4 +54,13 @@ router.post('/:customerId/representatives', authenticate, requirePermission('cus
|
||||
router.delete('/:customerId/representatives/:representativeId', authenticate, requirePermission('customers:update'), customerController.removeRepresentative);
|
||||
router.get('/:customerId/representatives/search', authenticate, requirePermission('customers:read'), customerController.searchForRepresentative);
|
||||
|
||||
// Werbung ("Geworben / angeworben") – nur Mitarbeiter/Admins (Controller
|
||||
// blockt Portal zusätzlich explizit). /search VOR der Liste ist unkritisch,
|
||||
// da unterschiedliche Pfade, aber der Klarheit halber gruppiert.
|
||||
router.get('/:customerId/referrals/search', authenticate, requirePermission('customers:read'), customerReferralController.searchCustomers);
|
||||
router.get('/:customerId/referrals', authenticate, requirePermission('customers:read'), customerReferralController.getReferrals);
|
||||
router.post('/:customerId/referrals', authenticate, requirePermission('customers:update'), customerReferralController.createReferral);
|
||||
router.put('/:customerId/referrals/:referralId', authenticate, requirePermission('customers:update'), customerReferralController.updateReferral);
|
||||
router.delete('/:customerId/referrals/:referralId', authenticate, requirePermission('customers:update'), customerReferralController.deleteReferral);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user