191 lines
18 KiB
Markdown
191 lines
18 KiB
Markdown
---
|
||
phase: 15-modul-berechtigungen-gruppen-user-grants
|
||
plan: 04
|
||
type: execute
|
||
wave: 2
|
||
depends_on: ["15-01"]
|
||
files_modified:
|
||
- apps/api/src/ldap/ldap.service.ts
|
||
- apps/api/src/ldap/ldap.service.spec.ts
|
||
autonomous: false
|
||
requirements: [PERM-02]
|
||
user_setup: []
|
||
|
||
estimate:
|
||
tokens: 48000
|
||
raw_tokens: 48000
|
||
tasks: 2
|
||
confidence: low
|
||
|
||
must_haves:
|
||
truths:
|
||
- "Eine Tessera-Gruppe kann optional an einen AD-Gruppen-DN gebunden werden; ohne gesetzten ldapDn fasst der Sync sie überhaupt nicht an (D-05, PERM-02)."
|
||
- "Der bestehende Benutzer-Sync pflegt die Gruppenmitgliedschaften im selben Durchlauf mit — es gibt keinen zweiten Sync-Job und keinen zweiten Button im Admin-UI (D-21)."
|
||
- "Verlässt ein Benutzer die gebundene AD-Gruppe, verschwindet ausschliesslich seine Mitgliedschaft mit source LDAP; eine manuell gesetzte Mitgliedschaft desselben Benutzers in derselben Gruppe bleibt bestehen (D-19)."
|
||
- "Einer AD-gebundenen Gruppe dürfen zusätzlich Benutzer von Hand hinzugefügt werden; der Sync entfernt diese nie (D-20)."
|
||
- "Die Mitgliederermittlung läuft als Reverse-Query mit memberOf als Filterkriterium, niemals über das Lesen von member- oder memberOf-Attributwerten — nur so entfällt das Range-Retrieval-Problem grosser AD-Gruppen."
|
||
- "Der Gruppen-DN wird vor der Interpolation in den LDAP-Filter durch LdapService.escapeLdapFilterValue geführt (RFC 4515)."
|
||
- "Verschachtelte AD-Gruppen werden in dieser Phase bewusst nicht aufgelöst — nur direkte Mitgliedschaft, konsistent zum bestehenden groupFilterDns-Verhalten aus Phase 2."
|
||
# --- Edge-Probe PERM-02 (6 Kategorien) ---
|
||
- "adjacency/PERM-02: Ist ein Benutzer sowohl manuell als auch über das AD Mitglied derselben Gruppe, existiert genau eine GroupMembership-Zeile; der Sync hebt eine bestehende MANUAL-Zeile nicht auf LDAP an und löscht sie nie."
|
||
- "empty/PERM-02: Liefert die memberOf-Suche einer gebundenen Gruppe null Treffer, verliert die Gruppe alle ihre LDAP-Mitgliedschaften, behält aber alle MANUAL-Mitgliedschaften; eine Gruppe ohne ldapDn wird gar nicht erst abgefragt."
|
||
- "encoding/PERM-02: Ein Gruppen-DN mit Klammern, Sternchen oder Backslash zerstört den Suchfilter nicht, weil er RFC-4515-escaped interpoliert wird."
|
||
- "ordering/PERM-02: Die Reihenfolge der AD-Treffer ist unerheblich — das Sync-Ergebnis ist eine Mengenoperation über Benutzernamen und bei umgekehrter Trefferreihenfolge identisch."
|
||
- "idempotency/PERM-02: Zwei Sync-Läufe ohne AD-Änderung erzeugen keine zusätzliche und löschen keine bestehende GroupMembership-Zeile."
|
||
- statement: "concurrency/PERM-02: Bricht der Sync mitten in der Gruppenschleife ab, bleiben die bereits verarbeiteten Gruppen konsistent, der Fehler landet in LdapSyncResult.errors, und kein Teilzustand verliert MANUAL-Mitgliedschaften."
|
||
verification: backstop
|
||
artifacts:
|
||
- "apps/api/src/ldap/ldap.service.ts — neue private Methode syncGroupMembershipsForTenant, aufgerufen aus syncUsersForTenant"
|
||
- "apps/api/src/ldap/ldap.service.spec.ts — erweitert um die AD-Gruppenbindungs-Fälle"
|
||
key_links:
|
||
- "syncUsersForTenant → syncGroupMembershipsForTenant — der Gruppen-Abgleich hängt am bestehenden Durchlauf, nicht an einem eigenen Job (D-21)"
|
||
- "GroupMembership.source = LDAP → die Lösch-Query des Sync — dieser Filter ist der einzige Schutz manueller Mitgliedschaften (D-19/D-20)"
|
||
- "Group.ldapDn → der memberOf-Filter — ohne diesen Wert ist eine Gruppe für den Sync unsichtbar"
|
||
---
|
||
|
||
<objective>
|
||
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.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||
@$HOME/.claude/gsd-core/templates/summary.md
|
||
</execution_context>
|
||
|
||
<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
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>Task 1: Gruppenmitgliedschafts-Abgleich im bestehenden LDAP-Sync-Durchlauf</name>
|
||
<files>apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts</files>
|
||
<precondition>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.</precondition>
|
||
<read_first>
|
||
- 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
|
||
</read_first>
|
||
<behavior>
|
||
- 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.
|
||
</behavior>
|
||
<action>
|
||
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.
|
||
</action>
|
||
<verify>
|
||
<automated>pnpm --filter @tessera/api test -- ldap.service</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- `pnpm --filter @tessera/api test -- ldap.service` ist grün und enthält für jeden unter `<behavior>` 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.
|
||
</acceptance_criteria>
|
||
<done>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.</done>
|
||
</task>
|
||
|
||
<task type="checkpoint:human-verify" gate="blocking">
|
||
<name>Task 2: Live-Abgleich der AD-Gruppenbindung gegen ein echtes Active Directory</name>
|
||
<what-built>
|
||
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.
|
||
</what-built>
|
||
<how-to-verify>
|
||
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).
|
||
</how-to-verify>
|
||
<resume-signal>Antworte "approved" oder beschreibe die Abweichung</resume-signal>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<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>
|
||
|
||
<verification>
|
||
- `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.
|
||
</verification>
|
||
|
||
<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`.
|
||
|
||
<output>
|
||
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-04-SUMMARY.md`, wenn der Plan abgeschlossen ist.
|
||
</output>
|