Files
tessera-ctl/.planning/phases/16-ad-gruppen-synchronisation/16-01-PLAN.md
T

37 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
16-ad-gruppen-synchronisation 01 execute 1
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
false
PERM-02
tokens raw_tokens tasks confidence
72000 72000 3 low
truths artifacts key_links prohibitions
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 verification
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. backstop
statement verification
Die Reihenfolge der angelegten Gruppen folgt der Reihenfolge der uebergebenen dns; das Ergebnis ist unabhaengig von dieser Reihenfolge identisch. backstop
statement verification
Zwei parallele Import-Requests mit ueberlappender Auswahl lassen keinen Request scheitern — der zweite meldet die Ueberlappung als skipped. 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 verification
UI E1/long-text: Ein AD-Gruppenname, der breiter ist als die Listenzeile, sprengt das Zeilenlayout der Discovery-Liste nicht. backstop
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
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
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.
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.

<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>

@.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

<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>
Task 1: Freigabe der one-way Schema-Erweiterung (D-04) - `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) 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. 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. Beide Spalten in einer Migration (empfohlen) 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). One-way: Rueckbau nach Vergabe echter interner Namen ist Datenverlust. Erhoeht die Group-Tabelle um zwei Spalten. Nur ldapObjectGuid, internalName spaeter Kleinere Migration, D-04 bleibt rueckbaubar. 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. Zurueckstellen und Phase neu zuschneiden Keine Schemaaenderung. Ohne beide Spalten ist keines der fuenf Success Criteria erreichbar; die Phase haette keinen Inhalt. <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> Antworte mit: approve-both, approve-guid-only oder defer

Task 2: Tracer — AD-Gruppe auswaehlen und importieren, durch alle Schichten 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). 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 - `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 - `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 **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. cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts && npx tsc --noEmit cd apps/web && npx tsc --noEmit 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')" <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> 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.

Task 3: [BLOCKING] Migration erzeugen, gegen die lokale Datenbank anwenden und per SQL-Test festnageln 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. apps/api/prisma/migrations/_add_group_internal_name_and_object_guid/migration.sql, apps/api/src/groups/migration-sql.spec.ts - `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 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. cd apps/api && npx vitest run src/groups/migration-sql.spec.ts ls -d apps/api/prisma/migrations/*_add_group_internal_name_and_object_guid | wc -l | grep -qx 1 cd apps/api && npx vitest run <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> Die Migration existiert versioniert, ist auf der lokalen Datenbank angewendet, per SQL-Textest festgenagelt, und die vollstaendige API-Testsuite ist gruen.

<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>
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 ``-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.

<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>
Create `.planning/phases/16-ad-gruppen-synchronisation/16-01-SUMMARY.md` when done