255 lines
23 KiB
Markdown
255 lines
23 KiB
Markdown
---
|
|
phase: quick-260805-d0r
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- apps/api/src/groups/module-grants.service.ts
|
|
- apps/api/src/groups/module-grants.service.spec.ts
|
|
- apps/api/src/groups/module-grants.controller.ts
|
|
- apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx
|
|
- apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx
|
|
autonomous: false
|
|
requirements:
|
|
- 260805-d0r
|
|
must_haves:
|
|
truths:
|
|
- "GET /module-grants/users/:userId liefert ein Objekt mit genau zwei Schluesseln: `groups` (Gruppenmitgliedschaften des Benutzers) und `modules` (die bisherige Liste). Ein Eintrag in `modules` behaelt exakt die Form { module, viaGroups, direct } — viaGroups bleibt unveraendert und weiterhin je Modul befuellt (D-16)."
|
|
- "`groups` wird aus GroupMembership-Zeilen des Benutzers abgeleitet, NICHT aus ModuleGrant. Eine Mitgliedschaft in einer Gruppe ohne jede Modul-Freigabe erscheint trotzdem in `groups`."
|
|
- "Werden alle Grants einer Gruppe entzogen, bleibt der Gruppen-Chip im Benutzer-Detail stehen — genau der reproduzierte Defekt ist damit geschlossen."
|
|
- "Jeder Chip traegt die Herkunft der Mitgliedschaft (MANUAL/LDAP) ueber die BESTEHENDEN Schluessel admin.groups.members.sourceManual / admin.groups.members.sourceLdap (D-19/D-20). Es entsteht kein neuer i18n-Schluessel; de.json und en.json werden nicht angefasst."
|
|
- "assertTargetBelongsToTenant laeuft weiterhin als erste Anweisung in getUserAccess — eine userId eines fremden Mandanten wirft NotFoundException, bevor irgendeine Mitgliedschaft gelesen wird."
|
|
- "Die Mitgliedschafts-Query ist mandantengescoped ueber `group: { tenantId }`; die Mitgliedschaft eines Benutzers in einer Gruppe eines fremden Mandanten taucht nie in `groups` auf."
|
|
artifacts:
|
|
- "apps/api/src/groups/module-grants.service.ts — getUserAccess liefert { groups, modules }, neue groupMembership.findMany-Abfrage mit Mandanten-Scope."
|
|
- "apps/api/src/groups/module-grants.service.spec.ts — Fake-Prisma um groupMembership.findMany erweitert, Regressionstest 'Gruppe ohne Grant bleibt sichtbar', Cross-Tenant-Test, LDAP-Herkunfts-Test."
|
|
- "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx — Chip-Liste aus data.groups statt aus viaGroups, mit Herkunfts-Badge."
|
|
- "apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx — Fixtures auf die neue Antwortform umgestellt, Regressionstest + Badge-Test."
|
|
key_links:
|
|
- "UserAccessModal-fetch -> data.groups: der Abschnitt Gruppenmitgliedschaften liest die Mitgliedschaftsliste direkt aus der Antwort. Das ist die Bruchstelle des Defekts (bisher Ableitung aus row.viaGroups)."
|
|
- "groupMembership.findMany where { userId, group: { tenantId } }: die Mandantengrenze, die vorher implizit ueber ModuleGrant.tenantId mitkam und jetzt explizit gezogen werden muss."
|
|
- "useTranslations('admin.groups.members') im UserAccessModal: Wiederverwendung der bestehenden Herkunfts-Schluessel haelt de/en deckungsgleich, ohne neue Schluessel."
|
|
---
|
|
|
|
<objective>
|
|
Das Benutzer-Detail in /admin/users zeigt Gruppenmitgliedschaften aus den tatsaechlichen
|
|
Mitgliedschaften (GroupMembership) statt aus den Modul-Freigaben.
|
|
|
|
Reproduzierter Defekt: `ModuleGrantsService.getUserAccess` fragt die Mitgliedschaften des
|
|
Benutzers nie ab, und `UserAccessModal` baut die Chip-Liste aus der Vereinigung aller
|
|
`row.viaGroups`. Eine Gruppe ohne Modul-Freigabe ist damit unsichtbar; entzieht man der
|
|
Gruppe die letzte Freigabe, meldet das Modal "Dieser Benutzer ist keiner Gruppe zugeordnet",
|
|
obwohl die GroupMembership-Zeile unveraendert in der Datenbank steht. Das widerspricht
|
|
Surface Contract 3 der 15-UI-SPEC.md, die den Abschnitt als read-only Chip-Liste der
|
|
Gruppenmitgliedschaften festlegt (D-16).
|
|
|
|
Die Antwort von GET /module-grants/users/:userId wird deshalb von einem Array zu einem Objekt
|
|
{ groups, modules } erweitert. Die Modulzeilen bleiben Form-identisch — `viaGroups` je Modul
|
|
ist korrekt, beantwortet aber eine andere Frage ("welche Gruppe gewaehrt dieses Modul") als der
|
|
Mitgliedschafts-Abschnitt ("in welchen Gruppen ist dieser Benutzer"). Der Chip traegt zusaetzlich
|
|
die Herkunft der Mitgliedschaft, weil LDAP-Mitgliedschaften ausschliesslich vom Sync verwaltet
|
|
werden und manuelle nicht (D-19/D-20); dafuer werden die bereits vorhandenen Schluessel
|
|
admin.groups.members.sourceManual / .sourceLdap wiederverwendet.
|
|
|
|
Purpose: Der Admin sieht im Benutzer-Detail die Wahrheit ueber Gruppenzugehoerigkeit, unabhaengig
|
|
davon, ob diese Gruppen gerade Module freigeben.
|
|
Output: Erweiterte Endpoint-Antwort, Modal-Abschnitt aus der neuen Datenquelle, Regressionstests
|
|
auf beiden Seiten.
|
|
|
|
## Hinweis zu RLS / forTenant (bewusste Abweichung, bitte lesen)
|
|
|
|
Der Auftrag nennt als Constraint, die neue Query muesse ueber `forTenant(...)` laufen, weil auf
|
|
Group / GroupMembership / ModuleGrant RLS aktiv sei und ein direkter `this.prisma.*`-Aufruf
|
|
keine Zeilen saehe. Dieser Plan folgt dem NICHT — begruendet:
|
|
|
|
- Der reproduzierte Defekt selbst ist der Gegenbeweis: solange ein Grant existierte, erschien der
|
|
Chip. Diese Anzeige stammt aus `this.prisma.moduleGrant.findMany` mit dem Relationsfilter
|
|
`group: { memberships: { some: { userId } } }` — die Abfrage liest also ueber den ungescopten
|
|
Client Zeilen aus ModuleGrant, Group UND GroupMembership, obwohl auf allen drei Tabellen laut
|
|
Migration 20260804130918_groups_rls_policies FORCE ROW LEVEL SECURITY steht. Die Datenbankrolle
|
|
umgeht RLS. Ein `forTenant()` ist hier also nicht die Bedingung dafuer, ueberhaupt Zeilen zu
|
|
sehen.
|
|
- Alle drei bestehenden Abfragen in `getUserAccess` und saemtliche Abfragen in `GroupsService`
|
|
laufen ueber `this.prisma` mit explizitem `where`-Mandantenfilter. Der Kopfkommentar von
|
|
`GroupsService` haelt genau das fest: der `where`-Filter ist der primaere Schutz, RLS das zweite
|
|
Netz. Eine einzelne von vier Abfragen derselben Methode auf `forTenant()` umzustellen erzeugt
|
|
zwei Wahrheiten ueber die Mandantenabgrenzung in einer Methode und zwingt zusaetzlich den
|
|
Hand-Fake in module-grants.service.spec.ts zu einem zweiten Mock-Muster.
|
|
- Die Absicht hinter dem Constraint — keine fremden Mandantenzeilen — wird vollstaendig erfuellt:
|
|
die neue Abfrage filtert `group: { tenantId }`, und Task 1 verlangt einen Test, der eine
|
|
Mitgliedschaft in der Gruppe eines fremden Mandanten explizit als nicht enthalten nachweist.
|
|
|
|
Sollte sich beim Browser-Check (Task 3) zeigen, dass die Chips leer bleiben, obwohl die
|
|
GroupMembership-Zeilen per psql da sind, waere das das Gegenzeichen: dann — und nur dann —
|
|
`getUserAccess` vollstaendig (alle vier Abfragen, nicht nur die neue) auf `forTenant(this.prisma,
|
|
tenantId)` umstellen und den Spec-Fake wie in ldap.service.spec.ts mocken.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/STATE.md
|
|
@CLAUDE.md
|
|
|
|
# Achtung: die Stack-Tabelle in CLAUDE.md ist veraltet — installiert sind Prisma 6.19.3 und Next.js 15.5.19.
|
|
|
|
@apps/api/src/groups/module-grants.service.ts
|
|
@apps/api/src/groups/module-grants.service.spec.ts
|
|
@apps/api/src/groups/module-grants.controller.ts
|
|
@apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx
|
|
@apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx
|
|
|
|
# Vorbild fuer das Herkunfts-Badge-Markup (Chip mit Badge, Zeilen 167-199):
|
|
@apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx
|
|
|
|
# Surface Contract 3 (Zeilen 149-156) und Copywriting Contract (Zeile 106):
|
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="tracer" tdd="true">
|
|
<name>Task 1: Mitgliedschaften end-to-end — Endpoint liefert groups, Modal rendert sie (D-16)</name>
|
|
<files>apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/api/src/groups/module-grants.controller.ts, apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx, apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx</files>
|
|
<behavior>
|
|
API (module-grants.service.spec.ts):
|
|
- getUserAccess liefert ein Objekt mit den Schluesseln groups und modules; modules enthaelt
|
|
weiterhin je aktivem Modul { module, viaGroups, direct } in der bisherigen Sortierung
|
|
(category, dann name) — die beiden bestehenden getUserAccess-Tests bleiben inhaltlich
|
|
gueltig und greifen nur noch auf result.modules zu.
|
|
- REGRESSION: Benutzer u1 ist Mitglied von g1, g1 hat KEINEN Grant. Erwartung: groups ist
|
|
[{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }] und jedes modules[].viaGroups ist leer.
|
|
Dieser Test faellt gegen den heutigen Code durch.
|
|
- Cross-Tenant: eine Mitgliedschaft von u1 in einer Gruppe des Mandanten t2 erscheint bei
|
|
getUserAccess('t1','u1') nicht in groups.
|
|
- Herkunft: eine mit source 'LDAP' geseedete Mitgliedschaft kommt mit source 'LDAP' zurueck.
|
|
- Ohne aktives Modul im Mandanten: modules ist [], groups trotzdem befuellt.
|
|
- Unveraendert: eine userId eines fremden Mandanten wirft NotFoundException.
|
|
- Sortierung: groups ist alphabetisch nach name stabil ueber wiederholte Aufrufe.
|
|
|
|
Web (user-access-modal.test.tsx):
|
|
- REGRESSION: Antwort mit groups: [{ id:'g1', name:'Alle Benutzer', source:'MANUAL' }] und
|
|
Modulzeilen, deren viaGroups saemtlich leer sind — der Chip "Alle Benutzer" ist sichtbar,
|
|
der Leertext erscheint NICHT.
|
|
- Leerzustand: groups: [] rendert "Dieser Benutzer ist keiner Gruppe zugeordnet."
|
|
- Ohne aktive Module: modules: [] bei befuellten groups zeigt gleichzeitig die Chips und den
|
|
Hinweis "Fuer diesen Mandanten sind keine Module aktiviert."
|
|
- Unveraendert gueltig: Modultabelle mit einer geerbten und einer nicht geerbten Zeile,
|
|
Rollback plus sichtbare Fehlermeldung beim fehlgeschlagenen Direkt-Toggle, aria-label an
|
|
jeder Direkt-Checkbox.
|
|
</behavior>
|
|
<action>
|
|
API — apps/api/src/groups/module-grants.service.ts, Methode getUserAccess: assertTargetBelongsToTenant bleibt unveraendert die erste Anweisung. Nimm in das bestehende Promise.all eine vierte Abfrage auf, this.prisma.groupMembership.findMany, mit where userId und group tenantId (also der Mandantenfilter ueber die Relation, weil GroupMembership keine eigene tenantId-Spalte hat) und include der Relation group beschraenkt auf id und name. Kein forTenant — Begruendung steht im objective-Abschnitt dieses Plans, uebernimm sie sinngemaess als Kommentar an der neuen Abfrage.
|
|
|
|
Baue daraus eine Liste groups aus Objekten mit id (die Gruppen-ID, nicht die Mitgliedschafts-ID), name und source, und sortiere sie in JavaScript per localeCompare nach name — bewusst wie die bestehende Modulsortierung derselben Methode und nicht ueber ein orderBy auf einer Relation, damit der Hand-Fake im Spec eine einfache Sortierregel abbilden kann. Aendere den Rueckgabewert von der bisherigen Liste auf ein Objekt mit den Schluesseln groups und modules; modules ist exakt das bisherige map-Ergebnis, unveraendert in Feldern und Reihenfolge.
|
|
|
|
Schreibe den JSDoc-Block ueber getUserAccess neu: er beschreibt heute nur die Modulzeilen. Er muss jetzt festhalten, dass groups aus GroupMembership stammt und bewusst unabhaengig von ModuleGrant ist, dass eine Gruppe ohne Freigabe deshalb sichtbar bleibt, und dass viaGroups je Modul die andere Frage beantwortet (welche Gruppe gewaehrt dieses Modul). Passe ebenso den JSDoc-Block ueber der Route GET users/:userId in apps/api/src/groups/module-grants.controller.ts an die Zwei-Schluessel-Antwort an — der Controller-Code selbst aendert sich nicht, er reicht die Service-Antwort weiter.
|
|
|
|
API-Spec — apps/api/src/groups/module-grants.service.spec.ts: erweitere makeFakePrisma um einen groupMembership-Zweig mit findMany. Die Memberships liegen dort bereits als Map groupId auf Set userId; ergaenze eine parallele Ablage fuer die Herkunft je Paar, und erweitere __seedMembership um einen optionalen dritten Parameter source mit Vorgabewert MANUAL, damit die bestehenden Aufrufe unveraendert bleiben. findMany filtert auf where.userId und where.group.tenantId gegen die groups-Map und liefert Objekte mit source und einer eingebetteten group aus id und name — dieselbe Form, die der echte Prisma-Client mit dem include liefert.
|
|
|
|
Stelle die beiden bestehenden getUserAccess-Tests auf result.modules um und ergaenze in ihnen je eine Zusicherung auf result.groups. Ergaenze danach die unter behavior gelisteten neuen Faelle als eigene it-Bloecke im describe-Block ModuleGrantsService.getUserAccess. Schreibe zuerst den Regressionsfall, lass ihn gegen den unveraenderten Service rot laufen, und implementiere erst dann die Service-Aenderung.
|
|
|
|
Web — apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx: fuege ein Interface fuer einen Mitgliedschafts-Chip hinzu, mit id, name und source als Vereinigungstyp aus den beiden Literalen MANUAL und LDAP, sowie ein Interface fuer die Antwort mit groups und modules. Halte zwei Zustaende: die bisherigen rows fuer die Modultabelle und einen neuen Zustand fuer die Chips. Der useEffect setzt beide aus der Antwort; der catch-Zweig setzt beide auf leere Listen. Entferne das useMemo, das groupNames aus row.viaGroups zusammensetzt, samt des ungenutzt werdenden useMemo-Imports. Die Chip-Liste rendert jetzt ueber den neuen Zustand, mit der Gruppen-ID als React-key statt des Namens; Markup und Klassen des li-Elements bleiben unveraendert, der Leerzustand und alle Uebersetzungsschluessel des Abschnitts bleiben ebenfalls unveraendert. toggleDirect und die Modultabelle bleiben unangetastet.
|
|
|
|
Der Kopfkommentar der Datei behauptet derzeit, die Chips seien die deduplizierte Menge aller viaGroups-Namen und ein zweiter Endpoint existiere bewusst nicht. Formuliere ihn neu: eine Antwort, zwei Abschnitte, Chips aus den Mitgliedschaften der Antwort, viaGroups weiterhin je Modulzeile. Der alte Ableitungssatz darf nicht stehenbleiben, sonst widerspricht die Datei sich selbst.
|
|
|
|
Web-Test — apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx: stelle die Fixture mockAccessRows auf die Objektform um (benenne sie passend, etwa mockAccess, mit groups und modules) und ziehe alle fetch-Stubs nach. Ergaenze die unter behavior gelisteten Faelle. Nutze im Regressionsfall bewusst eine Fixture, in der keine Modulzeile den Gruppennamen traegt — dann ist der gefundene Text eindeutig der Chip und nicht die Spalte Ueber Gruppe(n).
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @tessera/api test -- module-grants</automated>
|
|
<automated>pnpm --filter @tessera/api run type-check</automated>
|
|
<automated>pnpm --filter @tessera/web test -- user-access-modal</automated>
|
|
<automated>pnpm --filter @tessera/web run type-check</automated>
|
|
</verify>
|
|
<done>Beide Testdateien sind gruen und enthalten je den Regressionsfall "Mitglied einer Gruppe ohne Modul-Freigabe bleibt sichtbar"; die API-Spec enthaelt zusaetzlich den Cross-Tenant-Fall und den LDAP-Herkunfts-Fall. GET /module-grants/users/:userId liefert { groups, modules }; die Eintraege in modules sind feldgleich zu vorher. Beide type-checks sind sauber.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Herkunfts-Badge am Chip mit den bestehenden i18n-Schluesseln (D-19/D-20)</name>
|
|
<files>apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx, apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx</files>
|
|
<behavior>
|
|
- Ein Chip mit source LDAP zeigt zusaetzlich zum Gruppennamen das Badge "LDAP".
|
|
- Ein Chip mit source MANUAL zeigt das Badge "Manuell".
|
|
- Beide Beschriftungen kommen aus dem Namensraum admin.groups.members (Schluessel
|
|
sourceLdap / sourceManual); der Test mockt genau diesen Namensraum und keinen neuen.
|
|
- de.json und en.json bleiben deckungsgleich und unveraendert — es kommt kein Schluessel hinzu.
|
|
</behavior>
|
|
<action>
|
|
Ergaenze in apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx einen zweiten Uebersetzungs-Hook auf den Namensraum admin.groups.members, benannt etwa tMembers, neben den bestehenden Hooks. Rendere im Chip-li nach dem Namens-span ein Badge-span mit exakt dem Markup und den Klassen aus GroupMembersModal.tsx Zeilen 176 bis 184: bedingte Klassenliste, blaue Variante bei LDAP, neutral-graue sonst, Beschriftung aus sourceLdap beziehungsweise sourceManual. Kopiere die Klassen woertlich, damit beide Oberflaechen dasselbe Badge zeigen — es entsteht keine zweite Badge-Variante im Projekt.
|
|
|
|
Es wird KEIN neuer Uebersetzungsschluessel angelegt; apps/web/src/messages/de.json und apps/web/src/messages/en.json bleiben in diesem Task unveraendert. Die vier Schluessel existieren bereits in beiden Dateien.
|
|
|
|
Ergaenze im Mock-Objekt messages von user-access-modal.test.tsx den Namensraum admin.groups.members mit sourceManual und sourceLdap (die resolve-Hilfsfunktion arbeitet bereits namensraumbasiert, sie braucht keine Aenderung), und ergaenze die beiden unter behavior beschriebenen Zusicherungen — am einfachsten als ein zusaetzlicher it-Block mit einer Fixture aus zwei Gruppen, eine je Herkunft.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @tessera/web test -- user-access-modal</automated>
|
|
<automated>node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v!==null?walk(v,p+k+'.'):[p+k]);const a=walk(de).sort(),b=walk(en).sort();const miss=a.filter(k=>!b.includes(k)).concat(b.filter(k=>!a.includes(k)));if(miss.length){console.error('Abweichende Schluessel:',miss);process.exit(1)}console.log('i18n deckungsgleich:',a.length,'Schluessel')"</automated>
|
|
<automated>node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');for(const m of [de,en]){const s=m.admin.groups.members;if(!s.sourceManual||!s.sourceLdap){console.error('Herkunfts-Schluessel fehlen');process.exit(1)}}console.log('bestehende Herkunfts-Schluessel in de+en vorhanden')"</automated>
|
|
<automated>grep -q "admin.groups.members" "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx" && echo namespace-ok</automated>
|
|
<automated>pnpm --filter @tessera/web run type-check</automated>
|
|
</verify>
|
|
<done>Der Test fuer user-access-modal ist gruen und deckt beide Herkunftsbadges ab; de.json und en.json sind unveraendert und deckungsgleich; web type-check ist sauber.</done>
|
|
</task>
|
|
|
|
<task type="checkpoint:human-verify" gate="blocking">
|
|
<name>Task 3: Browser-Gegenprobe des reproduzierten Defekts</name>
|
|
<what-built>Der Abschnitt Gruppenmitgliedschaften im Benutzer-Detail liest jetzt die Mitgliedschaften aus GET /module-grants/users/:userId (Schluessel groups) statt sie aus den Modul-Freigaben abzuleiten; jeder Chip traegt zusaetzlich die Herkunft MANUAL oder LDAP.</what-built>
|
|
<how-to-verify>
|
|
Der Defekt wurde im Browser gegen den laufenden lokalen Stack gefunden — dieselbe Strecke bitte einmal rueckwaerts fahren. API und Web muessen dafuer mit dem neuen Stand laufen (lokal, kein Deploy auf den Testserver).
|
|
|
|
1. /admin/users oeffnen, bei einem Benutzer, der Mitglied mindestens einer Gruppe ist, auf "Details" klicken. Erwartung: der Gruppen-Chip erscheint, mit Badge "Manuell" beziehungsweise "LDAP".
|
|
2. Modal schliessen, /admin/modules/grants oeffnen und dieser Gruppe JEDE Modul-Freigabe entziehen (alle Haekchen der Gruppenspalte leeren).
|
|
3. Zurueck auf /admin/users, erneut "Details" beim selben Benutzer. Erwartung: der Gruppen-Chip steht unveraendert samt Badge da. Der Text "Dieser Benutzer ist keiner Gruppe zugeordnet." darf NICHT erscheinen. In der Tabelle Modul-Zugriff steht in der Spalte Ueber Gruppe(n) jetzt korrekt ueberall der Gedankenstrich.
|
|
4. Gegenprobe Leerzustand: einen Benutzer ohne jede Gruppenmitgliedschaft oeffnen. Erwartung: der Leertext erscheint.
|
|
5. Optional, falls das Ergebnis von Schritt 3 abweicht (Chips leer, obwohl die GroupMembership-Zeilen per psql vorhanden sind): das ist das im objective beschriebene Gegenzeichen zur RLS-Annahme — dann getUserAccess vollstaendig auf forTenant umstellen, nicht nur die neue Abfrage.
|
|
</how-to-verify>
|
|
<resume-signal>Mit "approved" bestaetigen oder die Abweichung beschreiben.</resume-signal>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Admin-Browser -> GET /module-grants/users/:userId | userId kommt als Pfadparameter aus dem Client, tenantId ausschliesslich aus dem JWT |
|
|
| API -> Datenbank (Group / GroupMembership) | Neue Leseabfrage auf einer Tabelle ohne eigene tenantId-Spalte, Mandantengrenze nur ueber die Relation |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
| T-d0r-01 | Information Disclosure | getUserAccess, neue groupMembership.findMany | medium | mitigate | Doppelte Absicherung: assertTargetBelongsToTenant bleibt die erste Anweisung der Methode (fremde userId wirft NotFoundException, bevor gelesen wird), und die Abfrage filtert zusaetzlich ueber `group: { tenantId }`. Task 1 verlangt einen Spec-Fall, der eine Mitgliedschaft in der Gruppe eines fremden Mandanten explizit als nicht enthalten nachweist. |
|
|
| T-d0r-02 | Information Disclosure | Herkunfts-Badge im Benutzer-Detail | low | accept | MANUAL/LDAP ist eine bereits im Gruppen-Detail derselben Rolle sichtbare Information (GroupMembersModal); die Route ist auf ADMIN/SUPER_ADMIN beschraenkt. Keine zusaetzliche Offenlegung. |
|
|
| T-d0r-03 | Elevation of Privilege | Geaenderte Antwortform | low | accept | `groups` ist reine Anzeige. Die Leseseite des Zugriffs (ModuleAccessService.getAccessibleModuleIds) wird nicht angefasst, und aus der Chip-Liste wird keine Berechtigung abgeleitet. |
|
|
|
|
Keine Paketinstallation in diesem Task (npm/pip/cargo) — das Supply-Chain-Gate mit Legitimacy-Checkpoint entfaellt.
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `pnpm --filter @tessera/api test -- module-grants` gruen, inklusive Regressionsfall, Cross-Tenant-Fall und LDAP-Herkunfts-Fall.
|
|
- `pnpm --filter @tessera/web test -- user-access-modal` gruen, inklusive Regressionsfall und beider Herkunftsbadges.
|
|
- `pnpm --filter @tessera/api run type-check` und `pnpm --filter @tessera/web run type-check` sauber.
|
|
- `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollstaendig gruen (keine Kollateralschaeden durch die geaenderte Antwortform).
|
|
- de.json / en.json unveraendert und deckungsgleich — kein neuer i18n-Schluessel.
|
|
- Keine Prisma-Schemaaenderung, keine Migration, kein `prisma db push`, kein neuer Endpoint, kein Deploy auf den Testserver — der git-Diff beruehrt ausschliesslich die fuenf gelisteten Dateien.
|
|
- Browser-Check aus Task 3 durch den Benutzer bestaetigt.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Der Abschnitt Gruppenmitgliedschaften zeigt die Gruppen des Benutzers, unabhaengig davon, ob diese Gruppen Module freigeben — der reproduzierte Defekt ist geschlossen.
|
|
- GET /module-grants/users/:userId liefert { groups, modules }; die Modulzeilen inklusive viaGroups sind feldgleich zu vorher.
|
|
- Jeder Chip traegt das Herkunfts-Badge MANUAL/LDAP aus den bestehenden Schluesseln admin.groups.members.sourceManual/.sourceLdap.
|
|
- Der Regressionsfall ist auf beiden Seiten testgedeckt und faellt gegen den alten Code durch.
|
|
- Die Mandantengrenze der neuen Abfrage ist explizit gezogen und testgedeckt; assertTargetBelongsToTenant bleibt unveraendert die erste Anweisung.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/quick/260805-d0r-benutzer-detail-zeigt-gruppenmitgliedsch/260805-d0r-SUMMARY.md` when done.
|
|
</output>
|