Files

260 lines
30 KiB
Markdown

---
phase: 16-ad-gruppen-synchronisation
plan: 03
type: execute
wave: 3
depends_on: [16-01, 16-02]
files_modified:
- apps/api/src/ldap/ldap.service.ts
- apps/api/src/ldap/ldap.module.ts
- apps/api/src/ldap/ldap.service.spec.ts
autonomous: true
requirements: [PERM-02]
estimate:
tokens: 58000
raw_tokens: 58000
tasks: 2
confidence: low
must_haves:
truths:
- "Wird eine importierte Gruppe im AD umbenannt, zieht der naechste Sync-Lauf Group.name und Group.ldapDn nach; Mitgliedschaften und Modulfreigaben bleiben erhalten (SC-3, D-03)."
- "Verschwindet eine importierte Gruppe aus dem AD, loescht der Sync die Tessera-Gruppe; Mitgliedschaften und Modulfreigaben fallen ueber die bestehenden onDelete-Cascade-Regeln mit (SC-4, D-05)."
- "Gruppen ohne AD-Bindung werden vom Gruppen-Sync in keiner Query beruehrt (SC-5)."
- "syncBoundGroupsForTenant laeuft im selben Durchlauf VOR syncGroupMembershipsForTenant — der Mitgliedschafts-Abgleich arbeitet damit immer mit dem bereits aktualisierten ldapDn (RESEARCH.md Pitfall 1)."
- "War die geloeschte Gruppe die markierte Standardgruppe, wandert die Markierung vor der Loeschung weiter; nach jeder Loeschung stellt der Lauf ueber ensureDefaultGroup sicher, dass der Mandant nicht ohne Gruppe dasteht (D-06)."
- "Bestehende Gruppen mit gesetztem ldapDn, aber ohne ldapObjectGuid (Alt-Bindungen aus Plan 15-06) erhalten ihren Identitaetsschluessel im ersten Lauf nachtraeglich und werden danach regulaer verwaltet (D-07)."
- "Ein Mandant ohne Gruppe mit gesetztem ldapObjectGuid loest keine einzige zusaetzliche LDAP-Suche aus."
- "Der gespeicherte Hex-Wert wird vor dem Existenz-Sweep in einen Buffer zurueckgewandelt und byteweise escaped; ein Wert, der nicht aus 32 Hex-Zeichen besteht, erzeugt eine Fehlerzeile statt einer Filter-Interpolation."
- "Ein zweiter Sync-Lauf ueber unveraenderten AD-Bestand ist ein No-Op: groupsRenamed und groupsDeleted bleiben 0, es entsteht keine Schreiboperation auf Group."
- "Zwei importierte Gruppen, von denen im AD nur eine verschwindet, fuehren zu genau einer Loeschung; die verbleibende bleibt samt Mitgliedschaften und Modulfreigaben unangetastet."
- "Verschwindet die einzige Gruppe eines Mandanten, ruft der Lauf nach der Loeschung ensureDefaultGroup(tenantId) und der Mandant hat wieder genau eine markierte Standardgruppe."
- "Die Reihenfolge, in der gebundene Gruppen abgearbeitet werden, hat keinen Einfluss auf das Ergebnis: der Standardmarkierungs-Handoff laeuft je Gruppe unmittelbar vor deren Loeschung, nicht als Sammelschritt am Ende."
- "Eine bereits geloeschte Gruppe taucht im Folgelauf nicht mehr in der Kandidatenliste auf; groupsDeleted bleibt dort 0."
- "Eine lokale Gruppe, die exakt denselben Namen traegt wie eine importierte, wird nicht angefasst — die Kandidatenauswahl laeuft ueber die AD-Bindung, nie ueber den Namen."
- "Ein Mandant mit ausschliesslich lokalen Gruppen durchlaeuft den Gruppen-Sync ohne eine einzige Schreiboperation."
- "Lokale Gruppen erscheinen in keiner Verarbeitungsreihenfolge, weil sie die Kandidaten-Bedingung nicht erfuellen."
- "Beliebig viele Sync-Laeufe hintereinander lassen jede lokale Gruppe unveraendert."
- "Ein gleichzeitiger manueller Eingriff an einer lokalen Gruppe kollidiert nicht mit dem Gruppen-Sync, weil dieser lokale Gruppen in keiner Query beruehrt."
- "Ein Fehler bei einer einzelnen Gruppe stoppt den Lauf nicht: er landet als eigene Zeile in result.errors und die uebrigen Gruppen werden weiterverarbeitet."
- statement: "Ein zweiter, gleichzeitig laufender Sync-Lauf fuer denselben Mandanten kann keine doppelte Umbenennung erzeugen, weil das Update ein idempotentes Setzen von name und ldapDn auf den aktuellen AD-Stand ist."
verification: backstop
- statement: "Loescht ein Admin dieselbe Gruppe gleichzeitig ueber die Oberflaeche, faengt der Sync den P2025 der ins Leere laufenden Loeschung ab und zaehlt sie nicht doppelt."
verification: backstop
- statement: "objectGUID bleibt ueber eine reine Umbenennung im AD unveraendert (RESEARCH.md Annahme A1) und die byteweise Escaping-Syntax liefert gegen ein echtes Active Directory Treffer (Annahme A2). Beide werden read-only gegen ViCoTest geprueft, bevor die Loeschsemantik als produktionsreif gilt."
verification: backstop
artifacts:
- "apps/api/src/ldap/ldap.service.ts — LdapSyncResult um groupsAdopted, groupsRenamed, groupsDeleted, defaultMarkerMoved erweitert; neue private Methode syncBoundGroupsForTenant()"
- "apps/api/src/ldap/ldap.module.ts — GroupsModule importiert"
- "apps/api/src/ldap/ldap.service.spec.ts — describe-Bloecke fuer Rename, Delete, Alt-Bindungs-Nachtrag, Default-Handoff, lokale Gruppen und Schrittreihenfolge"
key_links:
- "syncBoundGroupsForTenant() → syncGroupMembershipsForTenant(): die Reihenfolge ist die zentrale Korrektheitsbedingung dieser Phase — falsch herum wird eine AD-Umbenennung als Mitgliederverlust protokolliert"
- "syncBoundGroupsForTenant() → GroupsService.reassignDefaultBeforeDelete() → group.delete() → GroupsService.ensureDefaultGroup(): fehlt ein Glied, bleibt ein Mandant ohne Standardgruppe bis zum naechsten API-Neustart"
- "Group.ldapObjectGuid → binaerer AD-Filter: ohne diesen Schluessel ist eine Umbenennung nicht von einem Verschwinden unterscheidbar"
prohibitions:
- "Der Gruppen-Sync darf niemals eine Gruppe ohne AD-Bindung veraendern oder loeschen — auch nicht aufraeumend."
- "Der Sync darf internalName in keinem Codepfad schreiben, ueberschreiben oder leeren."
- "Der Sync darf eine Gruppe niemals loeschen, ohne sie ueber ihren stabilen Identitaetsschluessel zweifelsfrei als im Verzeichnis verschwunden nachgewiesen zu haben — eine fehlgeschlagene, abgebrochene oder unbeantwortete Suche ist kein Loeschgrund."
- "Eine sync-getriebene Loeschung darf nicht unsichtbar bleiben: jede geloeschte Gruppe und jede verschobene Standardmarkierung erscheint als Zahl im Sync-Bericht."
- "Es darf kein Zustand entstehen, in dem ein Mandant nach einem Sync-Lauf ohne markierte Standardgruppe dasteht."
---
<objective>
Der bestehende Benutzer-Sync fuehrt importierte Gruppen nach: eine Umbenennung im AD zieht in Tessera nach (SC-3, D-03), ein Verschwinden loescht die Tessera-Gruppe samt Mitgliedschaften und Modulfreigaben (SC-4, D-05), die Standardmarkierung wandert dabei weiter statt zu verschwinden (D-06), und lokal angelegte Gruppen bleiben unberuehrt (SC-5).
Das ist der einzige wirklich neue Baustein der Phase: die Unterscheidung zwischen "im AD umbenannt" und "im AD verschwunden". Sie haengt vollstaendig am `ldapObjectGuid` aus Plan 16-01 — der DN allein enthaelt den CN und aendert sich bei jeder Umbenennung.
Purpose: Ohne diesen Plan ist der Import aus 16-01 eine Einbahnstrasse — Gruppen entstehen, veralten aber ab dem ersten AD-Wechsel.
Output: `syncBoundGroupsForTenant()`, erweiterte `LdapSyncResult`-Felder, die Einhaengung als Schritt 5a vor dem bestehenden Mitgliedschafts-Abgleich, und die zugehoerigen Tests.
</objective>
<artifacts_this_phase_produces>
Neu entstehende Symbole in diesem Plan:
| Art | Symbol |
|-----|--------|
| Service-Methode | `LdapService.syncBoundGroupsForTenant(client, config, tenantId, result)` (private) |
| Result-Felder | `LdapSyncResult.groupsAdopted`, `.groupsRenamed`, `.groupsDeleted`, `.defaultMarkerMoved` |
| Konstruktor-Parameter | `LdapService(prisma, userService, groupsService)` |
| Modul-Import | `GroupsModule` in `LdapModule` |
Aus Plan 16-01 uebernommen: `Group.ldapObjectGuid`, `LdapService.escapeLdapFilterBuffer()`.
Aus Plan 16-02 uebernommen: `GroupsService.reassignDefaultBeforeDelete()`, `DEFAULT_GROUP_NAME`.
</artifacts_this_phase_produces>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/16-ad-gruppen-synchronisation/16-CONTEXT.md
@.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md
@.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md
@.planning/phases/16-ad-gruppen-synchronisation/16-01-SUMMARY.md
@.planning/phases/16-ad-gruppen-synchronisation/16-02-SUMMARY.md
</context>
<decisions_recorded>
- **Alt-Bindungen (D-07-Anschluss):** Gruppen mit gesetztem `ldapDn`, aber ohne `ldapObjectGuid` stammen aus der in 15-06 gebauten Radio-Auswahl. D-07 sagt ausdruecklich, dass sie als importiert gelten und vom Sync verwaltet werden. Der Lauf traegt ihren Identitaetsschluessel deshalb einmalig nach (base-scoped Suche auf dem gespeicherten DN). Loest der DN nicht mehr auf, wird die Gruppe **nicht** geloescht — ohne stabilen Schluessel ist Umbenennung nicht von Verschwinden unterscheidbar, und eine Loeschung waere hier eine Vermutung, kein Nachweis. Stattdessen eine Fehlerzeile.
- **Berichtsfeld `groupsAdopted` statt `groupsImported` — bewusste Abweichung von der UI-SPEC.** Die UI-SPEC (Copywriting Contract, Sync-Bericht Zeile 3) nennt das Feld `groupsImported` mit dem Wort "importiert". Der Sync **importiert** aber per D-02 nie eine Gruppe — Import ist ausschliesslich die ausdrueckliche Admin-Auswahl aus Plan 16-01. Ein Berichtsfeld, das nach jedem Lauf zwingend 0 zeigt, ist dauerhaft bedeutungslos. Das Feld heisst deshalb `groupsAdopted` und zaehlt, wie viele Alt-Bindungen der Lauf neu unter Verwaltung genommen hat. Die zugehoerige Textanpassung erfolgt in Plan 16-05. Diese Abweichung ist hier ausdruecklich vermerkt, nicht stillschweigend vollzogen.
- **`ensureDefaultGroup` einmal am Ende**, nicht nach jeder einzelnen Loeschung: die Methode ist idempotent und kehrt bei vorhandenen Gruppen sofort zurueck; ein Aufruf pro Loeschung waere reine Zusatzlast ohne Verhaltensunterschied.
</decisions_recorded>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Gruppen-Rekonziliation gegen das Verzeichnis (SC-3, SC-4, SC-5, D-05, D-06)</name>
<files>apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.module.ts, apps/api/src/ldap/ldap.service.spec.ts</files>
<read_first>
- `apps/api/src/ldap/ldap.service.ts` — `LdapSyncResult` (Zeilen 9-20), Konstruktor (86-92), `parseBaseDns`/`buildClientOptions`-Nutzung, `syncGroupMembershipsForTenant()` vollstaendig (Zeilen 820-935) als strukturelles Vorbild, `escapeLdapFilterValue()` (977-984) und der in 16-01 angelegte `escapeLdapFilterBuffer()`
- `apps/api/src/ldap/ldap.module.ts` — aktuelle Imports/Provider
- `apps/api/src/ldap/ldap.service.spec.ts` — Datei-Kopf mit den ldapts- und forTenant-Mocks (Zeilen 1-24), die sieben `new LdapService(...)`-Konstruktionen, und der bestehende D-21-Block ab Zeile 518 als naechstes Vorbild
- `apps/api/src/groups/groups.service.ts` — `reassignDefaultBeforeDelete()` und `ensureDefaultGroup()` in der nach Plan 16-02 gueltigen Fassung
- `apps/api/src/groups/groups.module.ts` und `apps/api/src/user/user.module.ts` — belegen, dass `GroupsModule` nichts importiert und ein direkter Import aus `LdapModule` keinen Zyklus erzeugt
- `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Pattern 2 und 3, Pitfalls 1, 2, 4, 5, Anti-Patterns
- `.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md` — Abschnitt `syncBoundGroupsForTenant()`
</read_first>
<behavior>
- Kandidat mit AD-Treffer, `cn` unveraendert und DN unveraendert → keine Schreiboperation, keine Zaehler steigen
- Kandidat mit AD-Treffer und geaendertem `cn`/DN → `name` und `ldapDn` werden auf den AD-Stand gesetzt, `groupsRenamed` steigt, `internalName` bleibt unveraendert
- Kandidat ohne AD-Treffer, nicht Standardgruppe → Gruppe wird geloescht, `groupsDeleted` steigt
- Kandidat ohne AD-Treffer, ist Standardgruppe, andere Gruppe vorhanden → Markierung wandert, `defaultMarkerMoved` steigt, danach Loeschung
- Kandidat ohne AD-Treffer, ist einzige Gruppe des Mandanten → Loeschung, danach legt `ensureDefaultGroup` die Standardgruppe neu an
- Umbenennung mit einem Namen, der bereits von einer anderen Gruppe belegt ist → `P2002` wird abgefangen, Fehlerzeile, Gruppe bleibt unveraendert, der Lauf geht weiter
- Gruppe mit `ldapDn`, ohne `ldapObjectGuid`, DN loest auf → GUID wird nachgetragen, `groupsAdopted` steigt, die Gruppe wird im selben Lauf regulaer weiterverarbeitet
- Gruppe mit `ldapDn`, ohne `ldapObjectGuid`, DN loest nicht auf → **keine** Loeschung, Fehlerzeile
- Mandant ohne einen einzigen Kandidaten → sofortige Rueckkehr, kein `client.search`-Aufruf
- Gruppe mit `ldapObjectGuid: null` und `ldapDn: null` (rein lokal) → kommt in keiner Query vor
- Eine `client.search`-Ausnahme bei einer Gruppe → Fehlerzeile mit dem Gruppennamen, die uebrigen Gruppen werden weiterverarbeitet
</behavior>
<action>
**Verdrahtung.** `LdapModule` importiert zusaetzlich `GroupsModule`. `LdapService` bekommt `private groupsService: GroupsService` als dritten Konstruktor-Parameter. Alle sieben `new LdapService(...)`-Konstruktionen in `ldap.service.spec.ts` erhalten einen dritten Mock-Parameter; die beiden Stellen, die heute `{} as any` uebergeben, bekommen einen dritten `{} as any`.
**Result-Felder.** `LdapSyncResult` waechst additiv um `groupsAdopted: number`, `groupsRenamed: number`, `groupsDeleted: number`, `defaultMarkerMoved: number` — exakt das Muster, das D-21 fuer die beiden Mitgliedschafts-Zaehler bereits benutzt hat. Alle Initialisierungsstellen des Result-Objekts auf 0 ergaenzen.
**Neue private Methode** `syncBoundGroupsForTenant(client, config, tenantId, result)`. Sie nutzt den bereits gebundenen Client des laufenden Sync — kein zweiter Verbindungsaufbau.
1. `const tenantPrisma = forTenant(this.prisma, tenantId) as any` — jeder Schreibzugriff laeuft hierueber. Ein ungescopter Zugriff waere ein Fehler: `Group` traegt `FORCE ROW LEVEL SECURITY`.
2. Kandidaten laden: `group.findMany({ where: { tenantId, OR: [{ ldapObjectGuid: { not: null } }, { ldapDn: { not: null } }] }, select: { id: true, name: true, ldapDn: true, ldapObjectGuid: true, isDefault: true } })`. Die `OR`-Form ist der D-07-Anschluss fuer Alt-Bindungen; rein lokale Gruppen erfuellen keinen der beiden Zweige. Leeres Ergebnis → sofortiges `return`, ohne jede LDAP-Suche.
3. `const baseDns = this.parseBaseDns(config.baseDn)`.
4. Pro Kandidat ein eigenes `try/catch` nach dem Vorbild der Schleife in `syncGroupMembershipsForTenant`; der `catch`-Zweig haengt `Gruppe <name>: <message>` an `result.errors` und faehrt fort. Ein einzelner Fehler beendet den Lauf nie.
5. **Nachtrag fuer Alt-Bindungen:** ist `ldapObjectGuid` leer und `ldapDn` gesetzt, eine base-scoped Suche auf genau diesem DN (`scope: 'base'`, `filter: '(objectClass=group)'`, `attributes: ['cn','dn','objectGUID']`, `explicitBufferAttributes: ['objectGUID']`). Treffer → Hex-GUID schreiben, `groupsAdopted` erhoehen, mit dem nachgetragenen Wert weiterarbeiten. Kein Treffer → Fehlerzeile mit dem Hinweis, dass die Bindung nicht aufgeloest werden konnte, und `continue`. Hier wird **nicht** geloescht: ohne stabilen Schluessel waere die Loeschung eine Vermutung.
6. **Existenz-Sweep:** den gespeicherten Hex-Wert validieren (genau 32 Zeichen aus `[0-9a-f]`); scheitert das, Fehlerzeile und `continue` — ein unsauberer Wert darf nicht in einen Filter geraten. Sonst per `Buffer.from(hex, 'hex')` zuruecklesen, mit `LdapService.escapeLdapFilterBuffer()` byteweise escapen und daraus den Filter `(objectGUID=<escaped>)` bauen. Ueber alle Base-DNs suchen (`scope: 'sub'`, `attributes: ['cn','dn']`) und beim ersten Treffer abbrechen.
7. **Treffer-Zweig (SC-3):** `cn` aufloesen (Array-Form wie in `mapEntry` beruecksichtigen). Weichen `name` oder `ldapDn` vom AD-Stand ab, per `tenantPrisma.group.update({ where: { id }, data: { name, ldapDn } })` nachziehen und `groupsRenamed` erhoehen. `internalName` erscheint in keinem `data`-Objekt dieser Methode — das ist die Durchsetzung von D-04 im Sync. Einen `P2002` aus einer Namenskollision abfangen, als Fehlerzeile sammeln und die Gruppe unveraendert lassen; `GroupsService.update()` wird bewusst nicht aufgerufen, dessen `ConflictException` wuerde den Batch abbrechen (RESEARCH.md Pitfall 4).
8. **Kein-Treffer-Zweig (SC-4, D-05, D-06):** zuerst `await this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)`; liefert sie `true`, `defaultMarkerMoved` erhoehen. Dann `tenantPrisma.group.delete({ where: { id: group.id } })` und `groupsDeleted` erhoehen. Mitgliedschaften und Modulfreigaben fallen ueber die bestehenden Cascade-Regeln aus 15-01 mit — kein eigener Aufraeumcode, keine zweite Wahrheit ueber das Loeschverhalten. Einen `P2025` (Zeile bereits weg, etwa durch eine gleichzeitige manuelle Loeschung) abfangen und den Zaehler dann nicht erhoehen. Merke dir in einer lokalen Variable, dass mindestens eine Loeschung stattgefunden hat.
9. **Nach der Schleife:** hat mindestens eine Loeschung stattgefunden, `await this.groupsService.ensureDefaultGroup(tenantId)` aufrufen. Das schliesst die in RESEARCH.md Pitfall 5 beschriebene Luecke — bisher laeuft diese Methode nur bei Mandanten-Anlage und beim API-Start, ein Mandant bliebe sonst bis zum naechsten Neustart ohne jede Gruppe. Die Methode ist idempotent und kehrt bei vorhandenen Gruppen sofort zurueck.
**Tests.** Neuer describe-Block `LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verzeichnis (SC-3/SC-4/SC-5, D-05/D-06)` in `apps/api/src/ldap/ldap.service.spec.ts`, aufgebaut wie der bestehende D-21-Block ab Zeile 518. Je ein `it(` pro Zeile der `<behavior>`-Liste. Der `groupsService`-Mock traegt `reassignDefaultBeforeDelete` und `ensureDefaultGroup` als `vi.fn()`. Ein zusaetzlicher Fall belegt die Idempotenz: derselbe Mock-AD-Zustand zweimal hintereinander verarbeitet, beim zweiten Lauf keine einzige `group.update`- oder `group.delete`-Aufrufung.
</action>
<verify>
<automated>cd apps/api &amp;&amp; npx vitest run src/ldap/ldap.service.spec.ts</automated>
<automated>cd apps/api &amp;&amp; npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `grep -q 'private async syncBoundGroupsForTenant' apps/api/src/ldap/ldap.service.ts` trifft
- Die neue Methode schreibt `internalName` in keinem Datensatz: `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'internalName'` ergibt 0
- Der Loesch-Zweig ruft den Handoff vor der Loeschung: in der Methode steht `reassignDefaultBeforeDelete` in einer frueheren Zeile als das zugehoerige `group.delete` — pruefbar mit `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -n 'reassignDefaultBeforeDelete\|group.delete'`
- `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'ensureDefaultGroup'` ist >= 1
- `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'escapeLdapFilterBuffer'` ist >= 1
- `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'forTenant\|tenantPrisma'` ist >= 3
- `grep -c 'groupsAdopted\|groupsRenamed\|groupsDeleted\|defaultMarkerMoved' apps/api/src/ldap/ldap.service.ts` ist >= 8 (Interface plus Initialisierung plus Zaehler)
- `grep -q 'GroupsModule' apps/api/src/ldap/ldap.module.ts` trifft
- `cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts` ist gruen und der neue describe-Block enthaelt mindestens 11 `it(`-Faelle
</acceptance_criteria>
<done>`syncBoundGroupsForTenant` erkennt Umbenennung, Verschwinden und Alt-Bindung, verschiebt die Standardmarkierung vor jeder Loeschung, baut die Standardgruppe bei Bedarf neu auf, laesst lokale Gruppen und interne Namen unberuehrt, und ist mit mindestens elf Faellen abgedeckt.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Einhaengen als Schritt 5a vor dem Mitgliedschafts-Abgleich (RESEARCH.md Pitfall 1)</name>
<precondition>Ein erreichbarer Active-Directory-Testserver (ViCoTest, `balios.ctl.local`) steht fuer die read-only Verifikation der Annahmen A1/A2 zur Verfuegung; ohne ihn bleibt die Loeschsemantik aus D-05 als nicht produktionsverifiziert markiert.</precondition>
<files>apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts</files>
<read_first>
- `apps/api/src/ldap/ldap.service.ts` — `syncUsersForTenant()` Zeilen 564-735, insbesondere der Deaktivierungs-Schritt 5 (685-702), der bestehende Aufruf von `syncGroupMembershipsForTenant` als Schritt 5b (705-715) und die abschliessende `lastSyncAt`-Aktualisierung
- `apps/api/src/ldap/ldap.service.spec.ts` — der D-21-Block ab Zeile 518, der bereits einen vollstaendigen `syncUsersForTenant`-Durchlauf mockt
- `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Pitfall 1 und der Abschnitt "Anti-Patterns to Avoid"
- `.planning/phases/16-ad-gruppen-synchronisation/16-VALIDATION.md` — Abschnitt "Manual-Only Verifications"
</read_first>
<behavior>
- In einem vollstaendigen `syncUsersForTenant`-Durchlauf wird die Gruppen-Rekonziliation aufgerufen, bevor der Mitgliedschafts-Abgleich aufgerufen wird
- Nach einer im selben Lauf erkannten Umbenennung arbeitet der Mitgliedschafts-Abgleich mit dem NEUEN DN — der Suchfilter enthaelt nicht mehr den alten
- Der Aufruf sitzt hinter demselben Base-DN-No-Op-Waechter wie der restliche Sync: ein Mandant mit leerer Base-DN-Liste loest keinen Gruppen-Sync aus
- Ein Fehler in der Gruppen-Rekonziliation verhindert nicht, dass `lastSyncAt` aktualisiert wird — das Fehlerbild landet in `result.errors`, nicht in einem abgebrochenen Lauf
</behavior>
<action>
Fuege in `syncUsersForTenant()` unmittelbar **vor** dem bestehenden Aufruf von `syncGroupMembershipsForTenant` (heute als Kommentar "5b" markiert) einen neuen Schritt ein: `await this.syncBoundGroupsForTenant(client, config, tenantId, result);` samt Kommentarblock, der die Reihenfolge begruendet.
Diese Reihenfolge ist die zentrale Korrektheitsbedingung der Phase, kein Stilfrage. Der Mitgliedschafts-Abgleich liest `group.ldapDn` aus der Datenbank und baut daraus den `memberOf`-Suchfilter. Steht dort nach einer AD-Umbenennung noch der alte Wert, liefert das Verzeichnis keinen einzigen Treffer mehr, und saemtliche verzeichnisgestuetzten Mitgliedschaften der umbenannten Gruppe wuerden faelschlich als entfernt behandelt — der Sync-Bericht meldete dann einen Mitgliederverlust, den im AD niemand ausgeloest hat.
Der neue Aufruf sitzt innerhalb desselben `try`-Blocks und damit hinter dem bereits vorhandenen Base-DN-No-Op-Waechter; ein Mandant ohne konfigurierte Base-DN loest weiterhin gar nichts aus.
Ergaenze im bestehenden D-21-Testblock (bzw. als eigener describe-Block `LdapService.syncUsersForTenant — Schrittreihenfolge Gruppen vor Mitgliedschaften`) einen Testfall, der die Reihenfolge **beobachtbar** belegt statt sie nur zu behaupten: eine gemeinsame Aufrufprotokoll-Liste, in die beide Schritte ihren Namen schreiben (etwa ueber `mockImplementation` auf `group.findMany` mit unterscheidbaren `where`-Formen oder ueber ein `vi.spyOn` auf beide privaten Methoden), und eine Assertion auf die Reihenfolge der Eintraege. Ein zweiter Fall bildet den Regressionsfall ab: AD liefert fuer die Gruppe einen neuen DN; nach dem Lauf enthaelt keiner der an `client.search` uebergebenen `memberOf`-Filter den alten DN.
**Live-Verifikation der Annahmen A1/A2.** RESEARCH.md markiert zwei Annahmen als nicht gegen ein echtes Verzeichnis geprueft: dass `objectGUID` eine reine Umbenennung unveraendert uebersteht (A1) und dass die byteweise Escaping-Syntax vom Ziel-AD als Filter akzeptiert wird (A2). Beide entscheiden darueber, ob die Loeschsemantik aus D-05 produktionssicher ist. Fuehre die Pruefung read-only gegen ViCoTest durch, wie in `16-VALIDATION.md` beschrieben: eine Gruppe suchen und ihren GUID notieren, im AD umbenennen, erneut suchen und den GUID vergleichen; danach eine Suche mit dem escaped Filter absetzen und pruefen, ob sie dieselbe Gruppe zurueckliefert. Kein Deploy, kein Docker-Eingriff auf dem Testserver. Halte das Ergebnis im SUMMARY fest. Faellt A1 oder A2 negativ aus, ist das ein Stopp-Grund fuer die Loeschsemantik — melde es, statt die Implementierung umzubiegen.
</action>
<verify>
<automated>cd apps/api &amp;&amp; npx vitest run src/ldap/ldap.service.spec.ts</automated>
<automated>cd apps/api &amp;&amp; npx vitest run</automated>
<automated>cd apps/api &amp;&amp; npx tsc --noEmit</automated>
<human-check>Read-only gegen ViCoTest (`balios.ctl.local`): (1) AD-Gruppe suchen, objectGUID notieren, Gruppe im AD umbenennen, erneut suchen — GUID identisch? (2) Suche mit dem byteweise escaped objectGUID-Filter absetzen — liefert sie genau diese Gruppe? Beide Antworten im SUMMARY festhalten.</human-check>
</verify>
<acceptance_criteria>
- In `apps/api/src/ldap/ldap.service.ts` steht der Aufruf von `syncBoundGroupsForTenant` vor dem Aufruf von `syncGroupMembershipsForTenant`: `grep -n 'this.syncBoundGroupsForTenant\|this.syncGroupMembershipsForTenant' apps/api/src/ldap/ldap.service.ts` zeigt die Aufrufzeile der ersten Methode mit kleinerer Zeilennummer als die der zweiten
- Beide Aufrufe stehen innerhalb von `syncUsersForTenant`: `awk '/async syncUsersForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'this.syncBoundGroupsForTenant'` ergibt 1
- Der neue Reihenfolge-Testfall existiert und ist gruen: `cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts -t "Schrittreihenfolge"` liefert mindestens 2 bestandene Faelle
- `cd apps/api && npx vitest run` ist vollstaendig gruen
- `cd apps/api && npx tsc --noEmit` ist fehlerfrei
- Das SUMMARY enthaelt das Ergebnis der A1/A2-Live-Pruefung mit Datum und Ergebnis je Annahme
</acceptance_criteria>
<done>Die Gruppen-Rekonziliation laeuft im selben Durchlauf vor dem Mitgliedschafts-Abgleich, die Reihenfolge ist durch einen beobachtenden Test abgesichert, ein Rename fuehrt nachweislich nicht mehr zu einem falschen Mitgliederverlust, und die beiden AD-Annahmen sind gegen ein echtes Verzeichnis geprueft.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| API → Active Directory | Gespeicherte GUID-/DN-Werte werden Teil eines LDAP-Suchfilters; AD-gelieferte `cn`/`dn` kommen ungeprueft zurueck und werden persistiert |
| API → PostgreSQL | Unbeaufsichtigte Schreib- und Loeschoperationen auf `Group` unter RLS; Loeschungen kaskadieren auf `GroupMembership` und `ModuleGrant` |
| Scheduler → API-interner Sync | Der Lauf startet ohne Benutzerkontext ueber den Cron aus `LdapSyncScheduler` |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-16-01 | Tampering | Binaerer `objectGUID`-Filter im Existenz-Sweep | high | mitigate | Der gespeicherte Wert wird vor jeder Verwendung gegen genau 32 Zeichen aus `[0-9a-f]` validiert und ausschliesslich ueber `escapeLdapFilterBuffer()` byteweise escaped interpoliert; ein nicht validierbarer Wert erzeugt eine Fehlerzeile statt eines Filters (ASVS V5) |
| T-16-02 | Elevation of Privilege | Alle Schreib-/Loeschpfade auf `Group` | high | mitigate | Jeder Zugriff laeuft ueber `forTenant(this.prisma, tenantId)`; `tenantId` stammt aus der Sync-Konfiguration des jeweiligen Mandanten, nie aus einem AD-gelieferten Wert. RLS mit `FORCE ROW LEVEL SECURITY` ist das zweite Netz (ASVS V1) |
| T-16-04 | Tampering | Standardmarkierungs-Handoff bei gleichzeitiger Admin-Aktion | high | mitigate | Der Handoff laeuft in der bestehenden Zwei-Schritt-Transaktion; der partielle Unique-Index `Group_one_default_per_tenant` ist das DB-seitige Netz, ein daraus resultierender `P2002` wird als "hat sich schon jemand anderes gekuemmert" behandelt statt geworfen |
| T-16-05 | Repudiation | Unbeaufsichtigte Loeschung entzieht Modulzugriff ohne den D-17-Warndialog | high | accept | **Bewusst akzeptiert laut D-05.** Der Warndialog greift ausschliesslich beim Loeschen ueber die Tessera-Oberflaeche. Einzige vorgesehene Massnahme ist die Sichtbarkeit im Sync-Bericht (`groupsDeleted`, `defaultMarkerMoved`) — es wird ausdruecklich **kein** technischer Schutz gebaut, der dieser Entscheidung widerspraeche |
| T-16-10 | Denial of Service | Ein fehlerhafter Gruppen-Datensatz bricht den gesamten Sync ab | medium | mitigate | Pro-Gruppe-`try/catch` mit gesammelten Fehlerzeilen; kein `GroupsService.create()`/`update()`-Aufruf, dessen HTTP-Exceptions den Batch beenden wuerden (RESEARCH.md Pitfall 4) |
| T-16-11 | Tampering | Loeschung auf Basis einer fehlgeschlagenen statt einer leeren Suche | high | mitigate | Der Loesch-Zweig wird ausschliesslich von einer erfolgreich beantworteten Suche mit null Treffern erreicht; eine geworfene Suche landet im `catch` und fuehrt zu einer Fehlerzeile, nie zu einer Loeschung. Alt-Bindungen ohne aufloesbaren DN werden ebenfalls nicht geloescht |
| T-16-SC | Tampering | Paketinstallation | low | accept | Keine neuen Pakete in diesem Plan |
</threat_model>
<verification>
1. `cd apps/api && npx vitest run` — vollstaendig gruen
2. `cd apps/api && npx tsc --noEmit` — fehlerfrei
3. Reihenfolge-Test belegt beobachtbar, dass die Gruppen-Rekonziliation vor dem Mitgliedschafts-Abgleich laeuft
4. Live-Pruefung gegen ViCoTest (read-only) beantwortet A1 und A2; das Ergebnis steht im SUMMARY
</verification>
<success_criteria>
- Umbenennung im AD zieht in den Gruppennamen nach, Mitgliedschaften und Freigaben bleiben erhalten (SC-3)
- Verschwinden im AD entfernt die Tessera-Gruppe samt Mitgliedschaften und Modulfreigaben (SC-4, D-05)
- Manuell angelegte Gruppen bleiben unberuehrt (SC-5)
- Kein Mandant steht nach einem Sync-Lauf ohne markierte Standardgruppe da (D-06)
- Alt-Bindungen aus Plan 15-06 werden vom Sync verwaltet, ohne dass eine nicht aufloesbare Bindung zu einer Loeschung fuehrt (D-07)
- `internalName` wird von keinem Sync-Codepfad geschrieben (D-04)
</success_criteria>
<output>
Create `.planning/phases/16-ad-gruppen-synchronisation/16-03-SUMMARY.md` when done
</output>