feat(quick-260910-das): messen die Kette user unsichtbar->frei->Eindeutigkeitsfehler

- runUserAreaChecks in rls-scratch-check.mjs: 12 neue Pruefungen gegen die
  ausgelieferte User-Policy (baut auf der vom Anmeldeweg-Abschnitt
  angelegten Tabelle auf, legt zusaetzlich Tenant ohne Zeilenschutz an)
- Belegt: ungebundene Suche nach vorhandenem Benutzernamen liefert 0
  Zeilen, gebundene Suche nach fremdem Benutzernamen ebenso ("frei"), und
  das anschliessende gebundene INSERT scheitert hart an SQLSTATE 23505
  (Eindeutigkeitsverletzung), nicht an 42501 (Zeilenschutz)
- SQLSTATE wird aus err.meta.code gelesen, nicht err.code (das bei
  $executeRaw-Fehlern immer den generischen Prisma-Code P2010 traegt,
  empirisch gegen tessera-ctl-db-1 geprueft)
- docs/mandantentrennung-etappe2-fehlerrichtung.md: neuer Abschnitt
  "Bereich user" (u1-u5) mit der tatsaechlich beobachteten Ausgabe,
  Signaltabelle, der vollstaendigen Kette (Befund L) und den Grenzen zu
  auth.service.ts/ldap.service.ts
- Teil 2/3 gemessen: keine Transaktion in apps/api/src/user (Befund B
  haelt), findByUsername hat genau einen Treffer, die eigene Definition
  (Befund D haelt)
- 789 Tests weiterhin gruen, Typpruefung sauber, Wegwerf-Werkzeug meldet
  alle 53 Pruefungen bestanden (41 bisherige + 12 neue)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
2026-09-10 10:16:12 +02:00
parent 7e7a697e3c
commit b848ba6baa
2 changed files with 590 additions and 0 deletions
@@ -912,6 +912,276 @@ entscheidet sie nicht — er bindet dienst-intern, wie `ldap`, `groups` und
Geprüft und bewusst gelassen — eine Lösung (Unterverzeichnisse je
Mandant, Umzug der Bestandsdateien) ist ein eigener Auftrag, siehe (d4).
## Bereich user
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `user`
(Quick-Task 260910-das) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
beantwortet sie erneut, für den Bereich, in dem das plattformweite
Eindeutigkeitsproblem tatsächlich wohnt: `username` und `email` sind im
Schema plattformweit eindeutig, nicht je Mandant, und dieser Bereich enthält
als einziger BEIDE Formen gleichzeitig — Wege, die binden MÜSSEN
(Benutzerverwaltung je Mandant), und einen Weg, der binden NICHT DARF
(Nachschlagen auf dem plattformweit eindeutigen Schlüssel `username`). Ein
Quer-Schreiben ist hier keine Offenlegung, sondern eine Rechteausweitung
über die Mandantengrenze hinweg — die schwerste Klasse dieses ganzen
Vorhabens.
### (u1) Die Messung
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen siebten
Abschnitt (`runUserAreaChecks`) erweitert. Die Tabelle `"User"` wird dabei
NICHT neu angelegt — sie existiert bereits, vom Abschnitt des Anmeldewegs,
samt eingeschaltetem und erzwungenem Zeilenschutz, beiden
Eindeutigkeitsbedingungen (`username`, `email`) und zwei Testzeilen in zwei
Mandanten (Befund O). Dieser Abschnitt baut darauf auf: er hält die im
Anmeldeweg-Abschnitt von Hand getippte Policy GEGEN die aus der
ausgelieferten Migration `20260618112133_rls_policies` geschnittene Fassung
(beide sind nach Normalisierung von Leerraum und abschließendem Semikolon
wortgleich — keine Ersetzung nötig), legt eine Tabelle `"Tenant"`
ausdrücklich OHNE Zeilenschutz an (die zu messende Eigenschaft selbst), und
ergänzt je eine weitere Benutzerzeile pro Mandant. Tatsächlich beobachtete
Ausgabe dieses Laufs (2026-09-10, gegen `tessera-ctl-db-1`, Adresse
`172.19.0.2`):
```
user-policy-aus-migration-wortgleich: bestanden — die im Anmeldeweg-Abschnitt (runAuthLookupChecks) von Hand getippte Policy auf "User" ist nach Normalisierung von Leerraum und abschliessendem Semikolon wortgleich mit der aus 20260618112133_rls_policies geschnittenen
user-gebunden-nur-eigener-mandant: bestanden — forTenant(TENANT-A) liefert 2 Zeile(n): ["TENANT-A","TENANT-A"]
user-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "User" liefert 0 Zeile(n)
user-ungebundene-suche-nach-benutzername-liefert-keine-zeile: bestanden — ungebundenes SELECT ... WHERE username = 'bob' liefert 0 Zeile(n), obwohl der Benutzer existiert — der aufrufende Code liest daraus "diesen Benutzer gibt es nicht" und legt an
user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile: bestanden — forTenant(TENANT-A) liefert fuer WHERE username = 'bob' (gehoert TENANT-B) 0 Zeile(n) — die Kollisionspruefung meldet faelschlich "frei"
user-eindeutigkeit-greift-trotz-unsichtbarkeit: bestanden — gebundenes INSERT unter TENANT-A mit dem angeblich freien Benutzernamen "bob" wird abgewiesen mit SQLSTATE 23505 (Raw query failed. Code: `23505`. Message: `Unique constraint failed: `) — eine Eindeutigkeitsverletzung (23505), NICHT eine Zeilenschutz-Ablehnung: genau die im Auftrag beschriebene Kette
user-gebundenes-einfuegen-fremder-mandant-abgelehnt: bestanden — gebundenes INSERT unter TENANT-A mit tenantId=TENANT-B abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
user-ungebundenes-einfuegen-abgelehnt: bestanden — ungebundenes INSERT mit gueltiger Mandantenkennung abgewiesen: Raw query failed. Code: `42501`. Message: `ERROR: new row violates row-level security policy for table "User"`
user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes UPDATE unter TENANT-A ueber die Kennung 'user-b' (gehoert TENANT-B) allein betrifft 0 Zeile(n) — die vorgeschalteten Besitz- und Rollenpruefungen bleiben deshalb erhalten und werden in Aufgabe 2/3 nicht durch die Datenbank ersetzt
user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen: bestanden — gebundenes DELETE unter TENANT-A ueber die Kennung 'user-b2' (gehoert TENANT-B) allein betrifft 0 Zeile(n)
tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar: bestanden — ungebundenes SELECT auf "Tenant" liefert 2 Zeile(n): ["TENANT-A","TENANT-B"]; pg_class.relrowsecurity fuer "Tenant" = false
user-fan-out-je-mandant-gebunden-liefert-alle-zeilen: bestanden — Vereinigung der je-Mandant gebundenen SELECTs liefert 4 Benutzernamen: ["alice","bob","carol","dave"]; Gesamtmenge (ueber die Wartungsrolle mit BYPASSRLS gemessen) sind 4: ["alice","bob","carol","dave"]
Alle 53 Pruefungen bestanden.
```
Drei Zeilen tragen diesen Abschnitt und werden hier ausdrücklich benannt und
auseinandergehalten, weil sie zusammen die im Auftrag beschriebene Kette
sind:
- **Die Belegzeile zur Unsichtbarkeit**, `user-ungebunden-null-zeilen`: der
IDENTISCHE `SELECT "tenantId" FROM "User"` ohne vorheriges `set_config`
liefert **0 Zeilen**, nicht etwa die 4 tatsächlich vorhandenen — an der
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
nachgebauten Hilfstabelle. Genau dieselbe Unsichtbarkeit trifft die
ungebundene Suche nach einem GÜLTIGEN Benutzernamen
(`user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`) — die
Form, die die Erstanlage-Prüfung beim Start heute benutzt.
- **Die Zeile zum falschen „frei"**,
`user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`:
dieselbe Suche, gebunden an TENANT-A, nach dem Benutzernamen `bob`
(gehört TENANT-B), liefert ebenfalls 0 Zeilen — der Moment, in dem eine
Kollisionsprüfung fälschlich „frei" meldet, obwohl der Name vergeben ist.
- **Die Zeile zum harten Eindeutigkeitsfehler**,
`user-eindeutigkeit-greift-trotz-unsichtbarkeit`: unmittelbar danach ein
gebundenes `INSERT` unter TENANT-A mit genau diesem, angeblich freien
Benutzernamen `bob`. Die Ablehnung trägt SQLSTATE **23505**
(Eindeutigkeitsverletzung) — gelesen aus `err.meta.code`, nicht aus
`err.code` (das bei einem fehlgeschlagenen `$executeRaw` immer den
generischen Prisma-Code `P2010` trägt, empirisch gegen
`tessera-ctl-db-1` geprüft, siehe Kopfkommentar von `sqlStateOf()` im
Werkzeug) — und NICHT SQLSTATE 42501 (Zeilenschutz-Ablehnung), die dieser
Abschnitt in zwei anderen Zeilen ebenfalls misst
(`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`,
`user-ungebundenes-einfuegen-abgelehnt`). Diese Unterscheidung ist der
Kern der Messung: nur die erste ist die im Auftrag beschriebene Kette,
die zweite wäre eine ganz andere Geschichte.
Zusätzlich gemessen, statt behauptet: `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`
hält sowohl das ungebundene `SELECT` (2 von 2 Zeilen sichtbar) als auch den
Systemkatalog (`pg_class.relrowsecurity` für `"Tenant"` = `false`)
gegeneinander — die Aussage hängt damit nicht allein daran, dass dieser
Abschnitt selbst keinen Zeilenschutz eingeschaltet hat.
`user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` bildet die in
Aufgabe 2/3 gewählte Form der Plattform-Administratorsicht nach (Mandanten
ungebunden lesen, je Mandant EIN gebundener `SELECT`, Ergebnisse
vereinigen) und hält das Ergebnis gegen eine über die Wartungsrolle (mit
`BYPASSRLS`) gemessene Gesamtmenge, nicht gegen eine angenommene Zahl — beide
Mengen sind identisch: `["alice","bob","carol","dave"]`.
**TEIL 2, Beleg statt Behauptung für Befund B** — keine Transaktion in
diesem Bereich:
```
$ grep -rn '\$transaction(' apps/api/src/user --include=*.ts | grep -v spec
$ echo $?
1
```
Null Treffer, Rückgabewert 1. Der im Kopf von `prisma-tenant.extension.ts`
verlangte erneute Test ist damit für diesen Bereich beantwortet: kein neuer
Transaktionsfall, `withTenantTransaction()` wird hier nicht gebraucht und in
Aufgabe 2/3 nicht eingeführt.
**TEIL 3, Beleg statt Behauptung für Befund D** — die Aufrufermessung für
`findByUsername`:
```
$ grep -rn "findByUsername" apps/api/src packages
apps/api/src/user/user.service.ts:21: async findByUsername(username: string) {
```
Genau EIN Treffer, die Definition selbst — kein Aufrufer. Etappe 1
(260909-eor) hat den Anmeldeweg auf die drei schmalen
SECURITY-DEFINER-Funktionen umgezogen; `auth.service.ts` sucht seither über
`auth_lookup_user_by_username` und nicht mehr über diese Methode. Die
Messung bestätigt Befund D unverändert: die Methode bleibt ungebunden
(Entscheidung (c) aus den Planungszeit-Befunden), ihr Kopfkommentar wird in
Aufgabe 2 richtiggestellt.
### (u2) Signaltabelle je umgestelltem Pfad
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|---|---|---|
| `UserService.findById` | Der gebundene `findUnique` liefert `null` statt des eigenen Benutzers | `GET /users/:id` liefert `404 User not found`, obwohl der Benutzer existiert |
| `UserService.create` | Betrifft nicht das Lesen — ein gebundenes `INSERT` mit fremder Mandantenkennung wird von der Policy abgewiesen (`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`) | `POST /users` scheitert mit einer Datenbank-Ablehnung statt einer verständlichen Meldung, sollte die Mandantenkennung je falsch ankommen — nach heutigem Code (Befund G, Selbstbedienungswege ausgenommen) nicht erreichbar, weil `tenantId` aus dem Sitzungsnachweis bzw. der ausdrücklichen SUPER_ADMIN-Übersteuerung stammt |
| `UserService.update` | Der gebundene `update` über die Kennung allein trifft eine fremde Zeile still (0 betroffene Zeilen, `user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`), nicht laut | `PATCH /users/:id` würfe ohne die vorgeschaltete Prüfung in `user.controller.ts` keinen Fehler, sondern liefe ins Leere — die vorgeschaltete Mandantenprüfung bleibt deshalb Pflicht |
| `UserService.deactivate`/`delete` | Dieselbe stille Form wie `update` | `DELETE /users/:id` bzw. das Deaktivieren würde ohne die vorgeschaltete Prüfung 0 Zeilen treffen, ohne Fehler |
| Neue Methode: Plattform-Administratorsicht, Liste | Der Schleifentreiber (`tenant.findMany`, bewusst ungebunden) liefert 0 Mandanten statt der tatsächlich vorhandenen | Die Benutzerliste des `SUPER_ADMIN` ist für die gesamte Plattform leer, obwohl Mandanten mit Benutzern existieren — `user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` misst die Vereinigung, nicht den Treiber; ein leerer Treiber ist eine Eigenschaft der Mandantentabelle, nicht dieses Bereichs |
| Neue Methode: Plattform-Administratorsicht, Kennungs-Auflösung | Läuft der gebundene Lesezugriff für den Mandanten des Zielbenutzers leer, findet die Methode den Benutzer nicht | `GET /users/:id`, `PATCH /users/:id`, `DELETE /users/:id` liefern für den `SUPER_ADMIN` `404`, obwohl der Benutzer existiert |
| `user.controller.ts`, `findAll` (ADMIN-Zweig) | Der gebundene `findMany` liefert 0 Benutzer statt der tatsächlich vorhandenen | Die Benutzerliste ist für einen Mandanten-Administrator leer — eine leere Liste sieht auf einer frischen Installation wie der Normalzustand aus |
| Selbstbedienungswege (Bild hochladen/löschen, Akzentfarbe, Bild ausliefern) | Der gebundene Zugriff über die eigene Kennung aus dem Sitzungsnachweis liefert 0 Zeilen | `GET /users/me/avatar` liefert die vorhandene `404 No avatar set`, obwohl ein Bild hinterlegt ist — harmlos, siehe (u3) |
| `UserService.findByUsername` (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht und liefert deshalb weiterhin korrekt. Das Risiko läge in einer KÜNFTIGEN Bindung | Würde man ihn binden: eine gebundene Suche nach einem fremden Benutzernamen meldete „frei" — hier ausdrücklich als Grenze festgehalten, nicht als heute beobachtbares Verhalten (Befund D) |
| `AdminSeedService`, Erstanlage-Prüfung (bewusst UNGEBUNDEN) | Betrifft nicht diesen Pfad selbst — er bindet nicht, läuft aber NACH dem Scharfschalten für JEDEN Administrator ins Leere, weil ohne Mandantenkontext keine Zeile sichtbar ist | Siehe (u3) — die schwerste Ausprägung dieses gesamten Bereichs |
| `AdminSeedService`, beide Zugriffe auf `tenant` (bewusst UNGEBUNDEN) | Betrifft nicht diese Pfade selbst — `Tenant` trägt keinen Zeilenschutz, ein ungebundenes Lesen/Schreiben bleibt korrekt | `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar` misst die tragende Eigenschaft |
### (u3) Welcher Code Leere als Abwesenheit deutet
Dies ist der Kern dieses Abschnitts. Die sechs Stellen aus Befund L,
namentlich benannt und nach ihrer Wirkung sortiert:
**Startverhindernd (eine Stelle, die schwerste Ausprägung im ganzen
Vorhaben):**
1. **`AdminSeedService.seedAdmin()`, die Erstanlage-Prüfung** — die
vollständige, im Auftrag beschriebene Kette in Reinform: eine
unsichtbare Zeile wird als Abwesenheit gelesen
(`user.findUnique({ where: { username } })` liefert nach dem
Scharfschalten `null`, nicht weil der Administrator fehlt, sondern weil
ohne gesetzten Mandantenkontext keine Zeile der Benutzertabelle sichtbar
ist — `user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`),
die natürliche Folgehandlung ist Anlegen (Schritt 4 läuft, weil Schritt
3 entfällt), und das Anlegen scheitert hart an der plattformweiten
Eindeutigkeit von `username`
(`user-eindeutigkeit-greift-trotz-unsichtbarkeit`, SQLSTATE 23505, KEINE
Zeilenschutz-Ablehnung). Weil `seedAdmin()` bewusst NICHT gekapselt ist
— der Dateikopf sagt ausdrücklich, ein Fehlschlag solle den Start
weiterhin laut scheitern lassen —, wird aus dieser einen unsichtbaren
Zeile eine **Startsperre**: die Anwendung startet nach dem
Scharfschalten nicht mehr, für jede bestehende Installation mit
gesetzten Administrator-Umgebungswerten. Eine Absicht (laut scheitern)
wird damit ungewollt zu einer Sperre für den Normalfall.
**Kollisionserzeugend (drei Stellen — dieselbe Kette, an anderen Stellen im
Bereich):**
2. **`user.controller.ts`, `findAll` im ADMIN-Zweig** — eine leere Liste
heißt „dieser Mandant hat keine Benutzer". Ein Administrator, der seine
Kollegen nicht mehr sieht, legt sie erneut an; jede dieser Anlagen
kollidiert auf `username` bzw. `email`. Die Oberfläche zeigt dabei
nichts Auffälliges: eine leere Benutzerliste ist auf einer frischen
Installation der Normalzustand.
3. **`user.controller.ts`, `findAll` im SUPER_ADMIN-Zweig** — dieselbe
Leere, eine Ebene höher: die Plattformverwaltung sieht eine
Installation ohne jeden Benutzer und würde, bliebe die neue
übergreifende Methode ungebunden statt als gebundene Schleife gebaut,
ebenfalls zum Neuanlegen verleiten.
4. **Eine gebundene Suche nach Benutzername oder Adresse** (Befund D,
`user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile`)
— meldet „frei" für einen Namen, den es gibt. Die Kollisionsprüfung wird
zur Kollisionserzeugung; genau deshalb bleibt `findByUsername`
ungebunden und übersetzt `UserService.create`/`update` die
Eindeutigkeitsverletzung stattdessen an ihrem eigenen Erzeugungspunkt in
eine verständliche Meldung (Aufgabe 2).
**Bereits gebunden, als Beleg benannt (eine Stelle, nicht Gegenstand
dieses Durchlaufs):**
5. **`ldap.service.ts`, `upsertMappedUser`** — bereits gebunden, seit
260909-ipc, in einem ANDEREN Bereich, hier nur zu benennen und nicht
anzufassen: die Identitätssuche entscheidet über Anlegen-oder-
Aktualisieren und läuft in dieselbe Kette. Sie ist der Beleg, dass die
Kette nicht erst nach dem Scharfschalten existiert — die
Suchbedingung trägt bereits heute den Mandanten, ein fremder Halter ist
also bereits heute unsichtbar. Die Übersetzung der
Eindeutigkeitsverletzung gehört deshalb an den gemeinsamen Anlegepunkt
in `user.service.ts` (Aufgabe 2), der in diesem Plan ohnehin angefasst
wird — dort und nicht in `ldap`.
**Harmlos (eine Stelle):**
6. **Die Selbstbedienungswege für Bild und Akzentfarbe** (Befund G) — ein
leerer Lesezugriff heißt „kein Bild hinterlegt". Harmlos in der
Wirkung, aber vollständigkeitshalber in der Tabelle (u2) festgehalten.
**Was ein GEBUNDENER Nachschlageweg auf `username` mit dem Anmeldeweg
machen würde, und warum die Frage hier gegenstandslos ist:** der Anmeldeweg
läuft seit Etappe 1 (260909-eor) über die drei SECURITY-DEFINER-Funktionen
und nicht mehr über `UserService.findByUsername` — belegt durch die
Aufrufermessung aus TEIL 3 oben (genau ein Treffer, die Definition selbst),
nicht behauptet. Würde `findByUsername` dennoch gebunden, säße das Problem
nicht im Anmeldeweg (der diese Methode gar nicht mehr aufruft), sondern
genau in der unter Punkt 4 beschriebenen Kollisionserzeugung — derselbe
Grund, aus dem `resolveEmailForWrite` im Bereich `ldap` ungebunden bleibt.
**Gegenrichtung, ebenfalls nachgesehen und in diese Kritikschrift
gehörend:** ein gebundenes Ändern oder Löschen über die Kennung allein
trifft eine fremde Zeile NICHT still im Sinne einer Zeilenschutz-Ablehnung,
sondern schlicht mit null betroffenen Zeilen
(`user-gebundenes-update-ueber-kennung-allein-trifft-null-zeilen`,
`user-gebundenes-loeschen-ueber-kennung-allein-trifft-null-zeilen`) — die
vorgeschalteten Besitz- und Rollenprüfungen in `user.controller.ts` bleiben
deshalb Pflicht und werden in Aufgabe 3 nicht durch die Datenbank ersetzt.
Ein gebundenes Einfügen mit fremder Mandantenkennung wird laut abgewiesen
(`user-gebundenes-einfuegen-fremder-mandant-abgelehnt`, SQLSTATE 42501).
Das sind die lauten Stellen, und dieser Abschnitt besteht nicht nur aus
Alarm.
### (u4) Was dieser Durchlauf bewusst nicht löst
Die plattformweite Eindeutigkeit von `username` und `email` selbst — die
Ursache, aus der jede Ausnahme dieses Plans folgt. Die ehrliche Reparatur
wäre eine Schemaänderung (eine Eindeutigkeit mit Mandantendimension); das
ist eine Produktentscheidung — darf dieselbe Adresse zwei Mandanten
gehören — und sie ist für Etappe 3 bereits vorgemerkt. Hier wird sie
festgehalten, nicht entschieden, und ausdrücklich nicht durch eine
Migration vorweggenommen (Broken-Windows-Register, Aufgabe 2).
Die Übergabe in den noch nicht umgestellten Bereich `auth`: `auth.service.ts`
ist aus demselben Grund `gemischt` (Befund E) und NICHT Gegenstand dieses
Plans. Die Grenze ist gemessen, nicht aus Erinnerung gezogen: die Datei hat
drei bereits über `forTenant()` gebundene Schreibzugriffe (Anmeldezeitstempel,
Kennwortwechsel nach Zurücksetzen) und fünf noch ungebundene Zugriffe auf
`user`, die zu `getMe`, `changePassword` und `adminResetPassword` gehören.
Die drei Anmelde-/Zurücksetz-Nachschlagewege laufen über `$queryRaw` auf die
drei SECURITY-DEFINER-Funktionen. Dieser Plan fasst `auth.service.ts` an
KEINER Stelle an.
Die offene Architekturfrage `req.tenantPrisma` — auch der Bereich `user`
entscheidet sie nicht. Er bindet dienst-intern, wie `ldap`, `groups`,
`tenders` und `dkv` es vormachen.
### (u5) Was dieser Durchlauf bewusst NICHT anfasst
- `auth.service.ts` an keiner Stelle (siehe (u4)).
- Die drei SECURITY-DEFINER-Funktionen aus Etappe 1 und ihre Rechte nicht —
der Anmeldeweg bleibt unberührt, belegt durch die Aufrufermessung aus
TEIL 3 oben.
- Schema und Migrationen nicht.
- Die Verdrängung im gemeinsamen Ablagverzeichnis der Profilbilder
(`user-files/avatars/`) nicht — anders als bei den Ausfuhrdateien des
Bereichs `dkv` (Befund F dort) ist die Dateibenennung hier
`{userId}.{ext}` und damit bereits kollisionsfrei über Mandanten hinweg;
es ist ohnehin keine Bindungsfrage.
Jeweils mit der Feststellung, dass sie geprüft und bewusst gelassen sind —
nicht übersehen.
## Verweis
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang