327 lines
37 KiB
Markdown
327 lines
37 KiB
Markdown
---
|
|
phase: 16-ad-gruppen-synchronisation
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- apps/api/prisma/schema.prisma
|
|
- apps/api/prisma/migrations/_add_group_internal_name_and_object_guid/migration.sql
|
|
- apps/api/src/ldap/ldap.service.ts
|
|
- apps/api/src/ldap/ldap.controller.ts
|
|
- apps/api/src/ldap/dto/ldap-config.dto.ts
|
|
- apps/api/src/ldap/ldap.service.spec.ts
|
|
- apps/api/src/groups/migration-sql.spec.ts
|
|
- apps/web/src/app/(portal)/admin/ldap/page.tsx
|
|
- apps/web/src/messages/de.json
|
|
- apps/web/src/messages/en.json
|
|
autonomous: false
|
|
requirements: [PERM-02]
|
|
|
|
estimate:
|
|
tokens: 72000
|
|
raw_tokens: 72000
|
|
tasks: 3
|
|
confidence: low
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Ein Admin sieht in /admin/ldap eine Sektion 'AD-Gruppen importieren', hakt dort einzelne AD-Gruppen an und erhaelt nach Klick auf den Import-Button je angehakter Gruppe eine Tessera-Gruppe mit gesetztem ldapDn und ldapObjectGuid (SC-1, D-01)."
|
|
- "Nicht angehakte AD-Gruppen erzeugen keine Group-Zeile — es gibt keinen Codepfad, der eine OU oder ein Suchergebnis pauschal uebernimmt (SC-2, D-02)."
|
|
- "Group traegt die Spalten internalName (nullable) und ldapObjectGuid (nullable) samt Unique-Index auf (tenantId, ldapObjectGuid); die Migration liegt versioniert unter apps/api/prisma/migrations/ (D-04)."
|
|
- "Ein Import, dessen AD-Name mit einer bestehenden Gruppe desselben Mandanten kollidiert, legt keine Gruppe an, meldet die Kollision als eigene Zeile und bricht den Import der uebrigen ausgewaehlten Gruppen nicht ab."
|
|
- "Eine leere Auswahl loest keinen Import aus: der Import-Button ist bei 0 Auswahl disabled und ImportGroupsDto lehnt ein leeres dns-Array mit HTTP 400 ab."
|
|
- "objectGUID wird als Buffer gelesen (explicitBufferAttributes) und als 32-stelliger Kleinbuchstaben-Hex-String in Group.ldapObjectGuid abgelegt; ein Eintrag ohne verwertbaren Buffer erzeugt eine Fehlerzeile statt eines als String fehlinterpretierten Werts."
|
|
- "Die Discovery-Liste ist nach name (localeCompare) und bei Gleichstand nach dn sortiert; zwei Aufrufe bei unveraenderter AD-Lage liefern dieselbe Reihenfolge."
|
|
- "Ein zweiter Import derselben AD-Gruppe legt keine zweite Group-Zeile an, sondern zaehlt sie als skipped."
|
|
- "Zwei verschiedene AD-Gruppen mit identischem cn unter verschiedenen OUs: die erste wird angelegt, die zweite als Namenskollision gemeldet — es entsteht nie eine Gruppe mit doppeltem Namen im Mandanten."
|
|
- "Ein DN ohne Treffer im AD erzeugt keine Group-Zeile, sondern eine Fehlerzeile mit dem DN."
|
|
- "Ein Import-Lauf ueber eine Mischung aus bereits importierten und neuen Gruppen legt genau die neuen an."
|
|
- "Eine nicht angehakte AD-Gruppe, die im selben Suchergebnis direkt neben einer angehakten steht, wird nicht angelegt."
|
|
- "Ein Discovery-Lauf ohne AD-Treffer legt keine Gruppe an und zeigt admin.ldap.groupImport.noneFound."
|
|
- "Nur die im dns-Array enthaltenen DNs werden verarbeitet; die Position eines DN in der Liste hat keinen Einfluss darauf, ob er importiert wird."
|
|
- "Ein zweiter Discovery-Lauf ohne Import legt keine Gruppe an — Discovery ist read-only."
|
|
- "Discovery und Import sind getrennte Requests; waehrend importingGroups laeuft, ist der Import-Button disabled und die Auswahl unveraendert."
|
|
- statement: "Zwei gleichzeitige Import-Requests fuer dieselbe AD-Gruppe erzeugen keine Doppelzeile: der Unique-Index auf (tenantId, ldapObjectGuid) faengt den zweiten mit P2002 ab, der wie skipped behandelt wird."
|
|
verification: backstop
|
|
- statement: "Die Reihenfolge der angelegten Gruppen folgt der Reihenfolge der uebergebenen dns; das Ergebnis ist unabhaengig von dieser Reihenfolge identisch."
|
|
verification: backstop
|
|
- statement: "Zwei parallele Import-Requests mit ueberlappender Auswahl lassen keinen Request scheitern — der zweite meldet die Ueberlappung als skipped."
|
|
verification: backstop
|
|
- "UI E1/empty: Findet die AD-Suche keine Gruppe, zeigt die Sektion admin.ldap.groupImport.noneFound; filtert das Suchfeld alle geladenen Treffer weg, erscheint admin.ldap.groupImport.noMatches."
|
|
- "UI E1/loading: Waehrend Discovery bzw. Import laeuft, traegt der jeweilige Button tCommon('loading') statt seines Labels und ist disabled."
|
|
- "UI E1/error: Ein fehlgeschlagener Discovery- oder Import-Request zeigt einen sichtbaren text-sm text-destructive Fehlerzustand statt eines stillen No-ops."
|
|
- "UI E1/populated: Bereits importierte AD-Gruppen erscheinen in derselben Liste mit Badge admin.ldap.groupImport.alreadyImported, disabled Checkbox und opacity-60 — sie werden nicht ausgeblendet."
|
|
- "UI E1/partial: Schlaegt der Import einzelner Gruppen fehl, laeuft der Import der uebrigen Auswahl weiter; der Ergebnisblock nennt importiert/uebersprungen/Fehler plus eine Fehlerzeile je betroffener Gruppe."
|
|
- "UI E1/overflow: Die Ergebnisliste traegt max-h-64 overflow-y-auto; DN-Werte tragen font-mono text-xs text-muted-foreground truncate."
|
|
- "UI E1/zero-one-many: Der Import-Button traegt die Auswahlzahl im Label und ist bei leerer Auswahl disabled."
|
|
- statement: "UI E1/long-text: Ein AD-Gruppenname, der breiter ist als die Listenzeile, sprengt das Zeilenlayout der Discovery-Liste nicht."
|
|
verification: backstop
|
|
artifacts:
|
|
- "apps/api/prisma/schema.prisma — Group.internalName, Group.ldapObjectGuid, @@unique([tenantId, ldapObjectGuid])"
|
|
- "apps/api/prisma/migrations/<timestamp>_add_group_internal_name_and_object_guid/migration.sql"
|
|
- "apps/api/src/ldap/ldap.service.ts — listGroups(config, tenantId) mit objectGUID + alreadyImported, importGroupsByDn(), escapeLdapFilterBuffer(), LdapGroupImportResult"
|
|
- "apps/api/src/ldap/dto/ldap-config.dto.ts — ImportGroupsDto"
|
|
- "apps/api/src/ldap/ldap.controller.ts — POST /ldap/groups/import"
|
|
- "apps/web/src/app/(portal)/admin/ldap/page.tsx — Sektion 'AD-Gruppen importieren'"
|
|
- "apps/web/src/messages/de.json + en.json — admin.ldap.groupImport.*"
|
|
- "apps/api/src/ldap/ldap.service.spec.ts — describe 'LdapService — AD group import (SC-1/SC-2, D-01/D-02)'"
|
|
- "apps/api/src/groups/migration-sql.spec.ts — describe fuer die neue Migration"
|
|
key_links:
|
|
- "GET /ldap/groups liefert alreadyImported, berechnet aus Group.ldapObjectGuid des anfragenden Mandanten — bricht das Flag, wird jede bereits importierte Gruppe erneut anwaehlbar"
|
|
- "POST /ldap/groups/import → LdapService.importGroupsByDn → forTenant(prisma, tenantId).group.create — ohne forTenant greift RLS auf Group nicht korrekt"
|
|
- "Group.ldapObjectGuid ist der Identitaetsschluessel, auf dem Plan 16-03 (Rename/Delete) aufsetzt — ohne ihn ist Umbenennen nicht von Verschwinden unterscheidbar"
|
|
prohibitions:
|
|
- "Der Import darf niemals mehr Gruppen anlegen, als der Admin ausdruecklich angehakt hat — kein OU-weiter Sweep, keine filterbasierte Uebernahme, kein 'alle unter diesem Knoten'."
|
|
- "Die generische Admin-Oberflaeche darf keine kunden- oder firmenspezifischen Vorgaben (OU-Namen, DNs, Gruppennamen) als Default oder Platzhalter enthalten."
|
|
- "Der Import darf niemals eine importierte Gruppe als Standardgruppe markieren oder eine bestehende Standardmarkierung verschieben."
|
|
---
|
|
|
|
<objective>
|
|
Ein Admin kann in `/admin/ldap` einzelne AD-Gruppen auswaehlen und importieren; je angehakter Gruppe entsteht eine Tessera-Gruppe mit AD-Bindung (D-01, D-02, SC-1, SC-2). Dieser Plan traegt die **Tracer-Scheibe** der Phase: eine einzige Faehigkeit, durchgezogen durch alle Schichten, die Phase 16 anfasst — Prisma-Schema, LdapService, DTO, Controller, Next.js-Seite, i18n.
|
|
|
|
Zusaetzlich legt der Plan die beiden neuen Spalten aus D-04 an: `internalName` (vom Sync nie beruehrt) und `ldapObjectGuid` (rename-stabiler Identitaetsschluessel). Beide sind **eine** Migration und **eine** one-way-Entscheidung — deshalb sitzt vor der Tracer-Task ein `checkpoint:decision`.
|
|
|
|
Purpose: Die Architektur der Phase wird an einer einzigen echten Faehigkeit bewiesen, bevor Rekonziliation (16-03), Anzeige-Fallback (16-02/16-05) und Dialog-Umbau (16-04) darauf aufbauen. Insbesondere wird hier entschieden und verifiziert, dass `objectGUID` ueberhaupt als Buffer lesbar und speicherbar ist — die Voraussetzung fuer SC-3 und SC-4.
|
|
|
|
Output: Neue Spalten + Migration, `listGroups()` mit `alreadyImported`, `importGroupsByDn()`, `POST /ldap/groups/import`, neue Import-Sektion in `/admin/ldap`, i18n-Keys, Unit-Tests.
|
|
</objective>
|
|
|
|
<artifacts_this_phase_produces>
|
|
Neu entstehende Symbole in diesem Plan (fuer die Source-Grounding-Pruefung: diese Namen existieren vor diesem Plan NICHT im Repo):
|
|
|
|
| Art | Symbol |
|
|
|-----|--------|
|
|
| Prisma-Spalte | `Group.internalName` |
|
|
| Prisma-Spalte | `Group.ldapObjectGuid` |
|
|
| Prisma-Index | `Group_tenantId_ldapObjectGuid_key` |
|
|
| Migrationsverzeichnis | `<timestamp>_add_group_internal_name_and_object_guid` |
|
|
| TS-Interface | `LdapGroupImportResult` (`{ imported, skipped, nameCollisions, errors }`) |
|
|
| Service-Methode | `LdapService.importGroupsByDn(config, tenantId, dns)` |
|
|
| Service-Helper | `LdapService.escapeLdapFilterBuffer(buf)` (static) |
|
|
| DTO | `ImportGroupsDto` |
|
|
| Route | `POST /ldap/groups/import` |
|
|
| Feld auf bestehendem Interface | `LdapDirectoryEntry.alreadyImported` |
|
|
| i18n-Namespace | `admin.ldap.groupImport.*` (`title`, `description`, `discover`, `searchPlaceholder`, `noneFound`, `noMatches`, `alreadyImported`, `importSelected`, `resultSummary`, `membershipHint`, `nameCollisionError`, `discoverError`, `importError`) |
|
|
| Frontend-State | `groupImportCandidates`, `groupImportSearch`, `discoveringGroupImport`, `selectedImportDns`, `importingGroups`, `groupImportResult`, `groupImportError` |
|
|
| Frontend-Handler | `handleDiscoverGroupsToImport`, `handleImportGroups`, `toggleImportDn` |
|
|
</artifacts_this_phase_produces>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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-UI-SPEC.md
|
|
</context>
|
|
|
|
<decisions_recorded>
|
|
Entscheidungen aus dem Discretion-Bereich von D-04..D-07, die dieser Plan festlegt:
|
|
|
|
- **Spaltennamen:** `internalName` und `ldapObjectGuid`, beide `String?`, Stil identisch zu `ldapDn` (RESEARCH.md/PATTERNS.md). `internalName` traegt bewusst **keinen** Unique-Index — zwei Gruppen duerfen denselben internen Namen tragen.
|
|
- **Kein zweiter Discovery-Endpoint:** D-01 verlangt Wiederverwendung der bestehenden AD-Gruppensuche. Statt eines zweiten `GET`-Endpoints wird `GET /ldap/groups` um `alreadyImported` erweitert; beide Sektionen der Seite rufen denselben Endpoint mit eigenem State auf (loest RESEARCH Open Question 2).
|
|
- **Import loest keinen Sofort-Sync aus** (UI-SPEC Copywriting Contract): Mitgliedschaften fuellt der naechste turnusmaessige oder manuelle Lauf; die Oberflaeche sagt das per `membershipHint` explizit.
|
|
- **Namenskollision:** Reject-with-Report je Gruppe. Das Ergebnisobjekt traegt ein eigenes `nameCollisions: string[]` (Gruppennamen) neben `errors: string[]`, damit die Kollisionsmeldung im Frontend uebersetzbar bleibt (`admin.ldap.groupImport.nameCollisionError`) statt als deutscher Backend-String durchgereicht zu werden. Der Fehler-Zaehler in `resultSummary` ist `errors.length + nameCollisions.length`.
|
|
- **Bereits importierte Gruppen** werden gekennzeichnet, nicht ausgeblendet (UI-SPEC E1/populated).
|
|
- **Identitaetsmodell (Assumption-Delta-Notiz):** `internalName` wird **add-alongside** eingefuehrt, nicht promote — `Group.name` bleibt der AD-gelieferte Name und das persistierte Sortierkriterium, `internalName` ist ein optionaler Anzeige-Override. Akzeptierte Schuld: jeder Anzeige-Konsument muss den Fallback `internalName ?? name` selbst kennen. Was spaeter einen Promote erzwingen wuerde: sobald Sortierung, Suche oder ein Export ueber den Anzeigenamen laufen muss (heute sortiert `GroupsService.listForTenant` ueber `name`) — dann wird eine berechnete/persistierte `displayName`-Spalte faellig. Dieser Punkt ist beratend und blockiert nichts.
|
|
</decisions_recorded>
|
|
|
|
<tasks>
|
|
|
|
<task type="checkpoint:decision" gate="blocking">
|
|
<name>Task 1: Freigabe der one-way Schema-Erweiterung (D-04)</name>
|
|
<read_first>
|
|
- `apps/api/prisma/schema.prisma` Zeilen 137-152 — das heutige `Group`-Modell mit `ldapDn` und den beiden bestehenden Unique-Indizes
|
|
- `.planning/phases/16-ad-gruppen-synchronisation/16-CONTEXT.md` — D-04 im Wortlaut samt Reversibility-Einstufung
|
|
- `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Abschnitt "Anti-Patterns to Avoid", erster Punkt (warum `ldapDn` als alleiniger Identitaetsschluessel nicht traegt)
|
|
</read_first>
|
|
<decision>Zwei neue nullable Spalten auf `Group` — `internalName` (D-04, sync-immuner Anzeigename) und `ldapObjectGuid` (rename-stabiler Sync-Identitaetsschluessel) — plus Unique-Index `(tenantId, ldapObjectGuid)`, umgesetzt als versionierte Prisma-Migration.</decision>
|
|
<context>
|
|
CONTEXT.md stuft D-04 als **one-way** ein: sobald echte interne Namen vergeben sind, ist ein Rueckbau der Spalte Datenverlust. `ldapObjectGuid` gehoert zur selben Migration und erbt dieselbe Einstufung — nach dem ersten Sync-Lauf haengt an diesem Wert die Unterscheidung "im AD umbenannt" vs. "im AD geloescht" (SC-3 vs. SC-4). Ein Rueckbau nach produktivem Einsatz bedeutet: interne Namen sind weg, und jede importierte Gruppe verliert ihre stabile Identitaet.
|
|
|
|
Ohne `ldapObjectGuid` bliebe nur `ldapDn` als Schluessel — der DN enthaelt den CN, jede AD-Umbenennung aendert ihn, und der Sync koennte Umbenennung strukturell nicht von Verschwinden unterscheiden. Er wuerde die Gruppe samt Mitgliedschaften und Modulfreigaben loeschen und neu anlegen.
|
|
</context>
|
|
<options>
|
|
<option id="approve-both">
|
|
<name>Beide Spalten in einer Migration (empfohlen)</name>
|
|
<pros>Erfuellt D-04 woertlich; macht SC-3/SC-4 ueberhaupt erst unterscheidbar; eine Migration statt zwei; Unique-Index folgt exakt dem bestehenden `(tenantId, ldapDn)`-Muster (NULL ist in Postgres je Zeile distinct).</pros>
|
|
<cons>One-way: Rueckbau nach Vergabe echter interner Namen ist Datenverlust. Erhoeht die Group-Tabelle um zwei Spalten.</cons>
|
|
</option>
|
|
<option id="approve-guid-only">
|
|
<name>Nur `ldapObjectGuid`, `internalName` spaeter</name>
|
|
<pros>Kleinere Migration, D-04 bleibt rueckbaubar.</pros>
|
|
<cons>Verfehlt die tragende Idee der Phase (CONTEXT.md `<specifics>`: der interne Name kam vom User selbst); erzwingt spaeter eine zweite Migration mit identischem one-way-Risiko.</cons>
|
|
</option>
|
|
<option id="defer">
|
|
<name>Zurueckstellen und Phase neu zuschneiden</name>
|
|
<pros>Keine Schemaaenderung.</pros>
|
|
<cons>Ohne beide Spalten ist keines der fuenf Success Criteria erreichbar; die Phase haette keinen Inhalt.</cons>
|
|
</option>
|
|
</options>
|
|
<acceptance_criteria>
|
|
- Die Antwort ist eine der drei Options-IDs (`approve-both`, `approve-guid-only`, `defer`); jede andere Eingabe wird als Rueckfrage behandelt, nicht als Freigabe
|
|
- Bei `approve-both` faehrt Task 2 unveraendert fort
|
|
- Bei `approve-guid-only` oder `defer` wird die Ausfuehrung angehalten und die Phase zur Neuplanung zurueckgemeldet — die uebrigen vier Plaene der Phase setzen beide Spalten voraus
|
|
</acceptance_criteria>
|
|
<resume-signal>Antworte mit: approve-both, approve-guid-only oder defer</resume-signal>
|
|
</task>
|
|
|
|
<task type="tracer" tdd="true">
|
|
<name>Task 2: Tracer — AD-Gruppe auswaehlen und importieren, durch alle Schichten</name>
|
|
<reversibility rating="one-way">Fuehrt `Group.internalName` und `Group.ldapObjectGuid` ein. Nach Vergabe echter interner Namen und nach dem ersten Sync-Lauf ist ein Rueckbau Datenverlust plus Verlust der stabilen AD-Identitaet (CONTEXT.md D-04).</reversibility>
|
|
<files>apps/api/prisma/schema.prisma, apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/dto/ldap-config.dto.ts, apps/api/src/ldap/ldap.controller.ts, apps/api/src/ldap/ldap.service.spec.ts, apps/web/src/app/(portal)/admin/ldap/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
|
<read_first>
|
|
- `apps/api/prisma/schema.prisma` (Zielmodell `Group`, Zeilen 137-152)
|
|
- `apps/api/src/ldap/ldap.service.ts` — Datei-Kopf mit `LdapSyncResult`/`LdapDirectoryEntry`/`LdapUserImportResult` (Zeilen 1-85), `listGroups()` (218-268), `searchUsers()` (356-446, Vorbild fuer die alreadyImported-Query), `importUsersByDn()` (447-547, Geruest-Vorbild), `escapeLdapFilterValue()` (977-984)
|
|
- `apps/api/src/ldap/ldap.controller.ts` — `listGroups()` (Zeilen 154-178) und `importUsers()` (210-245, Vorbild fuer die neue Route)
|
|
- `apps/api/src/ldap/dto/ldap-config.dto.ts` — `ImportUsersDto` (Zeilen 109-114)
|
|
- `apps/api/src/ldap/ldap.service.spec.ts` — bestehende describe-Bloecke, insbesondere `LdapService — individual user search & import (dedup)` (ab Zeile 283) als Mock-Vorbild
|
|
- `apps/web/src/app/(portal)/admin/ldap/page.tsx` — Interfaces (Zeilen 33-59), Handler `handleSearchUsers`/`handleImportUsers` (350-402), Sektion 2.5 (684-806) und Sektion 2.55 (808-910)
|
|
- `apps/web/src/app/(portal)/admin/groups/page.tsx` Zeilen 130-145 — das Fehlerbanner-Muster, das fuer die neuen Fehlerzustaende gilt
|
|
- `.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md` — Abschnitte zu `importGroupsByDn()`, `ImportGroupsDto`, `POST /ldap/groups/import`, `admin/ldap/page.tsx`
|
|
- `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Surface Contract 1 und der Copywriting Contract
|
|
- `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Pattern 2 (binaeres Attribut), Pitfall 2, Pitfall 4
|
|
</read_first>
|
|
<behavior>
|
|
- `importGroupsByDn` legt fuer einen DN mit AD-Treffer eine Group mit `name = cn`, `ldapDn = entry.dn`, `ldapObjectGuid = hex(objectGUID)` an und zaehlt `imported`
|
|
- derselbe DN ein zweites Mal: keine zweite Zeile, `skipped` steigt
|
|
- DN ohne AD-Treffer: keine Zeile, eine Zeile in `errors` mit dem DN
|
|
- AD-Name kollidiert mit bestehender Gruppe (P2002 auf `(tenantId, name)`): keine Zeile, Gruppenname landet in `nameCollisions`, die Schleife laeuft weiter und importiert die folgenden DNs
|
|
- P2002 auf `(tenantId, ldapObjectGuid)`: wie `skipped`, kein Fehler
|
|
- Eintrag ohne Buffer unter `objectGUID`: keine Zeile, Fehlerzeile mit dem DN
|
|
- `listGroups` markiert genau die Eintraege mit `alreadyImported: true`, deren Hex-GUID in `Group.ldapObjectGuid` dieses Mandanten liegt
|
|
- `listGroups` liefert die Eintraege nach `name` (localeCompare), bei Gleichstand nach `dn` sortiert
|
|
</behavior>
|
|
<action>
|
|
**Schicht 1 — Schema.** Ergaenze das `Group`-Modell in `apps/api/prisma/schema.prisma` um `internalName String?` (Kommentar: D-04, vom Sync nie beruehrt, ueberschreibt die Anzeige wenn gesetzt) und `ldapObjectGuid String?` (Kommentar: hex-kodierter objectGUID, rename-stabiler Sync-Match-Key; ldapDn bleibt Anzeige-/Debug-Feld). Ergaenze `@@unique([tenantId, ldapObjectGuid])` direkt unter dem vorhandenen `@@unique([tenantId, ldapDn])`. Kein Unique-Index auf `internalName`. Fuehre danach `cd apps/api && pnpm exec prisma generate` aus, damit der Prisma-Client die neuen Felder kennt (kein Datenbankzugriff noetig, die Migration selbst folgt in Task 3).
|
|
|
|
**Schicht 2 — LdapService.** In `apps/api/src/ldap/ldap.service.ts`:
|
|
|
|
1. Erweitere `LdapDirectoryEntry` um `alreadyImported?: boolean`.
|
|
2. Neues exportiertes Interface `LdapGroupImportResult` mit `imported: number`, `skipped: number`, `nameCollisions: string[]`, `errors: string[]`.
|
|
3. Neuer `static escapeLdapFilterBuffer(buf: Buffer): string` — byteweises Backslash-plus-zweistelliges-Hex je Byte (RFC-4515-Praxis, siehe RESEARCH.md Pattern 3). Wird in diesem Plan noch nicht aufgerufen; Plan 16-03 nutzt ihn fuer den Existenz-Sweep. Er entsteht hier, damit beide Escaping-Bausteine (String und binaer) an einer Stelle stehen.
|
|
4. `listGroups(...)` bekommt einen zweiten Parameter `tenantId: string`. Die LDAP-Suche fordert zusaetzlich `objectGUID` an und setzt `explicitBufferAttributes: ['objectGUID']` — dieser Punkt ist nicht optional: ohne diese Option versucht `ldapts` eine UTF-8-Dekodierung des 16-Byte-Werts (RESEARCH.md Pitfall 2). Fuer jeden Eintrag vom Typ `group` wird der Buffer per `.toString('hex')` in Kleinbuchstaben-Hex ueberfuehrt. Nach dem Aufbau der Ergebnisliste eine einzige Query `prisma.group.findMany({ where: { tenantId, ldapObjectGuid: { in: <alle Hex-Werte> } }, select: { ldapObjectGuid: true } })` — Vorbild ist die Existenz-Query in `searchUsers()`. Aus dem Ergebnis ein Set bauen und `alreadyImported` je Eintrag setzen; OU-Eintraege bekommen `alreadyImported: false`. Der Hex-Wert selbst wird NICHT an den Client geliefert (er hat dort keinen Verwendungszweck). Sortiere die Rueckgabe abschliessend nach `name` per `localeCompare`, bei Gleichstand nach `dn`.
|
|
5. Neue Methode `async importGroupsByDn(config: LdapConfigData, tenantId: string, dns: string[]): Promise<LdapGroupImportResult>`. Geruest exakt wie `importUsersByDn()`: ein `Client` aus `buildClientOptions`, `bind()`, `try/finally` mit `unbind()` im finally. Pro DN eine base-scoped Suche (`scope: 'base'`, `filter: '(objectClass=group)'`, `attributes: ['cn', 'dn', 'objectGUID']`, `explicitBufferAttributes: ['objectGUID']`) in einem eigenen try/catch, das den Lauf niemals abbricht (Vorbild: die Pro-Gruppe-Schleife in `syncGroupMembershipsForTenant`, Zeilen 860-934). Kein Treffer oder kein Buffer unter `objectGUID` → Zeile in `errors` mit dem DN, `continue`. Sonst `cn` als `name` (Array-Wert wie in `mapEntry` aufloesen) und Hex-GUID bilden. Vorab-Pruefung per `ldapObjectGuid`: existiert die Gruppe im Mandanten schon, `skipped++` und `continue`. Sonst anlegen ueber `forTenant(this.prisma, tenantId)` mit `group.create({ data: { tenantId, name, ldapDn, ldapObjectGuid } })` — `isDefault` wird nicht gesetzt, `internalName` wird nicht gesetzt. Prisma-Fehler `P2002` auswerten: betrifft das Ziel `name`, den Gruppennamen an `nameCollisions` haengen; betrifft es `ldapObjectGuid`, wie `skipped` behandeln; alles andere als Fehlerzeile sammeln. `GroupsService.create()` wird bewusst NICHT aufgerufen — dessen `ConflictException` ist fuer HTTP gebaut und wuerde den Batch abbrechen (RESEARCH.md Pitfall 4).
|
|
|
|
**Schicht 3 — DTO + Controller.** In `apps/api/src/ldap/dto/ldap-config.dto.ts` `ImportGroupsDto` mit `@IsArray() @ArrayNotEmpty() @IsString({ each: true }) dns!: string[]` — strukturgleich zu `ImportUsersDto`. In `apps/api/src/ldap/ldap.controller.ts`: `listGroups()` reicht `req.tenantId` als zweiten Parameter an den Service durch. Neue Route `@Post('groups/import')` mit `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, identischem Guard-Praefix wie `importUsers()` (tenantId-Pruefung, `getConfig`-Pruefung), Aufruf `ldapService.importGroupsByDn(<config-Objekt wie bei importUsers>, tenantId, dto.dns)`. Platziere die Route unmittelbar nach `GET 'groups'` und vor allen dynamischen Segmenten — statische Routen stehen in diesem Projekt immer vor `:id`-Routen.
|
|
|
|
**Schicht 4 — Frontend.** In `apps/web/src/app/(portal)/admin/ldap/page.tsx`: `LdapDirectoryEntry` um `alreadyImported?: boolean` erweitern, neues Interface `GroupImportResult` mit `imported`, `skipped`, `nameCollisions: string[]`, `errors: string[]`. Sieben neue State-Variablen: `groupImportCandidates`, `groupImportSearch`, `discoveringGroupImport`, `selectedImportDns`, `importingGroups`, `groupImportResult`, `groupImportError`. Handler `handleDiscoverGroupsToImport` ruft `GET /ldap/groups` mit `credentials: 'include'` und filtert die Antwort auf `type === 'group'` — OUs sind nicht importierbar. Handler `handleImportGroups` postet `{ dns: selectedImportDns }` an `/ldap/groups/import`, setzt danach `selectedImportDns` zurueck und ruft `handleDiscoverGroupsToImport()` erneut auf, damit die Markierung sofort nachzieht. Beide Handler setzen bei `!res.ok` und im catch-Zweig `groupImportError` auf den passenden uebersetzten Text und rendern ihn sichtbar; das in dieser Datei sonst uebliche stille Verschlucken von Fehlern wird fuer diese beiden Handler ausdruecklich nicht uebernommen (Owner-Entscheidung 2026-08-06). Bei Erfolg wird `groupImportError` auf `null` zurueckgesetzt.
|
|
|
|
Neue Sektion zwischen Sektion 2.5 (endet Zeile 806) und Sektion 2.55 (beginnt Zeile 808), im gleichen `rounded-lg border border-border p-6`-Card-Muster: `h2` mit `admin.ldap.groupImport.title`, Beschreibungsabsatz `description`, Discover-Button (Markup wie Zeilen 694-703, Label `discover`, waehrend `discoveringGroupImport` stattdessen `tCommon('loading')` und disabled), Suchfeld ueber der Liste (Markup wie Zeilen 707-713, Placeholder `searchPlaceholder`, filtert `groupImportCandidates` case-insensitive ueber `name` und `dn`), Ergebnisliste `max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border` mit Zeilen-Markup wie Zeilen 841-876 (Checkbox, Name in `font-medium`, DN in `font-mono text-xs text-muted-foreground truncate`, rechts das `alreadyImported`-Badge in `bg-muted text-muted-foreground`, Checkbox dann `disabled` und Zeile `opacity-60`), Import-Button mit Auswahlzahl im Label und `disabled` bei leerer Auswahl oder laufendem Import, Ergebnisabsatz `mt-3 text-sm text-muted-foreground` mit `resultSummary` (Fehlerzahl = `errors.length + nameCollisions.length`), darunter je Eintrag aus `nameCollisions` eine `text-xs text-destructive`-Zeile mit `nameCollisionError` und je Eintrag aus `errors` eine `text-xs text-destructive`-Zeile mit dem Rohtext, darunter der statische Hinweis `membershipHint` in `text-xs text-muted-foreground`. Leere geladene Liste → `noneFound`; nicht-leere Liste, aber leeres Filterergebnis → `noMatches`. Fehlerzustand `groupImportError` als `text-sm text-destructive` unterhalb des jeweils ausloesenden Buttons.
|
|
|
|
**Schicht 5 — i18n.** Beide Dateien `apps/web/src/messages/de.json` und `apps/web/src/messages/en.json` bekommen unter `admin.ldap` einen Block `groupImport` mit allen im Abschnitt "Artifacts this phase produces" gelisteten Keys. Deutsche Texte woertlich aus dem Copywriting Contract der UI-SPEC; englische Texte sinngleich. Keine firmen- oder kundenspezifischen Beispielwerte in Platzhaltern.
|
|
|
|
**Schicht 6 — Tests.** Neuer describe-Block `LdapService — AD group import (SC-1/SC-2, D-01/D-02)` in `apps/api/src/ldap/ldap.service.spec.ts`, Mock-Aufbau wie im bestehenden Block ab Zeile 283. Testfaelle exakt nach der `<behavior>`-Liste oben, plus je ein Fall fuer die Sortierung der Discovery-Liste und fuer `alreadyImported`.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts && npx tsc --noEmit</automated>
|
|
<automated>cd apps/web && npx tsc --noEmit</automated>
|
|
<automated>node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const k=Object.keys(de.admin.ldap.groupImport).sort().join(',');const l=Object.keys(en.admin.ldap.groupImport).sort().join(',');if(k!==l)throw new Error('groupImport keys differ: '+k+' vs '+l);if(!k.includes('membershipHint'))throw new Error('membershipHint missing')"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c 'explicitBufferAttributes' apps/api/src/ldap/ldap.service.ts` ist >= 2 (Discovery-Suche und Import-Suche fordern den Buffer beide explizit an)
|
|
- `grep -q 'ldapObjectGuid' apps/api/prisma/schema.prisma` und `grep -q 'internalName' apps/api/prisma/schema.prisma` treffen beide
|
|
- `grep -q '@@unique(\[tenantId, ldapObjectGuid\])' apps/api/prisma/schema.prisma` trifft
|
|
- `grep -q "Post('groups/import')" apps/api/src/ldap/ldap.controller.ts` trifft, und in derselben Datei steht in den drei Zeilen darunter `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`
|
|
- `grep -c 'forTenant' apps/api/src/ldap/ldap.service.ts` ist gegenueber dem Stand vor diesem Task um mindestens 1 gestiegen (der Schreibpfad in `importGroupsByDn` ist mandantengescopt)
|
|
- `cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts` ist gruen und der neue describe-Block enthaelt mindestens 8 `it(`-Faelle
|
|
- `cd apps/api && npx tsc --noEmit` und `cd apps/web && npx tsc --noEmit` sind beide fehlerfrei
|
|
- `de.json` und `en.json` tragen unter `admin.ldap.groupImport` denselben Schluesselsatz (Node-Check oben)
|
|
- In `apps/web/src/app/(portal)/admin/ldap/page.tsx` enthaelt keiner der beiden neuen Handler einen leeren catch-Block: `awk '/handleDiscoverGroupsToImport|handleImportGroups/,/^ };/' 'apps/web/src/app/(portal)/admin/ldap/page.tsx' | grep -c 'setGroupImportError'` ist >= 4
|
|
- Die neue Sektion rendert ausschliesslich Eintraege mit `type === 'group'` — `grep -q "type === 'group'" 'apps/web/src/app/(portal)/admin/ldap/page.tsx'` trifft
|
|
</acceptance_criteria>
|
|
<done>Ein Admin kann in `/admin/ldap` AD-Gruppen entdecken, einzelne anhaken und importieren; je angehakter Gruppe existiert danach eine Group-Zeile mit `ldapDn` und `ldapObjectGuid`, bereits importierte Gruppen sind gekennzeichnet und nicht erneut anwaehlbar, und ein Fehler im Discovery- oder Import-Request ist auf der Seite sichtbar. Die Scheibe ist committed.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: [BLOCKING] Migration erzeugen, gegen die lokale Datenbank anwenden und per SQL-Test festnageln</name>
|
|
<precondition>Der Container `tessera-ctl-db-1` laeuft (`docker start tessera-ctl-db-1`) und ist ueber seine Container-IP mit den Zugangsdaten `tessera:tessera_dev` erreichbar — die `db`-Service veroeffentlicht keinen Host-Port.</precondition>
|
|
<files>apps/api/prisma/migrations/_add_group_internal_name_and_object_guid/migration.sql, apps/api/src/groups/migration-sql.spec.ts</files>
|
|
<read_first>
|
|
- `apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql` — Spalten- und Index-Stil
|
|
- `apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql` — belegt, dass `Group` bereits `FORCE ROW LEVEL SECURITY` traegt; die neue Migration braucht KEINE eigene RLS-Anweisung
|
|
- `apps/api/src/groups/migration-sql.spec.ts` — vorhandenes Textabgleich-Muster inkl. `readMigrationSql(suffix)`-Helper
|
|
- `.planning/quick/260728-lih-ldap-sync-selektiv-und-auto-sync-default/260728-lih-PLAN.md` Zeilen 100-111 — das dokumentierte lokale Migrationsverfahren
|
|
- `docker-compose.yml` — Service `db`, zur Bestaetigung, dass kein Host-Port veroeffentlicht wird
|
|
</read_first>
|
|
<action>
|
|
Dieser Task ist **blockierend**: Build und Typpruefung laufen bereits durch, weil die Prisma-Typen aus `schema.prisma` stammen, nicht aus der laufenden Datenbank. Ohne diesen Schritt entsteht ein falsch-positiver Verifikationszustand.
|
|
|
|
Verfahren wortgleich zum dokumentierten Muster: `docker start tessera-ctl-db-1`, dann die Container-IP per `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1` ermitteln und `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` ausfuehren. Erwartetes Ergebnis: ein neues Verzeichnis mit dem Suffix `_add_group_internal_name_and_object_guid` unter `apps/api/prisma/migrations/`, dessen `migration.sql` zwei `ADD COLUMN`-Anweisungen fuer `internalName` und `ldapObjectGuid` sowie einen `CREATE UNIQUE INDEX` auf `("tenantId", "ldapObjectGuid")` enthaelt.
|
|
|
|
Erzeugt Prisma stattdessen ein Reset-Angebot oder eine Drift-Warnung, ist das ein Abbruchgrund — nicht bestaetigen, sondern den Zustand melden. Ein Reset wuerde die lokale Entwicklungsdatenbank leeren.
|
|
|
|
Keine RLS-Anweisung in dieser Migration: die Policy aus `20260804130918_groups_rls_policies` gilt tabellenweit und greift auf neue Spalten automatisch mit. Falls Prisma die Migration mit zusaetzlichen, nicht angeforderten Anweisungen erzeugt (z.B. Aenderungen an fremden Tabellen), diese entfernen und den Grund im SUMMARY festhalten.
|
|
|
|
Danach `apps/api/src/groups/migration-sql.spec.ts` um einen describe-Block fuer die neue Migration erweitern, der ueber den vorhandenen `readMigrationSql`-Helper mit dem Suffix `_add_group_internal_name_and_object_guid` liest und die drei Bestandteile per `toContain` festnagelt: die Spalte `internalName`, die Spalte `ldapObjectGuid` und den Unique-Index auf `("tenantId", "ldapObjectGuid")`.
|
|
|
|
In Produktion laeuft `prisma migrate deploy` beim Container-Start (`apps/api/Dockerfile:38`) — es ist kein zusaetzlicher Deploy-Schritt zu planen, und der Testserver wird von diesem Task nicht angefasst.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && npx vitest run src/groups/migration-sql.spec.ts</automated>
|
|
<automated>ls -d apps/api/prisma/migrations/*_add_group_internal_name_and_object_guid | wc -l | grep -qx 1</automated>
|
|
<automated>cd apps/api && npx vitest run</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- Genau ein Verzeichnis unter `apps/api/prisma/migrations/` endet auf `_add_group_internal_name_and_object_guid`
|
|
- Dessen `migration.sql` enthaelt die Literale `internalName`, `ldapObjectGuid` und `"tenantId", "ldapObjectGuid"`
|
|
- Die neue Migration enthaelt keine `ROW LEVEL SECURITY`-Anweisung: `grep -ci 'row level security' apps/api/prisma/migrations/*_add_group_internal_name_and_object_guid/migration.sql` ergibt 0
|
|
- `cd apps/api && npx vitest run src/groups/migration-sql.spec.ts` ist gruen
|
|
- `cd apps/api && npx vitest run` ist vollstaendig gruen (keine Regression in den 387+ bestehenden Faellen)
|
|
- `psql`-Gegenprobe gegen die lokale Datenbank zeigt beide Spalten auf `"Group"`: `docker exec tessera-ctl-db-1 psql -U tessera -d tessera -c '\d "Group"'` listet `internalName` und `ldapObjectGuid`
|
|
</acceptance_criteria>
|
|
<done>Die Migration existiert versioniert, ist auf der lokalen Datenbank angewendet, per SQL-Textest festgenagelt, und die vollstaendige API-Testsuite ist gruen.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Browser → API (`POST /ldap/groups/import`) | Admin-gelieferte DN-Liste ueberquert hier die Grenze; Mandantenkontext kommt ausschliesslich aus `req.tenantId`, nie aus dem Body |
|
|
| API → Active Directory | Admin-gewaehlte DNs werden Teil einer LDAP-Suche; AD-gelieferte Werte (`cn`, `objectGUID`) kommen ungeprueft zurueck |
|
|
| API → PostgreSQL | Schreibpfad auf `Group` unter RLS (`FORCE ROW LEVEL SECURITY`, Migration `20260804130918_groups_rls_policies`) |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
| T-16-01 | Tampering | `LdapService.importGroupsByDn` LDAP-Suche | high | mitigate | Suche laeuft `scope: 'base'` direkt auf dem uebergebenen DN mit konstantem Filter `(objectClass=group)` — der admin-gelieferte Wert wird als Such-Base uebergeben, nicht in einen Filterausdruck interpoliert. Jede kuenftige Filter-Interpolation eines String-Werts nutzt `escapeLdapFilterValue()`, jede eines Binaerwerts den in diesem Plan angelegten `escapeLdapFilterBuffer()` |
|
|
| T-16-02 | Elevation of Privilege | Schreibpfad `group.create` | high | mitigate | `forTenant(this.prisma, tenantId)` setzt `app.current_tenant`; `tenantId` stammt aus `req.tenantId`, nie aus dem Request-Body. RLS ist das zweite Netz |
|
|
| T-16-03 | Spoofing | Route `POST /ldap/groups/import` | high | mitigate | `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` + bestehender `RolesGuard`, identisch zu allen uebrigen LDAP-Routen (ASVS V4) |
|
|
| T-16-04 | Information Disclosure | Discovery-Antwort `GET /ldap/groups` | low | mitigate | Der Hex-`objectGUID` wird bewusst NICHT an den Client geliefert; die Antwort traegt nur `dn`, `name`, `type`, `alreadyImported` |
|
|
| T-16-05 | Tampering | `cn` aus dem AD als `Group.name` | medium | mitigate | Der Wert wird ausschliesslich als Prisma-Parameter geschrieben (keine Raw-SQL-Interpolation) und im Frontend als React-Text gerendert (kein `dangerouslySetInnerHTML`) |
|
|
| T-16-SC | Tampering | Paketinstallation | low | accept | Diese Phase installiert kein einziges neues Paket — es wird nur eine Option der bereits gepinnten `ldapts@8.1.8` genutzt. Kein Registry-Zugriff, damit kein Supply-Chain-Vektor und keine Legitimacy-Pruefung erforderlich |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
1. `cd apps/api && npx vitest run` — vollstaendig gruen
|
|
2. `cd apps/api && npx tsc --noEmit` und `cd apps/web && npx tsc --noEmit` — fehlerfrei
|
|
3. `docker exec tessera-ctl-db-1 psql -U tessera -d tessera -c '\d "Group"'` zeigt `internalName` und `ldapObjectGuid`
|
|
4. Der neue describe-Block in `ldap.service.spec.ts` deckt alle acht Punkte der `<behavior>`-Liste aus Task 2 ab
|
|
|
|
**Flagged assumption (nicht in diesem Plan aufloesbar):** RESEARCH.md A1 (`objectGUID` bleibt ueber eine AD-Umbenennung stabil) und A2 (Binaer-Filter-Syntax) sind gegen kein echtes Verzeichnis geprueft. Dieser Plan **schreibt** die GUID nur; er **liest sie nicht filternd zurueck**. Die Aufloesung beider Annahmen ist Bestandteil von Plan 16-03 und laeuft dort als Live-Pruefung gegen ViCoTest.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Admin waehlt in `/admin/ldap` einzelne AD-Gruppen und importiert sie; je Auswahl entsteht genau eine Tessera-Gruppe mit `ldapDn` und `ldapObjectGuid` (SC-1)
|
|
- Nicht ausgewaehlte AD-Gruppen entstehen nicht (SC-2)
|
|
- `Group.internalName` und `Group.ldapObjectGuid` existieren in Schema, Migration und laufender Datenbank (D-04)
|
|
- Doppelter Import derselben Gruppe erzeugt keine Doppelzeile
|
|
- Namenskollision meldet die betroffene Gruppe und bricht den restlichen Import nicht ab
|
|
- Discovery- und Import-Fehler sind auf der Seite sichtbar, nicht stumm
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/16-ad-gruppen-synchronisation/16-01-SUMMARY.md` when done
|
|
</output>
|