Compare commits
91
Commits
a28c8355fd
..
main
+5
-1
@@ -12,7 +12,7 @@ backend/dist
|
|||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
*.md
|
*.md
|
||||||
!docker/README.md
|
|
||||||
|
|
||||||
# Environment files (use Docker environment instead)
|
# Environment files (use Docker environment instead)
|
||||||
.env
|
.env
|
||||||
@@ -37,6 +37,10 @@ Thumbs.db
|
|||||||
__tests__
|
__tests__
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
# Pentester-/Dev-Helferscripte gehören NICHT ins Prod-Image (nur lokal/Staging
|
||||||
|
# via Repo-Checkout ausführen). Erzeugen sonst nur toten Ballast im Container.
|
||||||
|
backend/scripts/seed-magic-byte-test.ts
|
||||||
|
|
||||||
# Docker files in wrong location
|
# Docker files in wrong location
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,51 @@ JWT_REFRESH_EXPIRES_IN=7d
|
|||||||
# Generieren: openssl rand -hex 32
|
# Generieren: openssl rand -hex 32
|
||||||
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
|
ENCRYPTION_KEY=change-this-to-64-hex-characters-please-rotate-before-production-xx
|
||||||
|
|
||||||
|
# ---------- Optionaler Reverse-Proxy (Caddy) ----------
|
||||||
|
# Brauchst du nur, wenn du KEINEN eigenen Reverse-Proxy hast (nginx, Nginx
|
||||||
|
# Proxy Manager, Traefik) und Caddy das SSL übernehmen soll.
|
||||||
|
#
|
||||||
|
# EIN/AUS über diesen Schalter – nichts auskommentieren:
|
||||||
|
# leer -> Caddy AUS, wird nicht einmal angelegt ← Standard
|
||||||
|
# caddy -> Caddy startet bei jedem `docker-compose up -d` mit
|
||||||
|
# Der Schalter wirkt auch bei `down`, `logs` usw. – im Gegensatz zum
|
||||||
|
# Kommandozeilen-Flag `--profile caddy`, das man leicht vergisst.
|
||||||
|
COMPOSE_PROFILES= # leer lassen = Caddy aus
|
||||||
|
|
||||||
|
# Die folgenden drei Werte werden NUR gebraucht, wenn oben `caddy` steht.
|
||||||
|
# Ohne Caddy kannst du sie ignorieren.
|
||||||
|
DOMAIN=crm.example.com
|
||||||
|
CADDY_DIR=./data/caddy
|
||||||
|
CADDY_CONFIG_DIR=./data/caddy-config
|
||||||
|
|
||||||
|
# ---------- Siegel für das Audit-Log ----------
|
||||||
|
# OpenCRM protokolliert, wer wann welche Daten gesehen oder geändert hat – die
|
||||||
|
# Beweisspur bei Streit oder Datenschutz-Nachfragen. Dieser Schlüssel versieht
|
||||||
|
# jeden neuen Eintrag mit einem Siegel, das sich nur damit erzeugen lässt. Er
|
||||||
|
# liegt AUSSERHALB der Datenbank; wer nur die Datenbank in die Hände bekommt,
|
||||||
|
# kann damit nichts fälschen, ohne dass es auffliegt.
|
||||||
|
#
|
||||||
|
# Generieren: openssl rand -hex 32 (pro Umgebung ein eigener Wert)
|
||||||
|
#
|
||||||
|
# Ohne Wert läuft alles normal weiter, nur eben ungesiegelt – nichts fällt aus.
|
||||||
|
# Nachträglich setzen ist jederzeit möglich: bestehende Einträge bleiben gültig,
|
||||||
|
# ab dem Neustart werden neue gesiegelt (keine Fehlalarme dadurch).
|
||||||
|
#
|
||||||
|
# ACHTUNG – einmal gesetzt, nicht wieder entfernen: Einträge, die ohne Schlüssel
|
||||||
|
# entstehen, werden beanstandet, sobald er zurück ist (ein ungesiegelter Eintrag
|
||||||
|
# inmitten gesiegelter ist von einer Fälschung nicht zu unterscheiden).
|
||||||
|
# Schlüssel sichern wie ein Passwort – geht er verloren, sind die damit
|
||||||
|
# gesiegelten Einträge dauerhaft nicht mehr prüfbar.
|
||||||
|
# Ausführlich erklärt in der README, Abschnitt "Audit-Siegel".
|
||||||
|
AUDIT_HMAC_KEY=
|
||||||
|
|
||||||
|
# Nur beim Schlüsselwechsel füllen: die FRÜHEREN Schlüssel, kommagetrennt und
|
||||||
|
# jüngster zuerst. Damit bleiben die bisherigen Einträge prüfbar, während neue
|
||||||
|
# schon mit dem neuen Schlüssel gesiegelt werden. Nicht voreilig leeren – sonst
|
||||||
|
# werden die alten Einträge als "manipuliert" gemeldet.
|
||||||
|
# Beispiel: AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>
|
||||||
|
AUDIT_HMAC_KEY_OLD=
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
|
PORT=3001 # Backend-internal Port (Dev: localhost:3001)
|
||||||
|
|||||||
+12
@@ -36,6 +36,18 @@ data/factory-defaults/*
|
|||||||
data/backups/*
|
data/backups/*
|
||||||
!data/backups/.gitkeep
|
!data/backups/.gitkeep
|
||||||
|
|
||||||
|
# Gegenbuch (laeuft auf eigener Maschine): enthaelt das Buch selbst, den
|
||||||
|
# Signaturschluessel und den Beobachtungsspeicher. Nichts davon gehoert ins
|
||||||
|
# Repository - der Schluessel schon gar nicht.
|
||||||
|
tools/audit-notary/data/*
|
||||||
|
!tools/audit-notary/data/.gitkeep
|
||||||
|
!tools/audit-notary/data/prod/
|
||||||
|
tools/audit-notary/data/prod/*
|
||||||
|
!tools/audit-notary/data/prod/.gitkeep
|
||||||
|
!tools/audit-notary/data/staging/
|
||||||
|
tools/audit-notary/data/staging/*
|
||||||
|
!tools/audit-notary/data/staging/.gitkeep
|
||||||
|
|
||||||
# Factory-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
|
# Factory-Defaults-Drop-Box (Export-ZIPs zwischen dev/prod hin und her)
|
||||||
factory-exports/*
|
factory-exports/*
|
||||||
!factory-exports/.gitkeep
|
!factory-exports/.gitkeep
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# OpenCRM Caddyfile
|
||||||
|
# ===================
|
||||||
|
# Wird NUR gebraucht, wenn du den optionalen Caddy-Dienst startest:
|
||||||
|
# docker-compose --profile caddy up -d
|
||||||
|
# Caddy holt und erneuert dann das SSL-Zertifikat automatisch fuer ${DOMAIN}.
|
||||||
|
# Betreibst du bereits einen eigenen Reverse-Proxy (nginx, NPM, Traefik),
|
||||||
|
# brauchst du diese Datei nicht - dann laeuft alles ueber deinen Proxy.
|
||||||
|
|
||||||
|
{$DOMAIN:localhost} {
|
||||||
|
# Reverse proxy to OpenCRM app
|
||||||
|
reverse_proxy opencrm:3001
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
# Clickjacking protection
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
# XSS protection
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
# Referrer policy
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gzip compression – NUR fuer statische Assets, NICHT fuer /api/*.
|
||||||
|
# Komprimierte API-Antworten ermoeglichen die BREACH-Attacke
|
||||||
|
# (CVE-2013-3587): aus der Antwortgroesse liessen sich unter bestimmten
|
||||||
|
# Bedingungen Geheimnisse aus JSON-Responses ableiten. Statische Dateien
|
||||||
|
# sind davon nicht betroffen und bleiben komprimiert.
|
||||||
|
@nicht_api not path /api/*
|
||||||
|
encode @nicht_api gzip
|
||||||
|
|
||||||
|
# Handle file uploads (increase body limit)
|
||||||
|
request_body {
|
||||||
|
max_size 50MB
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,6 +229,42 @@ Nach dem Seed sind folgende Zugangsdaten verfügbar:
|
|||||||
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren –
|
> **Wichtig:** Vor dem ersten Production-Deployment Secrets rotieren –
|
||||||
> siehe [Production-Deployment](#production-deployment).
|
> siehe [Production-Deployment](#production-deployment).
|
||||||
|
|
||||||
|
### Zugangsdaten auf bestehender DB neu setzen (Reseed)
|
||||||
|
|
||||||
|
Nützlich z.B. auf **Staging/Test nach einem DB-Reset**, wenn Admin-/Portal-
|
||||||
|
Passwörter randomisiert sind und man wieder **bekannte** Zugangsdaten braucht
|
||||||
|
(etwa für einen Pentester).
|
||||||
|
|
||||||
|
**Der Seed ist datenerhaltend:** Er `upsert`et nur die Rollen und den
|
||||||
|
Admin-User (`admin@admin.com`) und synchronisiert die Rollen-Berechtigungen.
|
||||||
|
**Kundendaten, Verträge, Bankkarten usw. werden NICHT angefasst** (das einzige
|
||||||
|
`deleteMany` betrifft ausschließlich Rollen↔Permission-Zuordnungen). Ein
|
||||||
|
erneuter Seed setzt also faktisch nur das Admin-Passwort neu.
|
||||||
|
|
||||||
|
**Admin-Passwort deterministisch neu setzen:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) In der .env / docker-compose environment setzen:
|
||||||
|
SEED_ADMIN_PASSWORD=<ein dir bekanntes Passwort, MIND. 25 Zeichen>
|
||||||
|
RUN_SEED=true # erzwingt den Seed auch bei nicht-leerer DB
|
||||||
|
|
||||||
|
# 2) Container neu starten – der Entrypoint seedet erneut (upsert):
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 3) Login: admin@admin.com / <SEED_ADMIN_PASSWORD>
|
||||||
|
# 4) Danach RUN_SEED wieder auf false (sonst re-seedet jeder Restart – harmlos,
|
||||||
|
# aber unnötig). SEED_ADMIN_PASSWORD kann gesetzt bleiben.
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `SEED_ADMIN_PASSWORD` **muss ≥ 25 Zeichen** sein, sonst wird es ignoriert
|
||||||
|
> (Passwort-Policy) und der Seed generiert wieder ein Zufallspasswort (→ Logs,
|
||||||
|
> siehe oben).
|
||||||
|
|
||||||
|
**Portal-Login (Kunde):** Portal-Passwörter werden **nicht** vom Seed gesetzt.
|
||||||
|
Nach dem Admin-Login lässt sich in der Kundenakte für einen (Test-)Kunden das
|
||||||
|
Portal-Passwort im UI **anzeigen/neu setzen** – damit hat man wieder einen
|
||||||
|
bekannten Portal-Zugang für Scoping-Tests.
|
||||||
|
|
||||||
## Production-Deployment
|
## Production-Deployment
|
||||||
|
|
||||||
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
|
Vor dem öffentlichen Schalten der Instanz muss in der Production-`.env`:
|
||||||
@@ -245,6 +281,10 @@ LISTEN_ADDR=127.0.0.1
|
|||||||
|
|
||||||
# Bei separatem Frontend-Host: erlaubte Origins
|
# Bei separatem Frontend-Host: erlaubte Origins
|
||||||
CORS_ORIGINS=https://crm.deine-domain.de
|
CORS_ORIGINS=https://crm.deine-domain.de
|
||||||
|
|
||||||
|
# Siegel fuer das Audit-Log – schuetzt die Beweisspur vor nachtraeglicher
|
||||||
|
# Faelschung. Siehe Abschnitt "Audit-Siegel" weiter unten.
|
||||||
|
AUDIT_HMAC_KEY=$(openssl rand -hex 32)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deployment-Modus: On-Prem vs. Cloud
|
### Deployment-Modus: On-Prem vs. Cloud
|
||||||
@@ -262,6 +302,279 @@ SSRF-Schalter:
|
|||||||
SSRF_BLOCK_PRIVATE_IPS=true
|
SSRF_BLOCK_PRIVATE_IPS=true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Audit-Siegel (`AUDIT_HMAC_KEY`)
|
||||||
|
|
||||||
|
**Worum geht es?**
|
||||||
|
OpenCRM führt ein Audit-Log: eine lückenlose Aufzeichnung, wer wann welche
|
||||||
|
Daten gesehen oder geändert hat. Das ist die Beweisspur, wenn es Streit gibt,
|
||||||
|
etwas verschwindet oder der Datenschutz nachfragt.
|
||||||
|
|
||||||
|
Damit diese Aufzeichnung etwas wert ist, muss man ihr ansehen können, ob
|
||||||
|
jemand nachträglich daran herumgeschrieben hat. Dafür trägt jeder Eintrag
|
||||||
|
einen Fingerabdruck, der auch den Fingerabdruck des vorherigen Eintrags
|
||||||
|
enthält – wie eine Kette. Ändert jemand einen alten Eintrag, passen die
|
||||||
|
Fingerabdrücke nicht mehr und es fällt auf.
|
||||||
|
|
||||||
|
**Und wozu dann noch ein Schlüssel?**
|
||||||
|
Weil jemand mit Zugriff auf die Datenbank die ganze Kette neu berechnen
|
||||||
|
könnte. Er ändert also einen Eintrag – zum Beispiel einen fehlgeschlagenen
|
||||||
|
Login-Versuch in einen erfolgreichen – und zieht anschließend alle
|
||||||
|
Fingerabdrücke glatt. Danach sieht die Fälschung echt aus.
|
||||||
|
|
||||||
|
Mit `AUDIT_HMAC_KEY` bekommt jeder Eintrag zusätzlich ein **Siegel**, das sich
|
||||||
|
nur mit diesem Schlüssel erzeugen lässt. Der Schlüssel liegt in der
|
||||||
|
`.env`-Datei, also **außerhalb der Datenbank**. Wer nur die Datenbank in die
|
||||||
|
Hände bekommt, kann damit nichts fälschen, ohne dass es auffliegt.
|
||||||
|
|
||||||
|
> Bildlich: Die Fingerabdruck-Kette ist die fortlaufende Nummerierung der
|
||||||
|
> Aktenseiten. Der Schlüssel ist der Stempel des Notars. Die Akte steht zwar
|
||||||
|
> im Schrank – aber den Stempel hat nur der Notar.
|
||||||
|
|
||||||
|
**Einrichten**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Einmalig pro Umgebung einen Schlüssel erzeugen
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
**In welche Datei?** Das hängt davon ab, wie du OpenCRM betreibst:
|
||||||
|
|
||||||
|
| Betrieb | Datei |
|
||||||
|
|---|---|
|
||||||
|
| **Docker** (Standard) | **`.env` im Projektstamm** |
|
||||||
|
| Backend direkt, ohne Container (Entwicklung) | `backend/.env` |
|
||||||
|
|
||||||
|
Beim Docker-Betrieb reicht der Eintrag in der Stamm-`.env` – `docker-compose.yml`
|
||||||
|
reicht die Variable an den Container weiter. Ein Eintrag in `backend/.env` wirkt
|
||||||
|
dort **nicht**, weil der Container diese Datei nicht liest.
|
||||||
|
|
||||||
|
Wichtig: **pro Umgebung ein eigener Schlüssel** (Entwicklung, Test, Produktion)
|
||||||
|
– und niemals ins Git-Repository.
|
||||||
|
|
||||||
|
**Bestehende Installation: bisher kein Schlüssel gesetzt?**
|
||||||
|
|
||||||
|
Genau so ist es gedacht – der Schlüssel kam mit einem Update dazu. Du kannst
|
||||||
|
ihn jederzeit nachträglich setzen:
|
||||||
|
|
||||||
|
1. Schlüssel erzeugen (`openssl rand -hex 32`), in die `.env` eintragen
|
||||||
|
2. Backend neu starten
|
||||||
|
3. Fertig – mehr ist nicht nötig
|
||||||
|
|
||||||
|
Was dabei mit deinen **bereits vorhandenen** Einträgen passiert:
|
||||||
|
|
||||||
|
- Sie bleiben **unverändert** und weiterhin gültig. Es wird nichts neu
|
||||||
|
berechnet und nichts nachträglich gesiegelt.
|
||||||
|
- Ab dem Neustart werden **neue** Einträge gesiegelt. Alt und neu liegen
|
||||||
|
friedlich nebeneinander, die Prüfung erkennt für jeden Eintrag automatisch,
|
||||||
|
nach welchem Verfahren er zu prüfen ist.
|
||||||
|
- Es gibt **keine** Fehlalarme dadurch. (Nachgemessen auf einer Installation
|
||||||
|
mit 4903 alten, 57 mittleren und 67 gesiegelten Einträgen: 0 Beanstandungen.)
|
||||||
|
|
||||||
|
Rückwirkend lässt sich der Schutz nicht herstellen – ältere Einträge bleiben
|
||||||
|
ungesiegelt. Das ist kein Fehler, sondern liegt in der Natur der Sache: Ein
|
||||||
|
Siegel kann man nur beim Anlegen vergeben, nicht nachträglich. Je früher du
|
||||||
|
den Schlüssel setzt, desto größer der geschützte Zeitraum.
|
||||||
|
|
||||||
|
> ⚠️ **Einmal gesetzt, nicht wieder abschalten.** Fehlt der Schlüssel später
|
||||||
|
> (z. B. weil er beim Container-Rebuild aus der `.env` verlorenging), läuft die
|
||||||
|
> Anwendung normal weiter – aber die in dieser Zeit entstandenen Einträge sind
|
||||||
|
> ungesiegelt und werden von der Prüfung **beanstandet**, sobald der Schlüssel
|
||||||
|
> wieder da ist. Das ist Absicht: Ein ungesiegelter Eintrag inmitten
|
||||||
|
> gesiegelter ist von einer Fälschung nicht zu unterscheiden. Nimm den
|
||||||
|
> Schlüssel deshalb in deine Deploy-Checkliste auf.
|
||||||
|
|
||||||
|
Ein noch nicht ersetzter Platzhalter (`<hier-eigenen-wert-eintragen>`) zählt
|
||||||
|
bewusst **nicht** als Schlüssel – sonst würde mit einem öffentlich bekannten
|
||||||
|
Wert gesiegelt. Das Backend schreibt in diesem Fall eine Warnung ins Log und
|
||||||
|
lässt das Siegel aus.
|
||||||
|
|
||||||
|
**Häufige Fragen**
|
||||||
|
|
||||||
|
| Frage | Antwort |
|
||||||
|
|---|---|
|
||||||
|
| Was, wenn ich den Schlüssel gar nicht setze? | Nichts fällt aus. Das Audit-Log läuft normal weiter, nur ohne dieses zusätzliche Siegel. |
|
||||||
|
| Was, wenn ich ihn verliere? | Lässt du das Feld dann **leer**, gelten die gesiegelten Einträge als **„nicht prüfbar"** – ausdrücklich nicht als gefälscht, also kein Fehlalarm. Der Nachweis für diesen Zeitraum ist aber weg. Deshalb: sichern wie ein Passwort. |
|
||||||
|
| Und wenn ich stattdessen einen **neuen** Schlüssel eintrage? | Dann werden die alten Einträge als **„manipuliert" gemeldet** – das System kann einen falschen Schlüssel nicht von einer echten Fälschung unterscheiden. Trage den alten Schlüssel deshalb zusätzlich unter `AUDIT_HMAC_KEY_OLD` ein (siehe unten). |
|
||||||
|
| Muss ich ihn irgendwo eintragen außer in der `.env`? | Nein. Einmal setzen, Backup anlegen, fertig. |
|
||||||
|
| Verlangsamt das etwas? | Nein, spürbar nicht. |
|
||||||
|
|
||||||
|
**Wichtig bei einer bestehenden Installation: den Altbestand versiegeln**
|
||||||
|
|
||||||
|
Das Siegel gilt nur für Einträge, die **ab** dem Setzen des Schlüssels
|
||||||
|
geschrieben werden. Alles, was vorher im Protokoll steht, bleibt ungeschützt –
|
||||||
|
Änderungen daran wären nicht erkennbar. Die Integritätsprüfung sagt das auch:
|
||||||
|
|
||||||
|
Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären
|
||||||
|
nicht erkennbar. Behebbar mit POST /api/audit-logs/seal-backlog
|
||||||
|
{"confirm":"SEAL"}.
|
||||||
|
|
||||||
|
Das ist ein **einmaliger** Schritt: Er zieht ein Siegel über den vorhandenen
|
||||||
|
Bestand, sodass spätere Änderungen daran auffallen. Der Ablauf – wer es darf
|
||||||
|
(Haken **„Audit-Betrieb"** in der Benutzerverwaltung), was vorher zu prüfen ist
|
||||||
|
und wie man das Ergebnis gegenprüft – steht Schritt für Schritt unter
|
||||||
|
**[Den Altbestand versiegeln](tools/audit-notary/README.md#den-altbestand-versiegeln-einmalig-im-crm)**.
|
||||||
|
Die Anleitung liegt beim Gegenbuch, gilt aber **auch ohne** – es ist ein
|
||||||
|
Vorgang im CRM.
|
||||||
|
|
||||||
|
Den Zustand siehst du jederzeit unter **Einstellungen → Audit-Protokoll**, ganz
|
||||||
|
oben.
|
||||||
|
|
||||||
|
**Schlüssel wechseln (`AUDIT_HMAC_KEY_OLD`)**
|
||||||
|
|
||||||
|
Möchtest du den Schlüssel austauschen – etwa weil du vermutest, dass er in
|
||||||
|
falsche Hände geraten ist – geht das ohne Datenverlust:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUDIT_HMAC_KEY=<neuer Schlüssel>
|
||||||
|
AUDIT_HMAC_KEY_OLD=<bisheriger Schlüssel>
|
||||||
|
```
|
||||||
|
|
||||||
|
Neue Einträge werden ab sofort mit dem neuen Schlüssel gesiegelt, die
|
||||||
|
bisherigen bleiben über den alten Schlüssel weiterhin prüfbar.
|
||||||
|
|
||||||
|
> ⚠️ **`AUDIT_HMAC_KEY_OLD` nicht voreilig leeren.** Solange Einträge
|
||||||
|
> existieren, die mit einem alten Schlüssel gesiegelt wurden, muss dieser dort
|
||||||
|
> stehen bleiben. Entfernst du ihn, werden diese Einträge als **„manipuliert"
|
||||||
|
> gemeldet** – nicht als „nicht prüfbar". Das System kann einen falschen
|
||||||
|
> Schlüssel nicht von einer echten Fälschung unterscheiden. Leeren kannst du
|
||||||
|
> das Feld gefahrlos erst, wenn die betroffenen Einträge durch die
|
||||||
|
> Aufbewahrungsfristen ohnehin gelöscht sind.
|
||||||
|
|
||||||
|
**Mehrfach gewechselt?** Das Feld nimmt mehrere Schlüssel kommagetrennt auf –
|
||||||
|
vom jüngsten zum ältesten:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUDIT_HMAC_KEY=<aktueller Schlüssel>
|
||||||
|
AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>,<ganz alter>
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne das würde beim zweiten Wechsel der zuerst genutzte Schlüssel verloren
|
||||||
|
gehen – und alle damit gesiegelten Einträge fälschlich als manipuliert
|
||||||
|
erscheinen.
|
||||||
|
|
||||||
|
**Prüfen, ob alles in Ordnung ist**
|
||||||
|
|
||||||
|
Die Prüfung läuft derzeit nur über die API (eine Schaltfläche in der Oberfläche
|
||||||
|
gibt es dafür noch nicht) – als angemeldeter Benutzer mit dem Recht
|
||||||
|
`audit:read`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://crm.deine-domain.de/api/audit-logs/verify \
|
||||||
|
-H "Authorization: Bearer <Access-Token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Antwort enthält einen Klartext-Satz im Feld `message` und unterscheidet
|
||||||
|
zwei Dinge:
|
||||||
|
|
||||||
|
- **„Manipulierte Einträge"** – jemand hat einen bestehenden Eintrag
|
||||||
|
nachträglich verändert. Das ist ernst.
|
||||||
|
- **„Strukturelle Lücken"** – die Kette hat eine Unterbrechung, die Inhalte
|
||||||
|
sind aber unverändert. Meist harmlos (z. B. gelöschte alte Einträge).
|
||||||
|
Notiere dir die Zahl nach dem ersten Deploy: Bleibt sie konstant, ist alles
|
||||||
|
in Ordnung. Steigt sie, lohnt ein Blick.
|
||||||
|
|
||||||
|
### Gegenbuch – optionaler Zusatzschutz auf zweitem Rechner
|
||||||
|
|
||||||
|
Das Audit-Siegel schützt die Einträge **innerhalb** der Anwendung. Es liegt aber
|
||||||
|
in derselben Datenbank, die es absichert: Wer vollen Zugriff auf den Server hat,
|
||||||
|
kommt am Ende auch an das Siegel.
|
||||||
|
|
||||||
|
Dagegen gibt es das **Gegenbuch**. Ein zweiter Rechner holt regelmäßig einen
|
||||||
|
kurzen Kontrollwert vom CRM und schreibt ihn mit. Wird später im CRM etwas
|
||||||
|
nachträglich verändert oder gelöscht, widerspricht das dem Gegenbuch und fällt
|
||||||
|
beim nächsten Durchlauf auf.
|
||||||
|
|
||||||
|
Die Richtung ist dabei entscheidend:
|
||||||
|
|
||||||
|
```
|
||||||
|
Gegenbuch ──holt lesend──> OpenCRM (HTTPS, Konto nur mit audit:read)
|
||||||
|
OpenCRM ─────────────────> (kennt das Gegenbuch nicht)
|
||||||
|
```
|
||||||
|
|
||||||
|
Wer OpenCRM übernimmt, kommt damit nicht an das Gegenbuch. Das CRM braucht
|
||||||
|
dafür **keinerlei Konfiguration** – es liefert nur einen lesbaren Prüfwert, der
|
||||||
|
keine Geheimnisse enthält.
|
||||||
|
|
||||||
|
**Einrichten** (auf einem anderen Rechner als dem CRM):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <dieses Repository> opencrm
|
||||||
|
cd opencrm/tools/audit-notary
|
||||||
|
cp .env.example .env # CRM-Adresse und Dienstkonto eintragen
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vorher im CRM anlegen:** eine Rolle mit ausschließlich dem Recht
|
||||||
|
`audit:read` und einen Benutzer damit. Das Gegenbuch meldet sich mit diesem
|
||||||
|
Konto bei jedem Durchlauf selbst an – ein dauerhaftes Token gibt es nicht, weil
|
||||||
|
Zugangstoken nach 15 Minuten ablaufen.
|
||||||
|
|
||||||
|
Zwei Bücher auf einer Maschine – etwa für Produktion und Test – sind
|
||||||
|
vorgesehen. Alles Weitere in
|
||||||
|
[tools/audit-notary/README.md](tools/audit-notary/README.md).
|
||||||
|
|
||||||
|
> **Optional.** Ohne Gegenbuch bleibt der Schutz innerhalb der Anwendung
|
||||||
|
> vollständig erhalten. Es deckt zusätzlich den Fall ab, dass jemand den
|
||||||
|
> CRM-Server selbst übernimmt.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Technische Details</b> (für Entwickler/Admins)</summary>
|
||||||
|
|
||||||
|
**Verfahren.** Jeder Audit-Eintrag trägt einen Hash über seine Inhaltsspalten
|
||||||
|
plus den Hash des Vorgängers (`previousHash`) – daraus entsteht die Kette. Es
|
||||||
|
gibt drei Hash-Versionen, die Spalte `hashVersion` hält fest, welche verwendet
|
||||||
|
wurde:
|
||||||
|
|
||||||
|
| Version | Verfahren | Abdeckung |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | SHA-256 | 7 Felder – Altbestand vor der Härtung |
|
||||||
|
| 2 | SHA-256 | alle 24 Inhaltsspalten, unsigniert |
|
||||||
|
| 3 | **HMAC-SHA256** mit `AUDIT_HMAC_KEY` | alle 24 Inhaltsspalten, signiert |
|
||||||
|
|
||||||
|
Bestandsdaten werden **nicht** nachträglich neu berechnet – ein Rehash würde die
|
||||||
|
Beweiskraft der Vergangenheit überschreiben. Alte Einträge bleiben mit ihrer
|
||||||
|
Version gültig.
|
||||||
|
|
||||||
|
**Version-Floor.** Die zu erwartende Version wird aus der Kette abgeleitet
|
||||||
|
(erste je mit Version *n* geschriebene Zeile), **nicht** aus der Selbstauskunft
|
||||||
|
der Zeile. Sonst ließe sich per Downgrade (`hashVersion` 3 → 1) die schwächere
|
||||||
|
Prüfung erzwingen und anschließend ein gültiger Hash über die wenigen
|
||||||
|
abgedeckten Felder nachziehen. Zusätzlich gilt: Eine unerklärte Lücke direkt vor
|
||||||
|
einer signierten Zeile ist ein Befund, kein struktureller Zufall – deren
|
||||||
|
`previousHash` lässt sich ohne Schlüssel nicht fälschen.
|
||||||
|
|
||||||
|
**Löschungen.** `POST /api/audit-logs/cleanup` schreibt ein Manifest
|
||||||
|
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag. Die
|
||||||
|
Prüfung liest es aus und meldet nur Lücken **ohne** dokumentierte Löschung als
|
||||||
|
erklärungsbedürftig (`unexplainedGaps`) – sonst könnte sich eine böswillige
|
||||||
|
Löschung als harmlose Lücke tarnen.
|
||||||
|
|
||||||
|
**Rückgabe von `POST /api/audit-logs/verify`:**
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `tamperedEntries` | Inhalt nachträglich verändert, Versions-Downgrade oder gebrochener Anker – **ernst** |
|
||||||
|
| `chainGaps` | Verkettung unterbrochen (alle) |
|
||||||
|
| `unexplainedGaps` | Teilmenge davon ohne dokumentierte Löschung |
|
||||||
|
| `unverifiableEntries` | signiert, aber kein Schlüssel konfiguriert – **kein** Manipulationsverdacht |
|
||||||
|
|
||||||
|
**Fail-safe.** Ohne `AUDIT_HMAC_KEY` schreibt der Dienst weiter Version 2; das
|
||||||
|
Logging fällt nie wegen fehlender Konfiguration aus.
|
||||||
|
|
||||||
|
**Bekannte Grenze.** Der Anker schützt gegen reinen Datenbank-Schreibzugriff.
|
||||||
|
Wer Schlüssel *und* Datenbank kontrolliert, kann die Kette konsistent neu
|
||||||
|
rechnen. Eine Off-Site-Notarisierung (regelmäßiger Export/Versiegelung des
|
||||||
|
Kettenkopfes außerhalb des Systems) wäre die nächste Stufe.
|
||||||
|
|
||||||
|
**Achtung – zwei Endpunkte mit Nebenwirkung:**
|
||||||
|
`POST /api/audit-logs/rehash` berechnet alle Hashes neu; danach meldet die
|
||||||
|
Prüfung überall „gültig", aber eine bestehende Fälschung würde mitbesiegelt.
|
||||||
|
`POST /api/audit-logs/cleanup` löscht nach Aufbewahrungsregeln, jede gelöschte
|
||||||
|
Zeile erzeugt eine Lücke. Beide laufen nur manuell und schreiben einen Marker
|
||||||
|
ins Log.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
|
Cloud-Metadata-Endpoints (`169.254.169.254`, `metadata.google.internal` etc.)
|
||||||
sind UNABHÄNGIG vom Flag **immer** geblockt – das ist Mindestschutz gegen
|
sind UNABHÄNGIG vom Flag **immer** geblockt – das ist Mindestschutz gegen
|
||||||
AWS/GCP/Azure-IMDS-Diebstahl.
|
AWS/GCP/Azure-IMDS-Diebstahl.
|
||||||
@@ -568,39 +881,49 @@ docker-compose down -v # Container stoppen + Daten löschen
|
|||||||
docker-compose logs -f # Logs anzeigen
|
docker-compose logs -f # Logs anzeigen
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker (Produktion)
|
### Betrieb mit eigenem SSL (optionaler Caddy-Dienst)
|
||||||
|
|
||||||
Im `docker/` Verzeichnis liegt ein komplettes Produktions-Setup:
|
Es gibt **eine** `docker-compose.yml` und **eine** `.env` im Projektstamm.
|
||||||
|
Standardmässig bringt der Stack **keinen** Reverse-Proxy mit – die Annahme ist,
|
||||||
|
dass du bereits einen hast (nginx, Nginx Proxy Manager, Traefik) und OpenCRM
|
||||||
|
dahinter hängst.
|
||||||
|
|
||||||
|
Hast du **keinen**, kannst du Caddy als optionalen Dienst dazuschalten. Er holt
|
||||||
|
und erneuert das SSL-Zertifikat automatisch:
|
||||||
|
|
||||||
|
Dafür gibt es einen Schalter in der `.env` – nichts muss auskommentiert werden:
|
||||||
|
|
||||||
|
```env
|
||||||
|
COMPOSE_PROFILES=caddy # leer = kein Caddy (Standard)
|
||||||
|
DOMAIN=crm.example.de
|
||||||
|
HTTPS_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd docker
|
docker-compose up -d # startet jetzt zusätzlich Caddy
|
||||||
|
|
||||||
# Image bauen
|
|
||||||
docker-compose build
|
|
||||||
|
|
||||||
# Container starten
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Logs anzeigen
|
|
||||||
docker-compose logs -f app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Komponenten:**
|
Solange `COMPOSE_PROFILES` leer ist, wird der Caddy-Dienst **gar nicht erst
|
||||||
- **MariaDB 10.11**: Datenbank
|
angelegt** – der Stack verhält sich exakt wie ohne ihn. Der Schalter wirkt auch
|
||||||
- **App**: Backend + Frontend in einem Container
|
bei `down`, `logs` und `ps`, im Gegensatz zum Flag `--profile caddy`, das man
|
||||||
- **Caddy**: Reverse-Proxy mit automatischem SSL
|
bei Folgebefehlen leicht vergisst.
|
||||||
|
|
||||||
**Umgebungsvariablen (`docker/.env`):**
|
**Was dabei zu beachten ist:**
|
||||||
```env
|
|
||||||
MYSQL_ROOT_PASSWORD=sicheres-root-passwort
|
- Die Zertifikate liegen unter `./data/caddy`. Diesen Ordner **nicht löschen**,
|
||||||
MYSQL_DATABASE=opencrm
|
sonst werden bei jedem Start neue Zertifikate angefordert und du läufst in
|
||||||
MYSQL_USER=opencrm
|
die Mengenbegrenzung von Let's Encrypt.
|
||||||
MYSQL_PASSWORD=sicheres-passwort
|
- Die Domain muss vorher per DNS auf den Server zeigen, sonst schlägt die
|
||||||
JWT_SECRET=sehr-langer-zufaelliger-string
|
Zertifikatsausstellung fehl.
|
||||||
ENCRYPTION_KEY=64-zeichen-hex-string
|
- `HTTPS_ENABLED=true` setzen – das aktiviert das `Secure`-Attribut am
|
||||||
DOMAIN=crm.example.com
|
Refresh-Cookie und die korrekte Proxy-Erkennung.
|
||||||
RUN_SEED=true # Nur beim ersten Start
|
- Der direkte App-Port (`OPENCRM_PORT`, Standard 3010) bleibt weiterhin offen.
|
||||||
```
|
Wenn nur Caddy von aussen erreichbar sein soll, schliesse ihn per Firewall
|
||||||
|
oder setze ihn in der `.env` auf einen Wert, den du nicht freigibst.
|
||||||
|
|
||||||
|
Die Konfiguration liegt in der `Caddyfile` im Projektstamm. Sie liefert
|
||||||
|
Sicherheits-Header mit und komprimiert bewusst **nicht** die API-Antworten
|
||||||
|
(BREACH-Schutz, siehe Abschnitt zum Reverse-Proxy oben).
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
@@ -652,7 +975,8 @@ opencrm/
|
|||||||
│ │ ├── types/ # TypeScript-Typen
|
│ │ ├── types/ # TypeScript-Typen
|
||||||
│ │ └── App.tsx # Haupt-Komponente
|
│ │ └── App.tsx # Haupt-Komponente
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── docker/ # Docker-Deployment
|
├── Caddyfile # Optionaler Reverse-Proxy (nur mit --profile caddy)
|
||||||
|
├── tools/audit-notary/ # Gegenbuch – läuft auf einem zweiten Rechner
|
||||||
│ ├── Dockerfile # Multi-Stage Build
|
│ ├── Dockerfile # Multi-Stage Build
|
||||||
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
|
│ ├── docker-compose.yml # Produktion (MariaDB, App, Caddy)
|
||||||
│ ├── Caddyfile # Reverse-Proxy mit SSL
|
│ ├── Caddyfile # Reverse-Proxy mit SSL
|
||||||
|
|||||||
@@ -19,3 +19,110 @@ ENCRYPTION_KEY="32-byte-hex-key-for-aes-256-gcm"
|
|||||||
# Server
|
# Server
|
||||||
PORT=3001
|
PORT=3001
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# ==================== AUDIT-SIEGEL ====================
|
||||||
|
# Was ist das Audit-Log?
|
||||||
|
# OpenCRM protokolliert luekenlos, wer wann welche Daten gesehen oder
|
||||||
|
# geaendert hat. Das ist die Beweisspur, wenn es Streit gibt oder der
|
||||||
|
# Datenschutz nachfragt.
|
||||||
|
#
|
||||||
|
# Wogegen schuetzt dieser Schluessel?
|
||||||
|
# Ohne ihn koennte jemand mit Zugriff auf die Datenbank einen Eintrag
|
||||||
|
# nachtraeglich umschreiben - zum Beispiel einen fehlgeschlagenen
|
||||||
|
# Login-Versuch in einen erfolgreichen verwandeln - und die Faelschung so
|
||||||
|
# glattziehen, dass die Pruefung sie fuer echt haelt.
|
||||||
|
# Mit dem Schluessel bekommt jeder Eintrag ein Siegel, das sich nur mit
|
||||||
|
# diesem Schluessel erzeugen laesst. Wer ihn nicht hat, kann nichts
|
||||||
|
# faelschen, ohne dass es auffliegt. Bildlich: Der Schluessel ist der
|
||||||
|
# Stempel des Notars - die Akte liegt zwar im Schrank, aber den Stempel
|
||||||
|
# hat nur der Notar.
|
||||||
|
#
|
||||||
|
# Wie einrichten?
|
||||||
|
# Einmalig einen Schluessel erzeugen und hier eintragen:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
# Pro Umgebung ein EIGENER Schluessel (Entwicklung, Test, Produktion).
|
||||||
|
#
|
||||||
|
# Was passiert, wenn ich ihn weglasse?
|
||||||
|
# Nichts faellt aus. Das Audit-Log laeuft normal weiter, nur eben ohne
|
||||||
|
# dieses zusaetzliche Siegel.
|
||||||
|
#
|
||||||
|
# Ich habe bisher keinen Schluessel - kann ich ihn nachtraeglich setzen?
|
||||||
|
# Ja. Schluessel erzeugen, eintragen, Backend neu starten - fertig.
|
||||||
|
# Bestehende Eintraege bleiben unveraendert gueltig, ab dem Neustart
|
||||||
|
# werden neue gesiegelt. Alt und neu liegen nebeneinander, es gibt
|
||||||
|
# dadurch KEINE Fehlalarme. Rueckwirkend siegeln geht nicht - je frueher
|
||||||
|
# gesetzt, desto groesser der geschuetzte Zeitraum.
|
||||||
|
#
|
||||||
|
# ACHTUNG: einmal gesetzt, nicht wieder abschalten.
|
||||||
|
# Fehlt der Schluessel spaeter (z. B. beim Container-Rebuild verlorengegangen),
|
||||||
|
# laeuft alles weiter - aber die in dieser Zeit entstandenen Eintraege sind
|
||||||
|
# ungesiegelt und werden beanstandet, sobald der Schluessel wieder da ist.
|
||||||
|
# Ein ungesiegelter Eintrag inmitten gesiegelter ist von einer Faelschung
|
||||||
|
# nicht zu unterscheiden. Also: in die Deploy-Checkliste aufnehmen.
|
||||||
|
#
|
||||||
|
# Was passiert, wenn ich ihn verliere?
|
||||||
|
# Laesst du das Feld LEER, gelten die gesiegelten Eintraege als
|
||||||
|
# "nicht pruefbar" - NICHT als gefaelscht, also kein Fehlalarm. Der
|
||||||
|
# Nachweis fuer diesen Zeitraum ist aber weg.
|
||||||
|
# Traegst du stattdessen einen NEUEN Schluessel ein, ohne den alten unten
|
||||||
|
# zu hinterlegen, werden die alten Eintraege als "manipuliert" gemeldet -
|
||||||
|
# ein falscher Schluessel ist von einer Faelschung nicht zu unterscheiden.
|
||||||
|
# Deshalb: Schluessel sichern, so wie ein Passwort.
|
||||||
|
#
|
||||||
|
# Eigenen Wert erzeugen mit: openssl rand -hex 32
|
||||||
|
# Der Platzhalter unten zaehlt NICHT als Schluessel - laesst du ihn stehen,
|
||||||
|
# bleibt das Siegel aus (es wird also nicht versehentlich mit einem oeffentlich
|
||||||
|
# bekannten Wert gesiegelt). Leer lassen ist ebenfalls in Ordnung.
|
||||||
|
AUDIT_HMAC_KEY=<hier-eigenen-wert-eintragen>
|
||||||
|
|
||||||
|
# Frueher verwendete Schluessel - beim Wechsel hier eintragen.
|
||||||
|
# Moechtest du den Schluessel oben austauschen (z. B. weil du vermutest,
|
||||||
|
# dass er in falsche Haende geraten ist), trage den ALTEN Schluessel hier
|
||||||
|
# ein und den NEUEN oben. Dann bleiben die bisherigen Eintraege pruefbar,
|
||||||
|
# waehrend neue schon mit dem neuen Schluessel gesiegelt werden.
|
||||||
|
#
|
||||||
|
# ACHTUNG: Dieses Feld NICHT voreilig leeren. Solange Eintraege existieren,
|
||||||
|
# die mit einem alten Schluessel gesiegelt wurden, muessen sie hier stehen.
|
||||||
|
# Sonst werden diese Eintraege als "manipuliert" gemeldet (nicht als
|
||||||
|
# "nicht pruefbar"). Gefahrlos leeren kannst du erst, wenn die betroffenen
|
||||||
|
# Eintraege durch die Aufbewahrungsfristen ohnehin geloescht sind.
|
||||||
|
#
|
||||||
|
# Mehrfach gewechselt? Mehrere Schluessel kommagetrennt, juengster zuerst:
|
||||||
|
# AUDIT_HMAC_KEY_OLD=<vorheriger>,<davor>,<ganz alter>
|
||||||
|
#
|
||||||
|
# Beim ersten Einrichten leer lassen - erst beim Schluesselwechsel noetig.
|
||||||
|
AUDIT_HMAC_KEY_OLD=
|
||||||
|
|
||||||
|
# --- Technisch (fuer Entwickler/Admins) ---------------------------------
|
||||||
|
# Verfahren : HMAC-SHA256 ueber alle Inhaltsspalten eines Audit-Eintrags,
|
||||||
|
# inkl. previousHash (Verkettung). Entspricht Hash-Version 3.
|
||||||
|
# Abdeckung : Version 1 = 7 Felder (Altbestand), Version 2 = alle 24
|
||||||
|
# Inhaltsspalten (SHA-256, unsigniert), Version 3 = wie 2, aber
|
||||||
|
# HMAC-signiert. Die erwartete Version wird aus der Kette
|
||||||
|
# abgeleitet (Version-Floor), nicht aus der Selbstauskunft der
|
||||||
|
# Zeile - sonst liesse sich per Downgrade die schwaechere
|
||||||
|
# Pruefung erzwingen.
|
||||||
|
# Fail-safe : Ohne Schluessel schreibt der Dienst weiter Version 2. Bereits
|
||||||
|
# signierte Zeilen landen dann in `unverifiableEntries`,
|
||||||
|
# ausdruecklich NICHT in `tamperedEntries`.
|
||||||
|
# Rotation : AUDIT_HMAC_KEY_OLD (kommagetrennte Liste) wird bei der
|
||||||
|
# Pruefung zusaetzlich akzeptiert - Wechsel ohne Rehash. Ein
|
||||||
|
# falscher/fehlender Alt-Schluessel liefert einen HMAC-Mismatch
|
||||||
|
# und damit einen Manipulations-Befund; das ist nicht von einer
|
||||||
|
# echten Faelschung unterscheidbar und daher Absicht.
|
||||||
|
# Grenze : Schuetzt gegen DB-Schreibzugriff ohne Schluessel. Wer Schluessel
|
||||||
|
# UND Datenbank hat, kann die Kette konsistent neu rechnen.
|
||||||
|
# Erzeugung : openssl rand -hex 32 (256 Bit)
|
||||||
|
|
||||||
|
# ==================== DIENSTKONTO-WACHHUND ====================
|
||||||
|
# Nach wie vielen Minuten Stille eines Dienstkontos (z. B. des Gegenbuchs)
|
||||||
|
# ein Sicherheitsereignis erzeugt wird.
|
||||||
|
#
|
||||||
|
# Hintergrund: Die planmaessigen Anmeldungen selbst sind bewusst als Routine
|
||||||
|
# eingestuft, damit sie die CRITICAL-Stufe nicht entwerten. Das Signal ist
|
||||||
|
# deshalb die ABWESENHEIT: Wer das Gegenbuch stilllegt, setzt darauf, dass
|
||||||
|
# Stille nicht auffaellt.
|
||||||
|
#
|
||||||
|
# Faustregel: etwa das Dreifache des Gegenbuch-Takts. Bei stuendlichem Takt
|
||||||
|
# also 180. Ohne Dienstkonten im System passiert nichts.
|
||||||
|
SERVICE_ACCOUNT_MAX_SILENCE_MINUTES=180
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Kartennummer (Debit-/Kontokarte) als optionales Feld an der Bankkarte.
|
||||||
|
ALTER TABLE `BankCard` ADD COLUMN IF NOT EXISTS `cardNumber` VARCHAR(191) NULL;
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
-- Neuer Vertragsstatus CANCELLED_CONFIRMED ("Gekündigt / bestätigt"):
|
||||||
|
-- gesetzt, sobald eine Kündigungsbestätigung (Datum/Dokument) vorliegt.
|
||||||
|
-- CANCELLED bleibt "Gekündigt / Bestätigung abwarten" (Kündigungsschreiben raus).
|
||||||
|
-- MODIFY COLUMN ist idempotent (erneutes Ausführen ergibt denselben Zustand).
|
||||||
|
ALTER TABLE `Contract` MODIFY COLUMN `status`
|
||||||
|
ENUM('DRAFT','PENDING','ACTIVE','CANCELLED','CANCELLED_CONFIRMED','EXPIRED','DEACTIVATED','ONGOING')
|
||||||
|
NOT NULL DEFAULT 'DRAFT';
|
||||||
|
|
||||||
|
-- Daten-Migration: bestehende CANCELLED-Verträge auf CANCELLED_CONFIRMED heben.
|
||||||
|
-- Die alte Logik setzte CANCELLED NUR, wenn eine Kündigungsbestätigung
|
||||||
|
-- (Datum ODER Dokument) vorlag – diese Verträge sind also bereits bestätigt.
|
||||||
|
-- CANCELLED bedeutet ab jetzt "Bestätigung abwarten" (nur Kündigungsschreiben).
|
||||||
|
UPDATE `Contract` SET `status` = 'CANCELLED_CONFIRMED'
|
||||||
|
WHERE `status` = 'CANCELLED'
|
||||||
|
AND (`cancellationConfirmationPath` IS NOT NULL OR `cancellationConfirmationDate` IS NOT NULL);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- MaLo-IDs (Marktlokation) je Sparte an die Adresse: die MaLo gehört zur
|
||||||
|
-- (Liefer-)Adresse, nicht zum Vertrag. Strom + Gas getrennt.
|
||||||
|
ALTER TABLE `Address` ADD COLUMN IF NOT EXISTS `maloIdElectricity` VARCHAR(191) NULL;
|
||||||
|
ALTER TABLE `Address` ADD COLUMN IF NOT EXISTS `maloIdGas` VARCHAR(191) NULL;
|
||||||
|
|
||||||
|
-- Daten-Migration: bestehende Vertrags-MaLo-IDs an die jeweilige Lieferadresse
|
||||||
|
-- übertragen – je nach Vertragstyp in Strom- bzw. Gas-Feld. Nur setzen, wenn
|
||||||
|
-- am Ziel-Adressfeld noch nichts steht (idempotent, kein Überschreiben).
|
||||||
|
UPDATE `Address` a
|
||||||
|
JOIN `Contract` c ON c.addressId = a.id
|
||||||
|
JOIN `EnergyContractDetails` ecd ON ecd.contractId = c.id
|
||||||
|
SET a.maloIdElectricity = ecd.maloId
|
||||||
|
WHERE c.type = 'ELECTRICITY'
|
||||||
|
AND ecd.maloId IS NOT NULL AND ecd.maloId <> ''
|
||||||
|
AND (a.maloIdElectricity IS NULL OR a.maloIdElectricity = '');
|
||||||
|
|
||||||
|
UPDATE `Address` a
|
||||||
|
JOIN `Contract` c ON c.addressId = a.id
|
||||||
|
JOIN `EnergyContractDetails` ecd ON ecd.contractId = c.id
|
||||||
|
SET a.maloIdGas = ecd.maloId
|
||||||
|
WHERE c.type = 'GAS'
|
||||||
|
AND ecd.maloId IS NOT NULL AND ecd.maloId <> ''
|
||||||
|
AND (a.maloIdGas IS NULL OR a.maloIdGas = '');
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- "Keine Bonis erwünscht": Kunde möchte bei diesem Energievertrag keine Bonis.
|
||||||
|
ALTER TABLE `EnergyContractDetails` ADD COLUMN IF NOT EXISTS `noBonusDesired` BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Aufgaben ohne Vertrag/Kunde erlauben: contractId nullable.
|
||||||
|
-- Der bestehende FK (onDelete Cascade) bleibt: gesetzte contractId cascaden
|
||||||
|
-- weiterhin, NULL bleibt unberührt.
|
||||||
|
ALTER TABLE `ContractTask` MODIFY COLUMN `contractId` INT NULL;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Neuer AuditAction-Wert TOKEN_REFRESH: trennt stille Access-Token-Refreshes
|
||||||
|
-- vom interaktiven Login-Rauschen (eigene Action + LOW-Sensitivität).
|
||||||
|
-- MODIFY COLUMN ist idempotent (setzt die Enum-Definition, mehrfach ausführbar).
|
||||||
|
ALTER TABLE `AuditLog`
|
||||||
|
MODIFY COLUMN `action` ENUM(
|
||||||
|
'CREATE','READ','UPDATE','DELETE','EXPORT','ANONYMIZE',
|
||||||
|
'LOGIN','LOGOUT','LOGIN_FAILED','TOKEN_REFRESH'
|
||||||
|
) NOT NULL;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Aufbewahrungsregel fuer routinemaessige Auth-Eintraege (Token-Refresh).
|
||||||
|
--
|
||||||
|
-- Seit der Entrauschung landen erfolgreiche Token-Refreshes als
|
||||||
|
-- `Authentication / LOW`. Diese Kombination traf auf KEINE spezifische Regel
|
||||||
|
-- (es gab nur `Authentication / CRITICAL`) und fiel damit in die Auffangregel
|
||||||
|
-- `*` mit 3650 Tagen. Ergebnis: Das Rauschen waere 10 Jahre aufbewahrt worden,
|
||||||
|
-- echte Logins dagegen nur 2 Jahre - genau verkehrt herum.
|
||||||
|
--
|
||||||
|
-- 90 Tage reichen, um einen Refresh-Vorgang im Nachhinein nachzuvollziehen.
|
||||||
|
-- Idempotent: der Unique-Index (resourceType, sensitivity) verhindert Dubletten.
|
||||||
|
INSERT INTO `AuditRetentionPolicy`
|
||||||
|
(`resourceType`, `sensitivity`, `retentionDays`, `description`, `legalBasis`, `isActive`, `createdAt`, `updatedAt`)
|
||||||
|
VALUES
|
||||||
|
('Authentication', 'LOW', 90, 'Routine-Auth (stiller Token-Refresh)', 'Betriebsnotwendigkeit / Datenminimierung (DSGVO Art. 5)', 1, NOW(3), NOW(3))
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`retentionDays` = VALUES(`retentionDays`),
|
||||||
|
`description` = VALUES(`description`),
|
||||||
|
`legalBasis` = VALUES(`legalBasis`),
|
||||||
|
`updatedAt` = NOW(3);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Einzeiliger Mutex fuer die Audit-Hash-Kette (Pentest R166-01).
|
||||||
|
--
|
||||||
|
-- Vorher wurde ueber GET_LOCK serialisiert. Dessen RELEASE_LOCK muss auf
|
||||||
|
-- derselben Verbindung laufen und stand daher im finally INNERHALB des
|
||||||
|
-- Transaktions-Callbacks - also VOR dem COMMIT. In diesem Fenster konnte der
|
||||||
|
-- naechste Schreiber die Sperre holen und das Kettenende lesen, bevor die
|
||||||
|
-- Vorgaengerzeile committed war: beide haengten sich an denselben Vorgaenger
|
||||||
|
-- (Fork). InnoDB-Zeilensperren fallen dagegen erst beim COMMIT.
|
||||||
|
CREATE TABLE IF NOT EXISTS `AuditChainLock` (
|
||||||
|
`id` INT NOT NULL,
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `AuditChainLock` (`id`, `updatedAt`) VALUES (1, NOW(3));
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Hash-Versionierung fuer Audit-Eintraege (Pentest R166-02).
|
||||||
|
--
|
||||||
|
-- Der bisherige Hash deckte nur 7 Felder ab (userEmail, action, resourceType,
|
||||||
|
-- resourceId, endpoint, createdAt, previousHash). NICHT gehasht waren u. a.
|
||||||
|
-- changesBefore/changesAfter (die eigentliche Nutzlast), success, ipAddress,
|
||||||
|
-- resourceLabel, dataSubjectId, userId/customerId - ein nachtraeglicher
|
||||||
|
-- Einzeledit an genau diesen Feldern blieb also unsichtbar.
|
||||||
|
--
|
||||||
|
-- Version 2 hasht alle Inhaltsspalten. Bestandszeilen behalten Version 1 und
|
||||||
|
-- werden weiterhin mit dem alten Verfahren geprueft - kein Rehash noetig,
|
||||||
|
-- die Beweiskraft der Vergangenheit bleibt erhalten.
|
||||||
|
ALTER TABLE `AuditLog`
|
||||||
|
ADD COLUMN IF NOT EXISTS `hashVersion` INT NOT NULL DEFAULT 1;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Replay-Schutz fuer Refresh-Tokens (Pentest R164-02).
|
||||||
|
--
|
||||||
|
-- Die bisherige Rotation bot keinen Replay-Schutz: der alte Token blieb bis exp
|
||||||
|
-- gueltig (7 Tage), ein gestohlener Token war also parallel zum legitimen
|
||||||
|
-- nutzbar. Jetzt traegt jeder Refresh-Token eine jti und gehoert zu einer
|
||||||
|
-- Sitzungsfamilie; beim Einloesen wird die jti verbraucht. Taucht sie erneut
|
||||||
|
-- auf, wird die gesamte Familie widerrufen und der Vorfall gemeldet.
|
||||||
|
CREATE TABLE IF NOT EXISTS `RefreshTokenRecord` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`jti` VARCHAR(191) NOT NULL,
|
||||||
|
`familyId` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` INT NULL,
|
||||||
|
`customerId` INT NULL,
|
||||||
|
`isCustomerPortal` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`issuedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`expiresAt` DATETIME(3) NOT NULL,
|
||||||
|
`usedAt` DATETIME(3) NULL,
|
||||||
|
`replacedByJti` VARCHAR(191) NULL,
|
||||||
|
`reuseCount` INT NOT NULL DEFAULT 0,
|
||||||
|
`revokedAt` DATETIME(3) NULL,
|
||||||
|
`revokedReason` VARCHAR(191) NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `RefreshTokenRecord_jti_key` (`jti`),
|
||||||
|
KEY `RefreshTokenRecord_familyId_idx` (`familyId`),
|
||||||
|
KEY `RefreshTokenRecord_expiresAt_idx` (`expiresAt`),
|
||||||
|
KEY `RefreshTokenRecord_userId_idx` (`userId`),
|
||||||
|
KEY `RefreshTokenRecord_customerId_idx` (`customerId`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Bestandssiegel fuer den nicht signierbaren Altbestand (Pentest R171-02).
|
||||||
|
--
|
||||||
|
-- Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein Schreibzugriff auf
|
||||||
|
-- die Datenbank kann dort Felder wie `success` oder `resourceLabel` aendern,
|
||||||
|
-- ohne den gespeicherten Hash zu beruehren - die Pruefung meldet weiterhin
|
||||||
|
-- "gueltig". Live gezeigt: ein fehlgeschlagener Login liess sich unsichtbar in
|
||||||
|
-- einen erfolgreichen umschreiben.
|
||||||
|
--
|
||||||
|
-- Rueckwirkend signieren geht nicht. Stattdessen wird der Altbestand einmalig
|
||||||
|
-- versiegelt: je Zeile ein Blattwert ueber den vollen Inhalt, die Wurzel
|
||||||
|
-- darueber in einem HMAC-signierten Marker.
|
||||||
|
CREATE TABLE IF NOT EXISTS `AuditBacklogSeal` (
|
||||||
|
`auditLogId` INT NOT NULL,
|
||||||
|
`leafHash` VARCHAR(191) NOT NULL,
|
||||||
|
PRIMARY KEY (`auditLogId`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Dienstkonto-Kennzeichen (Pentest R182).
|
||||||
|
--
|
||||||
|
-- Das Gegenbuch meldet sich stuendlich an. Diese Anmeldung wurde bisher als
|
||||||
|
-- Authentication/CRITICAL protokolliert - also das vorhersagbarste Ereignis im
|
||||||
|
-- System auf der hoechsten Stufe. Damit trainiert die Routine den Betreiber
|
||||||
|
-- darauf, CRITICAL wegzuklicken, und der erste ECHTE Vorfall erbt diesen
|
||||||
|
-- Reflex. Mit dem Kennzeichen werden solche Anmeldungen als Routine gefuehrt.
|
||||||
|
ALTER TABLE `User`
|
||||||
|
ADD COLUMN IF NOT EXISTS `isServiceAccount` BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Neuer SecurityEventType AUDIT_SEAL_CHANGED (Pentest R185-01).
|
||||||
|
--
|
||||||
|
-- Das Setzen oder Ersetzen des Bestandssiegels veraendert die Grundlage, gegen
|
||||||
|
-- die spaeter Manipulation nachgewiesen wird. Bisher landete das ausschliesslich
|
||||||
|
-- als CRITICAL-Zeile im Audit-Log - also in einem Kanal, den ein Mensch lesen
|
||||||
|
-- muss. Der Alarmkanal ist ein separater Store; ohne eigenen Ereignistyp gab es
|
||||||
|
-- dort gar keinen Eintrag, und `valid` bleibt bei einem ersetzenden Siegel
|
||||||
|
-- konstruktionsbedingt `true`.
|
||||||
|
--
|
||||||
|
-- MODIFY COLUMN ist idempotent (setzt die Enum-Definition, mehrfach ausfuehrbar).
|
||||||
|
ALTER TABLE `SecurityEvent`
|
||||||
|
MODIFY COLUMN `type` ENUM(
|
||||||
|
'LOGIN_FAILED','LOGIN_SUCCESS','RATE_LIMIT_HIT','ACCESS_DENIED',
|
||||||
|
'SSRF_BLOCKED','PASSWORD_RESET_REQUEST','PASSWORD_RESET_CONFIRM',
|
||||||
|
'LOGOUT','TOKEN_REJECTED','PERMISSION_CHANGED','AUDIT_SEAL_CHANGED',
|
||||||
|
'SUSPICIOUS'
|
||||||
|
) NOT NULL;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Systemrollen kennzeichnen (Rechtemodell, Etappe 1).
|
||||||
|
--
|
||||||
|
-- Bis hierher war jede Rolle ueber die Rollen-CRUD frei aenderbar - auch
|
||||||
|
-- "Admin", "DSGVO" und "Developer". Man konnte sie umbenennen oder ihre
|
||||||
|
-- Rechte leeren. Zugleich haengen die versteckten Rollen an ihrem NAMEN:
|
||||||
|
-- `user.service.ts` sucht sie per `findFirst({ where: { name: 'DSGVO' } })`.
|
||||||
|
-- Ein umbenannter Datensatz haette den Notfallpfad ins Leere laufen lassen,
|
||||||
|
-- ohne dass irgendwo etwas gemeldet worden waere.
|
||||||
|
--
|
||||||
|
-- `isSystem` macht diese Rollen zu dem, was sie immer sein sollten: von der
|
||||||
|
-- Anwendung gepflegt, ueber die API sichtbar, aber nicht veraenderbar.
|
||||||
|
-- `isHidden` ersetzt die im Frontend hartkodierte Namensliste, in der
|
||||||
|
-- "Gegenbuch" bisher fehlte - die Rolle tauchte deshalb als anhakbare
|
||||||
|
-- Rolle im Benutzerformular auf.
|
||||||
|
ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isSystem` BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE `Role` ADD COLUMN IF NOT EXISTS `isHidden` BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Backfill nach Namen. Wer eine dieser Rollen lokal umbenannt hat, wird hier
|
||||||
|
-- nicht getroffen; `synchronisiereRechteUndRollen` legt beim naechsten Start
|
||||||
|
-- eine neue Rolle unter dem erwarteten Namen an und markiert sie. Das ist
|
||||||
|
-- sichtbar (zwei Rollen in der Liste) und damit behandelbar - im Gegensatz
|
||||||
|
-- zu einer stillen Fehlzuordnung.
|
||||||
|
UPDATE `Role` SET `isSystem` = true
|
||||||
|
WHERE `name` IN ('Admin','Developer','DSGVO','Audit-Betrieb','Gegenbuch',
|
||||||
|
'Mitarbeiter','Mitarbeiter (Nur-Lesen)','Kunde');
|
||||||
|
|
||||||
|
UPDATE `Role` SET `isHidden` = true
|
||||||
|
WHERE `name` IN ('Developer','DSGVO','Audit-Betrieb','Gegenbuch','Kunde');
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
-- Rechtekatalog begradigen (Rechtemodell, Etappe 1).
|
||||||
|
--
|
||||||
|
-- 18 der 50 Rechte bewachten nichts: `tariffs:*`, `cancellation-periods:*`,
|
||||||
|
-- `contract-durations:*` und `email-providers:*` standen im Katalog und waren
|
||||||
|
-- in der Rollenverwaltung anhakbar - die Routen prueften in Wahrheit
|
||||||
|
-- `providers:*`, `platforms:*` und `settings:*`. Ein Haken, der nichts tut,
|
||||||
|
-- ist schlimmer als ein fehlender: Er behauptet eine Trennung, die es nicht
|
||||||
|
-- gibt, und wer sich darauf verlaesst, vergibt zu viel oder zu wenig, ohne
|
||||||
|
-- es zu merken.
|
||||||
|
--
|
||||||
|
-- Diese Migration ist REIN ADDITIV. Sie entzieht keiner Rolle irgendetwas.
|
||||||
|
-- Sie muss vor dem Code laufen, der die Routen umstellt - sonst verlieren
|
||||||
|
-- bestehende Rollen den Zugriff auf Tarife, Kuendigungsfristen, Laufzeiten
|
||||||
|
-- und E-Mail-Provider.
|
||||||
|
--
|
||||||
|
-- Das Aufraeumen der jetzt ueberfluessigen Sammelrechte ist ausdruecklich
|
||||||
|
-- NICHT Aufgabe dieser Migration, sondern eine bewusste Entscheidung des
|
||||||
|
-- Betreibers in der Rollenoberflaeche.
|
||||||
|
|
||||||
|
-- 1) Fehlende Rechte in den Katalog.
|
||||||
|
-- UNIQUE(resource, action) traegt die Idempotenz.
|
||||||
|
INSERT IGNORE INTO `Permission` (`resource`,`action`) VALUES
|
||||||
|
('roles','manage'),
|
||||||
|
('tariffs','create'),('tariffs','read'),('tariffs','update'),('tariffs','delete'),
|
||||||
|
('cancellation-periods','create'),('cancellation-periods','read'),
|
||||||
|
('cancellation-periods','update'),('cancellation-periods','delete'),
|
||||||
|
('contract-durations','create'),('contract-durations','read'),
|
||||||
|
('contract-durations','update'),('contract-durations','delete'),
|
||||||
|
('contract-categories','create'),('contract-categories','read'),
|
||||||
|
('contract-categories','update'),('contract-categories','delete'),
|
||||||
|
('email-providers','create'),('email-providers','read'),
|
||||||
|
('email-providers','update'),('email-providers','delete'),
|
||||||
|
('platforms','read');
|
||||||
|
|
||||||
|
-- 2) Vererbung alt -> neu: Jede Rolle, die bisher das Sammelrecht hielt,
|
||||||
|
-- bekommt das neue Recht dazu. INSERT IGNORE gegen den Primaerschluessel
|
||||||
|
-- (roleId, permissionId) macht den Block beliebig wiederholbar.
|
||||||
|
--
|
||||||
|
-- `users:delete` -> `roles:manage` bewusst nur ueber `users:delete`, nicht
|
||||||
|
-- ueber `users:create`/`users:update`: `users:delete` ist im Code bereits
|
||||||
|
-- die Admin-Heuristik (user.service.ts). Wuerde man `roles:manage` an
|
||||||
|
-- jeden mit `users:update` vererben, waere die Trennung, die dieses Recht
|
||||||
|
-- herstellen soll, im selben Zug wieder eingerissen.
|
||||||
|
INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`)
|
||||||
|
SELECT rp.`roleId`, neu.`id`
|
||||||
|
FROM `RolePermission` rp
|
||||||
|
JOIN `Permission` alt ON alt.`id` = rp.`permissionId`
|
||||||
|
JOIN (
|
||||||
|
SELECT 'providers' AS aRes,'read' AS aAct,'tariffs' AS nRes,'read' AS nAct
|
||||||
|
UNION ALL SELECT 'providers','create','tariffs','create'
|
||||||
|
UNION ALL SELECT 'providers','update','tariffs','update'
|
||||||
|
UNION ALL SELECT 'providers','delete','tariffs','delete'
|
||||||
|
UNION ALL SELECT 'platforms','create','cancellation-periods','create'
|
||||||
|
UNION ALL SELECT 'platforms','update','cancellation-periods','update'
|
||||||
|
UNION ALL SELECT 'platforms','delete','cancellation-periods','delete'
|
||||||
|
UNION ALL SELECT 'platforms','create','contract-durations','create'
|
||||||
|
UNION ALL SELECT 'platforms','update','contract-durations','update'
|
||||||
|
UNION ALL SELECT 'platforms','delete','contract-durations','delete'
|
||||||
|
UNION ALL SELECT 'settings','read', 'email-providers','read'
|
||||||
|
UNION ALL SELECT 'settings','update','email-providers','create'
|
||||||
|
UNION ALL SELECT 'settings','update','email-providers','update'
|
||||||
|
UNION ALL SELECT 'settings','update','email-providers','delete'
|
||||||
|
UNION ALL SELECT 'users','delete','roles','manage'
|
||||||
|
) m ON m.aRes = alt.`resource` AND m.aAct = alt.`action`
|
||||||
|
JOIN `Permission` neu ON neu.`resource` = m.nRes AND neu.`action` = m.nAct;
|
||||||
|
|
||||||
|
-- 3) Die bisher ungegateten Lese-Endpunkte (Plattformen, Vertragstypen,
|
||||||
|
-- Kuendigungsfristen, Laufzeiten) bekommen ab jetzt ein Recht. Damit
|
||||||
|
-- niemand seine Listen verliert, erhalten es alle Rollen, die die
|
||||||
|
-- Anwendung ueberhaupt benutzen.
|
||||||
|
--
|
||||||
|
-- Bewusst NICHT pauschal an jede Rolle: "Gegenbuch" haelt nur `audit:read`
|
||||||
|
-- und soll so wenig wert bleiben wie moeglich - sein Kennwort liegt auf
|
||||||
|
-- der Notar-Maschine im Klartext (R185-01). Dasselbe gilt fuer
|
||||||
|
-- "Audit-Betrieb".
|
||||||
|
INSERT IGNORE INTO `RolePermission` (`roleId`,`permissionId`)
|
||||||
|
SELECT r.`id`, p.`id`
|
||||||
|
FROM `Role` r
|
||||||
|
JOIN `Permission` p
|
||||||
|
ON (p.`resource`,p.`action`) IN
|
||||||
|
(('cancellation-periods','read'),('contract-durations','read'),
|
||||||
|
('contract-categories','read'),('platforms','read'),('tariffs','read'))
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM `RolePermission` rp2
|
||||||
|
JOIN `Permission` p2 ON p2.`id` = rp2.`permissionId`
|
||||||
|
WHERE rp2.`roleId` = r.`id`
|
||||||
|
AND (p2.`resource`,p2.`action`) IN
|
||||||
|
(('customers','read'),('contracts','read'),('providers','read'),
|
||||||
|
('platforms','create'),('settings','read'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4) Alle Sitzungen einmalig beenden.
|
||||||
|
--
|
||||||
|
-- Die Rechte stehen im Zugangstoken. Nach der Routenumstellung verlangt
|
||||||
|
-- z.B. PUT /tariffs/:id das Recht `tariffs:update`; jedes bereits
|
||||||
|
-- ausgestellte Token traegt aber noch den alten Anspruch und liefe bis zu
|
||||||
|
-- 15 Minuten lang in ein 403. Die Datenbank waere dann laengst richtig -
|
||||||
|
-- nur das Token alt.
|
||||||
|
--
|
||||||
|
-- Einmal neu anmelden ist ehrlicher als ein Uebergangs-Doppelgate, das in
|
||||||
|
-- sechs Monaten jemand fuer Absicht haelt. Die Meldung dafuer gibt es
|
||||||
|
-- bereits im Klartext ("Ihre Berechtigungen wurden geändert."), und das
|
||||||
|
-- Gegenbuch-Dienstkonto meldet sich stuendlich ohnehin neu an.
|
||||||
|
UPDATE `User` SET `tokenInvalidatedAt` = NOW();
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Bestandsaufnahme des Rechtesystems. Liest nur, aendert nichts.
|
||||||
|
*
|
||||||
|
* npx tsx prisma/rechte-report.ts → Bericht auf stdout
|
||||||
|
* npx tsx prisma/rechte-report.ts > vorher.txt
|
||||||
|
*
|
||||||
|
* Zweck: Vor und nach dem Rechte-Umbau denselben Bericht erzeugen und
|
||||||
|
* vergleichen. Die Zusage lautet "niemand verliert Zugriff" - und eine
|
||||||
|
* Zusage, die niemand nachrechnen kann, ist keine.
|
||||||
|
*
|
||||||
|
* Der Bericht ist bewusst zeilenweise und sortiert, damit `diff` darauf
|
||||||
|
* etwas Lesbares ausgibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
|
||||||
|
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
|
||||||
|
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
|
||||||
|
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
|
||||||
|
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
|
||||||
|
// "Environment variable not found: DATABASE_URL".
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
import { ALLE_RECHTE, SYSTEMROLLEN_NAMEN } from '../src/config/rechte-katalog.js';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest isSystem/isHidden, sofern die Spalten existieren.
|
||||||
|
*
|
||||||
|
* Der Bericht muss VOR und NACH der Migration laufen - das ist sein Zweck.
|
||||||
|
* Vorher gibt es die Spalten noch nicht, und ein Absturz an dieser Stelle
|
||||||
|
* haette genau die Baseline verhindert, gegen die spaeter verglichen wird.
|
||||||
|
*/
|
||||||
|
async function leseKennzeichen(): Promise<Map<number, { isSystem: boolean; isHidden: boolean }>> {
|
||||||
|
const karte = new Map<number, { isSystem: boolean; isHidden: boolean }>();
|
||||||
|
try {
|
||||||
|
const zeilen = await prisma.$queryRawUnsafe<
|
||||||
|
Array<{ id: number; isSystem: number | boolean; isHidden: number | boolean }>
|
||||||
|
>('SELECT id, isSystem, isHidden FROM `Role`');
|
||||||
|
for (const z of zeilen) {
|
||||||
|
karte.set(Number(z.id), { isSystem: Boolean(z.isSystem), isHidden: Boolean(z.isHidden) });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Spalten noch nicht vorhanden - das ist der Zustand vor der Migration.
|
||||||
|
}
|
||||||
|
return karte;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const kennzeichen = await leseKennzeichen();
|
||||||
|
const rollen = await prisma.role.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
permissions: { include: { permission: true } },
|
||||||
|
_count: { select: { users: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('=== ROLLEN ===');
|
||||||
|
for (const r of rollen) {
|
||||||
|
const rechte = r.permissions
|
||||||
|
.map((rp) => `${rp.permission.resource}:${rp.permission.action}`)
|
||||||
|
.sort();
|
||||||
|
const k = kennzeichen.get(r.id);
|
||||||
|
const etikett = [k?.isSystem ? 'System' : null, k?.isHidden ? 'versteckt' : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
console.log(
|
||||||
|
`\n[${r.name}] #${r.id}` +
|
||||||
|
(etikett ? ` (${etikett})` : '') +
|
||||||
|
` – ${r._count.users} Konten, ${rechte.length} Rechte`,
|
||||||
|
);
|
||||||
|
for (const recht of rechte) console.log(` ${recht}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== KONTEN (aktiv) ===');
|
||||||
|
const konten = await prisma.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { email: 'asc' },
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
roles: {
|
||||||
|
select: {
|
||||||
|
role: {
|
||||||
|
// Ausdrueckliche Spaltenauswahl, damit der Bericht auch auf einer
|
||||||
|
// noch nicht migrierten Datenbank laeuft.
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
permissions: { include: { permission: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const k of konten) {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
for (const ur of k.roles) {
|
||||||
|
for (const rp of ur.role.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rollenNamen = k.roles.map((ur) => ur.role.name).sort().join(', ') || '(keine)';
|
||||||
|
console.log(`\n[${k.email}] Rollen: ${rollenNamen} – ${rechte.size} Rechte`);
|
||||||
|
for (const recht of [...rechte].sort()) console.log(` ${recht}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auffaelligkeiten -----------------------------------------------------
|
||||||
|
// Nicht als Alarm gemeint, sondern als das, was man vor einem Deploy
|
||||||
|
// wissen will. Eine umbenannte Systemrolle etwa wird vom Backfill der
|
||||||
|
// Migration nicht getroffen.
|
||||||
|
console.log('\n=== HINWEISE ===');
|
||||||
|
const hinweise: string[] = [];
|
||||||
|
|
||||||
|
const vorhandeneNamen = new Set(rollen.map((r) => r.name));
|
||||||
|
for (const erwartet of SYSTEMROLLEN_NAMEN) {
|
||||||
|
if (!vorhandeneNamen.has(erwartet)) {
|
||||||
|
hinweise.push(`Systemrolle "${erwartet}" fehlt in der Datenbank.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of rollen) {
|
||||||
|
const k = kennzeichen.get(r.id);
|
||||||
|
// Vor der Migration gibt es die Spalte nicht - dann ist "nicht
|
||||||
|
// gekennzeichnet" kein Befund, sondern der erwartete Zustand.
|
||||||
|
if (kennzeichen.size === 0) continue;
|
||||||
|
if (SYSTEMROLLEN_NAMEN.includes(r.name) && !k?.isSystem) {
|
||||||
|
hinweise.push(`Rolle "${r.name}" ist eine Systemrolle, aber isSystem ist nicht gesetzt.`);
|
||||||
|
}
|
||||||
|
if (!SYSTEMROLLEN_NAMEN.includes(r.name) && k?.isSystem) {
|
||||||
|
hinweise.push(`Rolle "${r.name}" traegt isSystem, gehoert aber nicht zum Katalog.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleRechteDb = await prisma.permission.findMany();
|
||||||
|
const imKatalog = new Set(ALLE_RECHTE);
|
||||||
|
for (const p of alleRechteDb) {
|
||||||
|
const s = `${p.resource}:${p.action}`;
|
||||||
|
if (!imKatalog.has(s)) hinweise.push(`Recht "${s}" steht in der Datenbank, aber nicht im Katalog.`);
|
||||||
|
}
|
||||||
|
const inDb = new Set(alleRechteDb.map((p) => `${p.resource}:${p.action}`));
|
||||||
|
for (const s of ALLE_RECHTE) {
|
||||||
|
if (!inDb.has(s)) hinweise.push(`Recht "${s}" steht im Katalog, aber nicht in der Datenbank.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konten ohne jede Rolle koennen sich anmelden und sehen nichts - eine
|
||||||
|
// haeufige Ursache fuer "bei mir ist alles leer".
|
||||||
|
for (const k of konten) {
|
||||||
|
if (k.roles.length === 0) hinweise.push(`Konto "${k.email}" hat keine Rolle.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hinweise.length === 0) console.log(' keine');
|
||||||
|
else for (const h of hinweise) console.log(` ! ${h}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('[rechte-report] Fehler:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Weist einem Konto eine Rolle zu oder nimmt sie ihm weg - von der
|
||||||
|
* Kommandozeile aus.
|
||||||
|
*
|
||||||
|
* npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname>
|
||||||
|
* npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> --entfernen
|
||||||
|
* npx tsx prisma/rolle-zuweisen.ts --liste
|
||||||
|
*
|
||||||
|
* Im Container:
|
||||||
|
* docker compose exec backend npx tsx prisma/rolle-zuweisen.ts \
|
||||||
|
* name@firma.de Developer
|
||||||
|
*
|
||||||
|
* WOZU: Die Teilmengenregel verhindert, dass jemand ueber die Weboberflaeche
|
||||||
|
* Rechte vergibt, die er selbst nicht haelt. Das ist gewollt - es schliesst
|
||||||
|
* die Selbst-Erhoehung. Es hat aber eine Kehrseite: Nach einer
|
||||||
|
* Neuinstallation haelt niemand `developer:access` oder `audit:admin`, und
|
||||||
|
* dann kann diese Rechte auch niemand erstmalig vergeben.
|
||||||
|
*
|
||||||
|
* Dieses Skript ist der dokumentierte Ausweg. Es umgeht die Regel bewusst,
|
||||||
|
* denn die Vertrauensgrenze stimmt: Wer eine Shell auf dieser Maschine hat,
|
||||||
|
* hat ohnehin Zugriff auf die Datenbank. Ein gestohlener Web-Zugang hat das
|
||||||
|
* nicht - und genau das ist der Unterschied, den die Regel schuetzen soll.
|
||||||
|
*
|
||||||
|
* Der Vorgang wird protokolliert und meldet die Traeger ab, damit die
|
||||||
|
* Aenderung sofort wirkt und nicht spurlos bleibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
|
||||||
|
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
|
||||||
|
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
|
||||||
|
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
|
||||||
|
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
|
||||||
|
// "Environment variable not found: DATABASE_URL".
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
import { createAuditLog } from '../src/services/audit.service.js';
|
||||||
|
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.includes('--liste') || args.length === 0) {
|
||||||
|
const rollen = await prisma.role.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
include: { _count: { select: { users: true } } },
|
||||||
|
});
|
||||||
|
console.log('Vorhandene Rollen:');
|
||||||
|
for (const r of rollen) {
|
||||||
|
const kennz = [r.isSystem ? 'System' : null, r.isHidden ? 'versteckt' : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
console.log(` ${r.name}${kennz ? ` (${kennz})` : ''} – ${r._count.users} Konten`);
|
||||||
|
}
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.log('\nAufruf: npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> [--entfernen]');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entfernen = args.includes('--entfernen');
|
||||||
|
const [email, rollenName] = args.filter((a) => !a.startsWith('--'));
|
||||||
|
|
||||||
|
if (!email || !rollenName) {
|
||||||
|
console.error('Aufruf: npx tsx prisma/rolle-zuweisen.ts <email> <Rollenname> [--entfernen]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const konto = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!konto) {
|
||||||
|
console.error(`Kein Konto mit der Adresse "${email}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolle = await prisma.role.findUnique({ where: { name: rollenName } });
|
||||||
|
if (!rolle) {
|
||||||
|
console.error(`Keine Rolle mit dem Namen "${rollenName}". Vorhandene mit --liste ansehen.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = await prisma.userRole.findUnique({
|
||||||
|
where: { userId_roleId: { userId: konto.id, roleId: rolle.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entfernen) {
|
||||||
|
if (!vorhanden) {
|
||||||
|
console.log(`"${email}" hat die Rolle "${rollenName}" gar nicht. Nichts zu tun.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prisma.userRole.delete({
|
||||||
|
where: { userId_roleId: { userId: konto.id, roleId: rolle.id } },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (vorhanden) {
|
||||||
|
console.log(`"${email}" hat die Rolle "${rollenName}" bereits. Nichts zu tun.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await prisma.userRole.create({ data: { userId: konto.id, roleId: rolle.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sofort wirksam machen: Die Rechte stehen im Zugangstoken, sonst behielte
|
||||||
|
// das Konto bis zu 15 Minuten den alten Stand.
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: konto.id },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nachvollziehbar machen. Ein Eingriff von der Kommandozeile ist berechtigt,
|
||||||
|
// aber er darf nicht unsichtbar sein - sonst waere das Skript selbst die
|
||||||
|
// Luecke, die es schliessen soll.
|
||||||
|
//
|
||||||
|
// Ueber createAuditLog, NICHT ueber prisma.auditLog.create: Das Protokoll
|
||||||
|
// ist eine Hash-Kette. Ein roh eingefuegter Datensatz haette kein `hash`
|
||||||
|
// und keinen `previousHash` und wuerde bei der naechsten Pruefung als
|
||||||
|
// Luecke erscheinen - das Skript wuerde also ausgerechnet dort Zweifel
|
||||||
|
// saeen, wo es Klarheit schaffen soll.
|
||||||
|
await createAuditLog({
|
||||||
|
userEmail: 'system (CLI)',
|
||||||
|
userRole: 'Kommandozeile',
|
||||||
|
action: entfernen ? 'DELETE' : 'CREATE',
|
||||||
|
sensitivity: 'CRITICAL',
|
||||||
|
resourceType: 'UserRole',
|
||||||
|
resourceId: `${konto.id}:${rolle.id}`,
|
||||||
|
resourceLabel: entfernen
|
||||||
|
? `Rolle "${rolle.name}" von ${email} entfernt (Kommandozeile)`
|
||||||
|
: `Rolle "${rolle.name}" an ${email} vergeben (Kommandozeile)`,
|
||||||
|
endpoint: 'prisma/rolle-zuweisen.ts',
|
||||||
|
httpMethod: 'CLI',
|
||||||
|
ipAddress: 'lokal',
|
||||||
|
changesAfter: { konto: email, rolle: rolle.name, entfernt: entfernen },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
entfernen
|
||||||
|
? `Rolle "${rolle.name}" von "${email}" entfernt.`
|
||||||
|
: `Rolle "${rolle.name}" an "${email}" vergeben.`,
|
||||||
|
);
|
||||||
|
console.log('Das Konto muss sich neu anmelden, damit die Änderung greift.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('[rolle-zuweisen] Fehler:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -76,6 +76,11 @@ model User {
|
|||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
/// Dienstkonto (z. B. das Gegenbuch). Meldet sich planmaessig und haeufig an.
|
||||||
|
/// Solche Anmeldungen werden im Audit-Log als Routine gefuehrt statt als
|
||||||
|
/// CRITICAL - sonst trainiert das vorhersagbarste Ereignis im System den
|
||||||
|
/// Betreiber darauf, die hoechste Stufe wegzuklicken (Pentest R182).
|
||||||
|
isServiceAccount Boolean @default(false)
|
||||||
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
|
||||||
|
|
||||||
// Passwort-Reset
|
// Passwort-Reset
|
||||||
@@ -101,6 +106,16 @@ model Role {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name String @unique
|
||||||
description String?
|
description String?
|
||||||
|
/// Von der Anwendung gepflegte Rolle (config/rechte-katalog.ts). Über die
|
||||||
|
/// API sichtbar, aber nicht umbenennbar, nicht löschbar, Rechte nicht
|
||||||
|
/// änderbar. Ohne diese Sperre liess sich die Admin-Rolle über die
|
||||||
|
/// Rollen-CRUD leeren oder umbenennen, und die Namens-Schlüssel, an denen
|
||||||
|
/// die versteckten Rollen hängen, waren nur scheinbar stabil.
|
||||||
|
isSystem Boolean @default(false)
|
||||||
|
/// Nicht in der normalen Rollenauswahl anbieten. Diese Rollen werden über
|
||||||
|
/// die Haken im Benutzerformular vergeben (DSGVO, Entwicklerzugriff,
|
||||||
|
/// Audit-Betrieb) oder gehören zu einem technischen Konto.
|
||||||
|
isHidden Boolean @default(false)
|
||||||
permissions RolePermission[]
|
permissions RolePermission[]
|
||||||
users UserRole[]
|
users UserRole[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -327,6 +342,10 @@ model Address {
|
|||||||
city String
|
city String
|
||||||
country String @default("Deutschland")
|
country String @default("Deutschland")
|
||||||
isDefault Boolean @default(false)
|
isDefault Boolean @default(false)
|
||||||
|
// Marktlokations-IDs (MaLo) je Sparte – die MaLo gehört zur (Liefer-)Adresse,
|
||||||
|
// nicht zum Vertrag. Strom- und Gas-Marktlokation sind getrennt.
|
||||||
|
maloIdElectricity String? // MaLo-ID Strom
|
||||||
|
maloIdGas String? // MaLo-ID Gas
|
||||||
// Eigentümer (leer = Kunde ist selbst Eigentümer)
|
// Eigentümer (leer = Kunde ist selbst Eigentümer)
|
||||||
ownerCompany String?
|
ownerCompany String?
|
||||||
ownerFirstName String?
|
ownerFirstName String?
|
||||||
@@ -353,6 +372,7 @@ model BankCard {
|
|||||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
accountHolder String
|
accountHolder String
|
||||||
iban String
|
iban String
|
||||||
|
cardNumber String? // Karten-/Kontokartennummer (optional, z.B. Debitkarte)
|
||||||
bic String?
|
bic String?
|
||||||
bankName String?
|
bankName String?
|
||||||
expiryDate DateTime?
|
expiryDate DateTime?
|
||||||
@@ -702,7 +722,10 @@ enum ContractStatus {
|
|||||||
DRAFT
|
DRAFT
|
||||||
PENDING
|
PENDING
|
||||||
ACTIVE
|
ACTIVE
|
||||||
|
// Kündigungsschreiben raus, Bestätigung des Anbieters steht noch aus.
|
||||||
CANCELLED
|
CANCELLED
|
||||||
|
// Kündigung vom Anbieter bestätigt (Bestätigungsdatum/-dokument liegt vor).
|
||||||
|
CANCELLED_CONFIRMED
|
||||||
EXPIRED
|
EXPIRED
|
||||||
DEACTIVATED
|
DEACTIVATED
|
||||||
// "Läuft weiter auf unbestimmte Zeit" – Vertrag ist unbefristet aktiv.
|
// "Läuft weiter auf unbestimmte Zeit" – Vertrag ist unbefristet aktiv.
|
||||||
@@ -862,8 +885,10 @@ enum ContractTaskStatus {
|
|||||||
|
|
||||||
model ContractTask {
|
model ContractTask {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
contractId Int
|
// Nullable: Aufgaben können auch OHNE Vertrag/Kunde angelegt werden
|
||||||
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
// (allgemeine interne Aufgabe). Ohne Vertrag → kein Kunde → nie im Portal.
|
||||||
|
contractId Int?
|
||||||
|
contract Contract? @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||||
title String
|
title String
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
status ContractTaskStatus @default(OPEN)
|
status ContractTaskStatus @default(OPEN)
|
||||||
@@ -915,6 +940,7 @@ model EnergyContractDetails {
|
|||||||
// `instantBonus` (Annahme: meistgenutzte Variante).
|
// `instantBonus` (Annahme: meistgenutzte Variante).
|
||||||
instantBonus Float? // Sofort-Bonus
|
instantBonus Float? // Sofort-Bonus
|
||||||
newCustomerBonus Float? // Neukunden-Bonus
|
newCustomerBonus Float? // Neukunden-Bonus
|
||||||
|
noBonusDesired Boolean @default(false) // Kunde wünscht keine Bonis
|
||||||
previousProviderName String?
|
previousProviderName String?
|
||||||
previousCustomerNumber String?
|
previousCustomerNumber String?
|
||||||
invoices Invoice[] // Rechnungen
|
invoices Invoice[] // Rechnungen
|
||||||
@@ -1210,6 +1236,76 @@ model CarInsuranceDetails {
|
|||||||
|
|
||||||
// ==================== AUDIT LOGGING (DSGVO) ====================
|
// ==================== AUDIT LOGGING (DSGVO) ====================
|
||||||
|
|
||||||
|
/// Einzeiliger Mutex fuer die Audit-Hash-Kette (genau eine Zeile, id = 1).
|
||||||
|
///
|
||||||
|
/// Warum eine eigene Tabelle statt GET_LOCK oder `SELECT … FOR UPDATE` auf
|
||||||
|
/// AuditLog selbst:
|
||||||
|
/// - GET_LOCK muss auf derselben Verbindung freigegeben werden. Innerhalb des
|
||||||
|
/// Prisma-Transaktions-Callbacks faellt das Release damit VOR den COMMIT –
|
||||||
|
/// der naechste Schreiber liest das Kettenende, bevor die Vorgaengerzeile
|
||||||
|
/// sichtbar ist, und haengt sich an denselben Vorgaenger (Fork, R166-01).
|
||||||
|
/// - `FOR UPDATE` auf das Kettenende von AuditLog nimmt Gap-/Next-Key-Locks,
|
||||||
|
/// die mit den gleichzeitigen INSERTs kollidieren (Deadlocks, dabei gingen
|
||||||
|
/// 38 von 40 Eintraegen verloren).
|
||||||
|
/// InnoDB-Zeilensperren werden erst beim COMMIT freigegeben – genau das
|
||||||
|
/// schliesst das Fenster.
|
||||||
|
model AuditChainLock {
|
||||||
|
id Int @id
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ausgegebene Refresh-Tokens (Pentest R164-02).
|
||||||
|
///
|
||||||
|
/// Vorher war die Rotation wirkungslos: Der alte Token blieb bis `exp`
|
||||||
|
/// gueltig, ein gestohlener Token also bis zu 7 Tage parallel nutzbar.
|
||||||
|
/// Jetzt traegt jeder Refresh-Token eine `jti` und gehoert zu einer
|
||||||
|
/// Sitzungs-`familyId`. Beim Einloesen wird die `jti` verbraucht; taucht sie
|
||||||
|
/// danach erneut auf, gilt das als Replay und die GESAMTE Familie wird
|
||||||
|
/// widerrufen (Angreifer und legitimer Nutzer fliegen raus, der Vorfall wird
|
||||||
|
/// gemeldet) - das uebliche Vorgehen aus der OAuth-Sicherheits-BCP.
|
||||||
|
///
|
||||||
|
/// Der Token selbst wird NICHT gespeichert - die Signatur authentifiziert ihn
|
||||||
|
/// bereits, und ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.
|
||||||
|
model RefreshTokenRecord {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
jti String @unique
|
||||||
|
familyId String
|
||||||
|
userId Int?
|
||||||
|
customerId Int?
|
||||||
|
isCustomerPortal Boolean @default(false)
|
||||||
|
issuedAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
/// Gesetzt, sobald der Token eingeloest wurde (Einmalverwendung).
|
||||||
|
usedAt DateTime?
|
||||||
|
replacedByJti String?
|
||||||
|
/// Wiederverwendungen innerhalb des Kulanzfensters (parallele Tabs).
|
||||||
|
reuseCount Int @default(0)
|
||||||
|
revokedAt DateTime?
|
||||||
|
revokedReason String?
|
||||||
|
|
||||||
|
@@index([familyId])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([customerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blattwerte des Bestandssiegels (Pentest R171-02 / R172).
|
||||||
|
///
|
||||||
|
/// Der Altbestand (Hash-Version 1/2) laesst sich nicht rueckwirkend signieren –
|
||||||
|
/// ein Siegel vergibt man beim Anlegen. Bei V1 sind nur 7 von 24 Spalten
|
||||||
|
/// gehasht, ein DB-Schreiber kann dort z. B. `success` von false auf true
|
||||||
|
/// setzen, ohne den Hash zu beruehren: die Pruefung meldet weiterhin "gueltig".
|
||||||
|
///
|
||||||
|
/// Das Bestandssiegel schliesst das, ohne irgendetwas umzuschreiben: Zum
|
||||||
|
/// Zeitpunkt X wird je Altzeile ein Blattwert ueber den VOLLEN Inhalt
|
||||||
|
/// (alle Spalten inkl. id und hashVersion) gebildet; die Wurzel darueber
|
||||||
|
/// steht in einem HMAC-signierten Marker. Jede spaetere Aenderung an einer
|
||||||
|
/// Altzeile bricht Blatt und Wurzel.
|
||||||
|
model AuditBacklogSeal {
|
||||||
|
auditLogId Int @id
|
||||||
|
leafHash String
|
||||||
|
}
|
||||||
|
|
||||||
enum AuditAction {
|
enum AuditAction {
|
||||||
CREATE
|
CREATE
|
||||||
READ
|
READ
|
||||||
@@ -1220,6 +1316,7 @@ enum AuditAction {
|
|||||||
LOGIN
|
LOGIN
|
||||||
LOGOUT
|
LOGOUT
|
||||||
LOGIN_FAILED
|
LOGIN_FAILED
|
||||||
|
TOKEN_REFRESH // stiller Access-Token-Refresh (Cookie), kein interaktiver Login
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuditSensitivity {
|
enum AuditSensitivity {
|
||||||
@@ -1272,6 +1369,9 @@ model AuditLog {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
hash String? // SHA-256 Hash des Eintrags
|
hash String? // SHA-256 Hash des Eintrags
|
||||||
previousHash String? // Hash des vorherigen Eintrags
|
previousHash String? // Hash des vorherigen Eintrags
|
||||||
|
/// 1 = Alt-Hash ueber 7 Felder, 2 = Hash ueber alle Inhaltsspalten
|
||||||
|
/// (Pentest R166-02). Bestandszeilen bleiben mit Version 1 gueltig.
|
||||||
|
hashVersion Int @default(1)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([customerId])
|
@@index([customerId])
|
||||||
@@ -1392,6 +1492,7 @@ enum SecurityEventType {
|
|||||||
LOGOUT // expliziter Logout
|
LOGOUT // expliziter Logout
|
||||||
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
|
||||||
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
|
||||||
|
AUDIT_SEAL_CHANGED // Bestandssiegel gesetzt oder ersetzt (Beweis-Grundlage)
|
||||||
SUSPICIOUS // generischer Catch-All
|
SUSPICIOUS // generischer Catch-All
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-213
@@ -1,225 +1,28 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
|
||||||
|
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../src/config/rechte-katalog.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('Seeding database...');
|
console.log('Seeding database...');
|
||||||
|
|
||||||
// ==================== PERMISSIONS ====================
|
// ==================== RECHTE UND ROLLEN ====================
|
||||||
// Ressourcen mit ihren erlaubten Aktionen
|
// Katalog und Systemrollen kommen aus einer einzigen Definition
|
||||||
const resourcePermissions: Record<string, string[]> = {
|
// (src/config/rechte-katalog.ts), aufgeloest von rollen-sync.service.ts.
|
||||||
// Haupt-Ressourcen (CRUD)
|
//
|
||||||
customers: ['create', 'read', 'update', 'delete'],
|
// Bis 09/2026 stand hier eine zweite, eigene Kopie - und sie wich ab: Die
|
||||||
contracts: ['create', 'read', 'update', 'delete'],
|
// DSGVO-Rolle bekam `audit:*` komplett, also auch `audit:admin`. Gerettet
|
||||||
users: ['create', 'read', 'update', 'delete'],
|
// hat das nur die Reihenfolge im Container-Start (sync-roles lief danach
|
||||||
platforms: ['create', 'read', 'update', 'delete'],
|
// und raeumte Ueberzaehliges weg); ein einzelnes `npm run db:seed` brachte
|
||||||
providers: ['create', 'read', 'update', 'delete'],
|
// die Buendelung zurueck. Drei Beschreibungen desselben Sachverhalts sind
|
||||||
tariffs: ['create', 'read', 'update', 'delete'],
|
// zwei zu viel.
|
||||||
// Konfiguration (CRUD)
|
await synchronisiereRechteUndRollen(prisma);
|
||||||
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-durations': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-categories': ['create', 'read', 'update', 'delete'],
|
|
||||||
'email-providers': ['create', 'read', 'update', 'delete'],
|
|
||||||
// Einstellungen (nur lesen/ändern)
|
|
||||||
settings: ['read', 'update'],
|
|
||||||
// Spezial-Permissions
|
|
||||||
developer: ['access'],
|
|
||||||
emails: ['delete'],
|
|
||||||
// DSGVO & Audit
|
|
||||||
audit: ['read', 'export', 'admin'],
|
|
||||||
gdpr: ['export', 'delete', 'admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const permissions: { resource: string; action: string }[] = [];
|
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
|
||||||
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
|
||||||
for (const action of actions) {
|
|
||||||
permissions.push({ resource, action });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const perm of permissions) {
|
|
||||||
await prisma.permission.upsert({
|
|
||||||
where: { resource_action: perm },
|
|
||||||
update: {},
|
|
||||||
create: perm,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Permissions created (${permissions.length} total)`);
|
|
||||||
|
|
||||||
// Get all permissions
|
|
||||||
const allPermissions = await prisma.permission.findMany();
|
|
||||||
const customerReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'customers' && p.action === 'read'
|
|
||||||
);
|
|
||||||
const contractReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'contracts' && p.action === 'read'
|
|
||||||
);
|
|
||||||
const platformReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'platforms' && p.action === 'read'
|
|
||||||
);
|
|
||||||
const providerReadPerm = allPermissions.find(
|
|
||||||
(p) => p.resource === 'providers' && p.action === 'read'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper: Sync permissions for a role (adds missing, removes excess)
|
|
||||||
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
|
||||||
const existing = await prisma.rolePermission.findMany({
|
|
||||||
where: { roleId },
|
|
||||||
select: { permissionId: true },
|
|
||||||
});
|
|
||||||
const existingIds = new Set(existing.map((e) => e.permissionId));
|
|
||||||
const targetIds = new Set(permissionIds);
|
|
||||||
|
|
||||||
// Add missing permissions
|
|
||||||
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
await prisma.rolePermission.createMany({
|
|
||||||
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
console.log(` → ${missing.length} Permissions hinzugefügt für Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove excess permissions
|
|
||||||
const excess = existing.filter((e) => !targetIds.has(e.permissionId)).map((e) => e.permissionId);
|
|
||||||
if (excess.length > 0) {
|
|
||||||
await prisma.rolePermission.deleteMany({
|
|
||||||
where: { roleId, permissionId: { in: excess } },
|
|
||||||
});
|
|
||||||
console.log(` → ${excess.length} Permissions entfernt für Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create roles
|
|
||||||
// Admin - all permissions EXCEPT developer:access and audit/gdpr (controlled separately via checkboxes)
|
|
||||||
const adminPermissions = allPermissions.filter(
|
|
||||||
(p) =>
|
|
||||||
!(p.resource === 'developer' && p.action === 'access') &&
|
|
||||||
p.resource !== 'audit' &&
|
|
||||||
p.resource !== 'gdpr'
|
|
||||||
);
|
|
||||||
const adminRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Admin' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Admin',
|
|
||||||
description: 'Voller Zugriff auf alle Funktionen',
|
|
||||||
permissions: {
|
|
||||||
create: adminPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(adminRole.id, adminPermissions.map((p) => p.id));
|
|
||||||
|
|
||||||
// Developer - ALL permissions (developer:access + alles andere)
|
|
||||||
const developerPermissions = allPermissions;
|
|
||||||
const developerRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Developer' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Developer',
|
|
||||||
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
|
||||||
permissions: {
|
|
||||||
create: developerPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(developerRole.id, developerPermissions.map((p) => p.id));
|
|
||||||
|
|
||||||
// DSGVO - audit and gdpr permissions (hidden role, controlled via hasGdprAccess)
|
|
||||||
const gdprPermissions = allPermissions.filter(
|
|
||||||
(p) => p.resource === 'audit' || p.resource === 'gdpr'
|
|
||||||
);
|
|
||||||
const gdprRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'DSGVO' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'DSGVO',
|
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
|
||||||
permissions: {
|
|
||||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(gdprRole.id, gdprPermissions.map((p) => p.id));
|
|
||||||
|
|
||||||
// Employee - full access to customers, contracts, read access to lookup tables
|
|
||||||
const employeePermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.resource === 'customers' ||
|
|
||||||
p.resource === 'contracts' ||
|
|
||||||
// Read-only Zugriff auf Stammdaten und Konfiguration
|
|
||||||
(p.action === 'read' && [
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
].includes(p.resource))
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
const employeeRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Mitarbeiter' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Mitarbeiter',
|
|
||||||
description: 'Kann Kunden und Verträge verwalten',
|
|
||||||
permissions: {
|
|
||||||
create: employeePermIds.map((id) => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(employeeRole.id, employeePermIds);
|
|
||||||
|
|
||||||
// Read-only employee - read access to main entities and lookup tables
|
|
||||||
const readOnlyResources = [
|
|
||||||
'customers',
|
|
||||||
'contracts',
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
];
|
|
||||||
const readOnlyPermIds = allPermissions
|
|
||||||
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
const readOnlyRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Mitarbeiter (Nur-Lesen)' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Mitarbeiter (Nur-Lesen)',
|
|
||||||
description: 'Kann nur lesen, keine Änderungen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(readOnlyRole.id, readOnlyPermIds);
|
|
||||||
|
|
||||||
// Customer role - read own data only (handled in middleware)
|
|
||||||
const customerRole = await prisma.role.upsert({
|
|
||||||
where: { name: 'Kunde' },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
name: 'Kunde',
|
|
||||||
description: 'Kann nur eigene Daten lesen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map((id) => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await syncRolePermissions(customerRole.id, readOnlyPermIds);
|
|
||||||
|
|
||||||
console.log('Roles created');
|
|
||||||
|
|
||||||
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
// Admin-User anlegen. Standard-Passwort darf NIEMALS in der Source-Repo
|
||||||
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
|
// landen (Pentest Runde 12: "admin" verletzt die eigene 12-Zeichen-
|
||||||
@@ -265,8 +68,19 @@ async function main() {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
firstName: 'Admin',
|
firstName: 'Admin',
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
|
// Zusaetzlich die DSGVO-Rolle (Pentest R189-01).
|
||||||
|
//
|
||||||
|
// Ohne sie kann nach einem frischen Seed NIEMAND eine Auskunft nach
|
||||||
|
// Art. 15 oder eine Loeschung nach Art. 17 ausfuehren - die Rechte
|
||||||
|
// haengen an DSGVO und Developer, und beide waren keinem Konto
|
||||||
|
// zugewiesen. Ein Ausfall mit Fristwirkung, ausgeloest durch nichts
|
||||||
|
// weiter als eine Neuinstallation.
|
||||||
|
//
|
||||||
|
// Die Trennung bleibt: Die Admin-ROLLE bekommt diese Rechte weiterhin
|
||||||
|
// nicht. Nur dieses eine Bootstrap-Konto traegt beide, damit ueberhaupt
|
||||||
|
// jemand handlungsfaehig ist.
|
||||||
roles: {
|
roles: {
|
||||||
create: [{ roleId: adminRole.id }],
|
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -497,6 +311,16 @@ async function main() {
|
|||||||
description: 'Allgemeine Einstellungen',
|
description: 'Allgemeine Einstellungen',
|
||||||
legalBasis: 'Verjährungsfrist (BGB §195)',
|
legalBasis: 'Verjährungsfrist (BGB §195)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Stiller Token-Refresh (Routine). Ohne eigene Regel fiele diese
|
||||||
|
// Kombination in die Auffangregel `*` mit 3650 Tagen – das Rauschen
|
||||||
|
// wäre dann länger aufbewahrt als echte Logins (730 Tage).
|
||||||
|
resourceType: 'Authentication',
|
||||||
|
sensitivity: 'LOW' as const,
|
||||||
|
retentionDays: 90,
|
||||||
|
description: 'Routine-Auth (stiller Token-Refresh)',
|
||||||
|
legalBasis: 'Betriebsnotwendigkeit / Datenminimierung (DSGVO Art. 5)',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const policy of specificPolicies) {
|
for (const policy of specificPolicies) {
|
||||||
|
|||||||
+20
-148
@@ -1,160 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Idempotenter Permissions+Rollen-Sync für den Container-Start.
|
* Bringt Rechtekatalog und Systemrollen beim Container-Start auf Stand.
|
||||||
*
|
*
|
||||||
* Hintergrund: seed.ts läuft nur auf leeren DBs (USER_COUNT=0). Wer das
|
* Hintergrund: seed.ts laeuft nur auf leeren Datenbanken (USER_COUNT=0). Wer
|
||||||
* System schon installiert hat, bekommt nachträglich hinzugefügte
|
* das System schon installiert hat, bekommt nachtraeglich hinzugefuegte
|
||||||
* Permissions oder neue Rollenzuordnungen NICHT — die DSGVO-Rolle kann
|
* Rechte oder geaenderte Rollenzuordnungen sonst NICHT.
|
||||||
* dann z.B. ohne audit:read landen, obwohl Settings.tsx das voraussetzt.
|
|
||||||
*
|
*
|
||||||
* Dieses Skript synchronisiert ausschließlich:
|
* Die Definition selbst steht in `src/config/rechte-katalog.ts`, die Logik in
|
||||||
* - Permission-Katalog (resource/action-Paare aus dem Code)
|
* `src/services/rollen-sync.service.ts`. Dieses Skript ist nur noch der
|
||||||
* - Roll-Zuordnungen (Admin, Developer, DSGVO, Mitarbeiter,
|
* Einstiegspunkt fuer die Kommandozeile - bis 09/2026 trug es eine eigene
|
||||||
* Mitarbeiter (Nur-Lesen), Kunde)
|
* Kopie des Katalogs, und es war nicht die einzige.
|
||||||
*
|
*
|
||||||
* KEINE Stammdaten, KEINE User, KEINE Verträge — das Skript ist auf
|
* KEINE Stammdaten, KEINE Benutzer, KEINE Vertraege - auf laufenden
|
||||||
* laufenden Prod-DBs sicher.
|
* Produktionsdatenbanken sicher.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
// Ueber src/lib/prisma.js, NICHT ueber einen eigenen PrismaClient: Dort wird
|
||||||
|
// DATABASE_URL aus den DB_*-Teilen zusammengesetzt, falls sie fehlt. Bei
|
||||||
|
// `docker compose exec <dienst> npx tsx prisma/<skript>.ts` ist genau das der
|
||||||
|
// Fall - der Entrypoint exportiert sie nur in den Serverprozess, eine neue
|
||||||
|
// Shell erbt sie nicht. Ein eigener Client scheitert dort mit
|
||||||
|
// "Environment variable not found: DATABASE_URL".
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
import { synchronisiereRechteUndRollen } from '../src/services/rollen-sync.service.js';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
const RESOURCE_PERMISSIONS: Record<string, string[]> = {
|
synchronisiereRechteUndRollen(prisma)
|
||||||
customers: ['create', 'read', 'update', 'delete'],
|
|
||||||
contracts: ['create', 'read', 'update', 'delete'],
|
|
||||||
users: ['create', 'read', 'update', 'delete'],
|
|
||||||
platforms: ['create', 'read', 'update', 'delete'],
|
|
||||||
providers: ['create', 'read', 'update', 'delete'],
|
|
||||||
tariffs: ['create', 'read', 'update', 'delete'],
|
|
||||||
'cancellation-periods': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-durations': ['create', 'read', 'update', 'delete'],
|
|
||||||
'contract-categories': ['create', 'read', 'update', 'delete'],
|
|
||||||
'email-providers': ['create', 'read', 'update', 'delete'],
|
|
||||||
settings: ['read', 'update'],
|
|
||||||
developer: ['access'],
|
|
||||||
emails: ['delete'],
|
|
||||||
audit: ['read', 'export', 'admin'],
|
|
||||||
gdpr: ['export', 'delete', 'admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
async function syncRolePermissions(roleId: number, permissionIds: number[]) {
|
|
||||||
const existing = await prisma.rolePermission.findMany({
|
|
||||||
where: { roleId },
|
|
||||||
select: { permissionId: true },
|
|
||||||
});
|
|
||||||
const existingIds = new Set(existing.map((e) => e.permissionId));
|
|
||||||
const targetIds = new Set(permissionIds);
|
|
||||||
|
|
||||||
const missing = permissionIds.filter((id) => !existingIds.has(id));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
await prisma.rolePermission.createMany({
|
|
||||||
data: missing.map((permissionId) => ({ roleId, permissionId })),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
console.log(` → +${missing.length} Permissions an Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const excess = existing
|
|
||||||
.filter((e) => !targetIds.has(e.permissionId))
|
|
||||||
.map((e) => e.permissionId);
|
|
||||||
if (excess.length > 0) {
|
|
||||||
await prisma.rolePermission.deleteMany({
|
|
||||||
where: { roleId, permissionId: { in: excess } },
|
|
||||||
});
|
|
||||||
console.log(` → -${excess.length} Permissions von Rolle #${roleId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('[sync-roles] Permissions-Katalog upserten…');
|
|
||||||
for (const [resource, actions] of Object.entries(RESOURCE_PERMISSIONS)) {
|
|
||||||
for (const action of actions) {
|
|
||||||
await prisma.permission.upsert({
|
|
||||||
where: { resource_action: { resource, action } },
|
|
||||||
update: {},
|
|
||||||
create: { resource, action },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const allPermissions = await prisma.permission.findMany();
|
|
||||||
console.log(`[sync-roles] ${allPermissions.length} Permissions vorhanden`);
|
|
||||||
|
|
||||||
// Admin: alles AUSSER developer:access und audit/gdpr (DSGVO + Developer
|
|
||||||
// sind separate hidden roles, über Checkboxen zugewiesen)
|
|
||||||
const adminPermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
!(p.resource === 'developer' && p.action === 'access') &&
|
|
||||||
p.resource !== 'audit' &&
|
|
||||||
p.resource !== 'gdpr'
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Developer: alles
|
|
||||||
const developerPermIds = allPermissions.map((p) => p.id);
|
|
||||||
|
|
||||||
// DSGVO: audit + gdpr komplett
|
|
||||||
const gdprPermIds = allPermissions
|
|
||||||
.filter((p) => p.resource === 'audit' || p.resource === 'gdpr')
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Mitarbeiter: customers + contracts + read auf Stammdaten
|
|
||||||
const employeePermIds = allPermissions
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.resource === 'customers' ||
|
|
||||||
p.resource === 'contracts' ||
|
|
||||||
(p.action === 'read' &&
|
|
||||||
[
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
].includes(p.resource))
|
|
||||||
)
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
// Read-only Mitarbeiter + Kunde: nur read auf Haupt-Entities + Stammdaten
|
|
||||||
const readOnlyResources = [
|
|
||||||
'customers',
|
|
||||||
'contracts',
|
|
||||||
'platforms',
|
|
||||||
'providers',
|
|
||||||
'tariffs',
|
|
||||||
'cancellation-periods',
|
|
||||||
'contract-durations',
|
|
||||||
'contract-categories',
|
|
||||||
];
|
|
||||||
const readOnlyPermIds = allPermissions
|
|
||||||
.filter((p) => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
||||||
.map((p) => p.id);
|
|
||||||
|
|
||||||
const rolesSpec: Array<{ name: string; description: string; permIds: number[] }> = [
|
|
||||||
{ name: 'Admin', description: 'Voller Zugriff auf alle Funktionen', permIds: adminPermIds },
|
|
||||||
{ name: 'Developer', description: 'Voller Zugriff inkl. Entwickler-Tools', permIds: developerPermIds },
|
|
||||||
{ name: 'DSGVO', description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung', permIds: gdprPermIds },
|
|
||||||
{ name: 'Mitarbeiter', description: 'Kann Kunden und Verträge verwalten', permIds: employeePermIds },
|
|
||||||
{ name: 'Mitarbeiter (Nur-Lesen)', description: 'Kann nur lesen, keine Änderungen', permIds: readOnlyPermIds },
|
|
||||||
{ name: 'Kunde', description: 'Kann nur eigene Daten lesen', permIds: readOnlyPermIds },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const r of rolesSpec) {
|
|
||||||
const role = await prisma.role.upsert({
|
|
||||||
where: { name: r.name },
|
|
||||||
update: { description: r.description },
|
|
||||||
create: { name: r.name, description: r.description },
|
|
||||||
});
|
|
||||||
await syncRolePermissions(role.id, r.permIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[sync-roles] fertig.');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error('[sync-roles] Fehler:', e);
|
console.error('[rollen-sync] Fehler:', e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Pentester-Hilfsscript (Pentest R152): legt einen KONTROLLIERTEN „getarnten"
|
||||||
|
* Upload an, um den Magic-Byte-Mismatch→attachment-Zweig des Download-Endpoints
|
||||||
|
* live zu belegen.
|
||||||
|
*
|
||||||
|
* Was passiert:
|
||||||
|
* - Es wird eine dedizierte TEST-Bankkarte (accountHolder-Marker) angelegt –
|
||||||
|
* ECHTE Kundendaten werden NICHT angefasst/überschrieben.
|
||||||
|
* - An diese Karte wird eine Datei mit `.pdf`-Endung gehängt, deren Inhalt
|
||||||
|
* aber ein SVG mit <script> ist (Non-Whitelist-Magic-Byte, klassischer
|
||||||
|
* Stored-XSS-Payload).
|
||||||
|
* - documentPath wird exakt gesetzt, damit findUploadOwner die Datei der
|
||||||
|
* Test-Bankkarte (→ Customer) zuordnet und der Ownership-Check greift.
|
||||||
|
*
|
||||||
|
* Erwartetes Verhalten am Endpoint
|
||||||
|
* GET /api/files/download?path=<pfad>&disposition=inline :
|
||||||
|
* → HTTP 200, aber `Content-Disposition: attachment` (NICHT inline),
|
||||||
|
* `X-Content-Type-Options: nosniff` → Browser lädt herunter, rendert
|
||||||
|
* NICHT im iframe → kein Stored-XSS.
|
||||||
|
* → Backend-Log: "[fileDownload] inline angefragt, aber Magic-Byte-Check
|
||||||
|
* fehlgeschlagen: <pfad>"
|
||||||
|
* Ohne `disposition=inline` ohnehin attachment (Default).
|
||||||
|
* Als fremder Portal-User: 403 (Ownership-Check, Karte gehört Test-Customer).
|
||||||
|
*
|
||||||
|
* Aufruf (im Backend-App-Verzeichnis / Container-WORKDIR /app):
|
||||||
|
* npx tsx scripts/seed-magic-byte-test.ts create [--customer <id>]
|
||||||
|
* npx tsx scripts/seed-magic-byte-test.ts cleanup
|
||||||
|
*
|
||||||
|
* Danach unbedingt `cleanup` laufen lassen (löscht Datei + Test-Bankkarte).
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import prisma from '../src/lib/prisma.js';
|
||||||
|
|
||||||
|
const MARKER = 'MAGICBYTE-TEST (Pentester R152)';
|
||||||
|
const FILENAME = 'magic-byte-test.pdf'; // .pdf-Endung, aber SVG-Inhalt (getarnt)
|
||||||
|
const UPLOAD_SUBDIR = 'bank-cards';
|
||||||
|
const RELATIVE_PATH = `/uploads/${UPLOAD_SUBDIR}/${FILENAME}`;
|
||||||
|
|
||||||
|
// SVG mit <script> – würde bei inline-Auslieferung als image/svg+xml im Browser
|
||||||
|
// ausgeführt. Genau das muss der Magic-Byte-Gate verhindern.
|
||||||
|
const SVG_XSS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="80">
|
||||||
|
<text x="10" y="40">MAGICBYTE-TEST</text>
|
||||||
|
<script type="text/javascript">/* Pentest R152 */ alert('XSS-MAGICBYTE-TEST');</script>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
function uploadsDir(): string {
|
||||||
|
return path.join(process.cwd(), 'uploads', UPLOAD_SUBDIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteFilePath(): string {
|
||||||
|
return path.join(uploadsDir(), FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(customerIdArg?: number): Promise<void> {
|
||||||
|
// Harter Prod-Riegel: dieses Script legt eine Fake-Bankkarte + getarnte
|
||||||
|
// Datei an – das darf NIE gegen Produktion laufen (weder versehentlich noch
|
||||||
|
// absichtlich). `cleanup` bleibt erlaubt, damit man immer aufräumen kann.
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console.error(
|
||||||
|
'ABBRUCH: "create" ist bei NODE_ENV=production deaktiviert. Dieser ' +
|
||||||
|
'Pentester-/Test-Helfer gehört nur nach Dev/Staging. (Zum Aufräumen ist ' +
|
||||||
|
'"cleanup" weiterhin erlaubt.)',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ziel-Customer bestimmen (Default: erster vorhandener Kunde).
|
||||||
|
const customer = customerIdArg
|
||||||
|
? await prisma.customer.findUnique({ where: { id: customerIdArg }, select: { id: true, customerNumber: true } })
|
||||||
|
: await prisma.customer.findFirst({ orderBy: { id: 'asc' }, select: { id: true, customerNumber: true } });
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
console.error('Kein Kunde gefunden – bitte zuerst einen Kunden anlegen oder --customer <id> angeben.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getarnte Datei schreiben.
|
||||||
|
fs.mkdirSync(uploadsDir(), { recursive: true });
|
||||||
|
fs.writeFileSync(absoluteFilePath(), SVG_XSS, 'utf8');
|
||||||
|
|
||||||
|
// Test-Bankkarte anlegen oder wiederverwenden (idempotent, kein Duplikat).
|
||||||
|
const existing = await prisma.bankCard.findFirst({ where: { accountHolder: MARKER } });
|
||||||
|
const card = existing
|
||||||
|
? await prisma.bankCard.update({ where: { id: existing.id }, data: { customerId: customer.id, documentPath: RELATIVE_PATH } })
|
||||||
|
: await prisma.bankCard.create({
|
||||||
|
data: {
|
||||||
|
customerId: customer.id,
|
||||||
|
accountHolder: MARKER,
|
||||||
|
iban: 'DE00000000000000000000',
|
||||||
|
documentPath: RELATIVE_PATH,
|
||||||
|
description: 'Pentest R152 – getarnte Datei (SVG-als-PDF). Nach Test via cleanup entfernen.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const head = Buffer.alloc(12);
|
||||||
|
const fd = fs.openSync(absoluteFilePath(), 'r');
|
||||||
|
fs.readSync(fd, head, 0, 12, 0);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
|
||||||
|
console.log('=== Magic-Byte-Test angelegt ===');
|
||||||
|
console.log('Test-Customer :', customer.customerNumber, `(id ${customer.id})`);
|
||||||
|
console.log('Test-Bankkarte : id', card.id, `– accountHolder="${MARKER}"`);
|
||||||
|
console.log('Datei (Disk) :', absoluteFilePath());
|
||||||
|
console.log('Erste 12 Bytes :', JSON.stringify(head.toString('latin1')), '(kein PDF/PNG/JPEG/GIF/WebP-Magic)');
|
||||||
|
console.log('documentPath :', RELATIVE_PATH);
|
||||||
|
console.log('');
|
||||||
|
console.log('--- So testen (Token des berechtigten Users anhängen) ---');
|
||||||
|
console.log(` curl -sSI "https://<host>/api/files/download?path=${RELATIVE_PATH}&disposition=inline&token=<JWT>"`);
|
||||||
|
console.log(' Erwartet: 200, Content-Disposition: attachment, X-Content-Type-Options: nosniff');
|
||||||
|
console.log(' (NICHT inline) + Backend-Log "Magic-Byte-Check fehlgeschlagen".');
|
||||||
|
console.log(` Ohne &disposition=inline: ebenfalls attachment.`);
|
||||||
|
console.log(' Als fremder Portal-User: 403 (Ownership-Check).');
|
||||||
|
console.log('');
|
||||||
|
console.log('>>> Nach dem Test aufräumen: npx tsx scripts/seed-magic-byte-test.ts cleanup');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(): Promise<void> {
|
||||||
|
let removed = 0;
|
||||||
|
const cards = await prisma.bankCard.findMany({ where: { accountHolder: MARKER } });
|
||||||
|
for (const c of cards) {
|
||||||
|
await prisma.bankCard.delete({ where: { id: c.id } });
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
// Datei löschen (falls vorhanden).
|
||||||
|
const abs = absoluteFilePath();
|
||||||
|
let fileDeleted = false;
|
||||||
|
if (fs.existsSync(abs)) {
|
||||||
|
fs.unlinkSync(abs);
|
||||||
|
fileDeleted = true;
|
||||||
|
}
|
||||||
|
console.log('=== Cleanup ===');
|
||||||
|
console.log('Gelöschte Test-Bankkarten:', removed);
|
||||||
|
console.log('Datei gelöscht :', fileDeleted ? abs : '(nicht vorhanden)');
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const mode = process.argv[2] || 'create';
|
||||||
|
const custIdx = process.argv.indexOf('--customer');
|
||||||
|
const customerIdArg = custIdx >= 0 ? parseInt(process.argv[custIdx + 1], 10) : undefined;
|
||||||
|
try {
|
||||||
|
if (mode === 'create') {
|
||||||
|
await create(Number.isFinite(customerIdArg as number) ? customerIdArg : undefined);
|
||||||
|
} else if (mode === 'cleanup') {
|
||||||
|
await cleanup();
|
||||||
|
} else {
|
||||||
|
console.error(`Unbekannter Modus "${mode}". Nutze: create | cleanup`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler:', err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,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 { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as appSettingService from '../services/appSetting.service.js';
|
import * as appSettingService from '../services/appSetting.service.js';
|
||||||
import { logChange } from '../services/audit.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);
|
res.json({ success: true, message: 'Einstellung gespeichert' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,9 +144,6 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
|
res.json({ success: true, message: 'Einstellungen gespeichert' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Speichern der Einstellungen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Einstellungen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,127 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
|
import { FachlicherFehler } from '../utils/apiError.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as auditService from '../services/audit.service.js';
|
import * as auditService from '../services/audit.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { AuditAction, AuditSensitivity } from '@prisma/client';
|
import { AuditAction, AuditSensitivity } from '@prisma/client';
|
||||||
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filterwerte aus der Query pruefen (Pentest R185-02).
|
||||||
|
*
|
||||||
|
* Vorher gingen `action` und `sensitivity` als roher String an die Enum-Spalte.
|
||||||
|
* Ein ungueltiger Wert liess Prisma auflaufen und der Handler antwortete 500.
|
||||||
|
* Das ist zweierlei: eine fehlende Validierung – und ein Fehler-Orakel. Wer
|
||||||
|
* 200 gegen 500 vergleicht, liest die Enum-Mitglieder aus, ohne sie zu kennen.
|
||||||
|
* Ein ungueltiger Filter ist eine schlechte ANFRAGE, keine Server-Panne: 400.
|
||||||
|
*
|
||||||
|
* Dasselbe gilt fuer Datumsangaben (`new Date('foo')` ergibt Invalid Date und
|
||||||
|
* sprengt die Query erst in der Datenbank) und fuer Zahlen (`parseInt('x')`
|
||||||
|
* ergibt NaN).
|
||||||
|
*/
|
||||||
|
class FilterFehler extends 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
|
* Audit-Logs mit Filtern abrufen
|
||||||
*/
|
*/
|
||||||
export async function getAuditLogs(req: AuthRequest, res: Response) {
|
export async function getAuditLogs(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const {
|
|
||||||
userId,
|
|
||||||
customerId,
|
|
||||||
dataSubjectId,
|
|
||||||
action,
|
|
||||||
sensitivity,
|
|
||||||
resourceType,
|
|
||||||
resourceId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
success,
|
|
||||||
search,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
} = req.query;
|
|
||||||
|
|
||||||
const result = await auditService.searchAuditLogs({
|
const result = await auditService.searchAuditLogs({
|
||||||
userId: userId ? parseInt(userId as string) : undefined,
|
...leseFilter(req),
|
||||||
customerId: customerId ? parseInt(customerId as string) : undefined,
|
page: pruefeZahl(req.query.page, 'page') || 1,
|
||||||
dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined,
|
// Deckel: sonst laesst sich ueber `limit` die gesamte Tabelle in einem
|
||||||
action: action as AuditAction | undefined,
|
// Zug ziehen, an der Seitenlogik vorbei.
|
||||||
sensitivity: sensitivity as AuditSensitivity | undefined,
|
limit: Math.min(pruefeZahl(req.query.limit, 'limit') || 50, 200),
|
||||||
resourceType: resourceType as string | undefined,
|
|
||||||
resourceId: resourceId as string | undefined,
|
|
||||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
|
||||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
|
||||||
success: success !== undefined ? success === 'true' : undefined,
|
|
||||||
search: search as string | undefined,
|
|
||||||
page: page ? parseInt(page as string) : 1,
|
|
||||||
limit: limit ? parseInt(limit as string) : 50,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, ...result });
|
res.json({ success: true, ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof FilterFehler) {
|
||||||
|
res.status(400).json({ success: false, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Fehler beim Abrufen der Audit-Logs:', error);
|
console.error('Fehler beim Abrufen der Audit-Logs:', error);
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
|
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
|
||||||
}
|
}
|
||||||
@@ -96,25 +175,8 @@ export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const format = (req.query.format as 'json' | 'csv') || 'json';
|
const format = req.query.format === 'csv' ? 'csv' : 'json';
|
||||||
const {
|
const content = await auditService.exportAuditLogs(leseFilter(req), format);
|
||||||
action,
|
|
||||||
sensitivity,
|
|
||||||
resourceType,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
} = req.query;
|
|
||||||
|
|
||||||
const content = await auditService.exportAuditLogs(
|
|
||||||
{
|
|
||||||
action: action as AuditAction | undefined,
|
|
||||||
sensitivity: sensitivity as AuditSensitivity | undefined,
|
|
||||||
resourceType: resourceType as string | undefined,
|
|
||||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
|
||||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
|
||||||
},
|
|
||||||
format
|
|
||||||
);
|
|
||||||
|
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
@@ -126,6 +188,10 @@ export async function exportAuditLogs(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: JSON.parse(content) })
|
res.json({ success: true, data: JSON.parse(content) })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof FilterFehler) {
|
||||||
|
res.status(400).json({ success: false, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Fehler beim Exportieren der Audit-Logs:', error);
|
console.error('Fehler beim Exportieren der Audit-Logs:', error);
|
||||||
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
|
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
|
||||||
}
|
}
|
||||||
@@ -143,15 +209,178 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
toId ? parseInt(toId as string) : undefined
|
toId ? parseInt(toId as string) : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Zwei sehr unterschiedliche Befunde sauber trennen – vorher wurde beides
|
||||||
|
// pauschal als "manipuliert" gemeldet, was strukturelle Lücken wie einen
|
||||||
|
// echten Angriff aussehen liess (und damit die Meldung entwertete).
|
||||||
|
const tampered = result.tamperedEntries.length;
|
||||||
|
const gaps = result.chainGaps.length;
|
||||||
|
const unexplained = result.unexplainedGaps.length;
|
||||||
|
// Beglaubigte Alt-Luecken sind kein offener Befund mehr, verschwinden aber
|
||||||
|
// auch nicht aus dem Bericht - sie werden eigens benannt.
|
||||||
|
const beglaubigt = result.attestedGaps.length;
|
||||||
|
const offeneGaps = gaps - beglaubigt;
|
||||||
|
const offeneUnexplained = result.unexplainedGaps.filter(
|
||||||
|
(id) => !result.attestedGaps.includes(id),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const luecken = offeneGaps > 0
|
||||||
|
? `${offeneGaps} strukturelle Lücke${offeneGaps === 1 ? '' : 'n'}` +
|
||||||
|
(offeneUnexplained === 0
|
||||||
|
? ' (alle durch protokollierte Löschungen erklärt)'
|
||||||
|
: offeneUnexplained < offeneGaps
|
||||||
|
? `, davon ${offeneUnexplained} ohne dokumentierte Löschung`
|
||||||
|
: ' ohne dokumentierte Löschung')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Der Satz erscheint IMMER, wenn beglaubigte Luecken existieren - auch
|
||||||
|
// neben einem Befund. Wer den Bericht liest, soll nie den Eindruck
|
||||||
|
// bekommen, die Kette sei lueckenlos, wenn sie es nicht ist.
|
||||||
|
const weitere = tampered > 0 || offeneGaps > 0 ? 'weitere ' : '';
|
||||||
|
const beglaubigtText =
|
||||||
|
beglaubigt === 0
|
||||||
|
? ''
|
||||||
|
: beglaubigt === 1
|
||||||
|
? ` Eine ${weitere}Lücke stammt aus der Zeit vor dem Bestandssiegel und ist darin als ` +
|
||||||
|
`Vorbefund beglaubigt (ID ${result.attestedGaps[0]}); der betroffene Eintrag selbst ` +
|
||||||
|
'ist unverändert.'
|
||||||
|
: ` ${beglaubigt} ${weitere}Lücken stammen aus der Zeit vor dem Bestandssiegel und sind ` +
|
||||||
|
`darin als Vorbefund beglaubigt (IDs ${result.attestedGaps.join(', ')}); ` +
|
||||||
|
'die betroffenen Einträge selbst sind unverändert.';
|
||||||
|
|
||||||
|
const unverifiable = result.unverifiableEntries.length;
|
||||||
|
const keinSchluessel = unverifiable > 0
|
||||||
|
? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const siegel =
|
||||||
|
result.backlogSealStatus === 'gebrochen'
|
||||||
|
? ' ⚠ Bestandssiegel GEBROCHEN:' +
|
||||||
|
(result.backlogTampered.length
|
||||||
|
? ` ${result.backlogTampered.length} Alteinträge nachträglich verändert.`
|
||||||
|
: '') +
|
||||||
|
(result.backlogMissing.length
|
||||||
|
? ` ${result.backlogMissing.length} gesiegelte Einträge wurden ENTFERNT (${result.backlogMissing.join(', ')}).`
|
||||||
|
: '') +
|
||||||
|
(!result.backlogTampered.length && !result.backlogMissing.length
|
||||||
|
? ' Der Blattbestand des Siegels stimmt nicht mehr mit der signierten Wurzel überein.'
|
||||||
|
: '')
|
||||||
|
: result.backlogSealStatus === 'entfernt'
|
||||||
|
? ' ⚠ Bestandssiegel ENTFERNT: Es liegen versiegelte Blattwerte vor, aber kein gültiger ' +
|
||||||
|
'Siegel-Marker mehr. Der Marker wurde gelöscht oder unbrauchbar gemacht – Änderungen am ' +
|
||||||
|
'Altbestand wären dadurch wieder unsichtbar. Das ist KEIN Normalzustand.'
|
||||||
|
: result.backlogSealStatus === 'leer'
|
||||||
|
? ' Hinweis: Es besteht ein Bestandssiegel, das aber NICHTS umschließt – ' +
|
||||||
|
'zum Zeitpunkt des Siegelns gab es keine unsignierten Alteinträge. Es sichert ' +
|
||||||
|
'also nichts ab. Das ist kein Fehler, aber auch keine Zusage.'
|
||||||
|
: result.backlogSealStatus === 'nicht_noetig'
|
||||||
|
? ' Ein Bestandssiegel wird hier nicht gebraucht: Es gibt keine unsignierten Alteinträge.'
|
||||||
|
: result.backlogSealStatus === 'kein_siegel'
|
||||||
|
? ' Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären nicht erkennbar. ' +
|
||||||
|
'Behebbar mit POST /api/audit-logs/seal-backlog {"confirm":"SEAL"}.'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Eine Neuberechnung der Kette gehoert IMMER erwaehnt – auch und gerade,
|
||||||
|
// wenn sonst alles grün ist. Nach einem Rehash ist die Kette
|
||||||
|
// zwangslaeufig stimmig, auch ueber Loeschungen hinweg. „Lueckenlos
|
||||||
|
// verkettet“ heisst dann nur noch „seit dem Rehash“, und wer das nicht
|
||||||
|
// mitliest, nimmt eine Entwarnung mit, die es so nicht gibt.
|
||||||
|
const rehashText = (() => {
|
||||||
|
if (result.rehashes.length === 0) return '';
|
||||||
|
const letzter = result.rehashes[result.rehashes.length - 1];
|
||||||
|
const datum = new Date(letzter.zeitpunkt).toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
const wieOft =
|
||||||
|
result.rehashes.length === 1
|
||||||
|
? 'Die Kette wurde einmal neu berechnet'
|
||||||
|
: `Die Kette wurde ${result.rehashes.length}× neu berechnet, zuletzt`;
|
||||||
|
// Nur benennen, was es zu benennen gibt. Bei sauberem Vorzustand ist die
|
||||||
|
// Auskunft „nichts uebertuencht“ selbst eine nuetzliche Information –
|
||||||
|
// ein Rehash ueber einer unbeanstandeten Kette wiegt anders als einer
|
||||||
|
// ueber 700 Luecken.
|
||||||
|
const vb = letzter.vorbefund;
|
||||||
|
const uebertuencht = !vb
|
||||||
|
? ' Der Zustand vor dieser Neuberechnung ist nicht mehr feststellbar.'
|
||||||
|
: vb.manipuliert === 0 && vb.luecken === 0
|
||||||
|
? ' Die Kette war unmittelbar davor unbeanstandet – es wurde nichts überdeckt.'
|
||||||
|
: ' Unmittelbar davor: ' +
|
||||||
|
`${vb.manipuliert} beanstandete${vb.manipuliert === 1 ? 'r Eintrag' : ' Einträge'}` +
|
||||||
|
` und ${vb.luecken} Lücke${vb.luecken === 1 ? '' : 'n'}. ` +
|
||||||
|
'Diese Spuren sind seitdem nicht mehr in der Kette sichtbar, sondern nur noch im ' +
|
||||||
|
`Vorbefund des Rehash-Eintrags (id ${letzter.id}).`;
|
||||||
|
const ohneSignatur = result.rehashes.some((r) => !r.signiert)
|
||||||
|
? ' Achtung: Mindestens ein Rehash-Eintrag trägt keine gültige Signatur.'
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
` ⚠ ${wieOft} am ${datum}` +
|
||||||
|
(letzter.neuBerechnet !== null ? ` (${letzter.neuBerechnet} Einträge)` : '') +
|
||||||
|
'. Ein Rehash verknüpft alle Einträge neu – die Aussage dieser Prüfung ' +
|
||||||
|
'reicht deshalb nur bis dorthin zurück, nicht weiter.' +
|
||||||
|
uebertuencht +
|
||||||
|
ohneSignatur
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es
|
||||||
|
// ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03).
|
||||||
|
const mehrfach =
|
||||||
|
result.backlogSealCount > 1
|
||||||
|
? ` Hinweis: Der Altbestand wurde ${result.backlogSealCount}× versiegelt – der jüngste Marker gilt. ` +
|
||||||
|
'Jeder Marker enthält den Befund vor dem Siegeln; bei Zweifeln dort vergleichen.'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Bei problematischem Siegel darf KEIN beruhigender Einstiegssatz stehen –
|
||||||
|
// sonst entwertet er die nachfolgende Warnung (Pentest R174-01).
|
||||||
|
const siegelProblem =
|
||||||
|
result.backlogSealStatus === 'gebrochen' || result.backlogSealStatus === 'entfernt';
|
||||||
|
|
||||||
|
// „Keine Manipulation“ darf NICHT dastehen, solange `valid` falsch ist
|
||||||
|
// (Pentest R183-02). Nach einem Cleanup mit abgesenkter Aufbewahrung waren
|
||||||
|
// tausende Anmeldeprotokolle endgueltig geloescht – die Luecken durch
|
||||||
|
// Tombstones „erklaert“, die Meldung las sich beruhigend, und der Befund
|
||||||
|
// stand nur noch im Feld `valid`. Wer die Prosa liest statt des Felds,
|
||||||
|
// klickt genau das weg, was ihn haette warnen sollen.
|
||||||
|
const message = tampered > 0
|
||||||
|
? `${tampered} MANIPULIERTE Einträge gefunden` + (offeneGaps > 0 ? ` (zusätzlich ${luecken})` : '')
|
||||||
|
: siegelProblem
|
||||||
|
? 'Die Kette selbst ist rechnerisch stimmig, ABER:'
|
||||||
|
: offeneGaps > 0
|
||||||
|
? `Die Kette ist nicht mehr lückenlos: ${luecken}. Die verbliebenen Inhalte sind ` +
|
||||||
|
'unverändert – aber gelöschte Einträge lassen sich naturgemäß nicht mehr prüfen. ' +
|
||||||
|
'Dokumentierte Löschungen sind erwartbar; unerwartete gehören nachgegangen.'
|
||||||
|
: beglaubigt > 0
|
||||||
|
? 'Alle Einträge sind unverändert. Seit dem Bestandssiegel ist keine neue Lücke ' +
|
||||||
|
'entstanden.'
|
||||||
|
: result.rehashes.length > 0
|
||||||
|
? 'Alle Einträge sind unverändert und lückenlos verkettet – allerdings erst ' +
|
||||||
|
'seit der letzten Neuberechnung.'
|
||||||
|
: 'Alle Einträge sind unverändert und lückenlos verkettet.';
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
valid: result.valid,
|
valid: result.valid,
|
||||||
checkedCount: result.checkedCount,
|
checkedCount: result.checkedCount,
|
||||||
invalidEntries: result.invalidEntries,
|
invalidEntries: result.invalidEntries,
|
||||||
message: result.valid
|
// Ernst: Inhalt einer bestehenden Zeile wurde nachträglich verändert.
|
||||||
? 'Alle Einträge sind valide'
|
tamperedEntries: result.tamperedEntries,
|
||||||
: `${result.invalidEntries.length} manipulierte Einträge gefunden`,
|
// Meist harmlos: Verkettung unterbrochen, Inhalte selbst unversehrt.
|
||||||
|
chainGaps: result.chainGaps,
|
||||||
|
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
|
||||||
|
unexplainedGaps: result.unexplainedGaps,
|
||||||
|
// Alt-Lücken, die das Bestandssiegel als bereits vorhanden beglaubigt.
|
||||||
|
attestedGaps: result.attestedGaps,
|
||||||
|
// Protokollierte Neuberechnungen – begrenzen die Reichweite der Aussage.
|
||||||
|
rehashes: result.rehashes,
|
||||||
|
tampered: tampered > 0,
|
||||||
|
message: message + beglaubigtText + rehashText + keinSchluessel + siegel + mehrfach,
|
||||||
|
unverifiableEntries: result.unverifiableEntries,
|
||||||
|
// Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||||||
|
backlogSealStatus: result.backlogSealStatus,
|
||||||
|
backlogTampered: result.backlogTampered,
|
||||||
|
backlogMissing: result.backlogMissing,
|
||||||
|
backlogSealCount: result.backlogSealCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -165,7 +394,26 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function rehashAll(req: AuthRequest, res: Response) {
|
export async function rehashAll(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const result = await auditService.rehashAll();
|
// Ausdrueckliche Bestaetigung verlangen (Pentest R170-01).
|
||||||
|
//
|
||||||
|
// Ein Rehash macht die Kette rechnerisch stimmig und setzt damit die
|
||||||
|
// Beweiskraft der Vergangenheit zurueck - das darf kein Nebeneffekt eines
|
||||||
|
// versehentlichen oder tastenden POST sein. Genau so wurde der Endpunkt
|
||||||
|
// bei einer Methoden-Erkundung unbeabsichtigt ausgeloest.
|
||||||
|
if (req.body?.confirm !== 'REHASH') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Rehash setzt die Beweiskraft der bestehenden Einträge zurück und ist nicht ' +
|
||||||
|
'umkehrbar. Zum Bestätigen {"confirm":"REHASH"} mitsenden.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await auditService.rehashAll({
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
ipAddress: req.ip || (req.socket as any)?.remoteAddress,
|
||||||
|
});
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
@@ -177,6 +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
|
* Retention-Policies abrufen
|
||||||
*/
|
*/
|
||||||
@@ -201,6 +571,48 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
|||||||
}
|
}
|
||||||
const { retentionDays, description, legalBasis, isActive } = req.body;
|
const { retentionDays, description, legalBasis, isActive } = req.body;
|
||||||
|
|
||||||
|
// Die Richtlinie bestimmt, WAS der Cleanup vernichtet – sie ist die
|
||||||
|
// geladene Waffe, der Cleanup nur der Abzug (Pentest R183-01). Bisher war
|
||||||
|
// ausgerechnet der Abzug gegatet und die Waffe frei zugaenglich: Ein
|
||||||
|
// `retentionDays: 0` ging ohne Bestaetigung durch und wurde nur als MEDIUM
|
||||||
|
// protokolliert, waehrend seine Wirkung CRITICAL ist. Die ausloesende Tat
|
||||||
|
// war damit leiser als die Folge – und lag unter der Schwelle, bei der
|
||||||
|
// jemand hinsieht.
|
||||||
|
const bisher = (await auditService.getRetentionPolicies()).find((p) => p.id === id);
|
||||||
|
if (!bisher) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Aufbewahrungsrichtlinie nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const neueTage = typeof retentionDays === 'number' ? retentionDays : bisher.retentionDays;
|
||||||
|
|
||||||
|
// Untergrenze: Auth-Eintraege sind der Einbruchsbeleg schlechthin. Eine
|
||||||
|
// Aufbewahrung von faktisch null macht sie mit dem naechsten Cleanup
|
||||||
|
// spurlos entfernbar – ueber einen sanktionierten Pfad.
|
||||||
|
const UNTERGRENZE: Record<string, number> = { Authentication: 30, AuditLog: 30 };
|
||||||
|
const grenze = UNTERGRENZE[bisher.resourceType];
|
||||||
|
if (grenze !== undefined && neueTage < grenze) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`Für ${bisher.resourceType} sind mindestens ${grenze} Tage Aufbewahrung vorgeschrieben ` +
|
||||||
|
`(angefragt: ${neueTage}). Diese Einträge belegen Anmeldungen und Zugriffe – eine ` +
|
||||||
|
`Aufbewahrung nahe null macht sie beim nächsten Cleanup spurlos entfernbar.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absenken ist die gefaehrliche Richtung und braucht dieselbe ausdrueckliche
|
||||||
|
// Bestaetigung wie der Cleanup selbst.
|
||||||
|
const senktAb = neueTage < bisher.retentionDays;
|
||||||
|
if (senktAb && req.body?.confirm !== 'SHORTEN') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`Das verkürzt die Aufbewahrung von ${bisher.retentionDays} auf ${neueTage} Tage. ` +
|
||||||
|
'Beim nächsten Cleanup werden dadurch Einträge endgültig gelöscht, die heute noch ' +
|
||||||
|
'da sind. Zum Bestätigen {"confirm":"SHORTEN"} mitsenden.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const policy = await auditService.updateRetentionPolicy(id, {
|
const policy = await auditService.updateRetentionPolicy(id, {
|
||||||
retentionDays,
|
retentionDays,
|
||||||
description,
|
description,
|
||||||
@@ -211,7 +623,15 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
|||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
|
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
|
||||||
resourceId: id.toString(),
|
resourceId: id.toString(),
|
||||||
label: `Aufbewahrungsrichtlinie aktualisiert`,
|
// Absenkung wird wie ihre Folge eingestuft: CRITICAL, nicht MEDIUM.
|
||||||
|
sensitivity: senktAb ? 'CRITICAL' : undefined,
|
||||||
|
label: senktAb
|
||||||
|
? `Aufbewahrung VERKÜRZT für ${bisher.resourceType}` +
|
||||||
|
(bisher.sensitivity ? `/${bisher.sensitivity}` : '') +
|
||||||
|
`: ${bisher.retentionDays} → ${neueTage} Tage`
|
||||||
|
: `Aufbewahrungsrichtlinie aktualisiert`,
|
||||||
|
before: { retentionDays: bisher.retentionDays },
|
||||||
|
details: { retentionDays: neueTage },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, data: policy });
|
res.json({ success: true, data: policy });
|
||||||
@@ -226,6 +646,18 @@ export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function runRetentionCleanup(req: AuthRequest, res: Response) {
|
export async function runRetentionCleanup(req: AuthRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
// Auch hier ausdrueckliche Bestaetigung: Cleanup loescht Audit-Zeilen
|
||||||
|
// endgueltig und reisst dabei die Kette auf (Pentest R170-01).
|
||||||
|
if (req.body?.confirm !== 'CLEANUP') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Cleanup löscht Audit-Einträge endgültig. Zum Bestätigen ' +
|
||||||
|
'{"confirm":"CLEANUP"} mitsenden.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await auditService.runRetentionCleanup();
|
const result = await auditService.runRetentionCleanup();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response, CookieOptions } from 'express';
|
import { Request, Response, CookieOptions } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
@@ -281,10 +282,7 @@ export async function confirmPasswordReset(req: Request, res: Response): Promise
|
|||||||
ipAddress: ctx.ipAddress,
|
ipAddress: ctx.ipAddress,
|
||||||
endpoint: ctx.endpoint,
|
endpoint: ctx.endpoint,
|
||||||
});
|
});
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Passwort-Reset fehlgeschlagen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Passwort-Reset fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,11 +307,16 @@ export async function logout(req: AuthRequest, res: Response): Promise<void> {
|
|||||||
where: { id: user.customerId },
|
where: { id: user.customerId },
|
||||||
data: { portalTokenInvalidatedAt: new Date() },
|
data: { portalTokenInvalidatedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
// Ausgegebene Refresh-Tokens zusätzlich hart widerrufen (R164-02):
|
||||||
|
// der Zeitstempel-Vergleich allein greift erst beim nächsten Refresh,
|
||||||
|
// der Widerruf macht die Tokens sofort unbrauchbar.
|
||||||
|
await authService.revokeAllRefreshTokens({ customerId: user.customerId }, 'Logout');
|
||||||
} else if (user.userId) {
|
} else if (user.userId) {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.userId },
|
where: { id: user.userId },
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
await authService.revokeAllRefreshTokens({ userId: user.userId }, 'Logout');
|
||||||
}
|
}
|
||||||
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
|
// Refresh-Cookie löschen, sonst könnte der Browser einen abgemeldeten User
|
||||||
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
|
// direkt wieder einloggen (server-seitige Invalidation oben fängt das ab,
|
||||||
@@ -362,10 +365,45 @@ export async function refresh(req: Request, res: Response): Promise<void> {
|
|||||||
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
// Refresh fehlgeschlagen: Cookie wegputzen, damit der Browser nicht
|
||||||
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
// weiter mit einem invaliden Token weiterhin den Endpoint klopft.
|
||||||
clearRefreshCookie(res);
|
clearRefreshCookie(res);
|
||||||
res.status(401).json({
|
// Detection (Pentest R164-01): ein vorgelegter, aber abgelehnter Refresh-Token
|
||||||
success: false,
|
// ist potenzieller Replay/Brute-Force → TOKEN_REJECTED emittieren, damit die
|
||||||
error: error instanceof Error ? error.message : 'Refresh fehlgeschlagen',
|
// bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP → CRITICAL) greift.
|
||||||
} as ApiResponse);
|
// 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);
|
clearRefreshCookie(res);
|
||||||
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
|
res.json({ success: true, message: 'Passwort geändert' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Passwort konnte nicht geändert werden', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Passwort konnte nicht geändert werden',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,7 +374,12 @@ export async function factoryReset(req: Request, res: Response) {
|
|||||||
label: `Werkseinstellungen wiederhergestellt`,
|
label: `Werkseinstellungen wiederhergestellt`,
|
||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Werkseinstellungen wiederhergestellt. Bitte melden Sie sich mit admin@admin.com / admin an.',
|
// Das Kennwort ist nicht mehr "admin", sondern zufaellig und steht
|
||||||
|
// genau einmal im Server-Log. Die alte Meldung hier nannte es noch
|
||||||
|
// im Klartext - sie haette nach der Haertung ins Leere gefuehrt.
|
||||||
|
message:
|
||||||
|
'Werkseinstellungen wiederhergestellt. Das neue Kennwort für admin@admin.com ' +
|
||||||
|
'steht einmalig im Server-Log (docker compose logs backend).',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { isValidIBAN } from 'ibantools';
|
import { isValidIBAN } from 'ibantools';
|
||||||
import * as blzData from '../services/blzData.service.js';
|
import * as blzData from '../services/blzData.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
@@ -47,9 +48,6 @@ export async function lookupIban(req: AuthRequest, res: Response): Promise<void>
|
|||||||
},
|
},
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim IBAN-Nachschlagen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim IBAN-Nachschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as birthdayService from '../services/birthday.service.js';
|
import * as birthdayService from '../services/birthday.service.js';
|
||||||
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
|
import { sendEmail, SmtpCredentials } from '../services/smtpService.js';
|
||||||
@@ -89,10 +90,7 @@ export async function resetBirthdayGreeting(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Zurücksetzen:', error);
|
console.error('Fehler beim Zurücksetzen:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,9 +178,6 @@ export async function sendBirthdayGreeting(req: AuthRequest, res: Response) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
|
console.error('Fehler beim Senden des Geburtstagsgrußes:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Senden',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import * as blzData from '../services/blzData.service.js';
|
import * as blzData from '../services/blzData.service.js';
|
||||||
@@ -9,10 +10,7 @@ export async function getStatus(req: AuthRequest, res: Response): Promise<void>
|
|||||||
const status = await blzData.getStatus();
|
const status = await blzData.getStatus();
|
||||||
res.json({ success: true, data: status } as ApiResponse);
|
res.json({ success: true, data: status } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Laden des BLZ-Status', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Laden des BLZ-Status',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +30,6 @@ export async function updateNow(req: AuthRequest, res: Response): Promise<void>
|
|||||||
const status = await blzData.getStatus();
|
const status = await blzData.getStatus();
|
||||||
res.json({ success: true, data: { result, status } } as ApiResponse);
|
res.json({ success: true, data: { result, status } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(502).json({
|
antworteAufFehler(res, error, 'BLZ-Aktualisierung fehlgeschlagen', 502);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'BLZ-Aktualisierung fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// ==================== CACHED EMAIL CONTROLLER ====================
|
// ==================== CACHED EMAIL CONTROLLER ====================
|
||||||
|
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as cachedEmailService from '../services/cachedEmail.service.js';
|
import * as cachedEmailService from '../services/cachedEmail.service.js';
|
||||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||||
import * as invoiceService from '../services/invoice.service.js';
|
import * as invoiceService from '../services/invoice.service.js';
|
||||||
@@ -272,10 +273,7 @@ export async function assignToContract(req: AuthRequest, res: Response): Promise
|
|||||||
res.json({ success: true, data: email } as ApiResponse);
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('assignToContract error:', error);
|
console.error('assignToContract error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Zuordnen der E-Mail');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Zuordnen der E-Mail',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,10 +288,7 @@ export async function unassignFromContract(req: AuthRequest, res: Response): Pro
|
|||||||
res.json({ success: true, data: email } as ApiResponse);
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('unassignFromContract error:', error);
|
console.error('unassignFromContract error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aufheben der Zuordnung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aufheben der Zuordnung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1660,12 +1655,7 @@ export async function saveAttachmentTo(req: AuthRequest, res: Response): Promise
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentTo error:', error);
|
console.error('saveAttachmentTo error:', error);
|
||||||
// Detailliertere Fehlermeldung für Debugging
|
// Detailliertere Fehlermeldung für Debugging
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Speichern des Anhangs', 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1909,11 +1899,7 @@ export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<v
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveEmailAsPdf error:', error);
|
console.error('saveEmailAsPdf error:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der PDF', 500);
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: `Fehler beim Erstellen der PDF: ${errorMessage}`,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2036,11 +2022,7 @@ export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promi
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveEmailAsInvoice error:', error);
|
console.error('saveEmailAsInvoice error:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 500);
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2145,9 +2127,7 @@ export async function saveEmailAsContractDocument(req: AuthRequest, res: Respons
|
|||||||
console.error('saveEmailAsContractDocument error:', error);
|
console.error('saveEmailAsContractDocument error:', error);
|
||||||
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
|
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
|
||||||
// Lock) statt pauschal 500.
|
// Lock) statt pauschal 500.
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
|
||||||
res.status(status).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2332,12 +2312,7 @@ export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response):
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsInvoice error:', error);
|
console.error('saveAttachmentAsInvoice error:', error);
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Rechnung', 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2512,11 +2487,6 @@ export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Re
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveAttachmentAsContractDocument error:', error);
|
console.error('saveAttachmentAsContractDocument error:', error);
|
||||||
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
|
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Speichern', 500);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: `Fehler beim Speichern: ${errorMessage}`,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as cancellationPeriodService from '../services/cancellation-period.service.js';
|
import * as cancellationPeriodService from '../services/cancellation-period.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.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);
|
res.status(201).json({ success: true, data: period } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Kündigungsfrist');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Kündigungsfrist',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +62,7 @@ export async function updateCancellationPeriod(req: Request, res: Response): Pro
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: period } as ApiResponse);
|
res.json({ success: true, data: period } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Kündigungsfrist');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Kündigungsfrist',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +78,6 @@ export async function deleteCancellationPeriod(req: Request, res: Response): Pro
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Kündigungsfrist gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Kündigungsfrist gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Kündigungsfrist');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Kündigungsfrist',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as contractDurationService from '../services/contract-duration.service.js';
|
import * as contractDurationService from '../services/contract-duration.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.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);
|
res.status(201).json({ success: true, data: duration } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Laufzeit');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Laufzeit',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +62,7 @@ export async function updateContractDuration(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: duration } as ApiResponse);
|
res.json({ success: true, data: duration } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Laufzeit');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Laufzeit',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +78,6 @@ export async function deleteContractDuration(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Laufzeit gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Laufzeit gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Laufzeit');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Laufzeit',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as contractService from '../services/contract.service.js';
|
import * as contractService from '../services/contract.service.js';
|
||||||
@@ -211,10 +212,7 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Vertrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Vertrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,8 +257,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
|
const contract = await contractService.updateContract(contractId, sanitizedBody as any);
|
||||||
|
|
||||||
// Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
|
// Wenn über das Formular ein (neues/geändertes) Kündigungsbestätigungs-
|
||||||
// datum gesetzt wurde: Vertrag ACTIVE → CANCELLED + Vertragsende =
|
// datum gesetzt wurde: Vertrag → CANCELLED_CONFIRMED ("Gekündigt / bestätigt")
|
||||||
// Kündigungsdatum. Nur bei tatsächlicher Änderung des Datums auslösen,
|
// + Vertragsende = Kündigungsdatum. Nur bei tatsächlicher Änderung auslösen,
|
||||||
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
|
// damit manuelle Status-Korrekturen nicht sofort zurückgesetzt werden.
|
||||||
{
|
{
|
||||||
const asDay = (d: unknown) =>
|
const asDay = (d: unknown) =>
|
||||||
@@ -374,10 +372,7 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
|
|||||||
: sanitizeContract(contract as any);
|
: sanitizeContract(contract as any);
|
||||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Vertrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Vertrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,10 +391,7 @@ export async function deleteContract(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Vertrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,10 +439,7 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
|||||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Folgevertrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Folgevertrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,10 +491,7 @@ export async function createRenewal(req: AuthRequest, res: Response): Promise<vo
|
|||||||
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
const sanitized = isPortal ? sanitizeContractStrict(contract as any) : sanitizeContract(contract as any);
|
||||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der VVL');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,10 +779,7 @@ export async function addSuccessorMeter(req: AuthRequest, res: Response): Promis
|
|||||||
|
|
||||||
res.json({ success: true, data: contractMeter } as ApiResponse);
|
res.json({ success: true, data: contractMeter } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Folgezählers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,10 +796,7 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: null } as ApiResponse);
|
res.json({ success: true, data: null } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Entfernen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Entfernen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,13 +885,9 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
|||||||
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
|
// Pentest 64.1: ApiError mit eigenem statusCode honorieren (z.B. 400
|
||||||
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
|
// vom Race-Lock); fallback bleibt 400 für sonstige ContractDocument-
|
||||||
// Schreibfehler.
|
// Schreibfehler.
|
||||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
|
||||||
// Multer hat die Datei schon geschrieben – bei Reject räumen.
|
// Multer hat die Datei schon geschrieben – bei Reject räumen.
|
||||||
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch { /* ignore */ }
|
||||||
res.status(status).json({
|
antworteAufFehler(res, error, 'Fehler beim Hochladen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hochladen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,10 +924,7 @@ export async function deleteContractDocument(req: AuthRequest, res: Response): P
|
|||||||
|
|
||||||
res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as contractCategoryService from '../services/contractCategory.service.js';
|
import * as contractCategoryService from '../services/contractCategory.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.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);
|
res.status(201).json({ success: true, data: category } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertragskategorie');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertragskategorie',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +62,7 @@ export async function updateContractCategory(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: category } as ApiResponse);
|
res.json({ success: true, data: category } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertragskategorie');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertragskategorie',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +78,6 @@ export async function deleteContractCategory(req: Request, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Vertragskategorie gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Vertragskategorie gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Vertragskategorie');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertragskategorie',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as contractHistoryService from '../services/contractHistory.service.js';
|
import * as contractHistoryService from '../services/contractHistory.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.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);
|
res.status(201).json({ success: true, data: entry } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Eintrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Eintrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +72,7 @@ export async function updateHistoryEntry(req: AuthRequest, res: Response): Promi
|
|||||||
|
|
||||||
res.json({ success: true, data: entry } as ApiResponse);
|
res.json({ success: true, data: entry } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Eintrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Eintrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,9 +92,6 @@ export async function deleteHistoryEntry(req: AuthRequest, res: Response): Promi
|
|||||||
|
|
||||||
res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Eintrag gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Eintrags');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Eintrags',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as contractTaskService from '../services/contractTask.service.js';
|
import * as contractTaskService from '../services/contractTask.service.js';
|
||||||
import * as contractService from '../services/contract.service.js';
|
import * as contractService from '../services/contract.service.js';
|
||||||
import * as customerService from '../services/customer.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> {
|
export async function createTask(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contractId = parseInt(req.params.contractId);
|
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);
|
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,10 +234,7 @@ export async function createSupportTicket(req: AuthRequest, res: Response): Prom
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: task } as ApiResponse);
|
res.status(201).json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Support-Anfrage');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Support-Anfrage',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,10 +257,7 @@ export async function updateTask(req: AuthRequest, res: Response): Promise<void>
|
|||||||
|
|
||||||
res.json({ success: true, data: task } as ApiResponse);
|
res.json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,10 +272,7 @@ export async function completeTask(req: AuthRequest, res: Response): Promise<voi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: task } as ApiResponse);
|
res.json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Abschließen der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,10 +287,7 @@ export async function reopenTask(req: AuthRequest, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: task } as ApiResponse);
|
res.json({ success: true, data: task } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,10 +302,7 @@ export async function deleteTask(req: AuthRequest, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Aufgabe gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Aufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Aufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,10 +337,7 @@ export async function createSubtask(req: AuthRequest, res: Response): Promise<vo
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +373,12 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allgemeine Aufgaben (ohne Vertrag/Kunde) sind reine Mitarbeiter-Aufgaben
|
||||||
|
// und für Portal-User nie zugänglich.
|
||||||
|
if (task.contractId == null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Zugriff' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
|
// Strikter Owner-Check über den Vertrag (mit Live-Vollmacht-Prüfung
|
||||||
// via hasAuthorization, Pentest Runde 6 – HOCH-04). Damit kann ein
|
// via hasAuthorization, Pentest Runde 6 – HOCH-04). Damit kann ein
|
||||||
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
|
// Portal-User keine fremde Task-ID mit visibleInPortal=true abgreifen.
|
||||||
@@ -398,10 +416,7 @@ export async function createCustomerReply(req: AuthRequest, res: Response): Prom
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
res.status(201).json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Antwort');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Antwort',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,10 +441,7 @@ export async function updateSubtask(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: subtask } as ApiResponse);
|
res.json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,10 +456,7 @@ export async function completeSubtask(req: AuthRequest, res: Response): Promise<
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: subtask } as ApiResponse);
|
res.json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Abschließen der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Abschließen der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,10 +471,7 @@ export async function reopenSubtask(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: subtask } as ApiResponse);
|
res.json({ success: true, data: subtask } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Wiedereröffnen der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Wiedereröffnen der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,9 +486,6 @@ export async function deleteSubtask(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Unteraufgabe gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Unteraufgabe');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Unteraufgabe',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiError } from '../utils/apiError.js';
|
import { ApiError } from '../utils/apiError.js';
|
||||||
@@ -36,11 +37,9 @@ function idParam(req: AuthRequest, res: Response, name: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleError(res: Response, error: unknown, fallback: string) {
|
function handleError(res: Response, error: unknown, fallback: string) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
// Eigene Huelle beibehalten, damit die Aufrufer unveraendert bleiben - die
|
||||||
res.status(status).json({
|
// Entscheidung, was nach draussen geht, faellt jetzt zentral.
|
||||||
success: false,
|
antworteAufFehler(res, error, fallback, 500);
|
||||||
error: error instanceof Error ? error.message : fallback,
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Gesamtübersicht (Hauptmenü) ----
|
// ---- Gesamtübersicht (Hauptmenü) ----
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as customerService from '../services/customer.service.js';
|
import * as customerService from '../services/customer.service.js';
|
||||||
import * as authService from '../services/auth.service.js';
|
import * as authService from '../services/auth.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { getConfiguredEmailDomains, emailUsesDomain } from '../services/emailProvider/emailProviderService.js';
|
||||||
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
import { validatePasswordComplexity, generateSecurePassword } from '../utils/passwordGenerator.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +26,23 @@ import {
|
|||||||
} from '../utils/accessControl.js';
|
} from '../utils/accessControl.js';
|
||||||
import { ApiError } from '../utils/apiError.js';
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
// Mass-Assignment-Schutz für Bankkarten (Pentest R155): nur diese Felder dürfen
|
||||||
|
// aus dem Request-Body übernommen werden – NICHT customerId/id/createdAt/… Nur
|
||||||
|
// tatsächlich gesendete Keys landen im Ergebnis, damit Partial-Updates und das
|
||||||
|
// Änderungs-Audit korrekt bleiben.
|
||||||
|
const BANK_CARD_ALLOWED_FIELDS = [
|
||||||
|
'accountHolder', 'iban', 'cardNumber', 'bic', 'bankName', 'expiryDate', 'description', 'isActive',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickBankCardFields(body: unknown): Record<string, unknown> {
|
||||||
|
const src = (body && typeof body === 'object') ? (body as Record<string, unknown>) : {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of BANK_CARD_ALLOWED_FIELDS) {
|
||||||
|
if (key in src) out[key] = src[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Customer CRUD
|
// Customer CRUD
|
||||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -82,12 +101,23 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen
|
||||||
const data: any = pickCustomerCreate(req.body);
|
const data: any = pickCustomerCreate(req.body);
|
||||||
|
// E-Mail ist Pflichtfeld.
|
||||||
|
if (!data.email || !String(data.email).trim()) {
|
||||||
|
res.status(400).json({ success: false, error: 'E-Mail-Adresse ist ein Pflichtfeld.' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
|
// Email-Format prüfen, sonst landet "test@x.de\nBcc:evil@..." als
|
||||||
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
|
// SMTP-Header-Injection-Vektor in der DB (Pentest 29.4).
|
||||||
if (data.email && !isValidEmail(data.email)) {
|
if (data.email && !isValidEmail(data.email)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Private E-Mail darf keine unserer verwalteten Provider-/Weiterleitungs-
|
||||||
|
// Domains verwenden.
|
||||||
|
if (emailUsesDomain(String(data.email), await getConfiguredEmailDomains())) {
|
||||||
|
res.status(400).json({ success: false, error: 'Diese E-Mail liegt auf einer verwalteten (Weiterleitungs-/Mailbox-)Domain und ist als private Kunden-E-Mail nicht zulässig.' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
|
if (data.portalEmail && !isValidEmail(data.portalEmail)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
@@ -126,10 +156,7 @@ export async function createCustomer(req: Request, res: Response): Promise<void>
|
|||||||
: sanitizeCustomerStrict(customer as any);
|
: sanitizeCustomerStrict(customer as any);
|
||||||
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
res.status(201).json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Kunden');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Kunden',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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);
|
res.status(400).json({ success: false, error: 'Ungültiges E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Private E-Mail darf keine verwaltete Provider-/Weiterleitungs-Domain sein.
|
||||||
|
if (req.body?.email && emailUsesDomain(String(req.body.email), await getConfiguredEmailDomains())) {
|
||||||
|
res.status(400).json({ success: false, error: 'Diese E-Mail liegt auf einer verwalteten (Weiterleitungs-/Mailbox-)Domain und ist als private Kunden-E-Mail nicht zulässig.' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
|
if (req.body?.portalEmail && !isValidEmail(req.body.portalEmail)) {
|
||||||
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
res.status(400).json({ success: false, error: 'Ungültiges Portal-E-Mail-Format' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data: any = pickCustomerUpdate(req.body);
|
const data: any = pickCustomerUpdate(req.body);
|
||||||
|
// E-Mail ist nur beim ANLEGEN Pflicht – beim Bearbeiten darf ein
|
||||||
|
// Bestandskunde ohne E-Mail bleiben (die Domain-Prüfung oben greift aber
|
||||||
|
// weiterhin, falls doch eine gesetzt wird).
|
||||||
|
|
||||||
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
|
// Pentest 60.3 (MEDIUM, 2026-06-01): pickCustomerUpdate macht nur
|
||||||
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
|
// stripHtml; CRLF und andere Control-Chars überlebten. Phone/Mobile
|
||||||
@@ -239,10 +274,7 @@ export async function updateCustomer(req: Request, res: Response): Promise<void>
|
|||||||
res.json({ success: true, data: sanitized } as ApiResponse);
|
res.json({ success: true, data: sanitized } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update customer error:', error);
|
console.error('Update customer error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Kunden');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Kunden',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,10 +291,7 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Kunde gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Kunden');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Kunden',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,10 +320,7 @@ export async function createAddress(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: address } as ApiResponse);
|
res.status(201).json({ success: true, data: address } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Adresse');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +381,7 @@ export async function updateAddress(req: AuthRequest, res: Response): Promise<vo
|
|||||||
|
|
||||||
res.json({ success: true, data: address } as ApiResponse);
|
res.json({ success: true, data: address } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Adresse');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,10 +400,7 @@ export async function deleteAddress(req: AuthRequest, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Adresse gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Adresse gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Adresse');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +421,8 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const card = await customerService.createBankCard(customerId, req.body);
|
const data = pickBankCardFields(req.body);
|
||||||
|
const card = await customerService.createBankCard(customerId, data as any);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'BankCard',
|
req, action: 'CREATE', resourceType: 'BankCard',
|
||||||
resourceId: card.id.toString(),
|
resourceId: card.id.toString(),
|
||||||
@@ -410,10 +431,7 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: card } as ApiResponse);
|
res.status(201).json({ success: true, data: card } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Bankkarte');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Bankkarte',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +439,10 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
try {
|
try {
|
||||||
const cardId = parseInt(req.params.id);
|
const cardId = parseInt(req.params.id);
|
||||||
if (!(await canAccessBankCard(req, res, cardId))) return;
|
if (!(await canAccessBankCard(req, res, cardId))) return;
|
||||||
const data = req.body;
|
// Mass-Assignment-Schutz (Pentest R155): nur erlaubte Felder aus dem Body,
|
||||||
|
// kein customerId/id/Fremdfeld. Das Audit unten läuft über diese saubere
|
||||||
|
// Menge (keine Phantom-Einträge). Enforcement zusätzlich im Service.
|
||||||
|
const data = pickBankCardFields(req.body);
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.bankCard.findUnique({ where: { id: cardId } });
|
const before = await prisma.bankCard.findUnique({ where: { id: cardId } });
|
||||||
@@ -433,7 +454,7 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
if (before) {
|
if (before) {
|
||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
iban: 'IBAN', bic: 'BIC', bankName: 'Bank',
|
iban: 'IBAN', bic: 'BIC', bankName: 'Bank', cardNumber: 'Kartennummer',
|
||||||
accountHolder: 'Kontoinhaber', isActive: 'Aktiv',
|
accountHolder: 'Kontoinhaber', isActive: 'Aktiv',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
@@ -469,10 +490,7 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.json({ success: true, data: card } as ApiResponse);
|
res.json({ success: true, data: card } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Bankkarte');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Bankkarte',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,10 +509,7 @@ export async function deleteBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Bankkarte gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Bankkarte gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Bankkarte');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Bankkarte',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,10 +539,7 @@ export async function createDocument(req: AuthRequest, res: Response): Promise<v
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Ausweises');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Ausweises',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,10 +601,7 @@ export async function updateDocument(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.json({ success: true, data: doc } as ApiResponse);
|
res.json({ success: true, data: doc } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Ausweises');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Ausweises',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,10 +620,7 @@ export async function deleteDocument(req: AuthRequest, res: Response): Promise<v
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Ausweis gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Ausweis gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Ausweises');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Ausweises',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,10 +653,7 @@ export async function createMeter(req: AuthRequest, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: meter } as ApiResponse);
|
res.status(201).json({ success: true, data: meter } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Zählers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Zählers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,10 +708,7 @@ export async function updateMeter(req: AuthRequest, res: Response): Promise<void
|
|||||||
|
|
||||||
res.json({ success: true, data: meter } as ApiResponse);
|
res.json({ success: true, data: meter } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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);
|
res.json({ success: true, message: 'Zähler gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Zählers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,10 +770,7 @@ export async function addMeterReading(req: AuthRequest, res: Response): Promise<
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Zählerstands');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Zählerstands',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,10 +798,7 @@ export async function updateMeterReading(req: AuthRequest, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: reading } as ApiResponse);
|
res.json({ success: true, data: reading } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Zählerstands');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Zählerstands',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,10 +815,7 @@ export async function deleteMeterReading(req: AuthRequest, res: Response): Promi
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: null } as ApiResponse);
|
res.json({ success: true, data: null } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Zählerstands');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Zählerstands',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,10 +870,7 @@ export async function reportMeterReading(req: AuthRequest, res: Response): Promi
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
res.status(201).json({ success: true, data: reading } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Melden des Zählerstands');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Melden des Zählerstands',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,10 +922,7 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
|
|||||||
|
|
||||||
res.json({ success: true, data: reading } as ApiResponse);
|
res.json({ success: true, data: reading } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1050,10 +1032,7 @@ export async function updatePortalSettings(req: Request, res: Response): Promise
|
|||||||
|
|
||||||
res.json({ success: true, data: settings } as ApiResponse);
|
res.json({ success: true, data: settings } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Portal-Einstellungen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Portal-Einstellungen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1067,10 +1046,7 @@ export async function generatePortalPassword(req: Request, res: Response): Promi
|
|||||||
const password = generateSecurePassword({ length: 16 });
|
const password = generateSecurePassword({ length: 16 });
|
||||||
res.json({ success: true, data: { password } } as ApiResponse);
|
res.json({ success: true, data: { password } } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Generieren des Passworts', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Generieren des Passworts',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,10 +1134,7 @@ export async function sendPortalCredentials(req: AuthRequest, res: Response): Pr
|
|||||||
|
|
||||||
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
|
res.json({ success: true, message: `Zugangsdaten an ${targetEmail} versendet (Einmalpasswort)` } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Versenden der Zugangsdaten', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Versenden der Zugangsdaten',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,10 +1160,7 @@ export async function setPortalPassword(req: Request, res: Response): Promise<vo
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,10 +1233,7 @@ export async function addRepresentative(req: AuthRequest, res: Response): Promis
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: representative } as ApiResponse);
|
res.status(201).json({ success: true, data: representative } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen des Vertreters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Vertreters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1285,10 +1252,7 @@ export async function removeRepresentative(req: AuthRequest, res: Response): Pro
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Vertreter entfernt' } as ApiResponse);
|
res.json({ success: true, message: 'Vertreter entfernt' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Entfernen des Vertreters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Entfernen des Vertreters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1313,11 +1277,7 @@ export async function getSalutationPreference(req: AuthRequest, res: Response):
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Pentest R104.1: ApiError-Statuscodes durchreichen (404 statt 500
|
// Pentest R104.1: ApiError-Statuscodes durchreichen (404 statt 500
|
||||||
// bei nicht-existierendem Kunden).
|
// bei nicht-existierendem Kunden).
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Laden der Anrede-Präferenz', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Laden der Anrede-Präferenz',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1346,11 +1306,7 @@ export async function setSalutationPreference(req: AuthRequest, res: Response):
|
|||||||
const result = await customerService.getSalutationPreference(userId, customerId);
|
const result = await customerService.getSalutationPreference(userId, customerId);
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Speichern der Anrede-Präferenz', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Speichern der Anrede-Präferenz',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,11 +1327,7 @@ export async function clearSalutationPreference(req: AuthRequest, res: Response)
|
|||||||
const result = await customerService.getSalutationPreference(userId, customerId);
|
const result = await customerService.getSalutationPreference(userId, customerId);
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen der Anrede-Präferenz', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen der Anrede-Präferenz',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import * as referralService from '../services/customerReferral.service.js';
|
import * as referralService from '../services/customerReferral.service.js';
|
||||||
@@ -98,11 +99,7 @@ export async function createReferral(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.status(201).json({ success: true, data: created } as ApiResponse);
|
res.status(201).json({ success: true, data: created } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = (error as referralService.ReferralError)?.status ?? 500;
|
antworteAufFehler(res, error, 'Fehler beim Anlegen der Werbe-Beziehung', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Anlegen der Werbe-Beziehung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,11 +150,7 @@ export async function updateReferral(req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
res.json({ success: true, data: updated } as ApiResponse);
|
res.json({ success: true, data: updated } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = (error as referralService.ReferralError)?.status ?? 500;
|
antworteAufFehler(res, error, 'Fehler beim Ändern der Werbe-Beziehung', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Ändern der Werbe-Beziehung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// ==================== EMAIL PROVIDER CONTROLLER ====================
|
// ==================== EMAIL PROVIDER CONTROLLER ====================
|
||||||
|
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
import * as emailProviderService from '../services/emailProvider/emailProviderService.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.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);
|
res.status(201).json({ success: true, data: config } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Email-Providers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Email-Providers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +78,7 @@ export async function updateProviderConfig(req: Request, res: Response): Promise
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: config } as ApiResponse);
|
res.json({ success: true, data: config } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Email-Providers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Email-Providers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +94,7 @@ export async function deleteProviderConfig(req: Request, res: Response): Promise
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Email-Provider gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Email-Provider gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Email-Providers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Email-Providers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,10 +150,7 @@ export async function testConnection(req: Request, res: Response): Promise<void>
|
|||||||
const result = await emailProviderService.testProviderConnection({ id, testData });
|
const result = await emailProviderService.testProviderConnection({ id, testData });
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Verbindungstest fehlgeschlagen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Verbindungstest fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,10 +331,7 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('testMailAccess error:', error);
|
console.error('testMailAccess error:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Test', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Test',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +341,7 @@ export async function checkEmailExists(req: Request, res: Response): Promise<voi
|
|||||||
const result = await emailProviderService.checkEmailExists(localPart);
|
const result = await emailProviderService.checkEmailExists(localPart);
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler bei der E-Mail-Prüfung', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Prüfung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,10 +360,7 @@ export async function provisionEmail(req: Request, res: Response): Promise<void>
|
|||||||
const result = await emailProviderService.provisionEmail(localPart, customerEmail);
|
const result = await emailProviderService.provisionEmail(localPart, customerEmail);
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler bei der E-Mail-Provisionierung', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler bei der E-Mail-Provisionierung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,10 +370,7 @@ export async function deprovisionEmail(req: Request, res: Response): Promise<voi
|
|||||||
const result = await emailProviderService.deprovisionEmail(localPart);
|
const result = await emailProviderService.deprovisionEmail(localPart);
|
||||||
res.json({ success: result.success, data: result } as ApiResponse);
|
res.json({ success: result.success, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der E-Mail', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der E-Mail',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
|
import * as factoryDefaultsService from '../services/factoryDefaults.service.js';
|
||||||
import { createAuditLog } from '../services/audit.service.js';
|
import { createAuditLog } from '../services/audit.service.js';
|
||||||
@@ -31,10 +32,7 @@ export async function exportFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
res.send(buffer);
|
res.send(buffer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Factory-Defaults-Export:', error);
|
console.error('Fehler beim Factory-Defaults-Export:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Export', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Export',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,9 +91,6 @@ export async function importFactoryDefaults(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: result });
|
res.json({ success: true, data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Factory-Defaults-Import:', error);
|
console.error('Fehler beim Factory-Defaults-Import:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Import');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Import',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as gdprService from '../services/gdpr.service.js';
|
import * as gdprService from '../services/gdpr.service.js';
|
||||||
import * as consentService from '../services/consent.service.js';
|
import * as consentService from '../services/consent.service.js';
|
||||||
@@ -54,10 +55,7 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Datenexport:', error);
|
console.error('Fehler beim Datenexport:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Datenexport', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Datenexport',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,10 +96,7 @@ export async function createDeletionRequest(req: AuthRequest, res: Response) {
|
|||||||
res.status(201).json({ success: true, data: request });
|
res.status(201).json({ success: true, data: request });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Erstellen der Löschanfrage:', error);
|
console.error('Fehler beim Erstellen der Löschanfrage:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,10 +178,7 @@ export async function processDeletionRequest(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: result });
|
res.json({ success: true, data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Bearbeiten der Löschanfrage:', error);
|
console.error('Fehler beim Bearbeiten der Löschanfrage:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Bearbeiten');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Bearbeiten',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,10 +338,7 @@ export async function updateCustomerConsent(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: consent });
|
res.json({ success: true, data: consent });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Aktualisieren der Einwilligung:', error);
|
console.error('Fehler beim Aktualisieren der Einwilligung:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,10 +686,7 @@ export async function sendConsentLink(req: AuthRequest, res: Response) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Senden des Consent-Links:', error);
|
console.error('Fehler beim Senden des Consent-Links:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Senden',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,10 +805,7 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
|
console.error('Fehler beim Senden der Vollmacht-Anfrage:', error);
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Senden', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Senden',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,10 +858,7 @@ export async function grantAuthorization(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Erteilen der Vollmacht:', error);
|
console.error('Fehler beim Erteilen der Vollmacht:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erteilen der Vollmacht');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erteilen der Vollmacht',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,10 +884,7 @@ export async function withdrawAuthorization(req: AuthRequest, res: Response) {
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Widerrufen der Vollmacht:', error);
|
console.error('Fehler beim Widerrufen der Vollmacht:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Widerrufen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Widerrufen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,10 +989,7 @@ export async function uploadAuthorizationDocument(req: AuthRequest, res: Respons
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Upload des Vollmacht-Dokuments:', error);
|
console.error('Fehler beim Upload des Vollmacht-Dokuments:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Upload');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Upload',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,10 +1013,7 @@ export async function deleteAuthorizationDocument(req: AuthRequest, res: Respons
|
|||||||
res.json({ success: true, data: auth });
|
res.json({ success: true, data: auth });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error);
|
console.error('Fehler beim Löschen des Vollmacht-Dokuments:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as invoiceService from '../services/invoice.service.js';
|
import * as invoiceService from '../services/invoice.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.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);
|
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('addInvoice error:', error);
|
console.error('addInvoice error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen der Rechnung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen der Rechnung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +134,7 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
res.json({ success: true, data: invoice } as ApiResponse);
|
res.json({ success: true, data: invoice } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('updateInvoice error:', error);
|
console.error('updateInvoice error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Rechnung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rechnung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,10 +158,7 @@ export async function deleteInvoice(req: AuthRequest, res: Response): Promise<vo
|
|||||||
res.json({ success: true, data: null } as ApiResponse);
|
res.json({ success: true, data: null } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('deleteInvoice error:', error);
|
console.error('deleteInvoice error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Rechnung');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rechnung',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +200,6 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
res.status(201).json({ success: true, data: invoice } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Hinzufügen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { AuthRequest, ApiResponse } from '../types/index.js';
|
import { AuthRequest, ApiResponse } from '../types/index.js';
|
||||||
import * as appSettingService from '../services/appSetting.service.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);
|
res.status(500).json({ success: false, error: result.error || 'Versand fehlgeschlagen' } as ApiResponse);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Test-Alert fehlgeschlagen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Test-Alert fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,9 +199,6 @@ export async function runDigestNow(_req: AuthRequest, res: Response): Promise<vo
|
|||||||
const result = await sendDigest({ force: true });
|
const result = await sendDigest({ force: true });
|
||||||
res.json({ success: true, data: result } as ApiResponse);
|
res.json({ success: true, data: result } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Digest fehlgeschlagen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Digest fehlgeschlagen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import { AuthRequest } from '../types/index.js';
|
import { AuthRequest } from '../types/index.js';
|
||||||
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
||||||
import { logChange } from '../services/audit.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 } });
|
res.status(201).json({ success: true, data: { ...template, pdfFields } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +85,7 @@ export async function updateTemplate(req: AuthRequest, res: Response) {
|
|||||||
|
|
||||||
res.json({ success: true, data: template });
|
res.json({ success: true, data: template });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +114,7 @@ export async function extractFields(req: AuthRequest, res: Response) {
|
|||||||
const result = await pdfTemplateService.extractPdfFields(template.templatePath);
|
const result = await pdfTemplateService.extractPdfFields(template.templatePath);
|
||||||
res.json({ success: true, data: result.fields, totalPages: result.totalPages });
|
res.json({ success: true, data: result.fields, totalPages: result.totalPages });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Auslesen der PDF-Felder');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : '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.setHeader('Content-Disposition', 'inline; filename="preview.pdf"');
|
||||||
res.send(pdfBuffer);
|
res.send(pdfBuffer);
|
||||||
} catch (error) {
|
} 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);
|
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
||||||
res.json({ success: true, data: inputs });
|
res.json({ success: true, data: inputs });
|
||||||
} catch (error) {
|
} 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);
|
res.send(pdfBuffer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PDF generate error:', error);
|
console.error('PDF generate error:', error);
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Generieren');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Generieren',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as platformService from '../services/platform.service.js';
|
import * as platformService from '../services/platform.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.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);
|
res.status(201).json({ success: true, data: platform } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Vertriebsplattform');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Vertriebsplattform',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +62,7 @@ export async function updatePlatform(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: platform } as ApiResponse);
|
res.json({ success: true, data: platform } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Vertriebsplattform');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Vertriebsplattform',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +78,6 @@ export async function deletePlatform(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Vertriebsplattform gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Vertriebsplattform gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Vertriebsplattform');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Vertriebsplattform',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as providerService from '../services/provider.service.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);
|
res.status(201).json({ success: true, data: provider } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Anbieters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Anbieters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +134,7 @@ export async function updateProvider(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: provider } as ApiResponse);
|
res.json({ success: true, data: provider } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Anbieters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Anbieters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,9 +150,6 @@ export async function deleteProvider(req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Anbieters');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Anbieters',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.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);
|
res.status(201).json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
antworteAufFehler(res, error, 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse', 400);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Stressfrei-Wechseln Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +116,7 @@ export async function updateEmail(req: AuthRequest, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: email } as ApiResponse);
|
res.json({ success: true, data: email } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 400;
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse', 400);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Stressfrei-Wechseln Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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);
|
res.json({ success: true, message: 'Stressfrei-Wechseln Adresse gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen der Stressfrei-Wechseln Adresse');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Stressfrei-Wechseln Adresse',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,10 +170,7 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
|
|||||||
message: 'Weiterleitungen aktualisiert',
|
message: 'Weiterleitungen aktualisiert',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Synchronisieren der Weiterleitungen', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Synchronisieren der Weiterleitungen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +215,7 @@ export async function updateAdditionalForwards(req: AuthRequest, res: Response):
|
|||||||
message: 'Weiterleitungen aktualisiert',
|
message: 'Weiterleitungen aktualisiert',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error instanceof ApiError ? error.statusCode : 500;
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren der Weiterleitungen', 500);
|
||||||
res.status(status).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Weiterleitungen',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +238,6 @@ export async function resetPassword(req: AuthRequest, res: Response): Promise<vo
|
|||||||
message: 'Passwort wurde zurückgesetzt',
|
message: 'Passwort wurde zurückgesetzt',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
antworteAufFehler(res, error, 'Fehler beim Zurücksetzen des Passworts', 500);
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen des Passworts',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import * as tariffService from '../services/tariff.service.js';
|
import * as tariffService from '../services/tariff.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
import { ApiResponse } from '../types/index.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);
|
res.status(201).json({ success: true, data: tariff } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Erstellen des Tarifs');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Tarifs',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +64,7 @@ export async function updateTariff(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.json({ success: true, data: tariff } as ApiResponse);
|
res.json({ success: true, data: tariff } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Aktualisieren des Tarifs');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Tarifs',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +80,6 @@ export async function deleteTariff(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Tarif gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Tarif gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Tarifs');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Tarifs',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { antworteAufFehler } from '../utils/fehlerAntwort.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import * as userService from '../services/user.service.js';
|
import * as userService from '../services/user.service.js';
|
||||||
import { logChange } from '../services/audit.service.js';
|
import { logChange } from '../services/audit.service.js';
|
||||||
|
import { AUDIT_OPS_ROLLE } from '../services/user.service.js';
|
||||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||||
import { pickUserCreate, pickUserUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js';
|
||||||
|
import { pickUserCreate, pickUserUpdate, pickRoleUpdate, isValidEmail, sanitizePhoneField } from '../utils/sanitize.js';
|
||||||
|
import { RechteEskalationError, RollenSperrError, UngueltigeEingabeError } from '../services/rechte.service.js';
|
||||||
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js';
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
@@ -54,6 +58,11 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
const data = pickUserCreate(req.body) as any;
|
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
|
// Email-Format prüfen, sonst landet "x@y\nBcc:..." in der DB
|
||||||
// (Pentest 29.4 – SMTP-Header-Injection).
|
// (Pentest 29.4 – SMTP-Header-Injection).
|
||||||
if (!isValidEmail(data?.email) || !data?.email) {
|
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);
|
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültige Nummer' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = await userService.createUser(data);
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = await userService.createUser(data, handelnder);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'User',
|
req, action: 'CREATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
@@ -92,14 +106,11 @@ export async function createUser(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.status(201).json({ success: true, data: user } as ApiResponse);
|
res.status(201).json({ success: true, data: user } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen des Benutzers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Benutzers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(req: Request, res: Response): Promise<void> {
|
export async function updateUser(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = parseInt(req.params.id);
|
const userId = parseInt(req.params.id);
|
||||||
// `permissions` und `password` darf der generische Update nicht
|
// `permissions` und `password` darf der generische Update nicht
|
||||||
@@ -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)
|
// Whitelist: nur erlaubte Felder aus req.body übernehmen (Mass-Assignment-Schutz)
|
||||||
const data = pickUserUpdate(req.body) as Record<string, unknown>;
|
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).
|
// Email-Validierung gegen SMTP-Header-Injection (Pentest 29.4).
|
||||||
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
// null/leer ist OK (Email darf optional sein), nur falsches Format prüfen.
|
||||||
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
if (data?.email !== undefined && !isValidEmail(data.email)) {
|
||||||
@@ -161,10 +177,129 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
...beforeUser,
|
...beforeUser,
|
||||||
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
hasGdprAccess: beforeUser.roles.some((ur) => ur.role.name === 'DSGVO'),
|
||||||
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
hasDeveloperAccess: beforeUser.roles.some((ur) => ur.role.name === 'Developer'),
|
||||||
|
hasAuditOpsAccess: beforeUser.roles.some((ur) => ur.role.name === AUDIT_OPS_ROLLE),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const user = await userService.updateUser(userId, data as any);
|
// Die drei Haken vergeben versteckte Rollen: DSGVO, Developer und
|
||||||
|
// Audit-Betrieb. Sie sind kein gewoehnliches Benutzerfeld - "Developer"
|
||||||
|
// traegt ALLE Rechte, "Audit-Betrieb" entscheidet, wer die
|
||||||
|
// Beweisgrundlage ersetzen darf.
|
||||||
|
//
|
||||||
|
// Bis 09/2026 stand hier, eine Rechte-Huerde scheitere am
|
||||||
|
// Henne-Ei-Problem: Nach der Aufteilung haelt zunaechst niemand
|
||||||
|
// `audit:admin`, also koennte ihn auch niemand vergeben. Das Argument war
|
||||||
|
// richtig, aber der Schluss zu weit. Ohne Huerde genuegte `users:create`,
|
||||||
|
// um sich zum Vollzugriff zu befoerdern - nicht einmal am eigenen Konto,
|
||||||
|
// sondern ueber ein frisch angelegtes zweites mit gesetztem Haken.
|
||||||
|
//
|
||||||
|
// Die Huerde steckt jetzt in der Teilmengenregel (rechte.service.ts):
|
||||||
|
// Wer einen Haken setzt, muss die dahinterliegenden Rechte selbst
|
||||||
|
// halten. Das Henne-Ei-Problem loest nicht die Weboberflaeche, sondern
|
||||||
|
// die Kommandozeile - `npx tsx prisma/rolle-zuweisen.ts` auf der
|
||||||
|
// Maschine. Das ist die richtige Grenze: "Datenbankzugriff" und
|
||||||
|
// "Protokoll neu berechnen" sollten Shell-Zugang voraussetzen, nicht ein
|
||||||
|
// Haekchen im Browser. Ein gestohlener Admin-Zugang hat den Container
|
||||||
|
// nicht.
|
||||||
|
const setztAuditBetrieb =
|
||||||
|
data.hasAuditOpsAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasAuditOpsAccess !== (before as any).hasAuditOpsAccess;
|
||||||
|
const aktiviertAuditBetrieb = data.hasAuditOpsAccess === true;
|
||||||
|
|
||||||
|
const setztDsgvo =
|
||||||
|
data.hasGdprAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasGdprAccess !== (before as any).hasGdprAccess;
|
||||||
|
const setztDeveloper =
|
||||||
|
data.hasDeveloperAccess !== undefined &&
|
||||||
|
before !== null &&
|
||||||
|
data.hasDeveloperAccess !== (before as any).hasDeveloperAccess;
|
||||||
|
|
||||||
|
// Keine Selbstbedienung. Die Teilmengenregel allein reicht dafuer nicht:
|
||||||
|
// Wer die Rechte bereits haelt, koennte sie sich formal selbst erneut
|
||||||
|
// zuweisen - und ein Vorgang, bei dem Antragsteller und Genehmigender
|
||||||
|
// dieselbe Person sind, hinterlaesst keine ueberpruefbare Spur.
|
||||||
|
if ((setztAuditBetrieb || setztDsgvo || setztDeveloper) && req.user?.userId === userId) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'DSGVO-Zugriff, Audit-Betrieb und Entwicklerzugriff lassen sich nicht am eigenen ' +
|
||||||
|
'Konto ändern. Bitte von einer anderen Person mit den entsprechenden Rechten ' +
|
||||||
|
'vornehmen lassen.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Dienstkonto-Kennzeichen SENKT die Alarmstufe der Anmeldungen dieses
|
||||||
|
// Kontos (Pentest R184). Damit ist es selbst ein Hebel zur Waesche: Wer sein
|
||||||
|
// Konto so markiert, laesst die eigenen auffaelligen Anmeldungen als
|
||||||
|
// Routine erscheinen. Es bekommt deshalb dieselbe Behandlung wie das
|
||||||
|
// Absenken einer Aufbewahrungsfrist – Bestaetigung, laute Protokollierung,
|
||||||
|
// und kein Selbstbedienen.
|
||||||
|
const setztDienstkonto =
|
||||||
|
typeof (data as any).isServiceAccount === 'boolean' &&
|
||||||
|
(data as any).isServiceAccount !== before?.isServiceAccount;
|
||||||
|
const aktiviertDienstkonto = setztDienstkonto && (data as any).isServiceAccount === true;
|
||||||
|
|
||||||
|
// R184-02: Das Kennzeichen ist ein Audit-Governance-Eingriff, kein
|
||||||
|
// gewoehnliches Benutzerfeld – es ist Geschwister von retention-shorten,
|
||||||
|
// seal-backlog, rehash und cleanup, die alle `audit:admin` verlangen. Es
|
||||||
|
// haengt aber an der Route mit `users:update`. Heute deckungsgleich, weil
|
||||||
|
// nur die Administratorrolle beides hat – aber jede kuenftige Rolle mit
|
||||||
|
// „Benutzer bearbeiten“ wuerde still „Login-Alarme herunterstufen“ erben.
|
||||||
|
// Deshalb hier zusaetzlich auf die Domaene pruefen, in der der Schaden
|
||||||
|
// liegt.
|
||||||
|
if (setztDienstkonto && !(req.user?.permissions || []).includes('audit:admin')) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Das Dienstkonto-Kennzeichen ändert, wie Anmeldungen im Audit-Log bewertet werden. ' +
|
||||||
|
'Dafür genügt „Benutzer bearbeiten“ nicht – es erfordert die Berechtigung ' +
|
||||||
|
'audit:admin, wie das Verkürzen von Aufbewahrungsfristen auch.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setztDienstkonto && req.user?.userId === userId) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
'Das Dienstkonto-Kennzeichen lässt sich nicht am eigenen Konto setzen oder entfernen. ' +
|
||||||
|
'Es stuft Anmeldungen dieses Kontos als Routine ein – wer das für sich selbst täte, ' +
|
||||||
|
'könnte die eigenen Anmeldungen unauffällig machen. Bitte von einem anderen ' +
|
||||||
|
'Administrator vornehmen lassen.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate in BEIDE Richtungen (Pentest R184-01). Das Entfernen war ungegatet –
|
||||||
|
// und es ist der gefaehrlichere Weg: Ein un-geflaggtes Konto faellt aus der
|
||||||
|
// Heartbeat-Wache, weil diese am Live-Kennzeichen haengt. Genau das
|
||||||
|
// „stilllegen und auf Stille setzen“, gegen das der Wachhund gebaut wurde.
|
||||||
|
if (setztDienstkonto && req.body?.confirm !== 'SERVICE_ACCOUNT') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: aktiviertDienstkonto
|
||||||
|
? `Damit werden künftige Anmeldungen von ${before?.email ?? 'diesem Konto'} im ` +
|
||||||
|
'Audit-Log als Routine geführt statt als kritisches Ereignis. Das ist für ' +
|
||||||
|
'planmäßig arbeitende Dienste gedacht (etwa das Gegenbuch) – für ein Konto, das ' +
|
||||||
|
'ein Mensch benutzt, wäre es eine Tarnung. ' +
|
||||||
|
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.'
|
||||||
|
: `Damit fällt ${before?.email ?? 'dieses Konto'} aus der Überwachung heraus: Es wird ` +
|
||||||
|
'dann nicht mehr gemeldet, wenn es sich nicht mehr anmeldet. Bei einem laufenden ' +
|
||||||
|
'Dienst wie dem Gegenbuch hieße das, sein Ausfall bliebe unbemerkt. ' +
|
||||||
|
'Zum Bestätigen {"confirm":"SERVICE_ACCOUNT"} mitsenden.',
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = await userService.updateUser(userId, data as any, handelnder);
|
||||||
if (user) {
|
if (user) {
|
||||||
// Audit: Geänderte Felder ermitteln und loggen
|
// Audit: Geänderte Felder ermitteln und loggen
|
||||||
if (before) {
|
if (before) {
|
||||||
@@ -172,6 +307,8 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
const fieldLabels: Record<string, string> = {
|
const fieldLabels: Record<string, string> = {
|
||||||
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
email: 'E-Mail', firstName: 'Vorname', lastName: 'Nachname', isActive: 'Aktiv',
|
||||||
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
hasGdprAccess: 'DSGVO-Zugriff', hasDeveloperAccess: 'Entwicklerzugriff',
|
||||||
|
hasAuditOpsAccess: 'Audit-Betrieb (versiegeln, aufräumen, Aufbewahrung)',
|
||||||
|
isServiceAccount: 'Dienstkonto (Anmeldungen als Routine)',
|
||||||
};
|
};
|
||||||
for (const [key, newVal] of Object.entries(data)) {
|
for (const [key, newVal] of Object.entries(data)) {
|
||||||
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
|
||||||
@@ -191,9 +328,102 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
resourceId: user.id.toString(),
|
resourceId: user.id.toString(),
|
||||||
label: changeList ? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}` : `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
label: setztDienstkonto
|
||||||
|
? `Dienstkonto-Kennzeichen ${aktiviertDienstkonto ? 'GESETZT' : 'entfernt'} für ` +
|
||||||
|
`${user.email} – Anmeldungen werden künftig ` +
|
||||||
|
`${aktiviertDienstkonto ? 'als Routine' : 'wieder als kritisch'} geführt`
|
||||||
|
: changeList
|
||||||
|
? `Benutzer ${user.firstName} ${user.lastName} aktualisiert: ${changeList}`
|
||||||
|
: `Benutzer ${user.firstName} ${user.lastName} aktualisiert`,
|
||||||
|
// Die Aenderung dieses Kennzeichens wird wie ihre Wirkung eingestuft:
|
||||||
|
// Sie beeinflusst, wie kuenftige Anmeldungen bewertet werden.
|
||||||
|
sensitivity: setztDienstkonto || setztAuditBetrieb ? 'CRITICAL' : undefined,
|
||||||
details: Object.keys(changes).length > 0 ? changes : undefined,
|
details: Object.keys(changes).length > 0 ? changes : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Zusaetzlich in den Alarmkanal (Pentest R184-01). Eine CRITICAL-Zeile
|
||||||
|
// im Audit-Log muss jemand LESEN – das ist die R183-02-Klasse. Das
|
||||||
|
// Entfernen des Kennzeichens nimmt das Konto aus der Heartbeat-Wache;
|
||||||
|
// ab dann faellt sein Ausbleiben nicht mehr auf. Diese Aenderung
|
||||||
|
// gehoert deshalb dorthin, wo automatisch reagiert wird.
|
||||||
|
if (setztDienstkonto) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: aktiviertDienstkonto
|
||||||
|
? `Konto ${user.email} als Dienstkonto markiert – seine Anmeldungen gelten ab ` +
|
||||||
|
'jetzt als Routine statt als kritisches Ereignis.'
|
||||||
|
: `Konto ${user.email} ist KEIN Dienstkonto mehr – es fällt damit aus der ` +
|
||||||
|
'Überwachung heraus: Ein Ausbleiben seiner Anmeldungen wird nicht mehr gemeldet.',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { betroffenesKonto: user.email, aktiviert: aktiviertDienstkonto },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSGVO und Entwicklerzugriff bekamen bis 09/2026 KEINEN Eintrag im
|
||||||
|
// Alarmkanal - nur Audit-Betrieb und das Dienstkonto-Kennzeichen.
|
||||||
|
// Dabei traegt die Developer-Rolle saemtliche Rechte des Systems:
|
||||||
|
// Der weitreichendste Haken war der leiseste.
|
||||||
|
if (setztDeveloper) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: data.hasDeveloperAccess === true
|
||||||
|
? `Konto ${user.email} hat ab jetzt Entwicklerzugriff – das schliesst die ` +
|
||||||
|
'Datenbankwerkzeuge und damit saemtliche Rechte des Systems ein.'
|
||||||
|
: `Konto ${user.email} hat keinen Entwicklerzugriff mehr.`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { betroffenesKonto: user.email, aktiviert: data.hasDeveloperAccess === true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setztDsgvo) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'HIGH',
|
||||||
|
message: data.hasGdprAccess === true
|
||||||
|
? `Konto ${user.email} darf ab jetzt das Audit-Protokoll lesen und exportieren ` +
|
||||||
|
'sowie Auskunft und Loeschung nach DSGVO ausfuehren.'
|
||||||
|
: `Konto ${user.email} hat keinen DSGVO-Zugriff mehr – es kann damit keine ` +
|
||||||
|
'Auskunft nach Art. 15 und keine Loeschung nach Art. 17 mehr ausfuehren.',
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: { betroffenesKonto: user.email, aktiviert: data.hasGdprAccess === true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setztAuditBetrieb) {
|
||||||
|
const ctx = contextFromRequest(req);
|
||||||
|
emitSecurityEvent({
|
||||||
|
type: 'PERMISSION_CHANGED',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message: aktiviertAuditBetrieb
|
||||||
|
? `Konto ${user.email} darf ab jetzt am Audit-Protokoll EINGREIFEN: versiegeln, ` +
|
||||||
|
'neu berechnen, aufräumen, Aufbewahrung ändern. Damit kann es die Grundlage ' +
|
||||||
|
'ersetzen, gegen die Manipulation nachgewiesen wird.'
|
||||||
|
: `Konto ${user.email} darf nicht mehr am Audit-Protokoll eingreifen.`,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
userEmail: req.user?.email,
|
||||||
|
endpoint: ctx.endpoint,
|
||||||
|
details: {
|
||||||
|
betroffenesKonto: user.email,
|
||||||
|
aktiviert: aktiviertAuditBetrieb,
|
||||||
|
selbstvergabe: user.id === req.user?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'User',
|
req, action: 'UPDATE', resourceType: 'User',
|
||||||
@@ -204,10 +434,7 @@ export async function updateUser(req: Request, res: Response): Promise<void> {
|
|||||||
}
|
}
|
||||||
res.json({ success: true, data: user } as ApiResponse);
|
res.json({ success: true, data: user } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren des Benutzers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Benutzers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +491,15 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
|||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = await userService.updateUser(userId, { password } as any);
|
const handelnderPw = handelnderOderNull(req);
|
||||||
|
if (handelnderPw === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reines Kennwort-Setzen: Der Zuwachs ist leer, die Teilmengenregel
|
||||||
|
// laeuft ins Leere. Der Handelnde wird trotzdem uebergeben, damit es
|
||||||
|
// keine Signatur gibt, die ihn weglassen darf.
|
||||||
|
const user = await userService.updateUser(userId, { password } as any, handelnderPw);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' } as ApiResponse);
|
||||||
return;
|
return;
|
||||||
@@ -286,10 +521,7 @@ export async function setUserPassword(req: Request, res: Response): Promise<void
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
res.json({ success: true, message: 'Passwort gesetzt' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Setzen des Passworts');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Setzen des Passworts',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,14 +537,70 @@ export async function deleteUser(req: Request, res: Response): Promise<void> {
|
|||||||
});
|
});
|
||||||
res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Benutzer gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufFehler(res, error, 'Fehler beim Löschen des Benutzers');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen des Benutzers',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Roles
|
// Roles
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet jede Aenderung am Rollenmodell in den Alarmkanal.
|
||||||
|
*
|
||||||
|
* Die Rollenpflege war bis 09/2026 der einzige Eingriff in die Rechtevergabe
|
||||||
|
* ohne SecurityEvent - Aenderungen an einzelnen Konten wurden gemeldet, das
|
||||||
|
* Umschreiben einer Rolle, die an zwanzig Konten haengt, nicht. Damit war
|
||||||
|
* ausgerechnet der wirksamste Weg der leiseste.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Die ID des Handelnden - oder null.
|
||||||
|
*
|
||||||
|
* `userId` fehlt bei Kundenportal-Anmeldungen. Ohne sie laesst sich die
|
||||||
|
* Teilmengenregel nicht auswerten, und "nicht auswertbar" darf nicht
|
||||||
|
* stillschweigend zu "erlaubt" werden. Deshalb wird hier abgelehnt statt
|
||||||
|
* durchgewinkt.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 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> {
|
export async function getRoles(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const roles = await userService.getAllRoles();
|
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 {
|
try {
|
||||||
const role = await userService.createRole(req.body);
|
const daten = pickRoleUpdate(req.body);
|
||||||
|
const fehler = pruefeRollenEingabe(daten, true);
|
||||||
|
if (fehler) {
|
||||||
|
res.status(400).json({ success: false, error: fehler } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handelnder = handelnderOderNull(req);
|
||||||
|
if (handelnder === null) {
|
||||||
|
res.status(403).json({ success: false, error: 'Kein Benutzerkonto im Zugang' } as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await userService.createRole(
|
||||||
|
{
|
||||||
|
name: daten.name as string,
|
||||||
|
description: daten.description as string | undefined,
|
||||||
|
permissionIds: daten.permissionIds as number[],
|
||||||
|
},
|
||||||
|
handelnder,
|
||||||
|
);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'Role',
|
req, action: 'CREATE', resourceType: 'Role',
|
||||||
resourceId: role.id.toString(),
|
resourceId: role.id.toString(),
|
||||||
label: `Rolle ${role.name} angelegt`,
|
label: `Rolle ${role.name} angelegt`,
|
||||||
});
|
});
|
||||||
|
meldeRollenAenderung(req, `Rolle "${role.name}" angelegt`, {
|
||||||
|
rolle: role.name,
|
||||||
|
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||||
|
});
|
||||||
res.status(201).json({ success: true, data: role } as ApiResponse);
|
res.status(201).json({ success: true, data: role } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Erstellen der Rolle');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Erstellen der Rolle',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateRole(req: Request, res: Response): Promise<void> {
|
export async function updateRole(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const role = await userService.updateRole(parseInt(req.params.id), req.body);
|
const daten = pickRoleUpdate(req.body);
|
||||||
if (role) {
|
const fehler = pruefeRollenEingabe(daten, false);
|
||||||
|
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({
|
await logChange({
|
||||||
req, action: 'UPDATE', resourceType: 'Role',
|
req, action: 'UPDATE', resourceType: 'Role',
|
||||||
resourceId: role.id.toString(),
|
resourceId: role.id.toString(),
|
||||||
label: `Rolle ${role.name} aktualisiert`,
|
label: `Rolle ${role.name} aktualisiert`,
|
||||||
});
|
});
|
||||||
}
|
meldeRollenAenderung(req, `Rechte der Rolle "${role.name}" geändert`, {
|
||||||
|
rolle: role.name,
|
||||||
|
rechte: role.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||||
|
traegerAbgemeldet: daten.permissionIds !== undefined,
|
||||||
|
});
|
||||||
res.json({ success: true, data: role } as ApiResponse);
|
res.json({ success: true, data: role } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Aktualisieren der Rolle');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rolle',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(req: Request, res: Response): Promise<void> {
|
export async function deleteRole(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const roleId = parseInt(req.params.id);
|
const roleId = parseInt(req.params.id);
|
||||||
const role = await userService.getRoleById(roleId);
|
const role = await userService.getRoleById(roleId);
|
||||||
@@ -390,12 +799,12 @@ export async function deleteRole(req: Request, res: Response): Promise<void> {
|
|||||||
resourceId: roleId.toString(),
|
resourceId: roleId.toString(),
|
||||||
label: `Rolle ${role?.name || roleId} gelöscht`,
|
label: `Rolle ${role?.name || roleId} gelöscht`,
|
||||||
});
|
});
|
||||||
|
meldeRollenAenderung(req, `Rolle "${role?.name || roleId}" gelöscht`, {
|
||||||
|
rolle: role?.name,
|
||||||
|
});
|
||||||
res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse);
|
res.json({ success: true, message: 'Rolle gelöscht' } as ApiResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({
|
antworteAufRollenFehler(res, error, 'Fehler beim Löschen der Rolle');
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rolle',
|
|
||||||
} as ApiResponse);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+76
-54
@@ -1,4 +1,4 @@
|
|||||||
import express from 'express';
|
import express, { Router } from 'express';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
@@ -30,6 +30,7 @@ if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD
|
|||||||
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
|
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { apiBackstopRateLimiter } from './middleware/rateLimit.js';
|
||||||
import authRoutes from './routes/auth.routes.js';
|
import authRoutes from './routes/auth.routes.js';
|
||||||
import customerRoutes from './routes/customer.routes.js';
|
import customerRoutes from './routes/customer.routes.js';
|
||||||
import addressRoutes from './routes/address.routes.js';
|
import addressRoutes from './routes/address.routes.js';
|
||||||
@@ -68,8 +69,11 @@ import { startContractStatusScheduler } from './services/contractStatusScheduler
|
|||||||
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
|
||||||
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
|
||||||
import monitoringRoutes from './routes/monitoring.routes.js';
|
import monitoringRoutes from './routes/monitoring.routes.js';
|
||||||
|
import { registriereIdPruefung } from './middleware/routeIds.js';
|
||||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||||
import { auditMiddleware } from './middleware/audit.js';
|
import { auditMiddleware } from './middleware/audit.js';
|
||||||
|
import { starteHeartbeatMonitor } from './services/heartbeatMonitor.service.js';
|
||||||
|
import { pruefePflichtrechte } from './services/pflichtrechte.service.js';
|
||||||
import { authenticate } from './middleware/auth.js';
|
import { authenticate } from './middleware/auth.js';
|
||||||
|
|
||||||
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ====================
|
||||||
@@ -324,64 +328,76 @@ app.use('/api', (_req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
// HIER STAND eine Pfad-Heuristik gegen abgeschnittene IDs (Pentest Runde 7,
|
||||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
// 2026-05-17): Sie blockte Segmente der Form `^\d+[a-zA-Z]+$` – also `6abc`,
|
||||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
// weil `parseInt('6abc')` die 6 ergibt und `/api/customers/6abc` still als
|
||||||
// Validierung. Pentest Runde 7 (2026-05-17), LOW.
|
// Kunde 6 gelesen wurde. Ihr eigener Kommentar nannte den Grund fuer die
|
||||||
|
// Heuristik: „`app.param()` greift nicht auf in Sub-Router gemounteten Routes".
|
||||||
//
|
//
|
||||||
// `app.param()` greift nicht auf in Sub-Router gemounteten Routes, deshalb
|
// Genau das loest `mounte()` weiter unten – die Pruefung wird an jedem Router
|
||||||
// machen wir es als Pfad-Heuristik. Geblockt wird NUR `^\d+[a-zA-Z]+$` –
|
// registriert, nicht am App-Objekt. Damit ist die Heuristik abgeloest, und
|
||||||
// reine Ziffern gefolgt von reinen Buchstaben (`6abc`, `12foo`). UUIDs wie
|
// zwar in beide Richtungen:
|
||||||
// `3018c9b9-b337-4c9a-a402-b47872f8ddae` (Consent-Hash) und Datumsstrings
|
//
|
||||||
// `2024-05-17` haben Bindestriche / gemischten Aufbau und werden korrekt
|
// – Sie war zu eng: `/api/users/abc` ging durch (keine Ziffer vorn) und
|
||||||
// nicht geblockt.
|
// endete als 500. Die Parameter-Pruefung kennt dagegen die tatsaechlichen
|
||||||
const TRUNCATED_ID_PATTERN = /^\d+[a-zA-Z]+$/;
|
// ID-Parameter und laesst nur kanonische Zahlen zu.
|
||||||
app.use('/api', (req, res, next) => {
|
// – Sie war zu weit: Ein Einstellungs-Schluessel `12abc` unter
|
||||||
for (const seg of req.path.split('/')) {
|
// `/api/settings/:key` wurde geblockt, obwohl `:key` gar keine ID ist.
|
||||||
if (seg.length > 0 && TRUNCATED_ID_PATTERN.test(seg)) {
|
//
|
||||||
res.status(400).json({ success: false, error: 'Ungültige ID im URL-Pfad' });
|
// Und sie antwortete 400, wo die Parameter-Pruefung 404 gibt – zwei Antworten
|
||||||
return;
|
// fuer dieselbe Eingabeklasse. Siehe middleware/routeIds.ts.
|
||||||
}
|
|
||||||
}
|
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
||||||
next();
|
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
||||||
});
|
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
||||||
|
app.use('/api', apiBackstopRateLimiter);
|
||||||
|
|
||||||
|
// Router einhängen – IMMER über `mounte`, nie über `app.use` direkt.
|
||||||
|
//
|
||||||
|
// `mounte` haengt die zentrale Pruefung numerischer Pfad-Parameter an (R188)
|
||||||
|
// und montiert danach. Wer hier kuenftig `app.use` schreibt, umgeht sie
|
||||||
|
// stillschweigend – deshalb steht die Pruefung im selben Handgriff wie das
|
||||||
|
// Einhaengen und nicht in einer zweiten Liste, die man vergessen kann.
|
||||||
|
const mounte = (pfad: string, router: Router): void => {
|
||||||
|
app.use(pfad, registriereIdPruefung(router));
|
||||||
|
};
|
||||||
|
|
||||||
// Öffentliche Routes (OHNE Authentifizierung)
|
// Öffentliche Routes (OHNE Authentifizierung)
|
||||||
app.use('/api/public/consent', consentPublicRoutes);
|
mounte('/api/public/consent', consentPublicRoutes);
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
mounte('/api/auth', authRoutes);
|
||||||
app.use('/api/customers', customerRoutes);
|
mounte('/api/customers', customerRoutes);
|
||||||
app.use('/api/addresses', addressRoutes);
|
mounte('/api/addresses', addressRoutes);
|
||||||
app.use('/api/bank-cards', bankcardRoutes);
|
mounte('/api/bank-cards', bankcardRoutes);
|
||||||
app.use('/api/documents', documentRoutes);
|
mounte('/api/documents', documentRoutes);
|
||||||
app.use('/api/meters', meterRoutes);
|
mounte('/api/meters', meterRoutes);
|
||||||
app.use('/api/stressfrei-emails', stressfreiEmailRoutes);
|
mounte('/api/stressfrei-emails', stressfreiEmailRoutes);
|
||||||
app.use('/api/contracts', contractRoutes);
|
mounte('/api/contracts', contractRoutes);
|
||||||
app.use('/api/credit-notes', creditNoteRoutes);
|
mounte('/api/credit-notes', creditNoteRoutes);
|
||||||
app.use('/api/company-profile', companyProfileRoutes);
|
mounte('/api/company-profile', companyProfileRoutes);
|
||||||
app.use('/api/platforms', platformRoutes);
|
mounte('/api/platforms', platformRoutes);
|
||||||
app.use('/api/cancellation-periods', cancellationPeriodRoutes);
|
mounte('/api/cancellation-periods', cancellationPeriodRoutes);
|
||||||
app.use('/api/contract-durations', contractDurationRoutes);
|
mounte('/api/contract-durations', contractDurationRoutes);
|
||||||
app.use('/api/providers', providerRoutes);
|
mounte('/api/providers', providerRoutes);
|
||||||
app.use('/api/tariffs', tariffRoutes);
|
mounte('/api/tariffs', tariffRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
mounte('/api/users', userRoutes);
|
||||||
app.use('/api/upload', uploadRoutes);
|
mounte('/api/upload', uploadRoutes);
|
||||||
app.use('/api/developer', developerRoutes);
|
mounte('/api/developer', developerRoutes);
|
||||||
app.use('/api/contract-categories', contractCategoryRoutes);
|
mounte('/api/contract-categories', contractCategoryRoutes);
|
||||||
app.use('/api', contractTaskRoutes);
|
mounte('/api', contractTaskRoutes);
|
||||||
app.use('/api/settings', appSettingRoutes);
|
mounte('/api/settings', appSettingRoutes);
|
||||||
app.use('/api/email-providers', emailProviderRoutes);
|
mounte('/api/email-providers', emailProviderRoutes);
|
||||||
app.use('/api', cachedEmailRoutes);
|
mounte('/api', cachedEmailRoutes);
|
||||||
app.use('/api/energy-details', invoiceRoutes);
|
mounte('/api/energy-details', invoiceRoutes);
|
||||||
app.use('/api', contractHistoryRoutes);
|
mounte('/api', contractHistoryRoutes);
|
||||||
app.use('/api/audit-logs', auditLogRoutes);
|
mounte('/api/audit-logs', auditLogRoutes);
|
||||||
app.use('/api/gdpr', gdprRoutes);
|
mounte('/api/gdpr', gdprRoutes);
|
||||||
app.use('/api/email-logs', emailLogRoutes);
|
mounte('/api/email-logs', emailLogRoutes);
|
||||||
app.use('/api/pdf-templates', pdfTemplateRoutes);
|
mounte('/api/pdf-templates', pdfTemplateRoutes);
|
||||||
app.use('/api/birthdays', birthdayRoutes);
|
mounte('/api/birthdays', birthdayRoutes);
|
||||||
app.use('/api/factory-defaults', factoryDefaultsRoutes);
|
mounte('/api/factory-defaults', factoryDefaultsRoutes);
|
||||||
app.use('/api/monitoring', monitoringRoutes);
|
mounte('/api/monitoring', monitoringRoutes);
|
||||||
|
|
||||||
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
// Health check – BEWUSST ohne Auth (Container-Healthcheck und Reverse-Proxy
|
||||||
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
// pingen das ohne Bearer-Token). Antwort enthält absichtlich nur statisch
|
||||||
@@ -481,6 +497,12 @@ app.use((err: any, req: express.Request, res: express.Response, _next: express.N
|
|||||||
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
const LISTEN_ADDR = process.env.LISTEN_ADDR
|
||||||
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
|| (process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0');
|
||||||
|
|
||||||
|
// Wachhund auf ausbleibende Dienstkonto-Anmeldungen (Pentest R182/R183):
|
||||||
|
// Ein stillgelegtes Gegenbuch soll auffallen, nicht als Ruhe durchgehen.
|
||||||
|
starteHeartbeatMonitor();
|
||||||
|
// Erkennt die ABWESENHEIT der DSGVO-/Audit-Faehigkeit (R189-01).
|
||||||
|
void pruefePflichtrechte();
|
||||||
|
|
||||||
app.listen(PORT as number, LISTEN_ADDR, () => {
|
app.listen(PORT as number, LISTEN_ADDR, () => {
|
||||||
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
console.log(`Server läuft auf ${LISTEN_ADDR}:${PORT}`);
|
||||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ function determineAction(method: string, path: string, success: boolean): AuditA
|
|||||||
if (path.includes('/auth/logout')) {
|
if (path.includes('/auth/logout')) {
|
||||||
return 'LOGOUT';
|
return 'LOGOUT';
|
||||||
}
|
}
|
||||||
|
// Stiller Token-Refresh (Cookie) – kein interaktiver Login, eigene Action.
|
||||||
|
// Erfolg vs. Fehlschlag wird NICHT über die Action getrennt (semantisch beides
|
||||||
|
// ein Refresh), sondern downstream über success-Flag + Sensitivität (LOW/HIGH)
|
||||||
|
// und den TOKEN_REJECTED-SecurityEvent im Controller (Pentest R164-01).
|
||||||
|
if (path.includes('/auth/refresh')) {
|
||||||
|
return 'TOKEN_REFRESH';
|
||||||
|
}
|
||||||
|
|
||||||
// Standard CRUD-Aktionen
|
// Standard CRUD-Aktionen
|
||||||
switch (method.toUpperCase()) {
|
switch (method.toUpperCase()) {
|
||||||
@@ -88,10 +95,10 @@ function findResourceMapping(path: string): { type: string; extractId?: (req: Au
|
|||||||
/**
|
/**
|
||||||
* Extrahiert die betroffene Kunden-ID für DSGVO-Tracking
|
* Extrahiert die betroffene Kunden-ID für DSGVO-Tracking
|
||||||
*/
|
*/
|
||||||
function extractDataSubjectId(req: AuthRequest): number | undefined {
|
function extractDataSubjectId(req: AuthRequest, fullPath: string): number | undefined {
|
||||||
// Aus Route-Parameter
|
// Aus Route-Parameter
|
||||||
const customerId = req.params.customerId || req.params.id;
|
const customerId = req.params.customerId || req.params.id;
|
||||||
if (customerId && req.path.includes('/customers')) {
|
if (customerId && fullPath.includes('/customers')) {
|
||||||
return parseInt(customerId);
|
return parseInt(customerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +164,40 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
LOGIN: 'angemeldet',
|
LOGIN: 'angemeldet',
|
||||||
LOGOUT: 'abgemeldet',
|
LOGOUT: 'abgemeldet',
|
||||||
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
|
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
|
||||||
|
TOKEN_REFRESH: 'Sitzung verlängert',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist das die planmaessige Anmeldung eines Dienstkontos? (Pentest R182)
|
||||||
|
*
|
||||||
|
* Vorhersagbare Ereignisse sind miserable Alarme und exzellente Grundlinien.
|
||||||
|
* Das Gegenbuch meldet sich stuendlich an – als CRITICAL gefuehrt trainiert
|
||||||
|
* genau diese Routine den Betreiber darauf, die hoechste Stufe wegzuklicken,
|
||||||
|
* und der erste ECHTE Vorfall erbt den Reflex. Der Eintrag bleibt (man soll
|
||||||
|
* sehen, dass das Gegenbuch arbeitet), aber als Routine.
|
||||||
|
*/
|
||||||
|
function istDienstkontoAnmeldung(responseBody: unknown, success: boolean): boolean {
|
||||||
|
if (!success || !responseBody || typeof responseBody !== 'object') return false;
|
||||||
|
const data = (responseBody as { data?: { user?: { isServiceAccount?: boolean } } }).data;
|
||||||
|
return data?.user?.isServiceAccount === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unterscheidet die drei Ausgaenge von POST /auth/refresh (Pentest R166-03).
|
||||||
|
*
|
||||||
|
* "Kein Cookie vorhanden" ist KEIN Ablehnungsfall: Der Frontend-Interceptor
|
||||||
|
* ruft /refresh beim App-Start auch dann, wenn nie ein Token gesetzt war
|
||||||
|
* (normaler Erstbesuch). Das als HIGH/"abgelehnt" zu fuehren erzeugt genau das
|
||||||
|
* Rauschen, das mit der Entrauschung beseitigt werden sollte.
|
||||||
|
*/
|
||||||
|
function refreshOutcome(responseBody: unknown, success: boolean): 'ok' | 'no-token' | 'rejected' {
|
||||||
|
if (success) return 'ok';
|
||||||
|
const err = responseBody && typeof responseBody === 'object' && 'error' in responseBody
|
||||||
|
? String((responseBody as { error?: unknown }).error ?? '')
|
||||||
|
: '';
|
||||||
|
return /kein refresh-token/i.test(err) ? 'no-token' : 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
|
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
|
||||||
*/
|
*/
|
||||||
@@ -166,7 +205,8 @@ function generateHumanLabel(
|
|||||||
action: AuditAction,
|
action: AuditAction,
|
||||||
resourceType: string,
|
resourceType: string,
|
||||||
req: AuthRequest,
|
req: AuthRequest,
|
||||||
responseBody: unknown
|
responseBody: unknown,
|
||||||
|
fullPath: string
|
||||||
): string {
|
): string {
|
||||||
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
|
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
|
||||||
const actionName = ACTION_LABELS[action] || action;
|
const actionName = ACTION_LABELS[action] || action;
|
||||||
@@ -188,16 +228,28 @@ function generateHumanLabel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spezial-Labels für bestimmte Endpunkte
|
// Spezial-Labels für bestimmte Endpunkte
|
||||||
const path = req.path;
|
// (fullPath statt req.path – siehe Hinweis in auditMiddleware, Pentest R165)
|
||||||
|
const path = fullPath;
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
|
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
|
||||||
const email = req.body?.email || '';
|
const email = req.body?.email || '';
|
||||||
|
if (action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, true)) {
|
||||||
|
return `Dienstkonto ${email} angemeldet (planmäßig)`;
|
||||||
|
}
|
||||||
return action === 'LOGIN'
|
return action === 'LOGIN'
|
||||||
? `Benutzer ${email} hat sich angemeldet`
|
? `Benutzer ${email} hat sich angemeldet`
|
||||||
: `Anmeldung fehlgeschlagen für ${email}`;
|
: `Anmeldung fehlgeschlagen für ${email}`;
|
||||||
}
|
}
|
||||||
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
|
||||||
|
if (path.includes('/auth/refresh')) {
|
||||||
|
const success = !(responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false);
|
||||||
|
switch (refreshOutcome(responseBody, success)) {
|
||||||
|
case 'ok': return 'Sitzung verlängert (Token erneuert)';
|
||||||
|
case 'no-token': return 'Token-Refresh ohne vorliegenden Token (kein Cookie)';
|
||||||
|
default: return 'Token-Refresh abgelehnt (ungültig/abgelaufen)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Kunden-Operationen
|
// Kunden-Operationen
|
||||||
if (resourceType === 'Customer') {
|
if (resourceType === 'Customer') {
|
||||||
@@ -359,14 +411,23 @@ function generateHumanLabel(
|
|||||||
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// WICHTIG (Pentest R165): `req.path` ist im res.on('finish')-Handler NICHT mehr der
|
||||||
|
// volle Pfad. Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url`
|
||||||
|
// und stellt ihn nur beim `next()`-Durchlauf wieder her – ein Handler, der die
|
||||||
|
// Response terminiert (res.json()), ruft nie `next()`, also bleibt `req.path`
|
||||||
|
// router-relativ (`/refresh` statt `/api/auth/refresh`). Deshalb den vollen Pfad
|
||||||
|
// EINMAL hier synchron festhalten und downstream ausschliesslich diesen nutzen –
|
||||||
|
// sonst matchen alle Pfad-Checks (/auth/login, /auth/refresh, …) ins Leere.
|
||||||
|
const fullPath = req.originalUrl?.split('?')[0] || req.path;
|
||||||
|
|
||||||
// Ausgeschlossene Routen überspringen
|
// Ausgeschlossene Routen überspringen
|
||||||
if (EXCLUDED_ROUTES.some((route) => req.path.startsWith(route))) {
|
if (EXCLUDED_ROUTES.some((route) => fullPath.startsWith(route))) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resource-Mapping finden
|
// Resource-Mapping finden
|
||||||
const mapping = findResourceMapping(req.path);
|
const mapping = findResourceMapping(fullPath);
|
||||||
if (!mapping) {
|
if (!mapping) {
|
||||||
// Unbekannte Route - trotzdem loggen mit generischem Typ
|
// Unbekannte Route - trotzdem loggen mit generischem Typ
|
||||||
next();
|
next();
|
||||||
@@ -402,7 +463,7 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
|||||||
setImmediate(async () => {
|
setImmediate(async () => {
|
||||||
try {
|
try {
|
||||||
const durationMs = Date.now() - startTime;
|
const durationMs = Date.now() - startTime;
|
||||||
const action = determineAction(req.method, req.path, responseSuccess);
|
const action = determineAction(req.method, fullPath, responseSuccess);
|
||||||
|
|
||||||
// READ-Aktionen nicht loggen (nur Änderungen, Logins und Exporte)
|
// READ-Aktionen nicht loggen (nur Änderungen, Logins und Exporte)
|
||||||
if (action === 'READ') return;
|
if (action === 'READ') return;
|
||||||
@@ -415,19 +476,19 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
|||||||
'/api/gdpr',
|
'/api/gdpr',
|
||||||
'/api/upload',
|
'/api/upload',
|
||||||
];
|
];
|
||||||
// Login/Logout immer loggen
|
// Login/Logout/Refresh immer loggen
|
||||||
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED') {
|
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED' && action !== 'TOKEN_REFRESH') {
|
||||||
if (manuallyLoggedPaths.some(p => req.originalUrl?.startsWith(p) || req.baseUrl?.startsWith(p))) return;
|
if (manuallyLoggedPaths.some(p => fullPath.startsWith(p))) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceId = mapping.extractId?.(req);
|
const resourceId = mapping.extractId?.(req);
|
||||||
const dataSubjectId = extractDataSubjectId(req);
|
const dataSubjectId = extractDataSubjectId(req, fullPath);
|
||||||
|
|
||||||
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
|
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
|
||||||
const auditContext = capturedAuditContext;
|
const auditContext = capturedAuditContext;
|
||||||
|
|
||||||
// Menschenlesbares Label generieren
|
// Menschenlesbares Label generieren
|
||||||
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
|
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody, fullPath);
|
||||||
|
|
||||||
await createAuditLog({
|
await createAuditLog({
|
||||||
userId: req.user?.userId,
|
userId: req.user?.userId,
|
||||||
@@ -436,10 +497,20 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
|
|||||||
customerId: req.user?.customerId,
|
customerId: req.user?.customerId,
|
||||||
isCustomerPortal: req.user?.isCustomerPortal,
|
isCustomerPortal: req.user?.isCustomerPortal,
|
||||||
action,
|
action,
|
||||||
|
// Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
|
||||||
|
// Abgelehnter Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in der
|
||||||
|
// Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01).
|
||||||
|
// "Kein Cookie vorhanden" ist dagegen der normale Erstbesuch → LOW (R166-03).
|
||||||
|
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
|
||||||
|
sensitivity: action === 'TOKEN_REFRESH'
|
||||||
|
? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW')
|
||||||
|
: action === 'LOGIN' && istDienstkontoAnmeldung(responseBody, responseSuccess)
|
||||||
|
? 'LOW'
|
||||||
|
: undefined,
|
||||||
resourceType: mapping.type,
|
resourceType: mapping.type,
|
||||||
resourceId,
|
resourceId,
|
||||||
resourceLabel,
|
resourceLabel,
|
||||||
endpoint: req.path,
|
endpoint: fullPath,
|
||||||
httpMethod: req.method,
|
httpMethod: req.method,
|
||||||
ipAddress: getClientIp(req),
|
ipAddress: getClientIp(req),
|
||||||
userAgent: req.headers['user-agent'],
|
userAgent: req.headers['user-agent'],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||||||
@@ -128,7 +129,18 @@ export function requirePermission(...requiredPermissions: string[]) {
|
|||||||
return;
|
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
|
// Check if user has any of the required permissions
|
||||||
const hasPermission = requiredPermissions.some((perm) =>
|
const hasPermission = requiredPermissions.some((perm) =>
|
||||||
|
|||||||
@@ -132,6 +132,47 @@ export const staffPasswordReAuthLimiter = rateLimit({
|
|||||||
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
|
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
|
||||||
* für legitime Kunden weit aus.
|
* für legitime Kunden weit aus.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Globaler Backstop-Limiter für ALLE /api-Requests (Pentest R148).
|
||||||
|
*
|
||||||
|
* Hintergrund: Es gab bislang KEINEN generellen /api-Limiter – nur die
|
||||||
|
* dedizierten oben (Login, Passwort-Reset, Staff-Re-Auth, Consent). Damit war
|
||||||
|
* jeder authentifizierte Endpoint gegen Enumeration/Scripted-Abuse/DoS
|
||||||
|
* ungedrosselt. Dieser Limiter ist eine großzügige Obergrenze, KEIN Ersatz für
|
||||||
|
* die feineren Limiter (die feuern früher und bleiben aktiv).
|
||||||
|
*
|
||||||
|
* Key = NUR die (IPv6-/56-normalisierte) IP. Bewusst NICHT (IP+User): den
|
||||||
|
* User-Claim könnten wir hier nur unverifiziert aus dem Token lesen (die volle
|
||||||
|
* `authenticate`-Prüfung inkl. DB läuft erst pro Route). Ein Angreifer könnte
|
||||||
|
* dann mit gefälschten userId-Claims beliebig frische Buckets erzeugen und den
|
||||||
|
* Backstop umgehen. Per-Account-Präzision liefern ohnehin die Login-Limiter.
|
||||||
|
*
|
||||||
|
* Limit per Env `API_RATE_LIMIT_PER_MIN` (Default 1200/min ≈ 20/s pro IP) –
|
||||||
|
* für legitime Nutzung (auch mehrere Nutzer hinter NAT) weit ausreichend,
|
||||||
|
* bremst aber Flooding massiv. Healthcheck (`/api/health`) ist ausgenommen.
|
||||||
|
*
|
||||||
|
* KEIN SecurityEvent pro geblocktem Request: Bei einem Flood würde das den
|
||||||
|
* Security-/Audit-Store selbst zumüllen (Amplification). Die 429 stehen im
|
||||||
|
* Access-Log; die feineren Limiter melden weiterhin an das Monitoring.
|
||||||
|
*/
|
||||||
|
const API_RATE_LIMIT_PER_MIN = Math.max(
|
||||||
|
parseInt(process.env.API_RATE_LIMIT_PER_MIN || '', 10) || 1200,
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const apiBackstopRateLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: API_RATE_LIMIT_PER_MIN,
|
||||||
|
standardHeaders: 'draft-7',
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: {
|
||||||
|
success: false,
|
||||||
|
error: 'Zu viele Anfragen in kurzer Zeit. Bitte einen Moment warten.',
|
||||||
|
},
|
||||||
|
keyGenerator: (req): string => ipKeyGenerator(req.ip || 'unknown'),
|
||||||
|
skip: (req) => req.path === '/health',
|
||||||
|
});
|
||||||
|
|
||||||
export const publicConsentRateLimiter = rateLimit({
|
export const publicConsentRateLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
limit: 30,
|
limit: 30,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Request, Response, NextFunction, Router, RequestParamHandler } from 'express';
|
||||||
|
import { ApiResponse } from '../types/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numerische Pfad-Parameter zentral pruefen (Pentest R188).
|
||||||
|
*
|
||||||
|
* Ausgangslage: In den Controllern stand 181-mal `parseInt(req.params.<x>)`
|
||||||
|
* ohne Pruefung. Das hatte zwei Folgen, und die zweite ist die unangenehmere:
|
||||||
|
*
|
||||||
|
* 1. Ein nicht-numerisches Segment ergab `NaN`, Prisma lief auf und der
|
||||||
|
* Handler antwortete mit **500**. `GET /api/users/permissions` etwa
|
||||||
|
* trifft `/:id` und war damit ein Serverfehler statt „gibt es nicht".
|
||||||
|
*
|
||||||
|
* 2. `parseInt` liest so weit, wie es kann: `parseInt('12abc')` ist **12**.
|
||||||
|
* `GET /api/users/12abc` lieferte also Benutzer 12 aus. Eine schlampig
|
||||||
|
* geratene ID wurde stillschweigend zu einer gueltigen gemacht.
|
||||||
|
*
|
||||||
|
* Die naheliegende Antwort waere gewesen, an allen 181 Stellen einen Guard
|
||||||
|
* einzusetzen. Genau daran haben wir uns in dieser Reihe mehrfach die Finger
|
||||||
|
* verbrannt: Was an vielen Stellen gepflegt werden muss, laeuft auseinander
|
||||||
|
* (zuletzt R186-01, drei Filterlisten, die dasselbe bedeuten sollten). Deshalb
|
||||||
|
* eine Stelle statt 181 – und eine neue Route ist automatisch mit abgedeckt.
|
||||||
|
*
|
||||||
|
* Umgesetzt ueber `router.param()`: Express ruft den Callback, bevor der
|
||||||
|
* Handler laeuft, und nur fuer Routen, die den Parameter wirklich benutzen.
|
||||||
|
*
|
||||||
|
* **Antwort ist 404, nicht 400.** Ein Pfad-Segment, das keine ID sein kann,
|
||||||
|
* benennt keine Ressource – das ist „nicht gefunden", nicht „falsch gefragt".
|
||||||
|
* Der Codebestand hat es an der einzigen bereits abgesicherten Stelle
|
||||||
|
* (`provider.controller.ts`, Pentest Mai 2026) genauso entschieden. Nebenbei
|
||||||
|
* verraet eine einheitliche 404 einem Probierenden nicht, ob ein Endpunkt
|
||||||
|
* existiert und eine Zahl erwartet oder ob es den Pfad gar nicht gibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameternamen, die eine Datenbank-ID bezeichnen – abgeglichen mit allen
|
||||||
|
* Routendateien. Bewusst NICHT dabei und deshalb unberuehrt: `consentType`,
|
||||||
|
* `filename`, `hash`, `key`, `localPart`, `name`, `tableName`.
|
||||||
|
*
|
||||||
|
* Die Namensregel „heisst `id` oder endet auf `Id`" gilt im gesamten Bestand;
|
||||||
|
* eine neue Route mit `:fooId` gehoert hier ergaenzt.
|
||||||
|
*/
|
||||||
|
const ID_PARAMETER = [
|
||||||
|
'id',
|
||||||
|
'contractId',
|
||||||
|
'contractMeterId',
|
||||||
|
'customerId',
|
||||||
|
'documentId',
|
||||||
|
'ecdId',
|
||||||
|
'emailId',
|
||||||
|
'entryId',
|
||||||
|
'invoiceId',
|
||||||
|
'meterId',
|
||||||
|
'phoneNumberId',
|
||||||
|
'providerId',
|
||||||
|
'readingId',
|
||||||
|
'referralId',
|
||||||
|
'representativeId',
|
||||||
|
'simCardId',
|
||||||
|
'subtaskId',
|
||||||
|
'taskId',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Obergrenze von MySQL INT – darueber gibt es keine Zeile, nur einen Fehler. */
|
||||||
|
const MAX_ID = 2147483647;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gueltig ist ausschliesslich eine kanonische positive Ganzzahl.
|
||||||
|
*
|
||||||
|
* Fuehrende Nullen werden abgelehnt: `007` und `7` wuerden dieselbe Zeile
|
||||||
|
* bezeichnen, und zwei Schreibweisen fuer dieselbe Ressource sind eine
|
||||||
|
* unnoetige Einladung – etwa fuer Zaehler, Zwischenspeicher oder Sperren, die
|
||||||
|
* auf dem Pfad als Schluessel arbeiten.
|
||||||
|
*/
|
||||||
|
export function istGueltigeId(wert: string): boolean {
|
||||||
|
if (!/^[1-9][0-9]*$/.test(wert)) return false;
|
||||||
|
const n = Number(wert);
|
||||||
|
return Number.isSafeInteger(n) && n <= MAX_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruefeIdParameter: RequestParamHandler = (
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
wert: unknown,
|
||||||
|
) => {
|
||||||
|
if (typeof wert === 'string' && istGueltigeId(wert)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Bewusst ohne Angabe, WELCHER Parameter beanstandet wurde: Die Meldung
|
||||||
|
// soll nicht zur Landkarte werden, welche Endpunkte welche IDs erwarten.
|
||||||
|
res.status(404).json({ success: false, error: 'Nicht gefunden' } as ApiResponse);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haengt die Pruefung an einen Router. Namen, die der Router gar nicht
|
||||||
|
* verwendet, kosten nichts – Express ruft den Callback nur fuer Parameter,
|
||||||
|
* die in einer getroffenen Route vorkommen.
|
||||||
|
*/
|
||||||
|
export function registriereIdPruefung<T extends Router>(router: T): T {
|
||||||
|
for (const name of ID_PARAMETER) {
|
||||||
|
router.param(name, pruefeIdParameter);
|
||||||
|
}
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -61,11 +61,17 @@ router.post(
|
|||||||
backupController.createBackup
|
backupController.createBackup
|
||||||
);
|
);
|
||||||
|
|
||||||
// Backup wiederherstellen
|
// Backup wiederherstellen.
|
||||||
|
//
|
||||||
|
// Ebenfalls UND-verknuepft: Ein Restore ersetzt auch Benutzer, Rollen und
|
||||||
|
// Rechtezuordnungen. Wer eine aeltere Sicherung einspielt, in der er selbst
|
||||||
|
// mehr durfte, hat sich damit befoerdert - derselbe Umweg wie beim
|
||||||
|
// Werksreset, nur leiser.
|
||||||
router.post(
|
router.post(
|
||||||
'/backup/:name/restore',
|
'/backup/:name/restore',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings:update'),
|
requirePermission('settings:update'),
|
||||||
|
requirePermission('roles:manage'),
|
||||||
backupController.restoreBackup
|
backupController.restoreBackup
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,11 +100,23 @@ router.post(
|
|||||||
backupController.uploadBackup
|
backupController.uploadBackup
|
||||||
);
|
);
|
||||||
|
|
||||||
// Werkseinstellungen (alles löschen)
|
// Werkseinstellungen (alles löschen).
|
||||||
|
//
|
||||||
|
// Verlangt UND-verknuepft `settings:update` und `roles:manage` - zwei
|
||||||
|
// requirePermission hintereinander, weil ein einzelner Aufruf mit mehreren
|
||||||
|
// Rechten ODER bedeutet.
|
||||||
|
//
|
||||||
|
// Grund: Der Werksreset loescht alle Rollen und Rechtezuordnungen und legt
|
||||||
|
// ein frisches admin@admin.com an. Eine Rolle mit nur `settings:update`
|
||||||
|
// haette damit die gesamte Rechtevergabe zuruecksetzen und sich anschliessend
|
||||||
|
// ueber das neue Konto anmelden koennen - eine Rechteerhoehung ueber den
|
||||||
|
// Umweg "alles wegwerfen". Wer das Rechtemodell platt machen darf, muss es
|
||||||
|
// auch pflegen duerfen.
|
||||||
router.post(
|
router.post(
|
||||||
'/factory-reset',
|
'/factory-reset',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings:update'),
|
requirePermission('settings:update'),
|
||||||
|
requirePermission('roles:manage'),
|
||||||
backupController.factoryReset
|
backupController.factoryReset
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,29 +7,50 @@ const router = Router();
|
|||||||
// Alle Routen erfordern Authentifizierung
|
// Alle Routen erfordern Authentifizierung
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ACHTUNG Reihenfolge: Alle konkreten Pfade MÜSSEN vor der Parameter-Route
|
||||||
|
// '/:id' stehen, sonst schluckt diese sie und antwortet mit
|
||||||
|
// "Ungültige Audit-Log-ID". Genau so war GET /retention-policies unerreichbar
|
||||||
|
// (Pentest R166-04).
|
||||||
|
|
||||||
// Audit-Logs abrufen
|
// Audit-Logs abrufen
|
||||||
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
|
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
|
||||||
|
|
||||||
// Audit-Logs exportieren (muss VOR /:id stehen!)
|
// Audit-Logs exportieren
|
||||||
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs);
|
//
|
||||||
|
// Verlangt `audit:export`, nicht `audit:read`. Die Berechtigung stand im
|
||||||
|
// Katalog und in der Rollenverwaltung, gatete aber NICHTS - jeder Leser
|
||||||
|
// konnte das vollstaendige Protokoll in einem Zug herausziehen.
|
||||||
|
//
|
||||||
|
// Das ist etwas anderes als Blaettern: Der Export liefert `changesBefore` und
|
||||||
|
// `changesAfter`, also die vollstaendigen Vorher/Nachher-Datensaetze, dazu
|
||||||
|
// `resourceLabel` mit Klartextnamen, IP-Adressen und User-Agents. Aufgefallen
|
||||||
|
// am Dienstkonto des Gegenbuchs: Es soll ausschliesslich Pruefwerte lesen -
|
||||||
|
// und konnte Personendaten exportieren.
|
||||||
|
router.get('/export', requirePermission('audit:export'), auditLogController.exportAuditLogs);
|
||||||
|
|
||||||
|
// Retention-Policies
|
||||||
|
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
|
||||||
|
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
|
||||||
|
|
||||||
|
// Kontrollwert für das externe Gegenbuch (nur lesend, ohne Geheimnisse)
|
||||||
|
router.get('/checkpoint', requirePermission('audit:read'), auditLogController.getCheckpoint);
|
||||||
|
|
||||||
// Audit-Logs für einen Kunden (DSGVO)
|
// Audit-Logs für einen Kunden (DSGVO)
|
||||||
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
|
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
|
||||||
|
|
||||||
// Einzelnes Audit-Log abrufen
|
|
||||||
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
|
|
||||||
|
|
||||||
// Hash-Ketten-Integrität prüfen
|
// Hash-Ketten-Integrität prüfen
|
||||||
router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity);
|
router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity);
|
||||||
|
|
||||||
// Hash-Kette reparieren
|
// Hash-Kette reparieren
|
||||||
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
|
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
|
||||||
|
|
||||||
// Retention-Policies
|
// Altbestand versiegeln (nicht destruktiv)
|
||||||
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
|
router.post('/seal-backlog', requirePermission('audit:admin'), auditLogController.sealBacklog);
|
||||||
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
|
|
||||||
|
|
||||||
// Retention-Cleanup manuell ausführen
|
// Retention-Cleanup manuell ausführen
|
||||||
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
|
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
|
||||||
|
|
||||||
|
// Einzelnes Audit-Log abrufen – als LETZTE GET-Route, siehe Hinweis oben
|
||||||
|
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
// Kuendigungsfristen haben eigene Rechte (`cancellation-periods:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
|
||||||
|
// nicht. `cancellation-periods:*` stand im Katalog und bewachte nichts.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js';
|
import * as cancellationPeriodController from '../controllers/cancellation-period.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', authenticate, cancellationPeriodController.getCancellationPeriods);
|
router.get('/', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriods);
|
||||||
router.post('/', authenticate, requirePermission('platforms:create'), cancellationPeriodController.createCancellationPeriod);
|
router.post('/', authenticate, requirePermission('cancellation-periods:create'), cancellationPeriodController.createCancellationPeriod);
|
||||||
router.get('/:id', authenticate, cancellationPeriodController.getCancellationPeriod);
|
router.get('/:id', authenticate, requirePermission('cancellation-periods:read'), cancellationPeriodController.getCancellationPeriod);
|
||||||
router.put('/:id', authenticate, requirePermission('platforms:update'), cancellationPeriodController.updateCancellationPeriod);
|
router.put('/:id', authenticate, requirePermission('cancellation-periods:update'), cancellationPeriodController.updateCancellationPeriod);
|
||||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), cancellationPeriodController.deleteCancellationPeriod);
|
router.delete('/:id', authenticate, requirePermission('cancellation-periods:delete'), cancellationPeriodController.deleteCancellationPeriod);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
// Vertragslaufzeiten haben eigene Rechte (`contract-durations:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten die Schreibwege auf `platforms:*` und die Lesewege gar
|
||||||
|
// nicht. `contract-durations:*` stand im Katalog und bewachte nichts.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as contractDurationController from '../controllers/contract-duration.controller.js';
|
import * as contractDurationController from '../controllers/contract-duration.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', authenticate, contractDurationController.getContractDurations);
|
router.get('/', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDurations);
|
||||||
router.post('/', authenticate, requirePermission('platforms:create'), contractDurationController.createContractDuration);
|
router.post('/', authenticate, requirePermission('contract-durations:create'), contractDurationController.createContractDuration);
|
||||||
router.get('/:id', authenticate, contractDurationController.getContractDuration);
|
router.get('/:id', authenticate, requirePermission('contract-durations:read'), contractDurationController.getContractDuration);
|
||||||
router.put('/:id', authenticate, requirePermission('platforms:update'), contractDurationController.updateContractDuration);
|
router.put('/:id', authenticate, requirePermission('contract-durations:update'), contractDurationController.updateContractDuration);
|
||||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), contractDurationController.deleteContractDuration);
|
router.delete('/:id', authenticate, requirePermission('contract-durations:delete'), contractDurationController.deleteContractDuration);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Lesen für alle authentifizierten Benutzer
|
// Lesen verlangt jetzt `contract-categories:read`, vorher genuegte
|
||||||
router.get('/', authenticate, contractCategoryController.getContractCategories);
|
// Angemeldetsein. Das Recht stand im Katalog und bewachte nichts; die
|
||||||
router.get('/:id', authenticate, contractCategoryController.getContractCategory);
|
// Migration vergibt es an jede Rolle, die die Anwendung benutzt, damit
|
||||||
|
// niemand seine Listen verliert.
|
||||||
|
router.get('/', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategories);
|
||||||
|
router.get('/:id', authenticate, requirePermission('contract-categories:read'), contractCategoryController.getContractCategory);
|
||||||
|
|
||||||
// Ändern/Löschen: `contract-categories:*` – wird per seed.ts an Admin-
|
// Ändern/Löschen: `contract-categories:*` – wird per seed.ts an Admin-
|
||||||
// Rollen vergeben. Vorher stand hier `developer:access` mit dem
|
// Rollen vergeben. Vorher stand hier `developer:access` mit dem
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ router.get(
|
|||||||
contractTaskController.getTaskStats
|
contractTaskController.getTaskStats
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Allgemeine Aufgabe OHNE Vertrag/Kunde anlegen (nur Mitarbeiter)
|
||||||
|
router.post(
|
||||||
|
'/tasks',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('contracts:update'),
|
||||||
|
contractTaskController.createGeneralTask
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== TASKS BY CONTRACT ====================
|
// ==================== TASKS BY CONTRACT ====================
|
||||||
|
|
||||||
// Alle Aufgaben eines Vertrags abrufen (auch für Kundenportal, aber nur sichtbare)
|
// Alle Aufgaben eines Vertrags abrufen (auch für Kundenportal, aber nur sichtbare)
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
// Die Providerkonfiguration hat eigene Rechte (`email-providers:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten diese Routen auf `settings:read`/`settings:update` -
|
||||||
|
// wer irgendeine Einstellung aendern durfte, konnte damit auch die
|
||||||
|
// Zugangsdaten des Mailservers auslesen und aendern. `email-providers:*`
|
||||||
|
// stand derweil im Katalog und bewachte nichts.
|
||||||
|
//
|
||||||
|
// BEWUSST NICHT umgestellt: /domain und /public-settings (ungegatet, werden
|
||||||
|
// vom Kundenportal beim Postfach-Antrag gebraucht) sowie check/provision/
|
||||||
|
// deprovision - das sind kundenbezogene Vorgaenge auf `customers:*`, keine
|
||||||
|
// Providerkonfiguration. `email-providers:*` waere dort die falsche Domaene.
|
||||||
// ==================== EMAIL PROVIDER ROUTES ====================
|
// ==================== EMAIL PROVIDER ROUTES ====================
|
||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
@@ -7,15 +18,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Provider Config CRUD (Admin-only)
|
// Provider Config CRUD (Admin-only)
|
||||||
router.get('/configs', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfigs);
|
router.get('/configs', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfigs);
|
||||||
router.get('/configs/:id', authenticate, requirePermission('settings:read'), emailProviderController.getProviderConfig);
|
router.get('/configs/:id', authenticate, requirePermission('email-providers:read'), emailProviderController.getProviderConfig);
|
||||||
router.post('/configs', authenticate, requirePermission('settings:update'), emailProviderController.createProviderConfig);
|
router.post('/configs', authenticate, requirePermission('email-providers:create'), emailProviderController.createProviderConfig);
|
||||||
router.put('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.updateProviderConfig);
|
router.put('/configs/:id', authenticate, requirePermission('email-providers:update'), emailProviderController.updateProviderConfig);
|
||||||
router.delete('/configs/:id', authenticate, requirePermission('settings:update'), emailProviderController.deleteProviderConfig);
|
router.delete('/configs/:id', authenticate, requirePermission('email-providers:delete'), emailProviderController.deleteProviderConfig);
|
||||||
|
|
||||||
// Email Operations
|
// Email Operations
|
||||||
router.post('/test-connection', authenticate, requirePermission('settings:update'), emailProviderController.testConnection);
|
router.post('/test-connection', authenticate, requirePermission('email-providers:update'), emailProviderController.testConnection);
|
||||||
router.post('/test-mail-access', authenticate, requirePermission('settings:update'), emailProviderController.testMailAccess);
|
router.post('/test-mail-access', authenticate, requirePermission('email-providers:update'), emailProviderController.testMailAccess);
|
||||||
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
|
router.get('/domain', authenticate, emailProviderController.getProviderDomain);
|
||||||
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
|
router.get('/public-settings', authenticate, emailProviderController.getPublicSettings);
|
||||||
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
router.get('/check/:localPart', authenticate, requirePermission('customers:read'), emailProviderController.checkEmailExists);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
|
// Lesen verlangt jetzt `platforms:read`, vorher genuegte Angemeldetsein.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as platformController from '../controllers/platform.controller.js';
|
import * as platformController from '../controllers/platform.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', authenticate, platformController.getPlatforms);
|
router.get('/', authenticate, requirePermission('platforms:read'), platformController.getPlatforms);
|
||||||
router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform);
|
router.post('/', authenticate, requirePermission('platforms:create'), platformController.createPlatform);
|
||||||
router.get('/:id', authenticate, platformController.getPlatform);
|
router.get('/:id', authenticate, requirePermission('platforms:read'), platformController.getPlatform);
|
||||||
router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform);
|
router.put('/:id', authenticate, requirePermission('platforms:update'), platformController.updatePlatform);
|
||||||
router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform);
|
router.delete('/:id', authenticate, requirePermission('platforms:delete'), platformController.deletePlatform);
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ router.get('/:id', authenticate, requirePermission('providers:read'), providerCo
|
|||||||
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
||||||
|
|
||||||
// Nested tariff routes
|
// Tarife unter dem Anbieter - eigene Rechte, siehe tariff.routes.ts
|
||||||
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
|
router.get('/:providerId/tariffs', authenticate, requirePermission('tariffs:read'), tariffController.getTariffs);
|
||||||
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
|
router.post('/:providerId/tariffs', authenticate, requirePermission('tariffs:create'), tariffController.createTariff);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
// Tarife haben eigene Rechte (`tariffs:*`).
|
||||||
|
//
|
||||||
|
// Bis 09/2026 gateten diese Routen auf `providers:*`, waehrend `tariffs:*`
|
||||||
|
// im Katalog stand und nichts bewachte - man konnte "Tarife bearbeiten"
|
||||||
|
// anhaken, ohne dass es etwas bewirkte, und wer Anbieter pflegen durfte,
|
||||||
|
// durfte automatisch auch alle Tarife aendern.
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import * as tariffController from '../controllers/tariff.controller.js';
|
import * as tariffController from '../controllers/tariff.controller.js';
|
||||||
import { authenticate, requirePermission } from '../middleware/auth.js';
|
import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||||
@@ -5,8 +11,8 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Standalone tariff routes (for update/delete by tariff id)
|
// Standalone tariff routes (for update/delete by tariff id)
|
||||||
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
|
router.get('/:id', authenticate, requirePermission('tariffs:read'), tariffController.getTariff);
|
||||||
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
|
router.put('/:id', authenticate, requirePermission('tariffs:update'), tariffController.updateTariff);
|
||||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
|
router.delete('/:id', authenticate, requirePermission('tariffs:delete'), tariffController.deleteTariff);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from '../utils/accessControl.js';
|
} from '../utils/accessControl.js';
|
||||||
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
import { validateOptionalIsoDate } from '../utils/sanitize.js';
|
||||||
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js';
|
||||||
import { maybeCancelOnCancellationConfirmation } from '../services/contractStatusScheduler.service.js';
|
import { maybeCancelOnCancellationConfirmation, maybeMarkAwaitingConfirmationOnLetter } from '../services/contractStatusScheduler.service.js';
|
||||||
import { deleteFileAbsolute, deleteUploadByRelativePath } from '../utils/fileCleanup.js';
|
import { deleteFileAbsolute, deleteUploadByRelativePath } from '../utils/fileCleanup.js';
|
||||||
|
|
||||||
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
// Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die
|
||||||
@@ -691,12 +691,17 @@ async function handleContractDocumentUpload(
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde:
|
// Kündigungsbestätigung (nicht "Optionen") hochgeladen → Vertrag auf
|
||||||
// Vertrag ACTIVE → CANCELLED und Vertragsende = Kündigungsdatum.
|
// "Gekündigt / bestätigt" (CANCELLED_CONFIRMED) + Vertragsende = Kündigungs-
|
||||||
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
|
// datum. "Optionen" ist für Vertrags-Änderungen gedacht, nicht für Kündigungen.
|
||||||
if (fieldName === 'cancellationConfirmationPath') {
|
if (fieldName === 'cancellationConfirmationPath') {
|
||||||
await maybeCancelOnCancellationConfirmation(contractId, req);
|
await maybeCancelOnCancellationConfirmation(contractId, req);
|
||||||
}
|
}
|
||||||
|
// Kündigungsschreiben hochgeladen → Vertrag auf "Gekündigt / Bestätigung
|
||||||
|
// abwarten" (CANCELLED), sofern noch nicht (bestätigt) gekündigt.
|
||||||
|
if (fieldName === 'cancellationLetterPath') {
|
||||||
|
await maybeMarkAwaitingConfirmationOnLetter(contractId, req);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -16,14 +16,26 @@ router.delete('/:id', authenticate, requirePermission('users:delete'), userContr
|
|||||||
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
|
// davor, damit ein gestohlener JWT das Admin-Passwort nicht brute-forcen kann.
|
||||||
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
|
router.post('/:id/password', staffPasswordReAuthLimiter, authenticate, requirePermission('users:update'), userController.setUserPassword);
|
||||||
|
|
||||||
// Roles
|
// Rollen und Rechte.
|
||||||
router.get('/roles/list', authenticate, requirePermission('users:read'), userController.getRoles);
|
//
|
||||||
router.post('/roles', authenticate, requirePermission('users:create'), userController.createRole);
|
// Die Schreibwege haengen an `roles:manage`, nicht mehr an `users:*`:
|
||||||
router.get('/roles/:id', authenticate, requirePermission('users:read'), userController.getRole);
|
// "Konten anlegen" und "festlegen, was ein Konto darf" sind zwei
|
||||||
router.put('/roles/:id', authenticate, requirePermission('users:update'), userController.updateRole);
|
// verschiedene Befugnisse. Wer beides hatte, konnte sich eine Rolle mit
|
||||||
router.delete('/roles/:id', authenticate, requirePermission('users:delete'), userController.deleteRole);
|
// `developer:access` bauen und zuweisen - die Rollenverwaltung war damit
|
||||||
|
// faktisch eine Rechteerhoehung mit Zwischenschritt. Die zweite Haelfte des
|
||||||
|
// Riegels ist die Teilmengenregel in rechte.service.ts.
|
||||||
|
//
|
||||||
|
// Die LESEwege bleiben zusaetzlich mit `users:read` erreichbar: Das
|
||||||
|
// Benutzerformular braucht die Rollenliste zum Zuweisen, ohne dass der
|
||||||
|
// Bearbeiter Rollen pflegen koennen muss. `requirePermission` ist
|
||||||
|
// ODER-verknuepft, das traegt ohne Zusatzcode.
|
||||||
|
router.get('/roles/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRoles);
|
||||||
|
router.post('/roles', authenticate, requirePermission('roles:manage'), userController.createRole);
|
||||||
|
router.get('/roles/:id', authenticate, requirePermission('roles:manage', 'users:read'), userController.getRole);
|
||||||
|
router.put('/roles/:id', authenticate, requirePermission('roles:manage'), userController.updateRole);
|
||||||
|
router.delete('/roles/:id', authenticate, requirePermission('roles:manage'), userController.deleteRole);
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
router.get('/permissions/list', authenticate, requirePermission('users:read'), userController.getPermissions);
|
router.get('/permissions/list', authenticate, requirePermission('roles:manage', 'users:read'), userController.getPermissions);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { PORTAL_RECHTE } from '../config/rechte-katalog.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@@ -23,10 +24,194 @@ export function signAccessToken(payload: JwtPayload): string {
|
|||||||
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function signRefreshToken(payload: JwtPayload): string {
|
export function signRefreshToken(payload: JwtPayload, jti?: string, familyId?: string): string {
|
||||||
return jwt.sign({ ...payload, type: 'refresh' }, process.env.JWT_SECRET as string, {
|
return jwt.sign(
|
||||||
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
|
{ ...payload, type: 'refresh', ...(jti ? { jti } : {}), ...(familyId ? { fam: familyId } : {}) },
|
||||||
|
process.env.JWT_SECRET as string,
|
||||||
|
{ expiresIn: REFRESH_TOKEN_EXPIRES_IN },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Refresh-Token-Replay-Schutz (R164-02) =====================
|
||||||
|
//
|
||||||
|
// Kulanzfenster: Mehrere Tabs teilen sich denselben Cookie und koennen einen
|
||||||
|
// Refresh praktisch gleichzeitig ausloesen. Ohne Toleranz wuerde der zweite
|
||||||
|
// legitime Aufruf als Replay gewertet und die Sitzung gesprengt. Die Toleranz
|
||||||
|
// ist bewusst eng (Sekunden UND Anzahl begrenzt): ein Burst mit vielen
|
||||||
|
// Wiederverwendungen - das typische Bild beim Missbrauch eines gestohlenen
|
||||||
|
// Tokens - schlaegt weiterhin an.
|
||||||
|
const REFRESH_REUSE_GRACE_MS = 15_000;
|
||||||
|
const REFRESH_REUSE_MAX = 3;
|
||||||
|
|
||||||
|
/** Legt einen neuen Sitzungs-Refresh-Token an (neue Familie). */
|
||||||
|
export async function issueRefreshToken(
|
||||||
|
payload: JwtPayload,
|
||||||
|
opts: {
|
||||||
|
userId?: number;
|
||||||
|
customerId?: number;
|
||||||
|
isCustomerPortal: boolean;
|
||||||
|
familyId?: string;
|
||||||
|
/** Beim Rotieren bereits beim Einloesen reservierte jti des Nachfolgers. */
|
||||||
|
jti?: string;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
const jti = opts.jti || crypto.randomUUID();
|
||||||
|
const familyId = opts.familyId || crypto.randomUUID();
|
||||||
|
const token = signRefreshToken(payload, jti, familyId);
|
||||||
|
const decoded: any = jwt.decode(token);
|
||||||
|
|
||||||
|
await prisma.refreshTokenRecord.create({
|
||||||
|
data: {
|
||||||
|
jti,
|
||||||
|
familyId,
|
||||||
|
userId: opts.userId ?? null,
|
||||||
|
customerId: opts.customerId ?? null,
|
||||||
|
isCustomerPortal: opts.isCustomerPortal,
|
||||||
|
expiresAt: new Date((decoded?.exp ?? 0) * 1000),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widerruft eine komplette Sitzungsfamilie. */
|
||||||
|
async function revokeFamily(familyId: string, reason: string): Promise<void> {
|
||||||
|
await prisma.refreshTokenRecord.updateMany({
|
||||||
|
where: { familyId, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date(), revokedReason: reason },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widerruft alle Familien eines Subjekts (Logout, Rechteaenderung). */
|
||||||
|
export async function revokeAllRefreshTokens(
|
||||||
|
subject: { userId?: number; customerId?: number },
|
||||||
|
reason: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const where = subject.userId != null ? { userId: subject.userId } : { customerId: subject.customerId };
|
||||||
|
await prisma.refreshTokenRecord.updateMany({
|
||||||
|
where: { ...where, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date(), revokedReason: reason },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abgelaufene Datensaetze entfernen (beim Login, seltener als beim Refresh). */
|
||||||
|
async function pruneExpiredRefreshTokens(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prisma.refreshTokenRecord.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||||
|
} catch {
|
||||||
|
// Aufraeumen darf den Login nie blockieren.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die jti eines vorgelegten Refresh-Tokens und verbraucht sie.
|
||||||
|
* Wirft mit `code = 'REFRESH_REPLAY'`, wenn ein bereits eingeloester Token
|
||||||
|
* erneut auftaucht - der Controller meldet das als Sicherheitsvorfall.
|
||||||
|
*/
|
||||||
|
async function consumeRefreshJti(decoded: any): Promise<{
|
||||||
|
familyId: string;
|
||||||
|
/** Frische Rotation: Datensatz fuer diese jti muss noch angelegt werden. */
|
||||||
|
issueJti?: string;
|
||||||
|
/** Kulanz: dieser bereits ausgestellte Nachfolger wird erneut ausgegeben. */
|
||||||
|
reuseJti?: string;
|
||||||
|
}> {
|
||||||
|
const jti: string | undefined = decoded?.jti;
|
||||||
|
const fam: string | undefined = decoded?.fam;
|
||||||
|
|
||||||
|
if (!jti || !fam) {
|
||||||
|
// Token aus der Zeit vor dem Replay-Schutz: bewusst fail-closed, damit
|
||||||
|
// kein Altbestand die Einmalverwendung umgehen kann. Kostet einmalig
|
||||||
|
// eine Neuanmeldung.
|
||||||
|
const err: any = new Error('Sitzung muss erneuert werden – bitte neu anmelden');
|
||||||
|
err.code = 'REFRESH_LEGACY';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
|
||||||
|
if (!rec) {
|
||||||
|
const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
|
||||||
|
err.code = 'REFRESH_INVALID';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (rec.revokedAt) {
|
||||||
|
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
|
||||||
|
err.code = 'REFRESH_REVOKED';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einloesen MUSS atomar sein. Ein Lesen-dann-Schreiben wuerde bei parallelen
|
||||||
|
// Anfragen nichts verhindern: alle wuerden den Token als "noch unbenutzt"
|
||||||
|
// sehen und durchgelassen (im Test kamen 90 gleichzeitige Requests
|
||||||
|
// ausnahmslos durch). Deshalb wird der Zustandswechsel als bedingtes UPDATE
|
||||||
|
// ausgefuehrt - die Datenbank entscheidet, wer zuerst war.
|
||||||
|
// Die jti des Nachfolgers wird SCHON HIER festgelegt und im selben UPDATE
|
||||||
|
// hinterlegt. Dadurch weiss eine spaetere Wiedervorlage, welcher Nachfolger
|
||||||
|
// bereits ausgestellt wurde - Grundlage der idempotenten Kulanz (R168-01).
|
||||||
|
const nachfolgerJti = crypto.randomUUID();
|
||||||
|
const beansprucht = await prisma.refreshTokenRecord.updateMany({
|
||||||
|
where: { jti, usedAt: null, revokedAt: null },
|
||||||
|
data: { usedAt: new Date(), replacedByJti: nachfolgerJti },
|
||||||
|
});
|
||||||
|
if (beansprucht.count === 1) {
|
||||||
|
return { familyId: rec.familyId, issueJti: nachfolgerJti };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bereits eingeloest. Innerhalb des engen Kulanzfensters begrenzt tolerieren
|
||||||
|
// (parallele Tabs) - als bedingtes UPDATE, damit die Obergrenze unter Last
|
||||||
|
// wirklich haelt.
|
||||||
|
const fensterAb = new Date(Date.now() - REFRESH_REUSE_GRACE_MS);
|
||||||
|
const toleriert = await prisma.refreshTokenRecord.updateMany({
|
||||||
|
where: {
|
||||||
|
jti,
|
||||||
|
revokedAt: null,
|
||||||
|
usedAt: { gte: fensterAb },
|
||||||
|
reuseCount: { lt: REFRESH_REUSE_MAX },
|
||||||
|
},
|
||||||
|
data: { reuseCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
if (toleriert.count === 1) {
|
||||||
|
// IDEMPOTENT: denselben, bereits ausgestellten Nachfolger zurueckgeben -
|
||||||
|
// NICHT erneut rotieren (Pentest R168-01).
|
||||||
|
//
|
||||||
|
// Vorher entstand bei jeder Kulanz-Wiedervorlage eine frische Linie mit
|
||||||
|
// eigenem, zurueckgesetztem Zaehler. Ein Angreifer mit gestohlenem Token
|
||||||
|
// konnte damit aus dem erkennbaren Replay-Zustand in eine eigene,
|
||||||
|
// sauber weiterrotierende Sitzung entkommen, die nie wieder mit der des
|
||||||
|
// Opfers kollidiert - also dauerhaft unsichtbar blieb.
|
||||||
|
//
|
||||||
|
// Jetzt laufen parallele Tabs auf DIESELBE Linie zusammen. Wer den Token
|
||||||
|
// spaeter erneut vorlegt (also ausserhalb des Fensters), kollidiert
|
||||||
|
// zwangslaeufig und loest den Familien-Widerruf aus.
|
||||||
|
const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
|
||||||
|
if (aktuell?.replacedByJti) {
|
||||||
|
const nachfolger = await prisma.refreshTokenRecord.findUnique({
|
||||||
|
where: { jti: aktuell.replacedByJti },
|
||||||
|
});
|
||||||
|
if (nachfolger?.revokedAt) {
|
||||||
|
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
|
||||||
|
err.code = 'REFRESH_REVOKED';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { familyId: rec.familyId, reuseJti: aktuell.replacedByJti };
|
||||||
|
}
|
||||||
|
// Kein Nachfolger hinterlegt: nicht ersatzweise rotieren (das waere genau
|
||||||
|
// der Fork). Fail-closed als Replay behandeln.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weder frei noch tolerierbar: War der Token zwischenzeitlich widerrufen
|
||||||
|
// (z. B. durch einen parallelen Replay-Fund), ist das kein neuer Vorfall.
|
||||||
|
const aktuell = await prisma.refreshTokenRecord.findUnique({ where: { jti } });
|
||||||
|
if (aktuell?.revokedAt) {
|
||||||
|
const err: any = new Error('Refresh-Token wurde invalidiert (Logout/Rechteänderung)');
|
||||||
|
err.code = 'REFRESH_REVOKED';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay: gesamte Familie widerrufen - der Angreifer verliert den Zugang,
|
||||||
|
// der legitime Nutzer muss sich neu anmelden und merkt es dadurch.
|
||||||
|
await revokeFamily(rec.familyId, 'Replay eines bereits eingelösten Refresh-Tokens');
|
||||||
|
const err: any = new Error('Refresh-Token wurde bereits verwendet – Sitzung aus Sicherheitsgründen beendet');
|
||||||
|
err.code = 'REFRESH_REPLAY';
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
|
// Kurzlebiger Download-Token (60s, single-purpose). Wird vom Frontend
|
||||||
@@ -133,8 +318,10 @@ export async function login(email: string, password: string) {
|
|||||||
isCustomerPortal: false,
|
isCustomerPortal: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await pruneExpiredRefreshTokens();
|
||||||
const accessToken = signAccessToken(payload);
|
const accessToken = signAccessToken(payload);
|
||||||
const refreshToken = signRefreshToken(payload);
|
// Neue Sitzungsfamilie (Replay-Schutz, R164-02)
|
||||||
|
const refreshToken = await issueRefreshToken(payload, { userId: user.id, isCustomerPortal: false });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -147,6 +334,9 @@ export async function login(email: string, password: string) {
|
|||||||
permissions: Array.from(permissions),
|
permissions: Array.from(permissions),
|
||||||
customerId: user.customerId,
|
customerId: user.customerId,
|
||||||
isCustomerPortal: false,
|
isCustomerPortal: false,
|
||||||
|
// Wird von der Audit-Middleware gelesen, um planmaessige
|
||||||
|
// Dienstkonto-Anmeldungen als Routine einzustufen (R182).
|
||||||
|
isServiceAccount: user.isServiceAccount,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -231,10 +421,9 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
|
const representedCustomerIds = grantedRepresentingFor.map((rep) => rep.customer.id);
|
||||||
|
|
||||||
// Kundenportal-Berechtigungen (eingeschränkt)
|
// Kundenportal-Berechtigungen (eingeschränkt)
|
||||||
const customerPermissions = [
|
// Feste Portal-Rechte aus config/rechte-katalog.ts. Bewusst getrennt vom
|
||||||
'contracts:read', // Eigene Verträge lesen
|
// Rollensystem - siehe den Kommentar dort.
|
||||||
'customers:read', // Eigene Kundendaten lesen
|
const customerPermissions = [...PORTAL_RECHTE];
|
||||||
];
|
|
||||||
|
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
email: customer.portalEmail!,
|
email: customer.portalEmail!,
|
||||||
@@ -244,8 +433,10 @@ export async function customerLogin(email: string, password: string) {
|
|||||||
representedCustomerIds,
|
representedCustomerIds,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await pruneExpiredRefreshTokens();
|
||||||
const accessToken = signAccessToken(payload);
|
const accessToken = signAccessToken(payload);
|
||||||
const refreshToken = signRefreshToken(payload);
|
// Neue Sitzungsfamilie (Replay-Schutz, R164-02)
|
||||||
|
const refreshToken = await issueRefreshToken(payload, { customerId: customer.id, isCustomerPortal: true });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -286,12 +477,32 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
|||||||
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
|
decoded = jwt.verify(refreshToken, process.env.JWT_SECRET as string, {
|
||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (e) {
|
||||||
throw new Error('Refresh-Token ungültig oder abgelaufen');
|
// Code erhalten, damit der Controller abgelaufen (benigne, LOW) von
|
||||||
|
// manipuliert/ungültiger Signatur (verdächtig, HIGH) trennen kann.
|
||||||
|
const err: any = new Error('Refresh-Token ungültig oder abgelaufen');
|
||||||
|
err.code = e instanceof jwt.TokenExpiredError ? 'REFRESH_EXPIRED' : 'REFRESH_INVALID';
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (decoded.type !== 'refresh') {
|
if (decoded.type !== 'refresh') {
|
||||||
throw new Error('Falscher Token-Typ');
|
throw new Error('Falscher Token-Typ');
|
||||||
}
|
}
|
||||||
|
// Einmalverwendung durchsetzen und Sitzungsfamilie bestimmen (R164-02).
|
||||||
|
// Wirft bei Replay – danach ist die gesamte Familie widerrufen.
|
||||||
|
const rotation = await consumeRefreshJti(decoded);
|
||||||
|
// Bei Kulanz wird derselbe, bereits ausgestellte Nachfolger erneut signiert -
|
||||||
|
// ohne neuen Datensatz, damit keine zweite Linie entsteht (R168-01).
|
||||||
|
const naechsterRefreshToken = async (
|
||||||
|
payload: JwtPayload,
|
||||||
|
subjekt: { userId?: number; customerId?: number; isCustomerPortal: boolean },
|
||||||
|
): Promise<string> =>
|
||||||
|
rotation.reuseJti
|
||||||
|
? signRefreshToken(payload, rotation.reuseJti, rotation.familyId)
|
||||||
|
: issueRefreshToken(payload, {
|
||||||
|
...subjekt,
|
||||||
|
familyId: rotation.familyId,
|
||||||
|
jti: rotation.issueJti,
|
||||||
|
});
|
||||||
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
|
const issuedAt = decoded.iat ? decoded.iat * 1000 : 0;
|
||||||
|
|
||||||
// Mitarbeiter
|
// Mitarbeiter
|
||||||
@@ -321,7 +532,9 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
accessToken: signAccessToken(payload),
|
accessToken: signAccessToken(payload),
|
||||||
refreshToken: signRefreshToken(payload),
|
// Nachfolger bleibt in derselben Familie – ein Replay des Vorgaengers
|
||||||
|
// sprengt damit auch alle daraus entstandenen Tokens.
|
||||||
|
refreshToken: await naechsterRefreshToken(payload, { userId: user.id, isCustomerPortal: false }),
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -352,7 +565,10 @@ export async function refreshAccessToken(refreshToken: string): Promise<{
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
accessToken: signAccessToken(payload),
|
accessToken: signAccessToken(payload),
|
||||||
refreshToken: signRefreshToken(payload),
|
refreshToken: await naechsterRefreshToken(payload, {
|
||||||
|
customerId: customer.id,
|
||||||
|
isCustomerPortal: true,
|
||||||
|
}),
|
||||||
user: portalUser,
|
user: portalUser,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -575,10 +791,7 @@ export async function getCustomerPortalUser(customerId: number) {
|
|||||||
|
|
||||||
if (!customer || !customer.portalEnabled) return null;
|
if (!customer || !customer.portalEnabled) return null;
|
||||||
|
|
||||||
const customerPermissions = [
|
const customerPermissions = [...PORTAL_RECHTE];
|
||||||
'contracts:read',
|
|
||||||
'customers:read',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
|
// Selbe Live-Vollmacht-Filterung wie in customerLogin (Pentest Runde 10):
|
||||||
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
|
// ohne sie zeigt /me dem Vertreter weiterhin widerrufene Beziehungen.
|
||||||
|
|||||||
@@ -10,6 +10,31 @@ import * as path from 'path';
|
|||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { synchronisiereRechteUndRollen } from './rollen-sync.service.js';
|
||||||
|
import { ROLLE_ADMIN, ROLLE_DSGVO } from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zufaelliges Initial-Kennwort fuer den Bootstrap-Admin nach einem
|
||||||
|
* Werksreset. Gleiche Regeln wie in prisma/seed.ts: 28 Zeichen, mindestens
|
||||||
|
* eines aus jeder Klasse, kryptografisch sichere Auswahl - Math.random() ist
|
||||||
|
* vorhersagbar und reicht dafuer nicht (Pentest 2026-05-20).
|
||||||
|
*/
|
||||||
|
function erzeugeInitialKennwort(): string {
|
||||||
|
const gross = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||||
|
const klein = 'abcdefghijkmnopqrstuvwxyz';
|
||||||
|
const ziffern = '23456789';
|
||||||
|
const sonder = '!@#$%&*+=?';
|
||||||
|
const alle = gross + klein + ziffern + sonder;
|
||||||
|
const waehle = (s: string) => s[crypto.randomInt(0, s.length)];
|
||||||
|
const zeichen = [waehle(gross), waehle(klein), waehle(ziffern), waehle(sonder)];
|
||||||
|
for (let i = zeichen.length; i < 28; i++) zeichen.push(waehle(alle));
|
||||||
|
for (let i = zeichen.length - 1; i > 0; i--) {
|
||||||
|
const j = crypto.randomInt(0, i + 1);
|
||||||
|
[zeichen[i], zeichen[j]] = [zeichen[j], zeichen[i]];
|
||||||
|
}
|
||||||
|
return zeichen.join('');
|
||||||
|
}
|
||||||
|
|
||||||
// Verzeichnisse
|
// Verzeichnisse
|
||||||
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
|
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
|
||||||
@@ -1187,110 +1212,40 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grundlegende Stammdaten neu anlegen (aus Seed)
|
// ==================== RECHTE UND ROLLEN ====================
|
||||||
// Berechtigungen - muss mit seed.ts übereinstimmen!
|
// Aus derselben Definition wie Seed und Container-Start
|
||||||
const resourcePermissions: Record<string, string[]> = {
|
// (config/rechte-katalog.ts).
|
||||||
// Haupt-Ressourcen (CRUD)
|
//
|
||||||
customers: ['create', 'read', 'update', 'delete'],
|
// Hier stand bis 09/2026 eine DRITTE Kopie des Katalogs, mit dem
|
||||||
contracts: ['create', 'read', 'update', 'delete'],
|
// Kommentar "muss mit seed.ts uebereinstimmen!" darueber - und sie stimmte
|
||||||
users: ['create', 'read', 'update', 'delete'],
|
// nicht: Die Lookup-Tabellen hatten nur `read`, `email-providers`,
|
||||||
platforms: ['create', 'read', 'update', 'delete'],
|
// `audit` und `gdpr` fehlten ganz, und von den Rollen wurden nur fuenf
|
||||||
providers: ['create', 'read', 'update', 'delete'],
|
// angelegt. DSGVO, Audit-Betrieb und Gegenbuch gab es nach einem
|
||||||
tariffs: ['create', 'read', 'update', 'delete'],
|
// Werksreset nicht mehr.
|
||||||
// Lookup-Tabellen (nur lesen)
|
//
|
||||||
'contract-categories': ['read'],
|
// Die Folge war kein Schoenheitsfehler: Ohne die DSGVO-Rolle kann
|
||||||
'cancellation-periods': ['read'],
|
// niemand eine Auskunft nach Art. 15 oder eine Loeschung nach Art. 17
|
||||||
'contract-durations': ['read'],
|
// ausfuehren. Ein Werksreset setzte damit stillschweigend die
|
||||||
// Einstellungen (nur lesen/ändern)
|
// Handlungsfaehigkeit fuer Betroffenenrechte aus, und aufgefallen waere
|
||||||
settings: ['read', 'update'],
|
// es erst, wenn eine Frist laeuft.
|
||||||
// Spezial-Permissions
|
await synchronisiereRechteUndRollen(prisma, (zeile) =>
|
||||||
developer: ['access'],
|
console.log(`[FactoryReset] ${zeile}`),
|
||||||
emails: ['delete'],
|
);
|
||||||
};
|
|
||||||
|
|
||||||
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_ADMIN } });
|
||||||
for (const action of actions) {
|
const gdprRole = await prisma.role.findUniqueOrThrow({ where: { name: ROLLE_DSGVO } });
|
||||||
await prisma.permission.create({
|
console.log('[FactoryReset] Rechte und Rollen erstellt');
|
||||||
data: { resource, action },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('[FactoryReset] Berechtigungen erstellt');
|
|
||||||
|
|
||||||
// Admin-Rolle mit allen Berechtigungen (außer developer:access)
|
// Standard Admin-Benutzer erstellen.
|
||||||
const allPermissions = await prisma.permission.findMany();
|
//
|
||||||
const adminRole = await prisma.role.create({
|
// Das Kennwort war hier bis 09/2026 fest auf "admin" verdrahtet, mit
|
||||||
data: {
|
// bcrypt-Cost 10. Genau das verbietet seed.ts seit Pentest Runde 12
|
||||||
name: 'Admin',
|
// ausdruecklich - und wieder war die Haertung nur in einer der beiden
|
||||||
description: 'Voller Zugriff auf alle Funktionen',
|
// Kopien angekommen. Wer einen Werksreset ausloest, bekommt jetzt ein
|
||||||
permissions: {
|
// zufaelliges Kennwort, das genau einmal im Log erscheint.
|
||||||
create: allPermissions
|
|
||||||
.filter(p => !(p.resource === 'developer' && p.action === 'access'))
|
|
||||||
.map(p => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Developer-Rolle - ALLE Berechtigungen inkl. developer:access
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Developer',
|
|
||||||
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
|
||||||
permissions: {
|
|
||||||
create: allPermissions.map(p => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mitarbeiter-Rolle - customers, contracts + read-only auf Stammdaten
|
|
||||||
const employeePermIds = allPermissions
|
|
||||||
.filter(p =>
|
|
||||||
p.resource === 'customers' ||
|
|
||||||
p.resource === 'contracts' ||
|
|
||||||
(p.action === 'read' && ['platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'].includes(p.resource))
|
|
||||||
)
|
|
||||||
.map(p => p.id);
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Mitarbeiter',
|
|
||||||
description: 'Kann Kunden und Verträge verwalten',
|
|
||||||
permissions: {
|
|
||||||
create: employeePermIds.map(id => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nur-Lesen Rolle
|
|
||||||
const readOnlyResources = ['customers', 'contracts', 'platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'];
|
|
||||||
const readOnlyPermIds = allPermissions
|
|
||||||
.filter(p => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
||||||
.map(p => p.id);
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Mitarbeiter (Nur-Lesen)',
|
|
||||||
description: 'Kann nur lesen, keine Änderungen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Kunden-Rolle
|
|
||||||
await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'Kunde',
|
|
||||||
description: 'Kann nur eigene Daten lesen',
|
|
||||||
permissions: {
|
|
||||||
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('[FactoryReset] Rollen erstellt');
|
|
||||||
|
|
||||||
// Standard Admin-Benutzer erstellen
|
|
||||||
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
|
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
|
||||||
const hashedPassword = await bcrypt.hash('admin', 10);
|
const adminPlainPassword = erzeugeInitialKennwort();
|
||||||
console.log('[FactoryReset] Passwort gehasht, Admin-Rolle ID:', adminRole.id);
|
const hashedPassword = await bcrypt.hash(adminPlainPassword, 12);
|
||||||
|
|
||||||
const adminUser = await prisma.user.create({
|
const adminUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1298,11 +1253,21 @@ export async function factoryReset(): Promise<{ success: boolean; error?: string
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
firstName: 'Admin',
|
firstName: 'Admin',
|
||||||
lastName: 'User',
|
lastName: 'User',
|
||||||
|
// Admin UND DSGVO (Pentest R189-01): Die Admin-Rolle traegt die
|
||||||
|
// Datenschutzrechte bewusst nicht, also braucht dieses eine
|
||||||
|
// Bootstrap-Konto beide - sonst ist nach dem Reset niemand
|
||||||
|
// handlungsfaehig.
|
||||||
roles: {
|
roles: {
|
||||||
create: [{ roleId: adminRole.id }],
|
create: [{ roleId: adminRole.id }, { roleId: gdprRole.id }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log('========================================================');
|
||||||
|
console.log(' Admin-User: admin@admin.com');
|
||||||
|
console.log(` Initial-Passwort: ${adminPlainPassword}`);
|
||||||
|
console.log(' ⚠️ Dieses Passwort wird hier EINMAL ausgegeben!');
|
||||||
|
console.log(' Bitte sofort nach dem ersten Login ändern.');
|
||||||
|
console.log('========================================================');
|
||||||
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
|
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
|
||||||
|
|
||||||
// Standard Kündigungsfristen (wie in seed.ts)
|
// Standard Kündigungsfristen (wie in seed.ts)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.o
|
|||||||
const FETCH_TIMEOUT_MS = 20_000;
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
// ---- Typen ----
|
// ---- Typen ----
|
||||||
type BankTuple = [string, string]; // [bankName, bic]
|
type BankTuple = [string, string?]; // [bankName, bic?] – BIC fehlt bei manchen Banken
|
||||||
type CurrentData = Record<string, BankTuple>;
|
type CurrentData = Record<string, BankTuple>;
|
||||||
interface NextData {
|
interface NextData {
|
||||||
valid: string;
|
valid: string;
|
||||||
@@ -159,7 +159,7 @@ export function lookupBlz(blz: string): BankInfo | null {
|
|||||||
if (!/^[1-9]\d{7}$/.test(blz)) return null;
|
if (!/^[1-9]\d{7}$/.test(blz)) return null;
|
||||||
const entry = combinedData()[blz];
|
const entry = combinedData()[blz];
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
return { bankName: entry[0], bic: entry[1], blz };
|
return { bankName: entry[0], bic: entry[1] ?? '', blz };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
|
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
|
||||||
@@ -171,20 +171,53 @@ export function lookupByIban(iban: string): BankInfo | null {
|
|||||||
|
|
||||||
// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ----
|
// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ----
|
||||||
|
|
||||||
|
// Prüft eine BLZ→[Name,BIC]-Map vollständig: JEDER Schlüssel eine 8-stellige
|
||||||
|
// BLZ, JEDER Wert genau [string, string] (BIC darf leer sein). Nicht nur die
|
||||||
|
// erste Zeile (Pentest R148, Code-Note) – sonst käme ein Set mit 1 echten +
|
||||||
|
// 999 Fake-Einträgen durch.
|
||||||
|
function assertValidBankMap(map: Record<string, unknown>, label: string): void {
|
||||||
|
for (const [blz, entry] of Object.entries(map)) {
|
||||||
|
if (!/^\d{8}$/.test(blz)) {
|
||||||
|
throw new Error(`${label}: ungültige BLZ "${blz}"`);
|
||||||
|
}
|
||||||
|
// Wert ist [Name] (Bank ohne BIC) ODER [Name, BIC]. Beides ist in der
|
||||||
|
// echten Bundesbank-Datei vorhanden (z.B. reine Zahlungsverkehr-BLZ).
|
||||||
|
if (
|
||||||
|
!Array.isArray(entry) ||
|
||||||
|
entry.length < 1 ||
|
||||||
|
entry.length > 2 ||
|
||||||
|
typeof entry[0] !== 'string' ||
|
||||||
|
(entry.length === 2 && typeof entry[1] !== 'string')
|
||||||
|
) {
|
||||||
|
throw new Error(`${label}: Eintrag ${blz} hat unerwartetes Format`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertValidRawDataset(raw: any): asserts raw is RawDataset {
|
function assertValidRawDataset(raw: any): asserts raw is RawDataset {
|
||||||
if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
|
if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
|
||||||
const cur = raw.current;
|
const cur = raw.current;
|
||||||
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
|
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
|
||||||
const keys = Object.keys(cur);
|
const keys = Object.keys(cur);
|
||||||
if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
|
if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
|
||||||
const sample = cur[keys[0]];
|
// VOLLSTÄNDIGE Formatprüfung aller Einträge, nicht nur des ersten.
|
||||||
if (!Array.isArray(sample) || sample.length !== 2 || typeof sample[0] !== 'string' || typeof sample[1] !== 'string') {
|
assertValidBankMap(cur, 'current');
|
||||||
throw new Error('current-Eintrag hat unerwartetes Format');
|
|
||||||
}
|
|
||||||
const next = raw.next;
|
const next = raw.next;
|
||||||
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || !Array.isArray(next.remove)) {
|
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || Array.isArray(next.upsert) || !Array.isArray(next.remove)) {
|
||||||
throw new Error('next fehlt/ungültig');
|
throw new Error('next fehlt/ungültig');
|
||||||
}
|
}
|
||||||
|
// Datum plausibel?
|
||||||
|
if (Number.isNaN(new Date(next.valid).getTime())) {
|
||||||
|
throw new Error('next.valid ist kein gültiges Datum');
|
||||||
|
}
|
||||||
|
// upsert-Einträge ebenso streng; remove muss aus 8-stelligen BLZ bestehen.
|
||||||
|
assertValidBankMap(next.upsert, 'next.upsert');
|
||||||
|
for (const blz of next.remove) {
|
||||||
|
if (typeof blz !== 'string' || !/^\d{8}$/.test(blz)) {
|
||||||
|
throw new Error(`next.remove enthält ungültige BLZ "${blz}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Ausgehende Requests (nur Updater) ----
|
// ---- Ausgehende Requests (nur Updater) ----
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export async function getAllContracts(filters: ContractFilters) {
|
|||||||
{ mobileDetails: { deviceImei: { contains: search } } },
|
{ mobileDetails: { deviceImei: { contains: search } } },
|
||||||
{ mobileDetails: { simCards: { some: { phoneNumber: { contains: search } } } } },
|
{ mobileDetails: { simCards: { some: { phoneNumber: { contains: search } } } } },
|
||||||
{ mobileDetails: { simCards: { some: { simCardNumber: { contains: search } } } } },
|
{ mobileDetails: { simCards: { some: { simCardNumber: { contains: search } } } } },
|
||||||
|
{ mobileDetails: { simCards: { some: { cardUser: { contains: search } } } } },
|
||||||
// Energie-Vertragsdetails (Zählernummer)
|
// Energie-Vertragsdetails (Zählernummer)
|
||||||
{ energyDetails: { meter: { meterNumber: { contains: search } } } },
|
{ energyDetails: { meter: { meterNumber: { contains: search } } } },
|
||||||
// TV-Vertragsdetails
|
// TV-Vertragsdetails
|
||||||
@@ -105,7 +106,8 @@ export async function getAllContracts(filters: ContractFilters) {
|
|||||||
mobileDetails: {
|
mobileDetails: {
|
||||||
select: {
|
select: {
|
||||||
phoneNumber: true,
|
phoneNumber: true,
|
||||||
simCards: { select: { phoneNumber: true, isMain: true } },
|
mobileNetwork: true,
|
||||||
|
simCards: { select: { phoneNumber: true, isMain: true, cardUser: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 2026-07-04: für Vertragsauswahl-Labels brauchen wir überall
|
// 2026-07-04: für Vertragsauswahl-Labels brauchen wir überall
|
||||||
@@ -253,6 +255,7 @@ interface ContractCreateData {
|
|||||||
unitPrice?: number;
|
unitPrice?: number;
|
||||||
instantBonus?: number;
|
instantBonus?: number;
|
||||||
newCustomerBonus?: number;
|
newCustomerBonus?: number;
|
||||||
|
noBonusDesired?: boolean;
|
||||||
previousProviderName?: string;
|
previousProviderName?: string;
|
||||||
previousCustomerNumber?: string;
|
previousCustomerNumber?: string;
|
||||||
};
|
};
|
||||||
@@ -339,6 +342,73 @@ function normalizeMobileNetwork(value: unknown): string | null {
|
|||||||
return ALLOWED_MOBILE_NETWORKS.has(v) ? v : null;
|
return ALLOWED_MOBILE_NETWORKS.has(v) ? v : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelist für die Contract-Top-Level-Scalars (Mass-Assignment-Schutz,
|
||||||
|
// Pentest R158-Hygiene, konsistent zur R156-Härtung von BankCard/Address/
|
||||||
|
// Document). Nur diese Felder gehen aus dem (bereits um Nested-Details +
|
||||||
|
// portalPassword bereinigten) `contractData` an Prisma – NICHT id/contractNumber/
|
||||||
|
// createdAt/updatedAt oder sonstige untergeschobene Keys. `contractNumber` wird
|
||||||
|
// bei create serverseitig gesetzt, nie aus dem Body.
|
||||||
|
// Autoritativ aus den DB-Spalten abgeleitet (nicht aus dem unvollständigen
|
||||||
|
// ContractCreateData-Typ!). BEWUSST NICHT enthalten:
|
||||||
|
// - id / contractNumber / createdAt / updatedAt → nie client-setzbar
|
||||||
|
// - portalPasswordEncrypted → via portalPassword-Verschlüsselung
|
||||||
|
// - cancellation*Path (Schreiben/Bestätigung/Optionen) → nur über die
|
||||||
|
// dedizierten Upload-/Delete-Endpunkte, nicht per Form-Update
|
||||||
|
const CONTRACT_SCALAR_FIELDS = [
|
||||||
|
'customerId', 'type', 'contractCategoryId', 'status', 'addressId', 'billingAddressId',
|
||||||
|
'bankCardId', 'identityDocumentId', 'salesPlatformId', 'previousContractId', 'providerId',
|
||||||
|
'tariffId', 'providerName', 'tariffName', 'customerNumberAtProvider', 'contractNumberAtProvider',
|
||||||
|
'orderNumberAtSalesPlatform',
|
||||||
|
'customerNumberAtSalesPlatform', 'contractNumberAtSalesPlatform', 'priceFirst12Months',
|
||||||
|
'priceFrom13Months', 'priceAfter24Months', 'startDate', 'endDate', 'cancellationPeriodId',
|
||||||
|
'contractDurationId', 'commission', 'portalUsername', 'stressfreiEmailId',
|
||||||
|
'portalCredentialsNotRequired', 'notes', 'cancellationConfirmationDate',
|
||||||
|
'cancellationConfirmationOptionsDate', 'wasSpecialCancellation',
|
||||||
|
// In ContractCreateData nicht typisiert, aber echte Spalten, die das Formular
|
||||||
|
// sendet (liefen bisher nur über den rohen Spread durch):
|
||||||
|
'previousProviderId', 'previousContractNumber', 'previousCustomerNumber', 'nextReviewDate',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickContractScalars(data: unknown): Record<string, unknown> {
|
||||||
|
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of CONTRACT_SCALAR_FIELDS) {
|
||||||
|
if (key in src) out[key] = src[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelists für die Nested-Detail-Modelle (Mass-Assignment-Schutz, Pentest
|
||||||
|
// R162-01). Autoritativ aus den DB-Spalten, jeweils OHNE id/contractId (PK/FK
|
||||||
|
// nie client-setzbar) und ohne Nested-Relationen (phoneNumbers/simCards/
|
||||||
|
// contractMeters) bzw. verschlüsselte Felder (internetPasswordEncrypted), die
|
||||||
|
// separat behandelt werden. Verhindert Reparenting/Orphaning per injizierter
|
||||||
|
// id/contractId im energy/mobile/tv/carInsurance-Objekt.
|
||||||
|
function makePick(fields: readonly string[]) {
|
||||||
|
return (data: unknown): Record<string, unknown> => {
|
||||||
|
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of fields) if (key in src) out[key] = src[key];
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickEnergyScalars = makePick([
|
||||||
|
'meterId', 'annualConsumption', 'basePrice', 'unitPrice', 'previousProviderName',
|
||||||
|
'previousCustomerNumber', 'annualConsumptionKwh', 'maloId', 'unitPriceNt',
|
||||||
|
'instantBonus', 'newCustomerBonus', 'noBonusDesired',
|
||||||
|
]);
|
||||||
|
const pickMobileScalars = makePick([
|
||||||
|
'phoneNumber', 'simCardNumber', 'dataVolume', 'includedMinutes', 'includedSMS',
|
||||||
|
'deviceModel', 'deviceImei', 'requiresMultisim', 'mobileNetwork',
|
||||||
|
]);
|
||||||
|
const pickTvScalars = makePick(['receiverModel', 'smartcardNumber', 'package']);
|
||||||
|
const pickCarInsuranceScalars = makePick([
|
||||||
|
'licensePlate', 'hsn', 'tsn', 'vin', 'vehicleType', 'firstRegistration',
|
||||||
|
'noClaimsClass', 'insuranceType', 'deductiblePartial', 'deductibleFull',
|
||||||
|
'policyNumber', 'previousInsurer',
|
||||||
|
]);
|
||||||
|
|
||||||
export async function createContract(data: ContractCreateData) {
|
export async function createContract(data: ContractCreateData) {
|
||||||
const {
|
const {
|
||||||
energyDetails,
|
energyDetails,
|
||||||
@@ -357,11 +427,11 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
|
|
||||||
const contract = await prisma.contract.create({
|
const contract = await prisma.contract.create({
|
||||||
data: {
|
data: {
|
||||||
...contractData,
|
...(pickContractScalars(contractData) as any),
|
||||||
contractNumber: generateContractNumber(data.type),
|
contractNumber: generateContractNumber(data.type),
|
||||||
portalPasswordEncrypted,
|
portalPasswordEncrypted,
|
||||||
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
|
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
|
||||||
? { energyDetails: { create: energyDetails } }
|
? { energyDetails: { create: pickEnergyScalars(energyDetails) as any } }
|
||||||
: {}),
|
: {}),
|
||||||
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
|
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
|
||||||
? {
|
? {
|
||||||
@@ -431,10 +501,10 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(tvDetails && data.type === 'TV'
|
...(tvDetails && data.type === 'TV'
|
||||||
? { tvDetails: { create: tvDetails } }
|
? { tvDetails: { create: pickTvScalars(tvDetails) as any } }
|
||||||
: {}),
|
: {}),
|
||||||
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
|
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
|
||||||
? { carInsuranceDetails: { create: carInsuranceDetails } }
|
? { carInsuranceDetails: { create: pickCarInsuranceScalars(carInsuranceDetails) as any } }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -487,11 +557,12 @@ export async function updateContract(
|
|||||||
// in diesem Update wirklich auf true gesetzt wird (nicht bei jedem PUT).
|
// in diesem Update wirklich auf true gesetzt wird (nicht bei jedem PUT).
|
||||||
const clearCredentials = contractData.portalCredentialsNotRequired === true;
|
const clearCredentials = contractData.portalCredentialsNotRequired === true;
|
||||||
|
|
||||||
// Update main contract
|
// Update main contract – nur Whitelist-Felder (kein roher `...contractData`
|
||||||
|
// mehr → kein Mass-Assignment von id/customerId-PK/contractNumber/Timestamps).
|
||||||
await prisma.contract.update({
|
await prisma.contract.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...contractData,
|
...(pickContractScalars(contractData) as any),
|
||||||
...(clearCredentials
|
...(clearCredentials
|
||||||
? {
|
? {
|
||||||
portalUsername: null,
|
portalUsername: null,
|
||||||
@@ -513,8 +584,8 @@ export async function updateContract(
|
|||||||
|
|
||||||
await prisma.energyContractDetails.upsert({
|
await prisma.energyContractDetails.upsert({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
update: energyDetails,
|
update: pickEnergyScalars(energyDetails) as any,
|
||||||
create: { contractId: id, ...energyDetails },
|
create: { contractId: id, ...(pickEnergyScalars(energyDetails) as any) },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
||||||
@@ -639,11 +710,12 @@ export async function updateContract(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mobileDetails) {
|
if (mobileDetails) {
|
||||||
const { simCards, ...mobileData } = mobileDetails;
|
const { simCards } = mobileDetails;
|
||||||
// Whitelist auf das Netz anwenden, bevor mobileData per Spread an
|
// Feld-Whitelist (Pentest R162-01): nur Scalars an Prisma – kein injiziertes
|
||||||
// Prisma geht (Update-Pfad reicht sonst beliebige Strings durch).
|
// id/contractId. Danach Netz normalisieren.
|
||||||
|
const mobileData: Record<string, unknown> = pickMobileScalars(mobileDetails);
|
||||||
if ('mobileNetwork' in mobileData) {
|
if ('mobileNetwork' in mobileData) {
|
||||||
mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork);
|
mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork as string | null | undefined);
|
||||||
}
|
}
|
||||||
const existing = await prisma.mobileContractDetails.findUnique({
|
const existing = await prisma.mobileContractDetails.findUnique({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
@@ -652,7 +724,7 @@ export async function updateContract(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.mobileContractDetails.update({
|
await prisma.mobileContractDetails.update({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
data: mobileData,
|
data: mobileData as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (simCards) {
|
if (simCards) {
|
||||||
@@ -690,7 +762,7 @@ export async function updateContract(
|
|||||||
await prisma.mobileContractDetails.create({
|
await prisma.mobileContractDetails.create({
|
||||||
data: {
|
data: {
|
||||||
contractId: id,
|
contractId: id,
|
||||||
...mobileData,
|
...(mobileData as any),
|
||||||
simCards: simCards
|
simCards: simCards
|
||||||
? {
|
? {
|
||||||
create: simCards.map((sc) => ({
|
create: simCards.map((sc) => ({
|
||||||
@@ -713,16 +785,16 @@ export async function updateContract(
|
|||||||
if (tvDetails) {
|
if (tvDetails) {
|
||||||
await prisma.tvContractDetails.upsert({
|
await prisma.tvContractDetails.upsert({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
update: tvDetails,
|
update: pickTvScalars(tvDetails) as any,
|
||||||
create: { contractId: id, ...tvDetails },
|
create: { contractId: id, ...(pickTvScalars(tvDetails) as any) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (carInsuranceDetails) {
|
if (carInsuranceDetails) {
|
||||||
await prisma.carInsuranceDetails.upsert({
|
await prisma.carInsuranceDetails.upsert({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
update: carInsuranceDetails,
|
update: pickCarInsuranceScalars(carInsuranceDetails) as any,
|
||||||
create: { contractId: id, ...carInsuranceDetails },
|
create: { contractId: id, ...(pickCarInsuranceScalars(carInsuranceDetails) as any) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1223,6 +1295,7 @@ export interface ContractTreeNode {
|
|||||||
status: ContractStatus;
|
status: ContractStatus;
|
||||||
startDate: Date | null;
|
startDate: Date | null;
|
||||||
endDate: Date | null;
|
endDate: Date | null;
|
||||||
|
cancellationConfirmationDate: Date | null;
|
||||||
providerName: string | null;
|
providerName: string | null;
|
||||||
tariffName: string | null;
|
tariffName: string | null;
|
||||||
previousContractId: number | null;
|
previousContractId: number | null;
|
||||||
@@ -1233,7 +1306,8 @@ export interface ContractTreeNode {
|
|||||||
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
address?: { street: string; houseNumber: string; postalCode: string; city: string } | null;
|
||||||
mobileDetails?: {
|
mobileDetails?: {
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
simCards: { phoneNumber: string | null; isMain: boolean }[];
|
mobileNetwork: string | null;
|
||||||
|
simCards: { phoneNumber: string | null; isMain: boolean; cardUser: string | null }[];
|
||||||
} | null;
|
} | null;
|
||||||
carInsuranceDetails?: { licensePlate: string | null } | null;
|
carInsuranceDetails?: { licensePlate: string | null } | null;
|
||||||
};
|
};
|
||||||
@@ -1268,6 +1342,7 @@ export async function getContractTreeForCustomer(
|
|||||||
status: true,
|
status: true,
|
||||||
startDate: true,
|
startDate: true,
|
||||||
endDate: true,
|
endDate: true,
|
||||||
|
cancellationConfirmationDate: true,
|
||||||
providerName: true,
|
providerName: true,
|
||||||
tariffName: true,
|
tariffName: true,
|
||||||
previousContractId: true,
|
previousContractId: true,
|
||||||
@@ -1279,7 +1354,8 @@ export async function getContractTreeForCustomer(
|
|||||||
mobileDetails: {
|
mobileDetails: {
|
||||||
select: {
|
select: {
|
||||||
phoneNumber: true,
|
phoneNumber: true,
|
||||||
simCards: { select: { phoneNumber: true, isMain: true } },
|
mobileNetwork: true,
|
||||||
|
simCards: { select: { phoneNumber: true, isMain: true, cardUser: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
carInsuranceDetails: { select: { licensePlate: true } },
|
carInsuranceDetails: { select: { licensePlate: true } },
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
const contracts = await prisma.contract.findMany({
|
const contracts = await prisma.contract.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: {
|
status: {
|
||||||
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED', 'ONGOING'],
|
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'DEACTIVATED', 'EXPIRED', 'ONGOING'],
|
||||||
},
|
},
|
||||||
...customerScopeFilter,
|
...customerScopeFilter,
|
||||||
},
|
},
|
||||||
@@ -363,8 +363,10 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
// Fristen semantisch keinen Sinn ergeben:
|
// Fristen semantisch keinen Sinn ergeben:
|
||||||
// - ONGOING : "Läuft weiter auf unbestimmte Zeit" → kein Ende
|
// - ONGOING : "Läuft weiter auf unbestimmte Zeit" → kein Ende
|
||||||
// - DEACTIVATED : manuell beendet/archiviert → nichts mehr zu erinnern
|
// - DEACTIVATED : manuell beendet/archiviert → nichts mehr zu erinnern
|
||||||
// - CANCELLED : Kündigung schon eingereicht → Vertragsende ist
|
// - CANCELLED_CONFIRMED : Kündigung vom Anbieter bestätigt →
|
||||||
// die Konsequenz der Kündigung, keine Frist mehr
|
// Vertragsende steht fest, keine Frist mehr.
|
||||||
|
// (CANCELLED = nur "Bestätigung abwarten" skippt NICHT,
|
||||||
|
// damit die "Kündigungsbestätigung fehlt"-Warnung greift.)
|
||||||
// Die Schlussrechnung-Fehlt-Warnung (Block 13a) bleibt aktiv, weil
|
// Die Schlussrechnung-Fehlt-Warnung (Block 13a) bleibt aktiv, weil
|
||||||
// sie WEGEN dieser Status feuert (kein separater Guard). DSGVO-/
|
// sie WEGEN dieser Status feuert (kein separater Guard). DSGVO-/
|
||||||
// Consent-Checks (Block 8) sind pro Kunde, nicht pro Vertrag, und
|
// Consent-Checks (Block 8) sind pro Kunde, nicht pro Vertrag, und
|
||||||
@@ -375,7 +377,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
const skipDeadlines =
|
const skipDeadlines =
|
||||||
contract.status === 'ONGOING'
|
contract.status === 'ONGOING'
|
||||||
|| contract.status === 'DEACTIVATED'
|
|| contract.status === 'DEACTIVATED'
|
||||||
|| contract.status === 'CANCELLED';
|
|| contract.status === 'CANCELLED_CONFIRMED';
|
||||||
|
|
||||||
// 1. KÜNDIGUNGSFRIST (nur wenn kein aktiver Folgevertrag UND Snooze nicht aktiv)
|
// 1. KÜNDIGUNGSFRIST (nur wenn kein aktiver Folgevertrag UND Snooze nicht aktiv)
|
||||||
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
|
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
|
||||||
@@ -628,9 +630,11 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setHours(0, 0, 0, 0);
|
now.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag gekündigt/deaktiviert ist)
|
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag bestätigt gekündigt/deaktiviert)
|
||||||
// "Beendet" = CANCELLED oder DEACTIVATED (nicht nur Laufzeit abgelaufen!)
|
// "Beendet" = CANCELLED_CONFIRMED (Kündigung bestätigt) oder DEACTIVATED
|
||||||
const isContractTerminated = contract.status === 'CANCELLED' || contract.status === 'DEACTIVATED';
|
// (nicht nur Laufzeit abgelaufen; NICHT "Bestätigung abwarten"/CANCELLED,
|
||||||
|
// da dort das Vertragsende noch nicht feststeht).
|
||||||
|
const isContractTerminated = contract.status === 'CANCELLED_CONFIRMED' || contract.status === 'DEACTIVATED';
|
||||||
|
|
||||||
if (isContractTerminated) {
|
if (isContractTerminated) {
|
||||||
const hasFinalInvoice = invoices.some(inv => inv.invoiceType === 'FINAL');
|
const hasFinalInvoice = invoices.some(inv => inv.invoiceType === 'FINAL');
|
||||||
@@ -783,7 +787,7 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise
|
|||||||
// CANCELLED/EXPIRED. Eigene Liste, weil solche Verträge (v.a. bereits
|
// CANCELLED/EXPIRED. Eigene Liste, weil solche Verträge (v.a. bereits
|
||||||
// CANCELLED/EXPIRED) sonst mangels „Issue" nicht in der Cockpit-Liste
|
// CANCELLED/EXPIRED) sonst mangels „Issue" nicht in der Cockpit-Liste
|
||||||
// auftauchen.
|
// auftauchen.
|
||||||
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED', 'EXPIRED'];
|
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'EXPIRED'];
|
||||||
const cancellationConfirmations: CockpitContract[] = contracts
|
const cancellationConfirmations: CockpitContract[] = contracts
|
||||||
.filter(
|
.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
|
|||||||
@@ -195,9 +195,13 @@ export async function maybeCancelOnCancellationConfirmation(
|
|||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
|
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
|
||||||
|
|
||||||
if (c.status === 'ACTIVE') {
|
// Kündigungsbestätigung liegt vor (Datum/Dokument) → "Gekündigt / bestätigt".
|
||||||
updateData.status = 'CANCELLED';
|
// Von jedem noch "lebenden" Status heben – auch aus "Gekündigt / Bestätigung
|
||||||
changes.status = { vorher: 'ACTIVE', nachher: 'CANCELLED' };
|
// abwarten" (CANCELLED) – aber NIE aus DRAFT (Vorlage) oder DEACTIVATED
|
||||||
|
// (archiviert). Ein bereits bestätigter Vertrag bleibt bestätigt (No-op).
|
||||||
|
if (['ACTIVE', 'PENDING', 'ONGOING', 'EXPIRED', 'CANCELLED'].includes(c.status)) {
|
||||||
|
updateData.status = 'CANCELLED_CONFIRMED';
|
||||||
|
changes.status = { vorher: c.status, nachher: 'CANCELLED_CONFIRMED' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertragsende = Kündigungsdatum (Bestätigungsdatum), falls vorhanden.
|
// Vertragsende = Kündigungsdatum (Bestätigungsdatum), falls vorhanden.
|
||||||
@@ -227,6 +231,45 @@ export async function maybeCancelOnCancellationConfirmation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt einen Vertrag automatisch auf "Gekündigt / Bestätigung abwarten"
|
||||||
|
* (CANCELLED), sobald ein Kündigungsschreiben hinterlegt wurde. Hebt nur noch
|
||||||
|
* "lebende" Verträge (ACTIVE/PENDING/ONGOING/EXPIRED) an – ein bereits bestätigt
|
||||||
|
* gekündigter Vertrag (CANCELLED_CONFIRMED) wird NICHT zurückgestuft, ein
|
||||||
|
* Entwurf (DRAFT) oder archivierter (DEACTIVATED) nicht angefasst.
|
||||||
|
*/
|
||||||
|
export async function maybeMarkAwaitingConfirmationOnLetter(
|
||||||
|
contractId: number,
|
||||||
|
req: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
const c = await prisma.contract.findUnique({
|
||||||
|
where: { id: contractId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
contractNumber: true,
|
||||||
|
customerId: true,
|
||||||
|
cancellationLetterPath: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!c || !c.cancellationLetterPath) return;
|
||||||
|
if (!['ACTIVE', 'PENDING', 'ONGOING', 'EXPIRED'].includes(c.status)) return;
|
||||||
|
|
||||||
|
await prisma.contract.update({
|
||||||
|
where: { id: contractId },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await logChange({
|
||||||
|
req,
|
||||||
|
action: 'UPDATE',
|
||||||
|
resourceType: 'Contract',
|
||||||
|
resourceId: contractId.toString(),
|
||||||
|
label: `Vertrag ${c.contractNumber} automatisch auf "Gekündigt / Bestätigung abwarten" gesetzt (Kündigungsschreiben)`,
|
||||||
|
details: { status: { vorher: c.status, nachher: 'CANCELLED' }, trigger: 'Kündigungsschreiben' },
|
||||||
|
customerId: c.customerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function maybeActivateOnDeliveryConfirmation(
|
export async function maybeActivateOnDeliveryConfirmation(
|
||||||
contractId: number,
|
contractId: number,
|
||||||
documentType: string,
|
documentType: string,
|
||||||
|
|||||||
@@ -50,18 +50,21 @@ export async function getTaskById(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createTask(data: {
|
export async function createTask(data: {
|
||||||
contractId: number;
|
contractId?: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
visibleInPortal?: boolean;
|
visibleInPortal?: boolean;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
}) {
|
}) {
|
||||||
|
// Ohne Vertrag (allgemeine Aufgabe) gibt es keinen Kunden → nie im Portal
|
||||||
|
// sichtbar, egal was der Client schickt.
|
||||||
|
const hasContract = data.contractId != null;
|
||||||
return prisma.contractTask.create({
|
return prisma.contractTask.create({
|
||||||
data: {
|
data: {
|
||||||
contractId: data.contractId,
|
contractId: data.contractId ?? null,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
visibleInPortal: data.visibleInPortal ?? false,
|
visibleInPortal: hasContract ? (data.visibleInPortal ?? false) : false,
|
||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -218,6 +218,27 @@ export async function getCustomerAddresses(customerId: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelist für Address-Create/Update (Mass-Assignment-Schutz, konsistent
|
||||||
|
// zu R156; autoritativ aus den DB-Spalten abgeleitet). BEWUSST NICHT enthalten:
|
||||||
|
// id/customerId/createdAt/updatedAt. Enthält die Eigentümer-Felder (owner*) –
|
||||||
|
// die waren beim ersten R156-Umbau versehentlich rausgefallen (Regression) –
|
||||||
|
// sowie die neuen MaLo-IDs.
|
||||||
|
const ADDRESS_SCALAR_FIELDS = [
|
||||||
|
'type', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'isDefault',
|
||||||
|
'maloIdElectricity', 'maloIdGas',
|
||||||
|
'ownerCompany', 'ownerFirstName', 'ownerLastName', 'ownerStreet', 'ownerHouseNumber',
|
||||||
|
'ownerPostalCode', 'ownerCity', 'ownerPhone', 'ownerMobile', 'ownerEmail',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickAddressScalars(data: unknown): Record<string, unknown> {
|
||||||
|
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of ADDRESS_SCALAR_FIELDS) {
|
||||||
|
if (key in src) out[key] = src[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAddress(
|
export async function createAddress(
|
||||||
customerId: number,
|
customerId: number,
|
||||||
data: {
|
data: {
|
||||||
@@ -238,10 +259,12 @@ export async function createAddress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe
|
||||||
|
// ADDRESS_SCALAR_FIELDS. customerId kommt ausschließlich aus dem Parameter.
|
||||||
return prisma.address.create({
|
return prisma.address.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
...(pickAddressScalars(data) as any),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -272,9 +295,11 @@ export async function updateAddress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feld-Whitelist (Mass-Assignment-Schutz) inkl. owner*/MaLo – siehe
|
||||||
|
// ADDRESS_SCALAR_FIELDS.
|
||||||
return prisma.address.update({
|
return prisma.address.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: pickAddressScalars(data) as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,16 +327,25 @@ export async function createBankCard(
|
|||||||
data: {
|
data: {
|
||||||
accountHolder: string;
|
accountHolder: string;
|
||||||
iban: string;
|
iban: string;
|
||||||
|
cardNumber?: string;
|
||||||
bic?: string;
|
bic?: string;
|
||||||
bankName?: string;
|
bankName?: string;
|
||||||
expiryDate?: Date;
|
expiryDate?: Date;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist statt `...data`-Spread: verhindert Mass-Assignment
|
||||||
|
// (Pentest R155). Sonst könnte roher req.body u.a. `customerId`/`id` setzen.
|
||||||
return prisma.bankCard.create({
|
return prisma.bankCard.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
accountHolder: data.accountHolder,
|
||||||
|
iban: data.iban,
|
||||||
|
cardNumber: data.cardNumber,
|
||||||
|
bic: data.bic,
|
||||||
|
bankName: data.bankName,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
description: data.description,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -322,6 +356,7 @@ export async function updateBankCard(
|
|||||||
data: {
|
data: {
|
||||||
accountHolder?: string;
|
accountHolder?: string;
|
||||||
iban?: string;
|
iban?: string;
|
||||||
|
cardNumber?: string;
|
||||||
bic?: string;
|
bic?: string;
|
||||||
bankName?: string;
|
bankName?: string;
|
||||||
expiryDate?: Date;
|
expiryDate?: Date;
|
||||||
@@ -329,9 +364,21 @@ export async function updateBankCard(
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist statt `data`-Passthrough: verhindert Mass-
|
||||||
|
// Assignment (Pentest R155 – roher req.body konnte `customerId`/`id`
|
||||||
|
// mutieren). Undefined-Felder ignoriert Prisma → Partial-Update bleibt.
|
||||||
return prisma.bankCard.update({
|
return prisma.bankCard.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: {
|
||||||
|
accountHolder: data.accountHolder,
|
||||||
|
iban: data.iban,
|
||||||
|
cardNumber: data.cardNumber,
|
||||||
|
bic: data.bic,
|
||||||
|
bankName: data.bankName,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
description: data.description,
|
||||||
|
isActive: data.isActive,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,10 +418,17 @@ export async function createDocument(
|
|||||||
licenseIssueDate?: Date;
|
licenseIssueDate?: Date;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||||||
return prisma.identityDocument.create({
|
return prisma.identityDocument.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
type: data.type,
|
||||||
|
documentNumber: data.documentNumber,
|
||||||
|
issuingAuthority: data.issuingAuthority,
|
||||||
|
issueDate: data.issueDate,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
licenseClasses: data.licenseClasses,
|
||||||
|
licenseIssueDate: data.licenseIssueDate,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -393,9 +447,19 @@ export async function updateDocument(
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||||||
return prisma.identityDocument.update({
|
return prisma.identityDocument.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: {
|
||||||
|
type: data.type,
|
||||||
|
documentNumber: data.documentNumber,
|
||||||
|
issuingAuthority: data.issuingAuthority,
|
||||||
|
issueDate: data.issueDate,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
licenseClasses: data.licenseClasses,
|
||||||
|
licenseIssueDate: data.licenseIssueDate,
|
||||||
|
isActive: data.isActive,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// "Geworben / angeworben": wer hat wen an Board geholt.
|
// "Geworben / angeworben": wer hat wen an Board geholt.
|
||||||
|
|
||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { FachlicherFehler } from '../utils/apiError.js';
|
||||||
|
|
||||||
// Erlaubte Beziehungsarten (Whitelist). Server vertraut dem Frontend-Dropdown
|
// Erlaubte Beziehungsarten (Whitelist). Server vertraut dem Frontend-Dropdown
|
||||||
// nicht – ein handgebauter Request mit beliebigem String wird abgewiesen.
|
// nicht – ein handgebauter Request mit beliebigem String wird abgewiesen.
|
||||||
@@ -64,10 +65,11 @@ export async function getReferralsForCustomer(customerId: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReferralError extends Error {
|
export class ReferralError extends FachlicherFehler {
|
||||||
status: number;
|
/** Beibehalten, weil bestehende Aufrufer `.status` lesen. */
|
||||||
|
readonly status: number;
|
||||||
constructor(message: string, status = 400) {
|
constructor(message: string, status = 400) {
|
||||||
super(message);
|
super(message, status);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,32 @@ export async function getActiveProviderConfig() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle konfigurierten Provider-Domains (lowercase, dedupliziert). Das sind
|
||||||
|
* UNSERE verwalteten Mailbox-/Weiterleitungs-Domains (z.B. stressfrei-wechseln.de)
|
||||||
|
* – eine private Kunden-E-Mail darf darauf NICHT liegen, sonst trägt man
|
||||||
|
* versehentlich eine unserer Weiterleitungsadressen als private Adresse ein.
|
||||||
|
*/
|
||||||
|
export async function getConfiguredEmailDomains(): Promise<string[]> {
|
||||||
|
const configs = await prisma.emailProviderConfig.findMany({ select: { domain: true } });
|
||||||
|
const domains = configs
|
||||||
|
.map((c) => (c.domain || '').trim().toLowerCase())
|
||||||
|
.filter((d) => d.length > 0);
|
||||||
|
return [...new Set(domains)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* true, wenn die E-Mail auf einer der übergebenen Domains (oder einer Subdomain
|
||||||
|
* davon) liegt. Vergleich case-insensitiv.
|
||||||
|
*/
|
||||||
|
export function emailUsesDomain(email: string, domains: string[]): boolean {
|
||||||
|
const at = email.lastIndexOf('@');
|
||||||
|
if (at < 0) return false;
|
||||||
|
const host = email.slice(at + 1).trim().toLowerCase();
|
||||||
|
if (!host) return false;
|
||||||
|
return domains.some((d) => host === d || host.endsWith('.' + d));
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateProviderConfigData {
|
export interface CreateProviderConfigData {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { emit as emitSecurityEvent } from './securityMonitor.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wachhund auf den AUSBLEIBENDEN Heartbeat (Pentest R182/R183).
|
||||||
|
*
|
||||||
|
* Dienstkonten – allen voran das Gegenbuch – melden sich in festem Takt an.
|
||||||
|
* Diese Anmeldungen sind bewusst als Routine eingestuft, damit sie die
|
||||||
|
* CRITICAL-Stufe nicht entwerten. Damit fehlt aber die andere Haelfte: Wer das
|
||||||
|
* Gegenbuch stilllegt, setzt genau darauf, dass STILLE nicht auffaellt. Ein
|
||||||
|
* gestopptes Gegenbuch ist sonst nicht von „laeuft ruhig“ zu unterscheiden.
|
||||||
|
*
|
||||||
|
* Das Sicherheitssignal ist deshalb nicht die Anwesenheit der Anmeldung,
|
||||||
|
* sondern ihre Abweichung: bleibt sie aus, wird es gemeldet.
|
||||||
|
*
|
||||||
|
* Bewusste Zurueckhaltung: Ohne jemals gesehene Anmeldung gibt es keine
|
||||||
|
* Grundlinie – dann wird geschwiegen statt geraten. Und pro Ausfall wird nur
|
||||||
|
* einmal gemeldet, nicht bei jedem Durchlauf.
|
||||||
|
*/
|
||||||
|
const PRUEFTAKT_MS = 15 * 60 * 1000;
|
||||||
|
const MAX_ALTER_MINUTEN = Number.parseInt(process.env.SERVICE_ACCOUNT_MAX_SILENCE_MINUTES || '180', 10);
|
||||||
|
|
||||||
|
export async function pruefeHeartbeats(): Promise<void> {
|
||||||
|
const konten = await prisma.user.findMany({
|
||||||
|
where: { isServiceAccount: true, isActive: true },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
});
|
||||||
|
if (konten.length === 0) return;
|
||||||
|
|
||||||
|
const grenze = new Date(Date.now() - MAX_ALTER_MINUTEN * 60 * 1000);
|
||||||
|
|
||||||
|
for (const konto of konten) {
|
||||||
|
const letzte = await prisma.auditLog.findFirst({
|
||||||
|
where: { userId: konto.id, action: 'LOGIN' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nie angemeldet = keine Grundlinie. Schweigen statt raten.
|
||||||
|
if (!letzte) continue;
|
||||||
|
if (letzte.createdAt >= grenze) continue;
|
||||||
|
|
||||||
|
// Pro Ausfall nur einmal melden.
|
||||||
|
const schonGemeldet = await prisma.securityEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
userEmail: konto.email,
|
||||||
|
createdAt: { gte: letzte.createdAt },
|
||||||
|
message: { contains: 'Dienstkonto' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (schonGemeldet) continue;
|
||||||
|
|
||||||
|
const stillSeit = Math.round((Date.now() - letzte.createdAt.getTime()) / 60000);
|
||||||
|
await emitSecurityEvent({
|
||||||
|
type: 'SUSPICIOUS',
|
||||||
|
severity: 'CRITICAL',
|
||||||
|
message:
|
||||||
|
`Dienstkonto ${konto.email} meldet sich seit ${stillSeit} Minuten nicht mehr ` +
|
||||||
|
`(erwartet mindestens alle ${MAX_ALTER_MINUTEN} Minuten). ` +
|
||||||
|
'Entweder steht der zugehörige Dienst – etwa das Gegenbuch – oder er wurde stillgelegt. ' +
|
||||||
|
'Stille ist hier kein guter Zustand: Ohne laufendes Gegenbuch fällt eine nachträgliche ' +
|
||||||
|
'Änderung am Audit-Log nicht mehr auf.',
|
||||||
|
userEmail: konto.email,
|
||||||
|
userId: konto.id,
|
||||||
|
endpoint: 'heartbeat-monitor',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function starteHeartbeatMonitor(): void {
|
||||||
|
if (!Number.isFinite(MAX_ALTER_MINUTEN) || MAX_ALTER_MINUTEN <= 0) {
|
||||||
|
console.warn('[Heartbeat] SERVICE_ACCOUNT_MAX_SILENCE_MINUTES ungültig – Wachhund bleibt aus.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lauf = () => {
|
||||||
|
pruefeHeartbeats().catch((e) => console.error('[Heartbeat] Fehler:', e));
|
||||||
|
};
|
||||||
|
// Nicht sofort beim Start: Nach einem Neustart darf ein kurz zurueckliegender
|
||||||
|
// Ausfall nicht doppelt melden, und die DB soll erst oben sein.
|
||||||
|
setTimeout(lauf, 60_000);
|
||||||
|
setInterval(lauf, PRUEFTAKT_MS).unref();
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { SYSTEMROLLEN, pruefePortalRechte } from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft beim Start, ob die gesetzlich gebundenen Rechte ueberhaupt jemand
|
||||||
|
* ausueben kann (Pentest R189-01).
|
||||||
|
*
|
||||||
|
* Hintergrund: `gdpr:export`, `gdpr:delete` und `gdpr:admin` haengen an den
|
||||||
|
* Rollen DSGVO und Developer – nicht an der Admin-Rolle. Nach einem frischen
|
||||||
|
* Seed war keine der beiden einem Konto zugewiesen. Ergebnis: Eine Auskunft
|
||||||
|
* nach Art. 15 oder eine Loeschung nach Art. 17 konnte NIEMAND ausfuehren,
|
||||||
|
* obwohl mehrere „Admin"-Konten existierten. Ein Ausfall mit Fristwirkung,
|
||||||
|
* ausgeloest durch nichts weiter als eine Neuinstallation.
|
||||||
|
*
|
||||||
|
* Der Seed weist das Recht jetzt zu – aber nur bei NEUINSTALLATION. Auf einer
|
||||||
|
* laufenden Datenbank aendert er nichts, und niemand merkt es, bis es darauf
|
||||||
|
* ankommt. Deshalb diese Wache: Sie erkennt die ABWESENHEIT einer Faehigkeit,
|
||||||
|
* so wie der Heartbeat das Ausbleiben eines Dienstkontos erkennt.
|
||||||
|
*
|
||||||
|
* Bewusst nur eine Meldung, keine automatische Vergabe: Rechte zu verteilen,
|
||||||
|
* ohne dass ein Mensch es veranlasst hat, waere der groessere Fehler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rechte, deren Fehlen ein rechtliches und kein technisches Problem ist. */
|
||||||
|
const PFLICHTRECHTE: Array<{ resource: string; action: string; wofuer: string }> = [
|
||||||
|
{ resource: 'gdpr', action: 'export', wofuer: 'Auskunft nach Art. 15 DSGVO' },
|
||||||
|
{ resource: 'gdpr', action: 'delete', wofuer: 'Löschung nach Art. 17 DSGVO' },
|
||||||
|
{ resource: 'audit', action: 'read', wofuer: 'Prüfung des Audit-Protokolls' },
|
||||||
|
// Ohne dieses Recht laesst sich keine Rolle mehr anlegen oder aendern - die
|
||||||
|
// Rechtevergabe waere eingefroren. Kein Rechtsproblem wie die beiden
|
||||||
|
// darueber, aber dieselbe Bauart: eine Faehigkeit, deren Fehlen erst
|
||||||
|
// auffaellt, wenn man sie braucht.
|
||||||
|
{ resource: 'roles', action: 'manage', wofuer: 'Pflege der Rollen und Rechte' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function pruefePflichtrechte(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fehlend: string[] = [];
|
||||||
|
|
||||||
|
for (const recht of PFLICHTRECHTE) {
|
||||||
|
const traeger = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
roles: {
|
||||||
|
some: {
|
||||||
|
role: {
|
||||||
|
permissions: {
|
||||||
|
some: {
|
||||||
|
permission: { resource: recht.resource, action: recht.action },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (traeger === 0) {
|
||||||
|
fehlend.push(`${recht.resource}:${recht.action} (${recht.wofuer})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wache ueber die Portal-Trennung.
|
||||||
|
//
|
||||||
|
// Ein schreibendes Recht in PORTAL_RECHTE waere kein Schoenheitsfehler,
|
||||||
|
// sondern eine stille Ausweitung der Kundenrechte - jeder Portal-Kunde
|
||||||
|
// haette es sofort. Diese Meldung soll den Tag ueberleben, an dem jemand
|
||||||
|
// "nur kurz" etwas hinzufuegt.
|
||||||
|
const portalBeanstandet = pruefePortalRechte();
|
||||||
|
if (portalBeanstandet.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
'\n' +
|
||||||
|
'========================================================================\n' +
|
||||||
|
' ACHTUNG: Das Kundenportal traegt Rechte, die nicht Lesen sind:\n' +
|
||||||
|
portalBeanstandet.map((r) => ` – ${r}\n`).join('') +
|
||||||
|
'\n' +
|
||||||
|
' Kunden sollen im Portal NIEMALS operative Rechte haben. Die Ansicht\n' +
|
||||||
|
' ist dafuer nicht gebaut und prueft es nicht. Bitte PORTAL_RECHTE in\n' +
|
||||||
|
' config/rechte-katalog.ts pruefen.\n' +
|
||||||
|
'========================================================================\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zweite Wache: Stimmen die Systemrollen noch?
|
||||||
|
//
|
||||||
|
// Die versteckten Rollen werden ueber ihren NAMEN gefunden
|
||||||
|
// (`findFirst({ where: { name: 'DSGVO' } })`). Fehlt eine, oder ist ihr
|
||||||
|
// isSystem-Flag von Hand entfernt worden, laeuft der Notfallpfad ins
|
||||||
|
// Leere - lautlos. Das hier ist die Meldung, die es dann geben soll.
|
||||||
|
const rollenHinweise: string[] = [];
|
||||||
|
for (const spec of SYSTEMROLLEN) {
|
||||||
|
const rolle = await prisma.role.findUnique({ where: { name: spec.name } });
|
||||||
|
if (!rolle) {
|
||||||
|
rollenHinweise.push(`Systemrolle „${spec.name}" fehlt.`);
|
||||||
|
} else if (!rolle.isSystem) {
|
||||||
|
rollenHinweise.push(
|
||||||
|
`Systemrolle „${spec.name}" ist nicht als Systemrolle gekennzeichnet – ` +
|
||||||
|
'sie ist damit über die Rollenverwaltung änderbar.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rollenHinweise.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
'\n' +
|
||||||
|
'========================================================================\n' +
|
||||||
|
' ACHTUNG: Die Systemrollen stimmen nicht mit dem Katalog überein:\n' +
|
||||||
|
rollenHinweise.map((h) => ` – ${h}\n`).join('') +
|
||||||
|
'\n' +
|
||||||
|
' Beheben: npx tsx prisma/sync-roles.ts\n' +
|
||||||
|
'========================================================================\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fehlend.length === 0) return;
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
'\n' +
|
||||||
|
'========================================================================\n' +
|
||||||
|
' ACHTUNG: Für folgende Rechte gibt es KEIN aktives Konto:\n' +
|
||||||
|
fehlend.map((f) => ` – ${f}\n`).join('') +
|
||||||
|
'\n' +
|
||||||
|
' Das ist kein Fehler der Anwendung, sondern eine Lücke in der\n' +
|
||||||
|
' Rechtevergabe – und sie fällt erst auf, wenn eine Frist läuft.\n' +
|
||||||
|
'\n' +
|
||||||
|
' Beheben: In der Benutzerverwaltung bei einem verantwortlichen Konto\n' +
|
||||||
|
' den Haken „DSGVO-Zugriff" setzen (Audit-Protokoll lesen und\n' +
|
||||||
|
' Datenschutz-Verwaltung). Für Eingriffe am Protokoll – versiegeln,\n' +
|
||||||
|
' aufräumen, Aufbewahrung ändern – zusätzlich „Audit-Betrieb".\n' +
|
||||||
|
'\n' +
|
||||||
|
' Geht das nicht, weil niemand mehr die nötigen Rechte hat: Rechte\n' +
|
||||||
|
' lassen sich über die Oberfläche nur weitergeben, nicht erschaffen.\n' +
|
||||||
|
' Für die Erstvergabe gibt es den Weg über die Kommandozeile:\n' +
|
||||||
|
' docker compose exec backend \\\n' +
|
||||||
|
' npx tsx prisma/rolle-zuweisen.ts <e-mail> DSGVO\n' +
|
||||||
|
' („--liste" zeigt die vorhandenen Rollen.)\n' +
|
||||||
|
'========================================================================\n',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Eine Wache darf den Start nicht verhindern. Aber schweigen darf sie
|
||||||
|
// auch nicht: „nicht geprüft" ist nicht dasselbe wie „nichts gefunden".
|
||||||
|
console.warn(
|
||||||
|
'[Pflichtrechte] Prüfung nicht möglich – der Zustand der Rechtevergabe ist ' +
|
||||||
|
'damit UNBEKANNT, nicht in Ordnung:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Rechteaufloesung und die Regel, die Selbst-Erhoehung verhindert.
|
||||||
|
*
|
||||||
|
* Die Regel lautet: Niemand kann ein Recht weitergeben, das er selbst nicht
|
||||||
|
* besitzt. Ohne sie genuegte `users:create`, um sich zum Vollzugriff zu
|
||||||
|
* befoerdern - eine Rolle mit `developer:access` anlegen und sich zuweisen,
|
||||||
|
* oder gleich ein zweites Konto mit dem Haken "Entwicklerzugriff" erzeugen.
|
||||||
|
* Die Rollenverwaltung war damit faktisch eine Rechteerhoehung mit
|
||||||
|
* Zwischenschritt.
|
||||||
|
*
|
||||||
|
* Zwei Dinge sind hier bewusst so gebaut:
|
||||||
|
*
|
||||||
|
* 1. Die effektiven Rechte werden IMMER frisch aus der Datenbank gelesen,
|
||||||
|
* nie aus `req.user.permissions`. Der JWT-Claim ist bis zu 15 Minuten alt
|
||||||
|
* (JWT_EXPIRES_IN). Fuer ein Gate ist das vertretbar - fuer die Frage
|
||||||
|
* "darf dieser Mensch dieses Recht weitergeben" nicht: Ein Konto, dem
|
||||||
|
* gerade `gdpr:admin` entzogen wurde, koennte es im Fenster noch
|
||||||
|
* weiterreichen und damit den Entzug ueberdauern.
|
||||||
|
*
|
||||||
|
* 2. Geprueft wird immer nur der ZUWACHS. Rechte entziehen bleibt jederzeit
|
||||||
|
* erlaubt, auch solche, die der Handelnde selbst nicht hat - sonst
|
||||||
|
* koennte ein Admin einen uebernommenen Developer-Zugang nicht mehr
|
||||||
|
* entschaerfen, und die Regel wuerde den Angreifer schuetzen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { FachlicherFehler } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
/** Groesste Zahl, die in eine INT-Spalte passt. Darueber gibt es keine ID. */
|
||||||
|
const INT_MAX = 2147483647;
|
||||||
|
import {
|
||||||
|
ROLLE_DEVELOPER,
|
||||||
|
ROLLE_DSGVO,
|
||||||
|
ROLLE_AUDIT_BETRIEB,
|
||||||
|
rechteDerSystemrolle,
|
||||||
|
} from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
/** Der Handelnde wollte Rechte vergeben, die er selbst nicht besitzt. */
|
||||||
|
export class RechteEskalationError extends FachlicherFehler {
|
||||||
|
constructor(public readonly fehlend: string[]) {
|
||||||
|
super(
|
||||||
|
'Sie können nur Rechte vergeben, die Sie selbst besitzen. ' +
|
||||||
|
`Nicht vergeben werden können: ${fehlend.join(', ')}`,
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Eingabe nennt etwas, das es nicht gibt - eine unbekannte Rechte- oder
|
||||||
|
* Rollen-ID. Eigene Klasse, damit daraus ein 400 wird und nicht ein 500 aus
|
||||||
|
* einem Fremdschluesselfehler tief in Prisma.
|
||||||
|
*/
|
||||||
|
export class UngueltigeEingabeError extends FachlicherFehler {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Vorgang zielte auf eine Systemrolle, die von der Anwendung gepflegt wird. */
|
||||||
|
export class RollenSperrError extends FachlicherFehler {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die effektiven Rechte eines Kontos, frisch aus der Datenbank.
|
||||||
|
*
|
||||||
|
* Das ist dieselbe Aufloesung, die auth.service.ts beim Anmelden und beim
|
||||||
|
* Erneuern des Tokens vornimmt - hier an einer Stelle, statt in vier Kopien.
|
||||||
|
*/
|
||||||
|
export async function effektiveRechte(userId: number): Promise<Set<string>> {
|
||||||
|
const konto = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
roles: {
|
||||||
|
include: { role: { include: { permissions: { include: { permission: true } } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
if (!konto) return rechte;
|
||||||
|
for (const ur of konto.roles) {
|
||||||
|
for (const rp of ur.role.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vereinigung der Rechte mehrerer Rollen. */
|
||||||
|
export async function rechteVonRollen(roleIds: number[]): Promise<Set<string>> {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
if (roleIds.length === 0) return rechte;
|
||||||
|
const rollen = await prisma.role.findMany({
|
||||||
|
where: { id: { in: roleIds } },
|
||||||
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
});
|
||||||
|
for (const r of rollen) {
|
||||||
|
for (const rp of r.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loest Rechte-IDs auf. Wirft bei unbekannter ID einen sprechenden Fehler -
|
||||||
|
* bisher lief eine erfundene ID in einen Fremdschluesselfehler und kam als
|
||||||
|
* HTTP 500 zurueck, also als "unser Fehler" statt "Ihre Eingabe".
|
||||||
|
*/
|
||||||
|
export async function rechteVonPermissionIds(ids: number[]): Promise<Set<string>> {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
if (ids.length === 0) return rechte;
|
||||||
|
const eindeutig = [...new Set(ids)];
|
||||||
|
const zuGross = eindeutig.filter((id) => id > INT_MAX);
|
||||||
|
const abfragbar = eindeutig.filter((id) => id <= INT_MAX);
|
||||||
|
const gefunden = abfragbar.length
|
||||||
|
? await prisma.permission.findMany({ where: { id: { in: abfragbar } } })
|
||||||
|
: [];
|
||||||
|
if (gefunden.length !== abfragbar.length || zuGross.length > 0) {
|
||||||
|
const bekannt = new Set(gefunden.map((p) => p.id));
|
||||||
|
const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross];
|
||||||
|
throw new UngueltigeEingabeError(`Unbekannte Rechte-ID: ${unbekannt.join(', ')}`);
|
||||||
|
}
|
||||||
|
for (const p of gefunden) rechte.add(`${p.resource}:${p.action}`);
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kernregel. Wirft RechteEskalationError, wenn der Handelnde etwas
|
||||||
|
* weitergeben will, das er selbst nicht haelt.
|
||||||
|
*/
|
||||||
|
export async function pruefeTeilmenge(
|
||||||
|
handelnderId: number,
|
||||||
|
benoetigt: Iterable<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const zuPruefen = [...benoetigt];
|
||||||
|
if (zuPruefen.length === 0) return;
|
||||||
|
|
||||||
|
const eigene = await effektiveRechte(handelnderId);
|
||||||
|
const fehlend = zuPruefen.filter((r) => !eigene.has(r)).sort();
|
||||||
|
if (fehlend.length > 0) throw new RechteEskalationError(fehlend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet die Sitzungen aller Traeger einer Rolle.
|
||||||
|
*
|
||||||
|
* Noetig, weil die Rechte im Zugangstoken stehen: Ohne das behielte jeder
|
||||||
|
* Traeger bis zu 15 Minuten lang die alten Rechte, und ein Entzug waere
|
||||||
|
* genau so lange wirkungslos. `updateUser` machte das laengst - die
|
||||||
|
* Rollenpflege nicht, und dort wiegt es schwerer, weil sie viele Konten auf
|
||||||
|
* einmal betrifft.
|
||||||
|
*
|
||||||
|
* Muss bei Loeschungen VOR dem Loeschen laufen: Danach sind die Traeger
|
||||||
|
* durch den Cascade nicht mehr ermittelbar.
|
||||||
|
*/
|
||||||
|
export async function meldeTraegerAb(roleId: number): Promise<number> {
|
||||||
|
const traeger = await prisma.userRole.findMany({
|
||||||
|
where: { roleId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (traeger.length === 0) return 0;
|
||||||
|
await prisma.user.updateMany({
|
||||||
|
where: { id: { in: traeger.map((t) => t.userId) } },
|
||||||
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
|
});
|
||||||
|
return traeger.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Rechte, die hinter den drei Haken im Benutzerformular stehen.
|
||||||
|
*
|
||||||
|
* Die Haken sind keine Datenbankspalten, sondern Kurzschrift fuer die
|
||||||
|
* versteckten Rollen DSGVO, Developer und Audit-Betrieb. Sie muessen unter
|
||||||
|
* dieselbe Teilmengenregel wie die Rollenzuweisung fallen, sonst ist der
|
||||||
|
* Rest Theater: Die Umgehung braucht nur `users:create` - ein zweites Konto
|
||||||
|
* mit dem Haken "Entwicklerzugriff" anlegen und sich damit anmelden. Die
|
||||||
|
* Developer-Rolle traegt ALLE Rechte. Eine reine Selbstvergabe-Sperre
|
||||||
|
* griffe dagegen nicht, denn der Angreifer vergibt sich nichts selbst.
|
||||||
|
*
|
||||||
|
* Nur das EINSCHALTEN wird geprueft. Wer einen Haken entfernt, nimmt Rechte
|
||||||
|
* weg - das darf jeder duerfen, der das Konto verwalten darf.
|
||||||
|
*/
|
||||||
|
export function rechteDerHaken(haken: Haken): string[] {
|
||||||
|
const rechte: string[] = [];
|
||||||
|
if (haken.hasDeveloperAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DEVELOPER));
|
||||||
|
if (haken.hasGdprAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_DSGVO));
|
||||||
|
if (haken.hasAuditOpsAccess === true) rechte.push(...rechteDerSystemrolle(ROLLE_AUDIT_BETRIEB));
|
||||||
|
return [...new Set(rechte)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft Rollen-IDs und gibt sie doppelt-frei zurueck.
|
||||||
|
*
|
||||||
|
* Muss VOR jedem Schreibvorgang laufen. Ohne das kam eine Dublette
|
||||||
|
* (`[4,4]`) oder eine erfundene ID erst beim Schreiben zum Vorschein - und
|
||||||
|
* beim Rollentausch geschah das NACH dem Loeschen der alten Zuordnungen.
|
||||||
|
* Ergebnis war ein HTTP 400, das wie "Eingabe abgelehnt, nichts passiert"
|
||||||
|
* aussah, waehrend das Konto in Wahrheit ohne jede Rolle dastand.
|
||||||
|
*
|
||||||
|
* Besonders bitter am letzten Admin: Die Sperre prueft die ABSICHT (die
|
||||||
|
* Admin-Rolle steht ja in der Anfrage) und liess den Vorgang durch - der
|
||||||
|
* Schreibvorgang scheiterte danach. Aussperrung, obwohl die Sperre gegriffen
|
||||||
|
* zu haben schien (Pentest R190-01).
|
||||||
|
*
|
||||||
|
* `rechteVonRollen` fing das nicht ab: Eine unbekannte ID findet einfach
|
||||||
|
* keine Rolle, bringt also keine Rechte mit und faellt durch die
|
||||||
|
* Teilmengenregel nicht auf. Das war richtig fuer die Rechtefrage und
|
||||||
|
* falsch als Eingabepruefung - zwei verschiedene Aufgaben.
|
||||||
|
*/
|
||||||
|
export async function normalisiereRollenIds(roleIds: number[]): Promise<number[]> {
|
||||||
|
// Erst pruefen, dass es ueberhaupt eine Liste ist. Ohne das lief ein
|
||||||
|
// `{"roleIds":{}}` in einen TypeError beim Aufspreizen, und dessen
|
||||||
|
// Wortlaut ging an den Client (Pentest R192-01).
|
||||||
|
if (!Array.isArray(roleIds)) {
|
||||||
|
throw new UngueltigeEingabeError('roleIds muss eine Liste sein');
|
||||||
|
}
|
||||||
|
const eindeutig = [...new Set(roleIds)];
|
||||||
|
if (eindeutig.length === 0) return [];
|
||||||
|
if (!eindeutig.every((id) => Number.isInteger(id) && id >= 1)) {
|
||||||
|
throw new UngueltigeEingabeError('roleIds darf nur positive ganze Zahlen enthalten');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zahlen jenseits des INT-Bereichs gar nicht erst abfragen: Prisma bricht
|
||||||
|
// dort mit einem Fremdfehler ab, und der kam als allgemeines "Fehler beim
|
||||||
|
// Aktualisieren" zurueck - eine andere Antwort auf dieselbe Eingabeklasse
|
||||||
|
// (Pentest R191, kosmetisch). Eine ID, die nicht in die Spalte passt,
|
||||||
|
// benennt keine Rolle; sie ist schlicht unbekannt.
|
||||||
|
const zuGross = eindeutig.filter((id) => id > INT_MAX);
|
||||||
|
const abfragbar = eindeutig.filter((id) => id <= INT_MAX);
|
||||||
|
|
||||||
|
const gefunden = abfragbar.length
|
||||||
|
? await prisma.role.findMany({ where: { id: { in: abfragbar } }, select: { id: true } })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (gefunden.length !== abfragbar.length || zuGross.length > 0) {
|
||||||
|
const bekannt = new Set(gefunden.map((r) => r.id));
|
||||||
|
const unbekannt = [...abfragbar.filter((id) => !bekannt.has(id)), ...zuGross];
|
||||||
|
throw new UngueltigeEingabeError(`Unbekannte Rollen-ID: ${unbekannt.join(', ')}`);
|
||||||
|
}
|
||||||
|
return eindeutig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die drei Haken, nachdem sie geprueft wurden. Nur noch echte Booleans. */
|
||||||
|
export interface Haken {
|
||||||
|
hasDeveloperAccess?: boolean;
|
||||||
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HAKEN_FELDER = ['hasDeveloperAccess', 'hasGdprAccess', 'hasAuditOpsAccess'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die drei Haken-Felder streng auf Boolean.
|
||||||
|
*
|
||||||
|
* Der Grund ist ein Angriff, keine Formalie (Pentest R193-02): Der Guard
|
||||||
|
* fragte `=== true`, die Zuweisung fragte auf Truthiness. Zwei verschiedene
|
||||||
|
* Vorstellungen davon, was "der Haken ist gesetzt" bedeutet - und dazwischen
|
||||||
|
* passte ein Bypass. Mit `{"hasDeveloperAccess": "ja"}` sah der Guard keinen
|
||||||
|
* Rechtezuwachs und liess durch, waehrend die Zuweisung den String als
|
||||||
|
* "gesetzt" las und die Developer-Rolle vergab. Damit war `developer:access`
|
||||||
|
* wieder ueber ein Browser-Feld erreichbar, obwohl es ausdruecklich nur ueber
|
||||||
|
* die Kommandozeile vergeben werden sollte.
|
||||||
|
*
|
||||||
|
* Die Lehre ist dieselbe wie bei R190-01 und R191-01: Eine Absicherung, die
|
||||||
|
* nur eine von zwei Kopien erreicht, ist keine. Deshalb gibt es ab hier nur
|
||||||
|
* noch EINE Lesart - alles, was kein echtes true oder false ist, wird
|
||||||
|
* abgewiesen, statt irgendwie ausgelegt zu werden.
|
||||||
|
*/
|
||||||
|
export function normalisiereHaken(roh: Record<string, unknown>): Haken {
|
||||||
|
const gepruef: Haken = {};
|
||||||
|
for (const feld of HAKEN_FELDER) {
|
||||||
|
const wert = roh[feld];
|
||||||
|
if (wert === undefined) continue;
|
||||||
|
if (typeof wert !== 'boolean') {
|
||||||
|
throw new UngueltigeEingabeError(
|
||||||
|
`${feld} muss true oder false sein (empfangen: ${JSON.stringify(wert)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
gepruef[feld] = wert;
|
||||||
|
}
|
||||||
|
return gepruef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Bringt Rechtekatalog und Systemrollen in der Datenbank auf den Stand des
|
||||||
|
* Codes. Idempotent - laeuft bei jedem Container-Start.
|
||||||
|
*
|
||||||
|
* Verbraucher: `prisma/sync-roles.ts` (Container-Start), `prisma/seed.ts`
|
||||||
|
* (Erstinstallation) und `factoryReset` in `backup.service.ts`. Alle drei
|
||||||
|
* benutzen dieselbe Definition aus `config/rechte-katalog.ts`; vorher hatte
|
||||||
|
* jeder seine eigene, und die dritte wich ab.
|
||||||
|
*
|
||||||
|
* Was hier NICHT passiert: Stammdaten, Benutzer, Vertraege. Das Skript ist
|
||||||
|
* auf einer laufenden Produktionsdatenbank sicher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
RECHTE_KATALOG,
|
||||||
|
SYSTEMROLLEN,
|
||||||
|
alsRechtString,
|
||||||
|
} from '../config/rechte-katalog.js';
|
||||||
|
|
||||||
|
type Protokoll = (zeile: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Rechte einer Rolle exakt auf `permissionIds` - fehlende kommen
|
||||||
|
* dazu, ueberzaehlige fliegen raus.
|
||||||
|
*
|
||||||
|
* Der Vollersatz ist Absicht: Er ist der Grund, warum eine per Adminer an
|
||||||
|
* einer Systemrolle vorgenommene Aenderung den naechsten Container-Start
|
||||||
|
* nicht ueberlebt.
|
||||||
|
*/
|
||||||
|
async function synchronisiereRollenrechte(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
roleId: number,
|
||||||
|
permissionIds: number[],
|
||||||
|
log: Protokoll,
|
||||||
|
): Promise<void> {
|
||||||
|
const vorhanden = await prisma.rolePermission.findMany({
|
||||||
|
where: { roleId },
|
||||||
|
select: { permissionId: true },
|
||||||
|
});
|
||||||
|
const vorhandenIds = new Set(vorhanden.map((e) => e.permissionId));
|
||||||
|
const zielIds = new Set(permissionIds);
|
||||||
|
|
||||||
|
const fehlend = permissionIds.filter((id) => !vorhandenIds.has(id));
|
||||||
|
if (fehlend.length > 0) {
|
||||||
|
await prisma.rolePermission.createMany({
|
||||||
|
data: fehlend.map((permissionId) => ({ roleId, permissionId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
log(` → +${fehlend.length} Rechte an Rolle #${roleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ueberzaehlig = vorhanden
|
||||||
|
.filter((e) => !zielIds.has(e.permissionId))
|
||||||
|
.map((e) => e.permissionId);
|
||||||
|
if (ueberzaehlig.length > 0) {
|
||||||
|
await prisma.rolePermission.deleteMany({
|
||||||
|
where: { roleId, permissionId: { in: ueberzaehlig } },
|
||||||
|
});
|
||||||
|
log(` → -${ueberzaehlig.length} Rechte von Rolle #${roleId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt alle Rechte aus dem Katalog an und bringt die Systemrollen auf Stand.
|
||||||
|
* Setzt dabei auch `isSystem` und `isHidden` - das ist die eigentliche
|
||||||
|
* Absicherung gegen eine von Hand verstellte Datenbank, die Migration setzt
|
||||||
|
* die Flags nur einmalig.
|
||||||
|
*/
|
||||||
|
export async function synchronisiereRechteUndRollen(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
log: Protokoll = (z) => console.log(z),
|
||||||
|
): Promise<void> {
|
||||||
|
log('[rollen-sync] Rechtekatalog upserten…');
|
||||||
|
for (const recht of RECHTE_KATALOG) {
|
||||||
|
await prisma.permission.upsert({
|
||||||
|
where: { resource_action: { resource: recht.resource, action: recht.action } },
|
||||||
|
update: {},
|
||||||
|
create: { resource: recht.resource, action: recht.action },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleRechte = await prisma.permission.findMany();
|
||||||
|
log(`[rollen-sync] ${alleRechte.length} Rechte in der Datenbank`);
|
||||||
|
|
||||||
|
// Auflösung Katalog → Datenbank-IDs. Rechte, die in der Datenbank stehen,
|
||||||
|
// aber nicht im Katalog, bleiben unangetastet und werden auch keiner Rolle
|
||||||
|
// zugeteilt: Der Katalog ist die Wahrheit, nicht der Altbestand.
|
||||||
|
const idFuerRecht = new Map<string, number>();
|
||||||
|
for (const p of alleRechte) idFuerRecht.set(alsRechtString(p), p.id);
|
||||||
|
|
||||||
|
for (const spec of SYSTEMROLLEN) {
|
||||||
|
const rechteIds = RECHTE_KATALOG.filter(spec.rechte)
|
||||||
|
.map((r) => idFuerRecht.get(alsRechtString(r)))
|
||||||
|
.filter((id): id is number => id !== undefined);
|
||||||
|
|
||||||
|
const rolle = await prisma.role.upsert({
|
||||||
|
where: { name: spec.name },
|
||||||
|
update: {
|
||||||
|
description: spec.description,
|
||||||
|
isSystem: true,
|
||||||
|
isHidden: spec.isHidden,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: spec.name,
|
||||||
|
description: spec.description,
|
||||||
|
isSystem: true,
|
||||||
|
isHidden: spec.isHidden,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await synchronisiereRollenrechte(prisma, rolle.id, rechteIds, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('[rollen-sync] fertig.');
|
||||||
|
}
|
||||||
@@ -1,6 +1,33 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { paginate, buildPaginationResponse } from '../utils/helpers.js';
|
import { paginate, buildPaginationResponse } from '../utils/helpers.js';
|
||||||
|
import {
|
||||||
|
pruefeTeilmenge,
|
||||||
|
rechteVonPermissionIds,
|
||||||
|
rechteVonRollen,
|
||||||
|
rechteDerHaken,
|
||||||
|
normalisiereHaken,
|
||||||
|
UngueltigeEingabeError,
|
||||||
|
effektiveRechte,
|
||||||
|
normalisiereRollenIds,
|
||||||
|
meldeTraegerAb,
|
||||||
|
RollenSperrError,
|
||||||
|
} from './rechte.service.js';
|
||||||
|
import type { Haken } from './rechte.service.js';
|
||||||
|
import {
|
||||||
|
istSystemrollenName,
|
||||||
|
RECHTE_KATALOG,
|
||||||
|
ROLLE_DEVELOPER,
|
||||||
|
ROLLE_DSGVO,
|
||||||
|
ROLLE_AUDIT_BETRIEB,
|
||||||
|
} from '../config/rechte-katalog.js';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-Export aus dem Katalog. Der Name ist an mehreren Stellen im Umlauf;
|
||||||
|
* die Wahrheit steht jetzt an einer Stelle.
|
||||||
|
*/
|
||||||
|
export const AUDIT_OPS_ROLLE = ROLLE_AUDIT_BETRIEB;
|
||||||
|
|
||||||
export interface UserFilters {
|
export interface UserFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -44,6 +71,7 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
isServiceAccount: true,
|
||||||
customerId: true,
|
customerId: true,
|
||||||
whatsappNumber: true,
|
whatsappNumber: true,
|
||||||
telegramUsername: true,
|
telegramUsername: true,
|
||||||
@@ -64,9 +92,10 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Get hidden role IDs
|
// Get hidden role IDs
|
||||||
const [developerRole, gdprRole] = await Promise.all([
|
const [developerRole, gdprRole, auditOpsRole] = await Promise.all([
|
||||||
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
prisma.role.findFirst({ where: { name: 'Developer' } }),
|
||||||
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
prisma.role.findFirst({ where: { name: 'DSGVO' } }),
|
||||||
|
prisma.role.findFirst({ where: { name: AUDIT_OPS_ROLLE } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -77,11 +106,15 @@ export async function getAllUsers(filters: UserFilters) {
|
|||||||
const hasGdprAccess = gdprRole
|
const hasGdprAccess = gdprRole
|
||||||
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
? u.roles.some((ur) => ur.roleId === gdprRole.id)
|
||||||
: false;
|
: false;
|
||||||
|
const hasAuditOpsAccess = auditOpsRole
|
||||||
|
? u.roles.some((ur) => ur.roleId === auditOpsRole.id)
|
||||||
|
: false;
|
||||||
return {
|
return {
|
||||||
...u,
|
...u,
|
||||||
roles: u.roles.map((r) => r.role),
|
roles: u.roles.map((r) => r.role),
|
||||||
hasDeveloperAccess,
|
hasDeveloperAccess,
|
||||||
hasGdprAccess,
|
hasGdprAccess,
|
||||||
|
hasAuditOpsAccess,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
pagination: buildPaginationResponse(page, limit, total),
|
pagination: buildPaginationResponse(page, limit, total),
|
||||||
@@ -97,6 +130,7 @@ export async function getUserById(id: number) {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
isServiceAccount: true,
|
||||||
customerId: true,
|
customerId: true,
|
||||||
whatsappNumber: true,
|
whatsappNumber: true,
|
||||||
telegramUsername: true,
|
telegramUsername: true,
|
||||||
@@ -135,6 +169,79 @@ export async function getUserById(id: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faehigkeiten, ohne die sich das System nicht mehr verwalten laesst.
|
||||||
|
*
|
||||||
|
* Bis 09/2026 galt hier "wer `users:delete` hat, ist Admin". Das war ein
|
||||||
|
* Zufallsmerkmal: Wer Konten anlegen und bearbeiten darf, aber nicht loeschen,
|
||||||
|
* verwaltet genauso - zaehlte aber nicht. Umgekehrt zaehlte jemand mit nur
|
||||||
|
* `users:delete` als Admin, obwohl er kein Konto anlegen kann. Ein
|
||||||
|
* Sicherheitsnetz an einem Merkmal, das mit der geschuetzten Eigenschaft nur
|
||||||
|
* lose zusammenhing (Pentest R193, Nachrangpunkt 2).
|
||||||
|
*
|
||||||
|
* Bewusst NICHT auf die Rolle "Admin" umgestellt, wie zunaechst vorgeschlagen:
|
||||||
|
* Eine selbst gebaute Rolle mit `users:update` verwaltet tatsaechlich, und ein
|
||||||
|
* Namenskriterium wuerde sie uebersehen - man koennte dann den letzten
|
||||||
|
* Admin loeschen, obwohl die Verwaltungsfaehigkeit erhalten bliebe. Geschuetzt
|
||||||
|
* wird die FAEHIGKEIT, nicht ihr ueblicher Traeger.
|
||||||
|
*
|
||||||
|
* `users:update` ist das Minimum, um Konten wieder in Ordnung zu bringen
|
||||||
|
* (Rollen zuweisen). `roles:manage` ist das Minimum, um Rollen zu definieren.
|
||||||
|
* Faellt eines davon ersatzlos weg, hilft nur noch die Kommandozeile.
|
||||||
|
*/
|
||||||
|
const UNVERZICHTBARE_FAEHIGKEITEN = [
|
||||||
|
{ recht: 'users:update', wofuer: 'Benutzer verwalten' },
|
||||||
|
{ recht: 'roles:manage', wofuer: 'Rollen und Rechte pflegen' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Die Rechte eines Kontos aus seinen geladenen Rollen. */
|
||||||
|
function rechteAusRollen(
|
||||||
|
rollen: Array<{ role: { permissions: Array<{ permission: { resource: string; action: string } }> } }>,
|
||||||
|
): Set<string> {
|
||||||
|
const rechte = new Set<string>();
|
||||||
|
for (const ur of rollen) {
|
||||||
|
for (const rp of ur.role.permissions) {
|
||||||
|
rechte.add(`${rp.permission.resource}:${rp.permission.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rechte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verhindert, dass die letzte Person mit einer unverzichtbaren Faehigkeit
|
||||||
|
* sie verliert - durch Rollenwechsel, Deaktivierung oder Loeschung.
|
||||||
|
*
|
||||||
|
* `kuenftigeRechte === null` heisst: Das Konto faellt ganz weg.
|
||||||
|
*/
|
||||||
|
async function pruefeLetzterVerwalter(
|
||||||
|
zielId: number,
|
||||||
|
bisherigeRechte: Set<string>,
|
||||||
|
kuenftigeRechte: Set<string> | null,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const { recht, wofuer } of UNVERZICHTBARE_FAEHIGKEITEN) {
|
||||||
|
if (!bisherigeRechte.has(recht)) continue; // hatte es nie
|
||||||
|
if (kuenftigeRechte?.has(recht)) continue; // behaelt es
|
||||||
|
|
||||||
|
const [resource, action] = recht.split(':');
|
||||||
|
const andere = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
id: { not: zielId },
|
||||||
|
isActive: true,
|
||||||
|
roles: { some: { role: { permissions: { some: { permission: { resource, action } } } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (andere === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Dieses Konto ist das letzte, das „${wofuer}" kann (${recht}). ` +
|
||||||
|
'Ohne es liesse sich das System nur noch über die Kommandozeile ' +
|
||||||
|
'wieder in Ordnung bringen. Bitte zuerst einem anderen aktiven Konto ' +
|
||||||
|
'die entsprechende Rolle geben.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createUser(data: {
|
export async function createUser(data: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -144,13 +251,38 @@ export async function createUser(data: {
|
|||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
}) {
|
},
|
||||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
handelnderId: number,
|
||||||
|
) {
|
||||||
|
// Was das neue Konto koennen wird - aus den Rollen UND aus den Haken.
|
||||||
|
// `handelnderId` ist Pflichtparameter, damit kein Aufrufer die Pruefung
|
||||||
|
// vergessen kann; der Compiler erzwingt sie.
|
||||||
|
// Erst pruefen, dann rechnen: Eine unbekannte oder doppelte Rollen-ID
|
||||||
|
// faellt der Teilmengenregel nicht auf (sie bringt keine Rechte mit) und
|
||||||
|
// schluege erst beim Schreiben fehl.
|
||||||
|
const rollenIds = await normalisiereRollenIds(data.roleIds);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
// Einmal pruefen, dann ueberall dieselbe Lesart: Guard und Zuweisung
|
||||||
|
// arbeiten auf DEMSELBEN Objekt (Pentest R193-02).
|
||||||
|
const haken = normalisiereHaken(data as Record<string, unknown>);
|
||||||
|
|
||||||
|
await pruefeTeilmenge(handelnderId, [
|
||||||
|
...(await rechteVonRollen(rollenIds)),
|
||||||
|
...rechteDerHaken(haken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cost 12 wie in prisma/seed.ts (OWASP 2026). Hier stand 10 - dieselbe
|
||||||
|
// Haertung, die im Seed laengst galt, war an dieser Stelle nie
|
||||||
|
// angekommen. Bestehende Kennwoerter bleiben pruefbar, der Cost steckt im
|
||||||
|
// Hash.
|
||||||
|
const hashedPassword = await bcrypt.hash(data.password, 12);
|
||||||
|
|
||||||
|
const user = await prisma.$transaction(async (tx) => {
|
||||||
|
const angelegt = await tx.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
@@ -161,7 +293,7 @@ export async function createUser(data: {
|
|||||||
telegramUsername: data.telegramUsername || null,
|
telegramUsername: data.telegramUsername || null,
|
||||||
signalNumber: data.signalNumber || null,
|
signalNumber: data.signalNumber || null,
|
||||||
roles: {
|
roles: {
|
||||||
create: data.roleIds.map((roleId) => ({ roleId })),
|
create: rollenIds.map((roleId) => ({ roleId })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -170,6 +302,7 @@ export async function createUser(data: {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
isServiceAccount: true,
|
||||||
customerId: true,
|
customerId: true,
|
||||||
roles: {
|
roles: {
|
||||||
include: { role: true },
|
include: { role: true },
|
||||||
@@ -177,15 +310,13 @@ export async function createUser(data: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Entwicklerzugriff setzen falls aktiviert
|
// Die Haken in derselben Transaktion wie das Konto (Pentest R191-01).
|
||||||
if (data.hasDeveloperAccess) {
|
// Sonst koennte ein halb ausgestattetes Konto zurueckbleiben: angelegt,
|
||||||
await setUserDeveloperAccess(user.id, true);
|
// aber ohne die zugesagte versteckte Rolle.
|
||||||
}
|
await setzeHaken(tx, angelegt.id, haken);
|
||||||
|
|
||||||
// DSGVO-Zugriff setzen falls aktiviert
|
return angelegt;
|
||||||
if (data.hasGdprAccess) {
|
});
|
||||||
await setUserGdprAccess(user.id, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -198,16 +329,50 @@ export async function updateUser(
|
|||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
isServiceAccount?: boolean;
|
||||||
roleIds?: number[];
|
roleIds?: number[];
|
||||||
customerId?: number;
|
customerId?: number;
|
||||||
hasDeveloperAccess?: boolean;
|
hasDeveloperAccess?: boolean;
|
||||||
hasGdprAccess?: boolean;
|
hasGdprAccess?: boolean;
|
||||||
|
hasAuditOpsAccess?: boolean;
|
||||||
whatsappNumber?: string;
|
whatsappNumber?: string;
|
||||||
telegramUsername?: string;
|
telegramUsername?: string;
|
||||||
signalNumber?: string;
|
signalNumber?: string;
|
||||||
}
|
},
|
||||||
|
handelnderId: number,
|
||||||
) {
|
) {
|
||||||
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, ...userData } = data;
|
const { roleIds, password, hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess, ...userData } = data;
|
||||||
|
|
||||||
|
// Rollen-IDs pruefen, bevor irgendetwas geschrieben oder entschieden wird.
|
||||||
|
// Die Letzter-Admin-Sperre weiter unten arbeitet mit dieser Liste - mit
|
||||||
|
// einer unbekannten ID darin haette sie eine Absicht bestaetigt, die der
|
||||||
|
// Schreibvorgang danach nicht einloesen konnte (Pentest R190-01).
|
||||||
|
const gepruefteRollenIds =
|
||||||
|
roleIds !== undefined ? await normalisiereRollenIds(roleIds) : undefined;
|
||||||
|
|
||||||
|
// Die Haken einmal pruefen - danach gibt es nur noch diese eine Lesart,
|
||||||
|
// fuer den Guard wie fuer die Zuweisung (Pentest R193-02).
|
||||||
|
const haken = normalisiereHaken({ hasDeveloperAccess, hasGdprAccess, hasAuditOpsAccess });
|
||||||
|
|
||||||
|
// Teilmengenregel auf dem ZUWACHS, nicht auf dem Endzustand.
|
||||||
|
//
|
||||||
|
// Wer nur den Nachnamen eines hoeher privilegierten Kollegen korrigiert,
|
||||||
|
// schickt das Formular unveraendert mit - inklusive gesetzter Haken. Wuerde
|
||||||
|
// hier der Endzustand geprueft, waere jede solche Korrektur ein 403.
|
||||||
|
// Geprueft wird deshalb nur, was das Zielkonto NEU dazubekommt.
|
||||||
|
{
|
||||||
|
const bisher = await effektiveRechte(id);
|
||||||
|
const zuwachs = new Set<string>();
|
||||||
|
if (roleIds !== undefined) {
|
||||||
|
for (const r of await rechteVonRollen(gepruefteRollenIds!)) {
|
||||||
|
if (!bisher.has(r)) zuwachs.add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of rechteDerHaken(haken)) {
|
||||||
|
if (!bisher.has(r)) zuwachs.add(r);
|
||||||
|
}
|
||||||
|
await pruefeTeilmenge(handelnderId, zuwachs);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this would remove the last admin
|
// Check if this would remove the last admin
|
||||||
const isBeingDeactivated = userData.isActive === false;
|
const isBeingDeactivated = userData.isActive === false;
|
||||||
@@ -232,74 +397,31 @@ export async function updateUser(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isCurrentlyAdmin = currentUser?.roles.some((ur) =>
|
// Geschuetzt wird die FAEHIGKEIT, nicht ihr ueblicher Traeger -
|
||||||
ur.role.permissions.some(
|
// siehe UNVERZICHTBARE_FAEHIGKEITEN.
|
||||||
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
|
const bisherigeRechte = rechteAusRollen(currentUser?.roles ?? []);
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCurrentlyAdmin) {
|
let kuenftigeRechte: Set<string> | null;
|
||||||
// Check if user will still be admin after role change
|
|
||||||
let willStillBeAdmin = false;
|
|
||||||
if (rolesAreBeingChanged) {
|
|
||||||
const newRoles = await prisma.role.findMany({
|
|
||||||
where: { id: { in: roleIds } },
|
|
||||||
include: {
|
|
||||||
permissions: {
|
|
||||||
include: { permission: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
willStillBeAdmin = newRoles.some((role) =>
|
|
||||||
role.permissions.some(
|
|
||||||
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
willStillBeAdmin = true; // Roles not being changed
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user is losing admin status or being deactivated, check for other admins
|
|
||||||
if (!willStillBeAdmin || isBeingDeactivated) {
|
|
||||||
const otherAdminCount = await prisma.user.count({
|
|
||||||
where: {
|
|
||||||
id: { not: id },
|
|
||||||
isActive: true,
|
|
||||||
roles: {
|
|
||||||
some: {
|
|
||||||
role: {
|
|
||||||
permissions: {
|
|
||||||
some: {
|
|
||||||
permission: {
|
|
||||||
resource: 'users',
|
|
||||||
action: 'delete',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (otherAdminCount === 0) {
|
|
||||||
if (isBeingDeactivated) {
|
if (isBeingDeactivated) {
|
||||||
throw new Error(
|
// Ein deaktiviertes Konto kann nichts mehr, egal welche Rollen formal
|
||||||
'Dieser Benutzer ist der letzte Administrator und kann nicht deaktiviert werden'
|
// drankleben.
|
||||||
);
|
kuenftigeRechte = null;
|
||||||
|
} else if (rolesAreBeingChanged) {
|
||||||
|
const neueRollen = await prisma.role.findMany({
|
||||||
|
where: { id: { in: gepruefteRollenIds! } },
|
||||||
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
});
|
||||||
|
kuenftigeRechte = rechteAusRollen(neueRollen.map((role) => ({ role })));
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
kuenftigeRechte = bisherigeRechte;
|
||||||
'Die Admin-Rolle kann nicht entfernt werden, da dies der letzte Administrator ist'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pruefeLetzterVerwalter(id, bisherigeRechte, kuenftigeRechte);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password if provided
|
// Hash password if provided
|
||||||
if (password) {
|
if (password) {
|
||||||
(userData as Record<string, unknown>).password = await bcrypt.hash(password, 10);
|
(userData as Record<string, unknown>).password = await bcrypt.hash(password, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob Rollen geändert werden (für Zwangslogout)
|
// Prüfen ob Rollen geändert werden (für Zwangslogout)
|
||||||
@@ -316,8 +438,23 @@ export async function updateUser(
|
|||||||
!currentRoleIds.every((id, i) => id === newRoleIds[i]);
|
!currentRoleIds.every((id, i) => id === newRoleIds[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user - bei Rollenänderung Token invalidieren
|
// Die gesamte Schreibphase in EINER Transaktion.
|
||||||
await prisma.user.update({
|
//
|
||||||
|
// Zwei Stufen, beide aus dem Pentest:
|
||||||
|
//
|
||||||
|
// R190-01: deleteMany und createMany standen nackt nebeneinander.
|
||||||
|
// Scheiterte das Anlegen, war das Loeschen schon passiert - das Konto
|
||||||
|
// hatte gar keine Rolle mehr, bei einer Antwort, die wie "abgelehnt,
|
||||||
|
// nichts geschehen" aussah.
|
||||||
|
//
|
||||||
|
// R191-01: Danach lag zwar der Rollentausch in einer Transaktion, die
|
||||||
|
// drei Haken aber liefen als eigene Schreibvorgaenge hinterher. Ein
|
||||||
|
// Datenbankfehler dort hinterliess den Rollenstand gesetzt und die Haken
|
||||||
|
// halb - kein Verlust und keine Rechteerhoehung, aber ein Zwischenstand,
|
||||||
|
// den niemand angefordert hat. Ein PUT ist ein Vorgang, also gehoert er
|
||||||
|
// in eine Klammer.
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...userData,
|
...userData,
|
||||||
@@ -326,133 +463,99 @@ export async function updateUser(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update roles if provided
|
if (gepruefteRollenIds !== undefined) {
|
||||||
if (roleIds) {
|
await tx.userRole.deleteMany({ where: { userId: id } });
|
||||||
await prisma.userRole.deleteMany({ where: { userId: id } });
|
await tx.userRole.createMany({
|
||||||
await prisma.userRole.createMany({
|
data: gepruefteRollenIds.map((roleId) => ({ userId: id, roleId })),
|
||||||
data: roleIds.map((roleId) => ({ userId: id, roleId })),
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle developer access
|
// Nach dem Rollentausch: Der loescht ALLE Zuordnungen, auch die
|
||||||
if (hasDeveloperAccess !== undefined) {
|
// versteckten Rollen. Die Haken setzen sie anschliessend wieder.
|
||||||
await setUserDeveloperAccess(id, hasDeveloperAccess);
|
await setzeHaken(tx, id, haken);
|
||||||
}
|
});
|
||||||
|
|
||||||
// Handle GDPR access
|
|
||||||
if (hasGdprAccess !== undefined) {
|
|
||||||
await setUserGdprAccess(id, hasGdprAccess);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to set developer access for a user
|
/**
|
||||||
async function setUserDeveloperAccess(userId: number, enabled: boolean) {
|
* Setzt oder entfernt eine der drei versteckten Rollen (DSGVO, Developer,
|
||||||
// Get or create developer:access permission
|
* Audit-Betrieb) - innerhalb der uebergebenen Transaktion.
|
||||||
let developerPerm = await prisma.permission.findFirst({
|
*
|
||||||
where: { resource: 'developer', action: 'access' },
|
* Ersetzt drei fast gleiche Funktionen, die jede fuer sich die Rolle bei
|
||||||
});
|
* Bedarf ANLEGTEN, falls sie fehlte. Das war als Notfallpfad gedacht und
|
||||||
|
* zugleich eine vierte Stelle, die festlegte, was "Developer" bedeutet -
|
||||||
if (!developerPerm) {
|
* mit einem anderen Rechtesatz als der Katalog (nur `developer:access`
|
||||||
developerPerm = await prisma.permission.create({
|
* statt allem) und ohne `isSystem`. Eine so entstandene Rolle waere ueber
|
||||||
data: { resource: 'developer', action: 'access' },
|
* die Rollenverwaltung aenderbar gewesen.
|
||||||
});
|
*
|
||||||
|
* Den Notfallpfad braucht es nicht mehr: `synchronisiereRechteUndRollen`
|
||||||
|
* legt die Rollen bei jedem Containerstart an, und die Startwache meldet,
|
||||||
|
* wenn eine fehlt. Fehlt sie hier trotzdem, ist Abbrechen mit klarer Ansage
|
||||||
|
* ehrlicher, als stillschweigend etwas Aehnliches zu erfinden.
|
||||||
|
*/
|
||||||
|
async function setzeVersteckteRolle(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: number,
|
||||||
|
rollenName: string,
|
||||||
|
aktiv: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (typeof aktiv !== 'boolean') {
|
||||||
|
throw new UngueltigeEingabeError(`Haken für „${rollenName}" muss true oder false sein`);
|
||||||
|
}
|
||||||
|
const rolle = await tx.role.findUnique({ where: { name: rollenName } });
|
||||||
|
if (!rolle) {
|
||||||
|
throw new Error(
|
||||||
|
`Die Rolle „${rollenName}" fehlt in der Datenbank. Sie wird beim ` +
|
||||||
|
'Containerstart angelegt – einmalig nachholen mit: npx tsx prisma/sync-roles.ts',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create Developer role
|
const vorhanden = await tx.userRole.findUnique({
|
||||||
let developerRole = await prisma.role.findFirst({
|
where: { userId_roleId: { userId, roleId: rolle.id } },
|
||||||
where: { name: 'Developer' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!developerRole) {
|
// Strikt auf `true`, nicht auf Truthiness. Hier war die zweite Haelfte des
|
||||||
developerRole = await prisma.role.create({
|
// Bypasses aus R193-02: Der Guard fragte `=== true`, diese Zeile fragte
|
||||||
data: {
|
// "irgendwie wahr". `normalisiereHaken` laesst inzwischen nichts anderes
|
||||||
name: 'Developer',
|
// mehr durch - aber die Lesart darf sich auch dann nicht unterscheiden,
|
||||||
description: 'Entwicklerzugriff auf Datenbanktools',
|
// wenn jemand diese Funktion kuenftig von woanders aufruft.
|
||||||
permissions: {
|
if (aktiv === true && !vorhanden) {
|
||||||
create: [{ permissionId: developerPerm.id }],
|
await tx.userRole.create({ data: { userId, roleId: rolle.id } });
|
||||||
},
|
} else if (aktiv === false && vorhanden) {
|
||||||
},
|
await tx.userRole.delete({
|
||||||
|
where: { userId_roleId: { userId, roleId: rolle.id } },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
return; // nichts geaendert - dann auch niemanden abmelden
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already has Developer role
|
// Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens.
|
||||||
const hasRole = await prisma.userRole.findFirst({
|
await tx.user.update({
|
||||||
where: { userId, roleId: developerRole.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (enabled && !hasRole) {
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: { userId, roleId: developerRole.id },
|
|
||||||
});
|
|
||||||
// Token invalidieren bei Rechteänderung
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
|
||||||
});
|
|
||||||
} else if (!enabled && hasRole) {
|
|
||||||
await prisma.userRole.delete({
|
|
||||||
where: { userId_roleId: { userId, roleId: developerRole.id } },
|
|
||||||
});
|
|
||||||
// Token invalidieren bei Rechteänderung
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
data: { tokenInvalidatedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die drei Haken auf ihre versteckten Rollen abbilden. */
|
||||||
|
async function setzeHaken(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
userId: number,
|
||||||
|
haken: Haken,
|
||||||
|
): Promise<void> {
|
||||||
|
if (haken.hasDeveloperAccess !== undefined) {
|
||||||
|
await setzeVersteckteRolle(tx, userId, ROLLE_DEVELOPER, haken.hasDeveloperAccess);
|
||||||
}
|
}
|
||||||
|
if (haken.hasGdprAccess !== undefined) {
|
||||||
// Helper to set GDPR access for a user
|
await setzeVersteckteRolle(tx, userId, ROLLE_DSGVO, haken.hasGdprAccess);
|
||||||
async function setUserGdprAccess(userId: number, enabled: boolean) {
|
|
||||||
// Get or create DSGVO role
|
|
||||||
let gdprRole = await prisma.role.findFirst({
|
|
||||||
where: { name: 'DSGVO' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!gdprRole) {
|
|
||||||
// Create DSGVO role with all audit:* and gdpr:* permissions
|
|
||||||
const gdprPermissions = await prisma.permission.findMany({
|
|
||||||
where: {
|
|
||||||
OR: [{ resource: 'audit' }, { resource: 'gdpr' }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
gdprRole = await prisma.role.create({
|
|
||||||
data: {
|
|
||||||
name: 'DSGVO',
|
|
||||||
description: 'DSGVO-Zugriff: Audit-Logs und Datenschutz-Verwaltung',
|
|
||||||
permissions: {
|
|
||||||
create: gdprPermissions.map((p) => ({ permissionId: p.id })),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if (haken.hasAuditOpsAccess !== undefined) {
|
||||||
// Check if user already has DSGVO role
|
await setzeVersteckteRolle(tx, userId, AUDIT_OPS_ROLLE, haken.hasAuditOpsAccess);
|
||||||
const hasRole = await prisma.userRole.findFirst({
|
|
||||||
where: { userId, roleId: gdprRole.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (enabled && !hasRole) {
|
|
||||||
await prisma.userRole.create({
|
|
||||||
data: { userId, roleId: gdprRole.id },
|
|
||||||
});
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
|
||||||
});
|
|
||||||
} else if (!enabled && hasRole) {
|
|
||||||
await prisma.userRole.delete({
|
|
||||||
where: { userId_roleId: { userId, roleId: gdprRole.id } },
|
|
||||||
});
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { tokenInvalidatedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function deleteUser(id: number) {
|
export async function deleteUser(id: number) {
|
||||||
// Check if user is an admin
|
// Check if user is an admin
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -476,42 +579,9 @@ export async function deleteUser(id: number) {
|
|||||||
throw new Error('Benutzer nicht gefunden');
|
throw new Error('Benutzer nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has admin permissions (users:delete means admin)
|
// Dasselbe Kriterium wie beim Bearbeiten: Geschuetzt wird die Faehigkeit,
|
||||||
const isAdmin = user.roles.some((ur) =>
|
// das System zu verwalten - nicht die Rolle, die sie ueblicherweise traegt.
|
||||||
ur.role.permissions.some(
|
await pruefeLetzterVerwalter(id, rechteAusRollen(user.roles), null);
|
||||||
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
// Count other admins (users with users:delete permission)
|
|
||||||
const adminCount = await prisma.user.count({
|
|
||||||
where: {
|
|
||||||
id: { not: id },
|
|
||||||
isActive: true,
|
|
||||||
roles: {
|
|
||||||
some: {
|
|
||||||
role: {
|
|
||||||
permissions: {
|
|
||||||
some: {
|
|
||||||
permission: {
|
|
||||||
resource: 'users',
|
|
||||||
action: 'delete',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (adminCount === 0) {
|
|
||||||
throw new Error(
|
|
||||||
'Dieser Benutzer ist der letzte Administrator und kann nicht gelöscht werden'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return prisma.user.delete({ where: { id } });
|
return prisma.user.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
@@ -542,17 +612,42 @@ export async function getRoleById(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRole(data: {
|
/**
|
||||||
|
* Legt eine Rolle an.
|
||||||
|
*
|
||||||
|
* `handelnderId` ist Pflichtparameter, nicht optional: So kann ein Controller
|
||||||
|
* die Eskalationspruefung nicht vergessen, und der Compiler erzwingt sie bei
|
||||||
|
* jedem kuenftigen Aufrufer. Bis 09/2026 ging hier `req.body` ungefiltert
|
||||||
|
* durch - wer `users:create` hatte, konnte eine Rolle mit `developer:access`
|
||||||
|
* bauen und sie sich anschliessend selbst zuweisen.
|
||||||
|
*/
|
||||||
|
export async function createRole(
|
||||||
|
data: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
permissionIds: number[];
|
permissionIds: number[];
|
||||||
}) {
|
},
|
||||||
|
handelnderId: number,
|
||||||
|
) {
|
||||||
|
const name = data.name.trim();
|
||||||
|
|
||||||
|
// Kein zweites "Admin". Getrimmt und ohne Ruecksicht auf Gross-/
|
||||||
|
// Kleinschreibung verglichen, sonst liesse sich " admin " anlegen, das in
|
||||||
|
// einer Liste wie das Original aussieht.
|
||||||
|
if (istSystemrollenName(name)) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${name}" ist der Name einer Systemrolle und kann nicht neu vergeben werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pruefeTeilmenge(handelnderId, await rechteVonPermissionIds(data.permissionIds));
|
||||||
|
|
||||||
return prisma.role.create({
|
return prisma.role.create({
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
permissions: {
|
permissions: {
|
||||||
create: data.permissionIds.map((permissionId) => ({ permissionId })),
|
create: [...new Set(data.permissionIds)].map((permissionId) => ({ permissionId })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -563,15 +658,50 @@ export async function createRole(data: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aendert eine Rolle. Systemrollen sind gesperrt.
|
||||||
|
*
|
||||||
|
* Geprueft wird nur der ZUWACHS gegenueber dem bisherigen Rechtesatz der
|
||||||
|
* Rolle - wer Rechte wegnimmt, braucht sie nicht selbst zu besitzen.
|
||||||
|
*/
|
||||||
export async function updateRole(
|
export async function updateRole(
|
||||||
id: number,
|
id: number,
|
||||||
data: {
|
data: {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
permissionIds?: number[];
|
permissionIds?: number[];
|
||||||
}
|
},
|
||||||
|
handelnderId: number,
|
||||||
) {
|
) {
|
||||||
|
const bestehend = await prisma.role.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { permissions: { include: { permission: true } } },
|
||||||
|
});
|
||||||
|
if (!bestehend) return null;
|
||||||
|
|
||||||
|
if (bestehend.isSystem) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${bestehend.name}" ist eine Systemrolle. Sie wird von der Anwendung ` +
|
||||||
|
'gepflegt und kann hier nicht geändert werden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.name !== undefined && istSystemrollenName(data.name)) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${data.name.trim()}" ist der Name einer Systemrolle und kann nicht vergeben werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { permissionIds, ...roleData } = data;
|
const { permissionIds, ...roleData } = data;
|
||||||
|
if (roleData.name !== undefined) roleData.name = roleData.name.trim();
|
||||||
|
|
||||||
|
if (permissionIds) {
|
||||||
|
const bisher = new Set(
|
||||||
|
bestehend.permissions.map((rp) => `${rp.permission.resource}:${rp.permission.action}`),
|
||||||
|
);
|
||||||
|
const kuenftig = await rechteVonPermissionIds(permissionIds);
|
||||||
|
const zuwachs = [...kuenftig].filter((r) => !bisher.has(r));
|
||||||
|
await pruefeTeilmenge(handelnderId, zuwachs);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.role.update({
|
await prisma.role.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -581,28 +711,69 @@ export async function updateRole(
|
|||||||
if (permissionIds) {
|
if (permissionIds) {
|
||||||
await prisma.rolePermission.deleteMany({ where: { roleId: id } });
|
await prisma.rolePermission.deleteMany({ where: { roleId: id } });
|
||||||
await prisma.rolePermission.createMany({
|
await prisma.rolePermission.createMany({
|
||||||
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })),
|
// Dedupliziert: Doppelte IDs im Body liefen vorher in einen
|
||||||
|
// Primaerschluesselkonflikt und kamen als HTTP 500 zurueck.
|
||||||
|
data: [...new Set(permissionIds)].map((permissionId) => ({ roleId: id, permissionId })),
|
||||||
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rechteaenderung wirkt sofort, nicht erst nach Ablauf des Tokens.
|
||||||
|
await meldeTraegerAb(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getRoleById(id);
|
return getRoleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRole(id: number) {
|
export async function deleteRole(id: number) {
|
||||||
// Check if role is assigned to any users
|
const bestehend = await prisma.role.findUnique({ where: { id } });
|
||||||
|
if (!bestehend) {
|
||||||
|
throw new Error('Rolle nicht gefunden');
|
||||||
|
}
|
||||||
|
if (bestehend.isSystem) {
|
||||||
|
throw new RollenSperrError(
|
||||||
|
`„${bestehend.name}" ist eine Systemrolle und kann nicht gelöscht werden.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vor dem Loeschen abmelden - danach sind die Traeger durch den Cascade
|
||||||
|
// nicht mehr ermittelbar. Greift heute nur theoretisch, weil zugewiesene
|
||||||
|
// Rollen ohnehin abgelehnt werden; die Reihenfolge bleibt trotzdem die
|
||||||
|
// richtige, falls diese Sperre je gelockert wird.
|
||||||
const count = await prisma.userRole.count({ where: { roleId: id } });
|
const count = await prisma.userRole.count({ where: { roleId: id } });
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist`
|
`Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await meldeTraegerAb(id);
|
||||||
|
|
||||||
return prisma.role.delete({ where: { id } });
|
return prisma.role.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permission operations
|
// Permission operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die vergebbaren Rechte - angereichert um Klartext und Gruppe.
|
||||||
|
*
|
||||||
|
* Gefiltert auf den Katalog: In der Datenbank koennen Rechte aus frueheren
|
||||||
|
* Schemata liegen (`customers:access`, `settings:create` und aehnliche), die
|
||||||
|
* nirgends geprueft werden. Ungefiltert wuerden sie in der Rollenoberflaeche
|
||||||
|
* als anhakbare Kaestchen erscheinen, die nichts bewirken - genau der
|
||||||
|
* Zustand, den dieser Umbau beseitigt. Geloescht werden sie nicht: Ein
|
||||||
|
* Lesefilter ist die kleinere Behauptung als ein DELETE, und der
|
||||||
|
* Rechte-Report meldet sie ohnehin.
|
||||||
|
*/
|
||||||
export async function getAllPermissions() {
|
export async function getAllPermissions() {
|
||||||
return prisma.permission.findMany({
|
const alle = await prisma.permission.findMany({
|
||||||
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
|
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
|
||||||
});
|
});
|
||||||
|
const beschreibung = new Map(
|
||||||
|
RECHTE_KATALOG.map((r) => [`${r.resource}:${r.action}`, r]),
|
||||||
|
);
|
||||||
|
return alle
|
||||||
|
.filter((p) => beschreibung.has(`${p.resource}:${p.action}`))
|
||||||
|
.map((p) => {
|
||||||
|
const k = beschreibung.get(`${p.resource}:${p.action}`)!;
|
||||||
|
return { ...p, bezeichnung: k.bezeichnung, gruppe: k.gruppe };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,33 @@
|
|||||||
* obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits
|
* obwohl die Fehlermeldung "Dokument vor wenigen Sekunden bereits
|
||||||
* angelegt" eindeutig eine 400-Class-Situation ist.
|
* angelegt" eindeutig eine 400-Class-Situation ist.
|
||||||
*/
|
*/
|
||||||
export class ApiError extends Error {
|
/**
|
||||||
|
* Basisklasse fuer Fehler, deren Wortlaut fuer den AUFRUFER bestimmt ist.
|
||||||
|
*
|
||||||
|
* Der Unterschied ist der Kern von R190-01 und R192-01: Bis 09/2026 reichten
|
||||||
|
* die Controller jede `error.message` durch. Damit landete erst der
|
||||||
|
* vollstaendige Prisma-Aufruf samt Serverpfad beim Client und dann der
|
||||||
|
* Wortlaut eines TypeError. Beide Male dieselbe Ursache - eine Meldung, die
|
||||||
|
* fuer Entwickler geschrieben ist, an einen Empfaenger, fuer den sie nicht
|
||||||
|
* gedacht war.
|
||||||
|
*
|
||||||
|
* Ab hier gilt: Was von dieser Klasse abstammt (oder ein blankes `Error` ist,
|
||||||
|
* das wir absichtlich geworfen haben), geht nach draussen. Alles andere -
|
||||||
|
* TypeError, Prisma, was auch immer - wird protokolliert und durch eine
|
||||||
|
* allgemeine Auskunft ersetzt.
|
||||||
|
*/
|
||||||
|
export class FachlicherFehler extends Error {
|
||||||
readonly statusCode: number;
|
readonly statusCode: number;
|
||||||
constructor(statusCode: number, message: string) {
|
constructor(message: string, statusCode = 400) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = new.target.name;
|
||||||
this.statusCode = statusCode;
|
this.statusCode = statusCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApiError extends FachlicherFehler {
|
||||||
|
constructor(statusCode: number, message: string) {
|
||||||
|
super(message, statusCode);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Eine Stelle, an der entschieden wird, was ein Fehler dem Aufrufer sagt.
|
||||||
|
*
|
||||||
|
* Vorher stand in 26 Controllern 124-mal dieselbe Zeile:
|
||||||
|
*
|
||||||
|
* error: error instanceof Error ? error.message : 'Irgendein Fallback'
|
||||||
|
*
|
||||||
|
* Das reicht JEDE Fehlermeldung durch - auch die, die fuer Entwickler
|
||||||
|
* geschrieben sind. Konkret gefunden wurden ein vollstaendiger Prisma-Aufruf
|
||||||
|
* samt Serverpfad (R190-01) und der Wortlaut eines TypeError (R192-01). Kein
|
||||||
|
* Loch fuer sich genommen, aber eine Landkarte: Pfade, Spaltennamen,
|
||||||
|
* eingesetzte Bibliotheken.
|
||||||
|
*
|
||||||
|
* 124 Einzelkorrekturen waeren die falsche Antwort gewesen - das ist die
|
||||||
|
* Falle aus R186-01 und R188, wo dieselbe Regel in mehreren Kopien lebte und
|
||||||
|
* auseinanderlief. Deshalb eine Funktion.
|
||||||
|
*
|
||||||
|
* Die Unterscheidung laeuft ueber die Fehlerklasse, nicht ueber eine
|
||||||
|
* Heuristik auf dem Text:
|
||||||
|
*
|
||||||
|
* - `FachlicherFehler` (und Abkoemmlinge wie `ApiError`) - absichtlich
|
||||||
|
* geworfen, Wortlaut fuer den Aufrufer, eigener Statuscode.
|
||||||
|
* - blankes `Error` - ebenfalls absichtlich geworfen, historisch die
|
||||||
|
* ueblichste Form ("Der letzte Admin kann nicht..."). Geht mit dem
|
||||||
|
* uebergebenen Status hinaus.
|
||||||
|
* - alles andere (TypeError, RangeError, Prisma...) - Programmierfehler oder
|
||||||
|
* Fremdfehler. Geht als 500 mit der allgemeinen Auskunft hinaus, die
|
||||||
|
* Einzelheiten ins Protokoll. 500 und nicht 400, weil ein
|
||||||
|
* Eingabefehler-Code hier die naechste Meldung waere, die sich falsch
|
||||||
|
* ausgibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { FachlicherFehler } from './apiError.js';
|
||||||
|
|
||||||
|
export function antworteAufFehler(
|
||||||
|
res: Response,
|
||||||
|
error: unknown,
|
||||||
|
fallback: string,
|
||||||
|
status = 400,
|
||||||
|
): void {
|
||||||
|
if (error instanceof FachlicherFehler) {
|
||||||
|
res.status(error.statusCode).json({ success: false, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.constructor === Error) {
|
||||||
|
res.status(status).json({ success: false, error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[${fallback}] unerwarteter Fehler:`, error);
|
||||||
|
res.status(500).json({ success: false, error: fallback });
|
||||||
|
}
|
||||||
@@ -640,6 +640,10 @@ const USER_UPDATABLE_FIELDS = [
|
|||||||
'firstName',
|
'firstName',
|
||||||
'lastName',
|
'lastName',
|
||||||
'isActive',
|
'isActive',
|
||||||
|
// Kennzeichnet planmaessig arbeitende Konten (z. B. das Gegenbuch). Ihre
|
||||||
|
// Anmeldungen werden als Routine statt CRITICAL gefuehrt, dafuer wacht der
|
||||||
|
// Heartbeat-Monitor ueber ihr AUSBLEIBEN (Pentest R182/R183).
|
||||||
|
'isServiceAccount',
|
||||||
'whatsappNumber',
|
'whatsappNumber',
|
||||||
'telegramUsername',
|
'telegramUsername',
|
||||||
'signalNumber',
|
'signalNumber',
|
||||||
@@ -650,6 +654,10 @@ const USER_UPDATABLE_FIELDS = [
|
|||||||
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
// stehen, damit pick() sie nicht aus dem Request entfernt.
|
||||||
'hasGdprAccess',
|
'hasGdprAccess',
|
||||||
'hasDeveloperAccess',
|
'hasDeveloperAccess',
|
||||||
|
// Eingreifende Audit-Rechte (versiegeln/aufraeumen/Aufbewahrung), bewusst
|
||||||
|
// getrennt von hasGdprAccess: Aufsicht und Eingriff sind zwei Rollen
|
||||||
|
// (Pentest R186). Mappt auf die versteckte Rolle 'Audit-Betrieb'.
|
||||||
|
'hasAuditOpsAccess',
|
||||||
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
||||||
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
// Nicht: password – wird über dedizierten Endpoint POST /users/:id/password
|
||||||
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
// gesetzt (Pentest Runde 12 (2026-05-18) – MITTEL: generisches User-Update
|
||||||
@@ -837,6 +845,18 @@ export function pickUserCreate(body: unknown): Partial<Record<string, unknown>>
|
|||||||
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rollen-Whitelist.
|
||||||
|
//
|
||||||
|
// `createRole`/`updateRole` reichten `req.body` bis 09/2026 ungefiltert an
|
||||||
|
// Prisma durch - dasselbe Muster wie R110, nur an der Stelle, an der
|
||||||
|
// festgelegt wird, wer was darf. `isSystem` und `isHidden` duerfen NIEMALS
|
||||||
|
// aus dem Request kommen: Sie sind die Sperre selbst, nicht ihr Gegenstand.
|
||||||
|
const ROLE_UPDATABLE_FIELDS = ['name', 'description', 'permissionIds'] as const;
|
||||||
|
|
||||||
|
export function pickRoleUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||||||
|
return pick((body as object) || {}, ROLE_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ====================
|
// ==================== KATALOG-/CONFIG-WHITELISTS (Pentest R110) ====================
|
||||||
// Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten
|
// Pentest 2026-07-11 (MEDIUM, R110): sieben Update-Endpunkte reichten
|
||||||
// `req.body` ungefiltert an Prisma durch – gleiches Muster wie das
|
// `req.body` ungefiltert an Prisma durch – gleiches Muster wie das
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ services:
|
|||||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m}
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m}
|
||||||
JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d}
|
JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
# Siegel für das Audit-Log: signiert jeden Eintrag mit einem Schlüssel,
|
||||||
|
# der außerhalb der Datenbank liegt. Ohne Wert läuft alles normal weiter,
|
||||||
|
# nur ungesiegelt (fail-safe). AUDIT_HMAC_KEY_OLD nur beim Wechsel füllen
|
||||||
|
# (kommagetrennte Liste früherer Schlüssel).
|
||||||
|
AUDIT_HMAC_KEY: ${AUDIT_HMAC_KEY:-}
|
||||||
|
AUDIT_HMAC_KEY_OLD: ${AUDIT_HMAC_KEY_OLD:-}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
LISTEN_ADDR: 0.0.0.0
|
LISTEN_ADDR: 0.0.0.0
|
||||||
@@ -84,6 +90,34 @@ services:
|
|||||||
# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen)
|
# Auto-aktualisierter BLZ-/Bankdaten-Datensatz (Bundesbank-Bankleitzahlen)
|
||||||
- ${BANKDATA_DIR:-./data/bankdata}:/app/bankdata
|
- ${BANKDATA_DIR:-./data/bankdata}:/app/bankdata
|
||||||
|
|
||||||
|
# Optionaler Reverse-Proxy mit automatischem SSL – nur fuer Betreiber OHNE
|
||||||
|
# eigenen Proxy. Wird standardmaessig NICHT angelegt.
|
||||||
|
#
|
||||||
|
# Einschalten ueber die .env (kein Auskommentieren noetig):
|
||||||
|
# COMPOSE_PROFILES=caddy
|
||||||
|
# Danach reicht wie gewohnt `docker-compose up -d`. Alternativ einmalig per
|
||||||
|
# Flag: `docker-compose --profile caddy up -d`.
|
||||||
|
# Wer nginx / Nginx Proxy Manager / Traefik davor hat, laesst das Feld leer.
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: opencrm-caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["caddy"]
|
||||||
|
depends_on:
|
||||||
|
- opencrm
|
||||||
|
environment:
|
||||||
|
DOMAIN: ${DOMAIN:-localhost}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
# Zertifikate liegen wie alle Daten unter ./data/ – nicht loeschen,
|
||||||
|
# sonst werden bei jedem Start neue Zertifikate angefordert
|
||||||
|
# (Let's-Encrypt-Limits!).
|
||||||
|
- ${CADDY_DIR:-./data/caddy}:/data
|
||||||
|
- ${CADDY_CONFIG_DIR:-./data/caddy-config}:/config
|
||||||
|
|
||||||
adminer:
|
adminer:
|
||||||
image: adminer:latest
|
image: adminer:latest
|
||||||
container_name: opencrm-adminer
|
container_name: opencrm-adminer
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# OpenCRM Docker Environment
|
|
||||||
# ============================
|
|
||||||
# Copy this file to .env and adjust the values
|
|
||||||
|
|
||||||
# Domain (for Caddy SSL certificate)
|
|
||||||
# Use your actual domain, e.g., crm.example.com
|
|
||||||
DOMAIN=localhost
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_ROOT_PASSWORD=change-this-root-password
|
|
||||||
DB_NAME=opencrm
|
|
||||||
DB_USER=opencrm
|
|
||||||
DB_PASSWORD=change-this-password
|
|
||||||
|
|
||||||
# JWT Authentication
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
JWT_SECRET=change-this-to-a-secure-random-string
|
|
||||||
# Access-Token kurz (XSS-Schutz, Token lebt nur im JS-Memory).
|
|
||||||
# Refresh-Token lang im httpOnly-Cookie.
|
|
||||||
JWT_EXPIRES_IN=15m
|
|
||||||
JWT_REFRESH_EXPIRES_IN=7d
|
|
||||||
|
|
||||||
# Encryption Key (for portal credentials)
|
|
||||||
# Generate with: openssl rand -hex 32
|
|
||||||
ENCRYPTION_KEY=change-this-to-a-32-byte-hex-key
|
|
||||||
|
|
||||||
# First Install: Set to "true" to seed database on first startup
|
|
||||||
# After first successful start, set back to "false"
|
|
||||||
RUN_SEED=true
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# OpenCRM Caddyfile
|
|
||||||
# ===================
|
|
||||||
# Replace {$DOMAIN} with your actual domain or use environment variable
|
|
||||||
# For local development without SSL, use localhost:80
|
|
||||||
|
|
||||||
{$DOMAIN:localhost} {
|
|
||||||
# Reverse proxy to OpenCRM app
|
|
||||||
reverse_proxy app:3001
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
log {
|
|
||||||
output stdout
|
|
||||||
format console
|
|
||||||
}
|
|
||||||
|
|
||||||
# Security headers
|
|
||||||
header {
|
|
||||||
# Clickjacking protection
|
|
||||||
X-Frame-Options "SAMEORIGIN"
|
|
||||||
# XSS protection
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
X-XSS-Protection "1; mode=block"
|
|
||||||
# Referrer policy
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Gzip compression
|
|
||||||
encode gzip
|
|
||||||
|
|
||||||
# Handle file uploads (increase body limit)
|
|
||||||
request_body {
|
|
||||||
max_size 50MB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# ===================================
|
|
||||||
# Stage 1: Build Frontend
|
|
||||||
# ===================================
|
|
||||||
FROM node:20-alpine AS frontend-builder
|
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY frontend/package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy frontend source
|
|
||||||
COPY frontend/ ./
|
|
||||||
|
|
||||||
# Build frontend
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# Stage 2: Build Backend
|
|
||||||
# ===================================
|
|
||||||
FROM node:20-alpine AS backend-builder
|
|
||||||
|
|
||||||
WORKDIR /app/backend
|
|
||||||
|
|
||||||
# Install OpenSSL for Prisma
|
|
||||||
RUN apk add --no-cache openssl
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY backend/package*.json ./
|
|
||||||
|
|
||||||
# Install dependencies (including dev for build)
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy backend source
|
|
||||||
COPY backend/ ./
|
|
||||||
|
|
||||||
# Generate Prisma client
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
# Build TypeScript
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# Stage 3: Production Runtime
|
|
||||||
# ===================================
|
|
||||||
FROM node:20-alpine AS production
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install OpenSSL for Prisma runtime and netcat for DB health check
|
|
||||||
RUN apk add --no-cache openssl netcat-openbsd
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S opencrm -u 1001 -G nodejs
|
|
||||||
|
|
||||||
# Copy backend package files
|
|
||||||
COPY backend/package*.json ./
|
|
||||||
|
|
||||||
# Install production dependencies + tsx for seed script
|
|
||||||
RUN npm ci --omit=dev && npm install tsx
|
|
||||||
|
|
||||||
# Copy Prisma schema and generate client
|
|
||||||
COPY backend/prisma ./prisma
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
# Copy built backend
|
|
||||||
COPY --from=backend-builder /app/backend/dist ./dist
|
|
||||||
|
|
||||||
# Copy built frontend to public directory
|
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
|
||||||
|
|
||||||
# Copy seed file for factory reset
|
|
||||||
COPY backend/prisma/seed.ts ./prisma/
|
|
||||||
|
|
||||||
# Copy entrypoint script
|
|
||||||
COPY docker/entrypoint.sh ./entrypoint.sh
|
|
||||||
|
|
||||||
# Create directories for uploads and backups
|
|
||||||
RUN mkdir -p uploads backups && \
|
|
||||||
chmod +x entrypoint.sh && \
|
|
||||||
chown -R opencrm:nodejs /app
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER opencrm
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3001
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3001/api/health || exit 1
|
|
||||||
|
|
||||||
# Start the application with entrypoint
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# OpenCRM Docker Deployment
|
|
||||||
|
|
||||||
## Schnellstart
|
|
||||||
|
|
||||||
1. **Umgebungsvariablen konfigurieren:**
|
|
||||||
```bash
|
|
||||||
cd docker
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env # Sichere Werte setzen
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Container starten (erster Start mit RUN_SEED=true):**
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Beim ersten Start wird automatisch:
|
|
||||||
- Auf die Datenbank gewartet
|
|
||||||
- Migrationen ausgeführt
|
|
||||||
- Seed-Daten geladen (wenn `RUN_SEED=true`)
|
|
||||||
|
|
||||||
3. **Nach erfolgreicher Installation:**
|
|
||||||
```bash
|
|
||||||
# RUN_SEED in .env auf false setzen
|
|
||||||
sed -i 's/RUN_SEED=true/RUN_SEED=false/' .env
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Anwendung aufrufen:**
|
|
||||||
- Mit Domain: `https://your-domain.com`
|
|
||||||
- Lokal: `http://localhost`
|
|
||||||
|
|
||||||
5. **Login:**
|
|
||||||
- E-Mail: `admin@admin.com`
|
|
||||||
- Passwort: `admin`
|
|
||||||
|
|
||||||
## Architektur
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐
|
|
||||||
│ Caddy │
|
|
||||||
│ (SSL/TLS) │
|
|
||||||
│ :80/:443 │
|
|
||||||
└──────┬──────┘
|
|
||||||
│
|
|
||||||
┌──────▼──────┐
|
|
||||||
│ OpenCRM │
|
|
||||||
│ (Node.js) │
|
|
||||||
│ :3001 │
|
|
||||||
└──────┬──────┘
|
|
||||||
│
|
|
||||||
┌──────▼──────┐
|
|
||||||
│ MariaDB │
|
|
||||||
│ :3306 │
|
|
||||||
└─────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Befehle
|
|
||||||
|
|
||||||
### Container verwalten
|
|
||||||
```bash
|
|
||||||
# Starten
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Stoppen
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Logs anzeigen
|
|
||||||
docker compose logs -f app
|
|
||||||
|
|
||||||
# Neustart
|
|
||||||
docker compose restart app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Datenbank
|
|
||||||
```bash
|
|
||||||
# Migration ausführen
|
|
||||||
docker compose exec app npx prisma migrate deploy
|
|
||||||
|
|
||||||
# Seed-Daten laden
|
|
||||||
docker compose exec app npx tsx prisma/seed.ts
|
|
||||||
|
|
||||||
# Prisma Studio (Datenbank-UI)
|
|
||||||
docker compose exec app npx prisma studio
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backup & Restore
|
|
||||||
```bash
|
|
||||||
# Backup-Verzeichnis ist unter /app/backups gemountet
|
|
||||||
# Backups werden über die Anwendung erstellt/wiederhergestellt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update
|
|
||||||
```bash
|
|
||||||
# Image neu bauen und Container aktualisieren
|
|
||||||
docker compose build --no-cache
|
|
||||||
docker compose up -d
|
|
||||||
# Migrationen werden automatisch beim Start ausgeführt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Volumes
|
|
||||||
|
|
||||||
| Volume | Beschreibung |
|
|
||||||
|--------|--------------|
|
|
||||||
| `mariadb_data` | Datenbank-Dateien |
|
|
||||||
| `uploads_data` | Hochgeladene Dokumente |
|
|
||||||
| `backups_data` | Backup-Dateien |
|
|
||||||
| `caddy_data` | SSL-Zertifikate |
|
|
||||||
| `caddy_config` | Caddy-Konfiguration |
|
|
||||||
|
|
||||||
## SSL-Zertifikat
|
|
||||||
|
|
||||||
Caddy holt automatisch ein Let's Encrypt Zertifikat wenn:
|
|
||||||
- Die Domain in `.env` korrekt gesetzt ist
|
|
||||||
- Port 80 und 443 von außen erreichbar sind
|
|
||||||
- DNS auf den Server zeigt
|
|
||||||
|
|
||||||
Für lokale Entwicklung mit `DOMAIN=localhost` wird ein selbstsigniertes Zertifikat verwendet.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container startet nicht
|
|
||||||
```bash
|
|
||||||
docker compose logs app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Datenbank-Verbindung fehlgeschlagen
|
|
||||||
```bash
|
|
||||||
# Warten bis MariaDB bereit ist
|
|
||||||
docker compose logs db
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSL-Zertifikat Probleme
|
|
||||||
```bash
|
|
||||||
docker compose logs caddy
|
|
||||||
# Caddy-Daten zurücksetzen
|
|
||||||
docker compose down
|
|
||||||
docker volume rm opencrm_caddy_data opencrm_caddy_config
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
# ===================================
|
|
||||||
# MariaDB Database
|
|
||||||
# ===================================
|
|
||||||
db:
|
|
||||||
image: mariadb:10.11
|
|
||||||
container_name: opencrm-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-rootpassword}
|
|
||||||
MYSQL_DATABASE: ${DB_NAME:-opencrm}
|
|
||||||
MYSQL_USER: ${DB_USER:-opencrm}
|
|
||||||
MYSQL_PASSWORD: ${DB_PASSWORD:-opencrm123}
|
|
||||||
volumes:
|
|
||||||
- mariadb_data:/var/lib/mysql
|
|
||||||
networks:
|
|
||||||
- opencrm-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
|
||||||
start_period: 10s
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# OpenCRM Application
|
|
||||||
# ===================================
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
container_name: opencrm-app
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
PORT: 3001
|
|
||||||
DATABASE_URL: mysql://${DB_USER:-opencrm}:${DB_PASSWORD:-opencrm123}@db:3306/${DB_NAME:-opencrm}
|
|
||||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
|
||||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}
|
|
||||||
RUN_SEED: ${RUN_SEED:-false}
|
|
||||||
volumes:
|
|
||||||
- uploads_data:/app/uploads
|
|
||||||
- backups_data:/app/backups
|
|
||||||
networks:
|
|
||||||
- opencrm-network
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/api/health"]
|
|
||||||
start_period: 60s
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
# ===================================
|
|
||||||
# Caddy Reverse Proxy (with auto SSL)
|
|
||||||
# ===================================
|
|
||||||
caddy:
|
|
||||||
image: caddy:2-alpine
|
|
||||||
container_name: opencrm-caddy
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
||||||
- caddy_data:/data
|
|
||||||
- caddy_config:/config
|
|
||||||
networks:
|
|
||||||
- opencrm-network
|
|
||||||
depends_on:
|
|
||||||
app:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
networks:
|
|
||||||
opencrm-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mariadb_data:
|
|
||||||
uploads_data:
|
|
||||||
backups_data:
|
|
||||||
caddy_data:
|
|
||||||
caddy_config:
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== OpenCRM Startup ==="
|
|
||||||
|
|
||||||
# Wait for database to be ready
|
|
||||||
echo "Waiting for database connection..."
|
|
||||||
MAX_RETRIES=30
|
|
||||||
RETRY_COUNT=0
|
|
||||||
|
|
||||||
while ! nc -z db 3306 2>/dev/null; do
|
|
||||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
|
||||||
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
|
|
||||||
echo "Error: Database not available after $MAX_RETRIES attempts"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " Attempt $RETRY_COUNT/$MAX_RETRIES - waiting..."
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Database is ready!"
|
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
echo "Running database migrations..."
|
|
||||||
npx prisma migrate deploy
|
|
||||||
|
|
||||||
# Seed database if RUN_SEED is set (first install)
|
|
||||||
if [ "$RUN_SEED" = "true" ]; then
|
|
||||||
echo "Seeding database..."
|
|
||||||
npx tsx prisma/seed.ts
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Einmal-Bereinigung für Pentest-Reste (Runde 12): XSS-Strings aus
|
|
||||||
# Customer/User-Feldern entfernen + unbekannte AppSettings löschen.
|
|
||||||
# Idempotent – läuft bei jedem Container-Start ohne Risiko.
|
|
||||||
echo "Running data cleanup..."
|
|
||||||
npx tsx prisma/cleanup-xss-and-mass-assignment.ts || echo " (Cleanup übersprungen, nicht-kritisch)"
|
|
||||||
|
|
||||||
# Start the application
|
|
||||||
echo "Starting OpenCRM server..."
|
|
||||||
exec node dist/index.js
|
|
||||||
+1856
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