Files
tessera-ctl/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-04-PLAN.md
T
schalli 8a4bb32847
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 46s
Tessera CI/CD / Build & Publish Images (push) Successful in 6s
docs(15): create phase plan — 8 plans, 4 waves, PERM-01..07
2026-08-04 14:39:46 +02:00

191 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 15-modul-berechtigungen-gruppen-user-grants
plan: 04
type: execute
wave: 2
depends_on: ["15-01"]
files_modified:
- apps/api/src/ldap/ldap.service.ts
- apps/api/src/ldap/ldap.service.spec.ts
autonomous: false
requirements: [PERM-02]
user_setup: []
estimate:
tokens: 48000
raw_tokens: 48000
tasks: 2
confidence: low
must_haves:
truths:
- "Eine Tessera-Gruppe kann optional an einen AD-Gruppen-DN gebunden werden; ohne gesetzten ldapDn fasst der Sync sie überhaupt nicht an (D-05, PERM-02)."
- "Der bestehende Benutzer-Sync pflegt die Gruppenmitgliedschaften im selben Durchlauf mit — es gibt keinen zweiten Sync-Job und keinen zweiten Button im Admin-UI (D-21)."
- "Verlässt ein Benutzer die gebundene AD-Gruppe, verschwindet ausschliesslich seine Mitgliedschaft mit source LDAP; eine manuell gesetzte Mitgliedschaft desselben Benutzers in derselben Gruppe bleibt bestehen (D-19)."
- "Einer AD-gebundenen Gruppe dürfen zusätzlich Benutzer von Hand hinzugefügt werden; der Sync entfernt diese nie (D-20)."
- "Die Mitgliederermittlung läuft als Reverse-Query mit memberOf als Filterkriterium, niemals über das Lesen von member- oder memberOf-Attributwerten — nur so entfällt das Range-Retrieval-Problem grosser AD-Gruppen."
- "Der Gruppen-DN wird vor der Interpolation in den LDAP-Filter durch LdapService.escapeLdapFilterValue geführt (RFC 4515)."
- "Verschachtelte AD-Gruppen werden in dieser Phase bewusst nicht aufgelöst — nur direkte Mitgliedschaft, konsistent zum bestehenden groupFilterDns-Verhalten aus Phase 2."
# --- Edge-Probe PERM-02 (6 Kategorien) ---
- "adjacency/PERM-02: Ist ein Benutzer sowohl manuell als auch über das AD Mitglied derselben Gruppe, existiert genau eine GroupMembership-Zeile; der Sync hebt eine bestehende MANUAL-Zeile nicht auf LDAP an und löscht sie nie."
- "empty/PERM-02: Liefert die memberOf-Suche einer gebundenen Gruppe null Treffer, verliert die Gruppe alle ihre LDAP-Mitgliedschaften, behält aber alle MANUAL-Mitgliedschaften; eine Gruppe ohne ldapDn wird gar nicht erst abgefragt."
- "encoding/PERM-02: Ein Gruppen-DN mit Klammern, Sternchen oder Backslash zerstört den Suchfilter nicht, weil er RFC-4515-escaped interpoliert wird."
- "ordering/PERM-02: Die Reihenfolge der AD-Treffer ist unerheblich — das Sync-Ergebnis ist eine Mengenoperation über Benutzernamen und bei umgekehrter Trefferreihenfolge identisch."
- "idempotency/PERM-02: Zwei Sync-Läufe ohne AD-Änderung erzeugen keine zusätzliche und löschen keine bestehende GroupMembership-Zeile."
- statement: "concurrency/PERM-02: Bricht der Sync mitten in der Gruppenschleife ab, bleiben die bereits verarbeiteten Gruppen konsistent, der Fehler landet in LdapSyncResult.errors, und kein Teilzustand verliert MANUAL-Mitgliedschaften."
verification: backstop
artifacts:
- "apps/api/src/ldap/ldap.service.ts — neue private Methode syncGroupMembershipsForTenant, aufgerufen aus syncUsersForTenant"
- "apps/api/src/ldap/ldap.service.spec.ts — erweitert um die AD-Gruppenbindungs-Fälle"
key_links:
- "syncUsersForTenant → syncGroupMembershipsForTenant — der Gruppen-Abgleich hängt am bestehenden Durchlauf, nicht an einem eigenen Job (D-21)"
- "GroupMembership.source = LDAP → die Lösch-Query des Sync — dieser Filter ist der einzige Schutz manueller Mitgliedschaften (D-19/D-20)"
- "Group.ldapDn → der memberOf-Filter — ohne diesen Wert ist eine Gruppe für den Sync unsichtbar"
---
<objective>
Die AD-Bindung von Gruppen: der bestehende Benutzer-Sync liest zusätzlich für jede an eine AD-Gruppe gebundene Tessera-Gruppe deren Mitglieder und gleicht die Mitgliedschaften ab — im selben Durchlauf, manuell per Button wie über das eingestellte Intervall.
Purpose: Ohne AD-Bindung müsste ein Admin jede Personaländerung zweimal pflegen. Der Abgleich muss dabei strikt zwischen den beiden Herkünften trennen: für gebundene Gruppen ist das AD die Wahrheit über seine eigenen Einträge, aber niemals über die von Hand gesetzten (D-19, D-20).
Output: Eine neue private Methode in `LdapService`, ein Aufruf im bestehenden `syncUsersForTenant`, und eine erweiterte Testsuite.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-01-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Gruppenmitgliedschafts-Abgleich im bestehenden LDAP-Sync-Durchlauf</name>
<files>apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts</files>
<precondition>Die aus Plan 15-01 stammende Migration ist angewendet und der Prisma-Client kennt `Group`, `GroupMembership` und `MembershipSource` — ohne die generierten Typen kompiliert `ldap.service.ts` nicht.</precondition>
<read_first>
- apps/api/src/ldap/ldap.service.ts — Zeilen 559–717 (`syncUsersForTenant`: der Base-DN-No-Op-Wächter, die Benutzerschleife, die Deaktivierungsschleife, das Aktualisieren von lastSyncAt) und Zeilen 737–792 (`collectSearchEntries`: exakt das memberOf-Filtermuster, das hier wiederverwendet wird)
- apps/api/src/ldap/ldap.service.ts — Zeilen 280–293 (`mapEntry`, die Auflösung eines Eintrags auf einen kleingeschriebenen Benutzernamen) und Zeile 834 (`escapeLdapFilterValue`)
- apps/api/src/ldap/ldap.service.ts — Zeilen 197–212 (`parseBaseDns`), weil der Gruppen-Abgleich dieselben Base-DNs durchsucht
- apps/api/src/ldap/ldap.service.spec.ts — der bestehende Aufbau der Suite, insbesondere wie der ldapts-`Client` gemockt wird
- apps/api/prisma/schema.prisma — die aus 15-01 stammenden Modelle Group und GroupMembership samt `@@unique([groupId, userId])`
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md — Pattern 3 und Pitfall 1 zum Range-Retrieval
</read_first>
<behavior>
- Ein Mandant ohne AD-gebundene Gruppen führt keine zusätzliche LDAP-Suche aus.
- Für jede Gruppe mit gesetztem `ldapDn` wird genau eine Suche je konfiguriertem Base-DN abgesetzt, mit dem Filter aus dem sanitisierten Basisfilter UND einer memberOf-Klausel auf den escapeten Gruppen-DN.
- Ein AD-Treffer, dessen Benutzername in der lokalen Datenbank existiert, erzeugt eine `GroupMembership` mit `source: LDAP`.
- Ein AD-Treffer, zu dem kein lokaler Benutzer existiert, erzeugt keine Mitgliedschaft und keinen Fehler — der Benutzer wurde entweder ausgeschlossen oder liegt ausserhalb der Sync-Basis.
- Eine bestehende Mitgliedschaft mit `source: LDAP`, deren Benutzer nicht mehr im Suchergebnis ist, wird gelöscht.
- Eine bestehende Mitgliedschaft mit `source: MANUAL` wird nie gelöscht, auch wenn der Benutzer nicht im Suchergebnis ist.
- Ist ein Benutzer sowohl im AD-Ergebnis als auch bereits mit `source: MANUAL` eingetragen, bleibt genau eine Zeile bestehen und ihre `source` bleibt `MANUAL`.
- Ein zweiter Lauf ohne AD-Änderung erzeugt und löscht keine Zeile.
- Liefert die Suche einer gebundenen Gruppe null Treffer, werden alle LDAP-Mitgliedschaften dieser Gruppe entfernt und alle MANUAL-Mitgliedschaften behalten.
- Wirft die Suche für eine Gruppe, wird der Fehler mit dem Gruppennamen in `LdapSyncResult.errors` aufgenommen und die Schleife läuft mit der nächsten Gruppe weiter.
- Ein Gruppen-DN mit den Zeichen `(`, `)`, `*` oder `\` wird escaped in den Filter interpoliert.
</behavior>
<action>
Erweitere `LdapSyncResult` um zwei Zählfelder `groupMembershipsAdded` und `groupMembershipsRemoved`, damit der bestehende Rückgabewert des Sync-Endpoints den Gruppen-Abgleich sichtbar macht.
Füge `LdapService` eine private Methode `syncGroupMembershipsForTenant(client, config, sanitizedFilter, attributes, tenantId, result)` hinzu. Sie lädt alle `group`-Zeilen des Mandanten mit `ldapDn: { not: null }`. Ist die Liste leer, kehrt sie sofort zurück, ohne eine Suche abzusetzen.
Für jede gebundene Gruppe baut sie den Filter nach dem Muster aus `collectSearchEntries`: `(&<sanitizedFilter>(memberOf=<escapeLdapFilterValue(group.ldapDn)>))`. Der Gruppen-DN ist Admin-Eingabe aus dem Auswahldialog und wird deshalb immer escaped, nie roh interpoliert. Die Suche läuft über jeden von `parseBaseDns(config.baseDn)` gelieferten Base-DN mit `scope: 'sub'` und derselben Attributliste wie der Benutzer-Sync. Die Treffer werden über `mapEntry` auf kleingeschriebene Benutzernamen abgebildet und in einem Set gesammelt — die Reihenfolge der Treffer spielt damit keine Rolle.
Lies memberOf niemals als Rückgabeattribut, um Mitglieder aufzuzählen. Bei AD-Gruppen mit mehr als 1500 Mitgliedern liefert das Verzeichnis stillschweigend nur einen Teilausschnitt in der Form `attribut;range=0-1499`, und der Sync würde eine gebundene Gruppe dauerhaft mit genau 1500 Mitgliedern führen, ohne dass irgendwo ein Fehler auftaucht. Die Filter-Variante umgeht das Problem strukturell.
Der Abgleich pro Gruppe läuft dann in drei Schritten. Erstens: die lokalen Benutzer des Mandanten zu den gefundenen Benutzernamen auflösen (eine einzige Query mit `username: { in: [...] }` und `tenantId`, keine Schleife). Zweitens: fehlende Mitgliedschaften mit `createMany` und `skipDuplicates: true` und `source: 'LDAP'` anlegen; `skipDuplicates` gegen `@@unique([groupId, userId])` ist genau der Mechanismus, der eine bestehende MANUAL-Zeile unangetastet lässt, statt sie auf LDAP umzuschreiben. Drittens: mit `deleteMany` und `where: { groupId, source: 'LDAP', userId: { notIn: [...] } }` die verwaisten AD-Mitgliedschaften entfernen. Der Filter auf `source: 'LDAP'` ist der einzige Schutz der manuell gesetzten Mitgliedschaften — ohne ihn würde jeder Sync-Lauf den Mischbetrieb aus D-20 zerstören.
Verschachtelte AD-Gruppen werden nicht aufgelöst: es kommt ausschliesslich der einfache `memberOf`-Filter zum Einsatz, nicht die AD-Erweiterung `LDAP_MATCHING_RULE_IN_CHAIN`. Halte das als Kommentar an der Methode fest, samt Begründung (kein Bedarf in D-19, herstellerspezifisch, würde auf einem Nicht-AD-Verzeichnis stillschweigend null Treffer liefern) — sonst wirkt es wie ein Versehen.
Jede Gruppe läuft in einem eigenen try/catch. Ein Fehler wird als `Gruppe <name>: <message>` in `result.errors` aufgenommen und die Schleife läuft weiter, exakt wie die bestehende Behandlung pro Verzeichniseintrag in der Benutzerschleife.
Rufe die neue Methode in `syncUsersForTenant` nach Schritt 5 (Deaktivierungsschleife) und vor Schritt 6 (Aktualisieren von `lastSyncAt`) auf. Sie liegt damit hinter dem Base-DN-No-Op-Wächter: ein Mandant ohne konfigurierte Base-DN führt weiterhin keinerlei LDAP-Aktion aus. Der Abgleich läuft ohne weiteres Zutun sowohl beim manuellen Sync über `POST /ldap/sync` als auch im Intervall-Scheduler, weil beide dieselbe Methode aufrufen — es entsteht kein zweiter Job und kein zweiter Button (D-21).
Erweitere `apps/api/src/ldap/ldap.service.spec.ts` um einen `describe`-Block, der jeden unter `<behavior>` genannten Fall mit gemocktem ldapts-`Client` und gemocktem PrismaService abdeckt.
</action>
<verify>
<automated>pnpm --filter @tessera/api test -- ldap.service</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @tessera/api test -- ldap.service` ist grün und enthält für jeden unter `<behavior>` gelisteten Fall ein eigenes `it(...)`; insbesondere existiert ein Test, der nach dem Sync eine verbliebene MANUAL-Mitgliedschaft eines nicht mehr im AD gefundenen Benutzers belegt.
- `grep -c 'syncGroupMembershipsForTenant' apps/api/src/ldap/ldap.service.ts` gibt `2` aus (Definition und der eine Aufruf in `syncUsersForTenant`).
- `grep -c "escapeLdapFilterValue" apps/api/src/ldap/ldap.service.ts` ist gegenüber dem Stand vor diesem Task um mindestens `1` gestiegen.
- `grep -c "source: 'LDAP'" apps/api/src/ldap/ldap.service.ts` gibt mindestens `2` aus — Anlage- und Löschpfad sind beide auf die AD-Herkunft eingeschränkt.
- Die neue Methode nimmt die Attributliste als Parameter entgegen und reicht sie unverändert an `client.search` weiter, statt eine eigene zu bilden — geprüft über `grep -c 'attributes,' apps/api/src/ldap/ldap.service.ts`, dessen Ergebnis gegenüber dem Stand vor diesem Task um mindestens `2` gestiegen ist (Parameter und Weitergabe).
- Ein Testfall in `ldap.service.spec.ts` belegt, dass der an `client.search` übergebene Filter die memberOf-Klausel enthält und die übergebene Attributliste identisch zu der des Benutzer-Sync ist — damit ist die Mitgliedschaft ein Filterkriterium und kein Rückgabeattribut.
- `pnpm --filter @tessera/api test` läuft vollständig grün — die bestehenden Sync-Tests aus den Quick-Tasks 260728-lih und 260729-d3k bleiben unverändert bestanden.
- `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch.
</acceptance_criteria>
<done>Der bestehende Sync-Durchlauf pflegt zusätzlich die Mitgliedschaften jeder AD-gebundenen Gruppe, entfernt dabei ausschliesslich seine eigenen Einträge, und der No-Op-Wächter für unkonfigurierte Mandanten bleibt wirksam.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Live-Abgleich der AD-Gruppenbindung gegen ein echtes Active Directory</name>
<what-built>
Der Benutzer-Sync gleicht jetzt zusätzlich die Mitgliedschaften jeder Tessera-Gruppe ab, die an einen AD-Gruppen-DN gebunden ist. Der Abgleich läuft im bestehenden Durchlauf mit — manuell über den Sync-Button wie über das Intervall — und entfernt ausschliesslich Mitgliedschaften mit der Herkunft LDAP.
</what-built>
<how-to-verify>
Diese Prüfung steht so in `15-VALIDATION.md` unter "Manual-Only Verifications" und ist nicht durch Unit-Tests ersetzbar: das Verhalten gegen ein echtes Active Directory ist laut Research nur mit MEDIUM-Confidence belegt.
1. In der Datenbank eine Tessera-Gruppe anlegen und ihren `ldapDn` auf eine reale AD-Gruppe von `balios.ctl.local` setzen (die Auswahl-Oberfläche dafür entsteht erst in Plan 15-06, bis dahin genügt ein direktes UPDATE oder `PATCH /groups/:id`).
2. `POST /ldap/sync` auslösen und die Antwort prüfen: `groupMembershipsAdded` ist grösser als 0.
3. `SELECT count(*) FROM "GroupMembership" WHERE "groupId" = '<id>';` mit der Mitgliederzahl der AD-Gruppe vergleichen — beide müssen übereinstimmen. Steht dort dauerhaft exakt 1000 oder 1500, ist das Range-Retrieval-Problem doch aufgetreten und der Task gilt als nicht bestanden.
4. Über `POST /groups/:id/members` einen Benutzer von Hand hinzufügen, der NICHT in der AD-Gruppe ist. Erneut syncen. Die manuelle Mitgliedschaft muss erhalten bleiben (D-20).
5. Im AD einen Benutzer aus der Gruppe entfernen, erneut syncen. Ausschliesslich seine Zeile mit `source = 'LDAP'` darf verschwinden; die manuelle Mitgliedschaft aus Schritt 4 bleibt unverändert (D-19).
</how-to-verify>
<resume-signal>Antworte "approved" oder beschreibe die Abweichung</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin-Eingabe → LDAP-Suchfilter | `Group.ldapDn` stammt aus einer Admin-Auswahl und wird in einen Suchfilter interpoliert |
| Active Directory → Tessera-Datenbank | Die Trefferliste des Verzeichnisses steuert das Anlegen und Löschen von Mitgliedschaften |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-15-07 | Tampering | Interpolation von `Group.ldapDn` in den memberOf-Filter | medium | mitigate | Der DN läuft immer durch `LdapService.escapeLdapFilterValue` (RFC 4515), identisch zur bestehenden Behandlung von `groupFilterDns`; ein Testfall mit Sonderzeichen im DN belegt es |
| T-15-15 | Elevation of Privilege | Ein AD-Treffer aus einem fremden Mandanten wird Mitglied einer Gruppe | high | mitigate | Die Auflösung von Benutzernamen auf lokale Benutzer filtert immer zusätzlich auf die `tenantId` des Sync-Laufs; ein Benutzername, der nur in einem anderen Mandanten existiert, erzeugt keine Mitgliedschaft |
| T-15-16 | Tampering | Der Sync löscht manuell gesetzte Mitgliedschaften | high | mitigate | Die Lösch-Query filtert ausschliesslich auf `source: 'LDAP'`; ein eigener Testfall belegt, dass eine MANUAL-Zeile eines nicht mehr gefundenen Benutzers erhalten bleibt (D-19/D-20) |
| T-15-17 | Information Disclosure | Unvollständige Mitgliederliste durch AD-Range-Retrieval | medium | mitigate | Mitglieder werden ausschliesslich per Reverse-Query mit memberOf als Filterkriterium ermittelt; die Attributliste der Suche enthält kein Mitgliedschaftsattribut. Der Human-Verify-Checkpoint prüft die Zahl gegen ein echtes AD |
</threat_model>
<verification>
- `pnpm --filter @tessera/api test` vollständig grün.
- `pnpm --filter @tessera/api run type-check` fehlerfrei.
- Manueller Live-Abgleich gegen `balios.ctl.local` laut Checkpoint-Anleitung, inklusive Mischbetrieb aus manueller und AD-Mitgliedschaft.
</verification>
<success_criteria>
- Eine Gruppe mit gesetztem `ldapDn` bekommt ihre Mitglieder aus dem AD, im bestehenden Sync-Durchlauf (PERM-02, D-21).
- Manuell gesetzte Mitgliedschaften überleben jeden Sync-Lauf (D-19, D-20).
- Kein Codepfad liest Mitgliedschaftsattribute als Rückgabewerte.
</success_criteria>
## Artifacts this phase produces
Von diesem Plan erzeugt beziehungsweise verändert:
- `LdapService.syncGroupMembershipsForTenant` (privat, `apps/api/src/ldap/ldap.service.ts`)
- `LdapSyncResult` erweitert um `groupMembershipsAdded` und `groupMembershipsRemoved`
- `apps/api/src/ldap/ldap.service.spec.ts` erweitert um den `describe`-Block zur AD-Gruppenbindung
Die phasenweite Gesamtliste steht in `15-01-PLAN.md`.
<output>
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-04-SUMMARY.md`, wenn der Plan abgeschlossen ist.
</output>