--- 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" --- 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. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.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 Task 1: Gruppenmitgliedschafts-Abgleich im bestehenden LDAP-Sync-Durchlauf apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts 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. - 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 - 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. 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`: `(&(memberOf=))`. 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 : ` 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 `` genannten Fall mit gemocktem ldapts-`Client` und gemocktem PrismaService abdeckt. pnpm --filter @tessera/api test -- ldap.service - `pnpm --filter @tessera/api test -- ldap.service` ist grün und enthält für jeden unter `` 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. 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. Task 2: Live-Abgleich der AD-Gruppenbindung gegen ein echtes Active Directory 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. 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" = '';` 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). Antworte "approved" oder beschreibe die Abweichung ## 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 | - `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. - 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. ## 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`. Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-04-SUMMARY.md`, wenn der Plan abgeschlossen ist.