Compare commits
51
Commits
No files matched your search
+38
-5
@@ -59,11 +59,11 @@ Datenzugriff ausschließlich über TanStack Query gegen die Backend-API (kein di
|
||||
|
||||
## 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).
|
||||
2. Frontend hält Access-Token im Memory/React-Context, Refresh-Token als httpOnly-Cookie.
|
||||
3. Bei 401 automatischer Refresh über `POST /api/v1/auth/refresh`, danach Retry.
|
||||
4. Jeder geschützte Endpunkt: `JwtAuthGuard` (Authentifizierung) + `RolesGuard` (Autorisierung nach Rolle aus Abschnitt 3).
|
||||
5. API-Client-Rolle nutzt dieselben JWT-Endpunkte mit client-credentials-artigem Service-Account (kein OAuth2-Server im MVP, siehe Annahmen).
|
||||
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 (`Secure`, `SameSite=Lax`, Pfad `/api/v1/auth`).
|
||||
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. Guards in dieser Reihenfolge: `ThrottlerGuard` → `JwtAuthGuard` (Authentifizierung, Gültigkeit frisch aus der DB je Anfrage) → `PermissionsGuard` (Berechtigung, fail-closed).
|
||||
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
|
||||
|
||||
@@ -100,6 +100,17 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
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. 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 – 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.
|
||||
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.
|
||||
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`, derzeit nur `shop-sync:read`). 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 Benutzer, Rollen, Webhooks und Audit-Log wieder her; 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.
|
||||
|
||||
## 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'`).
|
||||
@@ -119,3 +130,25 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
- **Restore:** Über `POST /backup/import` (bzw. den Import-Dialog unter Verwaltung > Backup) eine zuvor exportierte Bundle-Datei einspielen.
|
||||
|
||||
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, Scope-Allowlist (`shop-sync:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben |
|
||||
| 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.
|
||||
@@ -9,9 +9,19 @@ JWT_ACCESS_SECRET="change-me-to-a-random-value-at-least-32-characters-long"
|
||||
JWT_REFRESH_SECRET="change-me-refresh-secret"
|
||||
JWT_ACCESS_EXPIRES_IN="15m"
|
||||
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
|
||||
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.
|
||||
TRUST_PROXY=1
|
||||
|
||||
# Refresh-token cookie requires HTTPS by default. Set to "false" only for
|
||||
# local HTTP development - every real deployment must leave this unset (or
|
||||
@@ -33,11 +43,19 @@ DOCUMENTS_STORAGE_PATH="./uploads"
|
||||
# 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).
|
||||
|
||||
+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");
|
||||
@@ -80,6 +80,14 @@ model User {
|
||||
// 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,
|
||||
@@ -88,11 +96,17 @@ model User {
|
||||
// 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[]
|
||||
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
winterChecks WinterCheck[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests Harvest[]
|
||||
@@ -111,10 +125,47 @@ model User {
|
||||
webhookSubscriptions WebhookSubscription[]
|
||||
documents Document[]
|
||||
honeyBatchRelocations HoneyBatchRelocation[]
|
||||
apiKeys ApiKey[]
|
||||
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
@@ -123,6 +174,9 @@ model RefreshToken {
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
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])
|
||||
}
|
||||
@@ -138,6 +192,7 @@ enum AuditAction {
|
||||
TOKEN_REFRESHED
|
||||
MFA_ENABLED
|
||||
MFA_DISABLED
|
||||
ACCOUNT_LOCKED
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
@@ -181,6 +236,7 @@ model Apiary {
|
||||
colonies Colony[]
|
||||
colonyMoves ColonyApiaryHistory[]
|
||||
harvests Harvest[]
|
||||
treatments Treatment[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
@@ -245,6 +301,7 @@ model Colony {
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
winterChecks WinterCheck[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests HarvestColony[]
|
||||
@@ -460,11 +517,17 @@ model Task {
|
||||
sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id])
|
||||
sourceTemplateId String?
|
||||
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())
|
||||
completedAt DateTime?
|
||||
|
||||
@@index([status, dueDate])
|
||||
@@index([relatedType, relatedId])
|
||||
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
|
||||
}
|
||||
|
||||
enum TaskTemplateRecurrenceType {
|
||||
@@ -564,6 +627,54 @@ model SwarmControlCheck {
|
||||
@@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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -585,6 +696,14 @@ model Treatment {
|
||||
batchNumber 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())
|
||||
|
||||
@@index([colonyId, date])
|
||||
@@ -899,6 +1018,10 @@ model LabelTemplate {
|
||||
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())
|
||||
@@ -1040,6 +1163,8 @@ enum AutomationTriggerType {
|
||||
STOCK_ITEM_LOW
|
||||
HONEY_BATCH_NUMBER_FORMAT
|
||||
BOTTLING_LOT_NUMBER_FORMAT
|
||||
TREATMENT_FOLLOW_UP
|
||||
FEEDING_FOLLOW_UP
|
||||
}
|
||||
|
||||
model AutomationRule {
|
||||
@@ -1071,6 +1196,10 @@ model SiteSettings {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1136,6 +1265,37 @@ model CalendarSyncSettings {
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||
|
||||
/**
|
||||
* Permissions an API key may carry. Deliberately a short allowlist of
|
||||
* read-only permissions, 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. Removing an entry here also takes it away from existing keys (see
|
||||
* ApiKeysService.authenticate).
|
||||
*/
|
||||
export const API_KEY_SCOPES = [
|
||||
'shop-sync: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,232 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
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';
|
||||
|
||||
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;
|
||||
|
||||
/** 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);
|
||||
|
||||
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.
|
||||
*/
|
||||
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();
|
||||
if (
|
||||
!apiKey ||
|
||||
apiKey.revokedAt ||
|
||||
(apiKey.expiresAt && apiKey.expiresAt <= now)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,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');
|
||||
}
|
||||
@@ -4,18 +4,58 @@ import helmet from 'helmet';
|
||||
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';
|
||||
|
||||
/**
|
||||
* How many reverse-proxy hops to trust for X-Forwarded-For. It decides the
|
||||
* client IP behind every rate limit and API-key IP allowlist: too few and
|
||||
* all users share the proxy's IP, too many and a client can forge its own.
|
||||
* A hop count ("1", the single K8s ingress) or Express' list syntax
|
||||
* ("loopback, 10.0.0.0/8").
|
||||
*/
|
||||
export function trustProxySetting(): number | string {
|
||||
const raw = process.env.TRUST_PROXY?.trim();
|
||||
if (!raw) {
|
||||
return 1;
|
||||
}
|
||||
return /^\d+$/.test(raw) ? Number(raw) : raw;
|
||||
}
|
||||
|
||||
export function configureApp(app: NestExpressApplication): void {
|
||||
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the
|
||||
// real client IP from X-Forwarded-For — required for per-client throttling
|
||||
// to work correctly rather than rate-limiting all traffic together.
|
||||
app.set('trust proxy', 1);
|
||||
app.set('trust proxy', trustProxySetting());
|
||||
// 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());
|
||||
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(requestLoggerMiddleware);
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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 { ScheduleModule } from '@nestjs/schedule';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -40,13 +40,18 @@ import { ImportModule } from './import/import.module';
|
||||
import { PublicModule } from './public/public.module';
|
||||
import { TaskTemplatesModule } from './task-templates/task-templates.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 { ApiKeysModule } from './api-keys/api-keys.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.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({
|
||||
imports: [
|
||||
@@ -91,16 +96,23 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
|
||||
PublicModule,
|
||||
TaskTemplatesModule,
|
||||
SwarmControlModule,
|
||||
WinterChecksModule,
|
||||
WeatherModule,
|
||||
ProfileModule,
|
||||
SiteSettingsModule,
|
||||
BackupModule,
|
||||
ShopSyncModule,
|
||||
ApiKeysModule,
|
||||
],
|
||||
controllers: [],
|
||||
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: ThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
|
||||
IdempotencyCleanupService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -82,6 +82,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async logout(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
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';
|
||||
@@ -18,6 +19,6 @@ import { SsoController } from './sso/sso.controller';
|
||||
SsoModule,
|
||||
],
|
||||
controllers: [AuthController, SsoController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
providers: [AuthService, JwtStrategy, OneTimeTokenService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -5,6 +5,8 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from '../users/users.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', () => {
|
||||
let authService: AuthService;
|
||||
@@ -26,7 +28,13 @@ describe('AuthService', () => {
|
||||
let mfaService: {
|
||||
signChallengeToken: jest.Mock;
|
||||
verifyChallengeToken: jest.Mock;
|
||||
verifyCode: jest.Mock;
|
||||
};
|
||||
let oneTimeTokens: { consume: jest.Mock };
|
||||
let accountGuard: {
|
||||
isLocked: jest.Mock;
|
||||
recordFailure: jest.Mock;
|
||||
recordSuccess: jest.Mock;
|
||||
consumeTotp: jest.Mock;
|
||||
};
|
||||
|
||||
const activeUser = {
|
||||
@@ -60,7 +68,13 @@ describe('AuthService', () => {
|
||||
mfaService = {
|
||||
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
|
||||
verifyChallengeToken: jest.fn(),
|
||||
verifyCode: 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(
|
||||
@@ -70,6 +84,8 @@ describe('AuthService', () => {
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissionResolver as never,
|
||||
mfaService as unknown as MfaService,
|
||||
accountGuard as unknown as AccountGuardService,
|
||||
oneTimeTokens as unknown as OneTimeTokenService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -157,21 +173,40 @@ 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);
|
||||
await expect(
|
||||
authService.login('[email protected]', 'x'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'LOGIN_FAILED',
|
||||
entityId: '[email protected]',
|
||||
}),
|
||||
);
|
||||
const [[recordArg]] = auditLog.record.mock.calls as [
|
||||
[{ userId?: string }],
|
||||
];
|
||||
expect(recordArg).not.toHaveProperty('userId');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,9 +218,12 @@ describe('AuthService', () => {
|
||||
};
|
||||
|
||||
it('issues tokens and records LOGIN when the code is valid', async () => {
|
||||
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
|
||||
mfaService.verifyChallengeToken.mockReturnValue({
|
||||
userId: mfaUser.id,
|
||||
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||
});
|
||||
usersService.findById.mockResolvedValue(mfaUser);
|
||||
mfaService.verifyCode.mockResolvedValue(true);
|
||||
accountGuard.consumeTotp.mockResolvedValue(true);
|
||||
prisma.refreshToken.create.mockResolvedValue({});
|
||||
|
||||
const result = await authService.verifyMfaAndLogin(
|
||||
@@ -194,20 +232,20 @@ describe('AuthService', () => {
|
||||
);
|
||||
|
||||
expect(result.accessToken).toBe('signed.jwt.token');
|
||||
expect(mfaService.verifyCode).toHaveBeenCalledWith(
|
||||
mfaUser.mfaSecret,
|
||||
mfaUser.email,
|
||||
'123456',
|
||||
);
|
||||
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 });
|
||||
mfaService.verifyChallengeToken.mockReturnValue({
|
||||
userId: mfaUser.id,
|
||||
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||
});
|
||||
usersService.findById.mockResolvedValue(mfaUser);
|
||||
mfaService.verifyCode.mockResolvedValue(false);
|
||||
accountGuard.consumeTotp.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
authService.verifyMfaAndLogin('mfa-challenge-token', '000000'),
|
||||
@@ -295,7 +333,8 @@ describe('AuthService', () => {
|
||||
prisma.refreshToken.findUnique.mockResolvedValue({
|
||||
id: 'rt-1',
|
||||
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),
|
||||
});
|
||||
prisma.refreshToken.updateMany.mockResolvedValue({ count: 2 });
|
||||
@@ -325,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 () => {
|
||||
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.update.mockResolvedValue({});
|
||||
prisma.refreshToken.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.refreshToken.create.mockResolvedValue({});
|
||||
|
||||
const result = await authService.refresh('valid-token');
|
||||
|
||||
expect(result.accessToken).toBe('signed.jwt.token');
|
||||
expect(prisma.refreshToken.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'rt-1' } }),
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'rt-1', revokedAt: null } }),
|
||||
);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -7,7 +7,10 @@ import { UsersService } from '../users/users.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
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 { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
|
||||
|
||||
interface AuthTokens {
|
||||
accessToken: string;
|
||||
@@ -31,6 +34,25 @@ export interface CurrentUserProfile {
|
||||
|
||||
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()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
@@ -42,6 +64,8 @@ export class AuthService {
|
||||
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> {
|
||||
@@ -72,56 +96,64 @@ export class AuthService {
|
||||
);
|
||||
return this.jwtService.sign(
|
||||
{ sub: user.id, email: user.email },
|
||||
{
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
expiresIn: expiresInSeconds,
|
||||
},
|
||||
jwtSignOptions(TOKEN_AUDIENCE.access, 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 expiresAt = new Date(
|
||||
const slidingExpiry =
|
||||
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({
|
||||
data: { userId, tokenHash: this.hashRefreshToken(token), expiresAt },
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: this.hashRefreshToken(token),
|
||||
expiresAt: new Date(Math.min(slidingExpiry, absoluteExpiry)),
|
||||
sessionStartedAt,
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
// bcrypt runs on every path - unknown, deactivated, locked or real - so
|
||||
// the response time does not reveal which accounts exist.
|
||||
const passwordMatches = await bcrypt.compare(
|
||||
password,
|
||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||
);
|
||||
if (!user) {
|
||||
this.logger.warn(`Login failed: no user with email ${email}`);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: email,
|
||||
action: 'LOGIN_FAILED',
|
||||
});
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
this.logger.warn('Login failed: unknown email');
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||
}
|
||||
if (!user.isActive) {
|
||||
this.logger.warn(`Login failed: user ${user.id} is deactivated`);
|
||||
if (!user.isActive || this.accountGuard.isLocked(user)) {
|
||||
this.logger.warn(`Login refused: user ${user.id} inactive or locked`);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
action: 'LOGIN_FAILED',
|
||||
userId: user.id,
|
||||
});
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||
}
|
||||
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
|
||||
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');
|
||||
throw new UnauthorizedException(INVALID_CREDENTIALS);
|
||||
}
|
||||
if (user.mfaEnabled) {
|
||||
return {
|
||||
@@ -135,6 +167,7 @@ export class AuthService {
|
||||
};
|
||||
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,
|
||||
@@ -177,20 +210,20 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
||||
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
|
||||
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');
|
||||
}
|
||||
const isValid = await this.mfaService.verifyCode(
|
||||
user.mfaSecret,
|
||||
user.email,
|
||||
code,
|
||||
);
|
||||
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 code for user ${user.id}`,
|
||||
`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,
|
||||
@@ -199,6 +232,9 @@ export class AuthService {
|
||||
});
|
||||
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,
|
||||
email: user.email,
|
||||
@@ -222,6 +258,14 @@ export class AuthService {
|
||||
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
|
||||
@@ -244,16 +288,25 @@ export class AuthService {
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
await this.prisma.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
// Atomic: of two requests presenting the same token, exactly one wins
|
||||
// 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() },
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
const authenticatedUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
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({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
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 {
|
||||
@ApiProperty()
|
||||
@NormalizeEmail()
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
// Long enough for any real password; bounds the bcrypt work an
|
||||
// anonymous request can cause.
|
||||
@MaxLength(256)
|
||||
password!: string;
|
||||
}
|
||||
@@ -2,16 +2,19 @@ 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 };
|
||||
const request = { user, headers: { authorization } };
|
||||
return {
|
||||
getHandler: () => ({}),
|
||||
getClass: () => ({}),
|
||||
@@ -22,9 +25,11 @@ describe('JwtAuthGuard', () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,4 +77,27 @@ describe('JwtAuthGuard', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,17 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,6 +36,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly apiKeys: ApiKeysService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -40,11 +49,29 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
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 request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const user = request.user;
|
||||
if (
|
||||
!user ||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorat
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,15 @@ export class PermissionsGuard implements CanActivate {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +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],
|
||||
exports: [MfaService],
|
||||
providers: [MfaService, AccountGuardService],
|
||||
exports: [MfaService, AccountGuardService],
|
||||
})
|
||||
export class MfaModule {}
|
||||
@@ -139,10 +139,13 @@ describe('MfaService', () => {
|
||||
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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,18 @@ 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 } from './totp.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';
|
||||
@@ -41,7 +52,7 @@ export class MfaService {
|
||||
});
|
||||
const setupToken = this.jwtService.sign(
|
||||
{ sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE },
|
||||
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: SETUP_TOKEN_TTL },
|
||||
jwtSignOptions(TOKEN_AUDIENCE.mfaSetup, SETUP_TOKEN_TTL),
|
||||
);
|
||||
return { secret, qrCodeSvg, otpauthUri, setupToken };
|
||||
}
|
||||
@@ -55,9 +66,10 @@ export class MfaService {
|
||||
): Promise<{ encryptedSecret: string }> {
|
||||
let payload: SetupTokenPayload;
|
||||
try {
|
||||
payload = this.jwtService.verify<SetupTokenPayload>(setupToken, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
payload = this.jwtService.verify<SetupTokenPayload>(
|
||||
setupToken,
|
||||
jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup),
|
||||
);
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten',
|
||||
@@ -76,16 +88,19 @@ export class MfaService {
|
||||
signChallengeToken(userId: string): string {
|
||||
return this.jwtService.sign(
|
||||
{ sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE },
|
||||
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: CHALLENGE_TOKEN_TTL },
|
||||
jwtSignOptions(TOKEN_AUDIENCE.mfaChallenge, CHALLENGE_TOKEN_TTL),
|
||||
);
|
||||
}
|
||||
|
||||
verifyChallengeToken(challengeToken: string): { userId: string } {
|
||||
let payload: ChallengeTokenPayload;
|
||||
verifyChallengeToken(challengeToken: string): {
|
||||
userId: string;
|
||||
claims: OneTimeTokenClaims;
|
||||
} {
|
||||
let payload: ChallengeTokenPayload & OneTimeTokenClaims;
|
||||
try {
|
||||
payload = this.jwtService.verify<ChallengeTokenPayload>(challengeToken, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
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',
|
||||
@@ -94,7 +109,25 @@ export class MfaService {
|
||||
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
|
||||
throw new UnauthorizedException('Ungültiger Token');
|
||||
}
|
||||
return { userId: payload.sub };
|
||||
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(
|
||||
|
||||
@@ -20,6 +20,23 @@ 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { PERMISSION_CATALOG } from './permission-catalog';
|
||||
describe('PermissionCatalogSyncService', () => {
|
||||
let service: PermissionCatalogSyncService;
|
||||
let prisma: {
|
||||
permission: { findMany: jest.Mock; create: jest.Mock };
|
||||
permission: { findMany: jest.Mock; createMany: jest.Mock };
|
||||
role: { findMany: jest.Mock };
|
||||
rolePermission: { createMany: jest.Mock };
|
||||
};
|
||||
@@ -13,10 +13,10 @@ describe('PermissionCatalogSyncService', () => {
|
||||
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(),
|
||||
create: jest.fn((args: { data: { key: string } }) =>
|
||||
Promise.resolve({ id: `perm-${args.data.key}`, ...args.data }),
|
||||
),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
role: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
rolePermission: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||
@@ -33,16 +33,16 @@ describe('PermissionCatalogSyncService', () => {
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(prisma.permission.create).not.toHaveBeenCalled();
|
||||
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.mockResolvedValue(
|
||||
rest.map((entry) => ({ key: entry.key })),
|
||||
);
|
||||
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' },
|
||||
@@ -50,7 +50,10 @@ describe('PermissionCatalogSyncService', () => {
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(prisma.permission.create).toHaveBeenCalledWith({ data: first });
|
||||
expect(prisma.permission.createMany).toHaveBeenCalledWith({
|
||||
data: [first],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(prisma.role.findMany).toHaveBeenCalledWith({
|
||||
where: { isSystem: true },
|
||||
select: { id: true },
|
||||
@@ -70,9 +73,26 @@ describe('PermissionCatalogSyncService', () => {
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(prisma.permission.create).toHaveBeenCalledTimes(
|
||||
PERMISSION_CATALOG.length,
|
||||
);
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,10 +33,18 @@ export class PermissionCatalogSyncService implements OnModuleInit {
|
||||
const missing = PERMISSION_CATALOG.filter(
|
||||
(entry) => !existingKeys.has(entry.key),
|
||||
);
|
||||
const createdPermissions = await Promise.all(
|
||||
missing.map((entry) => this.prisma.permission.create({ data: entry })),
|
||||
);
|
||||
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)
|
||||
|
||||
@@ -111,6 +111,18 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
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',
|
||||
@@ -306,6 +318,21 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
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']],
|
||||
3300,
|
||||
),
|
||||
...group(
|
||||
'API-Schlüssel',
|
||||
'api-keys',
|
||||
[['manage', 'Anlegen, ansehen und widerrufen']],
|
||||
3400,
|
||||
),
|
||||
] as const satisfies readonly PermissionCatalogEntry[];
|
||||
|
||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||
@@ -165,15 +165,34 @@ describe('PermissionResolverService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('shares the cached entry with getPermissions instead of re-querying', async () => {
|
||||
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');
|
||||
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledTimes(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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,11 +37,18 @@ export class PermissionResolverService {
|
||||
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.getEntry(userId);
|
||||
const entry = await this.loadEntry(userId);
|
||||
this.cache.set(userId, entry);
|
||||
if (!entry.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -40,6 +41,7 @@ export class SsoController {
|
||||
constructor(
|
||||
private readonly ssoService: SsoService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly oneTimeTokens: OneTimeTokenService,
|
||||
) {}
|
||||
|
||||
private frontendLoginUrl(): string {
|
||||
@@ -82,7 +84,7 @@ export class SsoController {
|
||||
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
|
||||
PKCE_COOKIE
|
||||
];
|
||||
res.clearCookie(PKCE_COOKIE, { path: PKCE_COOKIE_PATH });
|
||||
res.clearCookie(PKCE_COOKIE, this.cookieOptions(PKCE_COOKIE_PATH, 0));
|
||||
|
||||
const pkce = this.parsePkceCookie(rawCookie);
|
||||
if (!pkce) {
|
||||
@@ -90,10 +92,10 @@ export class SsoController {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackUrl = new URL(
|
||||
req.originalUrl,
|
||||
`${req.protocol}://${req.get('host')}`,
|
||||
);
|
||||
// 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,
|
||||
@@ -126,13 +128,17 @@ export class SsoController {
|
||||
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
|
||||
SSO_CODE_COOKIE
|
||||
];
|
||||
res.clearCookie(SSO_CODE_COOKIE, { path: SSO_CODE_COOKIE_PATH });
|
||||
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 } = this.ssoService.verifySsoCode(ssoCode);
|
||||
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 };
|
||||
|
||||
@@ -18,9 +18,26 @@ jest.mock('openid-client', () => ({
|
||||
|
||||
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 };
|
||||
let usersService: {
|
||||
findByEmail: jest.Mock;
|
||||
findBySsoSubject: jest.Mock;
|
||||
linkSsoSubject: jest.Mock;
|
||||
};
|
||||
let jwtService: { sign: jest.Mock; verify: jest.Mock };
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
@@ -33,7 +50,11 @@ describe('SsoService', () => {
|
||||
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() };
|
||||
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,
|
||||
@@ -50,6 +71,12 @@ describe('SsoService', () => {
|
||||
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);
|
||||
@@ -116,7 +143,7 @@ describe('SsoService', () => {
|
||||
|
||||
it('returns no_email when the ID token has neither email nor preferred_username', async () => {
|
||||
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||
claims: () => ({ sub: 'abc' }),
|
||||
claims: () => ({ ...IDENTITY }),
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof client.authorizationCodeGrant>
|
||||
>);
|
||||
@@ -132,7 +159,7 @@ describe('SsoService', () => {
|
||||
|
||||
it('returns no_account when no active local user matches the email claim', async () => {
|
||||
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||
claims: () => ({ email: '[email protected]' }),
|
||||
claims: () => ({ ...IDENTITY, email: '[email protected]' }),
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof client.authorizationCodeGrant>
|
||||
>);
|
||||
@@ -149,7 +176,10 @@ describe('SsoService', () => {
|
||||
|
||||
it('signs a short-lived sso-complete token for an active matching user', async () => {
|
||||
mockedClient.authorizationCodeGrant.mockResolvedValue({
|
||||
claims: () => ({ preferred_username: '[email protected]' }),
|
||||
claims: () => ({
|
||||
...IDENTITY,
|
||||
preferred_username: '[email protected]',
|
||||
}),
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof client.authorizationCodeGrant>
|
||||
>);
|
||||
@@ -175,6 +205,56 @@ describe('SsoService', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,8 +263,13 @@ describe('SsoService', () => {
|
||||
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 },
|
||||
});
|
||||
expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' });
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when verification fails', () => {
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
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';
|
||||
@@ -16,6 +22,21 @@ interface SsoCompleteTokenPayload {
|
||||
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' };
|
||||
@@ -31,12 +52,22 @@ export class SsoService {
|
||||
) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(
|
||||
process.env.OIDC_ISSUER_URL &&
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_REDIRECT_URI,
|
||||
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> {
|
||||
@@ -93,6 +124,27 @@ export class SsoService {
|
||||
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);
|
||||
@@ -104,27 +156,38 @@ export class SsoService {
|
||||
}
|
||||
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: no active local account for ${email}`,
|
||||
`SSO login denied: user ${user.id} is linked to another identity`,
|
||||
);
|
||||
return { status: 'error', reason: 'no_account' };
|
||||
}
|
||||
const ssoCode = this.jwtService.sign(
|
||||
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
|
||||
{
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
expiresIn: SSO_COMPLETE_TOKEN_TTL,
|
||||
},
|
||||
);
|
||||
return { status: 'success', ssoCode };
|
||||
// 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) };
|
||||
}
|
||||
|
||||
verifySsoCode(ssoCode: string): { userId: string } {
|
||||
let payload: SsoCompleteTokenPayload;
|
||||
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>(ssoCode, {
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
});
|
||||
payload = this.jwtService.verify<
|
||||
SsoCompleteTokenPayload & OneTimeTokenClaims
|
||||
>(ssoCode, jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete));
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||
@@ -133,6 +196,9 @@ export class SsoService {
|
||||
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
|
||||
throw new UnauthorizedException('Ungültiger Token');
|
||||
}
|
||||
return { userId: payload.sub };
|
||||
return {
|
||||
userId: payload.sub,
|
||||
claims: { jti: payload.jti, exp: payload.exp },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { AuthenticatedUser } from '../types/authenticated-user';
|
||||
import { JWT_ALGORITHM, JWT_ISSUER, TOKEN_AUDIENCE } from '../util/jwt-claims';
|
||||
|
||||
interface AccessTokenPayload {
|
||||
sub: string;
|
||||
@@ -10,6 +11,8 @@ interface AccessTokenPayload {
|
||||
// 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()
|
||||
@@ -19,10 +22,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
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 {
|
||||
if (payload.purpose !== undefined || typeof payload.sub !== 'string') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -5,24 +5,32 @@ import { parseDurationToMs } from './duration';
|
||||
export const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
||||
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
||||
|
||||
export function setRefreshTokenCookie(
|
||||
res: Response,
|
||||
refreshToken: string,
|
||||
): void {
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
||||
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',
|
||||
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, { path: REFRESH_TOKEN_COOKIE_PATH });
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, cookieOptions());
|
||||
}
|
||||
|
||||
export function extractRefreshToken(req: Request): string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationTriggerType } from '../../generated/prisma';
|
||||
|
||||
describe('AutomationService', () => {
|
||||
let service: AutomationService;
|
||||
@@ -14,7 +15,7 @@ describe('AutomationService', () => {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
task: { create: jest.Mock };
|
||||
task: { create: jest.Mock; findFirst: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let notifications: { record: jest.Mock };
|
||||
@@ -50,7 +51,10 @@ describe('AutomationService', () => {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
task: { create: jest.fn().mockResolvedValue({ id: 'task-1' }) },
|
||||
task: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'task-1' }),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
notifications = { record: jest.fn() };
|
||||
@@ -79,7 +83,9 @@ describe('AutomationService', () => {
|
||||
|
||||
const result = await service.findAll();
|
||||
|
||||
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(8);
|
||||
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(
|
||||
Object.values(AutomationTriggerType).length,
|
||||
);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -154,6 +160,7 @@ describe('AutomationService', () => {
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-1',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 12,
|
||||
miteCount: null,
|
||||
} as never);
|
||||
@@ -167,6 +174,7 @@ describe('AutomationService', () => {
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-2',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 5,
|
||||
miteCount: null,
|
||||
} as never);
|
||||
@@ -174,17 +182,59 @@ describe('AutomationService', () => {
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to miteCount when calculatedValue is null', async () => {
|
||||
it('ignores a raw board count without a per-day rate', async () => {
|
||||
// 15 mites over an unknown number of days says nothing about the
|
||||
// per-day threshold - comparing them would be a unit error.
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-3',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: null,
|
||||
miteCount: 15,
|
||||
} as never);
|
||||
|
||||
expect(prisma.task.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores sample-based methods, whose values use another unit', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-4',
|
||||
colonyId: 'colony-1',
|
||||
method: 'WASHING_METHOD',
|
||||
calculatedValue: 30,
|
||||
miteCount: 30,
|
||||
} as never);
|
||||
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds no second task while one for the same colony is still open', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.task.findFirst.mockResolvedValue({ id: 'open-task' });
|
||||
|
||||
await service.evaluateVarroaMeasurement({
|
||||
id: 'measurement-5',
|
||||
colonyId: 'colony-1',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
calculatedValue: 25,
|
||||
miteCount: 25,
|
||||
} as never);
|
||||
|
||||
expect(prisma.task.create).not.toHaveBeenCalled();
|
||||
expect(notifications.record).not.toHaveBeenCalled();
|
||||
expect(prisma.task.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
sourceAutomationTrigger: 'VARROA_THRESHOLD',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: 'colony-1',
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
AutomationTriggerType,
|
||||
Colony,
|
||||
Inspection,
|
||||
Feeding,
|
||||
Prisma,
|
||||
StockItem,
|
||||
SwarmControlCheck,
|
||||
TaskRelatedType,
|
||||
Treatment,
|
||||
VarroaMeasurement,
|
||||
} from '../../generated/prisma';
|
||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||
@@ -20,6 +22,8 @@ import { formatGeneratedNumber } from './number-format';
|
||||
interface RuleDefaults {
|
||||
config: Record<string, unknown>;
|
||||
actionTemplate: string;
|
||||
/** Rules without a sensible built-in setting start switched off. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
@@ -60,8 +64,55 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
||||
actionTemplate: '',
|
||||
},
|
||||
// Follow-up checks: how many days after a treatment or feeding a check
|
||||
// makes sense depends on the product and the season, so there is no
|
||||
// built-in interval - the beekeeper switches the rule on and sets
|
||||
// config.dueInDays.
|
||||
TREATMENT_FOLLOW_UP: {
|
||||
config: {},
|
||||
actionTemplate: 'Behandlungserfolg kontrollieren',
|
||||
active: false,
|
||||
},
|
||||
FEEDING_FOLLOW_UP: {
|
||||
config: {},
|
||||
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
|
||||
active: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional per-rule follow-up interval (config.dueInDays). There is no
|
||||
* built-in default: how soon a queenless colony or a high mite fall needs a
|
||||
* look depends on season and practice, so the rule stays date-less unless the
|
||||
* beekeeper configures one.
|
||||
*/
|
||||
function formatDay(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
function dueDateFromConfig(
|
||||
dueInDays: unknown,
|
||||
baseDate: Date = new Date(),
|
||||
): Date | undefined {
|
||||
if (
|
||||
typeof dueInDays !== 'number' ||
|
||||
!Number.isInteger(dueInDays) ||
|
||||
dueInDays < 0 ||
|
||||
dueInDays > 365
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const due = new Date(baseDate);
|
||||
due.setUTCHours(0, 0, 0, 0);
|
||||
due.setUTCDate(due.getUTCDate() + dueInDays);
|
||||
return due;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AutomationService {
|
||||
constructor(
|
||||
@@ -81,6 +132,7 @@ export class AutomationService {
|
||||
update: {},
|
||||
create: {
|
||||
triggerType,
|
||||
active: defaults.active ?? true,
|
||||
config: defaults.config as Prisma.InputJsonValue,
|
||||
actionTemplate: defaults.actionTemplate,
|
||||
},
|
||||
@@ -174,9 +226,16 @@ export class AutomationService {
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
// The threshold is a natural mite fall rate (mites per day). Only that
|
||||
// method yields a value in this unit - a mite count from a sugar roll or
|
||||
// wash is per bee sample and would be compared against the wrong scale,
|
||||
// as would a raw board count without the number of days it covered.
|
||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as { thresholdMitesPerDay?: number } | null;
|
||||
const threshold = config?.thresholdMitesPerDay ?? 10;
|
||||
const value = measurement.calculatedValue ?? measurement.miteCount;
|
||||
const value = measurement.calculatedValue;
|
||||
if (value !== null && value >= threshold) {
|
||||
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
||||
}
|
||||
@@ -227,6 +286,44 @@ export class AutomationService {
|
||||
await this.triggerAction(rule, 'STORAGE', item.id);
|
||||
}
|
||||
|
||||
async evaluateTreatment(treatment: Treatment): Promise<void> {
|
||||
await this.evaluateFollowUp(
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
treatment.colonyId,
|
||||
treatment.endDate ?? treatment.date,
|
||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async evaluateFeeding(feeding: Feeding): Promise<void> {
|
||||
await this.evaluateFollowUp(
|
||||
'FEEDING_FOLLOW_UP',
|
||||
feeding.colonyId,
|
||||
feeding.date,
|
||||
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A check counted from the measure's own date, not from today. */
|
||||
private async evaluateFollowUp(
|
||||
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
|
||||
colonyId: string,
|
||||
measureDate: Date,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
await this.ensureDefaults();
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerAction(rule, 'COLONY', colonyId, undefined, {
|
||||
baseDate: measureDate,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
// Returns null (never throws) when the rule is inactive or has no format
|
||||
// configured, so the caller can fall back to requiring manual entry.
|
||||
async generateNumber(
|
||||
@@ -274,13 +371,36 @@ export class AutomationService {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves a suggestion task (never a record of work done) plus a warning.
|
||||
*
|
||||
* At most one open task per rule and object: while the reorder task for a
|
||||
* stock item or the check task for a colony is still open, further triggers
|
||||
* (the next removal below minimum, a second high varroa count) add nothing.
|
||||
* Repeating the same alarm would only bury the one task that matters.
|
||||
*/
|
||||
private async triggerAction(
|
||||
rule: AutomationRule,
|
||||
relatedType: TaskRelatedType,
|
||||
relatedId: string,
|
||||
sourceInspectionId?: string,
|
||||
options: { baseDate?: Date; description?: string } = {},
|
||||
): Promise<void> {
|
||||
const alreadyOpen = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
relatedType,
|
||||
relatedId,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||
const config = rule.config as { dueInDays?: unknown } | null;
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
title,
|
||||
@@ -288,6 +408,9 @@ export class AutomationService {
|
||||
relatedType,
|
||||
relatedId,
|
||||
sourceInspectionId,
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
description: options.description,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
||||
},
|
||||
});
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
|
||||
|
||||
describe('backup-crypto', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const bundle = {
|
||||
version: 2,
|
||||
exportedAt: '2026-10-01T09:00:00.000Z',
|
||||
data: { user: [{ id: 'u1', passwordHash: '$2a$12$secret' }] },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
||||
delete process.env.BACKUP_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('round-trips a bundle and hides its contents', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
|
||||
expect(isSealedBackup(sealed)).toBe(true);
|
||||
expect(JSON.stringify(sealed)).not.toContain('passwordHash');
|
||||
expect(JSON.stringify(sealed)).not.toContain('$2a$12$secret');
|
||||
expect(openBackup(sealed)).toEqual(bundle);
|
||||
});
|
||||
|
||||
it('rejects a file whose ciphertext was changed', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
const bytes = Buffer.from(sealed.ciphertext, 'base64');
|
||||
bytes[0] ^= 0xff;
|
||||
|
||||
expect(() =>
|
||||
openBackup({ ...sealed, ciphertext: bytes.toString('base64') }),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a file whose header was changed', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
|
||||
expect(() =>
|
||||
openBackup({ ...sealed, exportedAt: '2020-01-01T00:00:00.000Z' }),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a file sealed with another key', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
|
||||
|
||||
expect(() => openBackup(sealed)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('uses BACKUP_ENCRYPTION_KEY independently of the JWT secret', () => {
|
||||
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||
|
||||
expect(openBackup(sealed)).toEqual(bundle);
|
||||
});
|
||||
|
||||
it('refuses a too short BACKUP_ENCRYPTION_KEY', () => {
|
||||
process.env.BACKUP_ENCRYPTION_KEY = 'short';
|
||||
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as zlib from 'node:zlib';
|
||||
|
||||
/**
|
||||
* A backup holds everything - password hashes, encrypted MFA secrets,
|
||||
* webhook signing secrets - and an import writes straight into the users
|
||||
* table. So the file is sealed: AES-256-GCM over the gzipped bundle, which
|
||||
* keeps it unreadable outside ApiCore AND makes any change to it (or a
|
||||
* hand-made file) fail authentication before a single row is touched.
|
||||
*
|
||||
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
|
||||
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
|
||||
* can always restore its own backups. Either way the AES key comes from
|
||||
* HKDF with a backup-specific label, never the raw secret.
|
||||
*/
|
||||
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
|
||||
export const SEALED_BACKUP_VERSION = 3;
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_BYTES = 12;
|
||||
const MIN_KEY_LENGTH = 32;
|
||||
// Decompressed size cap: a small file must not unpack into gigabytes.
|
||||
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
export interface SealedBackup {
|
||||
format: typeof SEALED_BACKUP_FORMAT;
|
||||
version: typeof SEALED_BACKUP_VERSION;
|
||||
algorithm: 'A256GCM+gzip';
|
||||
/** Tells which key a file needs, without revealing the key. */
|
||||
keyId: string;
|
||||
exportedAt: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
|
||||
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
|
||||
if (backupKey) {
|
||||
if (backupKey.length < MIN_KEY_LENGTH) {
|
||||
throw new Error(
|
||||
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
return { secret: backupKey, source: 'backup' };
|
||||
}
|
||||
const jwtSecret = process.env.JWT_ACCESS_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_ACCESS_SECRET is not set');
|
||||
}
|
||||
return { secret: jwtSecret, source: 'jwt' };
|
||||
}
|
||||
|
||||
function deriveKey(): { key: Buffer; keyId: string } {
|
||||
const { secret, source } = rootSecret();
|
||||
const key = Buffer.from(
|
||||
crypto.hkdfSync(
|
||||
'sha256',
|
||||
secret,
|
||||
'apicore-backup',
|
||||
`apicore-backup-v${SEALED_BACKUP_VERSION}:${source}`,
|
||||
32,
|
||||
),
|
||||
);
|
||||
const keyId = crypto
|
||||
.createHash('sha256')
|
||||
.update(key)
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
return { key, keyId };
|
||||
}
|
||||
|
||||
function associatedData(
|
||||
header: Pick<SealedBackup, 'format' | 'version' | 'keyId' | 'exportedAt'>,
|
||||
): Buffer {
|
||||
return Buffer.from(
|
||||
`${header.format}|${header.version}|${header.keyId}|${header.exportedAt}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
|
||||
const { key, keyId } = deriveKey();
|
||||
const iv = crypto.randomBytes(IV_BYTES);
|
||||
const header = {
|
||||
format: SEALED_BACKUP_FORMAT,
|
||||
version: SEALED_BACKUP_VERSION,
|
||||
keyId,
|
||||
exportedAt,
|
||||
} as const;
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
cipher.setAAD(associatedData(header));
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
|
||||
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
return {
|
||||
...header,
|
||||
algorithm: 'A256GCM+gzip',
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
ciphertext: ciphertext.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSealedBackup(value: unknown): value is SealedBackup {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
candidate.format === SEALED_BACKUP_FORMAT &&
|
||||
typeof candidate.ciphertext === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */
|
||||
export function openBackup(sealed: SealedBackup): unknown {
|
||||
if (sealed.version !== SEALED_BACKUP_VERSION) {
|
||||
throw new BadRequestException(
|
||||
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
|
||||
);
|
||||
}
|
||||
const { key, keyId } = deriveKey();
|
||||
if (sealed.keyId !== keyId) {
|
||||
throw new BadRequestException(
|
||||
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
|
||||
);
|
||||
}
|
||||
let compressed: Buffer;
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
ALGORITHM,
|
||||
key,
|
||||
Buffer.from(sealed.iv, 'base64'),
|
||||
);
|
||||
decipher.setAAD(associatedData(sealed));
|
||||
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
|
||||
compressed = Buffer.concat([
|
||||
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
} catch {
|
||||
throw new BadRequestException(
|
||||
'Backup-Datei ist beschädigt oder wurde verändert',
|
||||
);
|
||||
}
|
||||
let json: string;
|
||||
try {
|
||||
json = zlib
|
||||
.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES })
|
||||
.toString('utf-8');
|
||||
} catch {
|
||||
throw new BadRequestException('Backup-Datei ist zu groß oder beschädigt');
|
||||
}
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
assertBackupCron,
|
||||
assertGuiBackupPath,
|
||||
resolveGuiBackupPath,
|
||||
} from './backup-schedule-rules';
|
||||
|
||||
describe('backup-schedule-rules', () => {
|
||||
const originalRoot = process.env.BACKUP_ROOT;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.BACKUP_ROOT = path.resolve('/data/backups');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.BACKUP_ROOT = originalRoot;
|
||||
});
|
||||
|
||||
it('keeps a GUI path under the backup root', () => {
|
||||
expect(resolveGuiBackupPath('nightly')).toBe(
|
||||
path.resolve('/data/backups/nightly'),
|
||||
);
|
||||
expect(() => assertGuiBackupPath('nightly')).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a GUI path that leaves the backup root', () => {
|
||||
expect(() => assertGuiBackupPath('../../app/public')).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(() => assertGuiBackupPath('/etc')).toThrow(BadRequestException);
|
||||
expect(() => assertGuiBackupPath('/data/backups-evil')).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a daily schedule', () => {
|
||||
expect(() => assertBackupCron('0 3 * * *')).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses a schedule that runs more than hourly or has seconds', () => {
|
||||
expect(() => assertBackupCron('* * * * *')).toThrow(BadRequestException);
|
||||
expect(() => assertBackupCron('*/10 * * * *')).toThrow(BadRequestException);
|
||||
expect(() => assertBackupCron('* * * * * *')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('refuses an invalid expression', () => {
|
||||
expect(() => assertBackupCron('every day')).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CronTime, validateCronExpression } from 'cron';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* Limits on what the admin UI may configure for automatic backups. The env
|
||||
* variables (set by the operator) stay unrestricted; a GUI value is data a
|
||||
* logged-in user typed, so it may neither write a full dump anywhere on the
|
||||
* server's file system nor run an export every second.
|
||||
*/
|
||||
const MIN_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const INTERVAL_SAMPLES = 48;
|
||||
|
||||
/** Root every GUI-configured backup directory must stay under. */
|
||||
export function backupRoot(): string {
|
||||
return path.resolve(process.env.BACKUP_ROOT ?? './backups');
|
||||
}
|
||||
|
||||
/** The GUI path resolved under backupRoot(), or null if it would leave it. */
|
||||
export function resolveGuiBackupPath(value: string): string | null {
|
||||
const root = backupRoot();
|
||||
const resolved = path.resolve(root, value);
|
||||
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function assertGuiBackupPath(value: string): void {
|
||||
if (resolveGuiBackupPath(value) === null) {
|
||||
throw new BadRequestException(
|
||||
`Backup-Pfad muss innerhalb von ${backupRoot()} liegen`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A standard five-field cron expression that fires at most once an hour. */
|
||||
export function assertBackupCron(expression: string): void {
|
||||
const fields = expression.trim().split(/\s+/);
|
||||
if (fields.length !== 5 || !validateCronExpression(expression).valid) {
|
||||
throw new BadRequestException(
|
||||
'Zeitplan muss ein Cron-Ausdruck mit fünf Feldern sein, z. B. "0 3 * * *"',
|
||||
);
|
||||
}
|
||||
const runs = new CronTime(expression)
|
||||
.sendAt(INTERVAL_SAMPLES)
|
||||
.map((run) => run.toMillis());
|
||||
for (let i = 1; i < runs.length; i++) {
|
||||
if (runs[i] - runs[i - 1] < MIN_INTERVAL_MS) {
|
||||
throw new BadRequestException(
|
||||
'Zeitplan darf höchstens einmal pro Stunde sichern',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
import {
|
||||
assertBackupCron,
|
||||
resolveGuiBackupPath,
|
||||
} from './backup-schedule-rules';
|
||||
|
||||
const SCHEDULED_JOB_NAME = 'automatic-backup';
|
||||
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
|
||||
@@ -68,7 +72,13 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
const gui = await this.backupSettingsService.get();
|
||||
|
||||
return {
|
||||
path: hasEnvPath ? (envPath as string) : (gui.storagePath ?? null),
|
||||
// A GUI path counts only inside BACKUP_ROOT (a value stored before
|
||||
// that rule existed and pointing elsewhere disables the schedule).
|
||||
path: hasEnvPath
|
||||
? (envPath as string)
|
||||
: gui.storagePath
|
||||
? resolveGuiBackupPath(gui.storagePath)
|
||||
: null,
|
||||
cronSchedule: hasEnvCron
|
||||
? (envCron as string)
|
||||
: (gui.cronSchedule ?? DEFAULT_CRON_SCHEDULE),
|
||||
@@ -88,11 +98,22 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
// admin's schedule/path/retention change takes effect immediately without
|
||||
// a pod restart.
|
||||
async reload(): Promise<void> {
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.envOverrides.cronSchedule) {
|
||||
try {
|
||||
assertBackupCron(settings.cronSchedule);
|
||||
} catch {
|
||||
// Keep whatever is running instead of silently ending backups.
|
||||
this.logger.error(
|
||||
`Ungültiger Backup-Zeitplan "${settings.cronSchedule}" - bisheriger Zeitplan bleibt aktiv`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) {
|
||||
this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME);
|
||||
}
|
||||
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.path) {
|
||||
this.logger.log(
|
||||
'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert',
|
||||
@@ -144,7 +165,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
|
||||
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
|
||||
// duplicate-tick guard, since a human explicitly asked for it.
|
||||
async runNow(): Promise<{ filePath: string }> {
|
||||
async runNow(): Promise<{ fileName: string }> {
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.path) {
|
||||
throw new Error(
|
||||
@@ -153,7 +174,8 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
}
|
||||
const filePath = await this.writeBackup(settings.path);
|
||||
await this.pruneOldBackups(settings.path, settings.retentionCount);
|
||||
return { filePath };
|
||||
// Only the name: the server's directory layout stays on the server.
|
||||
return { fileName: path.basename(filePath) };
|
||||
}
|
||||
|
||||
private async runScheduled(directory: string): Promise<void> {
|
||||
@@ -176,14 +198,15 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private async writeBackup(directory: string): Promise<string> {
|
||||
const bundle = await this.backupService.exportAll();
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const timestamp = bundle.exportedAt.replace(/[:.]/g, '-');
|
||||
const sealed = await this.backupService.exportSealed();
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
|
||||
const filePath = path.join(
|
||||
directory,
|
||||
`${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`,
|
||||
);
|
||||
await fs.writeFile(filePath, JSON.stringify(bundle, null, 2));
|
||||
// Owner-only: the file holds every account's credentials (encrypted).
|
||||
await fs.writeFile(filePath, JSON.stringify(sealed), { mode: 0o600 });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { BackupSettings } from '../../generated/prisma';
|
||||
import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto';
|
||||
import { assertBackupCron, assertGuiBackupPath } from './backup-schedule-rules';
|
||||
|
||||
const SINGLETON_ID = 'singleton';
|
||||
|
||||
@@ -31,6 +32,12 @@ export class BackupSettingsService {
|
||||
dto: UpdateBackupSettingsDto,
|
||||
actingUserId: string,
|
||||
): Promise<BackupSettings> {
|
||||
if (dto.storagePath) {
|
||||
assertGuiBackupPath(dto.storagePath);
|
||||
}
|
||||
if (dto.cronSchedule) {
|
||||
assertBackupCron(dto.cronSchedule);
|
||||
}
|
||||
const before = await this.get();
|
||||
const settings = await this.prisma.backupSettings.update({
|
||||
where: { id: SINGLETON_ID },
|
||||
|
||||
@@ -16,7 +16,9 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { BackupSettings } from '../../generated/prisma';
|
||||
import { BackupBundle, BackupService } from './backup.service';
|
||||
import { BackupService } from './backup.service';
|
||||
import { ReauthService } from '../auth/reauth/reauth.service';
|
||||
import { BackupImportProofDto } from './dto/backup-import-proof.dto';
|
||||
import {
|
||||
BackupScheduleStatus,
|
||||
BackupSchedulerService,
|
||||
@@ -38,6 +40,7 @@ export class BackupController {
|
||||
private readonly backupService: BackupService,
|
||||
private readonly backupSchedulerService: BackupSchedulerService,
|
||||
private readonly backupSettingsService: BackupSettingsService,
|
||||
private readonly reauth: ReauthService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@@ -48,7 +51,7 @@ export class BackupController {
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@Post('schedule/run-now')
|
||||
runScheduleNow(): Promise<{ filePath: string }> {
|
||||
runScheduleNow(): Promise<{ fileName: string }> {
|
||||
return this.backupSchedulerService.runNow();
|
||||
}
|
||||
|
||||
@@ -72,11 +75,12 @@ export class BackupController {
|
||||
@RequirePermission('backup:export')
|
||||
@Get('export')
|
||||
async export(@Res() res: Response): Promise<void> {
|
||||
const bundle = await this.backupService.exportAll();
|
||||
const sealed = await this.backupService.exportSealed();
|
||||
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(JSON.stringify(bundle, null, 2));
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(JSON.stringify(sealed));
|
||||
}
|
||||
|
||||
@RequirePermission('backup:import')
|
||||
@@ -99,17 +103,20 @@ export class BackupController {
|
||||
)
|
||||
async import(
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() proof: BackupImportProofDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
// Restoring overwrites every account: prove it is really you, now.
|
||||
await this.reauth.verify(actor.id, proof);
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
let bundle: BackupBundle;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle;
|
||||
parsed = JSON.parse(file.buffer.toString('utf-8'));
|
||||
} catch {
|
||||
throw new BadRequestException('Datei ist kein gültiges JSON');
|
||||
}
|
||||
return this.backupService.importAll(bundle, actor.id);
|
||||
return this.backupService.importFile(parsed, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReauthModule } from '../auth/reauth/reauth.module';
|
||||
import { BackupController } from './backup.controller';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
|
||||
@Module({
|
||||
imports: [ReauthModule],
|
||||
controllers: [BackupController],
|
||||
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { BackupService } from './backup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
@@ -24,6 +25,7 @@ const BUSINESS_ENTITY_KEYS = [
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
@@ -68,6 +70,8 @@ describe('BackupService', () => {
|
||||
user: { upsert: jest.Mock; findMany: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let permissionResolver: { invalidateAll: jest.Mock };
|
||||
const TRUSTED = { trusted: true };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -91,6 +95,9 @@ describe('BackupService', () => {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
|
||||
};
|
||||
tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]);
|
||||
(tx.role as unknown as { findMany: jest.Mock }).findMany = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
prisma = {
|
||||
user: {
|
||||
@@ -113,9 +120,11 @@ describe('BackupService', () => {
|
||||
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
permissionResolver = { invalidateAll: jest.fn() };
|
||||
service = new BackupService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -197,7 +206,7 @@ describe('BackupService', () => {
|
||||
describe('importAll', () => {
|
||||
it('rejects a bundle with no data object', async () => {
|
||||
await expect(
|
||||
service.importAll({ version: 2 } as never, 'admin-1'),
|
||||
service.importAll({ version: 2 } as never, 'admin-1', TRUSTED),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -210,6 +219,7 @@ describe('BackupService', () => {
|
||||
data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.role.upsert.mock.calls as [
|
||||
@@ -232,6 +242,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.user.upsert.mock.calls as [
|
||||
@@ -251,6 +262,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -274,6 +286,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1);
|
||||
@@ -292,6 +305,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
|
||||
@@ -325,6 +339,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||
@@ -351,6 +366,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||
@@ -371,6 +387,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.stockMovement.upsert.mock.calls as [
|
||||
@@ -395,6 +412,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.automationRule.upsert.mock.calls as [
|
||||
@@ -421,14 +439,14 @@ describe('BackupService', () => {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
entityType: 'Colony',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: 'known-user',
|
||||
contentBase64: Buffer.from('hello').toString('base64'),
|
||||
contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'),
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
entityType: 'Colony',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: null,
|
||||
contentBase64: null,
|
||||
@@ -437,6 +455,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
@@ -456,6 +475,7 @@ describe('BackupService', () => {
|
||||
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
@@ -463,9 +483,111 @@ describe('BackupService', () => {
|
||||
entityType: 'Backup',
|
||||
action: 'CREATE',
|
||||
userId: 'admin-1',
|
||||
afterData: expect.objectContaining({ apiary: 1 }) as unknown,
|
||||
afterData: expect.objectContaining({
|
||||
imported: expect.objectContaining({ apiary: 1 }) as unknown,
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a document whose entityType would leave the storage root', async () => {
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-evil',
|
||||
entityType: '../../app/dist',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fs.mkdir).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a document whose bytes do not match its declared type', async () => {
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-html',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('<script>').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never rewrites audit entries that already exist', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { auditLog: [{ id: 'audit-1', action: 'LOGIN' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
const [[call]] = tx.auditLog.upsert.mock.calls as [
|
||||
[{ update: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.update).toEqual({});
|
||||
});
|
||||
|
||||
it('takes no users, roles, webhooks or audit entries from an unsealed file', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
role: [{ key: 'ADMIN', name: 'Admin' }],
|
||||
user: [
|
||||
{
|
||||
id: 'admin-victim',
|
||||
email: '[email protected]',
|
||||
passwordHash: 'attacker-hash',
|
||||
roleKey: 'ADMIN',
|
||||
},
|
||||
],
|
||||
webhookSubscription: [{ id: 'hook-1', url: 'https://evil' }],
|
||||
auditLog: [{ id: 'audit-1' }],
|
||||
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
{ trusted: false },
|
||||
);
|
||||
|
||||
expect(tx.role.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.user.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.webhookSubscription.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(summary.skipped).toEqual(
|
||||
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,19 @@ import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { ALLOWED_DOCUMENT_MIME_TYPES } from '../documents/document-mime-types';
|
||||
import {
|
||||
ALLOWED_DOCUMENT_MIME_TYPES,
|
||||
isAllowedDocumentMimeType,
|
||||
} from '../documents/document-mime-types';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { DocumentEntityType } from '../../generated/prisma';
|
||||
import {
|
||||
SealedBackup,
|
||||
isSealedBackup,
|
||||
openBackup,
|
||||
sealBackup,
|
||||
} from './backup-crypto';
|
||||
|
||||
const BACKUP_VERSION = 2;
|
||||
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
@@ -17,8 +29,28 @@ export interface BackupBundle {
|
||||
|
||||
export interface BackupImportSummary {
|
||||
imported: Record<string, number>;
|
||||
/**
|
||||
* Tables left out because the file was an old, unsealed (v2) backup: its
|
||||
* contents cannot be proven to come from ApiCore, so nothing that grants
|
||||
* access (users, roles, webhooks) or rewrites history (audit log) is
|
||||
* taken from it.
|
||||
*/
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
// What an unsealed file may never write - see BackupImportSummary.skipped.
|
||||
const TRUSTED_ONLY_TABLES = [
|
||||
'role',
|
||||
'user',
|
||||
'rolePermission',
|
||||
'webhookSubscription',
|
||||
'auditLog',
|
||||
] as const;
|
||||
|
||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||
Object.values(DocumentEntityType),
|
||||
);
|
||||
|
||||
// Excluded on purpose:
|
||||
// - Permission: code-derived, re-synced from the catalog on every boot -
|
||||
// including it would just fight that sync. RolePermission references
|
||||
@@ -55,8 +87,33 @@ export class BackupService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
/** The export as it leaves the server: encrypted and tamper-evident. */
|
||||
async exportSealed(): Promise<SealedBackup> {
|
||||
const bundle = await this.exportAll();
|
||||
return sealBackup(bundle, bundle.exportedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores an uploaded file. A sealed backup is restored completely; an
|
||||
* old unsealed one only with its business data (see TRUSTED_ONLY_TABLES).
|
||||
*/
|
||||
async importFile(
|
||||
parsed: unknown,
|
||||
actingUserId: string,
|
||||
): Promise<BackupImportSummary> {
|
||||
if (isSealedBackup(parsed)) {
|
||||
return this.importAll(openBackup(parsed) as BackupBundle, actingUserId, {
|
||||
trusted: true,
|
||||
});
|
||||
}
|
||||
return this.importAll(parsed as BackupBundle, actingUserId, {
|
||||
trusted: false,
|
||||
});
|
||||
}
|
||||
|
||||
async exportAll(): Promise<BackupBundle> {
|
||||
const data: Record<string, unknown[]> = {};
|
||||
|
||||
@@ -93,6 +150,7 @@ export class BackupService {
|
||||
data.task = await this.prisma.task.findMany();
|
||||
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
||||
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
||||
data.winterCheck = await this.prisma.winterCheck.findMany();
|
||||
data.treatment = await this.prisma.treatment.findMany();
|
||||
data.feeding = await this.prisma.feeding.findMany();
|
||||
data.harvest = await this.prisma.harvest.findMany();
|
||||
@@ -144,6 +202,7 @@ export class BackupService {
|
||||
async importAll(
|
||||
bundle: BackupBundle,
|
||||
actingUserId: string,
|
||||
{ trusted }: { trusted: boolean },
|
||||
): Promise<BackupImportSummary> {
|
||||
if (
|
||||
typeof bundle !== 'object' ||
|
||||
@@ -154,7 +213,18 @@ export class BackupService {
|
||||
throw new BadRequestException('Invalid backup file');
|
||||
}
|
||||
|
||||
const isTrustedOnly = (key: string): boolean =>
|
||||
(TRUSTED_ONLY_TABLES as readonly string[]).includes(key);
|
||||
const skipped = trusted
|
||||
? []
|
||||
: TRUSTED_ONLY_TABLES.filter((key) => {
|
||||
const value = bundle.data[key];
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
});
|
||||
const rowsOf = (key: string): Row[] => {
|
||||
if (!trusted && isTrustedOnly(key)) {
|
||||
return [];
|
||||
}
|
||||
const value = bundle.data[key];
|
||||
return Array.isArray(value) ? (value as Row[]) : [];
|
||||
};
|
||||
@@ -164,6 +234,13 @@ export class BackupService {
|
||||
await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const roleKeyToId = new Map<string, string>();
|
||||
// Roles already in this database resolve too - an unsealed file
|
||||
// brings none of its own.
|
||||
for (const role of await tx.role.findMany({
|
||||
select: { id: true, key: true },
|
||||
})) {
|
||||
roleKeyToId.set(role.key, role.id);
|
||||
}
|
||||
for (const row of rowsOf('role')) {
|
||||
const withoutId: Row = { ...row };
|
||||
delete withoutId.id;
|
||||
@@ -430,6 +507,21 @@ export class BackupService {
|
||||
}
|
||||
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
|
||||
|
||||
for (const row of rowsOf('winterCheck')) {
|
||||
await tx.winterCheck.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.winterCheck = rowsOf('winterCheck').length;
|
||||
|
||||
for (const row of rowsOf('treatment')) {
|
||||
await tx.treatment.upsert({
|
||||
where: { id: row.id as string },
|
||||
@@ -677,13 +769,30 @@ export class BackupService {
|
||||
if (!contentBase64) {
|
||||
continue;
|
||||
}
|
||||
const buffer = Buffer.from(contentBase64, 'base64');
|
||||
const extension =
|
||||
ALLOWED_DOCUMENT_MIME_TYPES[row.mimeType as string] ?? '';
|
||||
const directory = path.join(
|
||||
documentsStorageRoot(),
|
||||
(row.entityType as string).toLowerCase(),
|
||||
const entityType = asString(row, 'entityType');
|
||||
const mimeType = asString(row, 'mimeType');
|
||||
if (
|
||||
!entityType ||
|
||||
!DOCUMENT_ENTITY_TYPES.has(entityType) ||
|
||||
!mimeType ||
|
||||
!isAllowedDocumentMimeType(mimeType)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Dokument ${String(row.id)} hat einen unzulässigen Typ`,
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.from(contentBase64, 'base64');
|
||||
if (!matchesFileSignature(buffer, mimeType)) {
|
||||
throw new BadRequestException(
|
||||
`Dokument ${String(row.id)}: Inhalt passt nicht zum Dateityp`,
|
||||
);
|
||||
}
|
||||
const extension = ALLOWED_DOCUMENT_MIME_TYPES[mimeType];
|
||||
const storageRoot = path.resolve(documentsStorageRoot());
|
||||
const directory = path.join(storageRoot, entityType.toLowerCase());
|
||||
if (!directory.startsWith(storageRoot + path.sep)) {
|
||||
throw new BadRequestException('Unzulässiger Speicherpfad');
|
||||
}
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const storagePath = path.join(
|
||||
directory,
|
||||
@@ -723,6 +832,7 @@ export class BackupService {
|
||||
}
|
||||
imported.notification = rowsOf('notification').length;
|
||||
|
||||
// Append-only: entries already here are never rewritten by a file.
|
||||
for (const row of rowsOf('auditLog')) {
|
||||
await tx.auditLog.upsert({
|
||||
where: { id: row.id as string },
|
||||
@@ -730,10 +840,7 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
imported.auditLog = rowsOf('auditLog').length;
|
||||
@@ -741,14 +848,17 @@ export class BackupService {
|
||||
{ timeout: IMPORT_TRANSACTION_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
// Users, roles and grants may have changed underneath every cache.
|
||||
this.permissionResolver.invalidateAll();
|
||||
|
||||
await this.auditLog.record({
|
||||
entityType: 'Backup',
|
||||
entityId: 'import',
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: imported,
|
||||
afterData: { imported, skipped, sealed: trusted },
|
||||
});
|
||||
|
||||
return { imported };
|
||||
return { imported, skipped };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
/** Step-up proof sent with a backup upload (multipart fields). */
|
||||
export class BackupImportProofDto {
|
||||
@ApiPropertyOptional({ description: 'Aktuelles Passwort' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Aktueller TOTP-Code, falls MFA aktiv' })
|
||||
@IsOptional()
|
||||
@Matches(/^\d{6}$/)
|
||||
mfaCode?: string;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
||||
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
|
||||
import { BottlingService } from './bottling.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
|
||||
@ApiTags('bottling')
|
||||
@ApiBearerAuth()
|
||||
@@ -35,6 +36,7 @@ export class BottlingController {
|
||||
}
|
||||
|
||||
@RequirePermission('bottling:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateBottlingBatchDto,
|
||||
|
||||
@@ -5,6 +5,18 @@ import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
|
||||
type AmountChange = number | { increment?: number; decrement?: number };
|
||||
|
||||
function applyAmountChange(
|
||||
current: number | null,
|
||||
change: AmountChange | undefined,
|
||||
): number | null {
|
||||
if (change === undefined) return current;
|
||||
if (typeof change === 'number') return change;
|
||||
if (current === null) return null;
|
||||
return current + (change.increment ?? 0) - (change.decrement ?? 0);
|
||||
}
|
||||
|
||||
describe('BottlingService', () => {
|
||||
let service: BottlingService;
|
||||
let prisma: {
|
||||
@@ -21,18 +33,71 @@ describe('BottlingService', () => {
|
||||
let auditLog: { record: jest.Mock };
|
||||
let tx: {
|
||||
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
|
||||
honeyBatch: { update: jest.Mock };
|
||||
honeyBatch: {
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
updateMany: jest.Mock;
|
||||
};
|
||||
};
|
||||
let webhooks: { dispatch: jest.Mock };
|
||||
// Honey batch rows as the transaction sees them - the tests assert the
|
||||
// resulting amounts instead of the exact sequence of UPDATE statements.
|
||||
let honeyBatches: Map<string, { amount: number | null; status?: string }>;
|
||||
|
||||
beforeEach(() => {
|
||||
honeyBatches = new Map();
|
||||
tx = {
|
||||
bottlingBatch: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
honeyBatch: { update: jest.fn() },
|
||||
honeyBatch: {
|
||||
findUnique: jest.fn(({ where }: { where: { id: string } }) => {
|
||||
const row = honeyBatches.get(where.id);
|
||||
return Promise.resolve(row ? { id: where.id, ...row } : null);
|
||||
}),
|
||||
update: jest.fn(
|
||||
({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: { amount?: AmountChange; status?: string };
|
||||
}) => {
|
||||
const row = honeyBatches.get(where.id) ?? { amount: null };
|
||||
honeyBatches.set(where.id, {
|
||||
amount: applyAmountChange(row.amount, data.amount),
|
||||
status: data.status ?? row.status,
|
||||
});
|
||||
return Promise.resolve({});
|
||||
},
|
||||
),
|
||||
updateMany: jest.fn(
|
||||
({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string; amount?: { gte?: number } };
|
||||
data: { amount: AmountChange; status?: string };
|
||||
}) => {
|
||||
const row = honeyBatches.get(where.id);
|
||||
const minimum = where.amount?.gte;
|
||||
if (
|
||||
!row ||
|
||||
row.amount === null ||
|
||||
(minimum !== undefined && row.amount < minimum)
|
||||
) {
|
||||
return Promise.resolve({ count: 0 });
|
||||
}
|
||||
honeyBatches.set(where.id, {
|
||||
amount: applyAmountChange(row.amount, data.amount),
|
||||
status: data.status ?? row.status,
|
||||
});
|
||||
return Promise.resolve({ count: 1 });
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
honeyBatch: { findUnique: jest.fn(), update: jest.fn() },
|
||||
@@ -101,6 +166,7 @@ describe('BottlingService', () => {
|
||||
id: 'h1',
|
||||
amount: 25,
|
||||
});
|
||||
honeyBatches.set('h1', { amount: 25 });
|
||||
tx.bottlingBatch.create.mockResolvedValueOnce({
|
||||
id: 'b1',
|
||||
totalAmount: 25,
|
||||
@@ -123,10 +189,7 @@ describe('BottlingService', () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: 'b1', totalAmount: 25 });
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 0, status: 'BOTTLED' },
|
||||
});
|
||||
expect(honeyBatches.get('h1')).toEqual({ amount: 0, status: 'BOTTLED' });
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'BottlingBatch',
|
||||
@@ -135,6 +198,32 @@ describe('BottlingService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a bottling when the stored amount shrank after the first check', async () => {
|
||||
// The pre-check saw 25 kg, but a concurrent bottling already took most
|
||||
// of it - the conditional booking inside the transaction must catch that.
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
|
||||
id: 'h1',
|
||||
amount: 25,
|
||||
});
|
||||
honeyBatches.set('h1', { amount: 3 });
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
honeyBatchId: 'h1',
|
||||
date: '2026-01-01',
|
||||
jarSize: 500,
|
||||
jarCount: 20,
|
||||
bestBeforeDate: '2027-01-01',
|
||||
lotNumber: 'L-9',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
|
||||
expect(honeyBatches.get('h1')?.amount).toBe(3);
|
||||
});
|
||||
|
||||
it('update writes an audit log entry with before and after data', async () => {
|
||||
const before = {
|
||||
id: 'b1',
|
||||
@@ -209,6 +298,7 @@ describe('BottlingService', () => {
|
||||
prisma.bottlingBatch.findUnique
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
|
||||
honeyBatches.set('h1', { amount: 0 });
|
||||
|
||||
await service.update('b1', { jarCount: 10 }, 'user-1');
|
||||
|
||||
@@ -217,10 +307,7 @@ describe('BottlingService', () => {
|
||||
];
|
||||
expect(updateArgs[0].data.jarCount).toBe(10);
|
||||
expect(updateArgs[0].data.totalAmount).toBe(5);
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 5 },
|
||||
});
|
||||
expect(honeyBatches.get('h1')?.amount).toBe(5);
|
||||
});
|
||||
|
||||
it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
|
||||
@@ -251,17 +338,13 @@ describe('BottlingService', () => {
|
||||
.mockResolvedValueOnce(before)
|
||||
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
|
||||
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
honeyBatches.set('h2', { amount: 8 });
|
||||
|
||||
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
|
||||
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 7 },
|
||||
});
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h2' },
|
||||
data: { amount: 3, status: 'BOTTLED' },
|
||||
});
|
||||
expect(honeyBatches.get('h1')?.amount).toBe(7);
|
||||
expect(honeyBatches.get('h2')).toEqual({ amount: 3, status: 'BOTTLED' });
|
||||
});
|
||||
|
||||
it('remove gives the amount back and records a DELETE audit entry', async () => {
|
||||
@@ -272,6 +355,7 @@ describe('BottlingService', () => {
|
||||
totalAmount: 5,
|
||||
};
|
||||
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
|
||||
honeyBatches.set('h1', { amount: 2 });
|
||||
|
||||
const result = await service.remove('b1', 'user-1');
|
||||
|
||||
@@ -279,10 +363,7 @@ describe('BottlingService', () => {
|
||||
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'b1' },
|
||||
});
|
||||
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
|
||||
where: { id: 'h1' },
|
||||
data: { amount: 7 },
|
||||
});
|
||||
expect(honeyBatches.get('h1')?.amount).toBe(7);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'BottlingBatch',
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { BottlingBatch } from '../../generated/prisma';
|
||||
import { BottlingBatch, Prisma } from '../../generated/prisma';
|
||||
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
||||
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
|
||||
|
||||
@@ -29,6 +29,74 @@ function roundKg(amount: number): number {
|
||||
return Math.round(amount * 1000) / 1000;
|
||||
}
|
||||
|
||||
const INSUFFICIENT_AMOUNT_MESSAGE =
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* Deducts `amount` kg from a honey batch in one conditional UPDATE, so the
|
||||
* database - not a value read earlier - decides whether enough is left.
|
||||
* A batch without a recorded amount (null) is not tracked and stays null.
|
||||
*/
|
||||
async function takeFromHoneyBatch(
|
||||
tx: Tx,
|
||||
honeyBatchId: string,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
const current = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true },
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
if (current.amount === null) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { status: 'BOTTLED' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Tolerance of half a gram absorbs float noise from earlier writes.
|
||||
const booked = await tx.honeyBatch.updateMany({
|
||||
where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } },
|
||||
data: { amount: { decrement: amount }, status: 'BOTTLED' },
|
||||
});
|
||||
if (booked.count === 0) {
|
||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
||||
}
|
||||
await normalizeAmount(tx, honeyBatchId);
|
||||
}
|
||||
|
||||
async function giveBackToHoneyBatch(
|
||||
tx: Tx,
|
||||
honeyBatchId: string,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
await tx.honeyBatch.updateMany({
|
||||
where: { id: honeyBatchId, amount: { not: null } },
|
||||
data: { amount: { increment: amount } },
|
||||
});
|
||||
await normalizeAmount(tx, honeyBatchId);
|
||||
}
|
||||
|
||||
/** Keeps gram precision and clamps float noise below zero to exactly 0. */
|
||||
async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
|
||||
const row = await tx.honeyBatch.findUnique({
|
||||
where: { id: honeyBatchId },
|
||||
select: { amount: true },
|
||||
});
|
||||
if (row?.amount == null) return;
|
||||
const normalized = Math.max(0, roundKg(row.amount));
|
||||
if (normalized !== row.amount) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { amount: normalized },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BottlingService {
|
||||
constructor(
|
||||
@@ -73,11 +141,9 @@ export class BottlingService {
|
||||
}
|
||||
}
|
||||
|
||||
const totalAmount = (dto.jarSize * dto.jarCount) / 1000;
|
||||
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
|
||||
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
|
||||
throw new BadRequestException(
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
||||
);
|
||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
||||
}
|
||||
|
||||
const lotNumber =
|
||||
@@ -91,7 +157,12 @@ export class BottlingService {
|
||||
|
||||
const bottlingBatch: BottlingBatch = await this.prisma.$transaction(
|
||||
async (tx) => {
|
||||
const created = await tx.bottlingBatch.create({
|
||||
// Booked against the stored amount inside the transaction, never
|
||||
// against the value read above: two bottlings submitted at the same
|
||||
// time (double tap, two devices) must not both pass the check and
|
||||
// overwrite each other's deduction.
|
||||
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
|
||||
return tx.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId: dto.honeyBatchId,
|
||||
productId: dto.productId,
|
||||
@@ -104,17 +175,6 @@ export class BottlingService {
|
||||
userId: actingUserId,
|
||||
},
|
||||
});
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: dto.honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
honeyBatch.amount !== null
|
||||
? honeyBatch.amount - totalAmount
|
||||
: null,
|
||||
status: 'BOTTLED',
|
||||
},
|
||||
});
|
||||
return created;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -163,8 +223,8 @@ export class BottlingService {
|
||||
throw new NotFoundException('Honey batch not found');
|
||||
}
|
||||
|
||||
// Capacity on the target batch, with this bottling batch's current amount
|
||||
// released first when it already sits on that same batch.
|
||||
// Early, friendly check against the amounts read above. The booking
|
||||
// itself happens atomically inside the transaction below.
|
||||
const availableOnTarget =
|
||||
targetHoneyBatch.amount === null
|
||||
? null
|
||||
@@ -173,14 +233,26 @@ export class BottlingService {
|
||||
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
|
||||
);
|
||||
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
|
||||
throw new BadRequestException(
|
||||
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
||||
);
|
||||
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
|
||||
}
|
||||
|
||||
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
|
||||
// 409 with a field-specific message by AllExceptionsFilter.
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const amountChanged =
|
||||
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
|
||||
if (amountChanged) {
|
||||
// Give the previous amount back first, then take the corrected one -
|
||||
// a correction never double-charges the source batch.
|
||||
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
|
||||
await takeFromHoneyBatch(tx, honeyBatchId, totalAmount);
|
||||
}
|
||||
if (movedToOtherHoneyBatch) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: { status: 'BOTTLED' },
|
||||
});
|
||||
}
|
||||
await tx.bottlingBatch.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -196,38 +268,6 @@ export class BottlingService {
|
||||
lotNumber: dto.lotNumber,
|
||||
},
|
||||
});
|
||||
|
||||
if (movedToOtherHoneyBatch) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: before.honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
before.honeyBatch.amount === null
|
||||
? null
|
||||
: roundKg(before.honeyBatch.amount + before.totalAmount),
|
||||
},
|
||||
});
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
availableOnTarget === null
|
||||
? null
|
||||
: roundKg(availableOnTarget - totalAmount),
|
||||
status: 'BOTTLED',
|
||||
},
|
||||
});
|
||||
} else if (totalAmount !== before.totalAmount) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: honeyBatchId },
|
||||
data: {
|
||||
amount:
|
||||
availableOnTarget === null
|
||||
? null
|
||||
: roundKg(availableOnTarget - totalAmount),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const bottlingBatch = await this.findById(id);
|
||||
@@ -257,14 +297,7 @@ export class BottlingService {
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.bottlingBatch.delete({ where: { id } });
|
||||
if (before.honeyBatch.amount !== null) {
|
||||
await tx.honeyBatch.update({
|
||||
where: { id: before.honeyBatchId },
|
||||
data: {
|
||||
amount: roundKg(before.honeyBatch.amount + before.totalAmount),
|
||||
},
|
||||
});
|
||||
}
|
||||
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
|
||||
});
|
||||
|
||||
await this.auditLog.record({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -13,6 +14,7 @@ import { UpdateColonyDto } from './dto/update-colony.dto';
|
||||
import { MoveColonyDto } from './dto/move-colony.dto';
|
||||
import { SplitColonyDto } from './dto/split-colony.dto';
|
||||
import { MergeColonyDto } from './dto/merge-colony.dto';
|
||||
import { isLivingColonyStatus } from './colony-status';
|
||||
|
||||
export interface ColonyFilter {
|
||||
status?: ColonyStatus;
|
||||
@@ -43,10 +45,13 @@ export class ColoniesService {
|
||||
return colony;
|
||||
}
|
||||
|
||||
// Only a colony that no longer exists (dissolved, sold, dead, merged) is
|
||||
// locked. Weak, conspicuous or queenless colonies are still worked with -
|
||||
// uniting a queenless colony with another is the textbook case for merge.
|
||||
private assertActive(colony: Colony): void {
|
||||
if (colony.status !== 'ACTIVE') {
|
||||
if (!isLivingColonyStatus(colony.status)) {
|
||||
throw new ConflictException(
|
||||
`Das Volk ist nicht aktiv (Status: ${colony.status})`,
|
||||
`Das Volk besteht nicht mehr (Status: ${colony.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -206,6 +211,16 @@ export class ColoniesService {
|
||||
): Promise<Colony> {
|
||||
const survivor = await this.findById(id);
|
||||
this.assertActive(survivor);
|
||||
if (dto.mergedColonyIds.includes(survivor.id)) {
|
||||
throw new BadRequestException(
|
||||
'Ein Volk kann nicht mit sich selbst vereinigt werden',
|
||||
);
|
||||
}
|
||||
if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) {
|
||||
throw new BadRequestException(
|
||||
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
|
||||
);
|
||||
}
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const mergedColonyId of dto.mergedColonyIds) {
|
||||
const merged = await tx.colony.findUnique({
|
||||
@@ -265,6 +280,9 @@ export class ColoniesService {
|
||||
|
||||
async dissolve(id: string, actingUserId: string): Promise<Colony> {
|
||||
const before = await this.findById(id);
|
||||
// Re-dissolving would overwrite deactivatedAt and the terminal status
|
||||
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
|
||||
this.assertActive(before);
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ColonyStatus } from '../../generated/prisma';
|
||||
|
||||
/**
|
||||
* Statuses after which a colony no longer exists as a living unit. The other
|
||||
* statuses (ACTIVE, WEAK, CONSPICUOUS, QUEENLESS) describe a colony that is
|
||||
* still in the apiary and can be worked with - a weak colony still gets moved,
|
||||
* and a queenless one is exactly the colony a beekeeper unites with another.
|
||||
*/
|
||||
export const TERMINAL_COLONY_STATUSES: readonly ColonyStatus[] = [
|
||||
'DISSOLVED',
|
||||
'SOLD',
|
||||
'DECEASED',
|
||||
'MERGED',
|
||||
];
|
||||
|
||||
export function isLivingColonyStatus(status: ColonyStatus): boolean {
|
||||
return !TERMINAL_COLONY_STATUSES.includes(status);
|
||||
}
|
||||
@@ -49,6 +49,30 @@ const FOREIGN_KEY_MESSAGE =
|
||||
* status and message they deserve. Anything not listed here stays a 500 with
|
||||
* a generic body, so no internal detail leaks.
|
||||
*/
|
||||
/**
|
||||
* Errors raised by Express middleware before Nest sees the request (body
|
||||
* too large, malformed JSON, unsupported encoding) carry a 4xx `status`.
|
||||
* They are client mistakes, not outages: answer with that status.
|
||||
*/
|
||||
function middlewareErrorResponse(
|
||||
exception: unknown,
|
||||
): { status: number; message: string } | null {
|
||||
if (typeof exception !== 'object' || exception === null) {
|
||||
return null;
|
||||
}
|
||||
const candidate = exception as { status?: unknown; statusCode?: unknown };
|
||||
const status = candidate.status ?? candidate.statusCode;
|
||||
if (typeof status !== 'number' || status < 400 || status >= 500) {
|
||||
return null;
|
||||
}
|
||||
const messages: Record<number, string> = {
|
||||
400: 'Ungültige Anfrage',
|
||||
413: 'Anfrage ist zu groß',
|
||||
415: 'Format der Anfrage wird nicht unterstützt',
|
||||
};
|
||||
return { status, message: messages[status] ?? 'Ungültige Anfrage' };
|
||||
}
|
||||
|
||||
function prismaErrorResponse(
|
||||
exception: unknown,
|
||||
): { status: number; message: string } | null {
|
||||
@@ -105,7 +129,8 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const isHttpException = exception instanceof HttpException;
|
||||
const prismaResponse = prismaErrorResponse(exception);
|
||||
const prismaResponse =
|
||||
prismaErrorResponse(exception) ?? middlewareErrorResponse(exception);
|
||||
const status = isHttpException
|
||||
? exception.getStatus()
|
||||
: (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
@@ -117,7 +142,8 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
};
|
||||
const message =
|
||||
exception instanceof Error ? exception.message : 'Unknown error';
|
||||
const context = `${request.method} ${request.originalUrl} -> ${status}`;
|
||||
// Path only - query strings can carry codes and personal data.
|
||||
const context = `${request.method} ${request.baseUrl}${request.path} -> ${status}`;
|
||||
// Compared against the literal 500, not HttpStatus.INTERNAL_SERVER_ERROR:
|
||||
// eslint's no-unsafe-enum-comparison and TS's no-unnecessary-type-assertion
|
||||
// fight over any cast here, so a plain number sidesteps both cleanly.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
// Longer than any realistic offline stretch at the apiary: an entry queued
|
||||
// on the phone must still find its key when it finally syncs, otherwise the
|
||||
// repeat would create a duplicate after all.
|
||||
export const IDEMPOTENCY_RETENTION_DAYS = 90;
|
||||
|
||||
@Injectable()
|
||||
export class IdempotencyCleanupService {
|
||||
private readonly logger = new Logger(IdempotencyCleanupService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||
async removeExpired(now: Date = new Date()): Promise<number> {
|
||||
const cutoff = new Date(
|
||||
now.getTime() - IDEMPOTENCY_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const { count } = await this.prisma.idempotencyRecord.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
if (count > 0) {
|
||||
this.logger.log(`Removed ${count} expired idempotency record(s)`);
|
||||
}
|
||||
// Used one-time tokens only matter until the token itself expires.
|
||||
await this.prisma.consumedToken.deleteMany({
|
||||
where: { expiresAt: { lt: now } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
CallHandler,
|
||||
ConflictException,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Observable, from, of, throwError } from 'rxjs';
|
||||
import { catchError, mergeMap } from 'rxjs/operators';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import type { AuthenticatedUser } from '../../auth/types/authenticated-user';
|
||||
import { IDEMPOTENT_KEY } from './idempotent.decorator';
|
||||
|
||||
export const IDEMPOTENCY_HEADER = 'idempotency-key';
|
||||
|
||||
// UUIDs and similar opaque tokens - long enough to be unguessable per user,
|
||||
// short enough to keep the unique index small. Anything else is a client bug.
|
||||
const KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
|
||||
|
||||
const ABANDONED_PENDING_MS = 5 * 60 * 1000;
|
||||
|
||||
type RequestWithUser = Request & { user?: AuthenticatedUser };
|
||||
|
||||
/**
|
||||
* Makes a POST safe to repeat. A client that cannot tell whether its request
|
||||
* arrived - the offline queue after a dropped connection, a retry after a
|
||||
* timeout - sends the same `Idempotency-Key` again and gets the stored first
|
||||
* response back instead of a second inspection, harvest or task.
|
||||
*
|
||||
* Flow per (user, key):
|
||||
* - first request claims the key (PENDING row), runs the handler and stores
|
||||
* status + body (COMPLETED);
|
||||
* - a repeat after completion replays the stored response;
|
||||
* - a repeat while the first one still runs gets 409, the client retries later;
|
||||
* - a failed handler releases the key, so a corrected retry is possible.
|
||||
*
|
||||
* Only handlers marked @Idempotent() take part (response bodies are stored,
|
||||
* so endpoints returning secrets must never opt in). Requests without the
|
||||
* header, or to other handlers, behave exactly as before.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IdempotencyInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Promise<Observable<unknown>> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<RequestWithUser>();
|
||||
const rawKey = request.headers[IDEMPOTENCY_HEADER];
|
||||
const userId = request.user?.id;
|
||||
const optedIn = this.reflector.get<boolean | undefined>(
|
||||
IDEMPOTENT_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
if (
|
||||
!optedIn ||
|
||||
request.method !== 'POST' ||
|
||||
rawKey === undefined ||
|
||||
!userId
|
||||
) {
|
||||
return next.handle();
|
||||
}
|
||||
const key = Array.isArray(rawKey) ? rawKey[0] : rawKey;
|
||||
if (!KEY_PATTERN.test(key)) {
|
||||
throw new BadRequestException(
|
||||
'Ungültiger Idempotency-Key (16–128 Zeichen, nur Buchstaben, Ziffern, "-" und "_")',
|
||||
);
|
||||
}
|
||||
const scope = `${request.method} ${request.baseUrl}${request.path}`;
|
||||
|
||||
let claimed = await this.claim(userId, key, scope);
|
||||
if (!claimed && (await this.releaseIfAbandoned(userId, key))) {
|
||||
claimed = await this.claim(userId, key, scope);
|
||||
}
|
||||
if (!claimed) {
|
||||
const existing = await this.prisma.idempotencyRecord.findUnique({
|
||||
where: { userId_key: { userId, key } },
|
||||
});
|
||||
if (!existing) {
|
||||
// Released between our failed insert and this read - the first
|
||||
// attempt failed. Let the client retry instead of guessing.
|
||||
throw new ConflictException(
|
||||
'Diese Anfrage wurde gerade abgebrochen - bitte erneut senden',
|
||||
);
|
||||
}
|
||||
if (existing.scope !== scope) {
|
||||
throw new ConflictException(
|
||||
'Dieser Idempotency-Key wurde bereits für eine andere Aktion verwendet',
|
||||
);
|
||||
}
|
||||
if (existing.status !== 'COMPLETED') {
|
||||
throw new ConflictException(
|
||||
'Diese Anfrage wird bereits verarbeitet - bitte kurz warten',
|
||||
);
|
||||
}
|
||||
http.getResponse<Response>().status(existing.statusCode ?? 201);
|
||||
return of(existing.responseBody);
|
||||
}
|
||||
|
||||
return next.handle().pipe(
|
||||
mergeMap((body: unknown) =>
|
||||
from(this.complete(userId, key, http.getResponse<Response>(), body)),
|
||||
),
|
||||
catchError((error: unknown) =>
|
||||
from(this.release(userId, key)).pipe(
|
||||
mergeMap(() => throwError(() => error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async claim(
|
||||
userId: string,
|
||||
key: string,
|
||||
scope: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.prisma.idempotencyRecord.create({
|
||||
data: { userId, key, scope },
|
||||
});
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async complete(
|
||||
userId: string,
|
||||
key: string,
|
||||
response: Response,
|
||||
body: unknown,
|
||||
): Promise<unknown> {
|
||||
await this.prisma.idempotencyRecord.update({
|
||||
where: { userId_key: { userId, key } },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
statusCode: response.statusCode,
|
||||
// Round-trip through JSON so Dates etc. are stored exactly as the
|
||||
// client received them the first time.
|
||||
responseBody:
|
||||
body === undefined
|
||||
? Prisma.JsonNull
|
||||
: (JSON.parse(JSON.stringify(body)) as Prisma.InputJsonValue),
|
||||
},
|
||||
});
|
||||
return body;
|
||||
}
|
||||
|
||||
private async release(userId: string, key: string): Promise<void> {
|
||||
await this.prisma.idempotencyRecord.deleteMany({ where: { userId, key } });
|
||||
}
|
||||
|
||||
/**
|
||||
* A PENDING row older than any real request means the process died
|
||||
* mid-request (pod restart) and never completed or released the key.
|
||||
* Without this, the queued entry holding that key would get "already
|
||||
* processing" forever and never sync.
|
||||
*/
|
||||
private async releaseIfAbandoned(
|
||||
userId: string,
|
||||
key: string,
|
||||
): Promise<boolean> {
|
||||
const released = await this.prisma.idempotencyRecord.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
key,
|
||||
status: 'PENDING',
|
||||
createdAt: { lt: new Date(Date.now() - ABANDONED_PENDING_MS) },
|
||||
},
|
||||
});
|
||||
return released.count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IDEMPOTENT_KEY = 'idempotent';
|
||||
|
||||
/**
|
||||
* Opts a POST handler into Idempotency-Key handling (see
|
||||
* IdempotencyInterceptor). Opt-in rather than global on purpose: the
|
||||
* interceptor stores the response body, and some POST responses carry
|
||||
* secrets (MFA setup, tokens) that must never be persisted. Only plain
|
||||
* record-creating endpoints - the ones the offline queue and quick-entry
|
||||
* forms retry - carry this decorator.
|
||||
*/
|
||||
export const Idempotent = () => SetMetadata(IDEMPOTENT_KEY, true);
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
// The public origin pages of honey lots are the only answers meant for
|
||||
// everyone; they may be cached briefly.
|
||||
const CACHEABLE_PREFIX = '/api/v1/public/';
|
||||
|
||||
/**
|
||||
* Personal data, tokens and exports must not end up in a browser's
|
||||
* back/forward cache or a shared proxy. Every API answer is no-store unless
|
||||
* it is one of the public, anonymous ones.
|
||||
*/
|
||||
export function noStoreMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!req.path.startsWith(CACHEABLE_PREFIX)) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -8,7 +8,12 @@ describe('requestLoggerMiddleware', () => {
|
||||
});
|
||||
|
||||
function fakeRequest(): Request {
|
||||
return { method: 'GET', originalUrl: '/api/v1/dashboard' } as Request;
|
||||
return {
|
||||
method: 'GET',
|
||||
baseUrl: '',
|
||||
path: '/api/v1/dashboard',
|
||||
originalUrl: '/api/v1/dashboard?q=secret',
|
||||
} as Request;
|
||||
}
|
||||
|
||||
function fakeResponse(statusCode: number): {
|
||||
|
||||
@@ -18,7 +18,9 @@ export function requestLoggerMiddleware(
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
const line = `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`;
|
||||
// Path only: query strings can carry secrets (the SSO callback's
|
||||
// authorization code and state, search terms with personal data).
|
||||
const line = `${req.method} ${req.baseUrl}${req.path} ${res.statusCode} ${duration}ms`;
|
||||
if (res.statusCode >= 500) {
|
||||
logger.error(line);
|
||||
} else if (res.statusCode >= 400) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { IsNewPassword, assertPasswordNotBreached } from './password-policy';
|
||||
|
||||
class Dto {
|
||||
@IsNewPassword()
|
||||
password!: string;
|
||||
}
|
||||
|
||||
function errorsFor(password: string): number {
|
||||
return validateSync(plainToInstance(Dto, { password })).length;
|
||||
}
|
||||
|
||||
describe('password policy', () => {
|
||||
it('requires 12 characters and at most 72 bytes', () => {
|
||||
expect(errorsFor('kurz123!')).toBe(1);
|
||||
expect(errorsFor('lang-genug-12')).toBe(0);
|
||||
expect(errorsFor('a'.repeat(72))).toBe(0);
|
||||
expect(errorsFor('a'.repeat(73))).toBe(1);
|
||||
expect(errorsFor('ä'.repeat(37))).toBe(1); // 74 bytes
|
||||
});
|
||||
|
||||
describe('assertPasswordNotBreached', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
let fetchMock: jest.SpiedFunction<typeof fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
fetchMock = jest.spyOn(global, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
function rangeFor(password: string, count: number): string {
|
||||
const sha1 = crypto
|
||||
.createHash('sha1')
|
||||
.update(password)
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
return `0000000000000000000000000000000000A:0\r\n${sha1.slice(5)}:${count}\r\n`;
|
||||
}
|
||||
|
||||
it('sends only the first five hash characters', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(rangeFor('other', 3)));
|
||||
await assertPasswordNotBreached('Pferdebatterie-Heftklammer');
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toMatch(/\/range\/[0-9A-F]{5}$/);
|
||||
});
|
||||
|
||||
it('refuses a breached password', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(rangeFor('Passwort1234', 4242)));
|
||||
await expect(
|
||||
assertPasswordNotBreached('Passwort1234'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('ignores padding entries with a count of 0', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(rangeFor('Padding-Only-1', 0)));
|
||||
await expect(
|
||||
assertPasswordNotBreached('Padding-Only-1'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts the password when the service is unreachable', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('offline'));
|
||||
await expect(
|
||||
assertPasswordNotBreached('Irgendwas-123'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException, Logger } from '@nestjs/common';
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength, ValidateBy, buildMessage } from 'class-validator';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
/** OWASP ASVS 2.1.1: at least 12 characters for a new password. */
|
||||
export const MIN_PASSWORD_LENGTH = 12;
|
||||
/** bcrypt ignores everything after 72 bytes - refuse instead of truncating. */
|
||||
export const MAX_PASSWORD_BYTES = 72;
|
||||
|
||||
const PWNED_RANGE_URL = 'https://api.pwnedpasswords.com/range/';
|
||||
const BREACH_CHECK_TIMEOUT_MS = 3_000;
|
||||
const logger = new Logger('PasswordPolicy');
|
||||
|
||||
/** Validation for every NEW password (not for login, where old ones still count). */
|
||||
export function IsNewPassword(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
ApiProperty({
|
||||
minLength: MIN_PASSWORD_LENGTH,
|
||||
description: `Mindestens ${MIN_PASSWORD_LENGTH} Zeichen, höchstens ${MAX_PASSWORD_BYTES} Byte`,
|
||||
}),
|
||||
IsString(),
|
||||
MinLength(MIN_PASSWORD_LENGTH, {
|
||||
message: `Passwort muss mindestens ${MIN_PASSWORD_LENGTH} Zeichen lang sein`,
|
||||
}),
|
||||
ValidateBy({
|
||||
name: 'maxPasswordBytes',
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' &&
|
||||
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES,
|
||||
defaultMessage: buildMessage(
|
||||
() =>
|
||||
`Passwort darf höchstens ${MAX_PASSWORD_BYTES} Byte lang sein (bei Umlauten weniger Zeichen)`,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function breachCheckEnabled(): boolean {
|
||||
return (
|
||||
process.env.PASSWORD_BREACH_CHECK !== 'false' &&
|
||||
process.env.NODE_ENV !== 'test'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a password that appears in known data breaches (Have I Been
|
||||
* Pwned, k-anonymity: only the first 5 hex characters of its SHA-1 leave
|
||||
* the server, padded responses). If the service cannot be reached the
|
||||
* password is accepted and a warning logged - a third-party outage must not
|
||||
* lock everyone out of changing passwords. PASSWORD_BREACH_CHECK=false turns
|
||||
* it off for offline deployments.
|
||||
*/
|
||||
export async function assertPasswordNotBreached(
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
if (!breachCheckEnabled()) {
|
||||
return;
|
||||
}
|
||||
const sha1 = crypto
|
||||
.createHash('sha1')
|
||||
.update(password)
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
const prefix = sha1.slice(0, 5);
|
||||
const suffix = sha1.slice(5);
|
||||
let body: string;
|
||||
try {
|
||||
const response = await fetch(`${PWNED_RANGE_URL}${prefix}`, {
|
||||
headers: { 'Add-Padding': 'true', 'User-Agent': 'ApiCore' },
|
||||
signal: AbortSignal.timeout(BREACH_CHECK_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
body = await response.text();
|
||||
} catch (error: unknown) {
|
||||
logger.warn(
|
||||
`Breach check unavailable, password accepted: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const breached = body.split('\n').some((line) => {
|
||||
const [hashSuffix, count] = line.trim().split(':');
|
||||
return hashSuffix === suffix && Number(count) > 0;
|
||||
});
|
||||
if (breached) {
|
||||
throw new BadRequestException(
|
||||
'Dieses Passwort ist aus Datenlecks bekannt und daher unsicher. Bitte ein anderes wählen.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* Emails are compared case-insensitively (see UsersService.findByEmail);
|
||||
* storing them trimmed and lower-case keeps "[email protected]" and "[email protected]"
|
||||
* from becoming two accounts.
|
||||
*/
|
||||
export function NormalizeEmail(): PropertyDecorator {
|
||||
return Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
PDF_MARGIN,
|
||||
drawSection,
|
||||
drawTable,
|
||||
fillWidth,
|
||||
formatDay,
|
||||
orDash,
|
||||
} from './pdf-table';
|
||||
|
||||
/**
|
||||
* Stockkarte: the complete written record of one colony - master data, queen,
|
||||
* every inspection as a table row, treatments, feedings, varroa counts and
|
||||
* harvests. For the folder at the apiary or to hand over with a sold colony.
|
||||
* Whole history by default, or one calendar year.
|
||||
*/
|
||||
|
||||
const COLONY_TYPE_LABELS: Record<string, string> = {
|
||||
PRODUCTION_COLONY: 'Wirtschaftsvolk',
|
||||
NUCLEUS_COLONY: 'Ableger',
|
||||
YOUNG_COLONY: 'Jungvolk',
|
||||
CARETAKER_COLONY: 'Pflegevolk',
|
||||
MATING_NUCLEUS: 'Begattungseinheit',
|
||||
RESERVE_COLONY: 'Reservevolk',
|
||||
};
|
||||
|
||||
const COLONY_STATUS_LABELS: Record<string, string> = {
|
||||
ACTIVE: 'Aktiv',
|
||||
WEAK: 'Schwach',
|
||||
CONSPICUOUS: 'Auffällig',
|
||||
QUEENLESS: 'Weisellos',
|
||||
DISSOLVED: 'Aufgelöst',
|
||||
SOLD: 'Verkauft',
|
||||
DECEASED: 'Eingegangen',
|
||||
MERGED: 'Vereinigt',
|
||||
};
|
||||
|
||||
const FEED_TYPE_LABELS: Record<string, string> = {
|
||||
SUGAR_WATER: 'Zuckerwasser',
|
||||
FEED_SYRUP: 'Futtersirup',
|
||||
FEED_PASTE: 'Futterteig',
|
||||
OWN_HONEY: 'Eigener Honig',
|
||||
OTHER: 'Sonstiges',
|
||||
};
|
||||
|
||||
const VARROA_METHOD_LABELS: Record<string, string> = {
|
||||
NATURAL_MITE_FALL: 'Natürlicher Milbenfall',
|
||||
POWDERED_SUGAR: 'Puderzucker',
|
||||
WASHING_METHOD: 'Auswaschen',
|
||||
DRONE_BROOD_CONTROL: 'Drohnenbrut',
|
||||
};
|
||||
|
||||
export interface ColonyRecordCard {
|
||||
generatedAt: Date;
|
||||
year: number | null;
|
||||
colony: {
|
||||
code: string;
|
||||
colonyType: string;
|
||||
status: string;
|
||||
hiveType: string | null;
|
||||
originText: string | null;
|
||||
createdAt: Date;
|
||||
apiaryName: string;
|
||||
apiaryLocation: string | null;
|
||||
};
|
||||
currentQueen: {
|
||||
code: string;
|
||||
hatchYear: number | null;
|
||||
markingColor: string | null;
|
||||
breed: string | null;
|
||||
origin: string | null;
|
||||
} | null;
|
||||
queenHistory: { code: string; startDate: Date; endDate: Date | null }[];
|
||||
inspections: {
|
||||
date: Date;
|
||||
colonyStrength: number | null;
|
||||
openBroodPresent: boolean | null;
|
||||
cappedBroodPresent: boolean | null;
|
||||
eggsPresent: boolean | null;
|
||||
queenSeen: boolean | null;
|
||||
queenCellsPresent: boolean | null;
|
||||
gentleness: number | null;
|
||||
swarmMood: number | null;
|
||||
feedStatus: string | null;
|
||||
measures: string | null;
|
||||
notes: string | null;
|
||||
}[];
|
||||
treatments: {
|
||||
date: Date;
|
||||
endDate: Date | null;
|
||||
product: string;
|
||||
activeIngredient: string | null;
|
||||
amount: string | null;
|
||||
batchNumber: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
}[];
|
||||
feedings: {
|
||||
date: Date;
|
||||
feedType: string;
|
||||
product: string | null;
|
||||
amount: number;
|
||||
unit: string;
|
||||
}[];
|
||||
varroaMeasurements: {
|
||||
date: Date;
|
||||
method: string;
|
||||
miteCount: number | null;
|
||||
calculatedValue: number | null;
|
||||
}[];
|
||||
harvests: {
|
||||
date: Date;
|
||||
honeyType: string | null;
|
||||
netWeight: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
function dateFilter(year: number | null) {
|
||||
return year === null
|
||||
? undefined
|
||||
: {
|
||||
gte: new Date(Date.UTC(year, 0, 1)),
|
||||
lt: new Date(Date.UTC(year + 1, 0, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildColonyRecordCard(
|
||||
prisma: PrismaService,
|
||||
colonyId: string,
|
||||
year: number | null,
|
||||
): Promise<ColonyRecordCard> {
|
||||
const colony = await prisma.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
include: { apiary: true, currentQueen: true },
|
||||
});
|
||||
if (!colony) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
const date = dateFilter(year);
|
||||
const [
|
||||
inspections,
|
||||
treatments,
|
||||
feedings,
|
||||
varroa,
|
||||
harvestLinks,
|
||||
queenHistory,
|
||||
] = await Promise.all([
|
||||
prisma.inspection.findMany({
|
||||
where: { colonyId, date },
|
||||
orderBy: { date: 'asc' },
|
||||
}),
|
||||
prisma.treatment.findMany({
|
||||
where: { colonyId, date },
|
||||
orderBy: { date: 'asc' },
|
||||
}),
|
||||
prisma.feeding.findMany({
|
||||
where: { colonyId, date },
|
||||
orderBy: { date: 'asc' },
|
||||
}),
|
||||
prisma.varroaMeasurement.findMany({
|
||||
where: { colonyId, date },
|
||||
orderBy: { date: 'asc' },
|
||||
}),
|
||||
prisma.harvestColony.findMany({
|
||||
where: { colonyId, harvest: { date } },
|
||||
include: { harvest: true },
|
||||
orderBy: { harvest: { date: 'asc' } },
|
||||
}),
|
||||
prisma.queenColonyHistory.findMany({
|
||||
where: { colonyId },
|
||||
include: { queen: true },
|
||||
orderBy: { startDate: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const queen = colony.currentQueen;
|
||||
return {
|
||||
generatedAt: new Date(),
|
||||
year,
|
||||
colony: {
|
||||
code: colony.code,
|
||||
colonyType: colony.colonyType,
|
||||
status: colony.status,
|
||||
hiveType: colony.hiveType,
|
||||
originText: colony.originText,
|
||||
createdAt: colony.createdAt,
|
||||
apiaryName: colony.apiary.name,
|
||||
apiaryLocation: colony.apiary.location,
|
||||
},
|
||||
currentQueen: queen
|
||||
? {
|
||||
code: queen.code,
|
||||
hatchYear: queen.hatchYear,
|
||||
markingColor: queen.markingColor,
|
||||
breed: queen.breed,
|
||||
origin: queen.origin,
|
||||
}
|
||||
: null,
|
||||
queenHistory: queenHistory.map((entry) => ({
|
||||
code: entry.queen.code,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
})),
|
||||
inspections: inspections.map((i) => ({
|
||||
date: i.date,
|
||||
colonyStrength: i.colonyStrength,
|
||||
openBroodPresent: i.openBroodPresent,
|
||||
cappedBroodPresent: i.cappedBroodPresent,
|
||||
eggsPresent: i.eggsPresent,
|
||||
queenSeen: i.queenSeen,
|
||||
queenCellsPresent: i.queenCellsPresent,
|
||||
gentleness: i.gentleness,
|
||||
swarmMood: i.swarmMood,
|
||||
feedStatus: i.feedStatus,
|
||||
measures: i.measures,
|
||||
notes: i.notes,
|
||||
})),
|
||||
treatments: treatments.map((t) => ({
|
||||
date: t.date,
|
||||
endDate: t.endDate,
|
||||
product: t.product,
|
||||
activeIngredient: t.activeIngredient,
|
||||
amount: t.amount,
|
||||
batchNumber: t.batchNumber,
|
||||
withdrawalPeriodDays: t.withdrawalPeriodDays,
|
||||
})),
|
||||
feedings: feedings.map((f) => ({
|
||||
date: f.date,
|
||||
feedType: f.feedType,
|
||||
product: f.product,
|
||||
amount: f.amount,
|
||||
unit: f.unit,
|
||||
})),
|
||||
varroaMeasurements: varroa.map((v) => ({
|
||||
date: v.date,
|
||||
method: v.method,
|
||||
miteCount: v.miteCount,
|
||||
calculatedValue: v.calculatedValue,
|
||||
})),
|
||||
harvests: harvestLinks.map((link) => ({
|
||||
date: link.harvest.date,
|
||||
honeyType: link.harvest.honeyType,
|
||||
netWeight: link.harvest.netWeight,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MARGIN = PDF_MARGIN;
|
||||
const day = formatDay;
|
||||
const section = drawSection;
|
||||
|
||||
function yesNo(value: boolean | null): string {
|
||||
return value === null ? '–' : value ? 'ja' : 'nein';
|
||||
}
|
||||
|
||||
export function renderColonyRecordCardPdf(
|
||||
card: ColonyRecordCard,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Landscape: the inspection table needs the width.
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: 'landscape',
|
||||
margin: MARGIN,
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
const { colony } = card;
|
||||
doc
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(16)
|
||||
.text(`Stockkarte ${colony.code}`, MARGIN, MARGIN);
|
||||
doc
|
||||
.font('Helvetica')
|
||||
.fontSize(9)
|
||||
.text(
|
||||
`${card.year === null ? 'Gesamte Historie' : `Jahr ${card.year}`} · erstellt am ${day(card.generatedAt)}`,
|
||||
);
|
||||
doc.moveDown(0.5);
|
||||
|
||||
doc
|
||||
.fontSize(9)
|
||||
.text(
|
||||
[
|
||||
`Stand: ${colony.apiaryName}${colony.apiaryLocation ? ` (${colony.apiaryLocation})` : ''}`,
|
||||
`Art: ${COLONY_TYPE_LABELS[colony.colonyType] ?? colony.colonyType}`,
|
||||
`Status: ${COLONY_STATUS_LABELS[colony.status] ?? colony.status}`,
|
||||
`Beute: ${orDash(colony.hiveType)}`,
|
||||
`Herkunft: ${orDash(colony.originText)}`,
|
||||
`Erfasst seit: ${day(colony.createdAt)}`,
|
||||
].join(' · '),
|
||||
);
|
||||
|
||||
section(doc, 'Königin');
|
||||
const queen = card.currentQueen;
|
||||
doc
|
||||
.font('Helvetica')
|
||||
.fontSize(9)
|
||||
.text(
|
||||
queen
|
||||
? `${queen.code} · Schlupfjahr ${orDash(queen.hatchYear)} · Zeichen ${orDash(queen.markingColor)} · Rasse ${orDash(queen.breed)} · Herkunft ${orDash(queen.origin)}`
|
||||
: 'Keine Königin zugeordnet.',
|
||||
MARGIN,
|
||||
);
|
||||
if (card.queenHistory.length > 0) {
|
||||
doc.text(
|
||||
`Verlauf: ${card.queenHistory
|
||||
.map(
|
||||
(q) =>
|
||||
`${q.code} (${day(q.startDate)} – ${q.endDate ? day(q.endDate) : 'heute'})`,
|
||||
)
|
||||
.join('; ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
section(doc, 'Durchsichten');
|
||||
drawTable(
|
||||
doc,
|
||||
fillWidth(doc, [
|
||||
{ header: 'Datum', width: 58 },
|
||||
{ header: 'Stärke (Waben)', width: 52 },
|
||||
{ header: 'Stifte', width: 36 },
|
||||
{ header: 'Offene Brut', width: 42 },
|
||||
{ header: 'Verd. Brut', width: 42 },
|
||||
{ header: 'Kö gesehen', width: 44 },
|
||||
{ header: 'Weiselzellen', width: 60 },
|
||||
{ header: 'Sanftmut 1–5', width: 44 },
|
||||
{ header: 'Schwarmtrieb 1–5', width: 66 },
|
||||
{ header: 'Futter', width: 56 },
|
||||
{ header: 'Maßnahmen', width: 126 },
|
||||
{ header: 'Notizen', width: 0 },
|
||||
]),
|
||||
card.inspections.map((i) => [
|
||||
day(i.date),
|
||||
orDash(i.colonyStrength),
|
||||
yesNo(i.eggsPresent),
|
||||
yesNo(i.openBroodPresent),
|
||||
yesNo(i.cappedBroodPresent),
|
||||
yesNo(i.queenSeen),
|
||||
yesNo(i.queenCellsPresent),
|
||||
orDash(i.gentleness),
|
||||
orDash(i.swarmMood),
|
||||
orDash(i.feedStatus),
|
||||
orDash(i.measures),
|
||||
orDash(i.notes),
|
||||
]),
|
||||
);
|
||||
|
||||
section(doc, 'Behandlungen');
|
||||
drawTable(
|
||||
doc,
|
||||
[
|
||||
{ header: 'Datum', width: 70 },
|
||||
{ header: 'Ende', width: 70 },
|
||||
{ header: 'Mittel', width: 150 },
|
||||
{ header: 'Wirkstoff', width: 120 },
|
||||
{ header: 'Menge', width: 100 },
|
||||
{ header: 'Chargen-Nr.', width: 100 },
|
||||
{ header: 'Wartezeit (Tage)', width: 80 },
|
||||
],
|
||||
card.treatments.map((t) => [
|
||||
day(t.date),
|
||||
t.endDate ? day(t.endDate) : '–',
|
||||
t.product,
|
||||
orDash(t.activeIngredient),
|
||||
orDash(t.amount),
|
||||
orDash(t.batchNumber),
|
||||
orDash(t.withdrawalPeriodDays),
|
||||
]),
|
||||
);
|
||||
|
||||
section(doc, 'Fütterungen');
|
||||
drawTable(
|
||||
doc,
|
||||
[
|
||||
{ header: 'Datum', width: 70 },
|
||||
{ header: 'Futterart', width: 120 },
|
||||
{ header: 'Produkt', width: 200 },
|
||||
{ header: 'Menge', width: 100 },
|
||||
],
|
||||
card.feedings.map((f) => [
|
||||
day(f.date),
|
||||
FEED_TYPE_LABELS[f.feedType] ?? f.feedType,
|
||||
orDash(f.product),
|
||||
`${f.amount} ${f.unit}`,
|
||||
]),
|
||||
);
|
||||
|
||||
section(doc, 'Varroa');
|
||||
drawTable(
|
||||
doc,
|
||||
[
|
||||
{ header: 'Datum', width: 70 },
|
||||
{ header: 'Methode', width: 150 },
|
||||
{ header: 'Milben gezählt', width: 90 },
|
||||
{ header: 'Berechneter Wert', width: 100 },
|
||||
],
|
||||
card.varroaMeasurements.map((v) => [
|
||||
day(v.date),
|
||||
VARROA_METHOD_LABELS[v.method] ?? v.method,
|
||||
orDash(v.miteCount),
|
||||
v.calculatedValue === null ? '–' : v.calculatedValue.toFixed(1),
|
||||
]),
|
||||
);
|
||||
|
||||
section(doc, 'Ernten');
|
||||
drawTable(
|
||||
doc,
|
||||
[
|
||||
{ header: 'Datum', width: 70 },
|
||||
{ header: 'Sorte', width: 200 },
|
||||
{ header: 'Netto (kg, gesamte Ernte)', width: 140 },
|
||||
],
|
||||
card.harvests.map((h) => [
|
||||
day(h.date),
|
||||
orDash(h.honeyType),
|
||||
orDash(h.netWeight),
|
||||
]),
|
||||
);
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { toCsvField } from './export.service';
|
||||
|
||||
describe('toCsvField', () => {
|
||||
it('neutralizes cells that a spreadsheet would run as a formula', () => {
|
||||
expect(toCsvField('=HYPERLINK("http://evil","x")')).toBe(
|
||||
`"'=HYPERLINK(""http://evil"",""x"")"`,
|
||||
);
|
||||
expect(toCsvField('+1+1')).toBe("'+1+1");
|
||||
expect(toCsvField('-2')).toBe("'-2");
|
||||
expect(toCsvField('@SUM(A1)')).toBe("'@SUM(A1)");
|
||||
});
|
||||
|
||||
it('keeps numbers, including negative ones, as numbers', () => {
|
||||
expect(toCsvField(-2.5)).toBe('-2.5');
|
||||
expect(toCsvField(0)).toBe('0');
|
||||
});
|
||||
|
||||
it('quotes carriage returns too', () => {
|
||||
expect(toCsvField('a\rb')).toBe('"a\rb"');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import {
|
||||
buildColonyRecordCard,
|
||||
renderColonyRecordCardPdf,
|
||||
} from './colony-record-card';
|
||||
import { buildTreatmentLog, renderTreatmentLogPdf } from './treatment-log';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
|
||||
import {
|
||||
@@ -22,6 +27,8 @@ const PDF_REPORTS = [
|
||||
'honey-batch-traceability',
|
||||
'colony-annual-report',
|
||||
'apiary-annual-report',
|
||||
'colony-record-card',
|
||||
'treatment-log',
|
||||
] as const;
|
||||
type PdfReport = (typeof PDF_REPORTS)[number];
|
||||
|
||||
@@ -64,12 +71,20 @@ function stringifyCsvValue(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function toCsvField(value: unknown): string {
|
||||
// A text cell starting with one of these is run as a formula by Excel and
|
||||
// LibreOffice (=HYPERLINK(...), DDE). Typed-in values such as a colony code
|
||||
// are prefixed with ' so they show as text; real numbers are not strings
|
||||
// and keep their sign.
|
||||
const FORMULA_TRIGGER = /^[=+\-@\t\r]/;
|
||||
|
||||
export function toCsvField(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
const text = stringifyCsvValue(value);
|
||||
if (/[",\n]/.test(text)) {
|
||||
const raw = stringifyCsvValue(value);
|
||||
const text =
|
||||
typeof value === 'string' && FORMULA_TRIGGER.test(raw) ? `'${raw}` : raw;
|
||||
if (/[",\r\n]/.test(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return text;
|
||||
@@ -188,6 +203,28 @@ export class ExportService {
|
||||
);
|
||||
return renderApiaryAnnualReportPdf(apiaryReport);
|
||||
}
|
||||
case 'colony-record-card': {
|
||||
if (!options.colonyId) {
|
||||
throw new BadRequestException(
|
||||
'Query parameter colonyId is required for this report',
|
||||
);
|
||||
}
|
||||
// Without a year the record card covers the colony's whole history.
|
||||
const card = await buildColonyRecordCard(
|
||||
this.prisma,
|
||||
options.colonyId,
|
||||
options.year ? resolveYear(options.year) : null,
|
||||
);
|
||||
return renderColonyRecordCardPdf(card);
|
||||
}
|
||||
case 'treatment-log': {
|
||||
const log = await buildTreatmentLog(
|
||||
this.prisma,
|
||||
resolveYear(options.year),
|
||||
options.apiaryId,
|
||||
);
|
||||
return renderTreatmentLogPdf(log);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Small pdfkit helpers shared by the printable records (Stockkarte,
|
||||
* Bestandsbuch): dates, empty values and a table that repeats its header on
|
||||
* every page.
|
||||
*/
|
||||
|
||||
export const PDF_MARGIN = 36;
|
||||
|
||||
export interface PdfColumn {
|
||||
header: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function formatDay(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
export function orDash(value: string | number | null | undefined): string {
|
||||
return value === null || value === undefined || value === ''
|
||||
? '–'
|
||||
: String(value);
|
||||
}
|
||||
|
||||
/** Widens the last column so the table fills the page width. */
|
||||
export function fillWidth(
|
||||
doc: PDFKit.PDFDocument,
|
||||
columns: PdfColumn[],
|
||||
): PdfColumn[] {
|
||||
const fixed = columns.slice(0, -1).reduce((sum, c) => sum + c.width, 0);
|
||||
const last = columns[columns.length - 1];
|
||||
return [
|
||||
...columns.slice(0, -1),
|
||||
{ ...last, width: doc.page.width - 2 * PDF_MARGIN - fixed },
|
||||
];
|
||||
}
|
||||
|
||||
export function drawSection(doc: PDFKit.PDFDocument, title: string): void {
|
||||
if (doc.y > doc.page.height - PDF_MARGIN - 80) doc.addPage();
|
||||
doc.moveDown(0.6);
|
||||
doc
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(11)
|
||||
.fillColor('#000000')
|
||||
.text(title, PDF_MARGIN);
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
|
||||
/** Draws a simple table, repeating the header on every new page. */
|
||||
export function drawTable(
|
||||
doc: PDFKit.PDFDocument,
|
||||
columns: PdfColumn[],
|
||||
rows: string[][],
|
||||
): void {
|
||||
const left = PDF_MARGIN;
|
||||
const bottomLimit = doc.page.height - PDF_MARGIN - 20;
|
||||
|
||||
const drawRow = (cells: string[], bold: boolean) => {
|
||||
doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
|
||||
const heights = cells.map((cell, index) =>
|
||||
doc.heightOfString(cell, { width: columns[index].width - 4 }),
|
||||
);
|
||||
const rowHeight = Math.max(...heights, 10) + 4;
|
||||
if (doc.y + rowHeight > bottomLimit) {
|
||||
doc.addPage();
|
||||
if (!bold) {
|
||||
drawRow(
|
||||
columns.map((c) => c.header),
|
||||
true,
|
||||
);
|
||||
doc.font('Helvetica').fontSize(8);
|
||||
}
|
||||
}
|
||||
const top = doc.y;
|
||||
let x = left;
|
||||
cells.forEach((cell, index) => {
|
||||
doc.text(cell, x + 2, top + 2, { width: columns[index].width - 4 });
|
||||
x += columns[index].width;
|
||||
});
|
||||
doc
|
||||
.moveTo(left, top + rowHeight)
|
||||
.lineTo(x, top + rowHeight)
|
||||
.lineWidth(0.3)
|
||||
.strokeColor('#999999')
|
||||
.stroke();
|
||||
doc.y = top + rowHeight;
|
||||
};
|
||||
|
||||
drawRow(
|
||||
columns.map((c) => c.header),
|
||||
true,
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
doc
|
||||
.font('Helvetica-Oblique')
|
||||
.fontSize(8)
|
||||
.text('Keine Einträge.', left + 2, doc.y + 2);
|
||||
doc.moveDown(0.5);
|
||||
return;
|
||||
}
|
||||
for (const row of rows) drawRow(row, false);
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
apiaryOnDate,
|
||||
groupTreatmentLog,
|
||||
TreatmentLogSource,
|
||||
} from './treatment-log';
|
||||
|
||||
const d = (value: string) => new Date(`${value}T00:00:00.000Z`);
|
||||
|
||||
function source(overrides: Partial<TreatmentLogSource>): TreatmentLogSource {
|
||||
return {
|
||||
id: 't',
|
||||
colonyId: 'c1',
|
||||
colonyCode: 'V-01',
|
||||
colonyApiaryId: 'today',
|
||||
apiaryId: null,
|
||||
date: d('2026-08-01'),
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
product: 'Oxalsäure',
|
||||
amount: '30 ml',
|
||||
batchNumber: 'B1',
|
||||
supplier: 'Imkereibedarf X',
|
||||
purchaseProof: 'Rechnung 1',
|
||||
prescribingVet: null,
|
||||
withdrawalPeriodDays: 0,
|
||||
administeredBy: 'Imker',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('apiaryOnDate', () => {
|
||||
it('uses the apiary stored with the treatment', () => {
|
||||
expect(apiaryOnDate(source({ apiaryId: 'stored' }), [])).toEqual({
|
||||
apiaryId: 'stored',
|
||||
uncertain: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the last move on or before the treatment day', () => {
|
||||
const moves = [
|
||||
{ colonyId: 'c1', apiaryId: 'summer', movedAt: d('2026-05-01') },
|
||||
{ colonyId: 'c1', apiaryId: 'winter', movedAt: d('2026-10-01') },
|
||||
];
|
||||
expect(apiaryOnDate(source({}), moves)).toEqual({
|
||||
apiaryId: 'summer',
|
||||
uncertain: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('flags the location when the colony was only moved after the treatment', () => {
|
||||
const moves = [
|
||||
{ colonyId: 'c1', apiaryId: 'today', movedAt: d('2026-09-01') },
|
||||
];
|
||||
expect(apiaryOnDate(source({}), moves)).toEqual({
|
||||
apiaryId: 'today',
|
||||
uncertain: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('trusts today when the colony was never moved', () => {
|
||||
expect(apiaryOnDate(source({}), [])).toEqual({
|
||||
apiaryId: 'today',
|
||||
uncertain: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupTreatmentLog', () => {
|
||||
it('puts identical treatments of several colonies into one row, per apiary', () => {
|
||||
const sheets = groupTreatmentLog(
|
||||
[
|
||||
source({ id: '1', colonyId: 'c1', colonyCode: 'V-02', apiaryId: 'a' }),
|
||||
source({ id: '2', colonyId: 'c2', colonyCode: 'V-01', apiaryId: 'a' }),
|
||||
source({ id: '3', colonyId: 'c3', colonyCode: 'V-09', apiaryId: 'b' }),
|
||||
source({
|
||||
id: '4',
|
||||
colonyId: 'c4',
|
||||
colonyCode: 'V-03',
|
||||
apiaryId: 'a',
|
||||
amount: '50 ml',
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const sheetA = sheets.find((sheet) => sheet.apiaryId === 'a');
|
||||
expect(sheetA?.rows).toHaveLength(2);
|
||||
expect(sheetA?.rows[0].colonyCodes).toEqual(['V-01', 'V-02']);
|
||||
expect(sheets.find((sheet) => sheet.apiaryId === 'b')?.rows).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('computes the treatment duration in days, inclusive', () => {
|
||||
const [sheet] = groupTreatmentLog(
|
||||
[
|
||||
source({
|
||||
apiaryId: 'a',
|
||||
startDate: d('2026-08-01'),
|
||||
endDate: d('2026-08-14'),
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(sheet.rows[0].durationDays).toBe(14);
|
||||
});
|
||||
|
||||
it('keeps missing mandatory fields empty so the PDF can flag them', () => {
|
||||
const [sheet] = groupTreatmentLog(
|
||||
[
|
||||
source({
|
||||
apiaryId: 'a',
|
||||
supplier: null,
|
||||
purchaseProof: null,
|
||||
withdrawalPeriodDays: null,
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(sheet.rows[0]).toMatchObject({
|
||||
supplier: null,
|
||||
purchaseProof: null,
|
||||
withdrawalPeriodDays: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
PDF_MARGIN,
|
||||
drawTable,
|
||||
fillWidth,
|
||||
formatDay,
|
||||
orDash,
|
||||
} from './pdf-table';
|
||||
|
||||
/**
|
||||
* Bestandsbuch: record of every veterinary medicine applied to bees, as
|
||||
* required for food-producing animals by Art. 108 Regulation (EU) 2019/6 -
|
||||
* for every product, including over-the-counter formic or oxalic acid. The
|
||||
* columns follow the model sheet of the German veterinary authorities
|
||||
* (Veterinäramt Kassel, "Buchführung über die Anwendung von TAM bei
|
||||
* Bienen", March 2026): one sheet per apiary; colonies, product, supplier
|
||||
* and purchase proof, date and duration, amount, withdrawal period (also
|
||||
* when 0), administering person. Keep 5 years, as well as purchase proofs.
|
||||
*
|
||||
* Missing mandatory information is printed as "FEHLT" so it can be added
|
||||
* before an inspection, instead of silently leaving a gap.
|
||||
*/
|
||||
|
||||
export const MISSING = 'FEHLT';
|
||||
|
||||
export interface TreatmentLogSource {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
colonyCode: string;
|
||||
colonyApiaryId: string;
|
||||
/** Apiary recorded with the treatment (set since this report exists). */
|
||||
apiaryId: string | null;
|
||||
date: Date;
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
product: string;
|
||||
amount: string | null;
|
||||
batchNumber: string | null;
|
||||
supplier: string | null;
|
||||
purchaseProof: string | null;
|
||||
prescribingVet: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
administeredBy: string;
|
||||
}
|
||||
|
||||
export interface ColonyMove {
|
||||
colonyId: string;
|
||||
apiaryId: string;
|
||||
movedAt: Date;
|
||||
}
|
||||
|
||||
export interface TreatmentLogRow {
|
||||
colonyCodes: string[];
|
||||
product: string;
|
||||
batchNumber: string | null;
|
||||
supplier: string | null;
|
||||
prescribingVet: string | null;
|
||||
purchaseProof: string | null;
|
||||
date: Date;
|
||||
endDate: Date | null;
|
||||
durationDays: number;
|
||||
amount: string | null;
|
||||
withdrawalPeriodDays: number | null;
|
||||
administeredBy: string;
|
||||
/** Apiary derived from today's assignment, not known for that date. */
|
||||
locationUncertain: boolean;
|
||||
}
|
||||
|
||||
export interface TreatmentLogSheet {
|
||||
apiaryId: string;
|
||||
rows: TreatmentLogRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the colony stood on the day of the treatment: the apiary stored with
|
||||
* the treatment, else the last recorded move on or before that day. Before
|
||||
* the first recorded move the original apiary is not known - then today's
|
||||
* apiary is used and the row is flagged.
|
||||
*/
|
||||
export function apiaryOnDate(
|
||||
source: TreatmentLogSource,
|
||||
moves: ColonyMove[],
|
||||
): { apiaryId: string; uncertain: boolean } {
|
||||
if (source.apiaryId) return { apiaryId: source.apiaryId, uncertain: false };
|
||||
const colonyMoves = moves
|
||||
.filter((move) => move.colonyId === source.colonyId)
|
||||
.sort((a, b) => a.movedAt.getTime() - b.movedAt.getTime());
|
||||
const before = colonyMoves.filter(
|
||||
(move) => move.movedAt.getTime() <= source.date.getTime(),
|
||||
);
|
||||
if (before.length > 0) {
|
||||
return { apiaryId: before[before.length - 1].apiaryId, uncertain: false };
|
||||
}
|
||||
return { apiaryId: source.colonyApiaryId, uncertain: colonyMoves.length > 0 };
|
||||
}
|
||||
|
||||
function durationDays(start: Date, end: Date | null): number {
|
||||
if (!end) return 1;
|
||||
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
|
||||
return Math.max(days, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* One sheet per apiary; treatments that are identical except for the colony
|
||||
* (same day, product, batch, amount, supplier, person) become one row listing
|
||||
* all colonies - as in the paper form.
|
||||
*/
|
||||
export function groupTreatmentLog(
|
||||
sources: TreatmentLogSource[],
|
||||
moves: ColonyMove[],
|
||||
): TreatmentLogSheet[] {
|
||||
const sheets = new Map<string, Map<string, TreatmentLogRow>>();
|
||||
const sorted = [...sources].sort(
|
||||
(a, b) =>
|
||||
a.date.getTime() - b.date.getTime() ||
|
||||
a.colonyCode.localeCompare(b.colonyCode, 'de', { numeric: true }),
|
||||
);
|
||||
|
||||
for (const source of sorted) {
|
||||
const { apiaryId, uncertain } = apiaryOnDate(source, moves);
|
||||
const start = source.startDate ?? source.date;
|
||||
const key = [
|
||||
source.date.toISOString(),
|
||||
source.endDate?.toISOString() ?? '',
|
||||
source.product,
|
||||
source.batchNumber ?? '',
|
||||
source.amount ?? '',
|
||||
source.supplier ?? '',
|
||||
source.purchaseProof ?? '',
|
||||
source.prescribingVet ?? '',
|
||||
source.withdrawalPeriodDays ?? '',
|
||||
source.administeredBy,
|
||||
uncertain,
|
||||
].join('|');
|
||||
|
||||
const rows = sheets.get(apiaryId) ?? new Map<string, TreatmentLogRow>();
|
||||
const existing = rows.get(key);
|
||||
if (existing) {
|
||||
existing.colonyCodes.push(source.colonyCode);
|
||||
} else {
|
||||
rows.set(key, {
|
||||
colonyCodes: [source.colonyCode],
|
||||
product: source.product,
|
||||
batchNumber: source.batchNumber,
|
||||
supplier: source.supplier,
|
||||
prescribingVet: source.prescribingVet,
|
||||
purchaseProof: source.purchaseProof,
|
||||
date: source.date,
|
||||
endDate: source.endDate,
|
||||
durationDays: durationDays(start, source.endDate),
|
||||
amount: source.amount,
|
||||
withdrawalPeriodDays: source.withdrawalPeriodDays,
|
||||
administeredBy: source.administeredBy,
|
||||
locationUncertain: uncertain,
|
||||
});
|
||||
}
|
||||
sheets.set(apiaryId, rows);
|
||||
}
|
||||
|
||||
return [...sheets.entries()].map(([apiaryId, rows]) => ({
|
||||
apiaryId,
|
||||
rows: [...rows.values()],
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading and rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TreatmentLog {
|
||||
year: number;
|
||||
generatedAt: Date;
|
||||
holderName: string | null;
|
||||
holderAddress: string | null;
|
||||
sheets: (TreatmentLogSheet & {
|
||||
apiaryName: string;
|
||||
apiaryLocation: string | null;
|
||||
})[];
|
||||
}
|
||||
|
||||
export async function buildTreatmentLog(
|
||||
prisma: PrismaService,
|
||||
year: number,
|
||||
apiaryId?: string,
|
||||
): Promise<TreatmentLog> {
|
||||
const treatments = await prisma.treatment.findMany({
|
||||
where: {
|
||||
date: {
|
||||
gte: new Date(Date.UTC(year, 0, 1)),
|
||||
lt: new Date(Date.UTC(year + 1, 0, 1)),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
colony: { select: { code: true, apiaryId: true } },
|
||||
user: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const colonyIds = [...new Set(treatments.map((t) => t.colonyId))];
|
||||
const [moves, settings, apiaries] = await Promise.all([
|
||||
prisma.colonyApiaryHistory.findMany({
|
||||
where: { colonyId: { in: colonyIds } },
|
||||
select: { colonyId: true, apiaryId: true, movedAt: true },
|
||||
}),
|
||||
prisma.siteSettings.findUnique({ where: { id: 'singleton' } }),
|
||||
prisma.apiary.findMany({
|
||||
select: { id: true, name: true, location: true, address: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const sheets = groupTreatmentLog(
|
||||
treatments.map((t) => ({
|
||||
id: t.id,
|
||||
colonyId: t.colonyId,
|
||||
colonyCode: t.colony.code,
|
||||
colonyApiaryId: t.colony.apiaryId,
|
||||
apiaryId: t.apiaryId,
|
||||
date: t.date,
|
||||
startDate: t.startDate,
|
||||
endDate: t.endDate,
|
||||
product: t.product,
|
||||
amount: t.amount,
|
||||
batchNumber: t.batchNumber,
|
||||
supplier: t.supplier,
|
||||
purchaseProof: t.purchaseProof,
|
||||
prescribingVet: t.prescribingVet,
|
||||
withdrawalPeriodDays: t.withdrawalPeriodDays,
|
||||
administeredBy: t.user.name,
|
||||
})),
|
||||
moves,
|
||||
).filter((sheet) => !apiaryId || sheet.apiaryId === apiaryId);
|
||||
|
||||
const apiaryById = new Map(apiaries.map((a) => [a.id, a]));
|
||||
return {
|
||||
year,
|
||||
generatedAt: new Date(),
|
||||
holderName: settings?.holderName ?? null,
|
||||
holderAddress: settings?.holderAddress ?? null,
|
||||
sheets: sheets
|
||||
.map((sheet) => {
|
||||
const apiary = apiaryById.get(sheet.apiaryId);
|
||||
return {
|
||||
...sheet,
|
||||
apiaryName: apiary?.name ?? 'Unbekannter Stand',
|
||||
apiaryLocation: apiary?.address ?? apiary?.location ?? null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de')),
|
||||
};
|
||||
}
|
||||
|
||||
function orMissing(value: string | number | null | undefined): string {
|
||||
return value === null || value === undefined || value === ''
|
||||
? MISSING
|
||||
: String(value);
|
||||
}
|
||||
|
||||
function renderSheet(
|
||||
doc: PDFKit.PDFDocument,
|
||||
log: TreatmentLog,
|
||||
sheet: TreatmentLog['sheets'][number] | null,
|
||||
): void {
|
||||
doc
|
||||
.font('Helvetica-Bold')
|
||||
.fontSize(14)
|
||||
.text(
|
||||
'Buchführung (Bestandsbuch) über die Anwendung von Tierarzneimitteln bei Bienen',
|
||||
PDF_MARGIN,
|
||||
PDF_MARGIN,
|
||||
);
|
||||
doc.font('Helvetica').fontSize(9);
|
||||
doc.text(
|
||||
`Jahr ${log.year} · erstellt am ${formatDay(log.generatedAt)} · Art. 108 VO (EU) 2019/6`,
|
||||
);
|
||||
doc.moveDown(0.4);
|
||||
doc.text(
|
||||
`Name und Anschrift des Bienenhalters: ${
|
||||
log.holderName || log.holderAddress
|
||||
? [log.holderName, log.holderAddress].filter(Boolean).join(', ')
|
||||
: `${MISSING} (in ApiCore unter Verwaltung → Automatisierung → Stammdaten & Web eintragen)`
|
||||
}`,
|
||||
);
|
||||
if (sheet) {
|
||||
doc.text(
|
||||
`Standort der Bienen: ${sheet.apiaryName}${sheet.apiaryLocation ? `, ${sheet.apiaryLocation}` : ''}`,
|
||||
);
|
||||
}
|
||||
doc.moveDown(0.5);
|
||||
|
||||
if (!sheet) {
|
||||
doc.text(`Im Jahr ${log.year} wurden keine Behandlungen erfasst.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const uncertain = sheet.rows.some((row) => row.locationUncertain);
|
||||
drawTable(
|
||||
doc,
|
||||
fillWidth(doc, [
|
||||
{ header: 'Anzahl und Nummern der Völker', width: 110 },
|
||||
{ header: 'Arzneimittel (Handelsname), Ch.-B.', width: 120 },
|
||||
{ header: 'a) Lieferant (Name, Anschrift), ggf. Tierarzt', width: 150 },
|
||||
{ header: 'b) Beleg für den Erwerb', width: 110 },
|
||||
{ header: 'Datum der Verabreichung, Dauer', width: 90 },
|
||||
{ header: 'Verabreichte Menge', width: 70 },
|
||||
{ header: 'Wartezeit (Tage)', width: 50 },
|
||||
{ header: 'Anwendende Person', width: 0 },
|
||||
]),
|
||||
sheet.rows.map((row) => [
|
||||
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
|
||||
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
|
||||
`${orMissing(row.supplier)}${row.prescribingVet ? `; Tierarzt: ${row.prescribingVet}` : ''}`,
|
||||
orMissing(row.purchaseProof),
|
||||
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
|
||||
orMissing(row.amount),
|
||||
orMissing(row.withdrawalPeriodDays),
|
||||
orDash(row.administeredBy),
|
||||
]),
|
||||
);
|
||||
doc.font('Helvetica').fontSize(7);
|
||||
if (uncertain) {
|
||||
doc.text(
|
||||
'* Standort aus der heutigen Zuordnung ermittelt: das Volk wurde später umgesetzt, der Stand am Behandlungstag ist nicht erfasst. Bitte prüfen.',
|
||||
PDF_MARGIN,
|
||||
);
|
||||
}
|
||||
doc.text(
|
||||
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung nachtragen. Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
|
||||
PDF_MARGIN,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderTreatmentLogPdf(log: TreatmentLog): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: 'landscape',
|
||||
margin: PDF_MARGIN,
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
if (log.sheets.length === 0) {
|
||||
renderSheet(doc, log, null);
|
||||
}
|
||||
log.sheets.forEach((sheet, index) => {
|
||||
if (index > 0) doc.addPage();
|
||||
renderSheet(doc, log, sheet);
|
||||
});
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
@@ -18,6 +19,7 @@ export class CreateExtractionDto {
|
||||
@ApiProperty({ type: [String] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
harvestIds!: string[];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateFeedingDto } from './dto/create-feeding.dto';
|
||||
import { UpdateFeedingDto } from './dto/update-feeding.dto';
|
||||
import { FeedingsService } from './feedings.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
|
||||
@ApiTags('feedings')
|
||||
@ApiBearerAuth()
|
||||
@@ -26,6 +27,7 @@ export class FeedingsController {
|
||||
}
|
||||
|
||||
@RequirePermission('feedings:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateFeedingDto,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationModule } from '../automation/automation.module';
|
||||
import { FeedingsController } from './feedings.controller';
|
||||
import { ColonyFeedingsController } from './colony-feedings.controller';
|
||||
import { FeedingsService } from './feedings.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationModule],
|
||||
controllers: [FeedingsController, ColonyFeedingsController],
|
||||
providers: [FeedingsService],
|
||||
exports: [FeedingsService],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { FeedingsService } from './feedings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -35,6 +36,7 @@ describe('FeedingsService', () => {
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
{ evaluateFeeding: jest.fn() } as unknown as AutomationService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
@@ -17,6 +18,7 @@ export class FeedingsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
private readonly automation: AutomationService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Feeding[]> {
|
||||
@@ -62,6 +64,7 @@ export class FeedingsService {
|
||||
afterData: feeding,
|
||||
});
|
||||
await this.calendarSync.enqueue('FEEDING', feeding.id);
|
||||
await this.automation.evaluateFeeding(feeding);
|
||||
return feeding;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
@@ -22,6 +23,7 @@ export class CreateHarvestDto {
|
||||
@ApiProperty({ type: [String] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
colonyIds!: string[];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateHarvestDto } from './dto/create-harvest.dto';
|
||||
import { UpdateHarvestDto } from './dto/update-harvest.dto';
|
||||
import { HarvestsService } from './harvests.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
|
||||
@ApiTags('harvests')
|
||||
@ApiBearerAuth()
|
||||
@@ -26,6 +27,7 @@ export class HarvestsController {
|
||||
}
|
||||
|
||||
@RequirePermission('harvests:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateHarvestDto,
|
||||
|
||||
@@ -55,7 +55,20 @@ export class HarvestsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Net weight is gross weight minus frames and boxes - it cannot be larger.
|
||||
private assertWeightsPlausible(
|
||||
grossWeight: number | null | undefined,
|
||||
netWeight: number | null | undefined,
|
||||
): void {
|
||||
if (grossWeight != null && netWeight != null && netWeight > grossWeight) {
|
||||
throw new BadRequestException(
|
||||
'Das Nettogewicht kann nicht größer als das Bruttogewicht sein',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateHarvestDto, actingUserId: string) {
|
||||
this.assertWeightsPlausible(dto.grossWeight, dto.netWeight);
|
||||
await this.assertColoniesExist(dto.colonyIds);
|
||||
const harvest = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.harvest.create({
|
||||
@@ -93,6 +106,10 @@ export class HarvestsService {
|
||||
|
||||
async update(id: string, dto: UpdateHarvestDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
this.assertWeightsPlausible(
|
||||
dto.grossWeight !== undefined ? dto.grossWeight : before.grossWeight,
|
||||
dto.netWeight !== undefined ? dto.netWeight : before.netWeight,
|
||||
);
|
||||
const { colonyIds, ...scalarFields } = dto;
|
||||
if (colonyIds) {
|
||||
await this.assertColoniesExist(colonyIds);
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateInspectionDto {
|
||||
@@ -30,6 +32,7 @@ export class CreateInspectionDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
colonyStrength?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@@ -40,6 +43,8 @@ export class CreateInspectionDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
broodPattern?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@@ -70,16 +75,22 @@ export class CreateInspectionDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
swarmMood?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
gentleness?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
combBehavior?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CreateInspectionDto } from './dto/create-inspection.dto';
|
||||
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
||||
import { UpdateInspectionDto } from './dto/update-inspection.dto';
|
||||
import { InspectionsService } from './inspections.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
|
||||
@ApiTags('inspections')
|
||||
@ApiBearerAuth()
|
||||
@@ -27,6 +28,7 @@ export class InspectionsController {
|
||||
}
|
||||
|
||||
@RequirePermission('inspections:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateInspectionDto,
|
||||
|
||||
@@ -80,7 +80,7 @@ export class InspectionsService {
|
||||
actingUserId: string,
|
||||
): Promise<Task> {
|
||||
const inspection = await this.findById(id);
|
||||
return this.prisma.task.create({
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
@@ -93,5 +93,19 @@ export class InspectionsService {
|
||||
sourceInspectionId: inspection.id,
|
||||
},
|
||||
});
|
||||
// Same trail as a task created on the task page: audit entry, webhook
|
||||
// and calendar entry must not depend on where the task was started.
|
||||
await this.auditLog.record({
|
||||
entityType: 'Task',
|
||||
entityId: task.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: task,
|
||||
});
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
if (task.dueDate) {
|
||||
await this.calendarSync.enqueue('TASK', task.id);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { computeCropMarks, CropMarkGrid, CropMarkLine } from './crop-marks';
|
||||
|
||||
// 2 x 2 labels of 90 x 35 mm, 10 mm margin, 2 mm gaps on A4.
|
||||
const GRID: CropMarkGrid = {
|
||||
pageWidthMm: 210,
|
||||
pageHeightMm: 297,
|
||||
columns: 2,
|
||||
rows: 2,
|
||||
slotWidthMm: 90,
|
||||
slotHeightMm: 35,
|
||||
marginLeftMm: 10,
|
||||
marginTopMm: 10,
|
||||
gapXMm: 2,
|
||||
gapYMm: 2,
|
||||
};
|
||||
|
||||
const vertical = (lines: CropMarkLine[]) =>
|
||||
lines.filter((line) => line.x1 === line.x2);
|
||||
const horizontal = (lines: CropMarkLine[]) =>
|
||||
lines.filter((line) => line.y1 === line.y2);
|
||||
|
||||
describe('computeCropMarks', () => {
|
||||
it('marks both edges of every column and row, at both ends of each cut', () => {
|
||||
const lines = computeCropMarks(GRID);
|
||||
|
||||
// Columns: 10|100 and 102|192 -> 4 cuts, marked at top and bottom.
|
||||
const xs = [...new Set(vertical(lines).map((line) => line.x1))];
|
||||
expect(xs).toEqual([10, 100, 102, 192]);
|
||||
expect(vertical(lines)).toHaveLength(8);
|
||||
// Rows: 10|45 and 47|82 -> 4 cuts, marked left and right.
|
||||
const ys = [...new Set(horizontal(lines).map((line) => line.y1))];
|
||||
expect(ys).toEqual([10, 45, 47, 82]);
|
||||
expect(horizontal(lines)).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('keeps every mark in the margin, never on a label', () => {
|
||||
const lines = computeCropMarks(GRID);
|
||||
const gridRight = 192;
|
||||
const gridBottom = 82;
|
||||
|
||||
for (const line of vertical(lines)) {
|
||||
const inTopMargin = line.y2 <= GRID.marginTopMm - 1;
|
||||
const inBottomMargin = line.y1 >= gridBottom + 1;
|
||||
expect(inTopMargin || inBottomMargin).toBe(true);
|
||||
}
|
||||
for (const line of horizontal(lines)) {
|
||||
const inLeftMargin = line.x2 <= GRID.marginLeftMm - 1;
|
||||
const inRightMargin = line.x1 >= gridRight + 1;
|
||||
expect(inLeftMargin || inRightMargin).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('stays clear of the unprintable paper edge', () => {
|
||||
const lines = computeCropMarks({
|
||||
...GRID,
|
||||
marginLeftMm: 5,
|
||||
marginTopMm: 5,
|
||||
});
|
||||
for (const line of lines) {
|
||||
expect(
|
||||
Math.min(line.x1, line.x2, line.y1, line.y2),
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws one cut, not two, where labels touch without a gap', () => {
|
||||
const lines = computeCropMarks({ ...GRID, gapXMm: 0 });
|
||||
const xs = [...new Set(vertical(lines).map((line) => line.x1))];
|
||||
expect(xs).toEqual([10, 100, 190]);
|
||||
});
|
||||
|
||||
it('leaves out marks when the margin is too narrow to print them', () => {
|
||||
const lines = computeCropMarks({ ...GRID, marginTopMm: 2 });
|
||||
// No room above the grid: top marks disappear, bottom marks remain.
|
||||
expect(vertical(lines).every((line) => line.y1 > 10)).toBe(true);
|
||||
expect(vertical(lines)).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Cut marks for trimming a printed label sheet with a guillotine / lever
|
||||
* paper cutter: short hairlines in the sheet margin, outside every label,
|
||||
* one at each end of every cut line. Laying both ends of a mark against the
|
||||
* cutter's ruler lines the blade up with the label edge.
|
||||
*
|
||||
* All values in millimetres on an A4 page (0,0 = top left).
|
||||
*/
|
||||
|
||||
/** How far a mark stays away from the label edge it points at. */
|
||||
export const CROP_MARK_GAP_MM = 1;
|
||||
/** Mark length. Short on purpose - it lives in the margin only. */
|
||||
export const CROP_MARK_LENGTH_MM = 4;
|
||||
/** Most printers cannot print closer to the paper edge than this. */
|
||||
export const CROP_MARK_PAGE_INSET_MM = 2;
|
||||
/** A mark shorter than this is not worth printing (too little margin). */
|
||||
const MIN_MARK_LENGTH_MM = 1.5;
|
||||
|
||||
export interface CropMarkGrid {
|
||||
pageWidthMm: number;
|
||||
pageHeightMm: number;
|
||||
columns: number;
|
||||
rows: number;
|
||||
slotWidthMm: number;
|
||||
slotHeightMm: number;
|
||||
marginLeftMm: number;
|
||||
marginTopMm: number;
|
||||
gapXMm: number;
|
||||
gapYMm: number;
|
||||
}
|
||||
|
||||
export interface CropMarkLine {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
/** Cut positions along one axis; edges closer than 0.05 mm are one cut. */
|
||||
function cutPositions(
|
||||
start: number,
|
||||
count: number,
|
||||
size: number,
|
||||
gap: number,
|
||||
): number[] {
|
||||
const positions: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const from = start + i * (size + gap);
|
||||
positions.push(from, from + size);
|
||||
}
|
||||
return positions
|
||||
.sort((a, b) => a - b)
|
||||
.filter(
|
||||
(value, index, all) => index === 0 || value - all[index - 1] > 0.05,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The segment of a mark in the margin between the page edge and the grid,
|
||||
* or null when the margin is too narrow to print one.
|
||||
*/
|
||||
function marginSegment(
|
||||
gridEdge: number,
|
||||
pageEdge: number,
|
||||
towardsPageStart: boolean,
|
||||
): [number, number] | null {
|
||||
if (towardsPageStart) {
|
||||
const end = gridEdge - CROP_MARK_GAP_MM;
|
||||
const start = Math.max(end - CROP_MARK_LENGTH_MM, CROP_MARK_PAGE_INSET_MM);
|
||||
return end - start >= MIN_MARK_LENGTH_MM ? [start, end] : null;
|
||||
}
|
||||
const start = gridEdge + CROP_MARK_GAP_MM;
|
||||
const end = Math.min(
|
||||
start + CROP_MARK_LENGTH_MM,
|
||||
pageEdge - CROP_MARK_PAGE_INSET_MM,
|
||||
);
|
||||
return end - start >= MIN_MARK_LENGTH_MM ? [start, end] : null;
|
||||
}
|
||||
|
||||
export function computeCropMarks(grid: CropMarkGrid): CropMarkLine[] {
|
||||
if (grid.columns < 1 || grid.rows < 1) {
|
||||
return [];
|
||||
}
|
||||
const gridLeft = grid.marginLeftMm;
|
||||
const gridTop = grid.marginTopMm;
|
||||
const gridRight =
|
||||
gridLeft +
|
||||
grid.columns * grid.slotWidthMm +
|
||||
(grid.columns - 1) * grid.gapXMm;
|
||||
const gridBottom =
|
||||
gridTop + grid.rows * grid.slotHeightMm + (grid.rows - 1) * grid.gapYMm;
|
||||
|
||||
const verticalCuts = cutPositions(
|
||||
gridLeft,
|
||||
grid.columns,
|
||||
grid.slotWidthMm,
|
||||
grid.gapXMm,
|
||||
);
|
||||
const horizontalCuts = cutPositions(
|
||||
gridTop,
|
||||
grid.rows,
|
||||
grid.slotHeightMm,
|
||||
grid.gapYMm,
|
||||
);
|
||||
|
||||
const top = marginSegment(gridTop, grid.pageHeightMm, true);
|
||||
const bottom = marginSegment(gridBottom, grid.pageHeightMm, false);
|
||||
const left = marginSegment(gridLeft, grid.pageWidthMm, true);
|
||||
const right = marginSegment(gridRight, grid.pageWidthMm, false);
|
||||
|
||||
const lines: CropMarkLine[] = [];
|
||||
for (const x of verticalCuts) {
|
||||
for (const segment of [top, bottom]) {
|
||||
if (segment) lines.push({ x1: x, y1: segment[0], x2: x, y2: segment[1] });
|
||||
}
|
||||
}
|
||||
for (const y of horizontalCuts) {
|
||||
for (const segment of [left, right]) {
|
||||
if (segment) lines.push({ x1: segment[0], y1: y, x2: segment[1], y2: y });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -78,6 +78,14 @@ export class CreateLabelTemplateDto {
|
||||
@Min(0)
|
||||
gapYMm?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Schnittmarken im Blattrand drucken (zum Schneiden mit dem Papierschneider)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
cropMarks?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
Loaded 100 of 369 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user