docs: add two backlog items found during Phase 16 testing
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 45s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s

Neither is a Phase 16 defect; both surfaced while testing it and would
otherwise have been lost with the session.

1. Module activation has no licence check — a tenant admin can activate any
   catalogue module for their own tenant. The grants matrix is NOT the hole: it
   only distributes what is already active. Carries open product questions
   (who issues licences, what expiry does), so it is written up as a draft, not
   a decision.

2. The LDAP bind password is stored in clear text although an AES-256-GCM
   service already exists and is used for calendar, DKV and tender inbox
   credentials. Hashing is not an option here — the password must be replayable
   to bind against the directory — so encryption at rest is the fix. No open
   questions, just work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 13:29:08 +02:00
parent 234f80b4fb
commit 6fb0276d1a
2 changed files with 119 additions and 0 deletions
@@ -0,0 +1,59 @@
---
created: 2026-08-11
title: LDAP-Bind-Passwort liegt im Klartext in der DB, obwohl der Verschluesselungsdienst schon existiert
area: ldap
severity: major
trigger: vor dem Verkauf an externe Kunden; unabhaengig davon jederzeit machbar, da klein und ohne Produktfragen
files:
- apps/api/prisma/schema.prisma:72
- apps/api/src/ldap/ldap-config.service.ts:38
- apps/api/src/calendar/crypto.service.ts
---
## Problem
`LdapConfig.bindPassword` wird so gespeichert, wie es aus dem Formular kommt —
`ldap-config.service.ts:38` und `:76` schreiben den Wert unveraendert in die
Datenbank. Wer Lesezugriff auf die Datenbank hat, hat damit die Zugangsdaten des
AD-Dienstkontos.
Hashen ist hier KEIN gangbarer Weg: Tessera muss sich mit diesem Passwort am
Domain Controller anmelden, braucht es also im Original. Der richtige Weg ist
symmetrische Verschluesselung mit einem Schluessel ausserhalb der Datenbank.
Genau das macht das Projekt an drei anderen Stellen bereits, mit AES-256-GCM:
| Feld | Modell |
|---|---|
| `encryptedPassword` | `CalendarSource` (schema.prisma:333) |
| `encryptedInboxCreds` | `DkvModuleConfig` (:264) |
| `encryptedInboxCreds` | `TenderEmailConfig` (:289) |
| `encryptedPassword` | `SmtpConfig`-naher Bereich (:237) |
Der Dienst dafuer existiert: `calendar/crypto.service.ts`, AES-256-GCM, Schluessel
aus der Umgebungsvariable `CALENDAR_ENCRYPTION_KEY`. Die LDAP-Konfiguration nutzt
ihn schlicht nicht — LDAP war frueher dran als der Verschluesselungsdienst und
wurde nie nachgezogen.
Was bereits richtig ist: die API gibt das Passwort nie heraus, `ldap.controller.ts`
maskiert es an allen vier Stellen zu `********`.
Aufgefallen am 2026-08-11 waehrend der Phase-16-Tests, als die LDAP-Konfiguration
fuer eine read-only-Pruefung aus der Datenbank gelesen wurde.
## Solution
1. Den bestehenden Verschluesselungsdienst aus `calendar/` heraushebeln, damit er
nicht laenger nach einem Modul benannt ist, das ihn zufaellig zuerst brauchte —
samt Schluesselvariable. Alternativ zunaechst unveraendert wiederverwenden und
die Umbenennung als eigenen Schritt fuehren; das ist die kleinere Aenderung.
2. `LdapConfigService.create/update` verschluesselt schreiben, alle Lesestellen
entschluesseln. Lesestellen sind `ldap.controller.ts` (Zeilen 136, 177, 210,
244, 277, 313) und der Sync selbst.
3. Migration, die den vorhandenen Klartext-Eintrag einmalig verschluesselt.
Muster vorhanden — dieselbe Form wie die bestehenden Backfill-Migrationen.
4. Feld umbenennen (`bindPassword` → `encryptedBindPassword`), damit am Schema
ablesbar ist, was drinsteht, wie bei den anderen vier Feldern.
Keine offenen Produktfragen — reine Umsetzung, entlang eines im Projekt bereits
etablierten Musters.
@@ -0,0 +1,60 @@
---
created: 2026-08-11
title: Jeder Mandanten-Admin kann sich jedes Modul selbst freischalten — Aktivierung ohne Lizenzpruefung
area: module-registry
severity: major
trigger: vor dem Verkauf an externe Kunden — intern folgenlos, extern haelt die Modullizenzierung nicht
files:
- apps/api/src/module-registry/module-registry.service.ts:53
- apps/api/src/module-registry/module-registry.controller.ts:103
- apps/api/prisma/schema.prisma:97
---
## Problem
`POST /modules/:moduleId/activate` ist nur mit `@Roles(ADMIN, SUPER_ADMIN)`
geschuetzt. Die `tenantId` stammt aus dem Login des Aufrufers, betrifft also
immer den eigenen Mandanten — insoweit ist die Mandantentrennung intakt.
`activateForTenant()` (`module-registry.service.ts:53`) prueft danach nur noch,
ob das Modul im Katalog existiert, und legt dann die `TenantModuleActivation` an.
Es gibt keine Pruefung, ob der Mandant dieses Modul erworben hat.
Im Datenmodell existiert dafuer auch nichts: es gibt `Module` (Katalog) und
`TenantModuleActivation` (an/aus pro Mandant), aber kein Objekt, das eine
Lizenz, ein Kontingent oder eine Laufzeit abbildet. **"Aktiviert" und
"lizenziert" sind heute dasselbe.**
Ein Kunden-Administrator kann sich damit unter Admin → Module jedes Modul aus
dem Katalog selbst freischalten.
Nicht betroffen ist die Freigaben-Matrix — die verteilt nur innerhalb des
bereits Aktivierten: `getMatrix()` (`module-grants.service.ts:174`) listet
ausschliesslich Module mit `isActive = true`, und `grant()` (`:93`) weist ein
nicht aktiviertes Modul mit einer Fehlermeldung ab. Die Luecke sitzt eine Ebene
darueber, in der Aktivierung selbst.
Aufgefallen am 2026-08-11, als der User beim Blick auf die Freigaben-Matrix
fragte, ob Mandantenfaehigkeit und Modullizenzierung so ueberhaupt tragen.
## Solution
Noch offen — als Entwurf, nicht als Entscheidung:
Lizenzierung von Aktivierung trennen. Ein eigenes Objekt (etwa
`TenantModuleLicense`) haelt fest, welche Module ein Mandant erworben hat, mit
Laufzeit und ggf. Nutzerzahl. `activateForTenant()` prueft dagegen und weist ab,
was nicht lizenziert ist — analog zu der Pruefung, die `grant()` bereits gegen
die Aktivierung macht.
Zu klaeren, bevor gebaut wird — das sind Produktfragen fuer den User:
- Wer vergibt Lizenzen? Nur der Plattform-Betreiber (SUPER_ADMIN eines
Betreiber-Mandanten), oder gibt es eine Selbstbedienung ueber den Marktplatz?
- Was passiert mit einer ablaufenden Lizenz — sofort abschalten, Schonfrist,
nur noch lesend?
- Braucht es ueberhaupt Laufzeiten, oder reicht "gekauft / nicht gekauft"?
- Wie kommt eine Lizenz technisch in eine Kundeninstallation, die beim Kunden
im eigenen Netz laeuft? (Lizenzdatei, Schluessel, Online-Abgleich?)
Solange Tessera nur intern laeuft, ist der Ist-Zustand folgenlos.