383 lines
26 KiB
Markdown
383 lines
26 KiB
Markdown
# Mandantentrennung, Etappe 2 — Die Fehlerrichtung dreht sich um
|
|
|
|
Dieses Dokument gehört zusammen mit
|
|
`docs/mandantentrennung-zugriffsklassifikation.md` zur Vorbereitung der
|
|
Mandantentrennung auf Datenbankebene. Während die Klassifikation festhält,
|
|
**welche** Fundstelle umgestellt wird, hält dieses Dokument fest, **woran**
|
|
man merkt, wenn eine umgestellte Fundstelle nach dem Scharfschalten
|
|
(Etappe 4) zu wenig liefert. Es ist etappenbezogen und wird nicht laufend
|
|
nachgezogen wie die Bestandsaufnahme — es beschreibt den Bereich `ldap` zum
|
|
Zeitpunkt seiner Umstellung (Etappe 2, Quick-Task 260909-ipc).
|
|
|
|
## (a) Die Leitfrage
|
|
|
|
Bis heute war der Fehlerfall einer Mandantentrennung "sieht zu viel": die
|
|
Rolle `tessera` läuft mit `BYPASSRLS`, jede Abfrage sieht alle Zeilen aller
|
|
Mandanten, unabhängig davon, ob sie an `forTenant()` gebunden ist oder nicht.
|
|
Ein vergessener `forTenant()`-Aufruf blieb bisher unsichtbar, weil die Policy
|
|
gar nicht griff.
|
|
|
|
Nach dem Scharfschalten (Etappe 4, wenn `DATABASE_URL` auf eine Rolle ohne
|
|
`BYPASSRLS` zeigt) kehrt sich das um. Jede Abfrage, die **nicht** gebunden
|
|
ist, sieht nicht mehr alle Zeilen, sondern **keine** — die Policy vergleicht
|
|
gegen `current_tenant_id()`, und ohne vorheriges `set_config` ist dieser Wert
|
|
`NULL`. `NULL = "tenantId"` ist in SQL nie wahr, auch wenn `"tenantId"`
|
|
selbst nicht `NULL` ist. Der Fehlerfall ist damit nicht mehr "ein
|
|
Administrator sieht die Konfiguration eines fremden Mandanten", sondern "der
|
|
Abgleich-Dienst sieht gar keine Konfiguration mehr, für niemanden, und tut
|
|
so, als sei nichts zu tun."
|
|
|
|
Diese Frage wird deshalb VOR der Umstellung gestellt, nicht erst beim
|
|
Scharfschalten entdeckt: **Woran würde ich merken, dass eine umgestellte
|
|
Abfrage jetzt zu WENIG liefert statt zu viel?**
|
|
|
|
## (b) Die Messung
|
|
|
|
Task 1 dieses Plans hat `apps/api/scripts/rls-scratch-check.mjs` um einen
|
|
dritten Abschnitt erweitert, der exakt das im Bereich `ldap` verwendete
|
|
Muster (Tabellen `LdapConfig`/`LdapFieldMapping`, Policies wortgleich aus der
|
|
ausgelieferten Migration `20260618112133_rls_policies/migration.sql`
|
|
herausgeschnitten) gegen eine Wegwerf-Datenbank unter einer Rolle **ohne**
|
|
`BYPASSRLS` prüft. Tatsächlich beobachtete Ausgabe dieses Laufs
|
|
(2026-09-09, gegen `tessera-ctl-db-1`, Adresse `172.19.0.2`):
|
|
|
|
```
|
|
ldapconfig-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
|
ldapconfig-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "LdapConfig" liefert 0 Zeile(n)
|
|
fieldmapping-folgt-join-auf-ldapconfig: bestanden — forTenant(TENANT-A) liefert 1 Feldzuordnung(en): ["cfg-a"]
|
|
fieldmapping-schreiben-eigene-konfiguration-erlaubt: bestanden — INSERT mit eigener ldapConfigId erfolgreich
|
|
fieldmapping-schreiben-fremde-konfiguration-abgelehnt: bestanden — INSERT mit fremder ldapConfigId abgewiesen: ERROR: new row violates row-level security policy for table "LdapFieldMapping"
|
|
Alle 13 Pruefungen bestanden.
|
|
```
|
|
|
|
Der Beleg, der diese Kritikschrift trägt, ist die Zeile
|
|
`ldapconfig-ungebunden-null-zeilen`: der IDENTISCHE `SELECT "tenantId" FROM
|
|
"LdapConfig"` ohne vorheriges `set_config` liefert **0 Zeilen**, nicht etwa
|
|
alle 2 vorhandenen. Das ist an der echten, ausgelieferten Policy gemessen,
|
|
nicht an einer im Werkzeug nachgebauten Hilfstabelle — die Fehlerrichtung
|
|
"sieht nichts" ist damit kein aus dem Code abgeleiteter Schluss, sondern eine
|
|
beobachtete Tatsache.
|
|
|
|
## (c) Signaltabelle je umgestelltem Pfad des Bereichs `ldap`
|
|
|
|
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal |
|
|
|---|---|---|
|
|
| `LdapConfigService.getConfig/createConfig/updateConfig` | `forTenant()` liefert für den anfragenden Mandanten 0 Zeilen statt der eigenen Konfiguration | `GET /ldap/config` liefert `null`; die Admin-Oberfläche zeigt "keine Konfiguration" für einen Mandanten, der tatsächlich eine hat |
|
|
| `LdapConfigService.addFieldMapping/removeFieldMapping` | Schreiben/Lesen einer Feldzuordnung läuft gebunden leer statt auf die eigene Zuordnung | `POST`/`DELETE /ldap/config/mappings` scheitert mit 404 bzw. legt scheinbar nichts an, obwohl die Konfiguration existiert |
|
|
| `LdapService.listGroups`/`searchUsers` (Markierung "bereits importiert") | Die Markierungsabfrage liefert 0 vorhandene Konten/Gruppen statt der tatsächlich vorhandenen | Die Kennzeichnung "bereits importiert" in den Auswahllisten von Gruppen und Benutzern fehlt für Einträge, die tatsächlich schon importiert sind — ein zweiter Import würde eine Dublette anlegen (bzw. bei Gruppen an der eigenen `ldapObjectGuid`-Eindeutigkeit scheitern) |
|
|
| `LdapService.upsertMappedUser`/`importUsersByDn` (Identitätssuche über `ldapDn`/`username`) | Die Suche nach dem bestehenden Konto liefert 0 Treffer statt des vorhandenen Kontos | Der Abgleich-Bericht zählt das Konto unter `created` statt `updated` — sichtbar in den Zählern created/updated/deactivated des Sync-Berichts |
|
|
| `LdapService.syncUsersForTenant` (Deaktivierungs-Kandidatenliste) | Die Kandidatenliste liefert 0 lokale LDAP-Konten statt der tatsächlich vorhandenen | Der Zähler `deactivated` bleibt 0, obwohl ein Konto im Verzeichnis entfernt wurde — harmlose Richtung: es wird zu WENIG deaktiviert, nie zu viel |
|
|
| `LdapService.syncUsersForTenant` (`lastSyncAt`-Fortschreibung) | Das `UPDATE` trifft 0 Zeilen statt der eigenen Konfiguration | Das Feld `lastSyncAt` der Konfiguration bleibt stehen, obwohl der Sync gerade lief — sichtbar in der Admin-Oberfläche als "nie synchronisiert" trotz laufendem Betrieb |
|
|
| `LdapService.importGroupsByDn` (Idempotenzprüfung über `ldapObjectGuid`) | Die Prüfung liefert 0 Treffer statt der bereits importierten Gruppe | Ein zweiter Import derselben AD-Gruppe würde eine Dublette anlegen, statt sie als "übersprungen" zu zählen — im gebundenen Zustand nicht mehr erreichbar, weil `group.create` an der eigenen `ldapObjectGuid`-Unique-Bedingung ohnehin scheitert |
|
|
| `LdapConfigScheduler` (Planer, `getAllActiveConfigs()`) | Der Planer liest 0 Konfigurationen statt aller aktiven Konfigurationen ALLER Mandanten | Die Protokollzeile "Starting LDAP sync for tenant ..." des Planers erscheint für KEINEN Mandanten mehr — siehe Abschnitt (d), Befund E |
|
|
|
|
## (d) Welcher Code deutet Leere als Abwesenheit
|
|
|
|
Diese vier Stellen sind die still gefährlichsten Orte des Bereichs `ldap`,
|
|
weil sie ein zu kleines Datenbankergebnis nicht als Fehler, sondern als
|
|
gültigen Zustand ("nichts zu tun", "Objekt existiert nicht mehr")
|
|
interpretieren:
|
|
|
|
1. **`syncGroupMembershipsForTenant`, `deleteMany` mit `notIn`** — gefährlich,
|
|
zerstörend. Liefert die Benutzerausfrage zu wenig (weil ungebunden nach
|
|
dem Scharfschalten 0 Zeilen zurückkommen), entfernt der anschließende
|
|
`deleteMany({ where: { NOT: { userId: { in: [...] } } } })`-Aufruf ALLE
|
|
LDAP-Mitgliedschaften der Gruppe, weil die Vergleichsliste leer ist und
|
|
jede vorhandene Mitgliedschaft als "nicht mehr in der Liste" gilt. Dieser
|
|
Pfad ist bereits gebunden (`tenantPrisma` seit Etappe 1, Zeile 1179 vor
|
|
dieser Umstellung); die Gefahr besteht nur, falls diese Bindung jemals
|
|
entfernt würde.
|
|
|
|
2. **Die Deaktivierungsschleife in `syncUsersForTenant`** — harmlose
|
|
Richtung, trotzdem festgehalten. Liefert die Kandidatenliste
|
|
(`localLdapUsers`) zu wenig, wird zu WENIG deaktiviert, nie zu viel: ein
|
|
Konto, das eigentlich deaktiviert werden müsste, bleibt aktiv. Das ist
|
|
unerwünscht, aber nicht destruktiv — es verliert keine Daten und sperrt
|
|
niemanden fälschlich aus.
|
|
|
|
3. **Der Löschzweig in `syncBoundGroupsForTenant`** — die eigentliche
|
|
Löschentscheidung fällt am VERZEICHNIS ("kein Treffer mehr für
|
|
`objectGUID`"), nicht an der Datenbank; ein zu kleines Datenbankergebnis
|
|
führt hier zu WENIGER Löschungen, nicht zu mehr. Gefährlich ist
|
|
stattdessen die Übergabe unmittelbar davor:
|
|
`this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)` und
|
|
`ensureDefaultGroup(tenantId)` liegen in `groups.service.ts` und sind
|
|
NICHT Teil dieser Umstellung. Nach dem Scharfschalten liefert
|
|
`reassignDefaultBeforeDelete` still `false` (kein Ersatzkandidat
|
|
sichtbar), der Standard-Marker wandert nicht mit, und die Gruppe wird
|
|
trotzdem gelöscht — der Mandant bleibt ohne Standardgruppe zurück
|
|
(Befund D, siehe (e)).
|
|
|
|
4. **`getAllActiveConfigs`** — die stillste Stelle im gesamten Bereich.
|
|
Liefert diese Abfrage nach dem Scharfschalten 0 Zeilen (sie ist bewusst
|
|
übergreifend und bleibt ungebunden, siehe (e)), stellt der LDAP-Abgleich
|
|
für JEDEN Mandanten ohne Fehlermeldung, ohne Protokolleintrag und ohne
|
|
sichtbare Änderung die Arbeit ein (Befund E). Eine Laufzeitwarnung bei
|
|
"0 aktive Konfigurationen" wurde erwogen und VERWORFEN: der Planer läuft
|
|
jede Minute, und auf einer frischen Installation ohne LDAP ist 0 der
|
|
Normalfall — eine Warnung wäre Dauerlärm, der nach kurzer Zeit ignoriert
|
|
wird und sein Signal verliert. Das Signal gehört deshalb hierhin und in
|
|
die Vorabprüfung von Etappe 4 (`rls-preflight.mjs`), nicht in den
|
|
Minutentakt des Planers.
|
|
|
|
## (e) Was dieser Durchlauf bewusst nicht löst
|
|
|
|
- **Befund A — `resolveEmailForWrite` muss übergreifend bleiben.** `email`
|
|
und `username` sind in `prisma/schema.prisma` plattformweit eindeutig
|
|
(`@unique`), nicht je Mandant. Würde diese Abfrage mitgebunden, sähe sie
|
|
einen fremden Halter der Adresse nicht mehr, meldete "Adresse frei", und
|
|
der anschließende Schreibvorgang liefe in die plattformweite
|
|
Eindeutigkeitsbedingung der Datenbank — aus einer sauber berichteten
|
|
Kollision (WINDOWS #15/T-Q3-01) würde ein P2002-Abbruch des gesamten
|
|
Sync-Laufs. Nach dem Scharfschalten liefert diese ungebundene Abfrage
|
|
IMMER "frei" (0 Zeilen unter jedem Mandantenkontext außer dem der
|
|
Adresse selbst) — ein bekannter, hier bewusst offen gelassener Punkt.
|
|
Die Lösung gehört nach Etappe 3, vermutlich als vierte
|
|
SECURITY-DEFINER-Funktion nach dem Muster der drei Funktionen des
|
|
Anmeldewegs (`auth_lookup_user_by_username`,
|
|
`auth_lookup_reset_token`, plus die dritte aus Etappe 1).
|
|
|
|
- **Befund D — die Standardgruppen-Übergabe an den Bereich `groups`.** Wie
|
|
in (d.3) beschrieben, ist der Löschzweig selbst bereits gebunden, aber
|
|
die Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup` in
|
|
`groups.service.ts` ist es nicht. Das ist eine Reihenfolgebedingung für
|
|
Etappe 4: der Bereich `groups` muss umgestellt sein, bevor scharf
|
|
geschaltet wird, sonst bleibt ein Mandant nach einer Gruppen-Löschung
|
|
ohne Standardgruppe zurück. `groups` ist ohnehin als nächster Bereich der
|
|
Etappe 2 vorgesehen.
|
|
|
|
**Nachtrag (260909-jts, Aufgabe 3): GESCHLOSSEN.** Der Bereich `groups`
|
|
ist umgestellt — `reassignDefaultBeforeDelete` und `ensureDefaultGroup`
|
|
laufen seit Aufgabe 2 dieses Plans vollständig über `forTenant()` bzw.
|
|
`withTenantTransaction()` (siehe Abschnitt "Bereich groups" unten und
|
|
`docs/mandantentrennung-zugriffsklassifikation.md`, Zeile
|
|
`groups.service.ts`/`group`, Stand `gebunden`). Die Reihenfolgebedingung
|
|
für Etappe 4 ist damit erfüllt. Der Befund oben bleibt unverändert stehen
|
|
— er beschreibt korrekt den Zustand zum Zeitpunkt der ldap-Umstellung.
|
|
|
|
- **Die offene Architekturfrage `req.tenantPrisma`.** `tenant.middleware.ts`
|
|
und `tenant.guard.ts` setzen `req.tenantPrisma = forTenant(...)`, aber
|
|
kein Controller liest diesen Wert je. Dieser Durchlauf entscheidet NICHT,
|
|
ob Controller künftig darüber gehen sollten statt eines erneuten
|
|
`forTenant()`-Aufrufs im Service — der Bereich `ldap` bindet weiterhin
|
|
dienst-intern, wie die vier Bestandsstellen in `ldap.service.ts` und die
|
|
drei in `auth.service.ts` es vormachen. Die Frage bleibt für die übrigen
|
|
Bereiche der Etappe 2 offen (siehe
|
|
`docs/mandantentrennung-zugriffsklassifikation.md`, Abschnitt "Was diese
|
|
Etappe NICHT entscheidet").
|
|
|
|
## Bereich groups
|
|
|
|
Dieser Abschnitt erweitert die Kritikschrift um den Bereich `groups`
|
|
(Quick-Task 260909-jts) und beschreibt ihn zum Zeitpunkt seiner Umstellung.
|
|
Die Leitfrage aus Abschnitt (a) gilt unverändert weiter — dieser Abschnitt
|
|
beantwortet sie erneut, aber für einen Bereich, der die Berechtigungsschicht
|
|
selbst ist: Gruppenmitgliedschaft und Modulfreigaben entscheiden, wer welches
|
|
Modul sehen darf.
|
|
|
|
### (g1) Die Messung
|
|
|
|
Aufgabe 1 hat `apps/api/scripts/rls-scratch-check.mjs` um einen vierten
|
|
Abschnitt (`runGroupsAreaChecks`) und eine eigene Transaktionsmessung
|
|
(`runTransactionShapeMeasurement`) erweitert, beide gegen die Wegwerf-Datenbank
|
|
unter der Rolle ohne `BYPASSRLS`, mit den vier Policies für `Group`,
|
|
`GroupMembership`, `ModuleGrant` (aus `20260804130918_groups_rls_policies`)
|
|
und `TenantModuleActivation` (aus `20260909140000_rls_remaining_tenant_tables`)
|
|
WORTGLEICH aus den ausgelieferten Migrationen extrahiert. Tatsächlich
|
|
beobachtete Ausgabe dieses Laufs (2026-09-09, gegen `tessera-ctl-db-1`,
|
|
Adresse `172.19.0.2`):
|
|
|
|
```
|
|
group-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
|
group-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "Group" liefert 0 Zeile(n)
|
|
groupmembership-folgt-join-auf-group: bestanden — forTenant(TENANT-A) liefert 1 Mitgliedschaft(en): ["group-a"]
|
|
groupmembership-schreiben-fremde-gruppe-abgelehnt: bestanden — INSERT mit fremder groupId abgewiesen: ERROR: new row violates row-level security policy for table "GroupMembership"
|
|
groupmembership-schreiben-fremder-benutzer-nicht-verhindert: bestanden — INSERT mit A-eigener Gruppe, aber einer Benutzerkennung, die es in A nicht gibt, ist GELUNGEN — die Policy auf GroupMembership prueft nur die Gruppenseite, nicht die Benutzerseite (Befund E)
|
|
modulegrant-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
|
modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt: bestanden — INSERT mit korrekter eigener tenantId, aber fremder groupId ist GELUNGEN — die Policy auf ModuleGrant prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F)
|
|
tenantmoduleactivation-gebunden-nur-eigene-zeile: bestanden — forTenant(TENANT-A) liefert 1 Zeile(n): ["TENANT-A"]
|
|
```
|
|
|
|
Die Belegzeile, die diesen Abschnitt trägt, ist `group-ungebunden-null-zeilen`:
|
|
der IDENTISCHE `SELECT "tenantId" FROM "Group"` ohne vorheriges `set_config`
|
|
liefert **0 Zeilen**, nicht etwa die 2 tatsächlich vorhandenen — an der
|
|
echten, ausgelieferten Policy gemessen, nicht an einer im Werkzeug
|
|
nachgebauten Hilfstabelle.
|
|
|
|
**Die Transaktionsmessung — namentliches Ergebnis.** Drei Formen wurden
|
|
gegen einen Client beobachtet, der die Erweiterungsform aus
|
|
`prisma-tenant.extension.ts` wortgleich nachbaut, jeweils mit
|
|
`pg_backend_pid()` und `current_tenant_id()` in jeder Teilabfrage plus einem
|
|
echten Lesezugriff auf `"Group"`:
|
|
|
|
```
|
|
[beobachtet] Form (i) — Array-Form auf gebundenem Client: {"step1":{"pid":276749,"t":"TENANT-A"},"step2":{"pid":276750,"t":"TENANT-A","rows":1}}
|
|
[beobachtet] Form (ii) — interaktive Callback-Form auf gebundenem Client: {"step1":{"pid":276752,"t":"TENANT-A"},"step2":{"pid":276752,"t":"TENANT-A","rows":1}}
|
|
[beobachtet] Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx): {"step1":{"pid":276753,"applied":"TENANT-A","t":"TENANT-A"},"step2":{"pid":276753,"t":"TENANT-A","rows":1}}
|
|
mindestens-eine-transaktionsform-traegt-den-mandantenkontext: bestanden — bestanden: [Form (ii) ; Form (iii)] — nicht bestanden: [Form (i)]
|
|
```
|
|
|
|
Form (i) (Array-Form auf dem gebundenen Client) versagt eindeutig: `step1`
|
|
lief auf Verbindung 276749, `step2` auf Verbindung 276750 — zwei
|
|
verschiedene physische Verbindungen, obwohl beide Schritte denselben
|
|
Mandantenkontext lasen. Das bestätigt wortgetreu den Vorbehalt aus dem
|
|
Kopfkommentar von `prisma-tenant.extension.ts`: jede Modell-Operation eines
|
|
`$transaction`-Arrays auf dem gebundenen Client dispatcht durch
|
|
`$allOperations` und bekommt dadurch ihre EIGENE Ein-Element-Transaktion —
|
|
mehrere solche Operationen laufen auf mehreren Teiltransaktionen statt
|
|
einer gemeinsamen. In diesem konkreten Fall lieferte jede Teiltransaktion
|
|
zwar noch den korrekten Mandantenkontext (kein Datenleck), aber die
|
|
Atomarität der äußeren Transaktion ist nicht mehr gegeben — bei einem
|
|
Absturz zwischen den beiden Teiltransaktionen bliebe der Zustand
|
|
inkonsistent.
|
|
|
|
Form (ii) und Form (iii) bestanden beide die im Plan festgelegte
|
|
Einzelmessung (gleiche Verbindungskennung, korrekter Kontext, korrekte
|
|
Zeilenzahl). Über diese Einzelmessung hinaus wurde als zusätzliche,
|
|
sicherheitsrelevante Sorgfaltsprüfung (nicht durch den Plan verlangt, aber
|
|
durch die Tragweite dieses Bereichs geboten) beide Formen unter echter
|
|
Nebenläufigkeit erneut gemessen — 40 parallele Aufrufe über EINEN
|
|
gemeinsamen Client, alternierend TENANT-A/TENANT-B.
|
|
|
|
Diese Belastungsprobe lief zunächst gegen eine separate Experiment-Datenbank
|
|
und war damit **nicht nachvollziehbar** — eine Zahl, die eine Entscheidung
|
|
trug, ohne dass jemand sie hätte nachprüfen können. Genau das Anti-Muster,
|
|
das dieses Projekt sich selbst verboten hat. Sie ist deshalb als
|
|
`runConcurrencyProbe` in `apps/api/scripts/rls-scratch-check.mjs`
|
|
nachgereicht worden und läuft seither bei jedem Werkzeuglauf mit. Als
|
|
Verletzung zählt beides: ein Aufruf, der einen fremden oder gar keinen
|
|
Mandantenkontext sieht, und ein Aufruf, der abbricht.
|
|
|
|
Gemessen wird, nicht behauptet:
|
|
|
|
- **Form (ii)** bricht unter dieser Last ab, mit Fehlern der Familie
|
|
`PrismaClientKnownRequestError: Transaction API error` (Code `P2028`).
|
|
Ursache: jede
|
|
`tx.$queryRaw`-Anweisung innerhalb der interaktiven Transaktion auf dem
|
|
gebundenen Client löst selbst wieder eine VERSCHACHTELTE
|
|
Array-Transaktion auf dem äußeren, ungebundenen Client aus (weil
|
|
`$allOperations` bei jedem Aufruf erneut feuert) — die äußere
|
|
interaktive Transaktion UND jede innere Verschachtelung belegen
|
|
gleichzeitig eine Verbindung aus demselben, endlichen Pool. Unter Last
|
|
reicht der Pool nicht mehr aus.
|
|
- **Form (iii)** besteht dieselbe Belastung ohne Verletzung — sie belegt
|
|
pro Aufruf genau eine Verbindung, ohne Verschachtelung.
|
|
|
|
Die konkreten Zahlen eines einzelnen Laufs stehen bewusst NICHT in diesem
|
|
Dokument, sondern fallen bei jeder Ausführung neu an; der Werkzeuglauf vom
|
|
2026-09-09 ergab 24 Verletzungen von 40 für Form (ii) und 0 von 40 für
|
|
Form (iii). **Geprüft** wird nur die Eigenschaft, auf die sich der Code
|
|
stützt — Form (iii) ohne Verletzung. Das Verhalten von Form (ii) läuft
|
|
daneben als ausgedruckte Beobachtung mit und ist bewusst KEINE Bedingung
|
|
für einen grünen Lauf: ab welcher Last sie bricht, hängt an
|
|
Verbindungsvorrat und Maschine.
|
|
|
|
Das ist der entscheidende Befund für die Werkzeugentscheidung in Aufgabe 2:
|
|
obwohl Form (ii) die im Plan geforderte EINZELMESSUNG technisch besteht,
|
|
ist sie unter echter Nebenläufigkeit strukturell fragil und ein
|
|
Denial-of-Service-Risiko genau an der Stelle, die T-JTS-08 benennt (die
|
|
Startreparatur ruft `ensureDefaultGroup` für mehrere Mandanten auf). Form
|
|
(iii) ist die einzige der drei Formen, die sowohl die Einzelmessung als
|
|
auch die Belastungsprobe besteht — sie ist deshalb die Grundlage des neuen
|
|
Hilfsmittels `withTenantTransaction` in `prisma-tenant.extension.ts`.
|
|
|
|
### (g2) Signaltabelle je umzustellendem Pfad
|
|
|
|
| Pfad | Verhalten bei zu wenig Ergebnis | Konkretes Signal, Ort |
|
|
|---|---|---|
|
|
| `GroupsService.listForTenant` | Liefert 0 Gruppen statt der tatsächlich vorhandenen | Die Gruppenliste in der Verwaltung ist leer (Mitgliederzahl je Zeile fehlt ganz, weil die Zeile fehlt) |
|
|
| `GroupsService.getImpact` | Liefert `{memberCount:0, grantCount:0}` statt der tatsächlichen Zahlen | Der Löschdialog zeigt seine zwei Zahlen als 0/0 — der Administrator entscheidet über eine kaskadierende Löschung auf falscher Grundlage |
|
|
| `GroupsService.listMembers` | Liefert 0 Mitglieder statt der tatsächlich vorhandenen | Die Mitgliederliste im Gruppen-Detail ist leer |
|
|
| `ModuleGrantsService.getMatrix` | Liefert 0 Module und/oder 0 Gruppen statt der tatsächlich aktiven/vorhandenen | Die Freigabe-Matrix zeigt weder Modul- noch Gruppenachse vollständig — eine leere Zelle sieht identisch aus wie eine bewusst nicht erteilte Freigabe |
|
|
| `ModuleGrantsService.getUserAccess` | Liefert 0 Module bzw. 0 Gruppen statt der tatsächlichen | Das Benutzer-Detail zeigt für beide unabhängigen Antworten (Gruppenmitgliedschaften, Modulzugriff) fälschlich "keine" |
|
|
| `LdapService.syncBoundGroupsForTenant` (Übergabe an `reassignDefaultBeforeDelete`/`ensureDefaultGroup`) | Der Zähler `defaultMarkerMoved` im Abgleich-Bericht bleibt bei 0, obwohl tatsächlich verschoben wurde — oder eine Gruppe verliert ihre Standardmarkierung ersatzlos | Der Abgleich-Bericht des Verzeichnis-Syncs (D-05/D-06) |
|
|
| `GroupsService.addUserToDefaultGroup` | Findet die Standardgruppe nicht (0 Zeilen statt der einen vorhandenen) und tut nichts | Ein frisch angelegter Benutzer sieht nach seiner ersten Anmeldung KEIN Modul (leere Modulkacheln) |
|
|
|
|
### (g3) Welcher Code deutet Leere als Abwesenheit — Bereich groups
|
|
|
|
Ausgangspunkt ist Befund I aus der Planung, ergänzt um eine erneute Sichtung
|
|
beider Dateien:
|
|
|
|
- **`GroupsService.reassignDefaultBeforeDelete`** — zerstörend und still.
|
|
Zwei getrennte Stellen liefern `false`: die Gruppe selbst ist nicht
|
|
sichtbar (`findFirst` auf `Group` liefert 0 Zeilen), oder es ist kein
|
|
Ersatzkandidat sichtbar (beide `findFirst`-Fallbacks liefern 0 Zeilen).
|
|
Der Aufrufer im Verzeichnis-Sync (`syncBoundGroupsForTenant`) löscht die
|
|
Gruppe danach in BEIDEN Fällen trotzdem, und die Löschung nimmt über die
|
|
Kaskadenregeln aus 15-01 Mitgliedschaften und Modulfreigaben mit. Das ist
|
|
Befund D aus der ldap-Kritik (Abschnitt (e) oben); ihn zu schließen ist
|
|
ein Hauptzweck dieses Durchlaufs.
|
|
- **`GroupsService.ensureDefaultGroup`** — die einzige Stelle des Bereichs,
|
|
an der zu wenig Lesen zu ZU VIEL Schreiben führt. Der Wächter ist
|
|
UMGEKEHRT gepolt: null gelesene Gruppen (`group.count` liefert 0 statt
|
|
der tatsächlichen Anzahl) heißt hier nicht "nichts zu tun", sondern
|
|
"alles neu aufbauen". Bliebe der Zähler ungebunden, während der
|
|
Schreibteil (die Transaktion) gebunden liefe, legte die Methode für einen
|
|
Mandanten, der bereits Gruppen hat, eine ZWEITE Standardgruppe an, nähme
|
|
ALLE seine Benutzer als Mitglieder auf und verteilte Freigaben für ALLE
|
|
aktiven Module — eine stille Ausweitung von Berechtigungen, ausgelöst
|
|
durch ein zu kleines Leseergebnis. Der partielle Eindeutigkeitsindex
|
|
`Group_one_default_per_tenant` fängt einen Teil der Fälle ab (P2002 beim
|
|
`group.create`, abgefangen und in `null` übersetzt) — aber nur, wenn der
|
|
Mandant BEREITS eine markierte Standardgruppe hat. Einen Mandanten mit
|
|
Gruppen, aber OHNE markierte Standardgruppe, fängt der Index nicht ab.
|
|
Genau deshalb müssen Zähler und Transaktion GEMEINSAM gebunden werden,
|
|
nie einzeln (T-JTS-05).
|
|
- **`GroupsService.getImpact`** — die Zahlen des Löschdialogs. Zwei
|
|
Zählungen (`groupMembership.count`, `moduleGrant.count`) ohne
|
|
Mandantenfilter, die bei Leere 0 und 0 melden. Der Administrator
|
|
entscheidet auf dieser Grundlage über eine kaskadierende Löschung und
|
|
bekommt "keine Mitglieder, keine Freigaben" für eine tatsächlich volle
|
|
Gruppe angezeigt.
|
|
- **`GroupsService.addUserToDefaultGroup`** — stilles Zurückkehren ohne
|
|
sichtbare Standardgruppe (`group.findFirst` liefert 0 Zeilen statt der
|
|
einen vorhandenen). Jeder neu angelegte Benutzer landet dann in KEINER
|
|
Gruppe und sieht nach seiner ersten Anmeldung kein einziges Modul. Nicht
|
|
zerstörend, aber lautlos und in der Wirkung ein Berechtigungsverlust.
|
|
|
|
Gegenrichtung, ebenfalls festgehalten: `ModuleGrantsService.grant` (die
|
|
Aktivierungsprüfung: kein aktives `TenantModuleActivation` wirft
|
|
`BadRequestException`, statt still zu erteilen) und
|
|
`ModuleGrantsService.assertTargetBelongsToTenant` (die
|
|
Mandanten-Gegenprüfung: kein Treffer wirft `NotFoundException`) werfen bei
|
|
Leere LAUT und sind damit die harmlosen Stellen des Bereichs.
|
|
|
|
Entlastung, ausdrücklich am Frontend nachgesehen statt aus dem Backend
|
|
geschlossen: `apps/web/src/app/(portal)/admin/modules/grants/page.tsx`
|
|
schaltet die Freigabe-Matrix je Zelle einzeln (ein `POST` bzw. `DELETE` pro
|
|
Klick) — es gibt keinen Sammel-Speichern-Knopf, der einen Abgleich gegen den
|
|
gelesenen Zustand fährt. Ein zu kleines Leseergebnis führt dort also zu
|
|
einer leeren Anzeige, nicht zu einem Massen-Entzug. Das ist der Unterschied
|
|
zum `deleteMany`-mit-`notIn` des ldap-Bereichs.
|
|
|
|
### (g4) Was dieser Durchlauf bewusst nicht löst
|
|
|
|
- **Die offene Architekturfrage `req.tenantPrisma`.** Auch der Bereich
|
|
`groups` entscheidet sie nicht — er bindet dienst-intern, wie `ldap` und
|
|
`auth.service.ts` es vormachen.
|
|
- **Die Policies auf `GroupMembership` und `ModuleGrant` prüfen jeweils nur
|
|
eine Seite.** Gemessen in (g1): `GroupMembership` prüft ausschließlich die
|
|
Gruppenseite (Befund E, T-JTS-02) — eine Mitgliedschaft mit einer
|
|
Benutzerkennung, die es im Mandanten der Gruppe nicht gibt, verletzt die
|
|
Policy NICHT. `ModuleGrant` prüft ausschließlich die Mandantenkennung der
|
|
Zeile selbst (Befund F, T-JTS-03) — eine Freigabe mit korrekter eigener
|
|
Mandantenkennung, aber einer fremden Gruppenkennung, verletzt die Policy
|
|
ebenfalls NICHT. Die Anwendungsprüfungen (die Benutzerfilterung in
|
|
`addUserToDefaultGroup`, `assertTargetBelongsToTenant`) bleiben deshalb
|
|
der primäre Schutz gegen diese beiden Formen der Rechteausweitung und
|
|
werden durch diesen Durchlauf NICHT durch die Datenbank ersetzt.
|
|
|
|
### (g5) Fortschreibung des ldap-Abschnitts
|
|
|
|
Der in Abschnitt (e) oben als offen geführte Befund D — die Übergabe der
|
|
Standardgruppe vor einer Gruppenlöschung — wird durch diesen Durchlauf
|
|
geschlossen. Der Vermerk selbst steht am Ende von Abschnitt (e), gesetzt
|
|
in Aufgabe 3 dieses Plans, weil die Schließung erst zu diesem Zeitpunkt
|
|
tatsächlich vorliegt.
|
|
|
|
## Verweis
|
|
|
|
Die Bestandsaufnahme, welche Fundstelle den hier beschriebenen Übergang
|
|
bereits vollzogen hat (Stand-Spalte `gebunden`/`ungebunden`/`gemischt`),
|
|
steht in `docs/mandantentrennung-zugriffsklassifikation.md`.
|