Files
tessera-ctl/docs/mandantentrennung-etappe2-fehlerrichtung.md
T
schalli abb6c8bea3 feat(jts-03): module-grants.service.ts binden, beide Dokumente schliessen
Alle fuenf Methoden von ModuleGrantsService (assertTargetBelongsToTenant,
grant, revoke, getMatrix, getUserAccess) laufen jetzt ueber forTenant(); bei
den beiden Datenlieferungen teilen sich alle parallel abgesetzten
Teilabfragen denselben gebundenen Client. Die Mandanten-Gegenpruefung vor
jedem Erteilen bleibt ausdruecklich bestehen und bekommt einen Verweis auf
Befund F/T-JTS-03: die Regel auf ModuleGrant prueft nur die
Mandantenkennung der Zeile, nicht die referenzierte Gruppe. Der veraltete
Kommentar ueber der Mitgliedschaftsabfrage im Benutzer-Detail ("kein
forTenant hier") ist durch den neuen Stand ersetzt.

module-grants.service.spec.ts bekommt denselben Bindungsnachweis-Mock wie
groups.service.spec.ts (zwei unterscheidbare Clients ueber demselben
Speicher) und sechs neue Bindungsnachweise; alle 28 Bestandstests bleiben
gruen.

Beide Dokumente geschlossen: die Bereichsuebersicht fuer groups ist neu
gemessen (0 ungebunden, 31 gebunden — ein dokumentierter methodischer
Bodensatz, da die einfache Rohtrefferzaehlung die neun ueber `tx` gebundenen
Zugriffe innerhalb der drei Transaktionen nicht sieht), die
Klassen-Verteilung auf 62 Paare aktualisiert, und der als offen gefuehrte
Befund D aus dem ldap-Abschnitt der Fehlerrichtung ist mit Verweis auf
diesen Durchlauf als erledigt vermerkt (Nachtrag, nicht Neuschrieb). 743
Tests und die Typpruefung gruen; Schema, Migrationen und alle vier
Compose-Dateien unveraendert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-09 15:07:09 +02:00

25 KiB

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, alternierend TENANT-A/TENANT-B, gegen eine separate Experiment-Datenbank mit derselben Struktur:

  • Form (ii) brach unter dieser Last mit PrismaClientKnownRequestError: Transaction API error: Unable to start a transaction in the given time. (Code P2028) ab. 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) bestand dieselbe Belastung mit 0 Verletzungen unter 40 parallelen Aufrufen — sie belegt pro Aufruf genau eine Verbindung, ohne Verschachtelung.

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.