Compare commits
188
Commits
No files matched your search
@@ -0,0 +1,69 @@
|
|||||||
|
name: Backend Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*.*.*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: docker-builder
|
||||||
|
env:
|
||||||
|
# Nur fuer Doku/OCI-Labels, siehe REGISTRY_PUSH fuer den eigentlichen Push.
|
||||||
|
REGISTRY: git.encry.de
|
||||||
|
# Cluster-interner Service-Name statt git.encry.de: Registry-Blob-
|
||||||
|
# Uploads (PUT) vertragen sich nicht mit dem Cloudflare-Proxy vor
|
||||||
|
# git.encry.de ("413 Payload Too Large" unabhaengig von der Groesse).
|
||||||
|
# Der Runner-Pod erreicht den Gitea-Service direkt ueber Kubernetes-DNS.
|
||||||
|
# Push und Pull landen im selben Registry-Pfad (Owner/Repo:Tag), der
|
||||||
|
# Hostname ist nur die Verbindung, daher bleibt der Pull in den
|
||||||
|
# k8s-Manifesten unveraendert ueber git.encry.de.
|
||||||
|
REGISTRY_PUSH: gitea:80
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
|
# der Gitea-Owner-Name behaelt Gross-/Kleinschreibung bei.
|
||||||
|
id: image
|
||||||
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-backend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
|
- name: Docker Buildx einrichten
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
|
config-inline: |
|
||||||
|
[registry."gitea:80"]
|
||||||
|
http = true
|
||||||
|
|
||||||
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
# GITEA_TOKEN hat kein Package-Write-Recht — separates Personal
|
||||||
|
# Access Token (Scope "package") als Secret REGISTRY_TOKEN anlegen.
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,format=short
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Image bauen und pushen
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./ApiCore-Backend
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Frontend Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*.*.*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: docker-builder
|
||||||
|
env:
|
||||||
|
# Nur fuer Doku/OCI-Labels, siehe REGISTRY_PUSH fuer den eigentlichen Push.
|
||||||
|
REGISTRY: git.encry.de
|
||||||
|
# Cluster-interner Service-Name statt git.encry.de: Registry-Blob-
|
||||||
|
# Uploads (PUT) vertragen sich nicht mit dem Cloudflare-Proxy vor
|
||||||
|
# git.encry.de ("413 Payload Too Large" unabhaengig von der Groesse).
|
||||||
|
# Der Runner-Pod erreicht den Gitea-Service direkt ueber Kubernetes-DNS.
|
||||||
|
# Push und Pull landen im selben Registry-Pfad (Owner/Repo:Tag), der
|
||||||
|
# Hostname ist nur die Verbindung, daher bleibt der Pull in den
|
||||||
|
# k8s-Manifesten unveraendert ueber git.encry.de.
|
||||||
|
REGISTRY_PUSH: gitea:80
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Image-Namen in Kleinbuchstaben ermitteln
|
||||||
|
# OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad;
|
||||||
|
# der Gitea-Owner-Name behaelt Gross-/Kleinschreibung bei.
|
||||||
|
id: image
|
||||||
|
run: echo "name=$(echo '${{ gitea.repository_owner }}/apicore-frontend' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
|
- name: Docker Buildx einrichten
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
# REGISTRY_PUSH ist Klartext-HTTP (Gitea-Service intern ohne TLS)
|
||||||
|
# statt HTTPS — BuildKit lehnt das ohne diese Freigabe ab.
|
||||||
|
config-inline: |
|
||||||
|
[registry."gitea:80"]
|
||||||
|
http = true
|
||||||
|
|
||||||
|
- name: Bei der Gitea-Container-Registry anmelden
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_PUSH }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
# GITEA_TOKEN hat kein Package-Write-Recht — separates Personal
|
||||||
|
# Access Token (Scope "package") als Secret REGISTRY_TOKEN anlegen.
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Metadaten (Tags/Labels) ermitteln
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,format=short
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Image bauen und pushen
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./ApiCore-Frontend
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
+54
-11
@@ -26,6 +26,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
|
|||||||
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
|
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
|
||||||
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
|
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
|
||||||
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
|
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
|
||||||
|
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
|
||||||
|
|
||||||
Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig.
|
Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig.
|
||||||
|
|
||||||
@@ -59,11 +60,11 @@ Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein di
|
|||||||
|
|
||||||
## 3. Auth-Flow
|
## 3. Auth-Flow
|
||||||
|
|
||||||
1. Login (`POST /api/v1/auth/login`) mit E-Mail/Passwort → Access-Token (kurzlebig, ~15 min, JWT) + Refresh-Token (langlebig, ~7 Tage, in DB als Hash gespeichert, rotierend).
|
1. Login (`POST /api/v1/auth/login`) mit E-Mail/Passwort → Access-Token (15 min, JWT HS256, `iss=apicore`, `aud=apicore:access`) + Refresh-Token (in DB als Hash, rotierend). Bei aktiver MFA zuerst ein Zwischen-Token (`aud=apicore:mfa-challenge`), das nie als Access-Token gilt.
|
||||||
2. Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie.
|
2. Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie (`Secure`, `SameSite=Lax`, Pfad `/api/v1/auth`).
|
||||||
3. Bei 401 automatischer Refresh über `POST /api/v1/auth/refresh`, danach Retry.
|
3. Bei 401 automatischer Refresh über `POST /api/v1/auth/refresh`, danach Retry. Rotation atomar; Wiederverwendung eines alten Tokens (nach 10 s Gnadenfrist für parallele Tabs) beendet alle Sitzungen. Spätestens `SESSION_MAX_AGE` (7 Tage) nach dem Login ist neu anzumelden.
|
||||||
4. Jeder geschützte Endpunkt: `JwtAuthGuard` (Authentifizierung) + `RolesGuard` (Autorisierung nach Rolle aus Abschnitt 3).
|
4. Guards in dieser Reihenfolge: `ThrottlerGuard` → `JwtAuthGuard` (Authentifizierung, Gültigkeit frisch aus der DB je Anfrage) → `PermissionsGuard` (Berechtigung, fail-closed).
|
||||||
5. API-Client-Rolle nutzt dieselben JWT-Endpunkte mit client-credentials-artigem Service-Account (kein OAuth2-Server im MVP, siehe Annahmen).
|
5. Maschinen-Zugriff (Wabenhain) nur per API-Schlüssel mit Scope-Allowlist und optionaler IP-Allowlist, siehe Annahme 14 und Abschnitt 9.
|
||||||
|
|
||||||
## 4. Datenfluss / Rückverfolgbarkeit
|
## 4. Datenfluss / Rückverfolgbarkeit
|
||||||
|
|
||||||
@@ -96,19 +97,61 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
|
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
|
||||||
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
|
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
|
||||||
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
|
||||||
|
8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern.
|
||||||
|
9. **M365-Kalender-Synchronisation als einseitiger, asynchroner Push**: Abschnitt 4.25 verlangt nur einen internen Betriebskalender, keine externe Anbindung – diese Funktion ist eine reine Erweiterung ohne Lastenheft-Vorgabe. Umgesetzt als `CalendarSyncModule` (`ApiCore-Backend/src/calendar-sync/`): App-only Graph-API-Zugriff per Client-Credentials-Flow (`GraphClientService`) statt delegierter Anmeldung, da die Synchronisation unbeaufsichtigt im Hintergrund laufen muss und nicht an eine interaktive Benutzersitzung gebunden sein darf. Nutzt bewusst dieselbe Entra-App-Registrierung wie das SSO-Login (`OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, ausschließlich Env-Variablen) statt einer zweiten, separaten App-Registrierung – ein Enterprise-App-Objekt im Tenant genügt, sofern es zusätzlich zu den delegierten Login-Scopes auch die Application-Permission „Calendars.ReadWrite“ (Admin-Consent) erhält; die Tenant-ID wird dafür aus dem Pfad von `OIDC_ISSUER_URL` abgeleitet, statt eine weitere Konfigurationsvariable zu benötigen. Die Übertragung ist bewusst nur einseitig (ApiCore → M365-Gruppenkalender, adressiert über `groupId`); Änderungen, die direkt im M365-Kalender vorgenommen werden, fließen nicht zurück. Statt eines synchronen Push im Request-Pfad (wie beim Webhooks-Modul) nutzt `CalendarSyncOutboxEntry` ein Outbox-Muster mit minütlichem `@Cron`-Worker: Graph-API-Aufrufe sind zu langsam/fehleranfällig (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/Aufgaben-Erstellung darauf warten zu lassen; fehlgeschlagene Versuche werden bis zu 5-mal wiederholt, danach bleibt der Eintrag `FAILED` bis zu einem manuellen "Jetzt synchronisieren" oder einer erneuten Änderung des Quelldatensatzes. Die Outbox ist sicher bei mehreren Replicas: Jeder Eintrag wird vor der Arbeit per bedingtem Update (`claimedAt`) reserviert, ein Claim älter als 5 min gilt als verwaist und wird übernommen, Graph-Requests haben ein 30-s-Timeout. `revision` sorgt dafür, dass eine Änderung während des Syncs nicht verloren geht (Eintrag bleibt PENDING, dasselbe Event wird beim nächsten Lauf aktualisiert); ein Abbruch während des Syncs löscht das gerade geschriebene Event. Ein neues Graph-Event entsteht beim Aktualisieren nur, wenn das alte nicht mehr existiert (404/410); andere Fehler gelten als fehlgeschlagener Versuch. Jedes Anlegen schickt eine `transactionId` (aus Eintrag, Revision und ersetztem Event), damit Graph eine Wiederholung nach einem Timeout nicht doppelt anlegt; „Jetzt synchronisieren“ startet den Lauf im Hintergrund (202, `running` im Status) und setzt FAILED-Einträge auf PENDING zurück. `groupId` muss eine Entra-Objekt-ID (GUID) sein; `""` oder `null` löscht sie; sie wird in Graph-URLs kodiert. Fehler aus Prisma werden nur mit Name, Code und Modell geloggt (`src/common/loggable-error.ts`), nie mit Abfragewerten. **Offene Entscheidung (2026-10-12):** Laut Microsoft-Doku (`group-post-events`) unterstützt `POST /groups/{id}/events` keine Application-Permission, nur delegiertes `Group.ReadWrite.All`. Mit dem App-only-Token oben kann das Anlegen im Gruppenkalender deshalb in Produktion scheitern (nicht gegen einen echten Tenant geprüft). Auswege: Zielkalender auf ein Ressourcen- oder Benutzerpostfach umstellen (`/users/{upn}/calendar/events`, App-only erlaubt, per Application Access Policy begrenzt) oder delegierter Zugriff über ein Dienstkonto. Pro Quelltyp (Aufgabe/Durchsicht/Behandlung/Fütterung/Ernte/Königinnen-Zuordnung) ist die Synchronisation einzeln über die GUI (Verwaltung > Automatisierung > Kalender-Sync) abschaltbar; Kalendereinträge werden als ganztägige Graph-Events (`isAllDay: true`) angelegt, um Zeitzonen-/Uhrzeit-Mehrdeutigkeiten zu vermeiden, da die zugrundeliegenden Fachdaten selbst keine Uhrzeit führen.
|
||||||
|
10. **Wartezeit manuell statt aus Wirkstoff-Datenbank abgeleitet**: `Treatment.withdrawalPeriodDays` wird pro Behandlung von Hand eingetragen (laut Packungsbeilage des verwendeten Mittels), statt automatisch aus Produktname/Wirkstoff hergeleitet zu werden. Eine hartkodierte Zuordnung Produkt→Wartezeit wäre eine faktische, rechtlich relevante Behauptung über Tierarzneimittel-Zulassungen, die im Code weder aktuell gehalten noch verlässlich verifiziert werden kann; die Verantwortung für den korrekten Wert bleibt beim Imker. Die App warnt in der Ernte-Erfassung nur, wenn ein Wert hinterlegt wurde und dessen Fenster (`endDate ?? date` + `withdrawalPeriodDays`) noch nicht abgelaufen ist – bezogen auf das Erntedatum (`GET /treatments/withdrawal-status?colonyIds=…&at=JJJJ-MM-TT`, ohne `at` gilt jetzt; Behandlungen, die erst nach diesem Datum beginnen, zählen nicht) – sie blockiert die Ernte nicht hart, da Ernte-Datensätze auch nachträglich für bereits erfolgte, reale Ernten erfasst werden.
|
||||||
|
|
||||||
|
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`).
|
||||||
|
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt.
|
||||||
|
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
|
||||||
|
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
|
||||||
|
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
|
||||||
|
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
|
||||||
|
|
||||||
|
17. **Schutz gegen Rechteausweitung in der Benutzerverwaltung**: Wer Benutzer pflegt, darf nur Konten verwalten (Passwort/MFA zurücksetzen, E-Mail ändern, (de)aktivieren) und Rollen vergeben, deren Rechte er selbst vollständig hat (`UsersService.assertWithinCallerPermissions`), analog zur Rollenbearbeitung.
|
||||||
|
18. **SSO-Bindung an die IdP-Identität**: Die erste Microsoft-Anmeldung bindet das Konto an `"<iss>|<oid>"` (`User.ssoSubject`); danach zählt nur noch diese Identität, nicht der unverifizierte E-Mail-Claim. Mandantenübergreifende Issuer (`common`/`organizations`/`consumers`) schalten SSO ab. E-Mails werden ohne Groß-/Kleinschreibung verglichen.
|
||||||
|
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel `BACKUP_ENCRYPTION_KEY` (sonst per HKDF aus `JWT_ACCESS_SECRET`). Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). Webhook-Secrets liegen verschlüsselt in der DB (Schlüssel aus `JWT_ACCESS_SECRET`) – für eine Wiederherstellung in einem anderen Deployment mit anderem `JWT_ACCESS_SECRET` müssen Webhook-Secrets und MFA neu eingerichtet werden.
|
||||||
|
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`.
|
||||||
|
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
|
||||||
|
|
||||||
## 7. Kubernetes
|
## 7. Kubernetes
|
||||||
|
|
||||||
`deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`).
|
`deploy/k8s/` enthält je Projekt: `deployment.yaml`, `service.yaml`, `ingress.yaml`, sowie `configmap.example.yaml` und `secret.example.yaml` (Platzhalter, keine echten Werte). Backend exponiert `GET /health` (liveness) und `GET /health/ready` (readiness, prüft DB-Verbindung). Beide Projekte je ein schlankes Multi-Stage-Dockerfile (Build-Stage mit vollem `node_modules`, Runtime-Stage mit `npm ci --omit=dev` bzw. Next.js `output: 'standalone'`).
|
||||||
|
|
||||||
|
`deploy/k8s/postgres/` enthält das PostgreSQL-`StatefulSet` (ein Replica, `volumeClaimTemplates` für persistente Daten, `postgres:18-alpine`), den zugehörigen headless `Service` (`apicore-postgres`, DNS-Name fürs Backend) sowie `secret.example.yaml` für `POSTGRES_USER`/`POSTGRES_PASSWORD`/`POSTGRES_DB`. `deploy/k8s/backend/secret.example.yaml`s `DATABASE_URL` zeigt auf diesen Service.
|
||||||
|
|
||||||
## 8. Backup & Restore
|
## 8. Backup & Restore
|
||||||
|
|
||||||
**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. `migrate deploy` läuft als eigener Schritt vor dem Rollout (z. B. Kubernetes-`Job` oder manueller Schritt in der Deploy-Pipeline), nicht im `CMD` des Runtime-Images, damit mehrere Pod-Replicas nicht gleichzeitig Migrationen ausführen.
|
**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. Der Backend-Container führt `migrate deploy` und danach `npm run seed` selbst beim Start aus, bevor der Server Verbindungen annimmt (Dockerfile-`CMD`: `npm run migrate:deploy && npm run seed && node dist/main.js`) – kein separater Kubernetes-`Job`. Bei `replicas: 2` starten mehrere Pods potenziell gleichzeitig; das ist unproblematisch, da Prisma für `migrate deploy` einen datenbankseitigen Advisory-Lock nutzt und `prisma/seed.ts` per Unique-Constraint auf `User.email` idempotent ist (verlierender Pod bekommt einen `P2002`-Fehler, den `seed.ts` abfängt und als No-op behandelt statt den Container abstürzen zu lassen) – eine Instanz wendet die Migrationen/den Seed an, die anderen finden danach nichts mehr zu tun. `deploy/k8s/backend/deployment.yaml` hat dafür eine `startupProbe` mit großzügigem `failureThreshold`, damit liveness/readiness den Container nicht neu starten, während die (beim allerersten Deploy ggf. länger laufende) Migration noch durchläuft.
|
||||||
|
|
||||||
**Datensicherung:** Die PostgreSQL-Instanz selbst ist bestehende Cluster-Infrastruktur (siehe CLAUDE.md, "keine eigene DB-Infrastruktur"); Backup-Ausführung liegt beim Cluster-/DBA-Betrieb. Empfohlene Strategie dort:
|
**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `[email protected]`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren.
|
||||||
|
|
||||||
- Tägliches logisches Backup via `pg_dump -Fc` (custom format, komprimiert, für `pg_restore` geeignet), Aufbewahrung nach Retention-Policy des Clusters.
|
**Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Umgesetzt als anwendungsseitiges `BackupModule` (`ApiCore-Backend/src/backup/`) statt der ursprünglich hier vorgesehenen reinen `pg_dump`/PITR-Strategie:
|
||||||
- Kontinuierliches WAL-Archiving/PITR (Point-in-Time Recovery), falls vom Cluster unterstützt, für Wiederherstellung auf einen beliebigen Zeitpunkt zwischen den täglichen Dumps.
|
|
||||||
- Restore-Test: `pg_restore` eines Dumps in eine isolierte Datenbank, danach `npx prisma migrate deploy` zur Kontrolle, dass der Migrationsstand konsistent ist (`prisma migrate status`).
|
|
||||||
|
|
||||||
**Restore-Ablauf (Kurzfassung):** 1) Zielinstanz/-datenbank bereitstellen. 2) `pg_restore` (oder WAL-Replay bis zum Zielzeitpunkt bei PITR). 3) `npx prisma migrate status` prüfen. 4) `npx prisma migrate deploy` falls seit dem Dump neue Migrationen hinzugekommen sind. 5) `GET /health/ready` gegen die wiederhergestellte Instanz aufrufen, um DB-Erreichbarkeit zu bestätigen, bevor Verkehr umgeschaltet wird.
|
- **Export/Import:** `BackupService.exportAll()`/`.importAll()` sichert alle fachlichen und Konfigurationsmodelle (Betriebsdaten inkl. Herstellung, Rohstoffe, Bewegungsbuch und Shop-Anfragen, Nummernkreise, Benutzer/Rollen/Rechte, Audit-Log, Website-/Backup-/Kalender-Einstellungen samt Kalender-Outbox, hochgeladene Dokumente inkl. Dateiinhalt als Base64) als eine versionierte JSON-Datei (Bundle-Version 3; ältere Bundles bleiben einspielbar). Bewusst ausgenommen sind nur Permission (Katalog aus dem Code), RefreshToken, ConsumedToken, ApiKey (Annahme 14) und IdempotencyRecord (Annahme 11); die Liste mit Begründung steht in `MODELS_EXCLUDED_FROM_BACKUP`, ein Test erzwingt sie für jedes neue Prisma-Modell. Import ist upsert-basiert (Rollen/Berechtigungen über `key`/`resource+action` statt roher `id`, damit ein Restore auch in ein anderes Deployment funktioniert) und läuft in einer einzigen `$transaction`.
|
||||||
|
- **Automatischer Zeitplan:** `BackupSchedulerService` registriert einen `@nestjs/schedule`-Cron-Job, der die Bundle-Datei periodisch auf `BACKUP_STORAGE_PATH` schreibt und alte Stände nach `BACKUP_RETENTION_COUNT` rotiert. Pfad/Zeitplan/Aufbewahrung sind sowohl per Env-Variable als auch über die GUI (Verwaltung > Backup) konfigurierbar – eine gesetzte Env-Variable hat dabei immer Vorrang vor dem GUI-Wert. Bei `replicas: 2` verhindert ein Datei-Mtime-Guard (kein DB-Lock nötig), dass beide Pods im selben Tick doppelt sichern; ohne gesetzten Pfad bleibt der Zeitplan inaktiv, manueller Export/Import über die UI funktioniert davon unabhängig immer.
|
||||||
|
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen. Import-Limit `BACKUP_MAX_IMPORT_BYTES` (Standard 50 MB, höchstens 256 MB, weil der Upload vollständig im RAM des 512-Mi-Pods verarbeitet wird); Export und Zeitplan prüfen jede Sicherung dagegen und warnen in Log und UI (Verwaltung > Backup), bevor eine Datei entsteht, die sich im Notfall nicht mehr einspielen ließe.
|
||||||
|
|
||||||
|
Ein rohes `pg_dump`/PITR-Backup auf Infrastrukturebene bleibt als zusätzliche, komplementäre Absicherung für den Katastrophenfall (z. B. korrupte Datenbankdateien, nicht nur fachliche Daten) sinnvoll, ist aber nicht mehr der primäre, hier dokumentierte Mechanismus – die in-App-Lösung deckt den fachlichen Anwendungsfall "Daten sichern/übertragen" bereits vollständig ab.
|
||||||
|
|
||||||
|
## 9. API-Sicherheit (Stand Oktober 2026)
|
||||||
|
|
||||||
|
Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung und Begründung je Punkt in `docs/pruefung-2026-09.md`, Abschnitt „API-Sicherheit“.
|
||||||
|
|
||||||
|
| Bereich | Maßnahme |
|
||||||
|
|---|---|
|
||||||
|
| Authentifizierung | JWT mit festem Algorithmus, Issuer und Audience je Zweck; MFA (TOTP) mit Replay-Schutz; Kontosperre 15 min nach 5 Fehlversuchen; gleiche Antwort für unbekannte/gesperrte Konten; bcrypt auf jedem Pfad |
|
||||||
|
| Passwörter | mind. 12 Zeichen, max. 72 Byte, Abgleich mit Have I Been Pwned (k-Anonymität, abschaltbar per `PASSWORD_BREACH_CHECK=false`) |
|
||||||
|
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
|
||||||
|
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
|
||||||
|
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
|
||||||
|
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
|
||||||
|
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
|
||||||
|
| Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
|
||||||
|
| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
|
||||||
|
| Daten | Backups verschlüsselt und signiert, Dateien 0600; CSV-Export ohne Formel-Injection; Logs ohne Query-Strings |
|
||||||
|
|
||||||
|
**Bekannte Restpunkte (bewusst nicht umgesetzt):**
|
||||||
|
|
||||||
|
- Rate-Limits zählen je Pod im Speicher; bei 2 Replikas verdoppelt sich das Limit. Brute-Force-Schutz hängt nicht daran, sondern an der Kontosperre in der DB. Ein gemeinsamer Speicher (Redis) wäre der nächste Schritt, falls Lastspitzen ein Thema werden.
|
||||||
|
- Eine E-Mail-Änderung im eigenen Profil meldet „bereits vergeben“, wenn die Adresse existiert. Das erfordert eine Anmeldung plus aktuelles Passwort; eine generische Meldung würde Nutzer nur verwirren.
|
||||||
@@ -2,13 +2,29 @@
|
|||||||
|
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
DATABASE_URL="postgresql://user:password@localhost:5432/apicore?schema=public"
|
||||||
|
|
||||||
JWT_ACCESS_SECRET="change-me-access-secret"
|
# Mindestens 32 Zeichen (server-seitig erzwungen, siehe main.ts) - signiert
|
||||||
|
# jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte TOTP-Secrets.
|
||||||
|
# Erzeugen z.B. mit: openssl rand -hex 32
|
||||||
|
JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
||||||
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
||||||
JWT_ACCESS_EXPIRES_IN="15m"
|
JWT_ACCESS_EXPIRES_IN="15m"
|
||||||
JWT_REFRESH_EXPIRES_IN="7d"
|
JWT_REFRESH_EXPIRES_IN="7d"
|
||||||
|
# Hard limit from login, however often the session is refreshed.
|
||||||
|
SESSION_MAX_AGE="7d"
|
||||||
|
# New passwords are checked against known breaches (Have I Been Pwned,
|
||||||
|
# k-anonymity: only 5 hash characters leave the server). "false" turns the
|
||||||
|
# check off for deployments without internet access.
|
||||||
|
# PASSWORD_BREACH_CHECK=true
|
||||||
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
FRONTEND_ORIGIN="http://localhost:3000"
|
FRONTEND_ORIGIN="http://localhost:3000"
|
||||||
|
# Reverse-proxy hops in front of the backend whose X-Forwarded-For is
|
||||||
|
# trusted (client IP for rate limits and API-key IP allowlists). "1" for the
|
||||||
|
# single K8s ingress; a list such as "loopback, 10.0.0.0/8" also works.
|
||||||
|
# Behind Cloudflare + Traefik use "loopback, <pod CIDR>, <Cloudflare ranges>"
|
||||||
|
# (see deploy/k8s/backend/configmap.example.yaml); "1" would yield the
|
||||||
|
# Cloudflare edge IP that many clients share.
|
||||||
|
TRUST_PROXY=1
|
||||||
|
|
||||||
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
|
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
|
||||||
# local HTTP development - every real deployment must leave this unset (or
|
# local HTTP development - every real deployment must leave this unset (or
|
||||||
@@ -19,3 +35,52 @@ COOKIE_SECURE=false
|
|||||||
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes
|
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes
|
||||||
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
|
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
|
||||||
DOCUMENTS_STORAGE_PATH="./uploads"
|
DOCUMENTS_STORAGE_PATH="./uploads"
|
||||||
|
|
||||||
|
# Automatische Backups (Modul Backup). Alle drei Werte sind auch ueber die
|
||||||
|
# GUI (Verwaltung > Backup) konfigurierbar - eine hier gesetzte Env-Variable
|
||||||
|
# hat aber IMMER Vorrang vor dem GUI-Wert, pro Feld einzeln. Ohne Pfad
|
||||||
|
# (weder Env noch GUI) bleibt der Zeitplan deaktiviert (nur manueller
|
||||||
|
# Export/Import ueber die UI verfuegbar).
|
||||||
|
# Bei horizontaler Skalierung (>1 Replica) muss der Pfad auf ein geteiltes
|
||||||
|
# Volume zeigen (wie DOCUMENTS_STORAGE_PATH), sonst sieht jede Replica nur
|
||||||
|
# ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift
|
||||||
|
# nicht.
|
||||||
|
BACKUP_STORAGE_PATH="./backups"
|
||||||
|
# Directory a backup path set in the admin UI must stay under (the env
|
||||||
|
# variable above is not restricted). Default "./backups".
|
||||||
|
# BACKUP_ROOT="./backups"
|
||||||
|
# Cron-Ausdruck (Standard: taeglich um 03:00 Uhr).
|
||||||
|
BACKUP_CRON_SCHEDULE="0 3 * * *"
|
||||||
|
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||||
|
# jedem Lauf geloescht (Standard: 14).
|
||||||
|
BACKUP_RETENTION_COUNT=14
|
||||||
|
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
|
||||||
|
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
|
||||||
|
# Set the same value on another deployment to restore there. At least 32
|
||||||
|
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
|
||||||
|
# BACKUP_ENCRYPTION_KEY=""
|
||||||
|
|
||||||
|
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||||
|
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||||
|
# OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI
|
||||||
|
# muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen.
|
||||||
|
#
|
||||||
|
# Dieselbe App-Registrierung wird auch fuer die M365-Kalender-Synchronisation
|
||||||
|
# (Modul Kalender) wiederverwendet - kein zweites Enterprise-App-Objekt noetig.
|
||||||
|
# Dafuer in der App-Registrierung zusaetzlich die Application-Permission
|
||||||
|
# "Calendars.ReadWrite" hinzufuegen (Admin-Consent noetig); die Tenant-ID wird
|
||||||
|
# aus OIDC_ISSUER_URL abgeleitet. Eintraege (Aufgaben, Durchsichten,
|
||||||
|
# Behandlungen, Fuetterungen, Ernten, Koeniginnen-Zuordnungen) werden
|
||||||
|
# einseitig (ApiCore -> M365) in den Kalender einer Microsoft-365-Gruppe
|
||||||
|
# uebertragen. Ohne diese drei Werte bleibt die Synchronisation deaktiviert;
|
||||||
|
# zusaetzlich muessen die Synchronisation selbst UND die jeweilige Gruppen-ID
|
||||||
|
# in der GUI (Verwaltung > Automatisierung > Kalender-Sync) gesetzt werden.
|
||||||
|
OIDC_ISSUER_URL="https://login.microsoftonline.com/<tenant-id>/v2.0"
|
||||||
|
OIDC_CLIENT_ID="change-me-entra-client-id"
|
||||||
|
OIDC_CLIENT_SECRET="change-me-entra-client-secret"
|
||||||
|
OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback"
|
||||||
|
|
||||||
|
# Swagger-UI unter /api/v1/docs ist per Default deaktiviert, da sie das
|
||||||
|
# gesamte API-Schema ungeschuetzt (kein JwtAuthGuard) preisgeben wuerde.
|
||||||
|
# Nur fuer lokale Entwicklung/Debugging explizit aktivieren.
|
||||||
|
ENABLE_SWAGGER=false
|
||||||
@@ -6,6 +6,12 @@ COPY package.json package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
# Qualitaets-Gate: ein Image entsteht nur, wenn Lint, Typecheck und
|
||||||
|
# Unit-Tests gruen sind. eslint laeuft hier ohne --fix (anders als
|
||||||
|
# `npm run lint`), damit ein Verstoss den Build abbricht statt still
|
||||||
|
# korrigiert zu werden. Die e2e-Tests brauchen eine Datenbank und laufen
|
||||||
|
# deshalb nicht im Image-Build.
|
||||||
|
RUN npx eslint "{src,test}/**/*.ts" && npm run typecheck && npm test -- --ci
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
@@ -15,6 +21,19 @@ COPY package.json package-lock.json ./
|
|||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/generated ./generated
|
COPY --from=builder /app/generated ./generated
|
||||||
|
# schema.prisma, migrations/ und prisma.config.ts: noetig fuer
|
||||||
|
# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt
|
||||||
|
# (siehe CMD unten). seed.ts selbst wird NICHT zur Laufzeit ausgefuehrt -
|
||||||
|
# `npm run build` kompiliert es vorab zu dist/seed.js (siehe
|
||||||
|
# tsconfig.seed.json), das schon in ./dist enthalten ist. Frueher lief
|
||||||
|
# `ts-node prisma/seed.ts` direkt im Container: der TypeScript-Compiler
|
||||||
|
# braucht dafuer deutlich mehr Heap als das 512Mi-Pod-Limit hergibt und
|
||||||
|
# fuehrte zu sporadischen OOM-Crashes beim Start.
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
USER node
|
USER node
|
||||||
CMD ["node", "dist/main.js"]
|
# migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock,
|
||||||
|
# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) -
|
||||||
|
# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas.
|
||||||
|
CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"]
|
||||||
Generated
+175
-131
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build && tsc -p tsconfig.seed.json",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
@@ -18,7 +18,9 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"migrate:deploy": "prisma migrate deploy",
|
||||||
|
"seed": "node dist/seed.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/schedule": "^12.0.1",
|
||||||
"@nestjs/swagger": "^11.4.7",
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
@@ -36,9 +39,12 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
|
"openid-client": "^6.8.4",
|
||||||
|
"otplib": "^13.5.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"pdfkit": "^0.19.1",
|
"pdfkit": "^0.19.1",
|
||||||
|
"prisma": "^7.9.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
@@ -65,7 +71,6 @@
|
|||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "^7.9.1",
|
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
@@ -86,6 +91,9 @@
|
|||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
|
"transformIgnorePatterns": [
|
||||||
|
"node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose|@nestjs/schedule|cron)/)"
|
||||||
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
-- RBAC migration: replaces the fixed User.role enum with a database-driven
|
||||||
|
-- Role/Permission/RolePermission model. See docs/lastenheft.md RBAC notes
|
||||||
|
-- for the phased rollout this belongs to (Phase 1 of 5).
|
||||||
|
--
|
||||||
|
-- Grants below reproduce today's effective access exactly, derived from a
|
||||||
|
-- fresh grep of every @Roles(...) call site in src/**/*.controller.ts at
|
||||||
|
-- the time this migration was written. Do not hand-tune without re-deriving
|
||||||
|
-- from the code.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Preserve the old enum + column under a new name so every existing
|
||||||
|
-- @Roles(Role.ADMIN, ...) call site keeps compiling untouched during the
|
||||||
|
-- migration (Phase 1-2 alias `LegacyRole as Role` in TS).
|
||||||
|
ALTER TYPE "Role" RENAME TO "LegacyRole";
|
||||||
|
ALTER TABLE "User" RENAME COLUMN "role" TO "legacyRole";
|
||||||
|
|
||||||
|
-- 2. Drop the old index on the renamed column (replaced by an index on the
|
||||||
|
-- new roleId column in step 10).
|
||||||
|
DROP INDEX "User_role_idx";
|
||||||
|
|
||||||
|
-- 3. New RBAC tables.
|
||||||
|
CREATE TYPE "RoleStatus" AS ENUM ('ACTIVE', 'INACTIVE');
|
||||||
|
|
||||||
|
CREATE TABLE "Role" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"isSystem" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"status" "RoleStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deactivatedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "Role_key_key" ON "Role"("key");
|
||||||
|
CREATE INDEX "Role_status_idx" ON "Role"("status");
|
||||||
|
|
||||||
|
CREATE TABLE "Permission" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"resource" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"labelDe" TEXT NOT NULL,
|
||||||
|
"groupDe" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "Permission_key_key" ON "Permission"("key");
|
||||||
|
CREATE UNIQUE INDEX "Permission_resource_action_key" ON "Permission"("resource", "action");
|
||||||
|
CREATE INDEX "Permission_resource_idx" ON "Permission"("resource");
|
||||||
|
|
||||||
|
CREATE TABLE "RolePermission" (
|
||||||
|
"roleId" TEXT NOT NULL,
|
||||||
|
"permissionId" TEXT NOT NULL,
|
||||||
|
"grantedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId", "permissionId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "RolePermission_permissionId_idx" ON "RolePermission"("permissionId");
|
||||||
|
|
||||||
|
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey"
|
||||||
|
FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey"
|
||||||
|
FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- 4. Seed the fixed permission catalog (generated from
|
||||||
|
-- src/auth/permissions/permission-catalog.ts — the code-owned source of
|
||||||
|
-- truth this table mirrors).
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
SELECT gen_random_uuid()::text, v.key, v.resource, v.action, v."labelDe", v."groupDe", v."sortOrder"
|
||||||
|
FROM (VALUES
|
||||||
|
('apiaries:read', 'apiaries', 'read', 'Ansehen', 'Bienenstände', 100),
|
||||||
|
('apiaries:create', 'apiaries', 'create', 'Anlegen', 'Bienenstände', 101),
|
||||||
|
('apiaries:update', 'apiaries', 'update', 'Bearbeiten', 'Bienenstände', 102),
|
||||||
|
('apiaries:deactivate', 'apiaries', 'deactivate', 'Deaktivieren', 'Bienenstände', 103),
|
||||||
|
('colonies:read', 'colonies', 'read', 'Ansehen', 'Völker', 200),
|
||||||
|
('colonies:create', 'colonies', 'create', 'Anlegen', 'Völker', 201),
|
||||||
|
('colonies:update', 'colonies', 'update', 'Bearbeiten', 'Völker', 202),
|
||||||
|
('colonies:deactivate', 'colonies', 'deactivate', 'Deaktivieren', 'Völker', 203),
|
||||||
|
('colonies:manage-lifecycle', 'colonies', 'manage-lifecycle', 'Umsetzen/Teilen/Vereinigen/Auflösen', 'Völker', 204),
|
||||||
|
('queens:read', 'queens', 'read', 'Ansehen', 'Königinnen', 300),
|
||||||
|
('queens:create', 'queens', 'create', 'Anlegen', 'Königinnen', 301),
|
||||||
|
('queens:update', 'queens', 'update', 'Bearbeiten', 'Königinnen', 302),
|
||||||
|
('queens:assign', 'queens', 'assign', 'Volk zuweisen', 'Königinnen', 303),
|
||||||
|
('queens:evaluate', 'queens', 'evaluate', 'Bewerten', 'Königinnen', 304),
|
||||||
|
('inspections:read', 'inspections', 'read', 'Ansehen', 'Durchsichten', 400),
|
||||||
|
('inspections:create', 'inspections', 'create', 'Anlegen', 'Durchsichten', 401),
|
||||||
|
('inspections:update', 'inspections', 'update', 'Bearbeiten', 'Durchsichten', 402),
|
||||||
|
('tasks:read', 'tasks', 'read', 'Ansehen', 'Aufgaben', 500),
|
||||||
|
('tasks:create', 'tasks', 'create', 'Anlegen', 'Aufgaben', 501),
|
||||||
|
('tasks:update', 'tasks', 'update', 'Bearbeiten', 'Aufgaben', 502),
|
||||||
|
('tasks:complete', 'tasks', 'complete', 'Erledigen', 'Aufgaben', 503),
|
||||||
|
('task-templates:read', 'task-templates', 'read', 'Ansehen', 'Wiederkehrende Aufgaben', 600),
|
||||||
|
('task-templates:create', 'task-templates', 'create', 'Anlegen', 'Wiederkehrende Aufgaben', 601),
|
||||||
|
('task-templates:update', 'task-templates', 'update', 'Bearbeiten', 'Wiederkehrende Aufgaben', 602),
|
||||||
|
('task-templates:deactivate', 'task-templates', 'deactivate', 'Deaktivieren', 'Wiederkehrende Aufgaben', 603),
|
||||||
|
('varroa:read', 'varroa', 'read', 'Ansehen', 'Varroa', 700),
|
||||||
|
('varroa:create', 'varroa', 'create', 'Anlegen', 'Varroa', 701),
|
||||||
|
('varroa:update', 'varroa', 'update', 'Bearbeiten', 'Varroa', 702),
|
||||||
|
('swarm-control:read', 'swarm-control', 'read', 'Ansehen', 'Schwarmkontrolle', 800),
|
||||||
|
('swarm-control:create', 'swarm-control', 'create', 'Anlegen', 'Schwarmkontrolle', 801),
|
||||||
|
('swarm-control:update', 'swarm-control', 'update', 'Bearbeiten', 'Schwarmkontrolle', 802),
|
||||||
|
('treatments:read', 'treatments', 'read', 'Ansehen', 'Behandlungen', 900),
|
||||||
|
('treatments:create', 'treatments', 'create', 'Anlegen', 'Behandlungen', 901),
|
||||||
|
('treatments:update', 'treatments', 'update', 'Bearbeiten', 'Behandlungen', 902),
|
||||||
|
('feedings:read', 'feedings', 'read', 'Ansehen', 'Fütterungen', 1000),
|
||||||
|
('feedings:create', 'feedings', 'create', 'Anlegen', 'Fütterungen', 1001),
|
||||||
|
('feedings:update', 'feedings', 'update', 'Bearbeiten', 'Fütterungen', 1002),
|
||||||
|
('harvests:read', 'harvests', 'read', 'Ansehen', 'Ernten', 1100),
|
||||||
|
('harvests:create', 'harvests', 'create', 'Anlegen', 'Ernten', 1101),
|
||||||
|
('harvests:update', 'harvests', 'update', 'Bearbeiten', 'Ernten', 1102),
|
||||||
|
('extractions:read', 'extractions', 'read', 'Ansehen', 'Schleuderungen', 1200),
|
||||||
|
('extractions:create', 'extractions', 'create', 'Anlegen', 'Schleuderungen', 1201),
|
||||||
|
('extractions:update', 'extractions', 'update', 'Bearbeiten', 'Schleuderungen', 1202),
|
||||||
|
('honey-batches:read', 'honey-batches', 'read', 'Ansehen', 'Honigchargen', 1300),
|
||||||
|
('honey-batches:create', 'honey-batches', 'create', 'Anlegen', 'Honigchargen', 1301),
|
||||||
|
('honey-batches:relocate', 'honey-batches', 'relocate', 'Umlagern', 'Honigchargen', 1302),
|
||||||
|
('honey-batches:measure', 'honey-batches', 'measure', 'Messwerte erfassen', 'Honigchargen', 1303),
|
||||||
|
('storage:read', 'storage', 'read', 'Ansehen', 'Lager', 1400),
|
||||||
|
('storage:create', 'storage', 'create', 'Anlegen', 'Lager', 1401),
|
||||||
|
('storage:move', 'storage', 'move', 'Bewegung buchen', 'Lager', 1402),
|
||||||
|
('products:read', 'products', 'read', 'Ansehen', 'Produkte', 1500),
|
||||||
|
('products:create', 'products', 'create', 'Anlegen', 'Produkte', 1501),
|
||||||
|
('products:update', 'products', 'update', 'Bearbeiten', 'Produkte', 1502),
|
||||||
|
('bottling:read', 'bottling', 'read', 'Ansehen', 'Abfüllungen', 1600),
|
||||||
|
('bottling:create', 'bottling', 'create', 'Anlegen', 'Abfüllungen', 1601),
|
||||||
|
('bottling:update', 'bottling', 'update', 'Bearbeiten', 'Abfüllungen', 1602),
|
||||||
|
('equipment:read', 'equipment', 'read', 'Ansehen', 'Inventar', 1700),
|
||||||
|
('equipment:create', 'equipment', 'create', 'Anlegen', 'Inventar', 1701),
|
||||||
|
('equipment:update', 'equipment', 'update', 'Bearbeiten', 'Inventar', 1702),
|
||||||
|
('documents:read', 'documents', 'read', 'Ansehen', 'Dokumente', 1800),
|
||||||
|
('documents:upload', 'documents', 'upload', 'Hochladen', 'Dokumente', 1801),
|
||||||
|
('documents:delete', 'documents', 'delete', 'Löschen', 'Dokumente', 1802),
|
||||||
|
('webhooks:read', 'webhooks', 'read', 'Ansehen', 'Webhooks', 1900),
|
||||||
|
('webhooks:create', 'webhooks', 'create', 'Anlegen', 'Webhooks', 1901),
|
||||||
|
('webhooks:delete', 'webhooks', 'delete', 'Löschen', 'Webhooks', 1902),
|
||||||
|
('automation:read', 'automation', 'read', 'Ansehen', 'Automatisierungen', 2000),
|
||||||
|
('automation:update', 'automation', 'update', 'Bearbeiten', 'Automatisierungen', 2001),
|
||||||
|
('import:execute', 'import', 'execute', 'Ausführen', 'Import', 2100),
|
||||||
|
('export:read', 'export', 'read', 'Ansehen', 'Export', 2200),
|
||||||
|
('users:read', 'users', 'read', 'Ansehen', 'Benutzer', 2300),
|
||||||
|
('users:create', 'users', 'create', 'Anlegen', 'Benutzer', 2301),
|
||||||
|
('users:update', 'users', 'update', 'Bearbeiten', 'Benutzer', 2302),
|
||||||
|
('users:deactivate', 'users', 'deactivate', 'Deaktivieren', 'Benutzer', 2303),
|
||||||
|
('roles:read', 'roles', 'read', 'Ansehen', 'Rollen & Rechte', 2400),
|
||||||
|
('roles:create', 'roles', 'create', 'Anlegen', 'Rollen & Rechte', 2401),
|
||||||
|
('roles:update', 'roles', 'update', 'Bearbeiten', 'Rollen & Rechte', 2402),
|
||||||
|
('roles:deactivate', 'roles', 'deactivate', 'Deaktivieren', 'Rollen & Rechte', 2403),
|
||||||
|
('audit:read', 'audit', 'read', 'Ansehen', 'Audit-Log', 2500),
|
||||||
|
('calendar:read', 'calendar', 'read', 'Ansehen', 'Kalender', 2600),
|
||||||
|
('dashboard:read', 'dashboard', 'read', 'Ansehen', 'Dashboard', 2700),
|
||||||
|
('statistics:read', 'statistics', 'read', 'Ansehen', 'Statistik', 2800),
|
||||||
|
('search:read', 'search', 'read', 'Ansehen', 'Suche', 2900),
|
||||||
|
('qr:read', 'qr', 'read', 'Ansehen', 'QR-Codes', 3000)
|
||||||
|
) AS v(key, resource, action, "labelDe", "groupDe", "sortOrder");
|
||||||
|
|
||||||
|
-- 5. Seed the 5 roles that replace the legacy enum values, one-for-one.
|
||||||
|
INSERT INTO "Role" (id, key, name, description, "isSystem", status, "createdAt", "updatedAt")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'ADMIN', 'Administrator', 'Voller Zugriff auf alle Bereiche, inkl. Benutzer- und Rechteverwaltung.', true, 'ACTIVE', now(), now()),
|
||||||
|
(gen_random_uuid()::text, 'BEEKEEPER', 'Imker', 'Voller fachlicher Zugriff, ohne Benutzer-, Rollen- und Systemverwaltung.', false, 'ACTIVE', now(), now()),
|
||||||
|
(gen_random_uuid()::text, 'STAFF', 'Mitarbeiter', 'Ansicht aller Bereiche, Erfassung der laufenden Tagesarbeit.', false, 'ACTIVE', now(), now()),
|
||||||
|
(gen_random_uuid()::text, 'READONLY', 'Nur Lesen', 'Lesezugriff auf alle Bereiche, keine Änderungen.', false, 'ACTIVE', now(), now()),
|
||||||
|
(gen_random_uuid()::text, 'API_CLIENT', 'API-Client', 'Lesezugriff für externe Integrationen.', false, 'ACTIVE', now(), now());
|
||||||
|
|
||||||
|
-- 6. Grants, derived from a fresh grep of every @Roles(...) call site.
|
||||||
|
|
||||||
|
-- ADMIN: everything.
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE r.key = 'ADMIN';
|
||||||
|
|
||||||
|
-- BEEKEEPER: everything except the administrative/system resources that
|
||||||
|
-- were ADMIN-only via class-level @Roles(Role.ADMIN).
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE r.key = 'BEEKEEPER'
|
||||||
|
AND p.resource NOT IN ('users', 'roles', 'audit', 'webhooks', 'automation', 'import');
|
||||||
|
|
||||||
|
-- STAFF: read access to every non-administrative resource ...
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE r.key = 'STAFF'
|
||||||
|
AND p.action = 'read'
|
||||||
|
AND p.resource NOT IN ('task-templates', 'users', 'roles', 'audit', 'webhooks', 'automation', 'import');
|
||||||
|
|
||||||
|
-- ... plus exactly the write actions that carried Role.STAFF today.
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE r.key = 'STAFF'
|
||||||
|
AND p.key IN (
|
||||||
|
'inspections:create', 'inspections:update',
|
||||||
|
'tasks:create', 'tasks:update', 'tasks:complete',
|
||||||
|
'feedings:create', 'feedings:update',
|
||||||
|
'harvests:create', 'harvests:update',
|
||||||
|
'honey-batches:relocate',
|
||||||
|
'documents:upload', 'documents:delete'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- READONLY and API_CLIENT: the same read-only set as STAFF (neither role
|
||||||
|
-- appears in any @Roles(...) call site today, so both currently mean
|
||||||
|
-- "read everything" — kept identical here, adjustable later via the UI).
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE r.key IN ('READONLY', 'API_CLIENT')
|
||||||
|
AND p.action = 'read'
|
||||||
|
AND p.resource NOT IN ('task-templates', 'users', 'roles', 'audit', 'webhooks', 'automation', 'import');
|
||||||
|
|
||||||
|
-- 7. New nullable roleId column on User, backfilled below.
|
||||||
|
ALTER TABLE "User" ADD COLUMN "roleId" TEXT;
|
||||||
|
|
||||||
|
-- 8. Backfill from the legacy enum value.
|
||||||
|
UPDATE "User" u
|
||||||
|
SET "roleId" = r.id
|
||||||
|
FROM "Role" r
|
||||||
|
WHERE r.key = u."legacyRole"::text;
|
||||||
|
|
||||||
|
-- 9. Verification gate: refuse to proceed if any user was left unresolved.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM "User" WHERE "roleId" IS NULL) THEN
|
||||||
|
RAISE EXCEPTION 'RBAC backfill incomplete: at least one User row has no resolvable roleId';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 10. Lock it down.
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "roleId" SET NOT NULL;
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey"
|
||||||
|
FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
CREATE INDEX "User_roleId_idx" ON "User"("roleId");
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- RBAC Phase 3: drops the transitional LegacyRole enum + User.legacyRole
|
||||||
|
-- column now that every controller runs on @RequirePermission and no code
|
||||||
|
-- reads the legacy field anymore (see permission-catalog.ts / roles module).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE "User" DROP COLUMN "legacyRole";
|
||||||
|
DROP TYPE "LegacyRole";
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AuditAction" ADD VALUE 'MFA_ENABLED';
|
||||||
|
ALTER TYPE "AuditAction" ADD VALUE 'MFA_DISABLED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "mfaEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "mfaSecret" TEXT,
|
||||||
|
ADD COLUMN "avatarPath" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "passwordChangedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ColonyCondition" AS ENUM ('EXCELLENT', 'GOOD', 'AVERAGE', 'WEAK', 'CRITICAL');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Colony"
|
||||||
|
ALTER COLUMN "broodStatus" TYPE "ColonyCondition" USING ("broodStatus"::"ColonyCondition"),
|
||||||
|
ALTER COLUMN "feedStatus" TYPE "ColonyCondition" USING ("feedStatus"::"ColonyCondition"),
|
||||||
|
ALTER COLUMN "healthStatus" TYPE "ColonyCondition" USING ("healthStatus"::"ColonyCondition");
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SWARM_MOOD_HIGH';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_HEALTH_CRITICAL';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'STOCK_ITEM_LOW';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LabelTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"widthMm" DOUBLE PRECISION NOT NULL DEFAULT 90,
|
||||||
|
"heightMm" DOUBLE PRECISION NOT NULL DEFAULT 35,
|
||||||
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "LabelTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LabelTemplate_active_idx" ON "LabelTemplate"("active");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- LabelTemplate switches from a plain-text "content" field to a structured
|
||||||
|
-- "elements" JSON array driving the canvas designer + PDF renderer. The
|
||||||
|
-- feature was never deployed live, so no existing rows need preserving.
|
||||||
|
ALTER TABLE "LabelTemplate" DROP COLUMN "content";
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "elements" JSONB NOT NULL DEFAULT '[]';
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
-- Explicit A4 sheet layout per LabelTemplate: columns/rows (nullable, null =
|
||||||
|
-- auto-fit) plus configurable page margins and gaps, so a template can be
|
||||||
|
-- made to match a specific physical label sheet's exact printed grid.
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "columns" INTEGER;
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "rows" INTEGER;
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "marginTopMm" DOUBLE PRECISION NOT NULL DEFAULT 5;
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "marginLeftMm" DOUBLE PRECISION NOT NULL DEFAULT 5;
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "gapXMm" DOUBLE PRECISION NOT NULL DEFAULT 2;
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "gapYMm" DOUBLE PRECISION NOT NULL DEFAULT 2;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SiteSettings" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"youtubeUrl" TEXT,
|
||||||
|
"instagramUrl" TEXT,
|
||||||
|
"tiktokUrl" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_BATCH_NUMBER_FORMAT';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'BOTTLING_LOT_NUMBER_FORMAT';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AutomationRule" ADD COLUMN "sequenceValue" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SiteSettings" ADD COLUMN "websiteUrl" TEXT;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BackupSettings" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"storagePath" TEXT,
|
||||||
|
"cronSchedule" TEXT,
|
||||||
|
"retentionCount" INTEGER,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "BackupSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CalendarSyncSourceType" AS ENUM ('TASK', 'INSPECTION', 'TREATMENT', 'FEEDING', 'HARVEST', 'QUEEN_ASSIGNMENT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CalendarSyncStatus" AS ENUM ('PENDING', 'SYNCED', 'FAILED', 'SKIPPED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CalendarSyncSettings" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"groupId" TEXT,
|
||||||
|
"syncTask" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"syncInspection" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"syncTreatment" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"syncFeeding" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"syncHarvest" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"syncQueenAssignment" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CalendarSyncSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CalendarSyncOutboxEntry" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sourceType" "CalendarSyncSourceType" NOT NULL,
|
||||||
|
"sourceId" TEXT NOT NULL,
|
||||||
|
"status" "CalendarSyncStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"graphEventId" TEXT,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastError" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CalendarSyncOutboxEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CalendarSyncOutboxEntry_status_idx" ON "CalendarSyncOutboxEntry"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CalendarSyncOutboxEntry_sourceType_sourceId_key" ON "CalendarSyncOutboxEntry"("sourceType", "sourceId");
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "HoneyBatchContainerAllocation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"honeyBatchId" TEXT NOT NULL,
|
||||||
|
"storageContainerId" TEXT NOT NULL,
|
||||||
|
"amount" DOUBLE PRECISION NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "HoneyBatchContainerAllocation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "HoneyBatchContainerAllocation_storageContainerId_idx" ON "HoneyBatchContainerAllocation"("storageContainerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key" ON "HoneyBatchContainerAllocation"("honeyBatchId", "storageContainerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_storageContainerId_fkey" FOREIGN KEY ("storageContainerId") REFERENCES "StorageContainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Treatment" ADD COLUMN "withdrawalPeriodDays" INTEGER;
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "IdempotencyStatus" AS ENUM ('PENDING', 'COMPLETED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "sourceAutomationTrigger" "AutomationTriggerType";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "IdempotencyRecord" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"status" "IdempotencyStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"statusCode" INTEGER,
|
||||||
|
"responseBody" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "IdempotencyRecord_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "IdempotencyRecord_createdAt_idx" ON "IdempotencyRecord"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "IdempotencyRecord_userId_key_key" ON "IdempotencyRecord"("userId", "key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Task_sourceAutomationTrigger_relatedType_relatedId_status_idx" ON "Task"("sourceAutomationTrigger", "relatedType", "relatedId", "status");
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ApiKey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"prefix" TEXT NOT NULL,
|
||||||
|
"keyHash" TEXT NOT NULL,
|
||||||
|
"scopes" TEXT[],
|
||||||
|
"createdByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"lastUsedAt" TIMESTAMP(3),
|
||||||
|
"expiresAt" TIMESTAMP(3),
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ApiKey_revokedAt_idx" ON "ApiKey"("revokedAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LabelTemplate" ADD COLUMN "cropMarks" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum.
|
||||||
|
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||||
|
-- in a single migration. This can be worked around by creating
|
||||||
|
-- multiple migrations, each migration adding only one value to
|
||||||
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'TREATMENT_FOLLOW_UP';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'FEEDING_FOLLOW_UP';
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SiteSettings" ADD COLUMN "holderAddress" TEXT,
|
||||||
|
ADD COLUMN "holderName" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Treatment" ADD COLUMN "apiaryId" TEXT,
|
||||||
|
ADD COLUMN "prescribingVet" TEXT,
|
||||||
|
ADD COLUMN "purchaseProof" TEXT,
|
||||||
|
ADD COLUMN "supplier" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WinterCheckSeason" AS ENUM ('AUTUMN', 'SPRING');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WinterOutcome" AS ENUM ('SURVIVED', 'WEAK', 'LOST');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ColonyLossCause" AS ENUM ('DEAD_OR_EMPTY', 'QUEEN_PROBLEM', 'NATURAL_DISASTER');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WinterCheck" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"colonyId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"winterYear" INTEGER NOT NULL,
|
||||||
|
"season" "WinterCheckSeason" NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"occupiedSeams" INTEGER,
|
||||||
|
"feedStockKg" DOUBLE PRECISION,
|
||||||
|
"queenPresent" BOOLEAN,
|
||||||
|
"outcome" "WinterOutcome",
|
||||||
|
"lossCause" "ColonyLossCause",
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WinterCheck_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WinterCheck_winterYear_idx" ON "WinterCheck"("winterYear");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WinterCheck_colonyId_winterYear_season_key" ON "WinterCheck"("colonyId", "winterYear", "season");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Permissions: the boot-time catalog sync only grants new permissions to
|
||||||
|
-- system roles (ADMIN). Winter checks are everyday beekeeping records, so the
|
||||||
|
-- other roles get the same access they have to inspections.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'winter-checks:read', 'winter-checks', 'read', 'Ansehen', 'Überwinterung', 850),
|
||||||
|
(gen_random_uuid()::text, 'winter-checks:create', 'winter-checks', 'create', 'Anlegen', 'Überwinterung', 851),
|
||||||
|
(gen_random_uuid()::text, 'winter-checks:update', 'winter-checks', 'update', 'Bearbeiten', 'Überwinterung', 852)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE p.resource = 'winter-checks'
|
||||||
|
AND (
|
||||||
|
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
|
||||||
|
OR (r.key IN ('READONLY', 'API_CLIENT') AND p.action = 'read')
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
-- Microsoft sign-in is bound to the IdP identity ("<iss>|<oid>"), set on the
|
||||||
|
-- first SSO login; the unverified email claim no longer picks the account.
|
||||||
|
ALTER TABLE "User" ADD COLUMN "ssoSubject" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "User_ssoSubject_key" ON "User"("ssoSubject");
|
||||||
|
|
||||||
|
-- Emails are compared case-insensitively from now on. Store existing ones
|
||||||
|
-- lower-case, except where that would collide with another account - those
|
||||||
|
-- stay as they are and are still found by the case-insensitive lookup.
|
||||||
|
UPDATE "User" AS u
|
||||||
|
SET "email" = lower(u."email")
|
||||||
|
WHERE u."email" <> lower(u."email")
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "User" AS o
|
||||||
|
WHERE o."id" <> u."id" AND lower(o."email") = lower(u."email")
|
||||||
|
);
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-- TOTP replay protection and per-account lockout after failed attempts.
|
||||||
|
ALTER TABLE "User" ADD COLUMN "mfaLastTimeStep" INTEGER;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "failedAuthAttempts" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "authLockedUntil" TIMESTAMP(3);
|
||||||
|
ALTER TYPE "AuditAction" ADD VALUE 'ACCOUNT_LOCKED';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Absolute session lifetime: every rotated refresh token keeps the time
|
||||||
|
-- the session was first opened. Existing sessions count from now.
|
||||||
|
ALTER TABLE "RefreshToken" ADD COLUMN "sessionStartedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- API keys can be limited to source addresses (IP or CIDR).
|
||||||
|
ALTER TABLE "ApiKey" ADD COLUMN "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
ALTER TABLE "ApiKey" ADD COLUMN "lastUsedIp" TEXT;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- One-time helper tokens (SSO completion, MFA challenge) are recorded once
|
||||||
|
-- used; a second use of the same jti is refused.
|
||||||
|
CREATE TABLE "ConsumedToken" (
|
||||||
|
"jti" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "ConsumedToken_pkey" PRIMARY KEY ("jti")
|
||||||
|
);
|
||||||
|
CREATE INDEX "ConsumedToken_expiresAt_idx" ON "ConsumedToken"("expiresAt");
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
-- Bestandsabgleich mit dem Webshop (docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md):
|
||||||
|
-- Bewegungsbuch je Charge, Herstellungschargen fuer Nicht-Honig-Artikel,
|
||||||
|
-- idempotente Buchungsanfragen des Shops. Rein additiv.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ProductKind" AS ENUM ('BOTTLED_HONEY', 'MANUFACTURED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ShopCategory" AS ENUM ('HONIG', 'KERZEN', 'PROPOLIS', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BatchMovementType" AS ENUM ('SHOP_SALE', 'SHOP_RETURN', 'WITHDRAWAL', 'CORRECTION');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WithdrawalReason" AS ENUM ('HOFLADEN', 'EIGENBEDARF', 'BRUCH', 'PROBE', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ShopStockRequestKind" AS ENUM ('SALE', 'RETURN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "kind" "ProductKind" NOT NULL DEFAULT 'BOTTLED_HONEY',
|
||||||
|
ADD COLUMN "shopCategory" "ShopCategory" NOT NULL DEFAULT 'HONIG';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductionBatch" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productId" TEXT NOT NULL,
|
||||||
|
"lotNumber" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"bestBeforeDate" TIMESTAMP(3),
|
||||||
|
"notes" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductionBatch_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "BatchMovement" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bottlingBatchId" TEXT,
|
||||||
|
"productionBatchId" TEXT,
|
||||||
|
"type" "BatchMovementType" NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"reason" "WithdrawalReason",
|
||||||
|
"notes" TEXT,
|
||||||
|
"shopRequestId" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
"apiKeyId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "BatchMovement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ShopStockRequest" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"externalId" TEXT NOT NULL,
|
||||||
|
"kind" "ShopStockRequestKind" NOT NULL,
|
||||||
|
"bottlingBatchId" TEXT,
|
||||||
|
"productId" TEXT,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"reversesExternalId" TEXT,
|
||||||
|
"apiKeyId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ShopStockRequest_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ProductionBatch_lotNumber_key" ON "ProductionBatch"("lotNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductionBatch_productId_idx" ON "ProductionBatch"("productId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_bottlingBatchId_idx" ON "BatchMovement"("bottlingBatchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_productionBatchId_idx" ON "BatchMovement"("productionBatchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_shopRequestId_idx" ON "BatchMovement"("shopRequestId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ShopStockRequest_externalId_key" ON "ShopStockRequest"("externalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ShopStockRequest_reversesExternalId_idx" ON "ShopStockRequest"("reversesExternalId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_bottlingBatchId_fkey" FOREIGN KEY ("bottlingBatchId") REFERENCES "BottlingBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_shopRequestId_fkey" FOREIGN KEY ("shopRequestId") REFERENCES "ShopStockRequest"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Genau eine Charge je Bewegung, genau ein Ziel je Shop-Anfrage.
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
|
||||||
|
CHECK (("bottlingBatchId" IS NULL) <> ("productionBatchId" IS NULL));
|
||||||
|
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_exactly_one_target"
|
||||||
|
CHECK (("bottlingBatchId" IS NULL) <> ("productId" IS NULL));
|
||||||
|
ALTER TABLE "ShopStockRequest" ADD CONSTRAINT "ShopStockRequest_quantity_positive"
|
||||||
|
CHECK ("quantity" > 0);
|
||||||
|
ALTER TABLE "ProductionBatch" ADD CONSTRAINT "ProductionBatch_quantity_positive"
|
||||||
|
CHECK ("quantity" > 0);
|
||||||
|
|
||||||
|
-- Permissions: the boot-time catalog sync only grants new permissions to
|
||||||
|
-- ADMIN; the other built-in roles get them here, matching their existing
|
||||||
|
-- bottling rights.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'bottling:withdraw', 'bottling', 'withdraw', 'Entnehmen und Inventur', 'Abfüllungen', 1604),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:read', 'production-batch', 'read', 'Ansehen', 'Herstellungschargen', 1650),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:create', 'production-batch', 'create', 'Anlegen', 'Herstellungschargen', 1651),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:update', 'production-batch', 'update', 'Bearbeiten', 'Herstellungschargen', 1652),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:delete', 'production-batch', 'delete', 'Löschen', 'Herstellungschargen', 1653),
|
||||||
|
(gen_random_uuid()::text, 'production-batch:withdraw', 'production-batch', 'withdraw', 'Entnehmen und Inventur', 'Herstellungschargen', 1654),
|
||||||
|
(gen_random_uuid()::text, 'shop-sync:write', 'shop-sync', 'write', 'Verkäufe und Rückbuchungen des Shops buchen', 'Shop-Synchronisation', 3301)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Wer Abfuellungen bearbeiten darf, darf kuenftig auch entnehmen und
|
||||||
|
-- Herstellungschargen pflegen; wer Abfuellungen sehen darf, sieht auch
|
||||||
|
-- Herstellungschargen.
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:update'
|
||||||
|
JOIN "Permission" p ON p.key IN (
|
||||||
|
'bottling:withdraw', 'production-batch:create', 'production-batch:update',
|
||||||
|
'production-batch:delete', 'production-batch:withdraw'
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'bottling:read'
|
||||||
|
JOIN "Permission" p ON p.key = 'production-batch:read'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Only ADMIN may hand out shop-sync:write to an API key (a key can only get
|
||||||
|
-- permissions its creator holds). Inserted here, the row is no longer "new"
|
||||||
|
-- for the boot-time catalog sync, so the grant has to happen here too.
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE r.key = 'ADMIN' AND p.key = 'shop-sync:write'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
-- Rohstoffe (Wachs, Propolis) mit eigenen Chargen und Bestand; Herstellungs-
|
||||||
|
-- chargen verbrauchen sie (ProductionInput + Bewegung CONSUMPTION). Damit
|
||||||
|
-- reicht die Rueckverfolgung vom Rohstoff ueber die Charge bis zur Kundin.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RawMaterialKind" AS ENUM ('BEESWAX', 'PROPOLIS');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "BatchMovementType" ADD VALUE 'CONSUMPTION';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "BatchMovement" ADD COLUMN "rawMaterialLotId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RawMaterialLot" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kind" "RawMaterialKind" NOT NULL,
|
||||||
|
"lotNumber" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"apiaryId" TEXT,
|
||||||
|
"harvestId" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RawMaterialLot_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ProductionInput" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"productionBatchId" TEXT NOT NULL,
|
||||||
|
"rawMaterialLotId" TEXT NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ProductionInput_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RawMaterialLot_lotNumber_key" ON "RawMaterialLot"("lotNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RawMaterialLot_kind_idx" ON "RawMaterialLot"("kind");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductionInput_productionBatchId_idx" ON "ProductionInput"("productionBatchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductionInput_rawMaterialLotId_idx" ON "ProductionInput"("rawMaterialLotId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "BatchMovement_rawMaterialLotId_idx" ON "BatchMovement"("rawMaterialLotId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_harvestId_fkey" FOREIGN KEY ("harvestId") REFERENCES "Harvest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_productionBatchId_fkey" FOREIGN KEY ("productionBatchId") REFERENCES "ProductionBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_rawMaterialLotId_fkey" FOREIGN KEY ("rawMaterialLotId") REFERENCES "RawMaterialLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- Eine Bewegung gehoert jetzt zu genau einer von drei Chargenarten.
|
||||||
|
ALTER TABLE "BatchMovement" DROP CONSTRAINT "BatchMovement_exactly_one_batch";
|
||||||
|
ALTER TABLE "BatchMovement" ADD CONSTRAINT "BatchMovement_exactly_one_batch"
|
||||||
|
CHECK (num_nonnulls("bottlingBatchId", "productionBatchId", "rawMaterialLotId") = 1);
|
||||||
|
ALTER TABLE "RawMaterialLot" ADD CONSTRAINT "RawMaterialLot_quantity_positive" CHECK ("quantity" > 0);
|
||||||
|
ALTER TABLE "ProductionInput" ADD CONSTRAINT "ProductionInput_quantity_positive" CHECK ("quantity" > 0);
|
||||||
|
|
||||||
|
-- Permissions (catalog sync grants only brand-new rows to ADMIN): roles that
|
||||||
|
-- may maintain production batches may maintain raw materials, readers read.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'raw-material:read', 'raw-material', 'read', 'Ansehen', 'Rohstoffe', 1630),
|
||||||
|
(gen_random_uuid()::text, 'raw-material:create', 'raw-material', 'create', 'Anlegen', 'Rohstoffe', 1631),
|
||||||
|
(gen_random_uuid()::text, 'raw-material:update', 'raw-material', 'update', 'Bearbeiten', 'Rohstoffe', 1632),
|
||||||
|
(gen_random_uuid()::text, 'raw-material:delete', 'raw-material', 'delete', 'Löschen', 'Rohstoffe', 1633),
|
||||||
|
(gen_random_uuid()::text, 'raw-material:withdraw', 'raw-material', 'withdraw', 'Entnehmen und Inventur', 'Rohstoffe', 1634)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:create'
|
||||||
|
JOIN "Permission" p ON p.key IN ('raw-material:create', 'raw-material:update', 'raw-material:delete', 'raw-material:withdraw')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT DISTINCT rp."roleId", p.id, now()
|
||||||
|
FROM "RolePermission" rp
|
||||||
|
JOIN "Permission" existing ON existing.id = rp."permissionId" AND existing.key = 'production-batch:read'
|
||||||
|
JOIN "Permission" p ON p.key = 'raw-material:read'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOT_NUMBER_FORMAT';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PRODUCTION_BATCH_NUMBER_FORMAT';
|
||||||
|
ALTER TYPE "AutomationTriggerType" ADD VALUE 'RAW_MATERIAL_LOW';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AutomationSequence" (
|
||||||
|
"triggerType" "AutomationTriggerType" NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AutomationSequence_pkey" PRIMARY KEY ("triggerType","scope")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AutomationSequence" ADD CONSTRAINT "AutomationSequence_triggerType_fkey" FOREIGN KEY ("triggerType") REFERENCES "AutomationRule"("triggerType") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Outbox claims: one worker at a time writes an entry to M365, and a
|
||||||
|
-- revision counter lets it notice an enqueue that arrived during the sync.
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CalendarSyncOutboxEntry" ADD COLUMN "claimedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -16,12 +16,52 @@ datasource db {
|
|||||||
// Benutzer / Auth / Audit (Abschnitt 3, 14, 15, 16)
|
// Benutzer / Auth / Audit (Abschnitt 3, 14, 15, 16)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
enum Role {
|
enum RoleStatus {
|
||||||
ADMIN
|
ACTIVE
|
||||||
BEEKEEPER
|
INACTIVE
|
||||||
STAFF
|
}
|
||||||
READONLY
|
|
||||||
API_CLIENT
|
model Role {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
isSystem Boolean @default(false)
|
||||||
|
status RoleStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deactivatedAt DateTime?
|
||||||
|
|
||||||
|
permissions RolePermission[]
|
||||||
|
users User[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Permission {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
resource String
|
||||||
|
action String
|
||||||
|
labelDe String
|
||||||
|
groupDe String
|
||||||
|
sortOrder Int
|
||||||
|
|
||||||
|
roles RolePermission[]
|
||||||
|
|
||||||
|
@@unique([resource, action])
|
||||||
|
@@index([resource])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RolePermission {
|
||||||
|
roleId String
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
permissionId String
|
||||||
|
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||||
|
grantedAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@id([roleId, permissionId])
|
||||||
|
@@index([permissionId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -29,17 +69,44 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
name String
|
name String
|
||||||
role Role @default(STAFF)
|
roleId String
|
||||||
|
role Role @relation(fields: [roleId], references: [id])
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deactivatedAt DateTime?
|
deactivatedAt DateTime?
|
||||||
|
|
||||||
|
// TOTP-Secret AES-256-GCM-verschluesselt gespeichert (siehe
|
||||||
|
// src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB.
|
||||||
|
mfaEnabled Boolean @default(false)
|
||||||
|
mfaSecret String?
|
||||||
|
// Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer)
|
||||||
|
// wird danach nie wieder akzeptiert (Replay-Schutz).
|
||||||
|
mfaLastTimeStep Int?
|
||||||
|
|
||||||
|
// Fehlversuche (Passwort, MFA-Code, Bestaetigung) seit dem letzten Erfolg;
|
||||||
|
// ab 5 ist das Konto bis authLockedUntil gesperrt.
|
||||||
|
failedAuthAttempts Int @default(0)
|
||||||
|
authLockedUntil DateTime?
|
||||||
|
avatarPath String?
|
||||||
|
|
||||||
|
// Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens,
|
||||||
|
// die vor diesem Zeitpunkt ausgestellt wurden (siehe
|
||||||
|
// PermissionResolverService.isTokenValid), damit ein gestohlener Token
|
||||||
|
// nach einer Passwortaenderung nicht bis zu 15 Minuten weiter funktioniert.
|
||||||
|
passwordChangedAt DateTime?
|
||||||
|
|
||||||
|
// Unveraenderliche Identitaet beim Identity Provider ("<iss>|<oid>"),
|
||||||
|
// gesetzt bei der ersten Microsoft-Anmeldung. Danach entscheidet nur sie,
|
||||||
|
// nicht mehr der (unverifizierte) E-Mail-Claim, welches Konto sich anmeldet.
|
||||||
|
ssoSubject String? @unique
|
||||||
|
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
|
|
||||||
inspections Inspection[]
|
inspections Inspection[]
|
||||||
varroaMeasurements VarroaMeasurement[]
|
varroaMeasurements VarroaMeasurement[]
|
||||||
swarmControlChecks SwarmControlCheck[]
|
swarmControlChecks SwarmControlCheck[]
|
||||||
|
winterChecks WinterCheck[]
|
||||||
treatments Treatment[]
|
treatments Treatment[]
|
||||||
feedings Feeding[]
|
feedings Feeding[]
|
||||||
harvests Harvest[]
|
harvests Harvest[]
|
||||||
@@ -53,13 +120,53 @@ model User {
|
|||||||
colonyMoves ColonyApiaryHistory[]
|
colonyMoves ColonyApiaryHistory[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
bottlingBatches BottlingBatch[]
|
bottlingBatches BottlingBatch[]
|
||||||
|
productionBatches ProductionBatch[]
|
||||||
|
batchMovements BatchMovement[]
|
||||||
|
rawMaterialLots RawMaterialLot[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
queenEvaluations QueenEvaluation[]
|
queenEvaluations QueenEvaluation[]
|
||||||
webhookSubscriptions WebhookSubscription[]
|
webhookSubscriptions WebhookSubscription[]
|
||||||
documents Document[]
|
documents Document[]
|
||||||
honeyBatchRelocations HoneyBatchRelocation[]
|
honeyBatchRelocations HoneyBatchRelocation[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
@@index([role])
|
@@index([roleId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schlüssel für Maschinen-Zugriffe (z. B. Wabenhain-Shop-Sync) statt eines
|
||||||
|
// Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash des
|
||||||
|
// Schlüssels; der Klartext wird genau einmal bei der Erstellung angezeigt.
|
||||||
|
// Ein Schlüssel ist kein Benutzer: er darf nur die Routen, deren
|
||||||
|
// Berechtigung in `scopes` steht (siehe api-keys/api-key-scopes.ts).
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
// Anfang des Schlüssels ("ack_ab12cd34") - zum Wiedererkennen in der
|
||||||
|
// Liste, ohne den Schlüssel selbst zu speichern.
|
||||||
|
prefix String
|
||||||
|
keyHash String @unique
|
||||||
|
scopes String[]
|
||||||
|
createdByUserId String?
|
||||||
|
createdByUser User? @relation(fields: [createdByUserId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
// Absender-IP der letzten Nutzung - hilft beim Einrichten der Allowlist.
|
||||||
|
lastUsedIp String?
|
||||||
|
// Nur von diesen Adressen/Netzen (IP oder CIDR) gueltig; leer = ueberall.
|
||||||
|
allowedIps String[] @default([])
|
||||||
|
expiresAt DateTime?
|
||||||
|
revokedAt DateTime?
|
||||||
|
|
||||||
|
@@index([revokedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einmal-Tokens (SSO-Abschluss, MFA-Zwischenschritt), die schon benutzt
|
||||||
|
// wurden. Ein zweiter Versuch mit derselben jti wird abgelehnt.
|
||||||
|
model ConsumedToken {
|
||||||
|
jti String @id
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
@@ -70,6 +177,9 @@ model RefreshToken {
|
|||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
|
// Anmeldezeitpunkt der Sitzung, an jede rotierte Nachfolge-Token vererbt:
|
||||||
|
// spaetestens SESSION_MAX_AGE danach ist neu anzumelden.
|
||||||
|
sessionStartedAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
@@ -83,6 +193,9 @@ enum AuditAction {
|
|||||||
LOGIN_FAILED
|
LOGIN_FAILED
|
||||||
LOGOUT
|
LOGOUT
|
||||||
TOKEN_REFRESHED
|
TOKEN_REFRESHED
|
||||||
|
MFA_ENABLED
|
||||||
|
MFA_DISABLED
|
||||||
|
ACCOUNT_LOCKED
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
@@ -126,6 +239,8 @@ model Apiary {
|
|||||||
colonies Colony[]
|
colonies Colony[]
|
||||||
colonyMoves ColonyApiaryHistory[]
|
colonyMoves ColonyApiaryHistory[]
|
||||||
harvests Harvest[]
|
harvests Harvest[]
|
||||||
|
treatments Treatment[]
|
||||||
|
rawMaterialLots RawMaterialLot[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -154,6 +269,15 @@ enum ColonyStatus {
|
|||||||
MERGED
|
MERGED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fuenfstufige Zustandsskala, geteilt von Brut-, Futter- und Gesundheitsstatus.
|
||||||
|
enum ColonyCondition {
|
||||||
|
EXCELLENT
|
||||||
|
GOOD
|
||||||
|
AVERAGE
|
||||||
|
WEAK
|
||||||
|
CRITICAL
|
||||||
|
}
|
||||||
|
|
||||||
model Colony {
|
model Colony {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
code String @unique
|
code String @unique
|
||||||
@@ -164,9 +288,9 @@ model Colony {
|
|||||||
colonyType ColonyType
|
colonyType ColonyType
|
||||||
status ColonyStatus @default(ACTIVE)
|
status ColonyStatus @default(ACTIVE)
|
||||||
strength Int?
|
strength Int?
|
||||||
broodStatus String?
|
broodStatus ColonyCondition?
|
||||||
feedStatus String?
|
feedStatus ColonyCondition?
|
||||||
healthStatus String?
|
healthStatus ColonyCondition?
|
||||||
notes String?
|
notes String?
|
||||||
currentQueenId String? @unique
|
currentQueenId String? @unique
|
||||||
currentQueen Queen? @relation("ColonyCurrentQueen", fields: [currentQueenId], references: [id])
|
currentQueen Queen? @relation("ColonyCurrentQueen", fields: [currentQueenId], references: [id])
|
||||||
@@ -181,6 +305,7 @@ model Colony {
|
|||||||
inspections Inspection[]
|
inspections Inspection[]
|
||||||
varroaMeasurements VarroaMeasurement[]
|
varroaMeasurements VarroaMeasurement[]
|
||||||
swarmControlChecks SwarmControlCheck[]
|
swarmControlChecks SwarmControlCheck[]
|
||||||
|
winterChecks WinterCheck[]
|
||||||
treatments Treatment[]
|
treatments Treatment[]
|
||||||
feedings Feeding[]
|
feedings Feeding[]
|
||||||
harvests HarvestColony[]
|
harvests HarvestColony[]
|
||||||
@@ -396,11 +521,17 @@ model Task {
|
|||||||
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
|
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
|
||||||
sourceTemplateId String?
|
sourceTemplateId String?
|
||||||
sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id])
|
sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id])
|
||||||
|
// Set when an automation rule created the task (not a person). Keeps an
|
||||||
|
// automatic suggestion distinguishable from a planned task, and lets the
|
||||||
|
// rule see that it already left an open task for the same object instead
|
||||||
|
// of piling up a duplicate on every further trigger.
|
||||||
|
sourceAutomationTrigger AutomationTriggerType?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
|
||||||
@@index([status, dueDate])
|
@@index([status, dueDate])
|
||||||
@@index([relatedType, relatedId])
|
@@index([relatedType, relatedId])
|
||||||
|
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TaskTemplateRecurrenceType {
|
enum TaskTemplateRecurrenceType {
|
||||||
@@ -500,6 +631,54 @@ model SwarmControlCheck {
|
|||||||
@@index([colonyId, date])
|
@@index([colonyId, date])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ein- und Auswinterung, Winterverluste
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum WinterCheckSeason {
|
||||||
|
AUTUMN // Einwinterung
|
||||||
|
SPRING // Auswinterung
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WinterOutcome {
|
||||||
|
SURVIVED
|
||||||
|
WEAK
|
||||||
|
LOST
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verlustkategorien des COLOSS-Monitorings: totes/leeres Volk, unloesbares
|
||||||
|
// Koeniginnenproblem, Naturereignis. Bewusst keine Diagnose (Varroa, Virus
|
||||||
|
// ...): die gehoert, wenn ueberhaupt bekannt, in die Notiz.
|
||||||
|
enum ColonyLossCause {
|
||||||
|
DEAD_OR_EMPTY
|
||||||
|
QUEEN_PROBLEM
|
||||||
|
NATURAL_DISASTER
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Einwinterung und eine Auswinterung je Volk und Winter. winterYear ist
|
||||||
|
// das Jahr, in dem der Winter beginnt (Winter 2026/27 = 2026).
|
||||||
|
model WinterCheck {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
colonyId String
|
||||||
|
colony Colony @relation(fields: [colonyId], references: [id])
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
winterYear Int
|
||||||
|
season WinterCheckSeason
|
||||||
|
date DateTime
|
||||||
|
occupiedSeams Int?
|
||||||
|
feedStockKg Float?
|
||||||
|
queenPresent Boolean?
|
||||||
|
outcome WinterOutcome?
|
||||||
|
lossCause ColonyLossCause?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([colonyId, winterYear, season])
|
||||||
|
@@index([winterYear])
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Behandlung / Tierarzneimittel (Abschnitt 4.11)
|
// Behandlung / Tierarzneimittel (Abschnitt 4.11)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -520,6 +699,15 @@ model Treatment {
|
|||||||
reason String?
|
reason String?
|
||||||
batchNumber String?
|
batchNumber String?
|
||||||
notes String?
|
notes String?
|
||||||
|
withdrawalPeriodDays Int?
|
||||||
|
// Bestandsbuch (Art. 108 VO (EU) 2019/6): where the colony stood on the
|
||||||
|
// day of the treatment - kept with the treatment because the colony may
|
||||||
|
// be moved later - and the supplier / purchase proof / prescribing vet.
|
||||||
|
apiaryId String?
|
||||||
|
apiary Apiary? @relation(fields: [apiaryId], references: [id])
|
||||||
|
supplier String?
|
||||||
|
purchaseProof String?
|
||||||
|
prescribingVet String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([colonyId, date])
|
@@index([colonyId, date])
|
||||||
@@ -575,6 +763,7 @@ model Harvest {
|
|||||||
|
|
||||||
colonies HarvestColony[]
|
colonies HarvestColony[]
|
||||||
extractions ExtractionHarvest[]
|
extractions ExtractionHarvest[]
|
||||||
|
rawMaterialLots RawMaterialLot[]
|
||||||
|
|
||||||
@@index([apiaryId, date])
|
@@index([apiaryId, date])
|
||||||
}
|
}
|
||||||
@@ -647,6 +836,7 @@ model HoneyBatch {
|
|||||||
measurements HoneyMeasurement[]
|
measurements HoneyMeasurement[]
|
||||||
bottlingBatches BottlingBatch[]
|
bottlingBatches BottlingBatch[]
|
||||||
relocations HoneyBatchRelocation[]
|
relocations HoneyBatchRelocation[]
|
||||||
|
containerAllocations HoneyBatchContainerAllocation[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([extractionId])
|
@@index([extractionId])
|
||||||
@@ -695,6 +885,7 @@ model StorageContainer {
|
|||||||
honeyBatches HoneyBatch[]
|
honeyBatches HoneyBatch[]
|
||||||
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
|
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
|
||||||
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
|
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
|
||||||
|
batchAllocations HoneyBatchContainerAllocation[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -717,6 +908,29 @@ model HoneyBatchRelocation {
|
|||||||
@@index([honeyBatchId, relocatedAt])
|
@@index([honeyBatchId, relocatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aufteilung einer Honigcharge auf mehrere Lagerbehälter (z.B. 125 kg auf 5
|
||||||
|
// Eimer à 25 kg) - additiv zum obigen "primären" storageContainerId/
|
||||||
|
// Umlagern-Fluss auf HoneyBatch, der weiterhin den einfachen Ein-Behälter-
|
||||||
|
// Fall abdeckt; diese Tabelle bildet zusätzlich den Fall ab, dass eine
|
||||||
|
// Charge zu groß für einen einzelnen Behälter ist. @@unique macht das
|
||||||
|
// Zuweisen einer Menge zu einem Behälter ein Upsert (erneutes Zuweisen
|
||||||
|
// überschreibt die Menge, statt einen Duplikat-Eintrag anzulegen).
|
||||||
|
model HoneyBatchContainerAllocation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
honeyBatchId String
|
||||||
|
honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id])
|
||||||
|
storageContainerId String
|
||||||
|
storageContainer StorageContainer @relation(fields: [storageContainerId], references: [id])
|
||||||
|
amount Float
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// The name the migration created (an older Prisma shortened it). Mapped
|
||||||
|
// explicitly so schema and database agree and no rename migration is due.
|
||||||
|
@@unique([honeyBatchId, storageContainerId], map: "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key")
|
||||||
|
@@index([storageContainerId])
|
||||||
|
}
|
||||||
|
|
||||||
enum StockCategory {
|
enum StockCategory {
|
||||||
HONEY
|
HONEY
|
||||||
CONSUMABLE
|
CONSUMABLE
|
||||||
@@ -772,6 +986,22 @@ enum ProductStatus {
|
|||||||
DISCONTINUED
|
DISCONTINUED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wo der Bestand eines Artikels liegt: Honig je Abfuellung (BottlingBatch),
|
||||||
|
// alles andere (Kerzen, Propolis-Produkte, ...) je Herstellungscharge
|
||||||
|
// (ProductionBatch). Nicht umstellbar, sobald Chargen existieren.
|
||||||
|
enum ProductKind {
|
||||||
|
BOTTLED_HONEY
|
||||||
|
MANUFACTURED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorgabe fuer die Kategorie des Produkts im Webshop (Wabenhain).
|
||||||
|
enum ShopCategory {
|
||||||
|
HONIG
|
||||||
|
KERZEN
|
||||||
|
PROPOLIS
|
||||||
|
SONSTIGES
|
||||||
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -781,14 +1011,51 @@ model Product {
|
|||||||
weight Float?
|
weight Float?
|
||||||
salesUnit String?
|
salesUnit String?
|
||||||
status ProductStatus @default(ACTIVE)
|
status ProductStatus @default(ACTIVE)
|
||||||
|
kind ProductKind @default(BOTTLED_HONEY)
|
||||||
|
shopCategory ShopCategory @default(HONIG)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
bottlingBatches BottlingBatch[]
|
bottlingBatches BottlingBatch[]
|
||||||
|
productionBatches ProductionBatch[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Etikettenvorlage mit {{Platzhalter}}-Text, wird beim Drucken mit den
|
||||||
|
// Daten einer Abfuellung befuellt (siehe LabelTemplatesService.renderLabelsPdf).
|
||||||
|
model LabelTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
// Array of LabelElement objects (text/placeholder/qrCode/image/line), see
|
||||||
|
// label-templates.service.ts. Drives both the in-browser canvas designer
|
||||||
|
// and the pdfkit print renderer from the same source of truth.
|
||||||
|
elements Json @default("[]")
|
||||||
|
widthMm Float @default(90)
|
||||||
|
heightMm Float @default(35)
|
||||||
|
// A4 sheet layout. columns/rows are nullable: null means "auto-fit as many
|
||||||
|
// as fit" (the original behaviour); a real label sheet product (e.g. a
|
||||||
|
// specific Avery SKU) publishes an exact columns/rows/margin/gap grid, so
|
||||||
|
// setting these lets a template's printed positions match the physical,
|
||||||
|
// pre-die-cut sheet instead of just whatever the naive fit computes.
|
||||||
|
columns Int?
|
||||||
|
rows Int?
|
||||||
|
marginTopMm Float @default(5)
|
||||||
|
marginLeftMm Float @default(5)
|
||||||
|
gapXMm Float @default(2)
|
||||||
|
gapYMm Float @default(2)
|
||||||
|
// Kleine Schnittmarken im Blattrand, eine an jedem Ende jeder
|
||||||
|
// Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten
|
||||||
|
// Etikettenbögen, die nicht geschnitten werden.
|
||||||
|
cropMarks Boolean @default(true)
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([active])
|
||||||
|
}
|
||||||
|
|
||||||
model BottlingBatch {
|
model BottlingBatch {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
honeyBatchId String
|
honeyBatchId String
|
||||||
@@ -805,10 +1072,167 @@ model BottlingBatch {
|
|||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
movements BatchMovement[]
|
||||||
|
|
||||||
@@index([honeyBatchId])
|
@@index([honeyBatchId])
|
||||||
@@index([productId])
|
@@index([productId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Herstellungschargen und Bestandsbewegungen (Bestandsabgleich mit dem Shop,
|
||||||
|
// docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Gegenstueck zur Abfuellung fuer Artikel der Art MANUFACTURED (Kerzen,
|
||||||
|
// Propolis-Tinkturen, Salben, ...). Spaeter Ziel eines Herstellungslaufs, der
|
||||||
|
// Rohstoff-Chargen verbraucht.
|
||||||
|
model ProductionBatch {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productId String
|
||||||
|
product Product @relation(fields: [productId], references: [id])
|
||||||
|
lotNumber String @unique
|
||||||
|
date DateTime
|
||||||
|
// Hergestellte Stueckzahl - Ausgangsmenge fuer den Rest.
|
||||||
|
quantity Int
|
||||||
|
bestBeforeDate DateTime?
|
||||||
|
notes String?
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
movements BatchMovement[]
|
||||||
|
// Woraus die Charge hergestellt wurde (Rueckverfolgung Rohstoff -> Ware).
|
||||||
|
inputs ProductionInput[]
|
||||||
|
|
||||||
|
@@index([productId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rohstoffe (Wachs, Propolis) und ihr Verbrauch in der Herstellung
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum RawMaterialKind {
|
||||||
|
BEESWAX
|
||||||
|
PROPOLIS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Rohstoff-Charge, z. B. "Wachs 2026-01: 4200 g" aus dem Entdeckeln
|
||||||
|
// oder "Propolis 2026-02: 180 g" vom Abkratzen der Gitter. Menge in Gramm;
|
||||||
|
// der Rest folgt wie bei allen Chargen aus dem Bewegungsbuch.
|
||||||
|
model RawMaterialLot {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
kind RawMaterialKind
|
||||||
|
lotNumber String @unique
|
||||||
|
// Gewinnungsdatum
|
||||||
|
date DateTime
|
||||||
|
// Gewonnene Menge in Gramm - Ausgangsmenge fuer den Rest.
|
||||||
|
quantity Int
|
||||||
|
// Herkunft, optional: Stand und/oder Ernte, aus der der Rohstoff stammt.
|
||||||
|
apiaryId String?
|
||||||
|
apiary Apiary? @relation(fields: [apiaryId], references: [id])
|
||||||
|
harvestId String?
|
||||||
|
harvest Harvest? @relation(fields: [harvestId], references: [id])
|
||||||
|
notes String?
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
movements BatchMovement[]
|
||||||
|
usedIn ProductionInput[]
|
||||||
|
|
||||||
|
@@index([kind])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbrauch einer Rohstoff-Charge in einer Herstellungscharge. Die
|
||||||
|
// Abbuchung selbst steht als CONSUMPTION im Bewegungsbuch des Rohstoffs.
|
||||||
|
model ProductionInput {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
productionBatchId String
|
||||||
|
productionBatch ProductionBatch @relation(fields: [productionBatchId], references: [id], onDelete: Cascade)
|
||||||
|
rawMaterialLotId String
|
||||||
|
rawMaterialLot RawMaterialLot @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
|
||||||
|
// Verbrauchte Menge in Gramm.
|
||||||
|
quantity Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([productionBatchId])
|
||||||
|
@@index([rawMaterialLotId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BatchMovementType {
|
||||||
|
SHOP_SALE
|
||||||
|
SHOP_RETURN
|
||||||
|
WITHDRAWAL
|
||||||
|
CORRECTION
|
||||||
|
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
|
||||||
|
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
|
||||||
|
CONSUMPTION
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WithdrawalReason {
|
||||||
|
HOFLADEN
|
||||||
|
EIGENBEDARF
|
||||||
|
BRUCH
|
||||||
|
PROBE
|
||||||
|
SONSTIGES
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bewegungsbuch: jede Bestandsaenderung einer Charge, nie geloescht (Fehler
|
||||||
|
// werden gegengebucht). Rest = Ausgangsmenge + Summe aller quantity.
|
||||||
|
// Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der
|
||||||
|
// Migration).
|
||||||
|
model BatchMovement {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
bottlingBatchId String?
|
||||||
|
bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict)
|
||||||
|
productionBatchId String?
|
||||||
|
productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict)
|
||||||
|
rawMaterialLotId String?
|
||||||
|
rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict)
|
||||||
|
type BatchMovementType
|
||||||
|
// Mit Vorzeichen: minus = raus, plus = rein.
|
||||||
|
quantity Int
|
||||||
|
reason WithdrawalReason?
|
||||||
|
notes String?
|
||||||
|
shopRequestId String?
|
||||||
|
shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict)
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
apiKeyId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([bottlingBatchId])
|
||||||
|
@@index([productionBatchId])
|
||||||
|
@@index([rawMaterialLotId])
|
||||||
|
@@index([shopRequestId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ShopStockRequestKind {
|
||||||
|
SALE
|
||||||
|
RETURN
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Buchungsanfrage des Shops. externalId (vom Shop vergeben) macht jede
|
||||||
|
// Anfrage idempotent: dieselbe Anfrage zweimal bucht nur einmal.
|
||||||
|
model ShopStockRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
externalId String @unique
|
||||||
|
kind ShopStockRequestKind
|
||||||
|
// Ziel: eine Abfuellung (Honig) ODER ein Artikel (MANUFACTURED, FIFO).
|
||||||
|
bottlingBatchId String?
|
||||||
|
productId String?
|
||||||
|
quantity Int
|
||||||
|
reversesExternalId String?
|
||||||
|
apiKeyId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
movements BatchMovement[]
|
||||||
|
|
||||||
|
@@index([reversesExternalId])
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Inventar (Abschnitt 4.22) – v1.x Modul 24
|
// Inventar (Abschnitt 4.22) – v1.x Modul 24
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -917,6 +1341,16 @@ enum AutomationTriggerType {
|
|||||||
QUEENLESS_NO_EGGS
|
QUEENLESS_NO_EGGS
|
||||||
VARROA_THRESHOLD
|
VARROA_THRESHOLD
|
||||||
FEED_STATUS_CRITICAL
|
FEED_STATUS_CRITICAL
|
||||||
|
SWARM_MOOD_HIGH
|
||||||
|
COLONY_HEALTH_CRITICAL
|
||||||
|
STOCK_ITEM_LOW
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT
|
||||||
|
TREATMENT_FOLLOW_UP
|
||||||
|
FEEDING_FOLLOW_UP
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT
|
||||||
|
RAW_MATERIAL_LOW
|
||||||
}
|
}
|
||||||
|
|
||||||
model AutomationRule {
|
model AutomationRule {
|
||||||
@@ -925,6 +1359,165 @@ model AutomationRule {
|
|||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
config Json?
|
config Json?
|
||||||
actionTemplate String?
|
actionTemplate String?
|
||||||
|
// Running counter for the number-format rules (*_NUMBER_FORMAT) - unused
|
||||||
|
// by the threshold-style rules. Kept as
|
||||||
|
// a dedicated column (not inside `config`) so it can be incremented with a
|
||||||
|
// single atomic `UPDATE ... SET sequenceValue = sequenceValue + 1`,
|
||||||
|
// race-safe under concurrent batch/bottling creation.
|
||||||
|
sequenceValue Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
sequences AutomationSequence[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate counters of one number-format rule, one per scope - e.g. per raw
|
||||||
|
// material kind when the format contains {ART}, so wax and propolis each
|
||||||
|
// count WA-2026-0001, WA-2026-0002, ... and PR-2026-0001, ... on their own.
|
||||||
|
// Without {ART} the rule keeps using AutomationRule.sequenceValue: separate
|
||||||
|
// counters behind an identical prefix would produce duplicate numbers.
|
||||||
|
model AutomationSequence {
|
||||||
|
triggerType AutomationTriggerType
|
||||||
|
rule AutomationRule @relation(fields: [triggerType], references: [triggerType], onDelete: Cascade)
|
||||||
|
scope String
|
||||||
|
value Int @default(0)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@id([triggerType, scope])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Website-Einstellungen – Social-Media-Links auf der öffentlichen Herkunftsseite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Singleton row (fixed id) rather than a key/value table: exactly one set of
|
||||||
|
// site-wide social links exists, and a fixed-id upsert is simpler than
|
||||||
|
// enforcing "at most one row" any other way.
|
||||||
|
model SiteSettings {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
youtubeUrl String?
|
||||||
|
instagramUrl String?
|
||||||
|
tiktokUrl String?
|
||||||
|
websiteUrl String?
|
||||||
|
// Name and address of the bee keeper, printed on the Bestandsbuch. Never
|
||||||
|
// part of any public page.
|
||||||
|
holderName String?
|
||||||
|
holderAddress String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backup-Einstellungen – GUI-Override für den automatischen Backup-Zeitplan.
|
||||||
|
// Jede der drei Umgebungsvariablen (BACKUP_STORAGE_PATH/_CRON_SCHEDULE/
|
||||||
|
// _RETENTION_COUNT) hat Vorrang vor dem hier gespeicherten Wert - diese Zeile
|
||||||
|
// greift nur pro Feld, wenn die jeweilige Env-Variable nicht gesetzt ist.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
model BackupSettings {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
storagePath String?
|
||||||
|
cronSchedule String?
|
||||||
|
retentionCount Int?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kalender-Synchronisation – einseitige Übertragung (ApiCore → M365) von
|
||||||
|
// Kalendereinträgen in den Kalender einer Microsoft-365-Gruppe via Graph API.
|
||||||
|
// Zugangsdaten (Tenant/Client-ID/-Secret) sind bewusst NICHT Teil dieses
|
||||||
|
// Modells - wie bei OIDC_CLIENT_SECRET nur über Env-Variablen (Secret), nie
|
||||||
|
// über die GUI editierbar. groupId ist keine Zugangsdaten-Information und
|
||||||
|
// daher hier GUI-konfigurierbar.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum CalendarSyncSourceType {
|
||||||
|
TASK
|
||||||
|
INSPECTION
|
||||||
|
TREATMENT
|
||||||
|
FEEDING
|
||||||
|
HARVEST
|
||||||
|
QUEEN_ASSIGNMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CalendarSyncStatus {
|
||||||
|
PENDING
|
||||||
|
SYNCED
|
||||||
|
FAILED
|
||||||
|
SKIPPED
|
||||||
|
}
|
||||||
|
|
||||||
|
model CalendarSyncSettings {
|
||||||
|
id String @id @default("singleton")
|
||||||
|
enabled Boolean @default(false)
|
||||||
|
groupId String?
|
||||||
|
syncTask Boolean @default(true)
|
||||||
|
syncInspection Boolean @default(true)
|
||||||
|
syncTreatment Boolean @default(true)
|
||||||
|
syncFeeding Boolean @default(true)
|
||||||
|
syncHarvest Boolean @default(true)
|
||||||
|
syncQueenAssignment Boolean @default(true)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outbox statt synchronem Push direkt im Request-Pfad (anders als das
|
||||||
|
// Webhooks-Modul): Graph-API-Aufrufe sind zu langsam/fehleranfällig
|
||||||
|
// (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/
|
||||||
|
// Aufgaben-Erstellung darauf warten zu lassen. Ein periodischer Worker
|
||||||
|
// verarbeitet PENDING-Zeilen und wiederholt fehlgeschlagene Versuche.
|
||||||
|
// @@unique auf [sourceType, sourceId] macht enqueue() ein Upsert: ein
|
||||||
|
// erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das
|
||||||
|
// bestehende Graph-Event über die gespeicherte graphEventId, statt ein
|
||||||
|
// Duplikat anzulegen.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Idempotenz – schützt Anlege-Anfragen vor doppelter Ausführung, wenn ein
|
||||||
|
// Client dieselbe Anfrage wiederholt (Offline-Warteschlange nach verlorener
|
||||||
|
// Antwort, Netzabbruch, erneutes Absenden). Der Client schickt pro fachlichem
|
||||||
|
// Vorgang einen eindeutigen `Idempotency-Key`; eine Wiederholung bekommt die
|
||||||
|
// gespeicherte erste Antwort statt eines zweiten Datensatzes. Siehe
|
||||||
|
// src/common/idempotency/idempotency.interceptor.ts.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum IdempotencyStatus {
|
||||||
|
PENDING
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
|
model IdempotencyRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
key String
|
||||||
|
// "POST /api/v1/inspections" - a key reused for a different endpoint is
|
||||||
|
// rejected instead of replaying an unrelated response.
|
||||||
|
scope String
|
||||||
|
status IdempotencyStatus @default(PENDING)
|
||||||
|
statusCode Int?
|
||||||
|
responseBody Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, key])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CalendarSyncOutboxEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sourceType CalendarSyncSourceType
|
||||||
|
sourceId String
|
||||||
|
status CalendarSyncStatus @default(PENDING)
|
||||||
|
graphEventId String?
|
||||||
|
attempts Int @default(0)
|
||||||
|
lastError String?
|
||||||
|
// Set while one worker (pod, cron tick, "Jetzt synchronisieren") writes
|
||||||
|
// this entry to M365, so no second worker creates the same event. A claim
|
||||||
|
// older than the stale timeout belongs to a crashed worker and is retaken.
|
||||||
|
claimedAt DateTime?
|
||||||
|
// Counts every enqueue: a worker only marks the entry SYNCED if nothing
|
||||||
|
// was enqueued since it claimed it - an edit made during the sync is not
|
||||||
|
// lost but synced on the next pass.
|
||||||
|
revision Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([sourceType, sourceId])
|
||||||
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,203 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { PrismaClient } from '../generated/prisma';
|
import { Prisma, PrismaClient } from '../generated/prisma';
|
||||||
|
|
||||||
const BCRYPT_SALT_ROUNDS = 12;
|
const BCRYPT_SALT_ROUNDS = 12;
|
||||||
const DEFAULT_ADMIN_EMAIL = '[email protected]';
|
const DEFAULT_ADMIN_EMAIL = '[email protected]';
|
||||||
const DEFAULT_ADMIN_PASSWORD = 'ChangeMe123!';
|
|
||||||
|
// No fixed fallback password: a fixed literal shipped in source would be a
|
||||||
|
// well-known, guessable credential for any deployment that forgets to set
|
||||||
|
// SEED_ADMIN_PASSWORD. A random one is generated instead and printed once
|
||||||
|
// below, same as an operator-supplied one would be.
|
||||||
|
function generateRandomPassword(): string {
|
||||||
|
return crypto.randomBytes(18).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only printed the moment the account is actually created (first deploy on
|
||||||
|
// an empty database), never on later restarts: by then the real admin may
|
||||||
|
// have changed the password via /profile, so repeating it would just be
|
||||||
|
// stale and would leak credentials into logs indefinitely for no reason.
|
||||||
|
function printLoginBanner(email: string, password: string): void {
|
||||||
|
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
||||||
|
const line = '='.repeat(60);
|
||||||
|
console.log(line);
|
||||||
|
console.log('Seed: Admin-Zugang eingerichtet.');
|
||||||
|
console.log(` URL: ${url}`);
|
||||||
|
console.log(` Benutzer: ${email}`);
|
||||||
|
console.log(` Passwort: ${password}`);
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicated from src/task-templates/recurrence.ts rather than imported:
|
||||||
|
// tsconfig.seed.json scopes this build to rootDir "./prisma" only (its own
|
||||||
|
// isolated compile, separate from the main Nest build), so a cross-rootDir
|
||||||
|
// import would fail to compile. Only the YEARLY case is needed here.
|
||||||
|
function nextYearlyOccurrenceUtc(
|
||||||
|
month: number,
|
||||||
|
dayOfMonth: number,
|
||||||
|
from: Date,
|
||||||
|
): Date {
|
||||||
|
const year = from.getUTCFullYear();
|
||||||
|
const clampedDay = (y: number): number =>
|
||||||
|
Math.min(dayOfMonth, new Date(Date.UTC(y, month, 0)).getUTCDate());
|
||||||
|
const today = new Date(
|
||||||
|
Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()),
|
||||||
|
);
|
||||||
|
const candidate = new Date(Date.UTC(year, month - 1, clampedDay(year)));
|
||||||
|
if (candidate.getTime() >= today.getTime()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return new Date(Date.UTC(year + 1, month - 1, clampedDay(year + 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DefaultTaskTemplate {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
|
||||||
|
month: number;
|
||||||
|
dayOfMonth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The standard German-beekeeping annual cycle, one flagship task per month -
|
||||||
|
// gives a new deployment a ready-made starter calendar instead of a
|
||||||
|
// completely empty task-template list. Admins can edit/deactivate any of
|
||||||
|
// these afterward; this only ever runs once (see the count() guard below).
|
||||||
|
const DEFAULT_TASK_TEMPLATES: DefaultTaskTemplate[] = [
|
||||||
|
{
|
||||||
|
title: 'Wintersitz kontrollieren',
|
||||||
|
description:
|
||||||
|
'Ruhige Kontrolle von außen: Flugloch frei, Gewicht per Anheben schätzen, auf Mäuse-/Spechtschäden achten. Beute möglichst nicht öffnen.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 1,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gemülldiagnose',
|
||||||
|
description:
|
||||||
|
'Windel auswerten: natürlicher Varroa-Totenfall, Bienensitz und Lage der Wintertraube anhand des Gemülls einschätzen.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 2,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Frühjahrsdurchsicht',
|
||||||
|
description:
|
||||||
|
'Erste Innendurchsicht bei ca. 15°C und ruhigem Wetter: Brutnest, Futtervorrat, Volksstärke und Weiselrichtigkeit prüfen.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 3,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Volk erweitern',
|
||||||
|
description:
|
||||||
|
'Zargen/Rähmchen nach Volksstärke erweitern, Baurahmen einhängen, Platz für die Frühtracht schaffen.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 4,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Schwarmkontrolle beginnen',
|
||||||
|
description:
|
||||||
|
'Wöchentlich auf Weiselzellen kontrollieren, bis zum Ende der Schwarmzeit fortführen. Maßnahmen bei Bedarf: Ableger bilden, Zellen brechen.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 5,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Honigernte vorbereiten',
|
||||||
|
description:
|
||||||
|
'Reifegrad der Frühtracht prüfen (Spritzprobe), Schleuderplan und Gerätereinigung vorbereiten.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 6,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Varroa-Kontrolle nach der Ernte',
|
||||||
|
description:
|
||||||
|
'Milbenbefall messen (z. B. Puderzuckermethode oder natürlicher Totenfall) und Behandlung planen, sobald die Honigräume abgeräumt sind.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 7,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Einfütterung beginnen',
|
||||||
|
description:
|
||||||
|
'Wintervorrat auffüttern (Zielgewicht je nach Beutensystem), Futteraufnahme nach ein bis zwei Wochen kontrollieren.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 8,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Varroa-Restentmilbung / Winterfestigkeit prüfen',
|
||||||
|
description:
|
||||||
|
'Zweite Varroabehandlung durchführen, Volksstärke und Weiselrichtigkeit für den Winter beurteilen.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 9,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fütterung abschließen, Mäusegitter anbringen',
|
||||||
|
description:
|
||||||
|
'Letzte Futtergabe, Fluglochkeile bzw. Mäusegitter montieren, Bienenstand winterfest machen.',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
month: 10,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Winterbehandlung (Oxalsäure)',
|
||||||
|
description:
|
||||||
|
'Brutfreiheit abwarten und Träufel- oder Sprühbehandlung gegen Varroa durchführen.',
|
||||||
|
priority: 'HIGH',
|
||||||
|
month: 11,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Jahresrückblick und Gerätewartung',
|
||||||
|
description:
|
||||||
|
'Saison auswerten, Zargen und Rähmchen reinigen/reparieren, Material für die kommende Saison planen.',
|
||||||
|
priority: 'LOW',
|
||||||
|
month: 12,
|
||||||
|
dayOfMonth: 15,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
|
||||||
|
const existingCount = await prisma.taskTemplate.count();
|
||||||
|
if (existingCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
await prisma.taskTemplate.createMany({
|
||||||
|
data: DEFAULT_TASK_TEMPLATES.map((template) => ({
|
||||||
|
title: template.title,
|
||||||
|
description: template.description,
|
||||||
|
priority: template.priority,
|
||||||
|
recurrenceType: 'YEARLY',
|
||||||
|
recurrenceMonth: template.month,
|
||||||
|
recurrenceDayOfMonth: template.dayOfMonth,
|
||||||
|
nextDueDate: nextYearlyOccurrenceUtc(
|
||||||
|
template.month,
|
||||||
|
template.dayOfMonth,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Seed: ${DEFAULT_TASK_TEMPLATES.length} Standard-Aufgabenvorlagen angelegt.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await seedDefaultTaskTemplates(prisma);
|
||||||
|
|
||||||
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
const email = process.env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
|
||||||
const password = process.env.SEED_ADMIN_PASSWORD ?? DEFAULT_ADMIN_PASSWORD;
|
const password = process.env.SEED_ADMIN_PASSWORD ?? generateRandomPassword();
|
||||||
const existing = await prisma.user.findUnique({ where: { email } });
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`Seed: admin user ${email} already exists, skipping.`);
|
console.log(`Seed: admin user ${email} already exists, skipping.`);
|
||||||
@@ -22,10 +206,35 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||||
await prisma.user.create({
|
const adminRole = await prisma.role.findUniqueOrThrow({
|
||||||
data: { email, passwordHash, name: 'Administrator', role: 'ADMIN' },
|
where: { key: 'ADMIN' },
|
||||||
});
|
});
|
||||||
console.log(`Seed: created admin user ${email}.`);
|
try {
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Administrator',
|
||||||
|
roleId: adminRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
printLoginBanner(email, password);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// P2002 = unique constraint violation on email: another replica's
|
||||||
|
// startup won this race between the existence check above and this
|
||||||
|
// create() call. The user now exists either way, so this is a no-op,
|
||||||
|
// not a failure - container startup must not crash on it.
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`Seed: admin user ${email} was created concurrently, skipping.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions an API key may carry. Deliberately a short allowlist, not the
|
||||||
|
* whole catalog: a key is a long-lived secret stored in another system, so it
|
||||||
|
* only ever gets what a machine integration needs. shop-sync:write lets the
|
||||||
|
* shop book its own sales and returns - nothing else - and every booking is
|
||||||
|
* idempotent and kept in the ledger, so a leaked key cannot erase history.
|
||||||
|
* erp-inventory:read lets ERPNext read the inventory mirror (articles,
|
||||||
|
* batches with remaining stock, stock movements, harvest totals) - read
|
||||||
|
* only, no people, no notes. Removing an entry here also takes it away from
|
||||||
|
* existing keys (see ApiKeysService.authenticate).
|
||||||
|
*/
|
||||||
|
export const API_KEY_SCOPES = [
|
||||||
|
'shop-sync:read',
|
||||||
|
'shop-sync:write',
|
||||||
|
'erp-inventory:read',
|
||||||
|
] as const satisfies readonly PermissionKey[];
|
||||||
|
|
||||||
|
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||||
|
|
||||||
|
export function isApiKeyScope(value: string): value is ApiKeyScope {
|
||||||
|
return (API_KEY_SCOPES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
|
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||||
|
import {
|
||||||
|
ApiKeySummary,
|
||||||
|
ApiKeysService,
|
||||||
|
CreatedApiKey,
|
||||||
|
} from './api-keys.service';
|
||||||
|
|
||||||
|
@ApiTags('api-keys')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('api-keys')
|
||||||
|
export class ApiKeysController {
|
||||||
|
constructor(private readonly apiKeys: ApiKeysService) {}
|
||||||
|
|
||||||
|
@RequirePermission('api-keys:manage')
|
||||||
|
@Get()
|
||||||
|
findAll(): Promise<ApiKeySummary[]> {
|
||||||
|
return this.apiKeys.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('api-keys:manage')
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateApiKeyDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
): Promise<CreatedApiKey> {
|
||||||
|
return this.apiKeys.create(dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('api-keys:manage')
|
||||||
|
@Patch(':id')
|
||||||
|
updateAllowedIps(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateApiKeyDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
): Promise<ApiKeySummary> {
|
||||||
|
return this.apiKeys.updateAllowedIps(id, dto, actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission('api-keys:manage')
|
||||||
|
@Post(':id/revoke')
|
||||||
|
revoke(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
): Promise<ApiKeySummary> {
|
||||||
|
return this.apiKeys.revoke(id, actor.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ApiKeysController } from './api-keys.controller';
|
||||||
|
import { ApiKeysService } from './api-keys.service';
|
||||||
|
|
||||||
|
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [ApiKeysController],
|
||||||
|
providers: [ApiKeysService],
|
||||||
|
exports: [ApiKeysService],
|
||||||
|
})
|
||||||
|
export class ApiKeysModule {}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { ForbiddenException, HttpException, Logger } from '@nestjs/common';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import {
|
||||||
|
API_KEY_FAILURE_LIMIT,
|
||||||
|
ApiKeysService,
|
||||||
|
extractApiKeyToken,
|
||||||
|
} from './api-keys.service';
|
||||||
|
|
||||||
|
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
||||||
|
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
||||||
|
const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`;
|
||||||
|
|
||||||
|
function storedKey(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'key-1',
|
||||||
|
name: 'ERPNext',
|
||||||
|
prefix: 'ack_abcd1234',
|
||||||
|
keyHash: createHash('sha256').update(TOKEN).digest('hex'),
|
||||||
|
scopes: ['erp-inventory:read'],
|
||||||
|
createdByUserId: 'user-1',
|
||||||
|
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||||
|
lastUsedAt: null,
|
||||||
|
lastUsedIp: null,
|
||||||
|
allowedIps: [],
|
||||||
|
expiresAt: null,
|
||||||
|
revokedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ApiKeysService.authenticate', () => {
|
||||||
|
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
||||||
|
let service: ApiKeysService;
|
||||||
|
let warn: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
apiKey: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
service = new ApiKeysService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
{ record: jest.fn() } as unknown as AuditLogService,
|
||||||
|
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
||||||
|
);
|
||||||
|
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
function loggedText(): string {
|
||||||
|
return JSON.stringify(warn.mock.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sucht den Schlüssel nur über seinen Hash', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
||||||
|
|
||||||
|
const principal = await service.authenticate(TOKEN, '203.0.113.7');
|
||||||
|
|
||||||
|
expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] });
|
||||||
|
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => {
|
||||||
|
await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt einen unbekannten Schlüssel ab', async () => {
|
||||||
|
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
|
||||||
|
);
|
||||||
|
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(loggedText()).toContain('revoked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt einen abgelaufenen Schlüssel ab', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
|
||||||
|
);
|
||||||
|
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(loggedText()).toContain('expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ revokedAt: new Date() }),
|
||||||
|
);
|
||||||
|
await service.authenticate(TOKEN, '203.0.113.7');
|
||||||
|
await service.authenticate(OTHER_TOKEN, '203.0.113.7');
|
||||||
|
|
||||||
|
const text = loggedText();
|
||||||
|
expect(text).not.toContain(TOKEN);
|
||||||
|
expect(text).not.toContain(OTHER_TOKEN);
|
||||||
|
expect(text).not.toContain('A'.repeat(43));
|
||||||
|
expect(text).not.toContain('B'.repeat(43));
|
||||||
|
expect(text).toContain('ack_abcd1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ scopes: ['erp-inventory:read', 'users:create'] }),
|
||||||
|
);
|
||||||
|
const principal = await service.authenticate(TOKEN, '203.0.113.7');
|
||||||
|
expect(principal?.scopes).toEqual(['erp-inventory:read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => {
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ allowedIps: ['198.51.100.0/24'] }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function failFrom(ip: string, times: number): Promise<void> {
|
||||||
|
for (let i = 0; i < times; i++) {
|
||||||
|
// Unbekannter, aber richtig aufgebauter Schlüssel.
|
||||||
|
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
|
||||||
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||||
|
|
||||||
|
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
|
||||||
|
await expect(blocked).rejects.toBeInstanceOf(HttpException);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
expect(loggedText()).toContain('203.0.113.7');
|
||||||
|
|
||||||
|
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||||
|
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
|
||||||
|
// Andere Adressen sind nicht betroffen.
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
|
||||||
|
).resolves.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
||||||
|
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
||||||
|
|
||||||
|
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
|
||||||
|
id: 'key-1',
|
||||||
|
scopes: ['erp-inventory:read'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
|
||||||
|
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
|
||||||
|
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Weder gesperrt noch eine Datenbankabfrage.
|
||||||
|
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||||
|
).resolves.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
|
||||||
|
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
|
||||||
|
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
||||||
|
await service.authenticate(
|
||||||
|
OTHER_TOKEN,
|
||||||
|
`2001:db8:1:2::${i.toString(16)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
|
||||||
|
).rejects.toMatchObject({ status: 429 });
|
||||||
|
// Ein anderes /64 ist frei.
|
||||||
|
await expect(
|
||||||
|
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
|
||||||
|
).resolves.toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractApiKeyToken', () => {
|
||||||
|
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
||||||
|
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
||||||
|
expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull();
|
||||||
|
expect(extractApiKeyToken(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { ApiKey } from '../../generated/prisma';
|
||||||
|
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||||
|
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
|
||||||
|
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||||
|
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||||
|
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
||||||
|
|
||||||
|
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
(entries ?? [])
|
||||||
|
.map(normalizeAllowlistEntry)
|
||||||
|
.filter((entry): entry is string => entry !== null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ack_<8 lowercase chars>_<32 random bytes, base64url>
|
||||||
|
const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
|
||||||
|
const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
// lastUsedAt is informational; writing it on every request would turn each
|
||||||
|
// read into a write for no benefit.
|
||||||
|
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
|
||||||
|
// "ack_" plus the 8-character prefix: identifies a key in logs without
|
||||||
|
// revealing its secret part.
|
||||||
|
const LOGGABLE_PREFIX_LENGTH = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unknown, revoked or expired keys per client network before that network
|
||||||
|
* gets 429 for further invalid keys for the rest of the window - the same
|
||||||
|
* budget as the shop's ERP export. It slows down guessing and a
|
||||||
|
* misconfigured client; a valid key is never refused by it. Otherwise
|
||||||
|
* anyone able to send requests from (or forge) a shared address - the
|
||||||
|
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
||||||
|
* holds 256 random bits, so letting it through costs nothing.
|
||||||
|
*/
|
||||||
|
export const API_KEY_FAILURE_LIMIT = {
|
||||||
|
maxFailures: 20,
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
maxTrackedSources: 10_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
||||||
|
|
||||||
|
/** What a request authenticated by an API key may do - it is not a user. */
|
||||||
|
export interface ApiKeyPrincipal {
|
||||||
|
id: string;
|
||||||
|
scopes: ApiKeyScope[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An API key as shown in the admin UI - never the hash or the key itself. */
|
||||||
|
export type ApiKeySummary = Omit<ApiKey, 'keyHash'>;
|
||||||
|
|
||||||
|
export interface CreatedApiKey {
|
||||||
|
apiKey: ApiKeySummary;
|
||||||
|
/** The only time the plain key exists outside the caller's system. */
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomPrefix(): string {
|
||||||
|
let prefix = '';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
prefix += PREFIX_ALPHABET[randomInt(PREFIX_ALPHABET.length)];
|
||||||
|
}
|
||||||
|
return `ack_${prefix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(apiKey: ApiKey): ApiKeySummary {
|
||||||
|
const { keyHash, ...summary } = apiKey;
|
||||||
|
void keyHash;
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeysService {
|
||||||
|
private readonly logger = new Logger(ApiKeysService.name);
|
||||||
|
private readonly failures = new FailedAttemptLimiter(API_KEY_FAILURE_LIMIT);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(): Promise<ApiKeySummary[]> {
|
||||||
|
const keys = await this.prisma.apiKey.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return keys.map(toSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateApiKeyDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<CreatedApiKey> {
|
||||||
|
// Nobody hands a machine more than they may do themselves.
|
||||||
|
const callerPermissions =
|
||||||
|
await this.permissionResolver.getPermissions(actingUserId);
|
||||||
|
const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const prefix = randomPrefix();
|
||||||
|
// 32 random bytes: a plain hash of it is safe to store without a slow
|
||||||
|
// KDF - there is nothing guessable to brute-force.
|
||||||
|
const token = `${prefix}_${randomBytes(32).toString('base64url')}`;
|
||||||
|
const expiresAt =
|
||||||
|
dto.expiresInDays !== undefined
|
||||||
|
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const apiKey = await this.prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name.trim(),
|
||||||
|
prefix,
|
||||||
|
keyHash: hashToken(token),
|
||||||
|
scopes: [...new Set(dto.scopes)],
|
||||||
|
allowedIps: normalizeAllowlist(dto.allowedIps),
|
||||||
|
expiresAt,
|
||||||
|
createdByUserId: actingUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = toSummary(apiKey);
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'ApiKey',
|
||||||
|
entityId: apiKey.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
afterData: summary,
|
||||||
|
});
|
||||||
|
return { apiKey: summary, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes where a key may be used from, without issuing a new one. */
|
||||||
|
async updateAllowedIps(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateApiKeyDto,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<ApiKeySummary> {
|
||||||
|
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('API-Schlüssel nicht gefunden');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.apiKey.update({
|
||||||
|
where: { id },
|
||||||
|
data: { allowedIps: normalizeAllowlist(dto.allowedIps) },
|
||||||
|
});
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'ApiKey',
|
||||||
|
entityId: id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: toSummary(before),
|
||||||
|
afterData: toSummary(updated),
|
||||||
|
});
|
||||||
|
return toSummary(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(id: string, actingUserId: string): Promise<ApiKeySummary> {
|
||||||
|
const before = await this.prisma.apiKey.findUnique({ where: { id } });
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('API-Schlüssel nicht gefunden');
|
||||||
|
}
|
||||||
|
if (before.revokedAt) {
|
||||||
|
return toSummary(before);
|
||||||
|
}
|
||||||
|
const revoked = await this.prisma.apiKey.update({
|
||||||
|
where: { id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'ApiKey',
|
||||||
|
entityId: id,
|
||||||
|
action: 'DEACTIVATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: toSummary(before),
|
||||||
|
afterData: toSummary(revoked),
|
||||||
|
});
|
||||||
|
return toSummary(revoked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a presented key to what it may do, or null for anything that
|
||||||
|
* must not pass: malformed, unknown, revoked or expired.
|
||||||
|
*
|
||||||
|
* The key is found by the SHA-256 of the presented token through a unique
|
||||||
|
* index - never by comparing secrets byte by byte, so response timing says
|
||||||
|
* nothing about the stored key. Order matters: the lookup comes first, and
|
||||||
|
* only an invalid key counts against (or is stopped by) the failure limit
|
||||||
|
* of the client network. A malformed token is not a guess at a key and is
|
||||||
|
* refused without lookup and without counting.
|
||||||
|
*/
|
||||||
|
async authenticate(
|
||||||
|
token: string,
|
||||||
|
clientIp?: string,
|
||||||
|
): Promise<ApiKeyPrincipal | null> {
|
||||||
|
if (!TOKEN_PATTERN.test(token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const apiKey = await this.prisma.apiKey.findUnique({
|
||||||
|
where: { keyHash: hashToken(token) },
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
const reason = rejectReason(apiKey, now);
|
||||||
|
if (reason === null && apiKey) {
|
||||||
|
return this.accept(apiKey, clientIp, now);
|
||||||
|
}
|
||||||
|
this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts an invalid key; 429 once the client network is over the limit. */
|
||||||
|
private recordRejection(
|
||||||
|
token: string,
|
||||||
|
reason: RejectReason,
|
||||||
|
clientIp: string | undefined,
|
||||||
|
): void {
|
||||||
|
// Counted per client network like the throttler (IPv6 /64), so rotating
|
||||||
|
// addresses inside one's own prefix does not reset the budget.
|
||||||
|
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
||||||
|
if (this.failures.isBlocked(subject)) {
|
||||||
|
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
||||||
|
// bare string), so clients can read `message` uniformly.
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
error: 'Too Many Requests',
|
||||||
|
message:
|
||||||
|
'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen',
|
||||||
|
},
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Only the public prefix is ever logged, never the key itself.
|
||||||
|
this.logger.warn(
|
||||||
|
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
||||||
|
);
|
||||||
|
if (this.failures.recordFailure(subject)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Invalid API keys from ${subject} refused for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async accept(
|
||||||
|
apiKey: ApiKey,
|
||||||
|
clientIp: string | undefined,
|
||||||
|
now: Date,
|
||||||
|
): Promise<ApiKeyPrincipal> {
|
||||||
|
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
|
||||||
|
// The key itself is right: say so, so the admin finds the cause - a
|
||||||
|
// thief learns nothing new, they already hold the key.
|
||||||
|
this.logger.warn(
|
||||||
|
`API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`,
|
||||||
|
);
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'API-Schlüssel ist für diese IP-Adresse nicht freigegeben',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!apiKey.lastUsedAt ||
|
||||||
|
now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS ||
|
||||||
|
apiKey.lastUsedIp !== (clientIp ?? null)
|
||||||
|
) {
|
||||||
|
await this.prisma.apiKey.update({
|
||||||
|
where: { id: apiKey.id },
|
||||||
|
data: { lastUsedAt: now, lastUsedIp: clientIp ?? null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Intersect with today's allowlist: narrowing it later also narrows
|
||||||
|
// keys issued before.
|
||||||
|
return { id: apiKey.id, scopes: apiKey.scopes.filter(isApiKeyScope) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
|
||||||
|
if (!apiKey) return 'unknown';
|
||||||
|
if (apiKey.revokedAt) return 'revoked';
|
||||||
|
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The key from an `Authorization: Bearer ack_…` header, or null. */
|
||||||
|
export function extractApiKeyToken(
|
||||||
|
authorization: string | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!authorization?.startsWith('Bearer ack_')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return authorization.slice('Bearer '.length).trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
ValidationArguments,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { normalizeAllowlistEntry } from '../ip-allowlist';
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'allowedIp' })
|
||||||
|
export class AllowedIpConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return typeof value === 'string' && normalizeAllowlistEntry(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
return `${String(args.value)} ist keine IP-Adresse und kein Netz (z. B. 203.0.113.7 oder 203.0.113.0/24)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
Validate,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { API_KEY_SCOPES } from '../api-key-scopes';
|
||||||
|
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
|
||||||
|
import { AllowedIpConstraint } from './allowed-ips.validator';
|
||||||
|
|
||||||
|
export class CreateApiKeyDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Wofür der Schlüssel ist, z. B. "Wabenhain-Shop"',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: API_KEY_SCOPES, isArray: true })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsIn(API_KEY_SCOPES, { each: true })
|
||||||
|
scopes!: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Gültigkeit in Tagen; ohne Angabe gilt der Schlüssel bis zum Widerruf',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(730)
|
||||||
|
expiresInDays?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Erlaubte Absender (IP oder CIDR), z. B. die Adresse des Wabenhain-Servers',
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(MAX_ALLOWED_IPS)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(AllowedIpConstraint, { each: true })
|
||||||
|
allowedIps?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ArrayMaxSize, IsArray, IsString, Validate } from 'class-validator';
|
||||||
|
import { MAX_ALLOWED_IPS } from '../ip-allowlist';
|
||||||
|
import { AllowedIpConstraint } from './allowed-ips.validator';
|
||||||
|
|
||||||
|
export class UpdateApiKeyDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Erlaubte Absender (IP oder CIDR); leere Liste = von überall nutzbar',
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(MAX_ALLOWED_IPS)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Validate(AllowedIpConstraint, { each: true })
|
||||||
|
allowedIps!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||||
|
|
||||||
|
describe('FailedAttemptLimiter', () => {
|
||||||
|
let now: number;
|
||||||
|
let limiter: FailedAttemptLimiter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
now = 1_000_000;
|
||||||
|
limiter = new FailedAttemptLimiter({
|
||||||
|
maxFailures: 3,
|
||||||
|
windowMs: 60_000,
|
||||||
|
maxTrackedSources: 100,
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sperrt eine Quelle erst, wenn die Obergrenze erreicht ist', () => {
|
||||||
|
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||||
|
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||||
|
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||||
|
|
||||||
|
// Der Fehlversuch, mit dem die Grenze erreicht wird, meldet die Sperre.
|
||||||
|
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
||||||
|
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet die Sperre nur einmal, nicht bei jedem weiteren Versuch', () => {
|
||||||
|
limiter.recordFailure('203.0.113.7');
|
||||||
|
limiter.recordFailure('203.0.113.7');
|
||||||
|
expect(limiter.recordFailure('203.0.113.7')).toBe(true);
|
||||||
|
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trennt Quellen voneinander', () => {
|
||||||
|
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
||||||
|
expect(limiter.isBlocked('203.0.113.7')).toBe(true);
|
||||||
|
expect(limiter.isBlocked('198.51.100.1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt die Quelle nach Ablauf des Fensters wieder frei', () => {
|
||||||
|
for (let i = 0; i < 3; i++) limiter.recordFailure('203.0.113.7');
|
||||||
|
now += 60_001;
|
||||||
|
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||||
|
// Neues Fenster: der nächste Fehlversuch zählt wieder ab eins.
|
||||||
|
expect(limiter.recordFailure('203.0.113.7')).toBe(false);
|
||||||
|
expect(limiter.isBlocked('203.0.113.7')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vergisst alte Quellen, statt unbegrenzt Speicher zu belegen', () => {
|
||||||
|
const small = new FailedAttemptLimiter({
|
||||||
|
maxFailures: 1,
|
||||||
|
windowMs: 60_000,
|
||||||
|
maxTrackedSources: 2,
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
small.recordFailure('a');
|
||||||
|
small.recordFailure('b');
|
||||||
|
now += 60_001;
|
||||||
|
// Beim dritten Eintrag wird aufgeräumt: a und b sind abgelaufen.
|
||||||
|
small.recordFailure('c');
|
||||||
|
expect(small.trackedSources()).toBe(1);
|
||||||
|
expect(small.isBlocked('c')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verdrängt bei voller Liste den ältesten Eintrag, auch wenn er noch läuft', () => {
|
||||||
|
const small = new FailedAttemptLimiter({
|
||||||
|
maxFailures: 5,
|
||||||
|
windowMs: 60_000,
|
||||||
|
maxTrackedSources: 2,
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
small.recordFailure('a');
|
||||||
|
small.recordFailure('b');
|
||||||
|
small.recordFailure('c');
|
||||||
|
expect(small.trackedSources()).toBe(2);
|
||||||
|
});
|
||||||
|
it('verdrängt bei voller Liste zuerst nicht gesperrte Quellen', () => {
|
||||||
|
const small = new FailedAttemptLimiter({
|
||||||
|
maxFailures: 2,
|
||||||
|
windowMs: 60_000,
|
||||||
|
maxTrackedSources: 2,
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
small.recordFailure('angreifer');
|
||||||
|
small.recordFailure('angreifer'); // gesperrt, ältester Eintrag
|
||||||
|
small.recordFailure('harmlos'); // ein Fehlversuch, nicht gesperrt
|
||||||
|
small.recordFailure('neu');
|
||||||
|
|
||||||
|
expect(small.trackedSources()).toBe(2);
|
||||||
|
// Die Sperre bleibt, obwohl sie der älteste Eintrag ist.
|
||||||
|
expect(small.isBlocked('angreifer')).toBe(true);
|
||||||
|
// Verdrängt wurde der nicht gesperrte Eintrag.
|
||||||
|
expect(small.recordFailure('harmlos')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verdrängt gesperrte Quellen erst, wenn alle gesperrt sind (älteste zuerst)', () => {
|
||||||
|
const small = new FailedAttemptLimiter({
|
||||||
|
maxFailures: 1,
|
||||||
|
windowMs: 60_000,
|
||||||
|
maxTrackedSources: 2,
|
||||||
|
clock: () => now,
|
||||||
|
});
|
||||||
|
small.recordFailure('a');
|
||||||
|
small.recordFailure('b');
|
||||||
|
small.recordFailure('c');
|
||||||
|
|
||||||
|
expect(small.trackedSources()).toBe(2);
|
||||||
|
expect(small.isBlocked('a')).toBe(false);
|
||||||
|
expect(small.isBlocked('b')).toBe(true);
|
||||||
|
expect(small.isBlocked('c')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export interface FailedAttemptLimiterOptions {
|
||||||
|
/** Ab so vielen Fehlversuchen im Fenster ist die Quelle gesperrt. */
|
||||||
|
maxFailures: number;
|
||||||
|
/** Länge des Fensters in Millisekunden, gezählt ab dem ersten Fehlversuch. */
|
||||||
|
windowMs: number;
|
||||||
|
/** Obergrenze gemerkter Quellen - schützt den Speicher bei verteilten Angriffen. */
|
||||||
|
maxTrackedSources: number;
|
||||||
|
clock?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
failures: number;
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt fehlgeschlagene Anmeldungen je Quelle (Client-IP) in einem festen
|
||||||
|
* Zeitfenster und sperrt die Quelle, sobald die Obergrenze erreicht ist.
|
||||||
|
*
|
||||||
|
* Bewusst im Speicher des Prozesses, wie der globale Throttler: ApiCore läuft
|
||||||
|
* mit einem Backend-Pod. Nach einem Neustart beginnt die Zählung von vorn -
|
||||||
|
* das ist für eine Bremse gegen Durchprobieren ausreichend.
|
||||||
|
*/
|
||||||
|
export class FailedAttemptLimiter {
|
||||||
|
private readonly windows = new Map<string, Window>();
|
||||||
|
private readonly clock: () => number;
|
||||||
|
|
||||||
|
constructor(private readonly options: FailedAttemptLimiterOptions) {
|
||||||
|
this.clock = options.clock ?? Date.now;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBlocked(source: string): boolean {
|
||||||
|
const window = this.activeWindow(source);
|
||||||
|
return window !== undefined && window.failures >= this.options.maxFailures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zählt einen Fehlversuch; true genau dann, wenn er die Sperre auslöst. */
|
||||||
|
recordFailure(source: string): boolean {
|
||||||
|
const now = this.clock();
|
||||||
|
const current = this.activeWindow(source);
|
||||||
|
if (!current) {
|
||||||
|
this.makeRoom(now);
|
||||||
|
}
|
||||||
|
const failures = (current?.failures ?? 0) + 1;
|
||||||
|
this.windows.set(source, {
|
||||||
|
failures,
|
||||||
|
startedAt: current?.startedAt ?? now,
|
||||||
|
});
|
||||||
|
return failures === this.options.maxFailures;
|
||||||
|
}
|
||||||
|
|
||||||
|
trackedSources(): number {
|
||||||
|
return this.windows.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private activeWindow(source: string): Window | undefined {
|
||||||
|
const window = this.windows.get(source);
|
||||||
|
if (window && this.clock() - window.startedAt > this.options.windowMs) {
|
||||||
|
this.windows.delete(source);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeRoom(now: number): void {
|
||||||
|
if (this.windows.size < this.options.maxTrackedSources) return;
|
||||||
|
for (const [source, window] of this.windows) {
|
||||||
|
if (now - window.startedAt > this.options.windowMs) {
|
||||||
|
this.windows.delete(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Immer noch voll: lieber eine Zählung verlieren als unbegrenzt wachsen.
|
||||||
|
// Zuerst den ältesten nicht gesperrten Eintrag verdrängen - sonst könnte
|
||||||
|
// ein Angreifer mit vielen Adressen seine eigene Sperre hinausschieben.
|
||||||
|
// Nur wenn alle gesperrt sind, geht der älteste gesperrte (die Map behält
|
||||||
|
// die Einfügereihenfolge).
|
||||||
|
if (this.windows.size >= this.options.maxTrackedSources) {
|
||||||
|
const victim =
|
||||||
|
this.oldestWhere(
|
||||||
|
(window) => window.failures < this.options.maxFailures,
|
||||||
|
) ?? this.oldestWhere(() => true);
|
||||||
|
if (victim !== undefined) this.windows.delete(victim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private oldestWhere(
|
||||||
|
matches: (window: Window) => boolean,
|
||||||
|
): string | undefined {
|
||||||
|
for (const [source, window] of this.windows) {
|
||||||
|
if (matches(window)) return source;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||||
|
|
||||||
|
describe('ip-allowlist', () => {
|
||||||
|
it('normalizes addresses and networks', () => {
|
||||||
|
expect(normalizeAllowlistEntry(' 203.0.113.7 ')).toBe('203.0.113.7');
|
||||||
|
expect(normalizeAllowlistEntry('203.0.113.0/24')).toBe('203.0.113.0/24');
|
||||||
|
expect(normalizeAllowlistEntry('2001:db8::/32')).toBe('2001:db8::/32');
|
||||||
|
expect(normalizeAllowlistEntry('::ffff:203.0.113.7')).toBe('203.0.113.7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects anything else', () => {
|
||||||
|
for (const entry of [
|
||||||
|
'',
|
||||||
|
'example.com',
|
||||||
|
'203.0.113.0/33',
|
||||||
|
'1.2.3.4/x',
|
||||||
|
'1.2.3.4/8/1',
|
||||||
|
]) {
|
||||||
|
expect(normalizeAllowlistEntry(entry)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows every address when the list is empty', () => {
|
||||||
|
expect(isIpAllowed('198.51.100.1', [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows only listed addresses and networks', () => {
|
||||||
|
const list = ['203.0.113.7', '198.51.100.0/24'];
|
||||||
|
expect(isIpAllowed('203.0.113.7', list)).toBe(true);
|
||||||
|
expect(isIpAllowed('::ffff:198.51.100.42', list)).toBe(true);
|
||||||
|
expect(isIpAllowed('203.0.113.8', list)).toBe(false);
|
||||||
|
expect(isIpAllowed(undefined, list)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import * as net from 'node:net';
|
||||||
|
|
||||||
|
/** Max entries per key - an allowlist is a short list of servers. */
|
||||||
|
export const MAX_ALLOWED_IPS = 20;
|
||||||
|
|
||||||
|
function unmapIpv4(ip: string): string {
|
||||||
|
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
|
||||||
|
return mapped ? mapped[1] : ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "203.0.113.7", "203.0.113.0/24", "2001:db8::/32" in canonical form, or
|
||||||
|
* null if the entry is neither an address nor a network.
|
||||||
|
*/
|
||||||
|
export function normalizeAllowlistEntry(entry: string): string | null {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
const [address, prefix, ...rest] = trimmed.split('/');
|
||||||
|
if (rest.length > 0 || !address) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ip = unmapIpv4(address);
|
||||||
|
const version = net.isIP(ip);
|
||||||
|
if (version === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (prefix === undefined) {
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
if (!/^\d{1,3}$/.test(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bits = Number(prefix);
|
||||||
|
if (bits > (version === 4 ? 32 : 128)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `${ip}/${bits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty list allows every address. */
|
||||||
|
export function isIpAllowed(
|
||||||
|
clientIp: string | undefined,
|
||||||
|
allowedIps: readonly string[],
|
||||||
|
): boolean {
|
||||||
|
if (allowedIps.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!clientIp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ip = unmapIpv4(clientIp);
|
||||||
|
const version = net.isIP(ip);
|
||||||
|
if (version === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const list = new net.BlockList();
|
||||||
|
for (const entry of allowedIps) {
|
||||||
|
const [address, prefix] = entry.split('/');
|
||||||
|
const family = net.isIP(address) === 6 ? 'ipv6' : 'ipv4';
|
||||||
|
if (prefix === undefined) {
|
||||||
|
list.addAddress(address, family);
|
||||||
|
} else {
|
||||||
|
list.addSubnet(address, Number(prefix), family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list.check(ip, version === 6 ? 'ipv6' : 'ipv4');
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { ApiaryStatus, Role } from '../../generated/prisma';
|
import { ApiaryStatus } from '../../generated/prisma';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||||
@@ -22,6 +22,7 @@ import { ApiariesService } from './apiaries.service';
|
|||||||
export class ApiariesController {
|
export class ApiariesController {
|
||||||
constructor(private readonly apiariesService: ApiariesService) {}
|
constructor(private readonly apiariesService: ApiariesService) {}
|
||||||
|
|
||||||
|
@RequirePermission('apiaries:read')
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
findAll(
|
||||||
@Query('status') status?: ApiaryStatus,
|
@Query('status') status?: ApiaryStatus,
|
||||||
@@ -30,12 +31,13 @@ export class ApiariesController {
|
|||||||
return this.apiariesService.findAll({ status, location });
|
return this.apiariesService.findAll({ status, location });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequirePermission('apiaries:read')
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.apiariesService.findById(id);
|
return this.apiariesService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(Role.ADMIN, Role.BEEKEEPER)
|
@RequirePermission('apiaries:create')
|
||||||
@Post()
|
@Post()
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateApiaryDto,
|
@Body() dto: CreateApiaryDto,
|
||||||
@@ -44,7 +46,7 @@ export class ApiariesController {
|
|||||||
return this.apiariesService.create(dto, actor.id);
|
return this.apiariesService.create(dto, actor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(Role.ADMIN, Role.BEEKEEPER)
|
@RequirePermission('apiaries:update')
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(
|
update(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -54,13 +56,13 @@ export class ApiariesController {
|
|||||||
return this.apiariesService.update(id, dto, actor.id);
|
return this.apiariesService.update(id, dto, actor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(Role.ADMIN, Role.BEEKEEPER)
|
@RequirePermission('apiaries:deactivate')
|
||||||
@Post(':id/deactivate')
|
@Post(':id/deactivate')
|
||||||
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||||
return this.apiariesService.deactivate(id, actor.id);
|
return this.apiariesService.deactivate(id, actor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(Role.ADMIN, Role.BEEKEEPER)
|
@RequirePermission('apiaries:deactivate')
|
||||||
@Post(':id/reactivate')
|
@Post(':id/reactivate')
|
||||||
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||||
return this.apiariesService.reactivate(id, actor.id);
|
return this.apiariesService.reactivate(id, actor.id);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ describe('ApiariesService', () => {
|
|||||||
create: jest.Mock;
|
create: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
|
colony: { count: jest.Mock };
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ describe('ApiariesService', () => {
|
|||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
|
colony: { count: jest.fn().mockResolvedValue(0) },
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
service = new ApiariesService(
|
service = new ApiariesService(
|
||||||
@@ -76,6 +78,30 @@ describe('ApiariesService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deactivate counts every living colony, weak and queenless ones included', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||||
|
prisma.apiary.update.mockResolvedValue(existingApiary);
|
||||||
|
|
||||||
|
await service.deactivate('apiary-1', 'user-1');
|
||||||
|
|
||||||
|
expect(prisma.colony.count).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deactivate throws ConflictException when active colonies remain', async () => {
|
||||||
|
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||||
|
prisma.colony.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.deactivate('apiary-1', 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(prisma.apiary.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('reactivate throws ConflictException when already active', async () => {
|
it('reactivate throws ConflictException when already active', async () => {
|
||||||
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
import { Apiary, ApiaryStatus } from '../../generated/prisma';
|
import { Apiary, ApiaryStatus } from '../../generated/prisma';
|
||||||
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
import { CreateApiaryDto } from './dto/create-apiary.dto';
|
||||||
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
import { UpdateApiaryDto } from './dto/update-apiary.dto';
|
||||||
@@ -76,6 +77,19 @@ export class ApiariesService {
|
|||||||
|
|
||||||
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
|
||||||
const before = await this.findById(id);
|
const before = await this.findById(id);
|
||||||
|
// Colony.apiaryId is a plain FK, not enforced against apiary status -
|
||||||
|
// without this guard a colony can silently keep pointing at an
|
||||||
|
// inactive apiary (still shows ACTIVE, still countable, but its
|
||||||
|
// "location" has vanished from the active apiary list). Move the
|
||||||
|
// colonies elsewhere or dissolve them first instead.
|
||||||
|
const activeColonyCount = await this.prisma.colony.count({
|
||||||
|
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
|
||||||
|
});
|
||||||
|
if (activeColonyCount > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const apiary = await this.prisma.apiary.update({
|
const apiary = await this.prisma.apiary.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
data: { status: 'INACTIVE', deactivatedAt: new Date() },
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateApiaryDto } from './create-apiary.dto';
|
import { CreateApiaryDto } from './create-apiary.dto';
|
||||||
|
|
||||||
export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {}
|
export class UpdateApiaryDto extends PartialType(CreateApiaryDto, {
|
||||||
|
skipNullProperties: false,
|
||||||
|
}) {}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import {
|
||||||
|
configureApp,
|
||||||
|
describeTrustProxy,
|
||||||
|
trustProxySetting,
|
||||||
|
} from './app.config';
|
||||||
|
|
||||||
|
describe('trustProxySetting', () => {
|
||||||
|
const original = process.env.TRUST_PROXY;
|
||||||
|
afterEach(() => {
|
||||||
|
if (original === undefined) delete process.env.TRUST_PROXY;
|
||||||
|
else process.env.TRUST_PROXY = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to one hop when unset or blank', () => {
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
expect(trustProxySetting()).toBe(1);
|
||||||
|
process.env.TRUST_PROXY = ' ';
|
||||||
|
expect(trustProxySetting()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a number as hop count and anything else as a list', () => {
|
||||||
|
process.env.TRUST_PROXY = '2';
|
||||||
|
expect(trustProxySetting()).toBe(2);
|
||||||
|
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
|
||||||
|
expect(trustProxySetting()).toBe('loopback, 10.42.0.0/16');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeTrustProxy', () => {
|
||||||
|
it('reports the hop count mode and the default fallback', () => {
|
||||||
|
expect(describeTrustProxy(1, false)).toBe(
|
||||||
|
'TRUST_PROXY not set, using default of 1 trusted hop',
|
||||||
|
);
|
||||||
|
expect(describeTrustProxy(2, true)).toBe('TRUST_PROXY: 2 trusted hop(s)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the list mode with entry count but not the entries', () => {
|
||||||
|
const text = describeTrustProxy(
|
||||||
|
'loopback, 10.42.0.0/16, 2400:cb00::/32',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(text).toBe('TRUST_PROXY: list of 3 trusted proxy range(s)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configureApp trust proxy logging', () => {
|
||||||
|
it('logs the active mode once at startup', () => {
|
||||||
|
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||||
|
const app = {
|
||||||
|
set: jest.fn(),
|
||||||
|
useBodyParser: jest.fn(),
|
||||||
|
use: jest.fn(),
|
||||||
|
useGlobalFilters: jest.fn(),
|
||||||
|
enableCors: jest.fn(),
|
||||||
|
setGlobalPrefix: jest.fn(),
|
||||||
|
enableVersioning: jest.fn(),
|
||||||
|
useGlobalPipes: jest.fn(),
|
||||||
|
};
|
||||||
|
configureApp(app as unknown as NestExpressApplication);
|
||||||
|
expect(
|
||||||
|
log.mock.calls.filter(([m]) => String(m).startsWith('TRUST_PROXY')),
|
||||||
|
).toHaveLength(1);
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configureApp trust proxy warning', () => {
|
||||||
|
const originalProxy = process.env.TRUST_PROXY;
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
|
||||||
|
else process.env.TRUST_PROXY = originalProxy;
|
||||||
|
if (originalEnv === undefined) delete process.env.NODE_ENV;
|
||||||
|
else process.env.NODE_ENV = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
function configure(): jest.SpyInstance {
|
||||||
|
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||||
|
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||||
|
const app = {
|
||||||
|
set: jest.fn(),
|
||||||
|
useBodyParser: jest.fn(),
|
||||||
|
use: jest.fn(),
|
||||||
|
useGlobalFilters: jest.fn(),
|
||||||
|
enableCors: jest.fn(),
|
||||||
|
setGlobalPrefix: jest.fn(),
|
||||||
|
enableVersioning: jest.fn(),
|
||||||
|
useGlobalPipes: jest.fn(),
|
||||||
|
};
|
||||||
|
configureApp(app as unknown as NestExpressApplication);
|
||||||
|
return warn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
|
||||||
|
const calls = warn.mock.calls as unknown[][];
|
||||||
|
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
expect(trustProxyWarnings(configure())).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
|
||||||
|
expect(trustProxyWarnings(configure())).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warnt nicht in der Entwicklung', () => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
expect(trustProxyWarnings(configure())).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,99 @@
|
|||||||
import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common';
|
import {
|
||||||
|
Logger,
|
||||||
|
RequestMethod,
|
||||||
|
ValidationPipe,
|
||||||
|
VersioningType,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
|
import { requestLoggerMiddleware } from './common/middleware/request-logger.middleware';
|
||||||
|
import { noStoreMiddleware } from './common/middleware/no-store.middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which proxies to trust for X-Forwarded-For. It decides the client IP behind
|
||||||
|
* every rate limit and API-key IP allowlist: trust too little and all users
|
||||||
|
* share the proxy's IP, trust too much and a client can forge its own.
|
||||||
|
*
|
||||||
|
* Production runs client -> Cloudflare -> Traefik -> backend, so the hop
|
||||||
|
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
|
||||||
|
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
|
||||||
|
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
|
||||||
|
* count and stays the default when the variable is unset.
|
||||||
|
*/
|
||||||
|
export function trustProxySetting(): number | string {
|
||||||
|
const raw = process.env.TRUST_PROXY?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return /^\d+$/.test(raw) ? Number(raw) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startup log line for the active mode; never includes the addresses. */
|
||||||
|
export function describeTrustProxy(
|
||||||
|
setting: number | string,
|
||||||
|
isExplicit: boolean,
|
||||||
|
): string {
|
||||||
|
if (typeof setting === 'number') {
|
||||||
|
return isExplicit
|
||||||
|
? `TRUST_PROXY: ${setting} trusted hop(s)`
|
||||||
|
: `TRUST_PROXY not set, using default of ${setting} trusted hop`;
|
||||||
|
}
|
||||||
|
const entries = setting.split(',').filter((e) => e.trim()).length;
|
||||||
|
return `TRUST_PROXY: list of ${entries} trusted proxy range(s)`;
|
||||||
|
}
|
||||||
|
|
||||||
export function configureApp(app: NestExpressApplication): void {
|
export function configureApp(app: NestExpressApplication): void {
|
||||||
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the
|
// Trust the configured proxies (Traefik, Cloudflare) so req.ip reflects the
|
||||||
// real client IP from X-Forwarded-For — required for per-client throttling
|
// real client IP from X-Forwarded-For — required for per-client throttling
|
||||||
// to work correctly rather than rate-limiting all traffic together.
|
// to work correctly rather than rate-limiting all traffic together.
|
||||||
app.set('trust proxy', 1);
|
const trustProxy = trustProxySetting();
|
||||||
app.use(helmet());
|
app.set('trust proxy', trustProxy);
|
||||||
|
const bootstrapLogger = new Logger('Bootstrap');
|
||||||
|
const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim();
|
||||||
|
bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit));
|
||||||
|
if (!trustProxyExplicit && process.env.NODE_ENV === 'production') {
|
||||||
|
// Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the
|
||||||
|
// proxy, so rate limits, API-key failure limits and IP allowlists act
|
||||||
|
// on a shared address instead of the client.
|
||||||
|
bootstrapLogger.warn(
|
||||||
|
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/backend/configmap.example.yaml',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// No ETags: they let caches and proxies revalidate (and keep) personal
|
||||||
|
// responses that are meant to be no-store.
|
||||||
|
app.set('etag', false);
|
||||||
|
// Default 100kb JSON body limit is too small for the avatar upload, which
|
||||||
|
// sends the image as a base64 data: URL in the JSON body (not multipart -
|
||||||
|
// see profile/dto/upload-avatar.dto.ts for why) - 5mb comfortably covers
|
||||||
|
// the 2MB decoded size cap plus base64 overhead.
|
||||||
|
app.useBodyParser('json', { limit: '5mb' });
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
// This server only answers JSON and files. A strict policy means even
|
||||||
|
// a response that is somehow rendered as a page can load nothing and
|
||||||
|
// cannot be framed. The Swagger UI (opt-in) needs scripts, so it keeps
|
||||||
|
// helmet's default page policy.
|
||||||
|
contentSecurityPolicy:
|
||||||
|
process.env.ENABLE_SWAGGER === 'true'
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
useDefaults: false,
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'none'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
baseUri: ["'none'"],
|
||||||
|
formAction: ["'none'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginResourcePolicy: { policy: 'same-site' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.use(noStoreMiddleware);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
app.use(requestLoggerMiddleware);
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000',
|
origin: process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000',
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { RolesModule } from './roles/roles.module';
|
||||||
import { ApiariesModule } from './apiaries/apiaries.module';
|
import { ApiariesModule } from './apiaries/apiaries.module';
|
||||||
import { ColoniesModule } from './colonies/colonies.module';
|
import { ColoniesModule } from './colonies/colonies.module';
|
||||||
import { QueensModule } from './queens/queens.module';
|
import { QueensModule } from './queens/queens.module';
|
||||||
@@ -25,29 +28,47 @@ import { ExportModule } from './export/export.module';
|
|||||||
import { QrModule } from './qr/qr.module';
|
import { QrModule } from './qr/qr.module';
|
||||||
import { ProductsModule } from './products/products.module';
|
import { ProductsModule } from './products/products.module';
|
||||||
import { BottlingModule } from './bottling/bottling.module';
|
import { BottlingModule } from './bottling/bottling.module';
|
||||||
|
import { ProductionBatchesModule } from './production-batches/production-batches.module';
|
||||||
|
import { RawMaterialsModule } from './raw-materials/raw-materials.module';
|
||||||
import { EquipmentModule } from './equipment/equipment.module';
|
import { EquipmentModule } from './equipment/equipment.module';
|
||||||
import { CalendarModule } from './calendar/calendar.module';
|
import { CalendarModule } from './calendar/calendar.module';
|
||||||
|
import { CalendarSyncModule } from './calendar-sync/calendar-sync.module';
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { StatisticsModule } from './statistics/statistics.module';
|
import { StatisticsModule } from './statistics/statistics.module';
|
||||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||||
import { DocumentsModule } from './documents/documents.module';
|
import { DocumentsModule } from './documents/documents.module';
|
||||||
import { AutomationModule } from './automation/automation.module';
|
import { AutomationModule } from './automation/automation.module';
|
||||||
|
import { LabelTemplatesModule } from './label-templates/label-templates.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
||||||
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
||||||
|
import { WinterChecksModule } from './winter-checks/winter-checks.module';
|
||||||
|
import { WeatherModule } from './weather/weather.module';
|
||||||
|
import { ProfileModule } from './profile/profile.module';
|
||||||
|
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
||||||
|
import { BackupModule } from './backup/backup.module';
|
||||||
|
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
||||||
|
import { ErpExportModule } from './erp-export/erp-export.module';
|
||||||
|
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './auth/guards/roles.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
|
import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||||
|
import { IdempotencyInterceptor } from './common/idempotency/idempotency.interceptor';
|
||||||
|
import { IdempotencyCleanupService } from './common/idempotency/idempotency-cleanup.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
|
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
PermissionsModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
RolesModule,
|
||||||
ApiariesModule,
|
ApiariesModule,
|
||||||
ColoniesModule,
|
ColoniesModule,
|
||||||
QueensModule,
|
QueensModule,
|
||||||
@@ -66,23 +87,39 @@ import { RolesGuard } from './auth/guards/roles.guard';
|
|||||||
QrModule,
|
QrModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
BottlingModule,
|
BottlingModule,
|
||||||
|
ProductionBatchesModule,
|
||||||
|
RawMaterialsModule,
|
||||||
EquipmentModule,
|
EquipmentModule,
|
||||||
CalendarModule,
|
CalendarModule,
|
||||||
|
CalendarSyncModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
StatisticsModule,
|
StatisticsModule,
|
||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
|
LabelTemplatesModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
TaskTemplatesModule,
|
TaskTemplatesModule,
|
||||||
SwarmControlModule,
|
SwarmControlModule,
|
||||||
|
WinterChecksModule,
|
||||||
|
WeatherModule,
|
||||||
|
ProfileModule,
|
||||||
|
SiteSettingsModule,
|
||||||
|
BackupModule,
|
||||||
|
ShopSyncModule,
|
||||||
|
ErpExportModule,
|
||||||
|
ApiKeysModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
// Throttle first: a request with a wrong token or API key must count
|
||||||
|
// against the limit too, before any database lookup is spent on it.
|
||||||
|
{ provide: APP_GUARD, useClass: ClientIpThrottlerGuard },
|
||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||||
|
IdempotencyCleanupService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { Role } from '../../generated/prisma';
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
|
||||||
import { AuditLogService } from './audit-log.service';
|
import { AuditLogService } from './audit-log.service';
|
||||||
|
|
||||||
@ApiTags('audit-log')
|
@ApiTags('audit-log')
|
||||||
@@ -10,7 +9,7 @@ import { AuditLogService } from './audit-log.service';
|
|||||||
export class AuditController {
|
export class AuditController {
|
||||||
constructor(private readonly auditLogService: AuditLogService) {}
|
constructor(private readonly auditLogService: AuditLogService) {}
|
||||||
|
|
||||||
@Roles(Role.ADMIN)
|
@RequirePermission('audit:read')
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
findAll(
|
||||||
@Query('entityType') entityType?: string,
|
@Query('entityType') entityType?: string,
|
||||||
|
|||||||
@@ -1,78 +1,73 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { Public } from './decorators/public.decorator';
|
import { Public } from './decorators/public.decorator';
|
||||||
|
import { AnyAuthenticatedUser } from './decorators/any-authenticated-user.decorator';
|
||||||
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from './types/authenticated-user';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { AccessTokenDto } from './dto/access-token.dto';
|
import { AccessTokenDto } from './dto/access-token.dto';
|
||||||
import { AuthService } from './auth.service';
|
import { MfaVerifyDto } from './dto/mfa-verify.dto';
|
||||||
import { parseDurationToMs } from './util/duration';
|
import { AuthService, CurrentUserProfile } from './auth.service';
|
||||||
|
import { SENSITIVE_ACTION_THROTTLE } from './util/throttle';
|
||||||
|
import {
|
||||||
|
clearRefreshTokenCookie,
|
||||||
|
extractRefreshToken,
|
||||||
|
setRefreshTokenCookie,
|
||||||
|
} from './util/refresh-token-cookie';
|
||||||
|
|
||||||
const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
export interface MfaRequiredResponse {
|
||||||
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
mfaRequired: true;
|
||||||
// Stricter than the global default: bounds brute-force/credential-stuffing
|
mfaToken: string;
|
||||||
// attempts against login and refresh-token guessing specifically.
|
}
|
||||||
const AUTH_THROTTLE = { default: { limit: 10, ttl: 60000 } };
|
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
|
|
||||||
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
// Explicit opt-out for local HTTP dev only; every real deployment must
|
|
||||||
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
|
||||||
// over plaintext HTTP.
|
|
||||||
secure: process.env.COOKIE_SECURE !== 'false',
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: REFRESH_TOKEN_COOKIE_PATH,
|
|
||||||
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearRefreshTokenCookie(res: Response): void {
|
|
||||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractRefreshToken(req: Request): string {
|
|
||||||
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
|
|
||||||
REFRESH_TOKEN_COOKIE
|
|
||||||
];
|
|
||||||
if (!refreshToken) {
|
|
||||||
throw new UnauthorizedException('Missing refresh token');
|
|
||||||
}
|
|
||||||
return refreshToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Throttle(AUTH_THROTTLE)
|
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async login(
|
async login(
|
||||||
@Body() dto: LoginDto,
|
@Body() dto: LoginDto,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<AccessTokenDto | MfaRequiredResponse> {
|
||||||
|
const result = await this.authService.login(dto.email, dto.password);
|
||||||
|
if (result.mfaRequired) {
|
||||||
|
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||||
|
}
|
||||||
|
setRefreshTokenCookie(res, result.refreshToken);
|
||||||
|
return { accessToken: result.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||||
|
@Post('mfa/verify')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async verifyMfa(
|
||||||
|
@Body() dto: MfaVerifyDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<AccessTokenDto> {
|
): Promise<AccessTokenDto> {
|
||||||
const { accessToken, refreshToken } = await this.authService.login(
|
const { accessToken, refreshToken } =
|
||||||
dto.email,
|
await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code);
|
||||||
dto.password,
|
setRefreshTokenCookie(res, refreshToken);
|
||||||
);
|
|
||||||
this.setRefreshTokenCookie(res, refreshToken);
|
|
||||||
return { accessToken };
|
return { accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Throttle(AUTH_THROTTLE)
|
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async refresh(
|
async refresh(
|
||||||
@@ -80,20 +75,28 @@ export class AuthController {
|
|||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<AccessTokenDto> {
|
): Promise<AccessTokenDto> {
|
||||||
const { accessToken, refreshToken } = await this.authService.refresh(
|
const { accessToken, refreshToken } = await this.authService.refresh(
|
||||||
this.extractRefreshToken(req),
|
extractRefreshToken(req),
|
||||||
);
|
);
|
||||||
this.setRefreshTokenCookie(res, refreshToken);
|
setRefreshTokenCookie(res, refreshToken);
|
||||||
return { accessToken };
|
return { accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
|
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async logout(
|
async logout(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.authService.logout(this.extractRefreshToken(req));
|
await this.authService.logout(extractRefreshToken(req));
|
||||||
this.clearRefreshTokenCookie(res);
|
clearRefreshTokenCookie(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@AnyAuthenticatedUser()
|
||||||
|
@Get('me')
|
||||||
|
me(@CurrentUser() user: AuthenticatedUser): Promise<CurrentUserProfile> {
|
||||||
|
return this.authService.getCurrentUserProfile(user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,20 @@ import { UsersModule } from '../users/users.module';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { OneTimeTokenService } from './one-time-token.service';
|
||||||
|
import { MfaModule } from './mfa/mfa.module';
|
||||||
|
import { SsoModule } from './sso/sso.module';
|
||||||
|
import { SsoController } from './sso/sso.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PassportModule, JwtModule.register({}), UsersModule],
|
imports: [
|
||||||
controllers: [AuthController],
|
PassportModule,
|
||||||
providers: [AuthService, JwtStrategy],
|
JwtModule.register({}),
|
||||||
|
UsersModule,
|
||||||
|
MfaModule,
|
||||||
|
SsoModule,
|
||||||
|
],
|
||||||
|
controllers: [AuthController, SsoController],
|
||||||
|
providers: [AuthService, JwtStrategy, OneTimeTokenService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
@@ -4,6 +4,9 @@ import { AuthService } from './auth.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { MfaService } from './mfa/mfa.service';
|
||||||
|
import { AccountGuardService } from './mfa/account-guard.service';
|
||||||
|
import { OneTimeTokenService } from './one-time-token.service';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
@@ -18,17 +21,35 @@ describe('AuthService', () => {
|
|||||||
};
|
};
|
||||||
let jwtService: { sign: jest.Mock };
|
let jwtService: { sign: jest.Mock };
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
let permissionResolver: {
|
||||||
|
getPermissions: jest.Mock;
|
||||||
|
invalidateUser: jest.Mock;
|
||||||
|
};
|
||||||
|
let mfaService: {
|
||||||
|
signChallengeToken: jest.Mock;
|
||||||
|
verifyChallengeToken: jest.Mock;
|
||||||
|
};
|
||||||
|
let oneTimeTokens: { consume: jest.Mock };
|
||||||
|
let accountGuard: {
|
||||||
|
isLocked: jest.Mock;
|
||||||
|
recordFailure: jest.Mock;
|
||||||
|
recordSuccess: jest.Mock;
|
||||||
|
consumeTotp: jest.Mock;
|
||||||
|
};
|
||||||
|
|
||||||
const activeUser = {
|
const activeUser = {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
email: '[email protected]',
|
email: '[email protected]',
|
||||||
role: 'STAFF',
|
|
||||||
isActive: true,
|
isActive: true,
|
||||||
passwordHash: '',
|
passwordHash: '',
|
||||||
|
mfaEnabled: false,
|
||||||
|
mfaSecret: null as string | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
activeUser.passwordHash = await bcrypt.hash('correct-password', 4);
|
activeUser.passwordHash = await bcrypt.hash('correct-password', 4);
|
||||||
|
activeUser.mfaEnabled = false;
|
||||||
|
activeUser.mfaSecret = null;
|
||||||
usersService = { findByEmail: jest.fn(), findById: jest.fn() };
|
usersService = { findByEmail: jest.fn(), findById: jest.fn() };
|
||||||
prisma = {
|
prisma = {
|
||||||
refreshToken: {
|
refreshToken: {
|
||||||
@@ -40,12 +61,31 @@ describe('AuthService', () => {
|
|||||||
};
|
};
|
||||||
jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') };
|
jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') };
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
permissionResolver = {
|
||||||
|
getPermissions: jest.fn().mockResolvedValue(new Set()),
|
||||||
|
invalidateUser: jest.fn(),
|
||||||
|
};
|
||||||
|
mfaService = {
|
||||||
|
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
||||||
|
verifyChallengeToken: jest.fn(),
|
||||||
|
};
|
||||||
|
oneTimeTokens = { consume: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
accountGuard = {
|
||||||
|
isLocked: jest.fn().mockReturnValue(false),
|
||||||
|
recordFailure: jest.fn().mockResolvedValue(undefined),
|
||||||
|
recordSuccess: jest.fn().mockResolvedValue(undefined),
|
||||||
|
consumeTotp: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
authService = new AuthService(
|
authService = new AuthService(
|
||||||
usersService as unknown as UsersService,
|
usersService as unknown as UsersService,
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
jwtService as never,
|
jwtService as never,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
|
permissionResolver as never,
|
||||||
|
mfaService as unknown as MfaService,
|
||||||
|
accountGuard as unknown as AccountGuardService,
|
||||||
|
oneTimeTokens as unknown as OneTimeTokenService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,6 +123,9 @@ describe('AuthService', () => {
|
|||||||
'correct-password',
|
'correct-password',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result.mfaRequired) {
|
||||||
|
throw new Error('expected mfaRequired to be false');
|
||||||
|
}
|
||||||
expect(result.accessToken).toBe('signed.jwt.token');
|
expect(result.accessToken).toBe('signed.jwt.token');
|
||||||
expect(result.refreshToken).toEqual(expect.any(String));
|
expect(result.refreshToken).toEqual(expect.any(String));
|
||||||
const [[createArg]] = prisma.refreshToken.create.mock.calls as [
|
const [[createArg]] = prisma.refreshToken.create.mock.calls as [
|
||||||
@@ -94,6 +137,29 @@ describe('AuthService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns an MFA challenge instead of tokens when MFA is enabled', async () => {
|
||||||
|
usersService.findByEmail.mockResolvedValue({
|
||||||
|
...activeUser,
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: 'encrypted-secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await authService.login(
|
||||||
|
activeUser.email,
|
||||||
|
'correct-password',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.mfaRequired) {
|
||||||
|
throw new Error('expected mfaRequired to be true');
|
||||||
|
}
|
||||||
|
expect(result.mfaToken).toBe('mfa-challenge-token');
|
||||||
|
expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id);
|
||||||
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'LOGIN' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('records a LOGIN_FAILED audit entry on wrong password', async () => {
|
it('records a LOGIN_FAILED audit entry on wrong password', async () => {
|
||||||
usersService.findByEmail.mockResolvedValue(activeUser);
|
usersService.findByEmail.mockResolvedValue(activeUser);
|
||||||
await expect(
|
await expect(
|
||||||
@@ -107,18 +173,151 @@ describe('AuthService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records a LOGIN_FAILED audit entry keyed by email when the user is unknown', async () => {
|
it('writes nothing attacker-chosen to the audit log for an unknown email', async () => {
|
||||||
usersService.findByEmail.mockResolvedValue(null);
|
usersService.findByEmail.mockResolvedValue(null);
|
||||||
await expect(
|
await expect(
|
||||||
authService.login('[email protected]', 'x'),
|
authService.login('[email protected]', 'x'),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a wrong password against the account', async () => {
|
||||||
|
usersService.findByEmail.mockResolvedValue(activeUser);
|
||||||
|
await expect(
|
||||||
|
authService.login(activeUser.email, 'wrong-password'),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(accountGuard.recordFailure).toHaveBeenCalledWith(activeUser.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a locked account even with the right password, with the same message', async () => {
|
||||||
|
usersService.findByEmail.mockResolvedValue(activeUser);
|
||||||
|
accountGuard.isLocked.mockReturnValue(true);
|
||||||
|
|
||||||
|
const lockedMessage = await authService
|
||||||
|
.login(activeUser.email, 'correct-password')
|
||||||
|
.then(() => 'no error')
|
||||||
|
.catch((error: Error) => error.message);
|
||||||
|
|
||||||
|
accountGuard.isLocked.mockReturnValue(false);
|
||||||
|
const wrongMessage = await authService
|
||||||
|
.login(activeUser.email, 'wrong-password')
|
||||||
|
.then(() => 'no error')
|
||||||
|
.catch((error: Error) => error.message);
|
||||||
|
|
||||||
|
expect(lockedMessage).not.toBe('no error');
|
||||||
|
expect(lockedMessage).toBe(wrongMessage);
|
||||||
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyMfaAndLogin', () => {
|
||||||
|
const mfaUser = {
|
||||||
|
...activeUser,
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: 'encrypted-secret',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('issues tokens and records LOGIN when the code is valid', async () => {
|
||||||
|
mfaService.verifyChallengeToken.mockReturnValue({
|
||||||
|
userId: mfaUser.id,
|
||||||
|
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||||
|
});
|
||||||
|
usersService.findById.mockResolvedValue(mfaUser);
|
||||||
|
accountGuard.consumeTotp.mockResolvedValue(true);
|
||||||
|
prisma.refreshToken.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await authService.verifyMfaAndLogin(
|
||||||
|
'mfa-challenge-token',
|
||||||
|
'123456',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.accessToken).toBe('signed.jwt.token');
|
||||||
|
expect(accountGuard.consumeTotp).toHaveBeenCalledWith(mfaUser, '123456');
|
||||||
|
expect(accountGuard.recordSuccess).toHaveBeenCalledWith(mfaUser.id);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'LOGIN', userId: mfaUser.id }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
|
||||||
|
mfaService.verifyChallengeToken.mockReturnValue({
|
||||||
|
userId: mfaUser.id,
|
||||||
|
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||||
|
});
|
||||||
|
usersService.findById.mockResolvedValue(mfaUser);
|
||||||
|
accountGuard.consumeTotp.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
action: 'LOGIN_FAILED',
|
action: 'LOGIN_FAILED',
|
||||||
entityId: '[email protected]',
|
userId: mfaUser.id,
|
||||||
userId: undefined,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates an expired/invalid challenge token as UnauthorizedException', async () => {
|
||||||
|
mfaService.verifyChallengeToken.mockImplementation(() => {
|
||||||
|
throw new UnauthorizedException('expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
authService.verifyMfaAndLogin('bad-token', '123456'),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(usersService.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('issueTokensForSsoUser', () => {
|
||||||
|
it('issues tokens and records LOGIN for an active user', async () => {
|
||||||
|
usersService.findById.mockResolvedValue(activeUser);
|
||||||
|
prisma.refreshToken.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await authService.issueTokensForSsoUser(activeUser.id);
|
||||||
|
|
||||||
|
if (result.mfaRequired) {
|
||||||
|
throw new Error('expected mfaRequired to be false');
|
||||||
|
}
|
||||||
|
expect(result.accessToken).toBe('signed.jwt.token');
|
||||||
|
expect(prisma.refreshToken.create).toHaveBeenCalled();
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an MFA challenge instead of tokens when the SSO user has local MFA enabled', async () => {
|
||||||
|
usersService.findById.mockResolvedValue({
|
||||||
|
...activeUser,
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: 'encrypted-secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await authService.issueTokensForSsoUser(activeUser.id);
|
||||||
|
|
||||||
|
if (!result.mfaRequired) {
|
||||||
|
throw new Error('expected mfaRequired to be true');
|
||||||
|
}
|
||||||
|
expect(result.mfaToken).toBe('mfa-challenge-token');
|
||||||
|
expect(mfaService.signChallengeToken).toHaveBeenCalledWith(activeUser.id);
|
||||||
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: 'LOGIN' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the user is deactivated', async () => {
|
||||||
|
usersService.findById.mockResolvedValue({
|
||||||
|
...activeUser,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
authService.issueTokensForSsoUser(activeUser.id),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,16 +329,27 @@ describe('AuthService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when the refresh token is revoked', async () => {
|
it('throws when the refresh token is revoked and revokes all other sessions for that user (reuse detection)', async () => {
|
||||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||||
id: 'rt-1',
|
id: 'rt-1',
|
||||||
userId: activeUser.id,
|
userId: activeUser.id,
|
||||||
revokedAt: new Date(),
|
// Rotated out a minute ago - well past the parallel-tab window.
|
||||||
|
revokedAt: new Date(Date.now() - 60_000),
|
||||||
expiresAt: new Date(Date.now() + 1000),
|
expiresAt: new Date(Date.now() + 1000),
|
||||||
});
|
});
|
||||||
|
prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 });
|
||||||
|
|
||||||
await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf(
|
await expect(authService.refresh('revoked-token')).rejects.toBeInstanceOf(
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
);
|
);
|
||||||
|
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { userId: activeUser.id, revokedAt: null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
|
||||||
|
activeUser.id,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when the refresh token is expired', async () => {
|
it('throws when the refresh token is expired', async () => {
|
||||||
@@ -154,22 +364,78 @@ describe('AuthService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not revoke everything when a parallel tab just rotated the token', async () => {
|
||||||
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'rt-1',
|
||||||
|
userId: activeUser.id,
|
||||||
|
revokedAt: new Date(Date.now() - 1000),
|
||||||
|
expiresAt: new Date(Date.now() + 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(authService.refresh('just-rotated')).rejects.toBeInstanceOf(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
expect(prisma.refreshToken.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives only one of two concurrent refreshes a successor', async () => {
|
||||||
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'rt-1',
|
||||||
|
userId: activeUser.id,
|
||||||
|
revokedAt: null,
|
||||||
|
expiresAt: new Date(Date.now() + 1000),
|
||||||
|
sessionStartedAt: new Date(),
|
||||||
|
});
|
||||||
|
usersService.findById.mockResolvedValue(activeUser);
|
||||||
|
prisma.refreshToken.updateMany.mockResolvedValue({ count: 0 });
|
||||||
|
|
||||||
|
await expect(authService.refresh('raced-token')).rejects.toBeInstanceOf(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never extends a session past seven days from login', async () => {
|
||||||
|
const startedSixDaysAgo = new Date(Date.now() - 6 * 24 * 3600 * 1000);
|
||||||
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||||
|
id: 'rt-1',
|
||||||
|
userId: activeUser.id,
|
||||||
|
revokedAt: null,
|
||||||
|
expiresAt: new Date(Date.now() + 1000),
|
||||||
|
sessionStartedAt: startedSixDaysAgo,
|
||||||
|
});
|
||||||
|
usersService.findById.mockResolvedValue(activeUser);
|
||||||
|
prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
prisma.refreshToken.create.mockResolvedValue({});
|
||||||
|
|
||||||
|
await authService.refresh('valid-token');
|
||||||
|
|
||||||
|
const [[createArg]] = prisma.refreshToken.create.mock.calls as [
|
||||||
|
[{ data: { expiresAt: Date; sessionStartedAt: Date } }],
|
||||||
|
];
|
||||||
|
expect(createArg.data.sessionStartedAt).toEqual(startedSixDaysAgo);
|
||||||
|
expect(createArg.data.expiresAt.getTime()).toBe(
|
||||||
|
startedSixDaysAgo.getTime() + 7 * 24 * 3600 * 1000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rotates the refresh token on success', async () => {
|
it('rotates the refresh token on success', async () => {
|
||||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||||
id: 'rt-1',
|
id: 'rt-1',
|
||||||
userId: activeUser.id,
|
userId: activeUser.id,
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
expiresAt: new Date(Date.now() + 1000),
|
expiresAt: new Date(Date.now() + 1000),
|
||||||
|
sessionStartedAt: new Date(),
|
||||||
});
|
});
|
||||||
usersService.findById.mockResolvedValue(activeUser);
|
usersService.findById.mockResolvedValue(activeUser);
|
||||||
prisma.refreshToken.update.mockResolvedValue({});
|
prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
|
||||||
prisma.refreshToken.create.mockResolvedValue({});
|
prisma.refreshToken.create.mockResolvedValue({});
|
||||||
|
|
||||||
const result = await authService.refresh('valid-token');
|
const result = await authService.refresh('valid-token');
|
||||||
|
|
||||||
expect(result.accessToken).toBe('signed.jwt.token');
|
expect(result.accessToken).toBe('signed.jwt.token');
|
||||||
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
|
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ where: { id: 'rt-1' } }),
|
expect.objectContaining({ where: { id: 'rt-1', revokedAt: null } }),
|
||||||
);
|
);
|
||||||
expect(auditLog.record).toHaveBeenCalledWith(
|
expect(auditLog.record).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -1,83 +1,243 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { AuthenticatedUser } from './types/authenticated-user';
|
import { PermissionResolverService } from './permissions/permission-resolver.service';
|
||||||
|
import { MfaService } from './mfa/mfa.service';
|
||||||
|
import { AccountGuardService } from './mfa/account-guard.service';
|
||||||
|
import { OneTimeTokenService } from './one-time-token.service';
|
||||||
import { parseDurationToMs } from './util/duration';
|
import { parseDurationToMs } from './util/duration';
|
||||||
|
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
|
||||||
|
|
||||||
interface AuthTokens {
|
interface AuthTokens {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LoginResult =
|
||||||
|
| ({ mfaRequired: false } & AuthTokens)
|
||||||
|
| { mfaRequired: true; mfaToken: string };
|
||||||
|
|
||||||
|
export interface CurrentUserProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
roleKey: string;
|
||||||
|
roleName: string;
|
||||||
|
permissions: string[];
|
||||||
|
mfaEnabled: boolean;
|
||||||
|
hasAvatar: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const REFRESH_TOKEN_BYTES = 40;
|
const REFRESH_TOKEN_BYTES = 40;
|
||||||
|
|
||||||
|
// Hard cap from the moment of login, however often the session is refreshed.
|
||||||
|
const DEFAULT_SESSION_MAX_AGE = '7d';
|
||||||
|
// Two tabs refreshing with the same token at the same moment is normal;
|
||||||
|
// only a reuse after this window is treated as theft.
|
||||||
|
const ROTATION_GRACE_MS = 10_000;
|
||||||
|
|
||||||
|
// Same answer for unknown email, wrong password, deactivated or locked
|
||||||
|
// account - nothing in the response tells which one it was.
|
||||||
|
const INVALID_CREDENTIALS =
|
||||||
|
'E-Mail oder Passwort falsch. Nach mehreren Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.';
|
||||||
|
|
||||||
|
// Hash of a random value nobody knows, at the same cost as real password
|
||||||
|
// hashes; compared against when there is no real hash, so every login
|
||||||
|
// costs the same time.
|
||||||
|
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
|
||||||
|
crypto.randomBytes(32).toString('hex'),
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
private readonly mfaService: MfaService,
|
||||||
|
private readonly accountGuard: AccountGuardService,
|
||||||
|
private readonly oneTimeTokens: OneTimeTokenService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { role: { select: { key: true, name: true } } },
|
||||||
|
});
|
||||||
|
const permissions = await this.permissionResolver.getPermissions(userId);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
roleKey: user.role.key,
|
||||||
|
roleName: user.role.name,
|
||||||
|
permissions: Array.from(permissions),
|
||||||
|
mfaEnabled: user.mfaEnabled,
|
||||||
|
hasAvatar: user.avatarPath !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private hashRefreshToken(token: string): string {
|
private hashRefreshToken(token: string): string {
|
||||||
return crypto.createHash('sha256').update(token).digest('hex');
|
return crypto.createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private signAccessToken(user: AuthenticatedUser): string {
|
private signAccessToken(user: { id: string; email: string }): string {
|
||||||
const expiresInSeconds = Math.floor(
|
const expiresInSeconds = Math.floor(
|
||||||
parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000,
|
parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000,
|
||||||
);
|
);
|
||||||
return this.jwtService.sign(
|
return this.jwtService.sign(
|
||||||
{ sub: user.id, email: user.email, role: user.role },
|
{ sub: user.id, email: user.email },
|
||||||
{
|
jwtSignOptions(TOKEN_AUDIENCE.access, expiresInSeconds),
|
||||||
secret: process.env.JWT_ACCESS_SECRET,
|
|
||||||
expiresIn: expiresInSeconds,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async issueRefreshToken(userId: string): Promise<string> {
|
private async issueRefreshToken(
|
||||||
|
userId: string,
|
||||||
|
sessionStartedAt: Date = new Date(),
|
||||||
|
): Promise<string> {
|
||||||
const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex');
|
const token = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('hex');
|
||||||
const expiresAt = new Date(
|
const slidingExpiry =
|
||||||
Date.now() +
|
Date.now() +
|
||||||
parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d');
|
||||||
);
|
const absoluteExpiry =
|
||||||
|
sessionStartedAt.getTime() +
|
||||||
|
parseDurationToMs(process.env.SESSION_MAX_AGE ?? DEFAULT_SESSION_MAX_AGE);
|
||||||
await this.prisma.refreshToken.create({
|
await this.prisma.refreshToken.create({
|
||||||
data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt },
|
data: {
|
||||||
|
userId,
|
||||||
|
tokenHash: this.hashRefreshToken(token),
|
||||||
|
expiresAt: new Date(Math.min(slidingExpiry, absoluteExpiry)),
|
||||||
|
sessionStartedAt,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(email: string, password: string): Promise<AuthTokens> {
|
async login(email: string, password: string): Promise<LoginResult> {
|
||||||
const user = await this.usersService.findByEmail(email);
|
const user = await this.usersService.findByEmail(email);
|
||||||
if (!user || !user.isActive) {
|
// bcrypt runs on every path - unknown, deactivated, locked or real - so
|
||||||
await this.auditLog.record({
|
// the response time does not reveal which accounts exist.
|
||||||
entityType: 'AuthSession',
|
const passwordMatches = await bcrypt.compare(
|
||||||
entityId: user?.id ?? email,
|
password,
|
||||||
action: 'LOGIN_FAILED',
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
userId: user?.id,
|
);
|
||||||
});
|
if (!user) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
this.logger.warn('Login failed: unknown email');
|
||||||
|
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||||
}
|
}
|
||||||
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
|
if (!user.isActive || this.accountGuard.isLocked(user)) {
|
||||||
if (!passwordMatches) {
|
this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'AuthSession',
|
entityType: 'AuthSession',
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
action: 'LOGIN_FAILED',
|
action: 'LOGIN_FAILED',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||||
}
|
}
|
||||||
const authenticatedUser: AuthenticatedUser = {
|
if (!passwordMatches) {
|
||||||
|
this.logger.warn(`Login failed: wrong password for user ${user.id}`);
|
||||||
|
await this.accountGuard.recordFailure(user.id);
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'AuthSession',
|
||||||
|
entityId: user.id,
|
||||||
|
action: 'LOGIN_FAILED',
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||||
|
}
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
return {
|
||||||
|
mfaRequired: true,
|
||||||
|
mfaToken: this.mfaService.signChallengeToken(user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const authenticatedUser = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
const accessToken = this.signAccessToken(authenticatedUser);
|
||||||
|
const refreshToken = await this.issueRefreshToken(user.id);
|
||||||
|
await this.accountGuard.recordSuccess(user.id);
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'AuthSession',
|
||||||
|
entityId: user.id,
|
||||||
|
action: 'LOGIN',
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
return { mfaRequired: false, accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues our own access/refresh token pair for a user already
|
||||||
|
* authenticated via SSO. Local MFA is still enforced here, same as
|
||||||
|
* password login: SSO establishes identity, not the second factor, so a
|
||||||
|
* user who has explicitly enabled TOTP must complete it here too rather
|
||||||
|
* than getting it silently skipped by signing in via Microsoft instead.
|
||||||
|
*/
|
||||||
|
async issueTokensForSsoUser(userId: string): Promise<LoginResult> {
|
||||||
|
const user = await this.usersService.findById(userId);
|
||||||
|
if (!user.isActive) {
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
return {
|
||||||
|
mfaRequired: true,
|
||||||
|
mfaToken: this.mfaService.signChallengeToken(user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const accessToken = this.signAccessToken({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
const refreshToken = await this.issueRefreshToken(user.id);
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'AuthSession',
|
||||||
|
entityId: user.id,
|
||||||
|
action: 'LOGIN',
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
return { mfaRequired: false, accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
||||||
|
const { userId, claims } = this.mfaService.verifyChallengeToken(mfaToken);
|
||||||
|
const user = await this.usersService.findById(userId);
|
||||||
|
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
|
||||||
|
throw new UnauthorizedException('MFA nicht verfügbar');
|
||||||
|
}
|
||||||
|
if (this.accountGuard.isLocked(user)) {
|
||||||
|
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||||
|
}
|
||||||
|
const isValid = await this.accountGuard.consumeTotp(user, code);
|
||||||
|
if (!isValid) {
|
||||||
|
this.logger.warn(
|
||||||
|
`MFA verification failed: wrong or reused code for user ${user.id}`,
|
||||||
|
);
|
||||||
|
await this.accountGuard.recordFailure(user.id);
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'AuthSession',
|
||||||
|
entityId: user.id,
|
||||||
|
action: 'LOGIN_FAILED',
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
throw new UnauthorizedException('Code ungültig');
|
||||||
|
}
|
||||||
|
// A challenge that already opened a session cannot open another one.
|
||||||
|
await this.oneTimeTokens.consume(claims);
|
||||||
|
await this.accountGuard.recordSuccess(user.id);
|
||||||
|
const authenticatedUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
|
||||||
};
|
};
|
||||||
const accessToken = this.signAccessToken(authenticatedUser);
|
const accessToken = this.signAccessToken(authenticatedUser);
|
||||||
const refreshToken = await this.issueRefreshToken(user.id);
|
const refreshToken = await this.issueRefreshToken(user.id);
|
||||||
@@ -95,24 +255,58 @@ export class AuthService {
|
|||||||
const stored = await this.prisma.refreshToken.findUnique({
|
const stored = await this.prisma.refreshToken.findUnique({
|
||||||
where: { tokenHash },
|
where: { tokenHash },
|
||||||
});
|
});
|
||||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
if (!stored) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
stored.revokedAt &&
|
||||||
|
Date.now() - stored.revokedAt.getTime() < ROTATION_GRACE_MS
|
||||||
|
) {
|
||||||
|
// Lost a race with a parallel refresh (second tab): no new session,
|
||||||
|
// but no alarm either - that tab simply uses the other's cookie.
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
if (stored.revokedAt) {
|
||||||
|
// Reuse of an already-rotated-out refresh token is a strong signal
|
||||||
|
// it was stolen (a legitimate client only ever presents the newest
|
||||||
|
// token) - revoke every other active session for this user rather
|
||||||
|
// than trusting rotation alone to have contained the leak.
|
||||||
|
await this.prisma.refreshToken.updateMany({
|
||||||
|
where: { userId: stored.userId, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
this.permissionResolver.invalidateUser(stored.userId);
|
||||||
|
this.logger.warn(
|
||||||
|
`Refresh token reuse detected for user ${stored.userId}; all sessions revoked`,
|
||||||
|
);
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
if (stored.expiresAt < new Date()) {
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
}
|
}
|
||||||
const user = await this.usersService.findById(stored.userId);
|
const user = await this.usersService.findById(stored.userId);
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
}
|
}
|
||||||
await this.prisma.refreshToken.update({
|
// Atomic: of two requests presenting the same token, exactly one wins
|
||||||
where: { id: stored.id },
|
// and gets a successor - one stolen token can never fork into two live
|
||||||
|
// sessions.
|
||||||
|
const claimed = await this.prisma.refreshToken.updateMany({
|
||||||
|
where: { id: stored.id, revokedAt: null },
|
||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
const authenticatedUser: AuthenticatedUser = {
|
if (claimed.count !== 1) {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
const authenticatedUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
|
||||||
};
|
};
|
||||||
const accessToken = this.signAccessToken(authenticatedUser);
|
const accessToken = this.signAccessToken(authenticatedUser);
|
||||||
const newRefreshToken = await this.issueRefreshToken(user.id);
|
const newRefreshToken = await this.issueRefreshToken(
|
||||||
|
user.id,
|
||||||
|
stored.sessionStartedAt,
|
||||||
|
);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'AuthSession',
|
entityType: 'AuthSession',
|
||||||
entityId: user.id,
|
entityId: user.id,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const ANY_AUTHENTICATED_USER_KEY = 'any-authenticated-user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit opt-out of permission checking for a route that is meant to be
|
||||||
|
* reachable by any logged-in user regardless of role - it acts only on the
|
||||||
|
* caller's own data (profile, own notifications) or is the auth bootstrap
|
||||||
|
* endpoint every authenticated user must reach. Required by
|
||||||
|
* PermissionsGuard, which fails closed on any route carrying neither this
|
||||||
|
* nor @RequirePermission: a route is either explicitly public, explicitly
|
||||||
|
* permission-gated, or explicitly opted into "any authenticated user" -
|
||||||
|
* there is no fourth, silent option.
|
||||||
|
*/
|
||||||
|
export const AnyAuthenticatedUser = () =>
|
||||||
|
SetMetadata(ANY_AUTHENTICATED_USER_KEY, true);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { PermissionKey } from '../permissions/permission-catalog';
|
||||||
|
|
||||||
|
export const REQUIRE_PERMISSION_KEY = 'require-permission';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grants access if the caller holds ANY of the given permission keys
|
||||||
|
* (OR semantics, matching the legacy @Roles(...) behaviour it replaces).
|
||||||
|
*/
|
||||||
|
export const RequirePermission = (...keys: PermissionKey[]) =>
|
||||||
|
SetMetadata(REQUIRE_PERMISSION_KEY, keys);
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
import { Role } from '../../../generated/prisma';
|
|
||||||
|
|
||||||
export const ROLES_KEY = 'roles';
|
|
||||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import { NormalizeEmail } from '../../common/validation/normalize-email';
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
@NormalizeEmail()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email!: string;
|
email!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8)
|
||||||
|
// Long enough for any real password; bounds the bcrypt work an
|
||||||
|
// anonymous request can cause.
|
||||||
|
@MaxLength(256)
|
||||||
password!: string;
|
password!: string;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class MfaVerifyDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
mfaToken!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||||
|
import { ApiKeysService } from '../../api-keys/api-keys.service';
|
||||||
|
|
||||||
|
describe('JwtAuthGuard', () => {
|
||||||
|
let guard: JwtAuthGuard;
|
||||||
|
let reflector: { getAllAndOverride: jest.Mock };
|
||||||
|
let permissionResolver: { isTokenValid: jest.Mock };
|
||||||
|
let apiKeys: { authenticate: jest.Mock };
|
||||||
|
|
||||||
|
function contextWithUser(
|
||||||
|
user: { id: string; tokenIssuedAt?: number } | undefined,
|
||||||
|
authorization?: string,
|
||||||
|
): ExecutionContext {
|
||||||
|
const request = { user, headers: { authorization } };
|
||||||
|
return {
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
switchToHttp: () => ({ getRequest: () => request }),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) };
|
||||||
|
permissionResolver = { isTokenValid: jest.fn() };
|
||||||
|
apiKeys = { authenticate: jest.fn() };
|
||||||
|
guard = new JwtAuthGuard(
|
||||||
|
reflector as unknown as Reflector,
|
||||||
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
apiKeys as unknown as ApiKeysService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows public routes through without checking authentication', async () => {
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(true);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(permissionResolver.isTokenValid).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the request when the JWT is valid and the token is still valid for the user', async () => {
|
||||||
|
const context = contextWithUser({ id: 'user-1', tokenIssuedAt: 1000 });
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
|
||||||
|
canActivate: (context: ExecutionContext) => boolean;
|
||||||
|
},
|
||||||
|
'canActivate',
|
||||||
|
)
|
||||||
|
.mockReturnValue(true);
|
||||||
|
permissionResolver.isTokenValid.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||||
|
expect(permissionResolver.isTokenValid).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException when the user is deactivated or the token predates a password change', async () => {
|
||||||
|
const context = contextWithUser({ id: 'user-1', tokenIssuedAt: 1000 });
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
|
||||||
|
canActivate: (context: ExecutionContext) => boolean;
|
||||||
|
},
|
||||||
|
'canActivate',
|
||||||
|
)
|
||||||
|
.mockReturnValue(true);
|
||||||
|
permissionResolver.isTokenValid.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates an API key as a machine principal, not as a user', async () => {
|
||||||
|
const principal = { id: 'key-1', scopes: ['shop-sync:read'] };
|
||||||
|
apiKeys.authenticate.mockResolvedValue(principal);
|
||||||
|
const context = contextWithUser(undefined, 'Bearer ack_abcdefgh_secret');
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||||
|
const request = context.switchToHttp().getRequest<{
|
||||||
|
apiKey?: unknown;
|
||||||
|
user?: unknown;
|
||||||
|
}>();
|
||||||
|
expect(request.apiKey).toBe(principal);
|
||||||
|
expect(request.user).toBeUndefined();
|
||||||
|
expect(permissionResolver.isTokenValid).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown, revoked or expired API key', async () => {
|
||||||
|
apiKeys.authenticate.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(contextWithUser(undefined, 'Bearer ack_abcdefgh_x')),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,47 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
import {
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||||
|
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||||
|
import {
|
||||||
|
ApiKeyPrincipal,
|
||||||
|
ApiKeysService,
|
||||||
|
extractApiKeyToken,
|
||||||
|
} from '../../api-keys/api-keys.service';
|
||||||
|
|
||||||
|
interface RequestWithUser {
|
||||||
|
headers: { authorization?: string };
|
||||||
|
ip?: string;
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
apiKey?: ApiKeyPrincipal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces User.isActive and token freshness (issued after the last
|
||||||
|
* password change) on every authenticated request, not just ones gated by
|
||||||
|
* @RequirePermission. Without this, a deactivated user's still-valid access
|
||||||
|
* token - or one stolen before a password change - would keep working on
|
||||||
|
* any route that carries no permission decorator until the token's own
|
||||||
|
* expiry (up to JWT_ACCESS_EXPIRES_IN). isTokenValid shares
|
||||||
|
* PermissionsGuard's cache, so this check costs no extra query beyond what
|
||||||
|
* permission resolution already does.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private readonly reflector: Reflector) {
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
private readonly apiKeys: ApiKeysService,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
@@ -17,6 +49,36 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
if (isPublic) {
|
if (isPublic) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return super.canActivate(context);
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||||
|
|
||||||
|
// An API key ("Bearer ack_…") authenticates a machine, not a user: no
|
||||||
|
// request.user is set, so it can never pass a user-only route, and
|
||||||
|
// PermissionsGuard checks its scopes instead of a role.
|
||||||
|
const apiKeyToken = extractApiKeyToken(request.headers.authorization);
|
||||||
|
if (apiKeyToken !== null) {
|
||||||
|
// request.ip honours TRUST_PROXY (see app.config.ts).
|
||||||
|
const principal = await this.apiKeys.authenticate(
|
||||||
|
apiKeyToken,
|
||||||
|
request.ip,
|
||||||
|
);
|
||||||
|
if (!principal) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
request.apiKey = principal;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticated = (await super.canActivate(context)) as boolean;
|
||||||
|
if (!authenticated) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const user = request.user;
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
!(await this.permissionResolver.isTokenValid(user.id, user.tokenIssuedAt))
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { PermissionsGuard } from './permissions.guard';
|
||||||
|
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
|
||||||
|
import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator';
|
||||||
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||||
|
|
||||||
|
describe('PermissionsGuard', () => {
|
||||||
|
let guard: PermissionsGuard;
|
||||||
|
let reflectorValues: Map<string, unknown>;
|
||||||
|
let reflector: { getAllAndOverride: jest.Mock };
|
||||||
|
let permissionResolver: { hasAny: jest.Mock };
|
||||||
|
|
||||||
|
function contextWithUser(user: { id: string } | undefined): ExecutionContext {
|
||||||
|
const request = { user };
|
||||||
|
return {
|
||||||
|
getHandler: () => ({}),
|
||||||
|
getClass: () => ({}),
|
||||||
|
switchToHttp: () => ({ getRequest: () => request }),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reflectorValues = new Map();
|
||||||
|
reflector = {
|
||||||
|
getAllAndOverride: jest.fn((key: string) => reflectorValues.get(key)),
|
||||||
|
};
|
||||||
|
permissionResolver = { hasAny: jest.fn() };
|
||||||
|
guard = new PermissionsGuard(
|
||||||
|
reflector as unknown as Reflector,
|
||||||
|
permissionResolver as unknown as PermissionResolverService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a @Public route through with no user on the request (e.g. health checks, login)', async () => {
|
||||||
|
reflectorValues.set(IS_PUBLIC_KEY, true);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a route that carries neither @RequirePermission nor @AnyAuthenticatedUser (fails closed)', async () => {
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(contextWithUser({ id: 'user-1' })),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the caller permission set when @RequirePermission is present', async () => {
|
||||||
|
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
|
||||||
|
permissionResolver.hasAny.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(contextWithUser({ id: 'user-1' })),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
expect(permissionResolver.hasAny).toHaveBeenCalledWith('user-1', [
|
||||||
|
'apiaries:read',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies when the caller lacks all required permissions', async () => {
|
||||||
|
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
|
||||||
|
permissionResolver.hasAny.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(contextWithUser({ id: 'user-1' })),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a @RequirePermission route when there is no authenticated user on the request', async () => {
|
||||||
|
reflectorValues.set(REQUIRE_PERMISSION_KEY, ['apiaries:read']);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an authenticated user through a route marked @AnyAuthenticatedUser', async () => {
|
||||||
|
reflectorValues.set(ANY_AUTHENTICATED_USER_KEY, true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
guard.canActivate(contextWithUser({ id: 'user-1' })),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
expect(permissionResolver.hasAny).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies @AnyAuthenticatedUser when there is no user on the request', async () => {
|
||||||
|
reflectorValues.set(ANY_AUTHENTICATED_USER_KEY, true);
|
||||||
|
|
||||||
|
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ANY_AUTHENTICATED_USER_KEY } from '../decorators/any-authenticated-user.decorator';
|
||||||
|
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||||
|
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
|
||||||
|
import { PermissionKey } from '../permissions/permission-catalog';
|
||||||
|
import { PermissionResolverService } from '../permissions/permission-resolver.service';
|
||||||
|
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||||
|
import type { ApiKeyPrincipal } from '../../api-keys/api-keys.service';
|
||||||
|
|
||||||
|
interface RequestWithUser {
|
||||||
|
user?: AuthenticatedUser;
|
||||||
|
apiKey?: ApiKeyPrincipal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fails closed by decorator absence: a route reaches this guard whether or
|
||||||
|
* not it's @Public (JwtAuthGuard only skips authentication for those, it
|
||||||
|
* doesn't remove this guard from the chain - NestJS APP_GUARD entries all
|
||||||
|
* run, there's no early-exit between them), so @Public is checked here too,
|
||||||
|
* the same way JwtAuthGuard checks it. A non-public route is denied unless
|
||||||
|
* it carries either @RequirePermission (checked against the caller's live
|
||||||
|
* permission set) or the explicit @AnyAuthenticatedUser opt-out for routes
|
||||||
|
* meant to be reachable by any logged-in user regardless of role. A route
|
||||||
|
* with none of the three is a bug - forgetting to decorate a new route now
|
||||||
|
* fails shut at runtime instead of silently granting access to every
|
||||||
|
* authenticated user and relying solely on
|
||||||
|
* test/authorization-coverage.e2e-spec.ts to catch it before deploy.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionsGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredPermissions = this.reflector.getAllAndOverride<
|
||||||
|
PermissionKey[]
|
||||||
|
>(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);
|
||||||
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
// API keys pass only routes that name a permission the key carries -
|
||||||
|
// never @AnyAuthenticatedUser routes, which are meant for people.
|
||||||
|
if (request.apiKey) {
|
||||||
|
const scopes: readonly string[] = request.apiKey.scopes;
|
||||||
|
return Boolean(
|
||||||
|
requiredPermissions?.some((permission) => scopes.includes(permission)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||||
|
if (!user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.permissionResolver.hasAny(user.id, requiredPermissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAnyAuthenticatedUser = this.reflector.getAllAndOverride<boolean>(
|
||||||
|
ANY_AUTHENTICATED_USER_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
return Boolean(isAnyAuthenticatedUser && user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { ExecutionContext } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { RolesGuard } from './roles.guard';
|
|
||||||
import { Role } from '../../../generated/prisma';
|
|
||||||
|
|
||||||
function createContext(user: { role: Role } | undefined): ExecutionContext {
|
|
||||||
return {
|
|
||||||
getHandler: () => ({}),
|
|
||||||
getClass: () => ({}),
|
|
||||||
switchToHttp: () => ({
|
|
||||||
getRequest: () => ({ user }),
|
|
||||||
}),
|
|
||||||
} as unknown as ExecutionContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RolesGuard', () => {
|
|
||||||
it('allows access when no roles are required', () => {
|
|
||||||
const reflector = {
|
|
||||||
getAllAndOverride: () => undefined,
|
|
||||||
} as unknown as Reflector;
|
|
||||||
const guard = new RolesGuard(reflector);
|
|
||||||
expect(guard.canActivate(createContext(undefined))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies access when the user role does not match', () => {
|
|
||||||
const reflector = {
|
|
||||||
getAllAndOverride: () => [Role.ADMIN],
|
|
||||||
} as unknown as Reflector;
|
|
||||||
const guard = new RolesGuard(reflector);
|
|
||||||
expect(guard.canActivate(createContext({ role: Role.STAFF }))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows access when the user role matches', () => {
|
|
||||||
const reflector = {
|
|
||||||
getAllAndOverride: () => [Role.ADMIN],
|
|
||||||
} as unknown as Reflector;
|
|
||||||
const guard = new RolesGuard(reflector);
|
|
||||||
expect(guard.canActivate(createContext({ role: Role.ADMIN }))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies access when no user is attached to the request', () => {
|
|
||||||
const reflector = {
|
|
||||||
getAllAndOverride: () => [Role.ADMIN],
|
|
||||||
} as unknown as Reflector;
|
|
||||||
const guard = new RolesGuard(reflector);
|
|
||||||
expect(guard.canActivate(createContext(undefined))).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { Role } from '../../../generated/prisma';
|
|
||||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
|
||||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
|
||||||
|
|
||||||
interface RequestWithUser {
|
|
||||||
user?: AuthenticatedUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RolesGuard implements CanActivate {
|
|
||||||
constructor(private readonly reflector: Reflector) {}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
if (!requiredRoles || requiredRoles.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
|
||||||
const user = request.user;
|
|
||||||
return !!user && requiredRoles.includes(user.role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AuditLogService } from '../../audit/audit-log.service';
|
||||||
|
import { MfaService } from './mfa.service';
|
||||||
|
|
||||||
|
/** After this many failed attempts in a row the account is locked ... */
|
||||||
|
export const MAX_FAILED_AUTH_ATTEMPTS = 5;
|
||||||
|
/** ... for this long. */
|
||||||
|
export const AUTH_LOCK_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface GuardedUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
mfaSecret: string | null;
|
||||||
|
mfaLastTimeStep: number | null;
|
||||||
|
authLockedUntil: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account-level brute-force protection, on top of the per-IP throttle:
|
||||||
|
* an attacker spreading guesses over many addresses still only gets five
|
||||||
|
* tries per account per 15 minutes - for the password, the MFA code and
|
||||||
|
* step-up confirmations alike. A TOTP code is accepted once: its time step
|
||||||
|
* is recorded, and that step or any earlier one is refused afterwards.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AccountGuardService {
|
||||||
|
private readonly logger = new Logger(AccountGuardService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly mfaService: MfaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isLocked(user: Pick<GuardedUser, 'authLockedUntil'>): boolean {
|
||||||
|
return Boolean(user.authLockedUntil && user.authLockedUntil > new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordFailure(userId: string): Promise<void> {
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { failedAuthAttempts: { increment: 1 } },
|
||||||
|
select: { failedAuthAttempts: true },
|
||||||
|
});
|
||||||
|
if (updated.failedAuthAttempts >= MAX_FAILED_AUTH_ATTEMPTS) {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
authLockedUntil: new Date(Date.now() + AUTH_LOCK_MS),
|
||||||
|
failedAuthAttempts: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.warn(`Account ${userId} locked after repeated failures`);
|
||||||
|
await this.auditLog.record({
|
||||||
|
entityType: 'AuthSession',
|
||||||
|
entityId: userId,
|
||||||
|
action: 'ACCOUNT_LOCKED',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordSuccess(userId: string): Promise<void> {
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { failedAuthAttempts: 0, authLockedUntil: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only for a valid code whose time step is newer than the last one
|
||||||
|
* used - and only for one of two concurrent requests with the same code.
|
||||||
|
*/
|
||||||
|
async consumeTotp(user: GuardedUser, code: string): Promise<boolean> {
|
||||||
|
if (!user.mfaSecret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const timeStep = await this.mfaService.verifyCodeStep(
|
||||||
|
user.mfaSecret,
|
||||||
|
user.email,
|
||||||
|
code,
|
||||||
|
user.mfaLastTimeStep ?? undefined,
|
||||||
|
);
|
||||||
|
if (timeStep === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const claimed = await this.prisma.user.updateMany({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
OR: [{ mfaLastTimeStep: null }, { mfaLastTimeStep: { lt: timeStep } }],
|
||||||
|
},
|
||||||
|
data: { mfaLastTimeStep: timeStep },
|
||||||
|
});
|
||||||
|
return claimed.count === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
||||||
|
|
||||||
|
describe('mfa-crypto.util', () => {
|
||||||
|
const originalSecret = process.env.JWT_ACCESS_SECRET;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = originalSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a TOTP secret through encrypt/decrypt', () => {
|
||||||
|
const plaintext = 'JBSWY3DPEHPK3PXP';
|
||||||
|
const ciphertext = encryptMfaSecret(plaintext);
|
||||||
|
expect(ciphertext).not.toContain(plaintext);
|
||||||
|
expect(decryptMfaSecret(ciphertext)).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a different ciphertext every time (random IV)', () => {
|
||||||
|
const plaintext = 'JBSWY3DPEHPK3PXP';
|
||||||
|
expect(encryptMfaSecret(plaintext)).not.toBe(encryptMfaSecret(plaintext));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails to decrypt with the wrong key', () => {
|
||||||
|
const ciphertext = encryptMfaSecret('JBSWY3DPEHPK3PXP');
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'a-different-secret';
|
||||||
|
expect(() => decryptMfaSecret(ciphertext)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed ciphertext', () => {
|
||||||
|
expect(() => decryptMfaSecret('not-a-valid-ciphertext')).toThrow(
|
||||||
|
'Invalid MFA secret ciphertext',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHash,
|
||||||
|
randomBytes,
|
||||||
|
} from 'node:crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const IV_BYTES = 12;
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
const secret = process.env.JWT_ACCESS_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('JWT_ACCESS_SECRET is not set');
|
||||||
|
}
|
||||||
|
return createHash('sha256').update(secret).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP secrets must be reversible (unlike passwords/refresh tokens, which
|
||||||
|
* are one-way hashed) so the server can compute the expected code on every
|
||||||
|
* login. AES-256-GCM at rest, keyed off JWT_ACCESS_SECRET, keeps the secret
|
||||||
|
* out of the DB in plaintext without introducing a new secret to manage.
|
||||||
|
*/
|
||||||
|
export function encryptMfaSecret(plaintext: string): string {
|
||||||
|
const iv = randomBytes(IV_BYTES);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return [iv, authTag, encrypted]
|
||||||
|
.map((buffer) => buffer.toString('base64'))
|
||||||
|
.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptMfaSecret(ciphertext: string): string {
|
||||||
|
const [ivB64, authTagB64, dataB64] = ciphertext.split('.');
|
||||||
|
if (!ivB64 || !authTagB64 || !dataB64) {
|
||||||
|
throw new Error('Invalid MFA secret ciphertext');
|
||||||
|
}
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
ALGORITHM,
|
||||||
|
getKey(),
|
||||||
|
Buffer.from(ivB64, 'base64'),
|
||||||
|
);
|
||||||
|
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(dataB64, 'base64')),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { MfaService } from './mfa.service';
|
||||||
|
import { AccountGuardService } from './account-guard.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [JwtModule.register({})],
|
||||||
|
providers: [MfaService, AccountGuardService],
|
||||||
|
exports: [MfaService, AccountGuardService],
|
||||||
|
})
|
||||||
|
export class MfaModule {}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { TOTP, NobleCryptoPlugin, ScureBase32Plugin } from 'otplib';
|
||||||
|
import { MfaService } from './mfa.service';
|
||||||
|
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
||||||
|
import { generateTotpSecret } from './totp.util';
|
||||||
|
|
||||||
|
const crypto = new NobleCryptoPlugin();
|
||||||
|
const base32 = new ScureBase32Plugin();
|
||||||
|
|
||||||
|
describe('MfaService', () => {
|
||||||
|
let mfaService: MfaService;
|
||||||
|
let jwtService: { sign: jest.Mock; verify: jest.Mock };
|
||||||
|
const originalSecret = process.env.JWT_ACCESS_SECRET;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
||||||
|
jwtService = { sign: jest.fn(), verify: jest.fn() };
|
||||||
|
mfaService = new MfaService(jwtService as unknown as JwtService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = originalSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startEnrollment', () => {
|
||||||
|
it('returns a secret, QR SVG, otpauth URI, and a signed setup token', async () => {
|
||||||
|
jwtService.sign.mockReturnValue('signed-setup-token');
|
||||||
|
|
||||||
|
const enrollment = await mfaService.startEnrollment(
|
||||||
|
'user-1',
|
||||||
|
'[email protected]',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(enrollment.secret).toMatch(/^[A-Z2-7]+$/);
|
||||||
|
expect(enrollment.qrCodeSvg).toContain('<svg');
|
||||||
|
expect(enrollment.otpauthUri).toContain('otpauth://totp/');
|
||||||
|
expect(enrollment.setupToken).toBe('signed-setup-token');
|
||||||
|
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sub: 'user-1',
|
||||||
|
secret: enrollment.secret,
|
||||||
|
purpose: 'mfa-setup',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ expiresIn: '10m' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('confirmEnrollment', () => {
|
||||||
|
async function totpCodeFor(secret: string): Promise<string> {
|
||||||
|
return new TOTP({ secret, crypto, base32 }).generate();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('encrypts and returns the secret when the code is valid', async () => {
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
jwtService.verify.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
secret,
|
||||||
|
purpose: 'mfa-setup',
|
||||||
|
});
|
||||||
|
const code = await totpCodeFor(secret);
|
||||||
|
|
||||||
|
const { encryptedSecret } = await mfaService.confirmEnrollment(
|
||||||
|
'user-1',
|
||||||
|
'setup-token',
|
||||||
|
'[email protected]',
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decryptMfaSecret(encryptedSecret)).toBe(secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the code does not match the secret', async () => {
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
jwtService.verify.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
secret,
|
||||||
|
purpose: 'mfa-setup',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mfaService.confirmEnrollment(
|
||||||
|
'user-1',
|
||||||
|
'setup-token',
|
||||||
|
'[email protected]',
|
||||||
|
'000000',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the setup token belongs to a different user', async () => {
|
||||||
|
jwtService.verify.mockReturnValue({
|
||||||
|
sub: 'someone-else',
|
||||||
|
secret: generateTotpSecret(),
|
||||||
|
purpose: 'mfa-setup',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mfaService.confirmEnrollment(
|
||||||
|
'user-1',
|
||||||
|
'setup-token',
|
||||||
|
'[email protected]',
|
||||||
|
'123456',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the token is expired or invalid', async () => {
|
||||||
|
jwtService.verify.mockImplementation(() => {
|
||||||
|
throw new Error('jwt expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mfaService.confirmEnrollment(
|
||||||
|
'user-1',
|
||||||
|
'expired-token',
|
||||||
|
'[email protected]',
|
||||||
|
'123456',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('challenge tokens', () => {
|
||||||
|
it('signs a challenge token for login-time MFA verification', () => {
|
||||||
|
jwtService.sign.mockReturnValue('signed-challenge-token');
|
||||||
|
|
||||||
|
const token = mfaService.signChallengeToken('user-1');
|
||||||
|
|
||||||
|
expect(token).toBe('signed-challenge-token');
|
||||||
|
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ sub: 'user-1', purpose: 'mfa-challenge' }),
|
||||||
|
expect.objectContaining({ expiresIn: '5m' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies a valid challenge token', () => {
|
||||||
|
jwtService.verify.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
purpose: 'mfa-challenge',
|
||||||
|
jti: 'jti-1',
|
||||||
|
exp: 1_900_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mfaService.verifyChallengeToken('token')).toEqual({
|
||||||
|
userId: 'user-1',
|
||||||
|
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an expired/invalid challenge token', () => {
|
||||||
|
jwtService.verify.mockImplementation(() => {
|
||||||
|
throw new Error('jwt expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => mfaService.verifyChallengeToken('bad-token')).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the token purpose does not match', () => {
|
||||||
|
jwtService.verify.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
purpose: 'mfa-setup',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => mfaService.verifyChallengeToken('token')).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyCode', () => {
|
||||||
|
it('decrypts the stored secret and verifies the code', async () => {
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
const encryptedSecret = encryptMfaSecret(secret);
|
||||||
|
const code = await new TOTP({ secret, crypto, base32 }).generate();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
mfaService.verifyCode(encryptedSecret, '[email protected]', code),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util';
|
||||||
|
import {
|
||||||
|
buildTotpUri,
|
||||||
|
generateTotpSecret,
|
||||||
|
verifyTotpCode,
|
||||||
|
verifyTotpStep,
|
||||||
|
} from './totp.util';
|
||||||
|
import {
|
||||||
|
OneTimeTokenClaims,
|
||||||
|
TOKEN_AUDIENCE,
|
||||||
|
jwtSignOptions,
|
||||||
|
jwtVerifyOptions,
|
||||||
|
} from '../util/jwt-claims';
|
||||||
|
|
||||||
|
const SETUP_TOKEN_TTL = '10m';
|
||||||
|
const CHALLENGE_TOKEN_TTL = '5m';
|
||||||
|
const SETUP_TOKEN_PURPOSE = 'mfa-setup';
|
||||||
|
const CHALLENGE_TOKEN_PURPOSE = 'mfa-challenge';
|
||||||
|
|
||||||
|
interface SetupTokenPayload {
|
||||||
|
sub: string;
|
||||||
|
secret: string;
|
||||||
|
purpose: typeof SETUP_TOKEN_PURPOSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChallengeTokenPayload {
|
||||||
|
sub: string;
|
||||||
|
purpose: typeof CHALLENGE_TOKEN_PURPOSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MfaEnrollment {
|
||||||
|
secret: string;
|
||||||
|
qrCodeSvg: string;
|
||||||
|
otpauthUri: string;
|
||||||
|
setupToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MfaService {
|
||||||
|
constructor(private readonly jwtService: JwtService) {}
|
||||||
|
|
||||||
|
async startEnrollment(userId: string, email: string): Promise<MfaEnrollment> {
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
const otpauthUri = buildTotpUri(secret, email);
|
||||||
|
const qrCodeSvg = await QRCode.toString(otpauthUri, {
|
||||||
|
type: 'svg',
|
||||||
|
margin: 1,
|
||||||
|
width: 256,
|
||||||
|
});
|
||||||
|
const setupToken = this.jwtService.sign(
|
||||||
|
{ sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE },
|
||||||
|
jwtSignOptions(TOKEN_AUDIENCE.mfaSetup, SETUP_TOKEN_TTL),
|
||||||
|
);
|
||||||
|
return { secret, qrCodeSvg, otpauthUri, setupToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the plaintext secret from a setup token once the enrollment code checks out. */
|
||||||
|
async confirmEnrollment(
|
||||||
|
userId: string,
|
||||||
|
setupToken: string,
|
||||||
|
email: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<{ encryptedSecret: string }> {
|
||||||
|
let payload: SetupTokenPayload;
|
||||||
|
try {
|
||||||
|
payload = this.jwtService.verify<SetupTokenPayload>(
|
||||||
|
setupToken,
|
||||||
|
jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.purpose !== SETUP_TOKEN_PURPOSE || payload.sub !== userId) {
|
||||||
|
throw new UnauthorizedException('Setup-Token ungültig');
|
||||||
|
}
|
||||||
|
const isValid = await verifyTotpCode(payload.secret, email, code);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new UnauthorizedException('Code ungültig');
|
||||||
|
}
|
||||||
|
return { encryptedSecret: encryptMfaSecret(payload.secret) };
|
||||||
|
}
|
||||||
|
|
||||||
|
signChallengeToken(userId: string): string {
|
||||||
|
return this.jwtService.sign(
|
||||||
|
{ sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE },
|
||||||
|
jwtSignOptions(TOKEN_AUDIENCE.mfaChallenge, CHALLENGE_TOKEN_TTL),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyChallengeToken(challengeToken: string): {
|
||||||
|
userId: string;
|
||||||
|
claims: OneTimeTokenClaims;
|
||||||
|
} {
|
||||||
|
let payload: ChallengeTokenPayload & OneTimeTokenClaims;
|
||||||
|
try {
|
||||||
|
payload = this.jwtService.verify<
|
||||||
|
ChallengeTokenPayload & OneTimeTokenClaims
|
||||||
|
>(challengeToken, jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge));
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
|
||||||
|
throw new UnauthorizedException('Ungültiger Token');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
claims: { jti: payload.jti, exp: payload.exp },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** See verifyTotpStep; use AccountGuardService.consumeTotp for logins. */
|
||||||
|
async verifyCodeStep(
|
||||||
|
encryptedSecret: string,
|
||||||
|
email: string,
|
||||||
|
code: string,
|
||||||
|
afterTimeStep?: number,
|
||||||
|
): Promise<number | null> {
|
||||||
|
return verifyTotpStep(
|
||||||
|
decryptMfaSecret(encryptedSecret),
|
||||||
|
email,
|
||||||
|
code,
|
||||||
|
afterTimeStep,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyCode(
|
||||||
|
encryptedSecret: string,
|
||||||
|
email: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const secret = decryptMfaSecret(encryptedSecret);
|
||||||
|
return verifyTotpCode(secret, email, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib';
|
||||||
|
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util';
|
||||||
|
|
||||||
|
const crypto = new NobleCryptoPlugin();
|
||||||
|
const base32 = new ScureBase32Plugin();
|
||||||
|
|
||||||
|
describe('totp.util', () => {
|
||||||
|
it('generates a base32 secret', () => {
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
expect(secret).toMatch(/^[A-Z2-7]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds an otpauth:// URI carrying the issuer and label', () => {
|
||||||
|
const uri = buildTotpUri('JBSWY3DPEHPK3PXP', '[email protected]');
|
||||||
|
expect(uri).toContain('otpauth://totp/');
|
||||||
|
expect(uri).toContain('ApiCore');
|
||||||
|
expect(uri).toContain('secret=JBSWY3DPEHPK3PXP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies a code generated for the same secret', async () => {
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
const totp = new TOTP({ secret, crypto, base32 });
|
||||||
|
const code = await totp.generate();
|
||||||
|
await expect(
|
||||||
|
verifyTotpCode(secret, '[email protected]', code),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an incorrect code', async () => {
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
const totp = new TOTP({ secret, crypto, base32 });
|
||||||
|
const validCode = await totp.generate();
|
||||||
|
const wrongCode = validCode === '000000' ? '111111' : '000000';
|
||||||
|
await expect(
|
||||||
|
verifyTotpCode(secret, '[email protected]', wrongCode),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a code generated for a different secret', async () => {
|
||||||
|
const secretA = generateTotpSecret();
|
||||||
|
const secretB = generateTotpSecret();
|
||||||
|
const totpB = new TOTP({ secret: secretB, crypto, base32 });
|
||||||
|
const codeFromB = await totpB.generate();
|
||||||
|
await expect(
|
||||||
|
verifyTotpCode(secretA, '[email protected]', codeFromB),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NobleCryptoPlugin, ScureBase32Plugin, TOTP } from 'otplib';
|
||||||
|
|
||||||
|
const ISSUER = 'ApiCore';
|
||||||
|
// Allows one 30s step of clock drift in either direction between the
|
||||||
|
// authenticator app and the server, matching common 2FA implementations.
|
||||||
|
const EPOCH_TOLERANCE_SECONDS = 30;
|
||||||
|
|
||||||
|
const crypto = new NobleCryptoPlugin();
|
||||||
|
const base32 = new ScureBase32Plugin();
|
||||||
|
|
||||||
|
function createTotp(secret: string, label: string): TOTP {
|
||||||
|
return new TOTP({ secret, label, issuer: ISSUER, crypto, base32 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTotpSecret(): string {
|
||||||
|
return new TOTP({ crypto, base32 }).generateSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTotpUri(secret: string, label: string): string {
|
||||||
|
return createTotp(secret, label).toURI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time step a code belongs to, or null if it is wrong - or not newer
|
||||||
|
* than afterTimeStep (a code that was already used, or an older one).
|
||||||
|
*/
|
||||||
|
export async function verifyTotpStep(
|
||||||
|
secret: string,
|
||||||
|
label: string,
|
||||||
|
code: string,
|
||||||
|
afterTimeStep?: number,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const result = await createTotp(secret, label).verify(code, {
|
||||||
|
epochTolerance: EPOCH_TOLERANCE_SECONDS,
|
||||||
|
afterTimeStep,
|
||||||
|
});
|
||||||
|
return result.valid ? result.timeStep : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTotpCode(
|
||||||
|
secret: string,
|
||||||
|
label: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await createTotp(secret, label).verify(code, {
|
||||||
|
epochTolerance: EPOCH_TOLERANCE_SECONDS,
|
||||||
|
});
|
||||||
|
return result.valid;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
import { OneTimeTokenClaims } from './util/jwt-claims';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a short-lived helper token usable exactly once (SSO completion,
|
||||||
|
* MFA challenge after a successful code): its id is recorded, and a
|
||||||
|
* second presentation - a replayed request, a copied cookie - is refused.
|
||||||
|
* Works across replicas because the record lives in the database.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OneTimeTokenService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async consume(claims: OneTimeTokenClaims): Promise<void> {
|
||||||
|
if (!claims.jti || !claims.exp) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Anmeldung abgelaufen, bitte erneut anmelden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.prisma.consumedToken.create({
|
||||||
|
data: { jti: claims.jti, expiresAt: new Date(claims.exp * 1000) },
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Diese Anmeldung wurde bereits abgeschlossen, bitte erneut anmelden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { PermissionCatalogSyncService } from './permission-catalog-sync.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { PERMISSION_CATALOG } from './permission-catalog';
|
||||||
|
|
||||||
|
describe('PermissionCatalogSyncService', () => {
|
||||||
|
let service: PermissionCatalogSyncService;
|
||||||
|
let prisma: {
|
||||||
|
permission: { findMany: jest.Mock; createMany: jest.Mock };
|
||||||
|
role: { findMany: jest.Mock };
|
||||||
|
rolePermission: { createMany: jest.Mock };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
permission: {
|
||||||
|
// 1st call: existing keys (set per test). 2nd call: ids of the
|
||||||
|
// freshly inserted keys, derived from the `where.key.in` filter.
|
||||||
|
findMany: jest.fn(),
|
||||||
|
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
},
|
||||||
|
role: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
rolePermission: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||||
|
};
|
||||||
|
service = new PermissionCatalogSyncService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when every catalog permission already exists', async () => {
|
||||||
|
prisma.permission.findMany.mockResolvedValue(
|
||||||
|
PERMISSION_CATALOG.map((entry) => ({ key: entry.key })),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
expect(prisma.permission.createMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.role.findMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts missing catalog permissions and grants them to every system role', async () => {
|
||||||
|
const [first, ...rest] = PERMISSION_CATALOG;
|
||||||
|
prisma.permission.findMany
|
||||||
|
.mockResolvedValueOnce(rest.map((entry) => ({ key: entry.key })))
|
||||||
|
.mockResolvedValueOnce([{ id: `perm-${first.key}` }]);
|
||||||
|
prisma.role.findMany.mockResolvedValue([
|
||||||
|
{ id: 'role-admin' },
|
||||||
|
{ id: 'role-owner' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
expect(prisma.permission.createMany).toHaveBeenCalledWith({
|
||||||
|
data: [first],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
expect(prisma.role.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { isSystem: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(prisma.rolePermission.createMany).toHaveBeenCalledWith({
|
||||||
|
data: [
|
||||||
|
{ roleId: 'role-admin', permissionId: `perm-${first.key}` },
|
||||||
|
{ roleId: 'role-owner', permissionId: `perm-${first.key}` },
|
||||||
|
],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the role grant step when no system role exists', async () => {
|
||||||
|
prisma.permission.findMany.mockResolvedValue([]);
|
||||||
|
prisma.role.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
expect(prisma.permission.createMany).toHaveBeenCalledWith({
|
||||||
|
data: PERMISSION_CATALOG,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
expect(prisma.rolePermission.createMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates another replica inserting the same permissions concurrently', async () => {
|
||||||
|
// Both replicas saw the permission as missing; the other one won the
|
||||||
|
// insert, so createMany skips it instead of throwing a unique error.
|
||||||
|
const [first, ...rest] = PERMISSION_CATALOG;
|
||||||
|
prisma.permission.findMany
|
||||||
|
.mockResolvedValueOnce(rest.map((entry) => ({ key: entry.key })))
|
||||||
|
.mockResolvedValueOnce([{ id: `perm-${first.key}` }]);
|
||||||
|
prisma.role.findMany.mockResolvedValue([{ id: 'role-admin' }]);
|
||||||
|
|
||||||
|
await expect(service.onModuleInit()).resolves.toBeUndefined();
|
||||||
|
expect(prisma.rolePermission.createMany).toHaveBeenCalledWith({
|
||||||
|
data: [{ roleId: 'role-admin', permissionId: `perm-${first.key}` }],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { PERMISSION_CATALOG } from './permission-catalog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the Permission table in step with the code-owned PERMISSION_CATALOG
|
||||||
|
* so a future feature only needs to add an entry to the catalog file, not
|
||||||
|
* hand-write a migration to seed it too. Runs once on boot: inserts any
|
||||||
|
* catalog entry missing from the database. Never deletes a Permission row
|
||||||
|
* that fell out of the catalog (only warns) — a role's grants must not
|
||||||
|
* silently disappear because of a typo or a reordered catalog edit.
|
||||||
|
*
|
||||||
|
* Every newly-synced permission is also granted to every `isSystem` role
|
||||||
|
* (ADMIN's seed row documents itself as "Voller Zugriff auf alle Bereiche" -
|
||||||
|
* full access to every area). Without this, a role that legitimately means
|
||||||
|
* "everything" would silently lose that guarantee each time a new feature
|
||||||
|
* ships a new permission, forcing a manual grant in Rollen & Rechte before
|
||||||
|
* the feature is usable at all - exactly the gap that made the freshly
|
||||||
|
* shipped Etiketten feature invisible to the seeded admin account.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionCatalogSyncService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(PermissionCatalogSyncService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
const existing = await this.prisma.permission.findMany({
|
||||||
|
select: { key: true },
|
||||||
|
});
|
||||||
|
const existingKeys = new Set(existing.map((permission) => permission.key));
|
||||||
|
|
||||||
|
const missing = PERMISSION_CATALOG.filter(
|
||||||
|
(entry) => !existingKeys.has(entry.key),
|
||||||
|
);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
// skipDuplicates: several replicas boot at once against the same
|
||||||
|
// database and all see the same "missing" set. Plain creates made
|
||||||
|
// every replica but the first crash on a unique-key error.
|
||||||
|
await this.prisma.permission.createMany({
|
||||||
|
data: missing,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
const createdPermissions = await this.prisma.permission.findMany({
|
||||||
|
where: { key: { in: missing.map((entry) => entry.key) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Synced ${missing.length} new permission(s) from the code catalog: ${missing
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const systemRoles = await this.prisma.role.findMany({
|
||||||
|
where: { isSystem: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (systemRoles.length > 0) {
|
||||||
|
await this.prisma.rolePermission.createMany({
|
||||||
|
data: systemRoles.flatMap((role) =>
|
||||||
|
createdPermissions.map((permission) => ({
|
||||||
|
roleId: role.id,
|
||||||
|
permissionId: permission.id,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Granted ${missing.length} new permission(s) to ${systemRoles.length} system role(s).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogKeys = new Set(PERMISSION_CATALOG.map((entry) => entry.key));
|
||||||
|
const orphaned = existing.filter(
|
||||||
|
(permission) => !catalogKeys.has(permission.key),
|
||||||
|
);
|
||||||
|
if (orphaned.length > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`${orphaned.length} permission(s) exist in the database but not in the code catalog (left untouched): ${orphaned
|
||||||
|
.map((permission) => permission.key)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
export interface PermissionCatalogEntry {
|
||||||
|
key: string;
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
labelDe: string;
|
||||||
|
groupDe: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(
|
||||||
|
groupDe: string,
|
||||||
|
resource: string,
|
||||||
|
actions: [action: string, labelDe: string][],
|
||||||
|
startOrder: number,
|
||||||
|
): PermissionCatalogEntry[] {
|
||||||
|
return actions.map(([action, labelDe], index) => ({
|
||||||
|
key: `${resource}:${action}`,
|
||||||
|
resource,
|
||||||
|
action,
|
||||||
|
labelDe,
|
||||||
|
groupDe,
|
||||||
|
sortOrder: startOrder + index,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERMISSION_CATALOG = [
|
||||||
|
...group(
|
||||||
|
'Bienenstände',
|
||||||
|
'apiaries',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['deactivate', 'Deaktivieren'],
|
||||||
|
],
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Völker',
|
||||||
|
'colonies',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['deactivate', 'Deaktivieren'],
|
||||||
|
['manage-lifecycle', 'Umsetzen/Teilen/Vereinigen/Auflösen'],
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Königinnen',
|
||||||
|
'queens',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['assign', 'Volk zuweisen'],
|
||||||
|
['evaluate', 'Bewerten'],
|
||||||
|
],
|
||||||
|
300,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Durchsichten',
|
||||||
|
'inspections',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
400,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Aufgaben',
|
||||||
|
'tasks',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['complete', 'Erledigen'],
|
||||||
|
],
|
||||||
|
500,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Wiederkehrende Aufgaben',
|
||||||
|
'task-templates',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['deactivate', 'Deaktivieren'],
|
||||||
|
],
|
||||||
|
600,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Varroa',
|
||||||
|
'varroa',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
700,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Schwarmkontrolle',
|
||||||
|
'swarm-control',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
800,
|
||||||
|
),
|
||||||
|
// Seeded with role grants by the winter_checks migration (everyday records
|
||||||
|
// like inspections, not an admin-only area).
|
||||||
|
...group(
|
||||||
|
'Überwinterung',
|
||||||
|
'winter-checks',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
850,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Behandlungen',
|
||||||
|
'treatments',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
900,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Fütterungen',
|
||||||
|
'feedings',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Ernten',
|
||||||
|
'harvests',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
1100,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Schleuderungen',
|
||||||
|
'extractions',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
1200,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Honigchargen',
|
||||||
|
'honey-batches',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['relocate', 'Umlagern'],
|
||||||
|
['measure', 'Messwerte erfassen'],
|
||||||
|
],
|
||||||
|
1300,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Lager',
|
||||||
|
'storage',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['move', 'Bewegung buchen'],
|
||||||
|
],
|
||||||
|
1400,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Produkte',
|
||||||
|
'products',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
1500,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Abfüllungen',
|
||||||
|
'bottling',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['delete', 'Löschen'],
|
||||||
|
['withdraw', 'Entnehmen und Inventur'],
|
||||||
|
],
|
||||||
|
1600,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Rohstoffe',
|
||||||
|
'raw-material',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['delete', 'Löschen'],
|
||||||
|
['withdraw', 'Entnehmen und Inventur'],
|
||||||
|
],
|
||||||
|
1630,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Herstellungschargen',
|
||||||
|
'production-batch',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['delete', 'Löschen'],
|
||||||
|
['withdraw', 'Entnehmen und Inventur'],
|
||||||
|
],
|
||||||
|
1650,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Etiketten',
|
||||||
|
'label-templates',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['deactivate', 'Deaktivieren'],
|
||||||
|
['print', 'Drucken'],
|
||||||
|
],
|
||||||
|
1650,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Inventar',
|
||||||
|
'equipment',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
1700,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Dokumente',
|
||||||
|
'documents',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['upload', 'Hochladen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['delete', 'Löschen'],
|
||||||
|
],
|
||||||
|
1800,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Webhooks',
|
||||||
|
'webhooks',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['delete', 'Löschen'],
|
||||||
|
],
|
||||||
|
1900,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Automatisierungen',
|
||||||
|
'automation',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
2000,
|
||||||
|
),
|
||||||
|
...group('Import', 'import', [['execute', 'Ausführen']], 2100),
|
||||||
|
...group('Export', 'export', [['read', 'Ansehen']], 2200),
|
||||||
|
...group(
|
||||||
|
'Benutzer',
|
||||||
|
'users',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['deactivate', 'Deaktivieren'],
|
||||||
|
['assign-role', 'Rolle zuweisen'],
|
||||||
|
],
|
||||||
|
2300,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Rollen & Rechte',
|
||||||
|
'roles',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
['deactivate', 'Deaktivieren'],
|
||||||
|
],
|
||||||
|
2400,
|
||||||
|
),
|
||||||
|
...group('Audit-Log', 'audit', [['read', 'Ansehen']], 2500),
|
||||||
|
...group(
|
||||||
|
'Kalender',
|
||||||
|
'calendar',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['configure', 'M365-Synchronisation konfigurieren'],
|
||||||
|
],
|
||||||
|
2600,
|
||||||
|
),
|
||||||
|
...group('Dashboard', 'dashboard', [['read', 'Ansehen']], 2700),
|
||||||
|
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
|
||||||
|
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
|
||||||
|
...group('QR-Codes', 'qr', [['read', 'Ansehen']], 3000),
|
||||||
|
...group(
|
||||||
|
'Website-Einstellungen',
|
||||||
|
'site-settings',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
3100,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'Backup',
|
||||||
|
'backup',
|
||||||
|
[
|
||||||
|
['export', 'Exportieren'],
|
||||||
|
['import', 'Importieren'],
|
||||||
|
['configure', 'Zeitplan konfigurieren'],
|
||||||
|
],
|
||||||
|
3200,
|
||||||
|
),
|
||||||
|
// Deliberately separate from harvests:read etc.: those endpoints return
|
||||||
|
// internal notes and full records. The shop sync account needs only the
|
||||||
|
// allowlisted feed, so it gets this one permission and nothing else.
|
||||||
|
...group(
|
||||||
|
'Shop-Synchronisation',
|
||||||
|
'shop-sync',
|
||||||
|
[
|
||||||
|
['read', 'Freigegebene Daten für den Shop lesen'],
|
||||||
|
['write', 'Verkäufe und Rückbuchungen des Shops buchen'],
|
||||||
|
],
|
||||||
|
3300,
|
||||||
|
),
|
||||||
|
...group(
|
||||||
|
'API-Schlüssel',
|
||||||
|
'api-keys',
|
||||||
|
[['manage', 'Anlegen, ansehen und widerrufen']],
|
||||||
|
3400,
|
||||||
|
),
|
||||||
|
// Like shop-sync: a narrow read-only allowlist for one machine consumer
|
||||||
|
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
||||||
|
// which return descriptions and full records.
|
||||||
|
...group(
|
||||||
|
'ERP-Anbindung',
|
||||||
|
'erp-inventory',
|
||||||
|
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
||||||
|
3500,
|
||||||
|
),
|
||||||
|
] as const satisfies readonly PermissionCatalogEntry[];
|
||||||
|
|
||||||
|
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { PermissionResolverService } from './permission-resolver.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('PermissionResolverService', () => {
|
||||||
|
let resolver: PermissionResolverService;
|
||||||
|
let prisma: { user: { findUnique: jest.Mock } };
|
||||||
|
|
||||||
|
function activeUserWithPermissions(...keys: string[]) {
|
||||||
|
return {
|
||||||
|
isActive: true,
|
||||||
|
passwordChangedAt: null as Date | null,
|
||||||
|
role: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: keys.map((key) => ({ permission: { key } })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = { user: { findUnique: jest.fn() } };
|
||||||
|
resolver = new PermissionResolverService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the granted permission keys for an active user with an active role', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
|
activeUserWithPermissions('colonies:read', 'tasks:create'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = await resolver.getPermissions('user-1');
|
||||||
|
|
||||||
|
expect(Array.from(permissions).sort()).toEqual(
|
||||||
|
['colonies:read', 'tasks:create'].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no permissions for a deactivated user, even if their role is active', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
isActive: false,
|
||||||
|
role: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: [{ permission: { key: 'colonies:read' } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissions = await resolver.getPermissions('user-1');
|
||||||
|
|
||||||
|
expect(permissions.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no permissions when the role itself is deactivated', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
isActive: true,
|
||||||
|
role: {
|
||||||
|
status: 'INACTIVE',
|
||||||
|
permissions: [{ permission: { key: 'colonies:read' } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissions = await resolver.getPermissions('user-1');
|
||||||
|
|
||||||
|
expect(permissions.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no permissions when the user does not exist', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const permissions = await resolver.getPermissions('missing');
|
||||||
|
|
||||||
|
expect(permissions.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches the result and does not re-query within the TTL', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-queries after invalidateUser', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
resolver.invalidateUser('user-1');
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isTokenValid', () => {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
it('returns true for an active user with no recorded password change', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for a deactivated user', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
isActive: false,
|
||||||
|
passwordChangedAt: null,
|
||||||
|
role: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: [{ permission: { key: 'colonies:read' } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the role is inactive', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue({
|
||||||
|
isActive: true,
|
||||||
|
passwordChangedAt: null,
|
||||||
|
role: {
|
||||||
|
status: 'INACTIVE',
|
||||||
|
permissions: [{ permission: { key: 'colonies:read' } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the user does not exist', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(resolver.isTokenValid('missing', nowSeconds)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the token was issued before the last password change', async () => {
|
||||||
|
const entry = activeUserWithPermissions('colonies:read');
|
||||||
|
entry.passwordChangedAt = new Date((nowSeconds + 60) * 1000);
|
||||||
|
prisma.user.findUnique.mockResolvedValue(entry);
|
||||||
|
|
||||||
|
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when the token was issued after the last password change', async () => {
|
||||||
|
const entry = activeUserWithPermissions('colonies:read');
|
||||||
|
entry.passwordChangedAt = new Date((nowSeconds - 60) * 1000);
|
||||||
|
prisma.user.findUnique.mockResolvedValue(entry);
|
||||||
|
|
||||||
|
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads fresh every time and refreshes the cache for getPermissions', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
await resolver.isTokenValid('user-1', nowSeconds);
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
|
||||||
|
// One read for the first getPermissions, one fresh read for the
|
||||||
|
// validity check - the second getPermissions uses that fresh entry.
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sees a deactivation made elsewhere on the next request', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce({
|
||||||
|
...activeUserWithPermissions('colonies:read'),
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-queries after invalidateAll', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
resolver.invalidateAll();
|
||||||
|
await resolver.getPermissions('user-1');
|
||||||
|
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasAny', () => {
|
||||||
|
it('returns true when the user holds at least one of the requested keys', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolver.hasAny('user-1', ['tasks:create', 'colonies:read']),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the user holds none of the requested keys', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValue(
|
||||||
|
activeUserWithPermissions('colonies:read'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(resolver.hasAny('user-1', ['tasks:create'])).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when no keys are required', async () => {
|
||||||
|
await expect(resolver.hasAny('user-1', [])).resolves.toBe(true);
|
||||||
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { PermissionKey } from './permission-catalog';
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
isActive: boolean;
|
||||||
|
passwordChangedAt: Date | null;
|
||||||
|
permissions: ReadonlySet<string>;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a user's effective permission set fresh from the database on
|
||||||
|
* every request (not embedded in the JWT, so revoking a role or editing a
|
||||||
|
* role's grants takes effect without forcing a re-login), with a short TTL
|
||||||
|
* cache to keep that from becoming a query-per-request cost. Call
|
||||||
|
* invalidateUser/invalidateAll after any write to Role, Permission,
|
||||||
|
* RolePermission, User.roleId, or User.passwordChangedAt so the change is
|
||||||
|
* visible immediately instead of waiting out the cache window.
|
||||||
|
*
|
||||||
|
* isTokenValid shares the same cached entry: JwtAuthGuard calls it on every
|
||||||
|
* authenticated request (including routes with no @RequirePermission) so a
|
||||||
|
* deactivated user's still-valid access token stops working within the
|
||||||
|
* cache TTL instead of staying usable until the token expires, and so does
|
||||||
|
* a token issued before the user's last password change.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionResolverService {
|
||||||
|
private readonly cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getPermissions(userId: string): Promise<ReadonlySet<string>> {
|
||||||
|
const entry = await this.getEntry(userId);
|
||||||
|
return entry.permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Always read fresh (one primary-key lookup) and stored as the cache
|
||||||
|
* entry the PermissionsGuard then uses: a deactivation, role change or
|
||||||
|
* password change made on ANOTHER replica takes effect on the very next
|
||||||
|
* request here, not after the cache window.
|
||||||
|
*/
|
||||||
|
async isTokenValid(
|
||||||
|
userId: string,
|
||||||
|
tokenIssuedAtSeconds: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const entry = await this.loadEntry(userId);
|
||||||
|
this.cache.set(userId, entry);
|
||||||
|
if (!entry.isActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!entry.passwordChangedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return tokenIssuedAtSeconds * 1000 >= entry.passwordChangedAt.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasAny(
|
||||||
|
userId: string,
|
||||||
|
keys: readonly PermissionKey[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const permissions = await this.getPermissions(userId);
|
||||||
|
return keys.some((key) => permissions.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateUser(userId: string): void {
|
||||||
|
this.cache.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateAll(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEntry(userId: string): Promise<CacheEntry> {
|
||||||
|
const cached = this.cache.get(userId);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const entry = await this.loadEntry(userId);
|
||||||
|
this.cache.set(userId, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadEntry(userId: string): Promise<CacheEntry> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
isActive: true,
|
||||||
|
passwordChangedAt: true,
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
permissions: {
|
||||||
|
select: { permission: { select: { key: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const isEligible = Boolean(
|
||||||
|
user && user.isActive && user.role.status === 'ACTIVE',
|
||||||
|
);
|
||||||
|
const permissions = isEligible
|
||||||
|
? new Set(user!.role.permissions.map((grant) => grant.permission.key))
|
||||||
|
: new Set<string>();
|
||||||
|
return {
|
||||||
|
isActive: isEligible,
|
||||||
|
passwordChangedAt: user?.passwordChangedAt ?? null,
|
||||||
|
permissions,
|
||||||
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PermissionCatalogSyncService } from './permission-catalog-sync.service';
|
||||||
|
import { PermissionResolverService } from './permission-resolver.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PermissionResolverService, PermissionCatalogSyncService],
|
||||||
|
exports: [PermissionResolverService],
|
||||||
|
})
|
||||||
|
export class PermissionsModule {}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MfaModule } from '../mfa/mfa.module';
|
||||||
|
import { ReauthService } from './reauth.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MfaModule],
|
||||||
|
providers: [ReauthService],
|
||||||
|
exports: [ReauthService],
|
||||||
|
})
|
||||||
|
export class ReauthModule {}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { AccountGuardService } from '../mfa/account-guard.service';
|
||||||
|
|
||||||
|
export interface ReauthProof {
|
||||||
|
password?: string;
|
||||||
|
mfaCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step-up check for actions that are as powerful as the account itself
|
||||||
|
* (restoring a backup overwrites every user): a stolen access token alone
|
||||||
|
* must not be enough, so the caller proves they know the password - and,
|
||||||
|
* with MFA on, holds the second factor - right now.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ReauthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly accountGuard: AccountGuardService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async verify(userId: string, proof: ReauthProof): Promise<void> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
passwordHash: true,
|
||||||
|
mfaEnabled: true,
|
||||||
|
mfaSecret: true,
|
||||||
|
mfaLastTimeStep: true,
|
||||||
|
authLockedUntil: true,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user || !user.isActive || this.accountGuard.isLocked(user)) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Bestätigung derzeit nicht möglich. Nach mehreren Fehlversuchen ist sie für 15 Minuten gesperrt.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const passwordMatches =
|
||||||
|
typeof proof.password === 'string' &&
|
||||||
|
(await bcrypt.compare(proof.password, user.passwordHash));
|
||||||
|
if (!passwordMatches) {
|
||||||
|
await this.accountGuard.recordFailure(user.id);
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (user.mfaEnabled) {
|
||||||
|
const codeMatches =
|
||||||
|
typeof proof.mfaCode === 'string' &&
|
||||||
|
(await this.accountGuard.consumeTotp(user, proof.mfaCode));
|
||||||
|
if (!codeMatches) {
|
||||||
|
await this.accountGuard.recordFailure(user.id);
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.accountGuard.recordSuccess(user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { Public } from '../decorators/public.decorator';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
import { MfaRequiredResponse } from '../auth.controller';
|
||||||
|
import { AccessTokenDto } from '../dto/access-token.dto';
|
||||||
|
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
||||||
|
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
||||||
|
import { OneTimeTokenService } from '../one-time-token.service';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
|
||||||
|
const PKCE_COOKIE = 'oidcPkceVerifier';
|
||||||
|
const PKCE_COOKIE_PATH = '/api/v1/auth/sso';
|
||||||
|
const PKCE_COOKIE_MAX_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Scoped to /complete only - the completion endpoint is the only place that
|
||||||
|
// ever needs to read it, and it's never sent on the wider /auth/sso/* path.
|
||||||
|
const SSO_CODE_COOKIE = 'oidcSsoCode';
|
||||||
|
const SSO_CODE_COOKIE_PATH = '/api/v1/auth/sso/complete';
|
||||||
|
const SSO_CODE_COOKIE_MAX_AGE_MS = 60 * 1000;
|
||||||
|
|
||||||
|
interface PkceCookiePayload {
|
||||||
|
codeVerifier: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller('auth/sso')
|
||||||
|
export class SsoController {
|
||||||
|
constructor(
|
||||||
|
private readonly ssoService: SsoService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly oneTimeTokens: OneTimeTokenService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private frontendLoginUrl(): string {
|
||||||
|
return `${process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'}/login`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cookieOptions(path: string, maxAge: number) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.COOKIE_SECURE !== 'false',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path,
|
||||||
|
maxAge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('status')
|
||||||
|
status(): { enabled: boolean } {
|
||||||
|
return { enabled: this.ssoService.isConfigured() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('login')
|
||||||
|
async login(@Res() res: Response): Promise<void> {
|
||||||
|
const { redirectUrl, codeVerifier, state } =
|
||||||
|
await this.ssoService.buildAuthorizationRequest();
|
||||||
|
const payload: PkceCookiePayload = { codeVerifier, state };
|
||||||
|
res.cookie(
|
||||||
|
PKCE_COOKIE,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
this.cookieOptions(PKCE_COOKIE_PATH, PKCE_COOKIE_MAX_AGE_MS),
|
||||||
|
);
|
||||||
|
res.redirect(redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('callback')
|
||||||
|
async callback(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||||
|
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
|
||||||
|
PKCE_COOKIE
|
||||||
|
];
|
||||||
|
res.clearCookie(PKCE_COOKIE, this.cookieOptions(PKCE_COOKIE_PATH, 0));
|
||||||
|
|
||||||
|
const pkce = this.parsePkceCookie(rawCookie);
|
||||||
|
if (!pkce) {
|
||||||
|
res.redirect(`${this.frontendLoginUrl()}?ssoError=session_expired`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built from the configured redirect URI, not from the Host header a
|
||||||
|
// client sends; only the query (code, state, iss) comes from the request.
|
||||||
|
const callbackUrl = new URL(process.env.OIDC_REDIRECT_URI as string);
|
||||||
|
callbackUrl.search = new URL(req.originalUrl, 'http://placeholder').search;
|
||||||
|
const result = await this.ssoService.handleCallback(
|
||||||
|
callbackUrl,
|
||||||
|
pkce.codeVerifier,
|
||||||
|
pkce.state,
|
||||||
|
);
|
||||||
|
if (result.status === 'error') {
|
||||||
|
res.redirect(`${this.frontendLoginUrl()}?ssoError=${result.reason}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one-time completion code never touches the URL (history, server
|
||||||
|
// logs, Referer headers) - it travels only in an httpOnly cookie scoped
|
||||||
|
// to the /complete endpoint, which the frontend calls right after.
|
||||||
|
res.cookie(
|
||||||
|
SSO_CODE_COOKIE,
|
||||||
|
result.ssoCode,
|
||||||
|
this.cookieOptions(SSO_CODE_COOKIE_PATH, SSO_CODE_COOKIE_MAX_AGE_MS),
|
||||||
|
);
|
||||||
|
res.redirect(`${this.frontendLoginUrl()}?ssoPending=1`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||||
|
@Post('complete')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async complete(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<AccessTokenDto | MfaRequiredResponse> {
|
||||||
|
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
|
||||||
|
SSO_CODE_COOKIE
|
||||||
|
];
|
||||||
|
res.clearCookie(
|
||||||
|
SSO_CODE_COOKIE,
|
||||||
|
this.cookieOptions(SSO_CODE_COOKIE_PATH, 0),
|
||||||
|
);
|
||||||
|
if (!ssoCode) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { userId, claims } = this.ssoService.verifySsoCode(ssoCode);
|
||||||
|
await this.oneTimeTokens.consume(claims);
|
||||||
|
const result = await this.authService.issueTokensForSsoUser(userId);
|
||||||
|
if (result.mfaRequired) {
|
||||||
|
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||||
|
}
|
||||||
|
setRefreshTokenCookie(res, result.refreshToken);
|
||||||
|
return { accessToken: result.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<PkceCookiePayload>;
|
||||||
|
if (
|
||||||
|
typeof parsed.codeVerifier !== 'string' ||
|
||||||
|
typeof parsed.state !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { codeVerifier: parsed.codeVerifier, state: parsed.state };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { UsersModule } from '../../users/users.module';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
|
||||||
|
// SsoController lives in AuthModule (not here) because it also depends on
|
||||||
|
// AuthService, which only AuthModule provides - keeping the controller here
|
||||||
|
// would require a circular import between AuthModule and SsoModule.
|
||||||
|
@Module({
|
||||||
|
imports: [JwtModule.register({}), UsersModule],
|
||||||
|
providers: [SsoService],
|
||||||
|
exports: [SsoService],
|
||||||
|
})
|
||||||
|
export class SsoModule {}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import {
|
||||||
|
ServiceUnavailableException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import * as client from 'openid-client';
|
||||||
|
import { SsoService } from './sso.service';
|
||||||
|
import { UsersService } from '../../users/users.service';
|
||||||
|
|
||||||
|
jest.mock('openid-client', () => ({
|
||||||
|
discovery: jest.fn(),
|
||||||
|
randomPKCECodeVerifier: jest.fn(),
|
||||||
|
calculatePKCECodeChallenge: jest.fn(),
|
||||||
|
randomState: jest.fn(),
|
||||||
|
buildAuthorizationUrl: jest.fn(),
|
||||||
|
authorizationCodeGrant: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedClient = client as jest.Mocked<typeof client>;
|
||||||
|
|
||||||
|
const IDENTITY = {
|
||||||
|
iss: 'https://login.microsoftonline.com/tenant/v2.0',
|
||||||
|
sub: 'pairwise-sub',
|
||||||
|
oid: 'object-id-1',
|
||||||
|
};
|
||||||
|
const SSO_SUBJECT = `${IDENTITY.iss}|${IDENTITY.oid}`;
|
||||||
|
|
||||||
|
function grantWithClaims(claims: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
claims: () => claims,
|
||||||
|
} as unknown as Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SsoService', () => {
|
||||||
|
let ssoService: SsoService;
|
||||||
|
let usersService: {
|
||||||
|
findByEmail: jest.Mock;
|
||||||
|
findBySsoSubject: jest.Mock;
|
||||||
|
linkSsoSubject: jest.Mock;
|
||||||
|
};
|
||||||
|
let jwtService: { sign: jest.Mock; verify: jest.Mock };
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
process.env.OIDC_ISSUER_URL =
|
||||||
|
'https://login.microsoftonline.com/tenant/v2.0';
|
||||||
|
process.env.OIDC_CLIENT_ID = 'client-id';
|
||||||
|
process.env.OIDC_CLIENT_SECRET = 'client-secret';
|
||||||
|
process.env.OIDC_REDIRECT_URI =
|
||||||
|
'https://api.example.com/api/v1/auth/sso/callback';
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
|
||||||
|
usersService = {
|
||||||
|
findByEmail: jest.fn(),
|
||||||
|
findBySsoSubject: jest.fn().mockResolvedValue(null),
|
||||||
|
linkSsoSubject: jest.fn(),
|
||||||
|
};
|
||||||
|
jwtService = { sign: jest.fn(), verify: jest.fn() };
|
||||||
|
ssoService = new SsoService(
|
||||||
|
usersService as unknown as UsersService,
|
||||||
|
jwtService as unknown as JwtService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isConfigured', () => {
|
||||||
|
it('returns true when all four OIDC variables are set', () => {
|
||||||
|
expect(ssoService.isConfigured()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for a multi-tenant issuer', () => {
|
||||||
|
process.env.OIDC_ISSUER_URL =
|
||||||
|
'https://login.microsoftonline.com/common/v2.0';
|
||||||
|
expect(ssoService.isConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when a variable is missing', () => {
|
||||||
|
delete process.env.OIDC_CLIENT_SECRET;
|
||||||
|
expect(ssoService.isConfigured()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildAuthorizationRequest', () => {
|
||||||
|
it('throws ServiceUnavailableException when SSO is not configured', async () => {
|
||||||
|
delete process.env.OIDC_ISSUER_URL;
|
||||||
|
await expect(ssoService.buildAuthorizationRequest()).rejects.toThrow(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers the issuer once and returns the authorization URL with a PKCE verifier', async () => {
|
||||||
|
const config = {} as client.Configuration;
|
||||||
|
mockedClient.discovery.mockResolvedValue(config);
|
||||||
|
mockedClient.randomPKCECodeVerifier.mockReturnValue('verifier');
|
||||||
|
mockedClient.calculatePKCECodeChallenge.mockResolvedValue('challenge');
|
||||||
|
mockedClient.randomState.mockReturnValue('random-state');
|
||||||
|
mockedClient.buildAuthorizationUrl.mockReturnValue(
|
||||||
|
new URL(
|
||||||
|
'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?x=1',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ssoService.buildAuthorizationRequest();
|
||||||
|
await ssoService.buildAuthorizationRequest();
|
||||||
|
|
||||||
|
expect(result.codeVerifier).toBe('verifier');
|
||||||
|
expect(result.state).toBe('random-state');
|
||||||
|
expect(result.redirectUrl).toContain('authorize');
|
||||||
|
expect(mockedClient.discovery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedClient.buildAuthorizationUrl).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
expect.objectContaining({
|
||||||
|
redirect_uri: process.env.OIDC_REDIRECT_URI,
|
||||||
|
code_challenge: 'challenge',
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state: 'random-state',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleCallback', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedClient.discovery.mockResolvedValue({} as client.Configuration);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns exchange_failed when the token exchange throws', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockRejectedValue(
|
||||||
|
new Error('bad code'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'error', reason: 'exchange_failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no_email when the ID token has neither email nor preferred_username', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||||
|
claims: () => ({ ...IDENTITY }),
|
||||||
|
} as unknown as Awaited<
|
||||||
|
ReturnType<typeof client.authorizationCodeGrant>
|
||||||
|
>);
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'error', reason: 'no_email' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no_account when no active local user matches the email claim', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||||
|
claims: () => ({ ...IDENTITY, email: '[email protected]' }),
|
||||||
|
} as unknown as Awaited<
|
||||||
|
ReturnType<typeof client.authorizationCodeGrant>
|
||||||
|
>);
|
||||||
|
usersService.findByEmail.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'error', reason: 'no_account' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs a short-lived sso-complete token for an active matching user', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||||
|
claims: () => ({
|
||||||
|
...IDENTITY,
|
||||||
|
preferred_username: '[email protected]',
|
||||||
|
}),
|
||||||
|
} as unknown as Awaited<
|
||||||
|
ReturnType<typeof client.authorizationCodeGrant>
|
||||||
|
>);
|
||||||
|
usersService.findByEmail.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
jwtService.sign.mockReturnValue('signed-sso-code');
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' });
|
||||||
|
expect(mockedClient.authorizationCodeGrant).toHaveBeenCalledWith(
|
||||||
|
{},
|
||||||
|
expect.any(URL),
|
||||||
|
{ pkceCodeVerifier: 'verifier', expectedState: 'random-state' },
|
||||||
|
);
|
||||||
|
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }),
|
||||||
|
expect.objectContaining({ expiresIn: '60s' }),
|
||||||
|
);
|
||||||
|
expect(usersService.linkSsoSubject).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
SSO_SUBJECT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs in a linked account by its identity, whatever the email claim says', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockResolvedValue(
|
||||||
|
grantWithClaims({ ...IDENTITY, email: '[email protected]' }),
|
||||||
|
);
|
||||||
|
usersService.findBySsoSubject.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
jwtService.sign.mockReturnValue('signed-sso-code');
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' });
|
||||||
|
expect(usersService.findBySsoSubject).toHaveBeenCalledWith(SSO_SUBJECT);
|
||||||
|
expect(usersService.findByEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a matching email when the account is linked to another identity', async () => {
|
||||||
|
mockedClient.authorizationCodeGrant.mockResolvedValue(
|
||||||
|
grantWithClaims({
|
||||||
|
...IDENTITY,
|
||||||
|
oid: 'attacker-object-id',
|
||||||
|
email: '[email protected]',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
usersService.findByEmail.mockResolvedValue({
|
||||||
|
id: 'user-1',
|
||||||
|
isActive: true,
|
||||||
|
ssoSubject: SSO_SUBJECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ssoService.handleCallback(
|
||||||
|
new URL('https://api.example.com/callback?code=x'),
|
||||||
|
'verifier',
|
||||||
|
'random-state',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'error', reason: 'no_account' });
|
||||||
|
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||||
|
expect(usersService.linkSsoSubject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifySsoCode', () => {
|
||||||
|
it('returns the userId for a valid sso-complete token', () => {
|
||||||
|
jwtService.verify.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
purpose: 'sso-complete',
|
||||||
|
jti: 'jti-1',
|
||||||
|
exp: 1_900_000_000,
|
||||||
|
});
|
||||||
|
expect(ssoService.verifySsoCode('token')).toEqual({
|
||||||
|
userId: 'user-1',
|
||||||
|
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException when verification fails', () => {
|
||||||
|
jwtService.verify.mockImplementation(() => {
|
||||||
|
throw new Error('expired');
|
||||||
|
});
|
||||||
|
expect(() => ssoService.verifySsoCode('token')).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws UnauthorizedException when the token purpose does not match', () => {
|
||||||
|
jwtService.verify.mockReturnValue({
|
||||||
|
sub: 'user-1',
|
||||||
|
purpose: 'mfa-challenge',
|
||||||
|
});
|
||||||
|
expect(() => ssoService.verifySsoCode('token')).toThrow(
|
||||||
|
UnauthorizedException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as client from 'openid-client';
|
||||||
|
import { UsersService } from '../../users/users.service';
|
||||||
|
import {
|
||||||
|
OneTimeTokenClaims,
|
||||||
|
TOKEN_AUDIENCE,
|
||||||
|
jwtSignOptions,
|
||||||
|
jwtVerifyOptions,
|
||||||
|
} from '../util/jwt-claims';
|
||||||
|
|
||||||
|
const SSO_COMPLETE_TOKEN_TTL = '60s';
|
||||||
|
const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete';
|
||||||
|
|
||||||
|
interface SsoCompleteTokenPayload {
|
||||||
|
sub: string;
|
||||||
|
purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entra's shared endpoints accept users of ANY tenant - with them, anyone
|
||||||
|
// able to create an account in some tenant could present a matching email.
|
||||||
|
const MULTI_TENANT_SEGMENTS = new Set(['common', 'organizations', 'consumers']);
|
||||||
|
|
||||||
|
function isSingleTenantIssuer(issuerUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
const segments = new URL(issuerUrl).pathname.split('/').filter(Boolean);
|
||||||
|
return !segments.some((segment) =>
|
||||||
|
MULTI_TENANT_SEGMENTS.has(segment.toLowerCase()),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SsoCallbackResult =
|
||||||
|
| { status: 'success'; ssoCode: string }
|
||||||
|
| { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SsoService {
|
||||||
|
private readonly logger = new Logger(SsoService.name);
|
||||||
|
private config: client.Configuration | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
const issuer = process.env.OIDC_ISSUER_URL;
|
||||||
|
if (
|
||||||
|
!issuer ||
|
||||||
|
!process.env.OIDC_CLIENT_ID ||
|
||||||
|
!process.env.OIDC_CLIENT_SECRET ||
|
||||||
|
!process.env.OIDC_REDIRECT_URI
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isSingleTenantIssuer(issuer)) {
|
||||||
|
this.logger.error(
|
||||||
|
'SSO disabled: OIDC_ISSUER_URL must name one tenant (not common/organizations/consumers)',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getConfig(): Promise<client.Configuration> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Microsoft-Anmeldung ist nicht konfiguriert',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!this.config) {
|
||||||
|
this.config = await client.discovery(
|
||||||
|
new URL(process.env.OIDC_ISSUER_URL as string),
|
||||||
|
process.env.OIDC_CLIENT_ID as string,
|
||||||
|
process.env.OIDC_CLIENT_SECRET,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildAuthorizationRequest(): Promise<{
|
||||||
|
redirectUrl: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
state: string;
|
||||||
|
}> {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
const codeVerifier = client.randomPKCECodeVerifier();
|
||||||
|
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
const state = client.randomState();
|
||||||
|
const redirectUrl = client.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: process.env.OIDC_REDIRECT_URI as string,
|
||||||
|
scope: 'openid email profile',
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
return { redirectUrl: redirectUrl.href, codeVerifier, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCallback(
|
||||||
|
callbackUrl: URL,
|
||||||
|
codeVerifier: string,
|
||||||
|
state: string,
|
||||||
|
): Promise<SsoCallbackResult> {
|
||||||
|
const config = await this.getConfig();
|
||||||
|
let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
|
||||||
|
try {
|
||||||
|
tokens = await client.authorizationCodeGrant(config, callbackUrl, {
|
||||||
|
pkceCodeVerifier: codeVerifier,
|
||||||
|
expectedState: state,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`SSO callback token exchange failed: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
return { status: 'error', reason: 'exchange_failed' };
|
||||||
|
}
|
||||||
|
const claims = tokens.claims();
|
||||||
|
// The account is bound to the IdP's immutable identity (issuer + object
|
||||||
|
// id), not to the email claim: Entra does not verify email or
|
||||||
|
// preferred_username, so a mutable claim alone must never pick the
|
||||||
|
// account.
|
||||||
|
const objectId =
|
||||||
|
(claims?.oid as string | undefined) ?? claims?.sub ?? undefined;
|
||||||
|
const ssoSubject =
|
||||||
|
claims?.iss && objectId ? `${claims.iss}|${objectId}` : null;
|
||||||
|
if (!ssoSubject) {
|
||||||
|
this.logger.warn('SSO callback succeeded but ID token had no subject');
|
||||||
|
return { status: 'error', reason: 'no_account' };
|
||||||
|
}
|
||||||
|
const linked = await this.usersService.findBySsoSubject(ssoSubject);
|
||||||
|
if (linked) {
|
||||||
|
if (!linked.isActive) {
|
||||||
|
this.logger.warn(`SSO login denied: user ${linked.id} is deactivated`);
|
||||||
|
return { status: 'error', reason: 'no_account' };
|
||||||
|
}
|
||||||
|
return { status: 'success', ssoCode: this.signSsoCode(linked.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const email =
|
||||||
|
(claims?.email as string | undefined) ??
|
||||||
|
(claims?.preferred_username as string | undefined);
|
||||||
|
if (!email) {
|
||||||
|
this.logger.warn(
|
||||||
|
'SSO callback succeeded but ID token had no email claim',
|
||||||
|
);
|
||||||
|
return { status: 'error', reason: 'no_email' };
|
||||||
|
}
|
||||||
|
const user = await this.usersService.findByEmail(email);
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
this.logger.warn('SSO login denied: no active local account for email');
|
||||||
|
return { status: 'error', reason: 'no_account' };
|
||||||
|
}
|
||||||
|
if (user.ssoSubject) {
|
||||||
|
// Already bound to a different IdP identity: someone else is
|
||||||
|
// presenting this email.
|
||||||
|
this.logger.warn(
|
||||||
|
`SSO login denied: user ${user.id} is linked to another identity`,
|
||||||
|
);
|
||||||
|
return { status: 'error', reason: 'no_account' };
|
||||||
|
}
|
||||||
|
// First sign-in: bind the account to this identity for good.
|
||||||
|
await this.usersService.linkSsoSubject(user.id, ssoSubject);
|
||||||
|
return { status: 'success', ssoCode: this.signSsoCode(user.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private signSsoCode(userId: string): string {
|
||||||
|
return this.jwtService.sign(
|
||||||
|
{ sub: userId, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
|
||||||
|
jwtSignOptions(TOKEN_AUDIENCE.ssoComplete, SSO_COMPLETE_TOKEN_TTL),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
verifySsoCode(ssoCode: string): {
|
||||||
|
userId: string;
|
||||||
|
claims: OneTimeTokenClaims;
|
||||||
|
} {
|
||||||
|
let payload: SsoCompleteTokenPayload & OneTimeTokenClaims;
|
||||||
|
try {
|
||||||
|
payload = this.jwtService.verify<
|
||||||
|
SsoCompleteTokenPayload & OneTimeTokenClaims
|
||||||
|
>(ssoCode, jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete));
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
|
||||||
|
throw new UnauthorizedException('Ungültiger Token');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
claims: { jti: payload.jti, exp: payload.exp },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||||
|
import { JWT_ALGORITHM, JWT_ISSUER, TOKEN_AUDIENCE } from '../util/jwt-claims';
|
||||||
|
|
||||||
interface AccessTokenPayload {
|
interface AccessTokenPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: AuthenticatedUser['role'];
|
// Standard JWT claim, added automatically by jsonwebtoken.sign(); used to
|
||||||
|
// reject tokens issued before the user's last password change (see
|
||||||
|
// PermissionResolverService.isTokenValid).
|
||||||
|
iat: number;
|
||||||
|
// Set only on the helper tokens (MFA setup/challenge, SSO completion).
|
||||||
|
purpose?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -16,10 +22,23 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: process.env.JWT_ACCESS_SECRET as string,
|
secretOrKey: process.env.JWT_ACCESS_SECRET as string,
|
||||||
|
// Only access tokens: the MFA challenge token (issued after the
|
||||||
|
// password step, before the second factor) shares the secret and must
|
||||||
|
// never open a session on its own.
|
||||||
|
algorithms: [JWT_ALGORITHM],
|
||||||
|
issuer: JWT_ISSUER,
|
||||||
|
audience: TOKEN_AUDIENCE.access,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
validate(payload: AccessTokenPayload): AuthenticatedUser {
|
validate(payload: AccessTokenPayload): AuthenticatedUser {
|
||||||
return { id: payload.sub, email: payload.email, role: payload.role };
|
if (payload.purpose !== undefined || typeof payload.sub !== 'string') {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
email: payload.email,
|
||||||
|
tokenIssuedAt: payload.iat,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Role } from '../../../generated/prisma';
|
|
||||||
|
|
||||||
export interface AuthenticatedUser {
|
export interface AuthenticatedUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: Role;
|
/** JWT `iat` claim (seconds since epoch) - see PermissionResolverService.isTokenValid. */
|
||||||
|
tokenIssuedAt: number;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every JWT ApiCore issues is signed with JWT_ACCESS_SECRET, so the token's
|
||||||
|
* audience - not the secret - is what tells an access token apart from the
|
||||||
|
* short-lived helper tokens (MFA setup, MFA challenge, SSO completion).
|
||||||
|
* Without a pinned audience, the MFA challenge token handed out after the
|
||||||
|
* password step would pass the access-token strategy and skip the second
|
||||||
|
* factor entirely.
|
||||||
|
*
|
||||||
|
* Algorithm and issuer are pinned as well, so a token is only ever accepted
|
||||||
|
* in the exact shape ApiCore itself produces.
|
||||||
|
*/
|
||||||
|
export const JWT_ISSUER = 'apicore';
|
||||||
|
export const JWT_ALGORITHM = 'HS256';
|
||||||
|
|
||||||
|
export const TOKEN_AUDIENCE = {
|
||||||
|
access: 'apicore:access',
|
||||||
|
mfaSetup: 'apicore:mfa-setup',
|
||||||
|
mfaChallenge: 'apicore:mfa-challenge',
|
||||||
|
ssoComplete: 'apicore:sso-complete',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TokenAudience =
|
||||||
|
(typeof TOKEN_AUDIENCE)[keyof typeof TOKEN_AUDIENCE];
|
||||||
|
|
||||||
|
export function jwtSignOptions(
|
||||||
|
audience: TokenAudience,
|
||||||
|
expiresIn: JwtSignOptions['expiresIn'],
|
||||||
|
): JwtSignOptions {
|
||||||
|
return {
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET,
|
||||||
|
algorithm: JWT_ALGORITHM,
|
||||||
|
issuer: JWT_ISSUER,
|
||||||
|
audience,
|
||||||
|
expiresIn,
|
||||||
|
// Unique id, so a one-time token can be marked as used.
|
||||||
|
jwtid: randomUUID(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a verified one-time token carries for OneTimeTokenService. */
|
||||||
|
export interface OneTimeTokenClaims {
|
||||||
|
jti?: string;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jwtVerifyOptions(audience: TokenAudience): JwtVerifyOptions {
|
||||||
|
return {
|
||||||
|
secret: process.env.JWT_ACCESS_SECRET,
|
||||||
|
algorithms: [JWT_ALGORITHM],
|
||||||
|
issuer: JWT_ISSUER,
|
||||||
|
audience,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { parseDurationToMs } from './duration';
|
||||||
|
|
||||||
|
export const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
||||||
|
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
||||||
|
|
||||||
|
function cookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
// Explicit opt-out for local HTTP dev only; every real deployment must
|
||||||
|
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
||||||
|
// over plaintext HTTP.
|
||||||
|
secure: process.env.COOKIE_SECURE !== 'false',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: REFRESH_TOKEN_COOKIE_PATH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRefreshTokenCookie(
|
||||||
|
res: Response,
|
||||||
|
refreshToken: string,
|
||||||
|
): void {
|
||||||
|
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
||||||
|
...cookieOptions(),
|
||||||
|
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same attributes as when set - some browsers keep a cookie that is
|
||||||
|
// cleared with different ones.
|
||||||
|
export function clearRefreshTokenCookie(res: Response): void {
|
||||||
|
res.clearCookie(REFRESH_TOKEN_COOKIE, cookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRefreshToken(req: Request): string {
|
||||||
|
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
|
||||||
|
REFRESH_TOKEN_COOKIE
|
||||||
|
];
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new UnauthorizedException('Missing refresh token');
|
||||||
|
}
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
Loaded 100 of 831 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user