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 |
|
|
true |
|
|
|
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 ohneldapObjectGuidstammen 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
groupsAdoptedstattgroupsImported— bewusste Abweichung von der UI-SPEC. Die UI-SPEC (Copywriting Contract, Sync-Bericht Zeile 3) nennt das FeldgroupsImportedmit 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 deshalbgroupsAdoptedund 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. ensureDefaultGroupeinmal 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>
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.
const tenantPrisma = forTenant(this.prisma, tenantId) as any— jeder Schreibzugriff laeuft hierueber. Ein ungescopter Zugriff waere ein Fehler:GrouptraegtFORCE ROW LEVEL SECURITY.- Kandidaten laden:
group.findMany({ where: { tenantId, OR: [{ ldapObjectGuid: { not: null } }, { ldapDn: { not: null } }] }, select: { id: true, name: true, ldapDn: true, ldapObjectGuid: true, isDefault: true } }). DieOR-Form ist der D-07-Anschluss fuer Alt-Bindungen; rein lokale Gruppen erfuellen keinen der beiden Zweige. Leeres Ergebnis → sofortigesreturn, ohne jede LDAP-Suche. const baseDns = this.parseBaseDns(config.baseDn).- Pro Kandidat ein eigenes
try/catchnach dem Vorbild der Schleife insyncGroupMembershipsForTenant; dercatch-Zweig haengtGruppe <name>: <message>anresult.errorsund faehrt fort. Ein einzelner Fehler beendet den Lauf nie. - Nachtrag fuer Alt-Bindungen: ist
ldapObjectGuidleer undldapDngesetzt, eine base-scoped Suche auf genau diesem DN (scope: 'base',filter: '(objectClass=group)',attributes: ['cn','dn','objectGUID'],explicitBufferAttributes: ['objectGUID']). Treffer → Hex-GUID schreiben,groupsAdoptederhoehen, mit dem nachgetragenen Wert weiterarbeiten. Kein Treffer → Fehlerzeile mit dem Hinweis, dass die Bindung nicht aufgeloest werden konnte, undcontinue. Hier wird nicht geloescht: ohne stabilen Schluessel waere die Loeschung eine Vermutung. - Existenz-Sweep: den gespeicherten Hex-Wert validieren (genau 32 Zeichen aus
[0-9a-f]); scheitert das, Fehlerzeile undcontinue— ein unsauberer Wert darf nicht in einen Filter geraten. Sonst perBuffer.from(hex, 'hex')zuruecklesen, mitLdapService.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. - Treffer-Zweig (SC-3):
cnaufloesen (Array-Form wie inmapEntryberuecksichtigen). WeichennameoderldapDnvom AD-Stand ab, pertenantPrisma.group.update({ where: { id }, data: { name, ldapDn } })nachziehen undgroupsRenamederhoehen.internalNameerscheint in keinemdata-Objekt dieser Methode — das ist die Durchsetzung von D-04 im Sync. EinenP2002aus einer Namenskollision abfangen, als Fehlerzeile sammeln und die Gruppe unveraendert lassen;GroupsService.update()wird bewusst nicht aufgerufen, dessenConflictExceptionwuerde den Batch abbrechen (RESEARCH.md Pitfall 4). - Kein-Treffer-Zweig (SC-4, D-05, D-06): zuerst
await this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id); liefert sietrue,defaultMarkerMovederhoehen. DanntenantPrisma.group.delete({ where: { id: group.id } })undgroupsDeletederhoehen. Mitgliedschaften und Modulfreigaben fallen ueber die bestehenden Cascade-Regeln aus 15-01 mit — kein eigener Aufraeumcode, keine zweite Wahrheit ueber das Loeschverhalten. EinenP2025(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. - 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.
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> |
<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)
internalNamewird von keinem Sync-Codepfad geschrieben (D-04) </success_criteria>