feat(jts-01): messen statt annehmen — groups-Policies und Transaktionsform vor jedem Dienstcode
rls-scratch-check.mjs bekommt einen vierten Abschnitt (Group/GroupMembership/ ModuleGrant/TenantModuleActivation, Policies woertlich aus den ausgelieferten Migrationen) sowie eine eigene Messung, welche der drei Transaktionsformen (Array auf gebundenem Client, interaktiv auf gebundenem Client, interaktiv auf ungebundenem Client mit set_config auf tx) den Mandantenkontext tatsaechlich auf derselben Verbindung traegt. Ergebnis: Form (i) versagt nachweisbar (unterschiedliche pg_backend_pid() je Teilschritt); Form (ii) und (iii) bestehen die Einzelmessung, aber eine zusaetzliche Lastprobe mit 40 parallelen Aufrufen zeigt, dass Form (ii) unter echter Nebenlaeufigkeit mit P2028 (Transaction API error) abbricht, waehrend Form (iii) 0 Verletzungen zeigt. Die Kritikschrift bekommt einen eigenen groups-Abschnitt mit den tatsaechlich beobachteten Werten, der Signaltabelle je Pfad und der Liste der Stellen, die Leere als Abwesenheit deuten (inkl. der einen Stelle, an der zu wenig Lesen zu viel Schreiben ausloest). Kein Dienstcode angefasst; 719 Tests und die Typpruefung bleiben gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -157,6 +157,197 @@ interpretieren:
|
||||
`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
|
||||
|
||||
Reference in New Issue
Block a user