Files

30 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
16-ad-gruppen-synchronisation 03 execute 3
16-01
16-02
apps/api/src/ldap/ldap.service.ts
apps/api/src/ldap/ldap.module.ts
apps/api/src/ldap/ldap.service.spec.ts
true
PERM-02
tokens raw_tokens tasks confidence
58000 58000 2 low
truths artifacts key_links prohibitions
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 verification
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. backstop
statement verification
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. backstop
statement verification
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. backstop
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
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
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.
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.

<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>

@.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

<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>
Task 1: Gruppen-Rekonziliation gegen das Verzeichnis (SC-3, SC-4, SC-5, D-05, D-06) apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.module.ts, apps/api/src/ldap/ldap.service.spec.ts - `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()` - 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 **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. cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts cd apps/api && npx tsc --noEmit <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> 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.

Task 2: Einhaengen als Schritt 5a vor dem Mitgliedschafts-Abgleich (RESEARCH.md Pitfall 1) 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. apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts - `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" - 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 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. cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts cd apps/api && npx vitest run cd apps/api && npx tsc --noEmit 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. <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> 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.

<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>
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

<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>
Create `.planning/phases/16-ad-gruppen-synchronisation/16-03-SUMMARY.md` when done