docs(16): research AD-Gruppen-Synchronisation phase
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
# Phase 16: AD-Gruppen-Synchronisation - Research
|
||||
|
||||
**Researched:** 2026-08-05
|
||||
**Domain:** LDAP/AD-Gruppen-Import und -Nachführung auf einem bestehenden NestJS/Prisma/PostgreSQL-Stack mit Zeilenebenen-Mandantentrennung (RLS)
|
||||
**Confidence:** HIGH (Codebasis vollständig gelesen und live durch die installierte `ldapts`-Bibliothek verifiziert) / MEDIUM-LOW für das konkrete AD-Laufzeitverhalten von `objectGUID` (keine Live-AD-Verbindung in dieser Session möglich — siehe Assumptions Log)
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
- **D-01:** Der Import sitzt im LDAP-Bereich (`/admin/ldap`), nicht in der Gruppenverwaltung: AD-Gruppe auswählen, importieren — dasselbe Muster wie der dort bereits vorhandene Benutzer-Import. Die vorhandene AD-Gruppensuche wird wiederverwendet, keine zweite Suchmechanik.
|
||||
- **D-02:** Nur ausdrücklich ausgewählte Gruppen werden übernommen. Keine pauschale Übernahme einer OU oder eines Filters — **Reversibility:** reversible.
|
||||
- **D-03:** `Group.name` gehört dem AD. Wird die Gruppe dort umbenannt, zieht Tessera beim nächsten Sync nach. In Tessera ist das Feld für importierte Gruppen gesperrt, mit Hinweis auf die Herkunft.
|
||||
- **D-04:** Dazu kommt eine neue Spalte für einen **internen Namen**, die der Sync niemals anfasst. Ist sie gesetzt, zeigt die Oberfläche diesen Namen — in der Gruppenliste, als Spaltenkopf der Freigabe-Matrix und in den Chips im Benutzer-Detail. Der AD-Name bleibt im Bearbeiten-Dialog sichtbar, damit die Herkunft nachvollziehbar bleibt. Löst den Zielkonflikt zwischen „AD ist die Wahrheit" und „wir wollen einen eigenen Namen", ohne dass der Sync ihn zurücksetzt — **Reversibility:** one-way — neue Spalte plus Migration; nach Vergabe echter interner Namen ist ein Rückbau Datenverlust.
|
||||
- **D-05:** Verschwindet eine importierte Gruppe aus dem AD, wird die Tessera-Gruppe entfernt — samt Mitgliedschaften und Modulfreigaben. Die betroffenen Benutzer verlieren den darüber vergebenen Zugriff, ohne den Warndialog aus Phase 15 D-17; der greift nur beim Löschen über die Tessera-Oberfläche. Bewusst akzeptiert.
|
||||
- **D-06:** War die gelöschte Gruppe die markierte Standardgruppe, wandert die Markierung weiter statt zu verschwinden: bevorzugt auf die lokale Gruppe „Alle Benutzer", sonst auf eine andere vorhandene Gruppe. Es darf kein Zustand entstehen, in dem ein Mandant ohne Standardgruppe dasteht und neue Benutzer nirgends beitreten. Der Sync protokolliert den Wechsel.
|
||||
- **D-07:** Eine lokal angelegte Gruppe kann **nicht** an eine AD-Gruppe gebunden werden. Die in Plan 15-06 gebaute Radio-Auswahl im Anlegen- und Bearbeiten-Dialog wird wieder entfernt. Begründung: Mit dem internen Namen (D-04) entfällt ihr Hauptzweck, und zwei Wege zum selben Ergebnis sind eine Fehlerquelle. Bestehende Gruppen mit gesetztem `ldapDn` gelten als importiert und werden vom Sync verwaltet.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
- Benennung und Typ der neuen Spalte für den internen Namen sowie die Migration
|
||||
- Ob der Import einen Sync sofort auslöst oder erst der nächste turnusmäßige Lauf die Mitglieder füllt
|
||||
- Form des Sync-Berichts: welche Zahlen zurückgemeldet werden (importiert, umbenannt, gelöscht, Markierung verschoben)
|
||||
- Verhalten bei Namenskollision, wenn eine importierte AD-Gruppe genauso heißt wie eine bestehende lokale Gruppe — `@@unique([tenantId, name])` erzwingt hier eine Entscheidung
|
||||
- Ob der Import-Dialog bereits importierte Gruppen als solche kennzeichnet oder ausblendet
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
|
||||
- DÖE-Ausschreibungen verlinken auf die API statt auf die Bekanntmachungsseite — separater, bereits diagnostizierter Todo, ausdrücklich erst nach Phase 16.
|
||||
- Verschachtelte AD-Gruppen (indirekte Mitgliedschaft über Untergruppen) — seit Phase 15 bewusst außerhalb.
|
||||
- OU-weite oder filterbasierte Automatik statt Einzelauswahl — vom User verworfen.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
Für Phase 16 sind noch keine REQ-IDs vergeben (`werden in /gsd-plan-phase 16 vergeben`). Die Abdeckung wird stattdessen direkt aus den fünf Roadmap-Success-Criteria und den Entscheidungen D-01..D-07 abgeleitet:
|
||||
|
||||
| Kriterium | Beschreibung | Research Support |
|
||||
|-----------|--------------|-------------------|
|
||||
| SC-1 | Admin wählt AD-Gruppen aus der Liste, jede ausgewählte Gruppe entsteht als Tessera-Gruppe mit gesetzter AD-Bindung | Import-Endpoint-Muster (Q1), stabile Identität via `objectGUID` (Q2), neue Spalten (Q3) |
|
||||
| SC-2 | Nicht ausgewählte AD-Gruppen werden nicht angelegt | Discovery-Response mit `alreadyImported`-Flag statt Auto-Import; kein OU-weiter Sweep |
|
||||
| SC-3 | Umbenennung im AD zieht beim nächsten Sync nach | Stabile Identität via `objectGUID` als Match-Key statt `ldapDn` (Q2), Reihenfolge im Sync-Lauf (Q7) |
|
||||
| SC-4 | Verschwinden im AD löscht die Tessera-Gruppe inkl. Mitgliedschaften/Freigaben | Bestehende `onDelete: Cascade`-Regeln (Q4), Default-Gruppen-Handoff (Q5) |
|
||||
| SC-5 | Manuell angelegte Gruppen ohne AD-Bindung bleiben unberührt | Bestehendes `ldapDn: { not: null }`-Filtermuster aus D-21 (`syncGroupMembershipsForTenant`), unverändert übernommen |
|
||||
| D-04 | Interner Name überlebt jeden Sync, wird an drei Stellen angezeigt | Anzeige-Fundstellen identifiziert (Gruppenliste, Matrix-Header, Benutzer-Chips) |
|
||||
| D-06 | Kein Mandant ohne Standardgruppe nach Sync-Löschung | `ensureDefaultGroup`-Muster + neue Vor-Lösch-Transaktion (Q5) |
|
||||
| D-07 | Radio-Auswahl in `GroupFormModal` entfernt | Exakte zu entfernende Codeabschnitte identifiziert |
|
||||
</phase_requirements>
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 16 ist zu über 90% eine Erweiterung bereits bestehenden, gut verstandenen Codes — kein neues Fundament. Die drei tragenden Bausteine existieren schon: `LdapService.listGroups()` liefert die AD-Gruppen-/OU-Liste (bereits von Phase-15-D-18 genutzt), `LdapService.syncGroupMembershipsForTenant()` (D-21, Commit `614de28`) pflegt Mitgliedschaften für JEDE Gruppe mit gesetztem `ldapDn` bereits vollautomatisch bei jedem Sync-Lauf, und `Group`/`GroupMembership`/`ModuleGrant` tragen bereits `onDelete: Cascade`-Regeln, die eine Gruppenlöschung ohne zusätzlichen Aufräumcode sauber durchreichen. Phase 16 muss diese Bausteine um drei neue Fähigkeiten ergänzen: (1) eine Import-UI im LDAP-Bereich, die aus der vorhandenen Gruppenliste gezielt einzelne `Group`-Zeilen anlegt statt nur ein Suchfilter-Array zu pflegen; (2) eine Identitäts-Strategie, die eine AD-Umbenennung von einem AD-Verschwinden unterscheiden kann — der `ldapDn`, den Phase 15 als Bindungsschlüssel nutzt, enthält den CN und ändert sich bei jeder Umbenennung, ist also für sich allein untauglich; und (3) eine neue Spalte für den sync-immunen internen Namen (D-04) plus deren Anzeige an drei Frontend-Stellen.
|
||||
|
||||
Die Kernentscheidung dieser Phase ist die Identitätsfrage aus Punkt (2). Die installierte `ldapts@8.1.8`-Bibliothek unterstützt nachweislich binäre LDAP-Attribute über die Option `explicitBufferAttributes` (im installierten Quellcode verifiziert, siehe Code Examples) — das erlaubt, `objectGUID` als `Buffer` abzufragen und hex-kodiert als stabilen, umbenennungsresistenten Schlüssel zu speichern. Diese Fähigkeit der Bibliothek ist verifiziert; das *Verhalten* eines echten Active Directory (ob `objectGUID` bei einer CN-Umbenennung tatsächlich unverändert bleibt, wie in der Windows-Dokumentation allgemein beschrieben) konnte in dieser Session nicht gegen einen echten AD-Server geprüft werden und ist entsprechend als Annahme markiert, mit derselben Live-Verifikations-Empfehlung wie beim EWS-Postfach-Pfad aus Phase 14 (14-03).
|
||||
|
||||
**Primary recommendation:** Neue Spalte `Group.ldapObjectGuid` (nullable, hex-kodiert, `@@unique([tenantId, ldapObjectGuid])` analog zu `ldapDn`) als Sync-Match-Key einführen; `ldapDn` bleibt bestehen, wird aber bei jedem Sync aus dem aktuellen AD-Zustand überschrieben (Anzeige/Debugging), nicht mehr als Identität benutzt. Ein neuer Rekonziliations-Schritt `syncBoundGroupsForTenant()` läuft in `syncUsersForTenant()` **vor** der bestehenden `syncGroupMembershipsForTenant()` (D-21), damit Letztere immer mit einem bereits aktualisierten `ldapDn` arbeitet.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| AD-Gruppenauswahl-UI (Checkbox-Liste, Import-Button) | Browser / Client | Frontend Server (Next.js `'use client'`-Seite) | Reiner Interaktions-State wie das bestehende `admin/ldap/page.tsx`-Muster; kein SSR-Datenbedarf |
|
||||
| AD-Gruppen-Discovery (LDAP-Suche, `objectGUID`-Abfrage) | API / Backend | — | `LdapService` spricht das LDAP-Protokoll; Directory-Zugriff darf nie vom Client aus erfolgen (Zugangsdaten liegen server-seitig) |
|
||||
| Stabile Identität / Rename-Erkennung | API / Backend | Database / Storage | Entscheidungslogik in `LdapService`, Persistenz der `ldapObjectGuid`-Spalte in Postgres |
|
||||
| Cascade-Löschung (Gruppe + Mitgliedschaften + Freigaben) | Database / Storage | API / Backend | Bereits als `onDelete: Cascade` in der DB verankert (15-01); der Sync-Code ruft nur `group.delete()` auf, baut keine eigene Lösch-Logik |
|
||||
| Default-Gruppen-Handoff (D-06) | API / Backend | Database / Storage | Transaktionale Entscheidungslogik im Service, abgesichert durch den bestehenden partiellen Unique-Index `Group_one_default_per_tenant` in der DB |
|
||||
| Interner-Name-Anzeige (Gruppenliste, Matrix-Header, Chips) | Browser / Client | API / Backend | Backend liefert `internalName ?? name` bzw. beide Felder; die Auswahl-Logik "welcher Name gewinnt" liegt bewusst im Service, nicht dreifach dupliziert im Frontend |
|
||||
| Mandantentrennung während des Sync | API / Backend | Database / Storage | `forTenant()`-Wrapper setzt `app.current_tenant`; RLS auf `Group`/`GroupMembership`/`ModuleGrant` ist das zweite Netz |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
|
||||
Keine neuen Abhängigkeiten. Diese Phase nutzt ausschließlich bereits installierte Bibliotheken:
|
||||
|
||||
| Library | Version (installiert) | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `ldapts` | 8.1.8 [VERIFIED: apps/api/package.json, node_modules/.pnpm/ldapts@8.1.8/node_modules/ldapts/package.json] | LDAP-Client, bereits im gesamten `LdapService` im Einsatz | Bereits etabliert; unterstützt binäre Attribute nativ über `explicitBufferAttributes` — keine zweite LDAP-Bibliothek nötig |
|
||||
| `@nestjs/schedule` | bereits installiert (Cron für `LdapSyncScheduler`) | Bestehender Scheduler-Trigger | Unverändert wiederverwendet |
|
||||
| Prisma | `^6.0.0` [VERIFIED: apps/api/package.json] | ORM/Migrationen | Unverändert |
|
||||
|
||||
**Hinweis zur Registry-Prüfung:** `npm view ldapts version` liefert aktuell `9.0.0` als neueste Version auf der Registry [VERIFIED: npm-Registry-Abfrage in dieser Session], das Projekt ist aber gepinnt auf `^8.1.8` und tatsächlich installiert ist `8.1.8`. Ein Upgrade auf 9.x ist **nicht** Teil dieser Phase — es besteht kein funktionaler Bedarf, und ein Versionssprung würde eine eigene Verifikation der API-Kompatibilität (insbesondere `explicitBufferAttributes`, das in 8.1.8 nachweislich existiert) erfordern, die außerhalb des Phasenumfangs liegt.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `objectGUID` als Rename-stabiler Identitäts-Schlüssel | `objectSid` | `objectSid` kann sich bei Domain-übergreifenden Verschiebungen ändern (SID-History-Mechanik), `objectGUID` gilt als über die gesamte Objektlebensdauer stabil — für dieses Projekt (ein AD, keine Cross-Domain-Migrationen im Scope) ist `objectGUID` die robustere Wahl [ASSUMED — allgemeines AD-Verzeichniswissen, nicht in dieser Session gegen Microsoft-Dokumentation verifiziert] |
|
||||
| Server-seitige LDAP-Suche als Import-Basis | Client-seitiges Caching der Gruppenliste | Verworfen — würde eine zweite Suchmechanik neben der bestehenden `listGroups()` einführen, was D-01 explizit ausschließt |
|
||||
|
||||
**Installation:** Keine — keine neuen Pakete.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
**Nicht anwendbar.** Diese Phase installiert keine neuen externen Pakete. Es wird ausschließlich eine bereits vorhandene, in `package.json` gepinnte Bibliothek (`ldapts@8.1.8`) um die Nutzung einer zusätzlichen, bereits im installierten Quellcode vorhandenen Option (`explicitBufferAttributes`) erweitert — kein Registry-Zugriff, kein neuer Lieferant, kein Supply-Chain-Risiko.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Admin (Browser)
|
||||
│
|
||||
│ 1. GET /ldap/groups (Discovery — bestehender Endpoint, erweitert um alreadyImported-Flag)
|
||||
▼
|
||||
LdapController.listGroups() ───────────────► LdapService.listGroups()
|
||||
│ │
|
||||
│ 2. Admin waehlt AD-Gruppen aus, klickt Import │ LDAP-Suche gegen AD
|
||||
▼ ▼
|
||||
POST /ldap/groups/import { dns: string[] } Active Directory
|
||||
│ (liefert dn, cn, objectGUID)
|
||||
▼
|
||||
LdapController.importGroups()
|
||||
│
|
||||
▼
|
||||
LdapService.importGroupsByDn() ── legt Group-Zeilen an
|
||||
│ (name = AD-cn, ldapDn = aktueller DN, ldapObjectGuid = hex(objectGUID))
|
||||
▼
|
||||
PostgreSQL Group-Tabelle (RLS, forTenant-scoped)
|
||||
|
||||
|
||||
── Turnusmaessiger/manueller Sync (unveraendert getriggert) ──────────────
|
||||
|
||||
LdapSyncScheduler (Cron) oder POST /ldap/sync (Button)
|
||||
│
|
||||
▼
|
||||
LdapService.syncUsersForTenant(config, tenantId)
|
||||
│
|
||||
├─ 1..4 Benutzer-Sync (unveraendert)
|
||||
├─ 5 Benutzer-Deaktivierung (unveraendert)
|
||||
├─ 5a NEU syncBoundGroupsForTenant(tenantId)
|
||||
│ für jede Group mit ldapObjectGuid gesetzt:
|
||||
│ AD-Suche nach objectGUID ─┬─► Treffer: cn geaendert? → Group.name/ldapDn aktualisieren (SC-3)
|
||||
│ └─► kein Treffer: D-06-Handoff, dann Group.delete() → Cascade (SC-4)
|
||||
├─ 5b syncGroupMembershipsForTenant(tenantId) (D-21, unveraendert,
|
||||
│ arbeitet jetzt mit dem in 5a aktualisierten ldapDn)
|
||||
└─ 6 lastSyncAt aktualisieren
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
Keine neuen Verzeichnisse. Erweiterungen bestehender Dateien:
|
||||
|
||||
```
|
||||
apps/api/src/ldap/
|
||||
├── ldap.service.ts # + importGroupsByDn(), syncBoundGroupsForTenant(), + objectGUID-Handling in listGroups()
|
||||
├── ldap.controller.ts # + POST /ldap/groups/import
|
||||
├── ldap.service.spec.ts # + neue describe-Bloecke fuer Rename/Delete/Import
|
||||
└── dto/ldap-config.dto.ts # + ImportGroupsDto (dns: string[])
|
||||
|
||||
apps/api/prisma/
|
||||
├── schema.prisma # Group: + internalName, + ldapObjectGuid
|
||||
└── migrations/<neu>_add_group_internal_name_and_object_guid/
|
||||
|
||||
apps/api/src/groups/
|
||||
├── groups.service.ts # + reassignDefaultBeforeDelete()-Helper (fuer Sync-Aufruf), Anzeige-Selects auf internalName ?? name
|
||||
├── groups.controller.ts # unveraendert (kein neuer Endpoint hier, D-01)
|
||||
└── module-grants.service.ts # Zeilen 240/249/264: group.name → group internalName-aware select
|
||||
|
||||
apps/web/src/app/(portal)/admin/
|
||||
├── ldap/page.tsx # + neue Sektion "AD-Gruppen importieren" (Muster: Sektion 2.55 Benutzer-Import)
|
||||
├── groups/components/GroupFormModal.tsx # D-07: ldapBind-Radio-Block komplett entfernen
|
||||
├── groups/page.tsx # Namensanzeige auf internalName ?? name umstellen
|
||||
├── modules/grants/page.tsx # Zeile ~209/212: Spaltenkopf auf internalName ?? name
|
||||
└── users/components/UserAccessModal.tsx # viaGroups-Chips: Backend liefert bereits den richtigen Namen (kein Frontend-Change noetig, wenn Backend-Select korrekt ist)
|
||||
```
|
||||
|
||||
### Pattern 1: Additive Result-Felder auf einem gemeinsamen Sync-Report
|
||||
|
||||
**What:** `LdapSyncResult` wächst additiv (wie schon bei D-21) statt einen zweiten Report-Typ einzuführen.
|
||||
**When to use:** Jede neue Sync-Teilaufgabe, die im selben Lauf wie `syncUsersForTenant()` passiert.
|
||||
**Example:**
|
||||
```typescript
|
||||
// Source: apps/api/src/ldap/ldap.service.ts:9-20 (bestehendes Muster, VERIFIED)
|
||||
export interface LdapSyncResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
deactivated: number;
|
||||
groupMembershipsAdded: number;
|
||||
groupMembershipsRemoved: number;
|
||||
// Phase 16 (additiv, gleiches Muster):
|
||||
// groupsImported: number;
|
||||
// groupsRenamed: number;
|
||||
// groupsDeleted: number;
|
||||
// defaultMarkerMoved: number;
|
||||
errors: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Binäres LDAP-Attribut abfragen (`objectGUID`)
|
||||
|
||||
**What:** `ldapts` liefert binäre Attribute nur als `Buffer`, wenn `explicitBufferAttributes` explizit gesetzt wird — sonst versucht die Bibliothek eine UTF-8-Dekodierung, die bei `objectGUID` (raw 16-Byte-GUID) zu Datenverlust führt.
|
||||
**When to use:** Immer, wenn `objectGUID` oder `objectSid` aus AD gelesen werden soll.
|
||||
**Example:**
|
||||
```typescript
|
||||
// Source: node_modules/.pnpm/ldapts@8.1.8/node_modules/ldapts/src/Client.ts:139,568,628
|
||||
// und src/messages/SearchEntry.ts:44-66 (VERIFIED — installierter Quellcode dieser Session gelesen)
|
||||
const { searchEntries } = await client.search(baseDn, {
|
||||
filter: '(objectClass=group)',
|
||||
attributes: ['cn', 'dn', 'objectGUID'],
|
||||
explicitBufferAttributes: ['objectGUID'], // erzwingt Buffer statt (verlustbehafteter) UTF-8-Dekodierung
|
||||
scope: 'sub',
|
||||
});
|
||||
// entry['objectGUID'] ist jetzt ein Buffer (oder Buffer[] bei Mehrfachwerten, hier nie der Fall)
|
||||
const guidHex = (entry['objectGUID'] as Buffer).toString('hex'); // stabiler, speicherbarer Identitaets-String
|
||||
```
|
||||
|
||||
### Pattern 3: Group-Existenz-Sweep mit binärem Filter (NEU, ungetestet gegen echtes AD)
|
||||
|
||||
**What:** Eine gespeicherte `objectGUID` gegen den aktuellen AD-Stand prüfen — Filterwert muss byteweise `\XX`-hex-escaped werden (RFC 4515), nicht wie ein String über `escapeLdapFilterValue()`.
|
||||
**When to use:** In `syncBoundGroupsForTenant()`, um pro gebundener Gruppe herauszufinden, ob sie noch existiert und wie sie aktuell heißt.
|
||||
**Example:**
|
||||
```typescript
|
||||
// [ASSUMED — Filtersyntax nach RFC 4515-Praxis, nicht in dieser Session gegen ein
|
||||
// echtes AD verifiziert; vor Produktivnahme mit einem echten Verzeichnis pruefen]
|
||||
function escapeLdapFilterBuffer(buf: Buffer): string {
|
||||
return Array.from(buf)
|
||||
.map((b) => '\\' + b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
const filter = `(objectGUID=${escapeLdapFilterBuffer(storedGuidBuffer)})`;
|
||||
const { searchEntries } = await client.search(baseDn, {
|
||||
filter,
|
||||
attributes: ['cn', 'dn'],
|
||||
scope: 'sub',
|
||||
});
|
||||
// searchEntries.length === 0 → Gruppe im AD verschwunden (D-05)
|
||||
// searchEntries[0].dn !== group.ldapDn → umbenannt (D-03), Group.name/ldapDn aktualisieren
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **`ldapDn` als alleinigen Identitäts-/Match-Schlüssel für den Gruppen-Sync verwenden:** Der DN enthält den CN — jede Umbenennung ändert ihn. Ein Sync, der ausschließlich per `ldapDn` matcht, kann eine Umbenennung strukturell nicht von einem Verschwinden unterscheiden (SC-3 vs. SC-4 würden identisch behandelt: beide als "gelöscht+neu angelegt", was Mitgliedschaften und Modulfreigaben unnötig zerstört).
|
||||
- **Gruppen-Rekonziliation nach der Mitgliedschafts-Rekonziliation laufen lassen:** Wenn `syncGroupMembershipsForTenant()` (D-21) vor einer Namens-/DN-Aktualisierung läuft, sucht sie mit dem VERALTETEN `ldapDn` — nach einer echten Umbenennung im AD liefert der `memberOf=<alte-DN>`-Filter keine Treffer mehr, und alle LDAP-Mitgliedschaften der umbenannten Gruppe würden fälschlich als "entfernt" behandelt.
|
||||
- **`prisma.group.delete()` außerhalb eines `forTenant()`-Scopes aufrufen:** `Group`/`GroupMembership`/`ModuleGrant` tragen `FORCE ROW LEVEL SECURITY` (Migration `20260804130918_groups_rls_policies`); ein ungescopter Aufruf ohne gesetzten `app.current_tenant`-Session-Wert liefert je nach DB-Rolle entweder null betroffene Zeilen oder — schlimmer — arbeitet mit der falschen Session-Variable aus einem vorherigen, nicht bereinigten Kontext.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Mandantentrennung bei DB-Schreibzugriffen im Sync | Eigene `WHERE tenantId = ...`-Disziplin von Hand | `forTenant(this.prisma, tenantId)` (bereits in `syncGroupMembershipsForTenant` verwendet) | Setzt die RLS-Session-Variable korrekt und konsistent; von Hand vergessen ist der dokumentierte Klassiker (Pitfall 2 aus der Phase-2-Research) |
|
||||
| Cascade-Löschung von Mitgliedschaften/Freigaben beim Gruppen-Löschen | Anwendungsseitige `deleteMany`-Aufrufe vor dem `group.delete()` | Bestehende DB-`onDelete: Cascade`-Regeln (15-01) | Bereits vorhanden und getestet (`GroupsService.remove()` verlässt sich exakt darauf, keine Doppel-Wahrheit über Aufräumverhalten schaffen) |
|
||||
| LDAP-Filter-Escaping für String-Werte | Eigene Regex | `LdapService.escapeLdapFilterValue()` (statisch, bereits vorhanden) | Bereits gegen T-02-16/T-15-07 (LDAP-Injection) geprüft und in Produktion verwendet |
|
||||
| Default-Gruppen-Bootstrap für einen Mandanten ganz ohne Gruppen | Eigene Re-Implementierung der "Alle Benutzer"-Anlage | `GroupsService.ensureDefaultGroup(tenantId)` (Quick-Task 260805-fok) | Bereits idempotent, race-sicher gegen den partiellen Unique-Index abgesichert — als Fallback nach einer Sync-Löschung des letzten verbleibenden Mandanten-Gruppe direkt wiederverwendbar |
|
||||
|
||||
**Key insight:** Praktisch der gesamte "harte" Teil dieser Phase (Mandantentrennung, Cascade-Semantik, Default-Gruppen-Bootstrap, LDAP-Escaping) ist in Phase 15 bereits gebaut und getestet worden. Der einzige wirklich neue Baustein ist die Rename-vs-Delete-Unterscheidung über `objectGUID`.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: DN-basierte Reihenfolge-Falle im selben Sync-Lauf
|
||||
|
||||
**What goes wrong:** Eine AD-Umbenennung wird im selben Lauf, in dem sie erkannt wird, als Mitgliederverlust protokolliert.
|
||||
**Why it happens:** `syncGroupMembershipsForTenant()` liest `group.ldapDn` aus der DB und baut daraus den `memberOf=<dn>`-Suchfilter — wenn dieser Wert noch der alte (vor der Umbenennung gültige) DN ist, liefert AD keine Treffer mehr für die neue CN.
|
||||
**How to avoid:** `syncBoundGroupsForTenant()` (neu) muss die `Group.ldapDn`/`Group.name`-Aktualisierung **innerhalb desselben Laufs, vor** dem bestehenden D-21-Schritt abschließen (siehe Pattern 1/Diagramm oben).
|
||||
**Warning signs:** Sync-Bericht zeigt nach einer bekannten AD-Umbenennung `groupMembershipsRemoved > 0` für exakt die umbenannte Gruppe, obwohl im AD niemand die Mitgliedschaft geändert hat.
|
||||
|
||||
### Pitfall 2: `objectGUID` per UTF-8 statt Buffer gelesen
|
||||
|
||||
**What goes wrong:** Ohne `explicitBufferAttributes: ['objectGUID']` versucht `ldapts`, das binäre 16-Byte-`objectGUID` als UTF-8-String zu interpretieren [VERIFIED: `Attribute.ts:63-73` — die Bibliothek fällt zwar bei nicht-dekodierbaren Bytes auf Buffer zurück, aber ein GUID, dessen Bytes zufällig gültige UTF-8-Sequenzen ergeben, würde als kaputter String weiterverarbeitet].
|
||||
**Why it happens:** `explicitBufferAttributes` ist ein Such-Optionsfeld, das explizit pro Aufruf gesetzt werden muss — es gibt keinen globalen Default für "dieses Attribut ist immer binär".
|
||||
**How to avoid:** JEDE Suche, die `objectGUID` anfordert (Discovery-Liste UND Existenz-Sweep), muss `explicitBufferAttributes: ['objectGUID']` mitgeben.
|
||||
**Warning signs:** Gespeicherte `ldapObjectGuid`-Werte sind nicht konsistent 32 Hex-Zeichen lang, oder derselbe AD-Gruppe erzeugt bei zwei Syncs unterschiedliche Hex-Strings.
|
||||
|
||||
### Pitfall 3: Sync-Bericht im Frontend verschluckt neue Felder (bestehende Lücke, wird durch Phase 16 verschärft)
|
||||
|
||||
**What goes wrong:** Das Frontend-`SyncResult`-Interface in `apps/web/.../admin/ldap/page.tsx:54-59` kennt bereits heute NICHT die von D-21 hinzugefügten Felder `groupMembershipsAdded`/`groupMembershipsRemoved` — sie werden vom Backend geliefert, aber von der UI stillschweigend ignoriert [VERIFIED: page.tsx:54-59 deklariert nur `created/updated/deactivated/errors`; `t('sync.result', {...})` bei Zeile 1072-1076 übergibt ebenfalls nur diese drei Werte].
|
||||
**Why it happens:** Additive Backend-Felder brauchen einen expliziten Frontend-Schritt, der bei D-21 offenbar ausgelassen wurde.
|
||||
**How to avoid:** Phase 16 sollte diese Lücke für BEIDE Feldgruppen (D-21 UND die neuen Phase-16-Felder) in einem Aufwasch schließen — Interface erweitern, `t('sync.result', {...})`-Aufruf und die zugehörigen i18n-Strings (`de.json`/`en.json`) ergänzen.
|
||||
**Warning signs:** Admin klickt "Jetzt synchronisieren", sieht nur "3 erstellt, 1 aktualisiert, 0 deaktiviert" und hat keine Ahnung, ob/wie viele Gruppen importiert, umbenannt oder gelöscht wurden.
|
||||
|
||||
### Pitfall 4: Namenskollision bei einer unattended Sync-Operation wirft statt zu protokollieren
|
||||
|
||||
**What goes wrong:** `GroupsService.create()`/`update()` werfen bei einer `@@unique([tenantId, name])`-Verletzung eine `ConflictException` — das ist für einen interaktiven Admin-Request richtig, würde aber, unverändert im Sync-Kontext wiederverwendet, den kompletten `syncUsersForTenant()`-Lauf für diesen Mandanten abbrechen, wenn er nicht explizit abgefangen wird.
|
||||
**Why it happens:** `GroupsService`-Methoden sind für HTTP-Request/Response gebaut, nicht für einen Batch-Lauf mit Sammel-Fehlerbericht.
|
||||
**How to avoid:** Der neue Gruppen-Sync-Code darf `GroupsService.create()`/`.update()` NICHT direkt aufrufen (oder muss deren Exceptions in einem eigenen try/catch pro Gruppe abfangen und in `result.errors` sammeln) — exakt das Muster, das `syncGroupMembershipsForTenant()` schon für Pro-Gruppe-Fehler nutzt (`Gruppe <name>: <message>`).
|
||||
**Warning signs:** Ein einzelner Namenskonflikt lässt den gesamten Sync für einen Mandanten fehlschlagen, andere unabhängige Gruppen werden nicht mehr verarbeitet.
|
||||
|
||||
### Pitfall 5: Zero-Default-Group-Fenster nach Sync-Löschung der letzten Gruppe
|
||||
|
||||
**What goes wrong:** Wird die einzige verbliebene (und zufällig als Standard markierte) Gruppe eines Mandanten vom Sync gelöscht, bleibt der Mandant bis zum nächsten API-Neustart ohne jede Gruppe — `ensureDefaultGroup()` wird aktuell nur bei `TenantService.create()` (neuer Mandant) und `AdminSeedService.ensureDefaultGroupsForAllTenants()` (nur beim Container-Start, [VERIFIED: `.planning`-Historie, Quick-Task 260805-fok]) aufgerufen, NICHT nach einer Sync-Löschung.
|
||||
**Why it happens:** D-06 beschreibt den Regelfall ("bevorzugt auf 'Alle Benutzer', sonst eine andere vorhandene Gruppe") — deckt aber den Fall "keine andere Gruppe existiert mehr" nicht explizit ab.
|
||||
**How to avoid:** `syncBoundGroupsForTenant()` muss nach jeder Gruppen-Löschung prüfen (oder unbedingt am Ende des Sync-Laufs für den betroffenen Mandanten `ensureDefaultGroup(tenantId)` aufrufen), damit das Zero-Default-Fenster nicht bis zum nächsten Deploy offen bleibt.
|
||||
**Warning signs:** Ein Mandant mit genau einer AD-gebundenen Gruppe verliert nach deren Löschung im AD jede Standard-Zuweisung für neu angelegte Benutzer, bis die API neu gestartet wird.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Bestehendes Muster: Discovery + Import (Benutzer), als Vorbild für Gruppen (D-01)
|
||||
|
||||
```typescript
|
||||
// Source: apps/api/src/ldap/ldap.service.ts:356-547 (VERIFIED, gekürzt)
|
||||
// searchUsers() liefert LdapUserSearchResult[] mit alreadyImported-Flag,
|
||||
// berechnet ueber EINE zusammengefasste Query gegen ldapDn ODER username:
|
||||
const existing = await this.prisma.user.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
OR: [{ ldapDn: { in: dns } }, { username: { in: usernames } }],
|
||||
},
|
||||
select: { ldapDn: true, username: true },
|
||||
});
|
||||
// → Gruppen-Aequivalent: Group.findMany({ where: { tenantId, ldapObjectGuid: { in: guids } } })
|
||||
// um alreadyImported pro Discovery-Treffer zu markieren.
|
||||
|
||||
// importUsersByDn() ist idempotent: ein bereits vorhandener User wird
|
||||
// UEBERSPRUNGEN, nicht dupliziert oder ueberschrieben — nur ldapDn wird
|
||||
// nachgezogen, falls es fehlt. Fuer Gruppen gilt dieselbe Idempotenz-Anforderung.
|
||||
```
|
||||
|
||||
### Bestehendes Muster: Pro-Gruppe try/catch mit gesammelten Fehlern (D-21)
|
||||
|
||||
```typescript
|
||||
// Source: apps/api/src/ldap/ldap.service.ts:860-934 (VERIFIED, gekürzt)
|
||||
for (const group of boundGroups) {
|
||||
try {
|
||||
// ... AD-Suche, Reconciliation ...
|
||||
} catch (groupError: unknown) {
|
||||
const msg = groupError instanceof Error ? groupError.message : 'Unknown error syncing group';
|
||||
result.errors.push(`Gruppe ${group.name}: ${msg}`);
|
||||
}
|
||||
}
|
||||
// Ein fehlschlagender Gruppen-Sync stoppt niemals die uebrigen Gruppen (Pitfall 4 oben)
|
||||
```
|
||||
|
||||
### Bestehendes Muster: Default-Marker-Transaktion (D-13, Vorbild für D-06-Handoff)
|
||||
|
||||
```typescript
|
||||
// Source: apps/api/src/groups/groups.service.ts:99-154 (VERIFIED, gekürzt)
|
||||
// GroupsService.update() setzt isDefault:true in einer Transaktion:
|
||||
// zuerst updateMany auf false fuer alle anderen Gruppen des Mandanten,
|
||||
// dann update der Zielgruppe auf true — abgesichert durch den partiellen
|
||||
// Unique-Index Group_one_default_per_tenant.
|
||||
if (data.isDefault === true) {
|
||||
const [, updated] = await this.prisma.$transaction([
|
||||
this.prisma.group.updateMany({ where: { tenantId, isDefault: true }, data: { isDefault: false } }),
|
||||
this.prisma.group.update({ where: { id }, data: { ...updateData, isDefault: true } }),
|
||||
]);
|
||||
return updated;
|
||||
}
|
||||
// Phase-16-Bedarf: NEUE Methode, die statt "setze Zielgruppe X" die Zielgruppe
|
||||
// selbst BESTIMMT (bevorzugt "Alle Benutzer", sonst irgendeine andere), BEVOR
|
||||
// die alte Standardgruppe geloescht wird — gleiche Transaktions-Grundform.
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach (Phase 15) | Current Approach (Phase 16) | When Changed | Impact |
|
||||
|--------------|------------------|---------------|--------|
|
||||
| `ldapDn` als einziger AD-Bindungs-/Identitäts-Schlüssel | `ldapObjectGuid` als Identität, `ldapDn` als aus dem AD nachgezogenes Anzeige-/Debug-Feld | Diese Phase | Ermöglicht die Rename-vs-Delete-Unterscheidung, die SC-3/SC-4 verlangen |
|
||||
| AD-Bindung wählbar sowohl beim Gruppen-Anlegen (`GroupFormModal`) als auch implizit beim Import | AD-Bindung ausschließlich über den Import-Flow im LDAP-Bereich, `GroupFormModal` verliert die Radio-Auswahl komplett (D-07) | Diese Phase | Ein Weg statt zwei — verhindert die von D-07 benannte Fehlerquelle |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- Die Radio-Auswahl-UI in `GroupFormModal.tsx` (Zeilen 147-227) für die AD-Bindung wird mit dieser Phase vollständig entfernt (D-07). Zugehörige i18n-Keys `admin.groups.ldapBind.*` (de.json:391-398) werden dadurch verwaist und sollten geprüft/entfernt werden — `en.json` wurde in dieser Session nicht gelesen und muss beim Pruning ebenfalls geprüft werden (siehe Assumptions Log).
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|----------------|
|
||||
| A1 | `objectGUID` bleibt bei einer reinen CN-/Namens-Umbenennung eines AD-Gruppenobjekts unverändert (nur `objectSid` kann sich bei Cross-Domain-Verschiebung ändern) | Summary, Standard Stack, Pattern 2/3 | Falls falsch: die gesamte Rename-Erkennung (SC-3) funktioniert nicht wie geplant — jede Umbenennung würde weiterhin wie ein Verschwinden+Neuanlage behandelt. Muss vor Implementierung gegen einen echten AD-Test-Server verifiziert werden (Empfehlung: gleicher Human-Verify-Checkpoint wie beim EWS-Pfad in Phase 14/14-03) |
|
||||
| A2 | Die von `escapeLdapFilterBuffer()` (Pattern 3) vorgeschlagene byteweise `\XX`-Hex-Escaping-Syntax für einen binären `(objectGUID=...)`-Filterwert wird vom Ziel-AD korrekt interpretiert | Pattern 3, Code Examples | Falls falsch: der Existenz-Sweep pro gebundener Gruppe liefert null Treffer für JEDE Gruppe, auch für weiterhin existierende — würde SC-4 fälschlich auf alle gebundenen Gruppen anwenden. Muss gegen ein echtes AD getestet werden, bevor die Sync-Löschung produktiv scharf geschaltet wird |
|
||||
| A3 | `en.json` enthält dieselben `admin.groups.ldapBind.*`-Keys wie `de.json` (391-398) und muss beim D-07-Pruning parallel bereinigt werden | State of the Art | Gering — nur i18n-Hygiene, keine funktionale Auswirkung, aber verwaiste Keys bleiben sonst in einer Sprache liegen |
|
||||
| A4 | Empfohlenes Namenskollisions-Verhalten (Reject-with-Report statt Suffix-Anhängen) ist die richtige Wahl für diesen Sync-Kontext | Q6 / Common Pitfalls Pitfall 4 | Gering-Mittel — ausdrücklich als Claude's Discretion markiert; falls der Admin eine automatische Suffix-Vergabe bevorzugt, ist das ein reiner Implementierungs-Swap ohne Schema-Auswirkung |
|
||||
|
||||
**Wenn diese Tabelle leer wäre:** Ist sie nicht — A1/A2 sind die zentralen technischen Risiken dieser Phase und sollten in der Planung als eigener, früh laufender Verifikations-Task (idealerweise gegen den ViCoTest-Server, 192.168.13.12, read-only) abgebildet werden, bevor die Lösch-Semantik (D-05) produktiv aktiv wird.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Verhalten, wenn ein Admin eine automatisch importierte Gruppe in Tessera umbenennt (AD-Name, nicht internalName)**
|
||||
- What we know: D-03 sperrt das Namensfeld für importierte Gruppen im UI ("In Tessera ist das Feld für importierte Gruppen gesperrt"); die Roadmap nennt genau diese Frage explizit als "offen für die Planung".
|
||||
- What's unclear: Ob es serverseitig überhaupt einen Endpoint gibt, der `name` für eine `ldapDn`-gebundene Gruppe akzeptiert (aktuell erlaubt `GroupsService.update()` uneingeschränkt `data.name` für jede Gruppe, unabhängig von `ldapDn`) — die Sperre existiert laut Kontext bisher nur als UI-Konvention, nicht als Backend-Invariante.
|
||||
- Recommendation: Backend-seitig `UpdateGroupDto.name` für Gruppen mit gesetztem `ldapObjectGuid`/`ldapDn` ablehnen (400), nicht nur im Frontend deaktivieren — sonst ist die "gesperrt"-Aussage aus D-03 nicht durchgesetzt, sondern nur kosmetisch.
|
||||
|
||||
2. **Verhältnis der neuen Import-Auswahloberfläche zur bestehenden `groupFilterDns`-Auswahl in `/admin/ldap`**
|
||||
- What we know: `groupFilterDns` (Section 2.5 in `admin/ldap/page.tsx`) steuert, welche OUs/Gruppen beim BENUTZER-Sync als Scope/Filter dienen — ein völlig anderer Zweck als "diese AD-Gruppe soll eine Tessera-Gruppe werden".
|
||||
- What's unclear: Ob beide Auswahllisten dieselbe Discovery-Response (`GET /ldap/groups`) teilen sollen (technisch ja möglich, da `listGroups()` bereits beide OU- und Gruppen-Einträge liefert) oder als zwei unabhängige UI-Abschnitte mit eigenem Such-State nebeneinanderstehen sollen.
|
||||
- Recommendation: Zwei getrennte UI-Sektionen (wie bereits heute Section 2.5 "Import-Filter" und die neue Section "Gruppen importieren" nebeneinander existieren würden), aber EIN gemeinsamer `GET /ldap/groups`-Aufruf mit gemeinsamem `discoverSearch`-State ist eine spätere Optimierung, kein Blocker für diese Phase.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Live Active-Directory-Server (für A1/A2-Verifikation) | Rename-Erkennung (SC-3), Existenz-Sweep (SC-4) | ✗ in dieser Sandbox | — | ViCoTest-Testserver (192.168.13.12) laut Projekt-Memory vorhanden — read-only Prüfung dort empfohlen, kein Docker-Deploy durch Claude (bestehende Projektregel) |
|
||||
| Lokale PostgreSQL für Migrationstest | Neue Spalten `internalName`/`ldapObjectGuid` | ✓ (Docker-Container `tessera-ctl-db-1`, kein Host-Port) | postgres:16-alpine [VERIFIED: docker-compose.yml:55] | Migration via `DATABASE_URL` gegen die Container-IP, siehe unten |
|
||||
| `ldapts` mit `explicitBufferAttributes`-Unterstützung | `objectGUID`-Abfrage | ✓ | 8.1.8 [VERIFIED: node_modules-Quellcode] | — |
|
||||
|
||||
**Migrationsbefehl (lokal, verifiziertes Muster aus einer früheren Phase-15-Vorstufe):**
|
||||
```bash
|
||||
# [CITED: .planning/quick/260728-lih-.../260728-lih-PLAN.md:100-111, in dieser Session gelesen]
|
||||
docker start tessera-ctl-db-1
|
||||
DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)
|
||||
cd apps/api && DATABASE_URL="postgresql://tessera:tessera_dev@${DB_IP}:5432/tessera" \
|
||||
pnpm exec prisma migrate dev --name add_group_internal_name_and_object_guid
|
||||
```
|
||||
In Produktion läuft `prisma migrate deploy` automatisch beim Container-Start [VERIFIED: `apps/api/Dockerfile:38`].
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Vitest 3.x [VERIFIED: apps/api/package.json "test": "vitest run"] |
|
||||
| Config file | `apps/api/vitest.config.ts` |
|
||||
| Quick run command | `cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts src/groups/groups.service.spec.ts` |
|
||||
| Full suite command | `cd apps/api && pnpm test` (bzw. `turbo test` vom Repo-Root) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Kriterium | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SC-1 | Ausgewählte AD-Gruppe erzeugt eine `Group`-Zeile mit `ldapDn`+`ldapObjectGuid` gesetzt | unit | `vitest run src/ldap/ldap.service.spec.ts -t "importGroupsByDn"` | ❌ Wave 0 — neuer `describe`-Block |
|
||||
| SC-2 | Nicht ausgewählte AD-Gruppen erzeugen KEINE `Group`-Zeile | unit | `vitest run src/ldap/ldap.service.spec.ts -t "importGroupsByDn"` (negativer Fall im selben Block) | ❌ Wave 0 |
|
||||
| SC-3 | Umbenennung im AD aktualisiert `Group.name`/`ldapDn`, Mitgliedschaften bleiben erhalten | unit | `vitest run src/ldap/ldap.service.spec.ts -t "syncBoundGroupsForTenant — rename"` | ❌ Wave 0 |
|
||||
| SC-4 | Verschwinden im AD löscht `Group` inkl. `GroupMembership`/`ModuleGrant` (Cascade) | unit + integration | `vitest run src/ldap/ldap.service.spec.ts -t "syncBoundGroupsForTenant — delete"` + ein Cascade-Test gegen die echte Migration (`migration-sql.spec.ts`-Stil, reiner Textabgleich der bereits vorhandenen `ON DELETE CASCADE`-Klauseln — kein neuer DB-Test nötig, da bereits durch 15-01 abgedeckt) | ❌ Wave 0 (neuer describe-Block), ✅ Cascade-Grundlage bereits durch bestehende Migration-SQL abgedeckt |
|
||||
| SC-5 | Manuelle Gruppen (`ldapDn: null`) bleiben vom Gruppen-Sync unberührt | unit | `vitest run src/ldap/ldap.service.spec.ts -t "ignores a Tessera group with no ldapDn"` (bestehendes Testmuster aus D-21, Zeile ~665, für den neuen `syncBoundGroupsForTenant` analog zu duplizieren) | ✅ Muster existiert bereits für D-21, ❌ analoger Test für den neuen Schritt fehlt |
|
||||
| D-04 | Interner Name überlebt Sync, wird an 3 Stellen angezeigt | unit + component | `vitest run src/groups/groups.service.spec.ts` (Backend-Select) + `vitest run apps/web/.../groups-page.test.tsx` (Anzeige) | ❌ Wave 0 für beide |
|
||||
| D-06 | Kein Mandant ohne Standardgruppe nach Sync-Löschung | unit | `vitest run src/ldap/ldap.service.spec.ts -t "default marker handoff"` | ❌ Wave 0 |
|
||||
| D-07 | `GroupFormModal` enthält keine AD-Radio-Auswahl mehr | component | Bestehende `groups-page.test.tsx`/Modal-Tests anpassen — Assertion, dass kein `ldapBind`-Discovery-Aufruf mehr passiert | ❌ Wave 0 (bestehende Tests müssen angepasst werden, nicht nur ergänzt) |
|
||||
|
||||
### Sampling Rate
|
||||
|
||||
- **Per task commit:** `cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts src/groups/groups.service.spec.ts`
|
||||
- **Per wave merge:** `pnpm test` (API + Web)
|
||||
- **Phase gate:** Volle Suite grün, PLUS ein manueller Human-Verify-Checkpoint gegen ein echtes AD (ViCoTest) für A1/A2 (`objectGUID`-Stabilität, Binär-Filter-Syntax), bevor `/gsd-verify-work` die Löschsemantik (D-05) als produktionsreif markiert.
|
||||
|
||||
### Wave 0 Gaps
|
||||
|
||||
- [ ] `apps/api/src/ldap/ldap.service.spec.ts` — neue `describe`-Blöcke für `importGroupsByDn`, `syncBoundGroupsForTenant` (Rename/Delete/Default-Handoff/Namenskollision)
|
||||
- [ ] `apps/api/src/groups/groups.service.spec.ts` — Anpassung der `listForTenant`/`getImpact`-Selects auf `internalName ?? name`, falls diese Logik im Service statt nur im Controller liegt
|
||||
- [ ] `apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx` — Assertion, dass `GroupFormModal` nach D-07 keine `GET /ldap/groups`-Discovery mehr für eine neue Gruppe auslöst
|
||||
- [ ] `apps/api/src/groups/migration-sql.spec.ts`-artiger Text-Test für die neue Migration (`internalName`/`ldapObjectGuid`-Spalten, Unique-Index-Klausel), analog zum bestehenden Muster
|
||||
- Kein neues Test-Framework nötig — Vitest ist bereits vollständig eingerichtet.
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-------------------|
|
||||
| V1 Architecture | yes | Mandantentrennung via `forTenant()` + RLS (bereits etabliert, in dieser Phase konsequent auf den neuen Sync-Code anzuwenden) |
|
||||
| V4 Access Control | yes | `RolesGuard` + `@Roles(ADMIN, SUPER_ADMIN)` auf jedem neuen Endpoint (`POST /ldap/groups/import`), identisch zu allen bestehenden LDAP-/Gruppen-Routen |
|
||||
| V5 Input Validation | yes | LDAP-Filter-Injection: jeder neue Filterbaustein (insbesondere der binäre `objectGUID`-Filter aus Pattern 3) muss konsequent escaped werden — kein Freitext-Interpolieren von AD-gelieferten Werten in einen Suchfilter |
|
||||
| V6 Cryptography | no | Diese Phase führt keine neuen Geheimnisse/Verschlüsselung ein — `bindPassword` bleibt unverändert im bestehenden Klartext-Muster (dokumentierte, bekannte Design-Entscheidung aus Phase 2, nicht Gegenstand dieser Phase) |
|
||||
|
||||
### Known Threat Patterns for LDAP-Sync + Multi-Tenant-RLS
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|-----------------------|
|
||||
| LDAP-Filter-Injection über einen AD-gelieferten oder admin-gewählten DN/GUID-Wert | Tampering | `escapeLdapFilterValue()` (String) bzw. der neue `escapeLdapFilterBuffer()` (Binär) vor jeder Filter-Interpolation — bestehendes, bereits verifiziertes Muster konsequent auf neue Codepfade anwenden |
|
||||
| Cross-Tenant-Zugriff auf eine `groupId` aus einem fremden Mandanten über den Import-Endpoint | Elevation of Privilege | `findOwned(tenantId, id)`-Ownership-Check-Muster (bereits in `GroupsService` etabliert) auf jeden neuen Codepfad anwenden, der eine `groupId` entgegennimmt |
|
||||
| Race Condition bei parallelem Default-Gruppen-Handoff (Sync + gleichzeitige manuelle Admin-Aktion) | Tampering | Partieller Unique-Index `Group_one_default_per_tenant` als DB-seitiges zweites Netz (bereits vorhanden) — die Sync-Transaktion fängt einen daraus resultierenden P2002 ab und behandelt ihn wie ein "hat sich schon jemand anderes gekümmert", statt zu werfen (Muster aus `ensureDefaultGroup()` übernehmen) |
|
||||
| Unattended Sync löscht Daten (Mitgliedschaften, Modulfreigaben) ohne den D-17-Warndialog | Repudiation/Information Disclosure (aus Nutzersicht: unerwarteter Zugriffsverlust) | Bewusst akzeptiertes Verhalten laut D-05 — Mitigation ist ausschließlich die Protokollierung im Sync-Bericht (Pattern 1), kein technischer Schutz vorgesehen |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence — in dieser Session gelesener Quellcode/Konfiguration)
|
||||
- `apps/api/src/ldap/ldap.service.ts` (VERIFIED, vollständig gelesen)
|
||||
- `apps/api/src/ldap/ldap.controller.ts`, `apps/api/src/ldap/dto/ldap-config.dto.ts`, `apps/api/src/ldap/ldap-sync.scheduler.ts`, `apps/api/src/ldap/ldap.module.ts` (VERIFIED)
|
||||
- `apps/api/src/groups/groups.service.ts`, `groups.controller.ts`, `groups.module.ts`, `module-grants.service.ts` (Auszüge), `groups.service.spec.ts` (VERIFIED)
|
||||
- `apps/api/prisma/schema.prisma` (VERIFIED, vollständig gelesen)
|
||||
- `apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql`, `20260804130918_groups_rls_policies/migration.sql` (VERIFIED)
|
||||
- `apps/api/src/prisma/prisma-tenant.extension.ts` (VERIFIED)
|
||||
- `apps/web/src/app/(portal)/admin/ldap/page.tsx`, `apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx` (VERIFIED)
|
||||
- `apps/web/src/app/(portal)/admin/modules/grants/page.tsx`, `apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx` (Auszüge via grep + gezielte Zeilenprüfung, VERIFIED)
|
||||
- `apps/web/src/messages/de.json` Zeilen 372-417 (VERIFIED)
|
||||
- `node_modules/.pnpm/ldapts@8.1.8/node_modules/ldapts/src/Client.ts`, `src/messages/SearchEntry.ts`, `src/Attribute.ts` (VERIFIED — installierter Bibliotheks-Quellcode)
|
||||
- `apps/api/Dockerfile:38` (VERIFIED)
|
||||
- `docker-compose.yml:54-69` (VERIFIED)
|
||||
- `.planning/quick/260728-lih-ldap-sync-selektiv-und-auto-sync-default/260728-lih-PLAN.md:100-111` (CITED — gelesen, dokumentiertes Migrationsverfahren)
|
||||
- `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md`, `.planning/ROADMAP.md` Phase 15/16, `.planning/REQUIREMENTS.md`, `.planning/STATE.md` (VERIFIED)
|
||||
- `npm view ldapts version` (VERIFIED — Registry-Abfrage in dieser Session)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- Keine — alle Websearch-Provider sind in `.planning/config.json` deaktiviert (`brave_search`, `exa_search`, `tavily_search`, `firecrawl`, `ref_search`, `perplexity`, `jina` alle `false`); diese Recherche stützt sich vollständig auf Codebasis-Lektüre statt externer Dokumentation.
|
||||
|
||||
### Tertiary (LOW confidence — als [ASSUMED] markiert)
|
||||
- Allgemeines Active-Directory-Verzeichniswissen zu `objectGUID`-Stabilität über Umbenennungen hinweg (Assumptions Log A1)
|
||||
- RFC-4515-Praxis für binäre LDAP-Filterwerte, nicht gegen ein echtes AD verifiziert (Assumptions Log A2)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard Stack: HIGH — keine neuen Pakete, bestehende `ldapts`-Fähigkeit direkt im installierten Quellcode verifiziert
|
||||
- Architecture: HIGH — jeder betroffene Codepfad wurde gelesen, nicht aus Trainingswissen rekonstruiert
|
||||
- Pitfalls: HIGH für die codebasierten Pitfalls (1, 3, 4, 5 — alle aus gelesenem Code abgeleitet), MEDIUM-LOW für Pitfall 2/die AD-Laufzeit-Annahmen (A1/A2)
|
||||
|
||||
**Research date:** 2026-08-05
|
||||
**Valid until:** 30 Tage (stabiler interner Code-Stack) — die AD-spezifischen Annahmen (A1/A2) sollten jedoch VOR Implementierungsbeginn, nicht erst nach 30 Tagen, gegen ein echtes Verzeichnis verifiziert werden.
|
||||
Reference in New Issue
Block a user