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

18 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup estimate must_haves
15-modul-berechtigungen-gruppen-user-grants 04 execute 2
15-01
apps/api/src/ldap/ldap.service.ts
apps/api/src/ldap/ldap.service.spec.ts
false
PERM-02
tokens raw_tokens tasks confidence
48000 48000 2 low
truths artifacts key_links
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.
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 verification
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. backstop
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
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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-01-SUMMARY.md 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`: `(&<sanitizedFilter>(memberOf=<escapeLdapFilterValue(group.ldapDn)>))`. Der Gruppen-DN ist Admin-Eingabe aus dem Auswahldialog und wird deshalb immer escaped, nie roh interpoliert. Die Suche läuft über jeden von `parseBaseDns(config.baseDn)` gelieferten Base-DN mit `scope: 'sub'` und derselben Attributliste wie der Benutzer-Sync. Die Treffer werden über `mapEntry` auf kleingeschriebene Benutzernamen abgebildet und in einem Set gesammelt — die Reihenfolge der Treffer spielt damit keine Rolle.

Lies memberOf niemals als Rückgabeattribut, um Mitglieder aufzuzählen. Bei AD-Gruppen mit mehr als 1500 Mitgliedern liefert das Verzeichnis stillschweigend nur einen Teilausschnitt in der Form `attribut;range=0-1499`, und der Sync würde eine gebundene Gruppe dauerhaft mit genau 1500 Mitgliedern führen, ohne dass irgendwo ein Fehler auftaucht. Die Filter-Variante umgeht das Problem strukturell.

Der Abgleich pro Gruppe läuft dann in drei Schritten. Erstens: die lokalen Benutzer des Mandanten zu den gefundenen Benutzernamen auflösen (eine einzige Query mit `username: { in: [...] }` und `tenantId`, keine Schleife). Zweitens: fehlende Mitgliedschaften mit `createMany` und `skipDuplicates: true` und `source: 'LDAP'` anlegen; `skipDuplicates` gegen `@@unique([groupId, userId])` ist genau der Mechanismus, der eine bestehende MANUAL-Zeile unangetastet lässt, statt sie auf LDAP umzuschreiben. Drittens: mit `deleteMany` und `where: { groupId, source: 'LDAP', userId: { notIn: [...] } }` die verwaisten AD-Mitgliedschaften entfernen. Der Filter auf `source: 'LDAP'` ist der einzige Schutz der manuell gesetzten Mitgliedschaften — ohne ihn würde jeder Sync-Lauf den Mischbetrieb aus D-20 zerstören.

Verschachtelte AD-Gruppen werden nicht aufgelöst: es kommt ausschliesslich der einfache `memberOf`-Filter zum Einsatz, nicht die AD-Erweiterung `LDAP_MATCHING_RULE_IN_CHAIN`. Halte das als Kommentar an der Methode fest, samt Begründung (kein Bedarf in D-19, herstellerspezifisch, würde auf einem Nicht-AD-Verzeichnis stillschweigend null Treffer liefern) — sonst wirkt es wie ein Versehen.

Jede Gruppe läuft in einem eigenen try/catch. Ein Fehler wird als `Gruppe <name>: <message>` in `result.errors` aufgenommen und die Schleife läuft weiter, exakt wie die bestehende Behandlung pro Verzeichniseintrag in der Benutzerschleife.

Rufe die neue Methode in `syncUsersForTenant` nach Schritt 5 (Deaktivierungsschleife) und vor Schritt 6 (Aktualisieren von `lastSyncAt`) auf. Sie liegt damit hinter dem Base-DN-No-Op-Wächter: ein Mandant ohne konfigurierte Base-DN führt weiterhin keinerlei LDAP-Aktion aus. Der Abgleich läuft ohne weiteres Zutun sowohl beim manuellen Sync über `POST /ldap/sync` als auch im Intervall-Scheduler, weil beide dieselbe Methode aufrufen — es entsteht kein zweiter Job und kein zweiter Button (D-21).

Erweitere `apps/api/src/ldap/ldap.service.spec.ts` um einen `describe`-Block, der jeden unter `<behavior>` genannten Fall mit gemocktem ldapts-`Client` und gemocktem PrismaService abdeckt.
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" = '<id>';` mit der Mitgliederzahl der AD-Gruppe vergleichen — beide müssen übereinstimmen. Steht dort dauerhaft exakt 1000 oder 1500, ist das Range-Retrieval-Problem doch aufgetreten und der Task gilt als nicht bestanden.
4. Über `POST /groups/:id/members` einen Benutzer von Hand hinzufügen, der NICHT in der AD-Gruppe ist. Erneut syncen. Die manuelle Mitgliedschaft muss erhalten bleiben (D-20).
5. Im AD einen Benutzer aus der Gruppe entfernen, erneut syncen. Ausschliesslich seine Zeile mit `source = 'LDAP'` darf verschwinden; die manuelle Mitgliedschaft aus Schritt 4 bleibt unverändert (D-19).
Antworte "approved" oder beschreibe die Abweichung

<threat_model>

Trust Boundaries

Boundary Description
Admin-Eingabe → LDAP-Suchfilter Group.ldapDn stammt aus einer Admin-Auswahl und wird in einen Suchfilter interpoliert
Active Directory → Tessera-Datenbank Die Trefferliste des Verzeichnisses steuert das Anlegen und Löschen von Mitgliedschaften

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-15-07 Tampering Interpolation von Group.ldapDn in den memberOf-Filter medium mitigate Der DN läuft immer durch LdapService.escapeLdapFilterValue (RFC 4515), identisch zur bestehenden Behandlung von groupFilterDns; ein Testfall mit Sonderzeichen im DN belegt es
T-15-15 Elevation of Privilege Ein AD-Treffer aus einem fremden Mandanten wird Mitglied einer Gruppe high mitigate Die Auflösung von Benutzernamen auf lokale Benutzer filtert immer zusätzlich auf die tenantId des Sync-Laufs; ein Benutzername, der nur in einem anderen Mandanten existiert, erzeugt keine Mitgliedschaft
T-15-16 Tampering Der Sync löscht manuell gesetzte Mitgliedschaften high mitigate Die Lösch-Query filtert ausschliesslich auf source: 'LDAP'; ein eigener Testfall belegt, dass eine MANUAL-Zeile eines nicht mehr gefundenen Benutzers erhalten bleibt (D-19/D-20)
T-15-17 Information Disclosure Unvollständige Mitgliederliste durch AD-Range-Retrieval medium mitigate Mitglieder werden ausschliesslich per Reverse-Query mit memberOf als Filterkriterium ermittelt; die Attributliste der Suche enthält kein Mitgliedschaftsattribut. Der Human-Verify-Checkpoint prüft die Zahl gegen ein echtes AD
</threat_model>
- `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.

<success_criteria>

  • Eine Gruppe mit gesetztem ldapDn bekommt ihre Mitglieder aus dem AD, im bestehenden Sync-Durchlauf (PERM-02, D-21).
  • Manuell gesetzte Mitgliedschaften überleben jeden Sync-Lauf (D-19, D-20).
  • Kein Codepfad liest Mitgliedschaftsattribute als Rückgabewerte. </success_criteria>

Artifacts this phase produces

Von diesem Plan erzeugt beziehungsweise verändert:

  • LdapService.syncGroupMembershipsForTenant (privat, apps/api/src/ldap/ldap.service.ts)
  • LdapSyncResult erweitert um groupMembershipsAdded und groupMembershipsRemoved
  • apps/api/src/ldap/ldap.service.spec.ts erweitert um den describe-Block zur AD-Gruppenbindung

Die phasenweite Gesamtliste steht in 15-01-PLAN.md.

Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-04-SUMMARY.md`, wenn der Plan abgeschlossen ist.