From 3dfa671ec9f50337ad7241c4e60ca2822f0fb832 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 6 Aug 2026 14:38:45 +0200 Subject: [PATCH] =?UTF-8?q?docs(16):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=205=20plans,=204=20waves,=20tracer-first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 10 +- .../16-01-PLAN.md | 326 ++++++++++++++++++ .../16-02-PLAN.md | 273 +++++++++++++++ .../16-03-PLAN.md | 259 ++++++++++++++ .../16-04-PLAN.md | 255 ++++++++++++++ .../16-05-PLAN.md | 217 ++++++++++++ 6 files changed, 1338 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/16-ad-gruppen-synchronisation/16-01-PLAN.md create mode 100644 .planning/phases/16-ad-gruppen-synchronisation/16-02-PLAN.md create mode 100644 .planning/phases/16-ad-gruppen-synchronisation/16-03-PLAN.md create mode 100644 .planning/phases/16-ad-gruppen-synchronisation/16-04-PLAN.md create mode 100644 .planning/phases/16-ad-gruppen-synchronisation/16-05-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 1944096..8b3c45e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -562,11 +562,17 @@ Plans: **Offen für die Planung**: ob ein Admin eine automatisch angelegte Gruppe in Tessera umbenennen darf (würde beim nächsten Sync überschrieben), und wie sich die Auswahl-Oberfläche zur bestehenden AD-Gruppenauswahl in `/admin/groups` verhält. -**Plans**: 0 plans +**Plans**: 5 plans Plans: -- [ ] TBD (run /gsd-plan-phase 16 to break down) +- [ ] 16-01-PLAN.md — Tracer: Schema-Erweiterung (internalName, ldapObjectGuid) + AD-Gruppen-Import end-to-end (D-01, D-02, D-04) +- [ ] 16-02-PLAN.md — GroupsService: Standardgruppen-Handoff, Namenssperre, interner Name, Anzeige-Fallback (D-03, D-04, D-06, D-07) +- [ ] 16-03-PLAN.md — Sync-Rekonziliation: Umbenennung, Loeschung, Alt-Bindungen; eingehaengt vor dem Mitgliedschafts-Abgleich (D-03, D-05, D-06) +- [ ] 16-04-PLAN.md — GroupFormModal ohne AD-Bindung, gesperrtes Namensfeld, interner Name, i18n-Bereinigung (D-03, D-04, D-07) +- [ ] 16-05-PLAN.md — Sync-Bericht vollstaendig verdrahtet + Anzeigename in der Freigabe-Matrix (D-04, D-05, D-06) + +**Waves**: 1 → 16-01 · 2 → 16-02 · 3 → 16-03, 16-04 (parallel) · 4 → 16-05 **UI hint**: yes diff --git a/.planning/phases/16-ad-gruppen-synchronisation/16-01-PLAN.md b/.planning/phases/16-ad-gruppen-synchronisation/16-01-PLAN.md new file mode 100644 index 0000000..0a801b2 --- /dev/null +++ b/.planning/phases/16-ad-gruppen-synchronisation/16-01-PLAN.md @@ -0,0 +1,326 @@ +--- +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/_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." +--- + + +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. + + + +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 | `_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` | + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + +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. + + + + + + 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. + + + + + + + + - 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 + + 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: } }, 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`. 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(, 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 ``-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')" + + + - `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 + + 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 + + + - 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` + + Die Migration existiert versioniert, ist auf der lokalen Datenbank angewendet, per SQL-Textest festgenagelt, und die vollstaendige API-Testsuite ist gruen. + + + + + +## 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 | + + + +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. + + + +- 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 + + + +Create `.planning/phases/16-ad-gruppen-synchronisation/16-01-SUMMARY.md` when done + diff --git a/.planning/phases/16-ad-gruppen-synchronisation/16-02-PLAN.md b/.planning/phases/16-ad-gruppen-synchronisation/16-02-PLAN.md new file mode 100644 index 0000000..19ed980 --- /dev/null +++ b/.planning/phases/16-ad-gruppen-synchronisation/16-02-PLAN.md @@ -0,0 +1,273 @@ +--- +phase: 16-ad-gruppen-synchronisation +plan: 02 +type: execute +wave: 2 +depends_on: [16-01] +files_modified: + - apps/api/src/groups/groups.service.ts + - apps/api/src/groups/groups.service.spec.ts + - apps/api/src/groups/dto/update-group.dto.ts + - apps/api/src/groups/module-grants.service.ts + - apps/api/src/groups/module-grants.service.spec.ts +autonomous: true +requirements: [PERM-02] + +estimate: + tokens: 50000 + raw_tokens: 50000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "Eine importierte Gruppe (gesetzter ldapObjectGuid) laesst sich ueber PATCH /groups/:id nicht umbenennen: der Request wird mit HTTP 400 abgelehnt, die Sperre aus D-03 ist eine Backend-Invariante und nicht nur eine UI-Konvention." + - "internalName ist fuer jede Gruppe — importiert wie lokal — ueber PATCH /groups/:id setz- und wieder loeschbar; kein Codepfad ausser einer ausdruecklichen Admin-Eingabe schreibt diese Spalte (D-04)." + - "GroupsService.reassignDefaultBeforeDelete(tenantId, groupId) verschiebt die Standardmarkierung deterministisch auf die lokale Gruppe mit dem Namen aus DEFAULT_GROUP_NAME und, falls es sie nicht gibt, auf die aelteste verbleibende Gruppe des Mandanten (D-06)." + - "GroupsService.listForTenant liefert internalName als eigenes Feld zusaetzlich zu name — der Anzeige-Fallback bleibt beim Konsumenten, das Backend versteckt keines der beiden Felder." + - "module-grants.service.ts liefert an beiden Projektionsstellen (viaGroups-Namen und Mitgliedschafts-Chips) internalName ?? name; beide zugehoerigen Queries selektieren internalName mit (D-04)." + - "Eine Gruppe mit internalName null oder nur aus Leerzeichen bestehend zeigt ihren AD- bzw. lokalen Namen; ein PATCH mit leerem oder nur aus Leerzeichen bestehendem internalName wird zu null normalisiert, nie zu einem leeren Anzeigenamen." + - "Der Test 'interner Name gesetzt' laeuft ueber den getrimmten Inhalt, nicht ueber die Truthiness eines Strings mit Leerzeichen; Unicode-Namen werden unveraendert gespeichert und zurueckgeliefert." + - "Ein zweites PATCH mit demselben internalName aendert nichts und wirft nicht." + - "reassignDefaultBeforeDelete ist idempotent: ein zweiter Aufruf fuer eine Gruppe, die die Markierung nicht (mehr) traegt, ist ein No-Op mit Rueckgabe false." + - "Existiert ausser der zu loeschenden Gruppe keine weitere Gruppe im Mandanten, gibt reassignDefaultBeforeDelete false zurueck und wirft nicht — der Aufrufer ist dann fuer den Wiederaufbau zustaendig." + - "Die Zielauswahl bei mehreren Kandidaten ist stabil: orderBy createdAt aufsteigend, damit zwei Laeufe ueber denselben Bestand dieselbe Gruppe waehlen." + - "UI E6/empty: Bestehendes Verhalten bei leerem groups- bzw. viaGroups-Array unveraendert — dieser Plan erzeugt keinen Frontend-Diff im UserAccessModal." + - "UI E6/loading: Bestehender Ladezustand des UserAccessModal unveraendert." + - "UI E6/error: Bestehendes Fehlerverhalten des UserAccessModal unveraendert." + - "UI E6/populated: Die Chips rendern den vom Backend gelieferten String; module-grants.service.ts liefert an beiden Projektionsstellen internalName ?? name." + - "UI E6/partial: Der Fallback sitzt serverseitig — ein nicht gesetzter interner Name erzeugt nie einen leeren Chip." + - "UI E6/overflow: Bestehendes Chip-Umbruchverhalten unveraendert." + - "UI E6/zero-one-many: Unveraendert; abgedeckt durch Empty-State und Chip-Rendering." + - statement: "Ein paralleles PATCH auf internalName und ein gleichzeitig laufender Sync koennen einander nicht ueberschreiben, weil kein Sync-Codepfad die Spalte internalName in seine update-Daten aufnimmt." + verification: backstop + - statement: "Die Gruppenliste sortiert weiterhin ueber name, waehrend die Oberflaeche internalName ?? name anzeigt — bei gesetzten internen Namen weicht die sichtbare Reihenfolge daher von der alphabetischen Ordnung der Anzeigenamen ab. Bewusst akzeptierte Folge des add-alongside-Modells aus 16-01." + verification: backstop + artifacts: + - "apps/api/src/groups/groups.service.ts — DEFAULT_GROUP_NAME, reassignDefaultBeforeDelete(), Namenssperre in update(), internalName in update() und listForTenant()" + - "apps/api/src/groups/dto/update-group.dto.ts — internalName, ldapDn entfernt" + - "apps/api/src/groups/module-grants.service.ts — internalName ?? name an beiden Projektionsstellen" + - "apps/api/src/groups/groups.service.spec.ts — describe-Bloecke fuer Namenssperre, internalName und Default-Handoff" + - "apps/api/src/groups/module-grants.service.spec.ts — Faelle fuer den Anzeige-Fallback" + key_links: + - "reassignDefaultBeforeDelete() ist der Baustein, den Plan 16-03 vor jedem sync-getriebenen group.delete() aufruft — fehlt der Aufruf, bleibt ein Mandant ohne Standardgruppe" + - "DEFAULT_GROUP_NAME wird von ensureDefaultGroup() und reassignDefaultBeforeDelete() geteilt — zwei getrennte Literale wuerden bei einer Umbenennung auseinanderlaufen" + - "update() lehnt name fuer Gruppen mit gesetztem ldapObjectGuid ab — das ist die serverseitige Durchsetzung, auf die sich das gesperrte Feld in Plan 16-04 verlaesst" + prohibitions: + - "Ein gesetzter interner Name darf niemals durch einen automatisierten Codepfad (Sync, Import, Migration, Backfill, Seed) veraendert oder geleert werden — ausschliesslich durch eine ausdrueckliche Admin-Eingabe." + - "Die Namenssperre fuer importierte Gruppen darf nicht rein kosmetisch im Frontend leben: ein direkter PATCH auf name muss serverseitig abgelehnt werden." + - "Es darf kein Codepfad entstehen, der einer lokal angelegten Gruppe nachtraeglich eine AD-Bindung zuweist (D-07)." + - "Der Anzeige-Fallback darf nie einen leeren Namen liefern — eine Gruppe ohne internen Namen zeigt immer ihren gespeicherten Namen." +--- + + +Der interne Name aus D-04 wird im Backend nutzbar: `GroupsService` nimmt ihn entgegen, gibt ihn aus und sperrt gleichzeitig das AD-eigene Namensfeld fuer importierte Gruppen (D-03) — als echte Backend-Invariante, nicht als UI-Konvention. Zusaetzlich entsteht der Baustein fuer den Standardgruppen-Handoff aus D-06, den der Sync in Plan 16-03 vor jeder Loeschung aufruft. Schliesslich reicht `module-grants.service.ts` den Anzeigenamen mit Fallback an das Benutzer-Detail durch, wodurch die Chips im `UserAccessModal` ohne einen einzigen Frontend-Diff korrekt werden (UI-SPEC Surface Contract 6). + +Purpose: Alle Gruppen-seitigen Bausteine liegen fertig vor, bevor der Sync in 16-03 sie orchestriert. Der Sync soll keine eigene Lösch-, Handoff- oder Namenslogik erfinden. + +Output: `DEFAULT_GROUP_NAME`, `reassignDefaultBeforeDelete()`, Namenssperre und `internalName`-Unterstuetzung in `update()`/`listForTenant()`, angepasstes `UpdateGroupDto`, Anzeige-Fallback in `module-grants.service.ts`, Tests. + + + +Neu entstehende Symbole in diesem Plan: + +| Art | Symbol | +|-----|--------| +| Exportierte Konstante | `DEFAULT_GROUP_NAME` (`apps/api/src/groups/groups.service.ts`) | +| Service-Methode | `GroupsService.reassignDefaultBeforeDelete(tenantId, groupId): Promise` | +| DTO-Feld | `UpdateGroupDto.internalName?: string \| null` | +| Entferntes DTO-Feld | `UpdateGroupDto.ldapDn` (D-07) | +| Rueckgabefeld | `internalName` in der Projektion von `GroupsService.listForTenant()` | + +Aus Plan 16-01 uebernommen und hier vorausgesetzt: `Group.internalName`, `Group.ldapObjectGuid`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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-PATTERNS.md +@.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md +@.planning/phases/16-ad-gruppen-synchronisation/16-01-SUMMARY.md + + + + + + Task 1: Standardgruppen-Handoff als eigener Baustein (D-06) + apps/api/src/groups/groups.service.ts, apps/api/src/groups/groups.service.spec.ts + + - `apps/api/src/groups/groups.service.ts` — `findOwned()` (Zeilen 78-86), `update()` mit der Standardmarkierungs-Transaktion (99-154), `ensureDefaultGroup()` (277-328, enthaelt heute das Literal des Standardgruppennamens und das P2002-Muster) + - `apps/api/src/groups/groups.service.spec.ts` — bestehende Mock-Struktur fuer `update`/`findOwned` + - `.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md` — Abschnitt `groups.service.ts`, insbesondere die Default-Marker-Transaktion und der `ensureDefaultGroup`-Auszug + - `.planning/phases/16-ad-gruppen-synchronisation/16-CONTEXT.md` — D-06 im Wortlaut + + + - Gruppe traegt die Standardmarkierung, und es existiert eine andere Gruppe mit dem Namen aus `DEFAULT_GROUP_NAME` → Markierung wandert dorthin, Rueckgabe `true` + - Gruppe traegt die Markierung, `DEFAULT_GROUP_NAME` existiert nicht, aber andere Gruppen → Markierung wandert auf die aelteste andere Gruppe (`createdAt` aufsteigend), Rueckgabe `true` + - Gruppe traegt die Markierung, es gibt keine andere Gruppe → Rueckgabe `false`, kein Wurf + - Gruppe traegt die Markierung nicht → No-Op, Rueckgabe `false` + - Gruppen-ID gehoert zu einem fremden Mandanten → No-Op, Rueckgabe `false` (kein Wurf, da der Aufrufer ein Batch-Lauf ist) + - Die Transaktion laeuft in einen `P2002` → Rueckgabe `false` statt Wurf ("hat sich schon jemand anderes gekuemmert") + + +Ziehe das heute in `ensureDefaultGroup()` hart stehende Literal des Standardgruppennamens in eine exportierte Konstante `DEFAULT_GROUP_NAME` am Dateikopf von `apps/api/src/groups/groups.service.ts` und nutze sie an der bestehenden Stelle weiter. Damit teilen sich beide Methoden eine einzige Wahrheit; zwei getrennte Literale wuerden bei einer spaeteren Umbenennung auseinanderlaufen. + +Neue oeffentliche Methode `async reassignDefaultBeforeDelete(tenantId: string, groupId: string): Promise`. Sie loescht **nichts** — sie verschiebt ausschliesslich die Markierung und meldet per Rueckgabewert, ob sie es getan hat. Ablauf: + +1. Gruppe per `findFirst({ where: { id: groupId, tenantId } })` laden. Kein Treffer oder `isDefault === false` → `false` zurueckgeben. Bewusst NICHT `findOwned()` verwenden: dessen `NotFoundException` ist fuer HTTP gebaut und wuerde einen Batch-Lauf abbrechen. +2. Zielgruppe bestimmen — zuerst `findFirst({ where: { tenantId, id: { not: groupId }, name: DEFAULT_GROUP_NAME } })`, sonst `findFirst({ where: { tenantId, id: { not: groupId } }, orderBy: { createdAt: 'asc' } })`. Die aufsteigende Sortierung ist der Determinismus-Garant: zwei Laeufe ueber denselben Bestand waehlen dieselbe Gruppe. +3. Kein Ziel → `false` zurueckgeben. Der Aufrufer (Plan 16-03) ruft nach der Loeschung `ensureDefaultGroup(tenantId)` und baut damit den Bestand neu auf. +4. Sonst dieselbe Zwei-Schritt-Transaktionsform wie in `update()`: `$transaction([group.updateMany({ where: { tenantId, isDefault: true }, data: { isDefault: false } }), group.update({ where: { id: }, data: { isDefault: true } })])`. `true` zurueckgeben. +5. Fehler mit `code === 'P2002'` abfangen und `false` zurueckgeben — der partielle Unique-Index `Group_one_default_per_tenant` ist der eigentliche Durchsetzungspunkt, das Muster ist aus `ensureDefaultGroup()` uebernommen. Andere Fehler weiterwerfen. + +Ergaenze in `apps/api/src/groups/groups.service.spec.ts` einen describe-Block `GroupsService.reassignDefaultBeforeDelete (D-06)` mit je einem Fall pro Zeile der ``-Liste, aufgebaut auf der bestehenden Prisma-Mock-Struktur der Datei. Achte darauf, dass der gemockte `findFirst` mehrere unterschiedliche Aufrufmuster bedienen muss (Gruppe laden, Namensziel, Fallback-Ziel) — verwende `mockResolvedValueOnce`-Ketten statt eines einzelnen `mockResolvedValue`, damit die drei Aufrufe unterscheidbar bleiben. + + + cd apps/api && npx vitest run src/groups/groups.service.spec.ts -t "reassignDefaultBeforeDelete" + cd apps/api && npx tsc --noEmit + + + - `grep -c "export const DEFAULT_GROUP_NAME" apps/api/src/groups/groups.service.ts` ergibt 1 + - Der Standardgruppenname steht nur noch an dieser einen Stelle als Literal: `grep -c "'Alle Benutzer'" apps/api/src/groups/groups.service.ts` ergibt 1 + - `grep -q "async reassignDefaultBeforeDelete" apps/api/src/groups/groups.service.ts` trifft + - Die Methode enthaelt keinen Aufruf von `group.delete`: `awk '/async reassignDefaultBeforeDelete/,/^ }$/' apps/api/src/groups/groups.service.ts | grep -c 'group.delete'` ergibt 0 + - `awk '/async reassignDefaultBeforeDelete/,/^ }$/' apps/api/src/groups/groups.service.ts | grep -c "orderBy"` ist >= 1 (deterministische Zielauswahl) + - `cd apps/api && npx vitest run src/groups/groups.service.spec.ts -t "reassignDefaultBeforeDelete"` ist gruen mit mindestens 6 Faellen + + `reassignDefaultBeforeDelete` verschiebt die Standardmarkierung deterministisch, meldet den Ausgang per Rueckgabewert, wirft in keinem der sechs Faelle und ist getestet. + + + + Task 2: Namenssperre fuer importierte Gruppen und internalName im GroupsService (D-03, D-04, D-07) + apps/api/src/groups/groups.service.ts, apps/api/src/groups/dto/update-group.dto.ts, apps/api/src/groups/groups.service.spec.ts + + - `apps/api/src/groups/groups.service.ts` — `listForTenant()` (Zeilen 28-45), `create()` (54-72), `findOwned()` (78-86), `update()` (99-154) inklusive des P2002-Uebersetzungsblocks + - `apps/api/src/groups/dto/update-group.dto.ts` — vollstaendig, inkl. des Kommentars zur `ldapDn`-Null-Semantik, der mit dem Feld entfaellt + - `apps/api/src/groups/groups.service.spec.ts` — bestehende `update`-Tests + - `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Open Question 1 (Namenssperre serverseitig statt kosmetisch) + - `.planning/phases/16-ad-gruppen-synchronisation/16-CONTEXT.md` — D-03, D-04, D-07 + + + - PATCH mit `name` auf eine Gruppe mit gesetztem `ldapObjectGuid` → `BadRequestException` (HTTP 400), keine Schreiboperation + - PATCH mit `name` auf eine Gruppe ohne `ldapObjectGuid` → verhaelt sich unveraendert wie heute + - PATCH mit `internalName: 'Vertrieb'` auf eine importierte Gruppe → Spalte gesetzt, `name` unangetastet + - PATCH mit `internalName: ' '` → Spalte auf `null` gesetzt (kein leerer Anzeigename) + - PATCH mit `internalName: null` → Spalte auf `null` gesetzt + - Zweites PATCH mit identischem `internalName` → kein Fehler, kein Unterschied im Ergebnis + - PATCH mit `internalName` auf eine lokale Gruppe → erlaubt (keine Sperre auf diesem Feld) + - `listForTenant()` liefert `internalName` zusaetzlich zu `name` in jeder Zeile + + +**DTO.** In `apps/api/src/groups/dto/update-group.dto.ts` das Feld `ldapDn` **entfernen** — nach D-07 kann eine lokale Gruppe nicht mehr nachtraeglich an eine AD-Gruppe gebunden und eine bestehende Bindung nicht mehr ueber diesen Weg geloest werden. Die globale `ValidationPipe` laeuft mit `whitelist: true` ohne `forbidNonWhitelisted`, ein noch mitgesendetes Feld wird also stillschweigend verworfen statt einen 400 zu erzeugen. Neues Feld `internalName?: string | null` mit `@IsOptional() @IsString()` — dieselbe Null-Durchlass-Mechanik, die der entfallende Kommentar bisher fuer `ldapDn` beschrieben hat. Passe den Docblock der Klasse entsprechend an: er beschreibt jetzt Umbenennen (nur lokale Gruppen), Standardmarkierung und internen Namen. + +**Service.** In `GroupsService.update()` die Signatur auf `{ name?: string; isDefault?: boolean; internalName?: string | null }` umstellen und den `ldapDn`-Zweig ersatzlos streichen. Der Rueckgabewert von `findOwned(tenantId, id)` wird ab jetzt gebraucht — bisher wurde er verworfen. Unmittelbar vor dem bestehenden `if (data.name !== undefined)`-Block eine Pruefung einziehen: ist `data.name !== undefined` **und** traegt die geladene Gruppe einen gesetzten `ldapObjectGuid`, dann `BadRequestException` mit dem Hinweis, dass der Name einer aus dem Verzeichnis uebernommenen Gruppe dort gepflegt wird. Reihenfolge ist wichtig: die Sperre greift vor der Leerstring-Pruefung, damit ein gesperrter Name nicht mit der falschen Fehlermeldung antwortet. + +Danach den `internalName`-Zweig: bei `undefined` nichts tun; bei `null` `updateData.internalName = null`; bei einem String den getrimmten Wert nehmen und, wenn er leer ist, ebenfalls `null` schreiben. Das ist der Grund, warum "gesetzt" spaeter ueber Inhalt statt ueber Truthiness eines Strings mit Leerzeichen entschieden werden kann — leere und nur aus Leerzeichen bestehende Werte erreichen die Datenbank nie. + +Der bestehende P2002-Uebersetzungsblock bleibt unveraendert und bezieht sich weiterhin auf `updateData.name`; `internalName` traegt keinen Unique-Index und kann dort nicht auftreten. + +In `listForTenant()` die Projektion um `internalName: g.internalName` erweitern. Die Sortierung bleibt bewusst auf `name` — die Konsequenz (Anzeige-Reihenfolge weicht bei gesetzten internen Namen von der alphabetischen Ordnung der Anzeigenamen ab) ist als backstop in `must_haves` festgehalten und wird nicht in diesem Plan aufgeloest. + +**Tests.** In `apps/api/src/groups/groups.service.spec.ts` einen describe-Block `GroupsService.update — Namenssperre und interner Name (D-03/D-04)` ergaenzen, mit je einem Fall pro Zeile der ``-Liste. Der Mock fuer `findFirst` muss dabei die geladene Gruppe samt `ldapObjectGuid` liefern — beim Erweitern der bestehenden Mocks darauf achten, dass `findOwned` weiterhin genau ein `findFirst`-Aufrufmuster erwartet und kein zweiter, inkompatibler Mock fuer dieselbe Methode entsteht. + + + cd apps/api && npx vitest run src/groups/groups.service.spec.ts + cd apps/api && npx tsc --noEmit + + + - `grep -c 'ldapDn' apps/api/src/groups/dto/update-group.dto.ts` ergibt 0 + - `grep -q 'internalName' apps/api/src/groups/dto/update-group.dto.ts` trifft + - `awk '/async update\(/,/^ }$/' apps/api/src/groups/groups.service.ts | grep -c 'ldapObjectGuid'` ist >= 1 (die Sperre liest die Spalte) + - `awk '/async update\(/,/^ }$/' apps/api/src/groups/groups.service.ts | grep -c 'updateData.ldapDn'` ergibt 0 + - `awk '/async listForTenant/,/^ }$/' apps/api/src/groups/groups.service.ts | grep -c 'internalName'` ist >= 1 + - `cd apps/api && npx vitest run src/groups/groups.service.spec.ts` ist gruen und der neue describe-Block enthaelt mindestens 8 `it(`-Faelle + - Ein Testfall belegt HTTP-400-Semantik: der Block referenziert `BadRequestException` mindestens einmal + + Der Name einer importierten Gruppe ist serverseitig gesperrt, `internalName` ist setz- und loeschbar mit Leerstring-Normalisierung auf `null`, `listForTenant` liefert das Feld aus, und `UpdateGroupDto` bietet keinen Weg mehr, eine AD-Bindung zu setzen. + + + + Task 3: Anzeigename mit Fallback im Benutzer-Detail (D-04, UI-SPEC Surface Contract 6) + apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts + + - `apps/api/src/groups/module-grants.service.ts` — `getUserAccess()` Zeilen 215-278, insbesondere die vier parallelen Queries (220-241) und die beiden Projektionsstellen (`names.push(...)` bei ~249 und `name: m.group.name` bei ~264) + - `apps/api/src/groups/module-grants.service.spec.ts` — bestehende Mock-Struktur fuer `getUserAccess` + - `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Surface Contract 6 (kein Frontend-Diff, der Fallback sitzt serverseitig) + + + - Gruppe mit gesetztem `internalName` → beide Projektionen liefern den internen Namen + - Gruppe ohne `internalName` (null) → beide Projektionen liefern `name` + - Gruppe mit `internalName` aus reinen Leerzeichen kommt in dieser Schicht nicht vor, weil Task 2 solche Werte auf `null` normalisiert; der Fallback prueft dennoch auf `null`/`undefined` und nicht auf Truthiness + - Die alphabetische Sortierung der `groups`-Chips laeuft ueber den **angezeigten** Namen, nicht mehr ueber `name` + + +In `apps/api/src/groups/module-grants.service.ts`, Methode `getUserAccess()`: + +1. Die Mitgliedschafts-Query selektiert heute ausdruecklich nur `{ id: true, name: true }` fuer die eingebundene Gruppe — ergaenze `internalName: true`. Ohne diese Ergaenzung ist das Feld zur Laufzeit `undefined` und der Fallback greift stillschweigend immer auf `name`. Die `groupGrants`-Query nutzt `include: { group: true }` und liefert die Spalte bereits mit; hier ist keine Aenderung noetig, aber pruefe das beim Lesen gegen. +2. An der Projektionsstelle fuer die `viaGroups`-Namen den gepushten Wert auf den internen Namen mit Rueckfall auf den gespeicherten Namen umstellen. +3. An der Projektionsstelle fuer die Mitgliedschafts-Chips dasselbe fuer das `name`-Feld des zurueckgegebenen Objekts. Da die anschliessende Sortierung ueber genau dieses Feld laeuft, sortiert sie damit automatisch ueber den angezeigten Namen — das ist gewollt und der Unterschied zu `GroupsService.listForTenant()`, das weiterhin ueber die Datenbankspalte sortiert. +4. Verwende an beiden Stellen die Nullish-Variante, nicht die Oder-Variante — ein leerer String soll hier nicht stillschweigend zu `name` zurueckfallen, sondern gar nicht erst ankommen (Task 2 normalisiert ihn zu `null`). Wuerde diese Schicht auf Truthiness pruefen, verstuende sie die Normalisierung nachtraeglich um. + +Ergaenze in `apps/api/src/groups/module-grants.service.spec.ts` einen describe-Block `ModuleGrantsService.getUserAccess — Anzeigename mit Fallback (D-04)` mit je einem Fall fuer gesetzten und nicht gesetzten internen Namen, der beide Projektionen (`groups[].name` und `modules[].viaGroups`) in derselben Antwort prueft, sowie einem Fall, der die Sortierung ueber den angezeigten Namen belegt. + + + cd apps/api && npx vitest run src/groups/module-grants.service.spec.ts + cd apps/api && npx vitest run + + + - `grep -c 'internalName' apps/api/src/groups/module-grants.service.ts` ist >= 3 (Select plus beide Projektionsstellen) + - `awk '/async getUserAccess/,/^ }$/' apps/api/src/groups/module-grants.service.ts | grep -c '??'` ist >= 2 + - Keine Truthiness-Variante an den beiden Stellen: `awk '/async getUserAccess/,/^ }$/' apps/api/src/groups/module-grants.service.ts | grep -c 'group.internalName ||'` ergibt 0 + - `cd apps/api && npx vitest run src/groups/module-grants.service.spec.ts` ist gruen mit mindestens 3 neuen Faellen + - `cd apps/api && npx vitest run` ist vollstaendig gruen + - Keine Datei unter `apps/web/` wurde in diesem Task geaendert (UI-SPEC Surface Contract 6): `git diff --name-only HEAD -- apps/web | wc -l` ergibt 0 + + Die Gruppen-Chips und die viaGroups-Namen im Benutzer-Detail zeigen den internen Namen, sobald einer gesetzt ist, und sonst den gespeicherten Namen — ohne eine einzige Zeile Frontend-Aenderung. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → API (`PATCH /groups/:id`) | Admin-gelieferter Body mit `name`, `internalName`, `isDefault`; die Gruppen-ID stammt aus dem Pfad | +| API → PostgreSQL | Schreibpfade auf `Group` unter RLS und unter dem partiellen Unique-Index `Group_one_default_per_tenant` | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-16-06 | Elevation of Privilege | `GroupsService.update`, `reassignDefaultBeforeDelete` | high | mitigate | Jede Gruppen-Lookup-Query filtert zusaetzlich auf `tenantId` (bestehendes `findOwned`-Muster); `reassignDefaultBeforeDelete` nutzt bewusst eine eigene, nicht werfende Variante desselben Filters, verzichtet aber nicht auf den `tenantId`-Teil | +| T-16-07 | Tampering | Namenssperre in `update()` | high | mitigate | Die Sperre ist eine Backend-Invariante (`BadRequestException`), nicht eine deaktivierte Eingabe im Browser — ein direkter API-Aufruf kommt an ihr nicht vorbei (RESEARCH.md Open Question 1) | +| T-16-04 | Tampering | Standardmarkierungs-Transaktion | medium | mitigate | Partieller Unique-Index `Group_one_default_per_tenant` als DB-seitiges zweites Netz; ein daraus resultierender P2002 wird als "hat sich schon jemand anderes gekuemmert" behandelt statt geworfen (Muster aus `ensureDefaultGroup()`) | +| T-16-08 | Information Disclosure | Ausgabe von `internalName` in `listForTenant`/`getUserAccess` | low | accept | `internalName` ist ein admin-gepflegtes Anzeigefeld ohne Geheimnischarakter; es verlaesst den Mandanten nicht, weil beide Methoden bereits mandantengefiltert abfragen | +| T-16-SC | Tampering | Paketinstallation | low | accept | Keine neuen Pakete in diesem Plan | + + + +1. `cd apps/api && npx vitest run` — vollstaendig gruen +2. `cd apps/api && npx tsc --noEmit` — fehlerfrei +3. Manuelle Gegenprobe der Namenssperre (optional, ohne Deploy): ein `PATCH /groups/:id` mit `{ "name": "X" }` gegen eine Gruppe mit gesetztem `ldapObjectGuid` antwortet mit 400 +4. `apps/web/` bleibt in diesem Plan unveraendert + + + +- Der Name einer importierten Gruppe kann ueber die API nicht geaendert werden (D-03) +- `internalName` ist setz-, aenderbar und loeschbar, wird auf `null` normalisiert statt leer gespeichert (D-04) +- `listForTenant` liefert `internalName`; `getUserAccess` liefert den Anzeigenamen mit Fallback +- `reassignDefaultBeforeDelete` steht als deterministischer, nicht werfender Baustein fuer Plan 16-03 bereit (D-06) +- Kein Codepfad setzt mehr eine AD-Bindung auf eine bestehende Gruppe (D-07, Backend-Haelfte) + + + +Create `.planning/phases/16-ad-gruppen-synchronisation/16-02-SUMMARY.md` when done + diff --git a/.planning/phases/16-ad-gruppen-synchronisation/16-03-PLAN.md b/.planning/phases/16-ad-gruppen-synchronisation/16-03-PLAN.md new file mode 100644 index 0000000..1fb68f7 --- /dev/null +++ b/.planning/phases/16-ad-gruppen-synchronisation/16-03-PLAN.md @@ -0,0 +1,259 @@ +--- +phase: 16-ad-gruppen-synchronisation +plan: 03 +type: execute +wave: 3 +depends_on: [16-01, 16-02] +files_modified: + - apps/api/src/ldap/ldap.service.ts + - apps/api/src/ldap/ldap.module.ts + - apps/api/src/ldap/ldap.service.spec.ts +autonomous: true +requirements: [PERM-02] + +estimate: + tokens: 58000 + raw_tokens: 58000 + tasks: 2 + confidence: low + +must_haves: + truths: + - "Wird eine importierte Gruppe im AD umbenannt, zieht der naechste Sync-Lauf Group.name und Group.ldapDn nach; Mitgliedschaften und Modulfreigaben bleiben erhalten (SC-3, D-03)." + - "Verschwindet eine importierte Gruppe aus dem AD, loescht der Sync die Tessera-Gruppe; Mitgliedschaften und Modulfreigaben fallen ueber die bestehenden onDelete-Cascade-Regeln mit (SC-4, D-05)." + - "Gruppen ohne AD-Bindung werden vom Gruppen-Sync in keiner Query beruehrt (SC-5)." + - "syncBoundGroupsForTenant laeuft im selben Durchlauf VOR syncGroupMembershipsForTenant — der Mitgliedschafts-Abgleich arbeitet damit immer mit dem bereits aktualisierten ldapDn (RESEARCH.md Pitfall 1)." + - "War die geloeschte Gruppe die markierte Standardgruppe, wandert die Markierung vor der Loeschung weiter; nach jeder Loeschung stellt der Lauf ueber ensureDefaultGroup sicher, dass der Mandant nicht ohne Gruppe dasteht (D-06)." + - "Bestehende Gruppen mit gesetztem ldapDn, aber ohne ldapObjectGuid (Alt-Bindungen aus Plan 15-06) erhalten ihren Identitaetsschluessel im ersten Lauf nachtraeglich und werden danach regulaer verwaltet (D-07)." + - "Ein Mandant ohne Gruppe mit gesetztem ldapObjectGuid loest keine einzige zusaetzliche LDAP-Suche aus." + - "Der gespeicherte Hex-Wert wird vor dem Existenz-Sweep in einen Buffer zurueckgewandelt und byteweise escaped; ein Wert, der nicht aus 32 Hex-Zeichen besteht, erzeugt eine Fehlerzeile statt einer Filter-Interpolation." + - "Ein zweiter Sync-Lauf ueber unveraenderten AD-Bestand ist ein No-Op: groupsRenamed und groupsDeleted bleiben 0, es entsteht keine Schreiboperation auf Group." + - "Zwei importierte Gruppen, von denen im AD nur eine verschwindet, fuehren zu genau einer Loeschung; die verbleibende bleibt samt Mitgliedschaften und Modulfreigaben unangetastet." + - "Verschwindet die einzige Gruppe eines Mandanten, ruft der Lauf nach der Loeschung ensureDefaultGroup(tenantId) und der Mandant hat wieder genau eine markierte Standardgruppe." + - "Die Reihenfolge, in der gebundene Gruppen abgearbeitet werden, hat keinen Einfluss auf das Ergebnis: der Standardmarkierungs-Handoff laeuft je Gruppe unmittelbar vor deren Loeschung, nicht als Sammelschritt am Ende." + - "Eine bereits geloeschte Gruppe taucht im Folgelauf nicht mehr in der Kandidatenliste auf; groupsDeleted bleibt dort 0." + - "Eine lokale Gruppe, die exakt denselben Namen traegt wie eine importierte, wird nicht angefasst — die Kandidatenauswahl laeuft ueber die AD-Bindung, nie ueber den Namen." + - "Ein Mandant mit ausschliesslich lokalen Gruppen durchlaeuft den Gruppen-Sync ohne eine einzige Schreiboperation." + - "Lokale Gruppen erscheinen in keiner Verarbeitungsreihenfolge, weil sie die Kandidaten-Bedingung nicht erfuellen." + - "Beliebig viele Sync-Laeufe hintereinander lassen jede lokale Gruppe unveraendert." + - "Ein gleichzeitiger manueller Eingriff an einer lokalen Gruppe kollidiert nicht mit dem Gruppen-Sync, weil dieser lokale Gruppen in keiner Query beruehrt." + - "Ein Fehler bei einer einzelnen Gruppe stoppt den Lauf nicht: er landet als eigene Zeile in result.errors und die uebrigen Gruppen werden weiterverarbeitet." + - statement: "Ein zweiter, gleichzeitig laufender Sync-Lauf fuer denselben Mandanten kann keine doppelte Umbenennung erzeugen, weil das Update ein idempotentes Setzen von name und ldapDn auf den aktuellen AD-Stand ist." + verification: backstop + - statement: "Loescht ein Admin dieselbe Gruppe gleichzeitig ueber die Oberflaeche, faengt der Sync den P2025 der ins Leere laufenden Loeschung ab und zaehlt sie nicht doppelt." + verification: backstop + - statement: "objectGUID bleibt ueber eine reine Umbenennung im AD unveraendert (RESEARCH.md Annahme A1) und die byteweise Escaping-Syntax liefert gegen ein echtes Active Directory Treffer (Annahme A2). Beide werden read-only gegen ViCoTest geprueft, bevor die Loeschsemantik als produktionsreif gilt." + verification: backstop + artifacts: + - "apps/api/src/ldap/ldap.service.ts — LdapSyncResult um groupsAdopted, groupsRenamed, groupsDeleted, defaultMarkerMoved erweitert; neue private Methode syncBoundGroupsForTenant()" + - "apps/api/src/ldap/ldap.module.ts — GroupsModule importiert" + - "apps/api/src/ldap/ldap.service.spec.ts — describe-Bloecke fuer Rename, Delete, Alt-Bindungs-Nachtrag, Default-Handoff, lokale Gruppen und Schrittreihenfolge" + key_links: + - "syncBoundGroupsForTenant() → syncGroupMembershipsForTenant(): die Reihenfolge ist die zentrale Korrektheitsbedingung dieser Phase — falsch herum wird eine AD-Umbenennung als Mitgliederverlust protokolliert" + - "syncBoundGroupsForTenant() → GroupsService.reassignDefaultBeforeDelete() → group.delete() → GroupsService.ensureDefaultGroup(): fehlt ein Glied, bleibt ein Mandant ohne Standardgruppe bis zum naechsten API-Neustart" + - "Group.ldapObjectGuid → binaerer AD-Filter: ohne diesen Schluessel ist eine Umbenennung nicht von einem Verschwinden unterscheidbar" + prohibitions: + - "Der Gruppen-Sync darf niemals eine Gruppe ohne AD-Bindung veraendern oder loeschen — auch nicht aufraeumend." + - "Der Sync darf internalName in keinem Codepfad schreiben, ueberschreiben oder leeren." + - "Der Sync darf eine Gruppe niemals loeschen, ohne sie ueber ihren stabilen Identitaetsschluessel zweifelsfrei als im Verzeichnis verschwunden nachgewiesen zu haben — eine fehlgeschlagene, abgebrochene oder unbeantwortete Suche ist kein Loeschgrund." + - "Eine sync-getriebene Loeschung darf nicht unsichtbar bleiben: jede geloeschte Gruppe und jede verschobene Standardmarkierung erscheint als Zahl im Sync-Bericht." + - "Es darf kein Zustand entstehen, in dem ein Mandant nach einem Sync-Lauf ohne markierte Standardgruppe dasteht." +--- + + +Der bestehende Benutzer-Sync fuehrt importierte Gruppen nach: eine Umbenennung im AD zieht in Tessera nach (SC-3, D-03), ein Verschwinden loescht die Tessera-Gruppe samt Mitgliedschaften und Modulfreigaben (SC-4, D-05), die Standardmarkierung wandert dabei weiter statt zu verschwinden (D-06), und lokal angelegte Gruppen bleiben unberuehrt (SC-5). + +Das ist der einzige wirklich neue Baustein der Phase: die Unterscheidung zwischen "im AD umbenannt" und "im AD verschwunden". Sie haengt vollstaendig am `ldapObjectGuid` aus Plan 16-01 — der DN allein enthaelt den CN und aendert sich bei jeder Umbenennung. + +Purpose: Ohne diesen Plan ist der Import aus 16-01 eine Einbahnstrasse — Gruppen entstehen, veralten aber ab dem ersten AD-Wechsel. + +Output: `syncBoundGroupsForTenant()`, erweiterte `LdapSyncResult`-Felder, die Einhaengung als Schritt 5a vor dem bestehenden Mitgliedschafts-Abgleich, und die zugehoerigen Tests. + + + +Neu entstehende Symbole in diesem Plan: + +| Art | Symbol | +|-----|--------| +| Service-Methode | `LdapService.syncBoundGroupsForTenant(client, config, tenantId, result)` (private) | +| Result-Felder | `LdapSyncResult.groupsAdopted`, `.groupsRenamed`, `.groupsDeleted`, `.defaultMarkerMoved` | +| Konstruktor-Parameter | `LdapService(prisma, userService, groupsService)` | +| Modul-Import | `GroupsModule` in `LdapModule` | + +Aus Plan 16-01 uebernommen: `Group.ldapObjectGuid`, `LdapService.escapeLdapFilterBuffer()`. +Aus Plan 16-02 uebernommen: `GroupsService.reassignDefaultBeforeDelete()`, `DEFAULT_GROUP_NAME`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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-01-SUMMARY.md +@.planning/phases/16-ad-gruppen-synchronisation/16-02-SUMMARY.md + + + +- **Alt-Bindungen (D-07-Anschluss):** Gruppen mit gesetztem `ldapDn`, aber ohne `ldapObjectGuid` stammen aus der in 15-06 gebauten Radio-Auswahl. D-07 sagt ausdruecklich, dass sie als importiert gelten und vom Sync verwaltet werden. Der Lauf traegt ihren Identitaetsschluessel deshalb einmalig nach (base-scoped Suche auf dem gespeicherten DN). Loest der DN nicht mehr auf, wird die Gruppe **nicht** geloescht — ohne stabilen Schluessel ist Umbenennung nicht von Verschwinden unterscheidbar, und eine Loeschung waere hier eine Vermutung, kein Nachweis. Stattdessen eine Fehlerzeile. +- **Berichtsfeld `groupsAdopted` statt `groupsImported` — bewusste Abweichung von der UI-SPEC.** Die UI-SPEC (Copywriting Contract, Sync-Bericht Zeile 3) nennt das Feld `groupsImported` mit dem Wort "importiert". Der Sync **importiert** aber per D-02 nie eine Gruppe — Import ist ausschliesslich die ausdrueckliche Admin-Auswahl aus Plan 16-01. Ein Berichtsfeld, das nach jedem Lauf zwingend 0 zeigt, ist dauerhaft bedeutungslos. Das Feld heisst deshalb `groupsAdopted` und zaehlt, wie viele Alt-Bindungen der Lauf neu unter Verwaltung genommen hat. Die zugehoerige Textanpassung erfolgt in Plan 16-05. Diese Abweichung ist hier ausdruecklich vermerkt, nicht stillschweigend vollzogen. +- **`ensureDefaultGroup` einmal am Ende**, nicht nach jeder einzelnen Loeschung: die Methode ist idempotent und kehrt bei vorhandenen Gruppen sofort zurueck; ein Aufruf pro Loeschung waere reine Zusatzlast ohne Verhaltensunterschied. + + + + + + Task 1: Gruppen-Rekonziliation gegen das Verzeichnis (SC-3, SC-4, SC-5, D-05, D-06) + apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.module.ts, apps/api/src/ldap/ldap.service.spec.ts + + - `apps/api/src/ldap/ldap.service.ts` — `LdapSyncResult` (Zeilen 9-20), Konstruktor (86-92), `parseBaseDns`/`buildClientOptions`-Nutzung, `syncGroupMembershipsForTenant()` vollstaendig (Zeilen 820-935) als strukturelles Vorbild, `escapeLdapFilterValue()` (977-984) und der in 16-01 angelegte `escapeLdapFilterBuffer()` + - `apps/api/src/ldap/ldap.module.ts` — aktuelle Imports/Provider + - `apps/api/src/ldap/ldap.service.spec.ts` — Datei-Kopf mit den ldapts- und forTenant-Mocks (Zeilen 1-24), die sieben `new LdapService(...)`-Konstruktionen, und der bestehende D-21-Block ab Zeile 518 als naechstes Vorbild + - `apps/api/src/groups/groups.service.ts` — `reassignDefaultBeforeDelete()` und `ensureDefaultGroup()` in der nach Plan 16-02 gueltigen Fassung + - `apps/api/src/groups/groups.module.ts` und `apps/api/src/user/user.module.ts` — belegen, dass `GroupsModule` nichts importiert und ein direkter Import aus `LdapModule` keinen Zyklus erzeugt + - `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Pattern 2 und 3, Pitfalls 1, 2, 4, 5, Anti-Patterns + - `.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md` — Abschnitt `syncBoundGroupsForTenant()` + + + - Kandidat mit AD-Treffer, `cn` unveraendert und DN unveraendert → keine Schreiboperation, keine Zaehler steigen + - Kandidat mit AD-Treffer und geaendertem `cn`/DN → `name` und `ldapDn` werden auf den AD-Stand gesetzt, `groupsRenamed` steigt, `internalName` bleibt unveraendert + - Kandidat ohne AD-Treffer, nicht Standardgruppe → Gruppe wird geloescht, `groupsDeleted` steigt + - Kandidat ohne AD-Treffer, ist Standardgruppe, andere Gruppe vorhanden → Markierung wandert, `defaultMarkerMoved` steigt, danach Loeschung + - Kandidat ohne AD-Treffer, ist einzige Gruppe des Mandanten → Loeschung, danach legt `ensureDefaultGroup` die Standardgruppe neu an + - Umbenennung mit einem Namen, der bereits von einer anderen Gruppe belegt ist → `P2002` wird abgefangen, Fehlerzeile, Gruppe bleibt unveraendert, der Lauf geht weiter + - Gruppe mit `ldapDn`, ohne `ldapObjectGuid`, DN loest auf → GUID wird nachgetragen, `groupsAdopted` steigt, die Gruppe wird im selben Lauf regulaer weiterverarbeitet + - Gruppe mit `ldapDn`, ohne `ldapObjectGuid`, DN loest nicht auf → **keine** Loeschung, Fehlerzeile + - Mandant ohne einen einzigen Kandidaten → sofortige Rueckkehr, kein `client.search`-Aufruf + - Gruppe mit `ldapObjectGuid: null` und `ldapDn: null` (rein lokal) → kommt in keiner Query vor + - Eine `client.search`-Ausnahme bei einer Gruppe → Fehlerzeile mit dem Gruppennamen, die uebrigen Gruppen werden weiterverarbeitet + + +**Verdrahtung.** `LdapModule` importiert zusaetzlich `GroupsModule`. `LdapService` bekommt `private groupsService: GroupsService` als dritten Konstruktor-Parameter. Alle sieben `new LdapService(...)`-Konstruktionen in `ldap.service.spec.ts` erhalten einen dritten Mock-Parameter; die beiden Stellen, die heute `{} as any` uebergeben, bekommen einen dritten `{} as any`. + +**Result-Felder.** `LdapSyncResult` waechst additiv um `groupsAdopted: number`, `groupsRenamed: number`, `groupsDeleted: number`, `defaultMarkerMoved: number` — exakt das Muster, das D-21 fuer die beiden Mitgliedschafts-Zaehler bereits benutzt hat. Alle Initialisierungsstellen des Result-Objekts auf 0 ergaenzen. + +**Neue private Methode** `syncBoundGroupsForTenant(client, config, tenantId, result)`. Sie nutzt den bereits gebundenen Client des laufenden Sync — kein zweiter Verbindungsaufbau. + +1. `const tenantPrisma = forTenant(this.prisma, tenantId) as any` — jeder Schreibzugriff laeuft hierueber. Ein ungescopter Zugriff waere ein Fehler: `Group` traegt `FORCE ROW LEVEL SECURITY`. +2. Kandidaten laden: `group.findMany({ where: { tenantId, OR: [{ ldapObjectGuid: { not: null } }, { ldapDn: { not: null } }] }, select: { id: true, name: true, ldapDn: true, ldapObjectGuid: true, isDefault: true } })`. Die `OR`-Form ist der D-07-Anschluss fuer Alt-Bindungen; rein lokale Gruppen erfuellen keinen der beiden Zweige. Leeres Ergebnis → sofortiges `return`, ohne jede LDAP-Suche. +3. `const baseDns = this.parseBaseDns(config.baseDn)`. +4. Pro Kandidat ein eigenes `try/catch` nach dem Vorbild der Schleife in `syncGroupMembershipsForTenant`; der `catch`-Zweig haengt `Gruppe : ` an `result.errors` und faehrt fort. Ein einzelner Fehler beendet den Lauf nie. +5. **Nachtrag fuer Alt-Bindungen:** ist `ldapObjectGuid` leer und `ldapDn` gesetzt, eine base-scoped Suche auf genau diesem DN (`scope: 'base'`, `filter: '(objectClass=group)'`, `attributes: ['cn','dn','objectGUID']`, `explicitBufferAttributes: ['objectGUID']`). Treffer → Hex-GUID schreiben, `groupsAdopted` erhoehen, mit dem nachgetragenen Wert weiterarbeiten. Kein Treffer → Fehlerzeile mit dem Hinweis, dass die Bindung nicht aufgeloest werden konnte, und `continue`. Hier wird **nicht** geloescht: ohne stabilen Schluessel waere die Loeschung eine Vermutung. +6. **Existenz-Sweep:** den gespeicherten Hex-Wert validieren (genau 32 Zeichen aus `[0-9a-f]`); scheitert das, Fehlerzeile und `continue` — ein unsauberer Wert darf nicht in einen Filter geraten. Sonst per `Buffer.from(hex, 'hex')` zuruecklesen, mit `LdapService.escapeLdapFilterBuffer()` byteweise escapen und daraus den Filter `(objectGUID=)` bauen. Ueber alle Base-DNs suchen (`scope: 'sub'`, `attributes: ['cn','dn']`) und beim ersten Treffer abbrechen. +7. **Treffer-Zweig (SC-3):** `cn` aufloesen (Array-Form wie in `mapEntry` beruecksichtigen). Weichen `name` oder `ldapDn` vom AD-Stand ab, per `tenantPrisma.group.update({ where: { id }, data: { name, ldapDn } })` nachziehen und `groupsRenamed` erhoehen. `internalName` erscheint in keinem `data`-Objekt dieser Methode — das ist die Durchsetzung von D-04 im Sync. Einen `P2002` aus einer Namenskollision abfangen, als Fehlerzeile sammeln und die Gruppe unveraendert lassen; `GroupsService.update()` wird bewusst nicht aufgerufen, dessen `ConflictException` wuerde den Batch abbrechen (RESEARCH.md Pitfall 4). +8. **Kein-Treffer-Zweig (SC-4, D-05, D-06):** zuerst `await this.groupsService.reassignDefaultBeforeDelete(tenantId, group.id)`; liefert sie `true`, `defaultMarkerMoved` erhoehen. Dann `tenantPrisma.group.delete({ where: { id: group.id } })` und `groupsDeleted` erhoehen. Mitgliedschaften und Modulfreigaben fallen ueber die bestehenden Cascade-Regeln aus 15-01 mit — kein eigener Aufraeumcode, keine zweite Wahrheit ueber das Loeschverhalten. Einen `P2025` (Zeile bereits weg, etwa durch eine gleichzeitige manuelle Loeschung) abfangen und den Zaehler dann nicht erhoehen. Merke dir in einer lokalen Variable, dass mindestens eine Loeschung stattgefunden hat. +9. **Nach der Schleife:** hat mindestens eine Loeschung stattgefunden, `await this.groupsService.ensureDefaultGroup(tenantId)` aufrufen. Das schliesst die in RESEARCH.md Pitfall 5 beschriebene Luecke — bisher laeuft diese Methode nur bei Mandanten-Anlage und beim API-Start, ein Mandant bliebe sonst bis zum naechsten Neustart ohne jede Gruppe. Die Methode ist idempotent und kehrt bei vorhandenen Gruppen sofort zurueck. + +**Tests.** Neuer describe-Block `LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verzeichnis (SC-3/SC-4/SC-5, D-05/D-06)` in `apps/api/src/ldap/ldap.service.spec.ts`, aufgebaut wie der bestehende D-21-Block ab Zeile 518. Je ein `it(` pro Zeile der ``-Liste. Der `groupsService`-Mock traegt `reassignDefaultBeforeDelete` und `ensureDefaultGroup` als `vi.fn()`. Ein zusaetzlicher Fall belegt die Idempotenz: derselbe Mock-AD-Zustand zweimal hintereinander verarbeitet, beim zweiten Lauf keine einzige `group.update`- oder `group.delete`-Aufrufung. + + + cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts + cd apps/api && npx tsc --noEmit + + + - `grep -q 'private async syncBoundGroupsForTenant' apps/api/src/ldap/ldap.service.ts` trifft + - Die neue Methode schreibt `internalName` in keinem Datensatz: `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'internalName'` ergibt 0 + - Der Loesch-Zweig ruft den Handoff vor der Loeschung: in der Methode steht `reassignDefaultBeforeDelete` in einer frueheren Zeile als das zugehoerige `group.delete` — pruefbar mit `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -n 'reassignDefaultBeforeDelete\|group.delete'` + - `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'ensureDefaultGroup'` ist >= 1 + - `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'escapeLdapFilterBuffer'` ist >= 1 + - `awk '/private async syncBoundGroupsForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'forTenant\|tenantPrisma'` ist >= 3 + - `grep -c 'groupsAdopted\|groupsRenamed\|groupsDeleted\|defaultMarkerMoved' apps/api/src/ldap/ldap.service.ts` ist >= 8 (Interface plus Initialisierung plus Zaehler) + - `grep -q 'GroupsModule' apps/api/src/ldap/ldap.module.ts` trifft + - `cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts` ist gruen und der neue describe-Block enthaelt mindestens 11 `it(`-Faelle + + `syncBoundGroupsForTenant` erkennt Umbenennung, Verschwinden und Alt-Bindung, verschiebt die Standardmarkierung vor jeder Loeschung, baut die Standardgruppe bei Bedarf neu auf, laesst lokale Gruppen und interne Namen unberuehrt, und ist mit mindestens elf Faellen abgedeckt. + + + + Task 2: Einhaengen als Schritt 5a vor dem Mitgliedschafts-Abgleich (RESEARCH.md Pitfall 1) + Ein erreichbarer Active-Directory-Testserver (ViCoTest, `balios.ctl.local`) steht fuer die read-only Verifikation der Annahmen A1/A2 zur Verfuegung; ohne ihn bleibt die Loeschsemantik aus D-05 als nicht produktionsverifiziert markiert. + apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts + + - `apps/api/src/ldap/ldap.service.ts` — `syncUsersForTenant()` Zeilen 564-735, insbesondere der Deaktivierungs-Schritt 5 (685-702), der bestehende Aufruf von `syncGroupMembershipsForTenant` als Schritt 5b (705-715) und die abschliessende `lastSyncAt`-Aktualisierung + - `apps/api/src/ldap/ldap.service.spec.ts` — der D-21-Block ab Zeile 518, der bereits einen vollstaendigen `syncUsersForTenant`-Durchlauf mockt + - `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Pitfall 1 und der Abschnitt "Anti-Patterns to Avoid" + - `.planning/phases/16-ad-gruppen-synchronisation/16-VALIDATION.md` — Abschnitt "Manual-Only Verifications" + + + - In einem vollstaendigen `syncUsersForTenant`-Durchlauf wird die Gruppen-Rekonziliation aufgerufen, bevor der Mitgliedschafts-Abgleich aufgerufen wird + - Nach einer im selben Lauf erkannten Umbenennung arbeitet der Mitgliedschafts-Abgleich mit dem NEUEN DN — der Suchfilter enthaelt nicht mehr den alten + - Der Aufruf sitzt hinter demselben Base-DN-No-Op-Waechter wie der restliche Sync: ein Mandant mit leerer Base-DN-Liste loest keinen Gruppen-Sync aus + - Ein Fehler in der Gruppen-Rekonziliation verhindert nicht, dass `lastSyncAt` aktualisiert wird — das Fehlerbild landet in `result.errors`, nicht in einem abgebrochenen Lauf + + +Fuege in `syncUsersForTenant()` unmittelbar **vor** dem bestehenden Aufruf von `syncGroupMembershipsForTenant` (heute als Kommentar "5b" markiert) einen neuen Schritt ein: `await this.syncBoundGroupsForTenant(client, config, tenantId, result);` samt Kommentarblock, der die Reihenfolge begruendet. + +Diese Reihenfolge ist die zentrale Korrektheitsbedingung der Phase, kein Stilfrage. Der Mitgliedschafts-Abgleich liest `group.ldapDn` aus der Datenbank und baut daraus den `memberOf`-Suchfilter. Steht dort nach einer AD-Umbenennung noch der alte Wert, liefert das Verzeichnis keinen einzigen Treffer mehr, und saemtliche verzeichnisgestuetzten Mitgliedschaften der umbenannten Gruppe wuerden faelschlich als entfernt behandelt — der Sync-Bericht meldete dann einen Mitgliederverlust, den im AD niemand ausgeloest hat. + +Der neue Aufruf sitzt innerhalb desselben `try`-Blocks und damit hinter dem bereits vorhandenen Base-DN-No-Op-Waechter; ein Mandant ohne konfigurierte Base-DN loest weiterhin gar nichts aus. + +Ergaenze im bestehenden D-21-Testblock (bzw. als eigener describe-Block `LdapService.syncUsersForTenant — Schrittreihenfolge Gruppen vor Mitgliedschaften`) einen Testfall, der die Reihenfolge **beobachtbar** belegt statt sie nur zu behaupten: eine gemeinsame Aufrufprotokoll-Liste, in die beide Schritte ihren Namen schreiben (etwa ueber `mockImplementation` auf `group.findMany` mit unterscheidbaren `where`-Formen oder ueber ein `vi.spyOn` auf beide privaten Methoden), und eine Assertion auf die Reihenfolge der Eintraege. Ein zweiter Fall bildet den Regressionsfall ab: AD liefert fuer die Gruppe einen neuen DN; nach dem Lauf enthaelt keiner der an `client.search` uebergebenen `memberOf`-Filter den alten DN. + +**Live-Verifikation der Annahmen A1/A2.** RESEARCH.md markiert zwei Annahmen als nicht gegen ein echtes Verzeichnis geprueft: dass `objectGUID` eine reine Umbenennung unveraendert uebersteht (A1) und dass die byteweise Escaping-Syntax vom Ziel-AD als Filter akzeptiert wird (A2). Beide entscheiden darueber, ob die Loeschsemantik aus D-05 produktionssicher ist. Fuehre die Pruefung read-only gegen ViCoTest durch, wie in `16-VALIDATION.md` beschrieben: eine Gruppe suchen und ihren GUID notieren, im AD umbenennen, erneut suchen und den GUID vergleichen; danach eine Suche mit dem escaped Filter absetzen und pruefen, ob sie dieselbe Gruppe zurueckliefert. Kein Deploy, kein Docker-Eingriff auf dem Testserver. Halte das Ergebnis im SUMMARY fest. Faellt A1 oder A2 negativ aus, ist das ein Stopp-Grund fuer die Loeschsemantik — melde es, statt die Implementierung umzubiegen. + + + cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts + cd apps/api && npx vitest run + cd apps/api && npx tsc --noEmit + Read-only gegen ViCoTest (`balios.ctl.local`): (1) AD-Gruppe suchen, objectGUID notieren, Gruppe im AD umbenennen, erneut suchen — GUID identisch? (2) Suche mit dem byteweise escaped objectGUID-Filter absetzen — liefert sie genau diese Gruppe? Beide Antworten im SUMMARY festhalten. + + + - In `apps/api/src/ldap/ldap.service.ts` steht der Aufruf von `syncBoundGroupsForTenant` vor dem Aufruf von `syncGroupMembershipsForTenant`: `grep -n 'this.syncBoundGroupsForTenant\|this.syncGroupMembershipsForTenant' apps/api/src/ldap/ldap.service.ts` zeigt die Aufrufzeile der ersten Methode mit kleinerer Zeilennummer als die der zweiten + - Beide Aufrufe stehen innerhalb von `syncUsersForTenant`: `awk '/async syncUsersForTenant/,/^ }$/' apps/api/src/ldap/ldap.service.ts | grep -c 'this.syncBoundGroupsForTenant'` ergibt 1 + - Der neue Reihenfolge-Testfall existiert und ist gruen: `cd apps/api && npx vitest run src/ldap/ldap.service.spec.ts -t "Schrittreihenfolge"` liefert mindestens 2 bestandene Faelle + - `cd apps/api && npx vitest run` ist vollstaendig gruen + - `cd apps/api && npx tsc --noEmit` ist fehlerfrei + - Das SUMMARY enthaelt das Ergebnis der A1/A2-Live-Pruefung mit Datum und Ergebnis je Annahme + + Die Gruppen-Rekonziliation laeuft im selben Durchlauf vor dem Mitgliedschafts-Abgleich, die Reihenfolge ist durch einen beobachtenden Test abgesichert, ein Rename fuehrt nachweislich nicht mehr zu einem falschen Mitgliederverlust, und die beiden AD-Annahmen sind gegen ein echtes Verzeichnis geprueft. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| API → Active Directory | Gespeicherte GUID-/DN-Werte werden Teil eines LDAP-Suchfilters; AD-gelieferte `cn`/`dn` kommen ungeprueft zurueck und werden persistiert | +| API → PostgreSQL | Unbeaufsichtigte Schreib- und Loeschoperationen auf `Group` unter RLS; Loeschungen kaskadieren auf `GroupMembership` und `ModuleGrant` | +| Scheduler → API-interner Sync | Der Lauf startet ohne Benutzerkontext ueber den Cron aus `LdapSyncScheduler` | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-16-01 | Tampering | Binaerer `objectGUID`-Filter im Existenz-Sweep | high | mitigate | Der gespeicherte Wert wird vor jeder Verwendung gegen genau 32 Zeichen aus `[0-9a-f]` validiert und ausschliesslich ueber `escapeLdapFilterBuffer()` byteweise escaped interpoliert; ein nicht validierbarer Wert erzeugt eine Fehlerzeile statt eines Filters (ASVS V5) | +| T-16-02 | Elevation of Privilege | Alle Schreib-/Loeschpfade auf `Group` | high | mitigate | Jeder Zugriff laeuft ueber `forTenant(this.prisma, tenantId)`; `tenantId` stammt aus der Sync-Konfiguration des jeweiligen Mandanten, nie aus einem AD-gelieferten Wert. RLS mit `FORCE ROW LEVEL SECURITY` ist das zweite Netz (ASVS V1) | +| T-16-04 | Tampering | Standardmarkierungs-Handoff bei gleichzeitiger Admin-Aktion | high | mitigate | Der Handoff laeuft in der bestehenden Zwei-Schritt-Transaktion; der partielle Unique-Index `Group_one_default_per_tenant` ist das DB-seitige Netz, ein daraus resultierender `P2002` wird als "hat sich schon jemand anderes gekuemmert" behandelt statt geworfen | +| T-16-05 | Repudiation | Unbeaufsichtigte Loeschung entzieht Modulzugriff ohne den D-17-Warndialog | high | accept | **Bewusst akzeptiert laut D-05.** Der Warndialog greift ausschliesslich beim Loeschen ueber die Tessera-Oberflaeche. Einzige vorgesehene Massnahme ist die Sichtbarkeit im Sync-Bericht (`groupsDeleted`, `defaultMarkerMoved`) — es wird ausdruecklich **kein** technischer Schutz gebaut, der dieser Entscheidung widerspraeche | +| T-16-10 | Denial of Service | Ein fehlerhafter Gruppen-Datensatz bricht den gesamten Sync ab | medium | mitigate | Pro-Gruppe-`try/catch` mit gesammelten Fehlerzeilen; kein `GroupsService.create()`/`update()`-Aufruf, dessen HTTP-Exceptions den Batch beenden wuerden (RESEARCH.md Pitfall 4) | +| T-16-11 | Tampering | Loeschung auf Basis einer fehlgeschlagenen statt einer leeren Suche | high | mitigate | Der Loesch-Zweig wird ausschliesslich von einer erfolgreich beantworteten Suche mit null Treffern erreicht; eine geworfene Suche landet im `catch` und fuehrt zu einer Fehlerzeile, nie zu einer Loeschung. Alt-Bindungen ohne aufloesbaren DN werden ebenfalls nicht geloescht | +| T-16-SC | Tampering | Paketinstallation | low | accept | Keine neuen Pakete in diesem Plan | + + + +1. `cd apps/api && npx vitest run` — vollstaendig gruen +2. `cd apps/api && npx tsc --noEmit` — fehlerfrei +3. Reihenfolge-Test belegt beobachtbar, dass die Gruppen-Rekonziliation vor dem Mitgliedschafts-Abgleich laeuft +4. Live-Pruefung gegen ViCoTest (read-only) beantwortet A1 und A2; das Ergebnis steht im SUMMARY + + + +- Umbenennung im AD zieht in den Gruppennamen nach, Mitgliedschaften und Freigaben bleiben erhalten (SC-3) +- Verschwinden im AD entfernt die Tessera-Gruppe samt Mitgliedschaften und Modulfreigaben (SC-4, D-05) +- Manuell angelegte Gruppen bleiben unberuehrt (SC-5) +- Kein Mandant steht nach einem Sync-Lauf ohne markierte Standardgruppe da (D-06) +- Alt-Bindungen aus Plan 15-06 werden vom Sync verwaltet, ohne dass eine nicht aufloesbare Bindung zu einer Loeschung fuehrt (D-07) +- `internalName` wird von keinem Sync-Codepfad geschrieben (D-04) + + + +Create `.planning/phases/16-ad-gruppen-synchronisation/16-03-SUMMARY.md` when done + diff --git a/.planning/phases/16-ad-gruppen-synchronisation/16-04-PLAN.md b/.planning/phases/16-ad-gruppen-synchronisation/16-04-PLAN.md new file mode 100644 index 0000000..90f1878 --- /dev/null +++ b/.planning/phases/16-ad-gruppen-synchronisation/16-04-PLAN.md @@ -0,0 +1,255 @@ +--- +phase: 16-ad-gruppen-synchronisation +plan: 04 +type: execute +wave: 3 +depends_on: [16-01, 16-02] +files_modified: + - apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx + - apps/web/src/app/(portal)/admin/groups/page.tsx + - apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx + - apps/web/src/messages/de.json + - apps/web/src/messages/en.json +autonomous: true +requirements: [PERM-02] + +estimate: + tokens: 46000 + raw_tokens: 46000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "Der Gruppen-Dialog bietet keinen Weg mehr, eine lokal angelegte Gruppe an eine AD-Gruppe zu binden: die Radio-Auswahl, der Discovery-Aufruf, der Bindung-entfernen-Link und der zweistufige Anlegen-dann-Binden-Ablauf sind vollstaendig entfernt (D-07)." + - "Der Anlegen-Zustand sendet genau einen POST /groups und keinen Folge-PATCH." + - "Im Bearbeiten-Zustand einer importierten Gruppe ist das Namensfeld deaktiviert, zeigt den AD-Namen, traegt darunter den Herkunftshinweis und darueber hinaus eine reine Anzeigezeile mit dem AD-DN (D-03, D-04)." + - "Im Bearbeiten-Zustand einer importierten Gruppe gibt es ein editierbares Feld 'Interner Name'; gespeichert wird in diesem Zustand ausschliesslich internalName, nie name (D-04)." + - "Die Gruppenliste zeigt in der Namensspalte internalName ?? name und traegt den AD-Namen als title-Attribut zur Nachvollziehbarkeit (D-04)." + - "Die sechs verwaisten i18n-Schluessel unter admin.groups fuer die entfallene AD-Bindungsauswahl sind aus de.json UND en.json entfernt; kein Quelltext referenziert sie noch." + - "Ein fehlgeschlagenes Speichern zeigt eine sichtbare Fehlerzeile im Dialog; der Dialog bleibt offen und die Eingaben bleiben erhalten. Meldet der Server eine Namenskollision, erscheint stattdessen der dafuer vorgesehene Text." + - "Der Bearbeiten-Dialog unterscheidet lokale und importierte Gruppen ausschliesslich am gesetzten ldapDn; eine Gruppe genau an dieser Grenze — ldapDn gesetzt, Identitaetsschluessel noch nicht nachgetragen — gilt als importiert und zeigt das gesperrte Namensfeld." + - "Der Anlegen-Zustand oeffnet mit leerem Namensfeld und ohne jedes AD-Steuerelement; ein leerer Name wird wie bisher als Pflichtfeld abgelehnt." + - "Ein interner Name aus reinen Leerzeichen wird wie ein leeres Feld behandelt und fuehrt serverseitig zu null, nicht zu einem leeren Anzeigenamen." + - "Der Dialog rendert seine drei Zustaende allein aus dem group-Prop und dessen ldapDn; es gibt keinen Effekt mehr, der beim Oeffnen ein AD-Verzeichnis nachlaedt." + - "UI E3/empty: Bestehender Empty-State der Gruppenliste unveraendert — dieser Plan aendert nur den Textwert der Namensspalte." + - "UI E3/loading: Bestehender Ladezustand der Gruppenliste unveraendert." + - "UI E3/error: Bestehendes Fehlerbanner der Gruppenliste unveraendert." + - "UI E3/populated: Die Namensspalte zeigt internalName ?? name; die AD-Bindungs-Spalte traegt weiterhin ihr Badge und ist nach D-07 der alleinige Marker importiert-vs-lokal." + - "UI E3/partial: Ist internalName einer importierten Gruppe nicht gesetzt, greift der Fallback auf name — es gibt keinen Zustand mit leerem Namen." + - "UI E3/overflow: Bestehendes Tabellen-Scrollverhalten unveraendert; dieser Plan fuegt keine Spalte hinzu." + - "UI E3/zero-one-many: Abgedeckt durch Empty-State und Listenrendering, beide unveraendert." + - "UI E4/empty: Der Anlegen-Zustand oeffnet mit leerem Pflichtfeld Name und dem Hinweisabsatz, dass AD-Gruppen im LDAP-Bereich importiert werden, samt Link dorthin." + - "UI E4/loading: Bestehender saving-Zustand — der Speichern-Button traegt den Ladetext und ist deaktiviert." + - "UI E4/error: Ein fehlgeschlagenes Speichern zeigt eine text-sm text-destructive Zeile im Dialog; der Dialog bleibt offen, Eingaben bleiben erhalten; bei Namenskollision greift die dafuer vorgesehene Copy." + - "UI E4/partial: Im Bearbeiten-Zustand einer importierten Gruppe ist der interne Name optional leer; das gesperrte Namensfeld zeigt weiterhin den AD-Namen und die AD-DN-Zeile bleibt sichtbar." + - statement: "UI E3/long-text: Ein langer interner oder AD-Name in der Namensspalte sprengt die Tabellenzeile nicht." + verification: backstop + - statement: "UI E4/long-text: Ein sehr langer AD-Name im gesperrten Eingabefeld sprengt den max-w-md-Modal-Container nicht." + verification: backstop + artifacts: + - "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx — drei Zustaende ohne AD-Auswahl" + - "apps/web/src/app/(portal)/admin/groups/page.tsx — Group-Interface um internalName, Namenszelle mit Fallback und title" + - "apps/web/src/messages/de.json + en.json — neue Schluessel unter admin.groups, sechs verwaiste Schluessel entfernt" + - "apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx — Faelle fuer D-07, gesperrtes Namensfeld, internen Namen und Namensanzeige" + key_links: + - "GroupFormModal → PATCH /groups/:id mit ausschliesslich internalName fuer importierte Gruppen — sendet der Dialog weiterhin name, antwortet das Backend aus Plan 16-02 mit 400" + - "Group-Interface in groups/page.tsx → GroupFormModal: der Dialog importiert den Typ von dort, das Feld muss an der Quelle ergaenzt werden" + - "Entfernte i18n-Schluessel ↔ entfernter Dialogblock: bleibt ein Verweis stehen, faellt der Dialog zur Laufzeit auf einen fehlenden Schluessel" + prohibitions: + - "Die Oberflaeche darf keinen zweiten Weg anbieten, eine lokal angelegte Gruppe nachtraeglich an eine AD-Gruppe zu binden — weder als Formularfeld, noch als Link, noch als versteckter Request." + - "Das gesperrte Namensfeld darf nicht nur optisch deaktiviert sein: der Dialog darf in diesem Zustand kein name-Feld an den Server senden." + - "Der Dialog darf einen Fehlschlag beim Speichern nicht stumm verschlucken — der Benutzer muss sehen, dass nichts gespeichert wurde, und seine Eingaben behalten." + - "Die generische Admin-Oberflaeche darf keine kunden- oder firmenspezifischen Beispielnamen als Platzhalter im Feld fuer den internen Namen tragen." +--- + + +Der Gruppen-Dialog wird auf die Trennung aus D-07 zurueckgebaut: eine lokal angelegte Gruppe kann nicht mehr an eine AD-Gruppe gebunden werden, die in Plan 15-06 gebaute Radio-Auswahl verschwindet vollstaendig. An ihre Stelle treten die beiden Bausteine aus D-03 und D-04 — ein gesperrtes Namensfeld mit sichtbarer Herkunft fuer importierte Gruppen und ein frei editierbares Feld fuer den internen Namen, den der Sync nie anfasst. Die Gruppenliste zeigt ab hier den internen Namen, sobald einer gesetzt ist. + +Der Wortlaut aus CONTEXT.md: "Eine lokale Gruppe soll nicht mit einer AD verknuepft werden koennen. Das ist ja Quatsch" — die Trennung der beiden Gruppenarten soll in der Oberflaeche sichtbar sein. Nach diesem Plan ist das bestehende AD-Badge in der Bindungsspalte der alleinige Marker dafuer. + +Purpose: Zwei Wege zum selben Ergebnis sind laut D-07 eine Fehlerquelle. Der Dialog wird einfacher, nicht komplizierter. + +Output: Umgebauter `GroupFormModal`, Namensanzeige mit Fallback in der Gruppenliste, neue und bereinigte i18n-Schluessel, angepasste Komponententests. + + + +Neu entstehende Symbole in diesem Plan: + +| Art | Symbol | +|-----|--------| +| Typfeld | `Group.internalName?: string \| null` (`apps/web/src/app/(portal)/admin/groups/page.tsx`) | +| Frontend-State | `internalName` in `GroupFormModal` | +| i18n-Schluessel | `admin.groups.nameLockedHint`, `admin.groups.adDnLabel`, `admin.groups.internalName`, `admin.groups.internalNameHint`, `admin.groups.createLdapHint`, `admin.groups.goToLdap`, `admin.groups.saveErrorNameTaken` | +| Entfernte i18n-Schluessel | der komplette Unterbaum `admin.groups.ldapBind` mit seinen sechs Schluesseln, in de.json und en.json | + + + +Aus Plan 16-02 uebernommen und hier vorausgesetzt: `UpdateGroupDto.internalName`, die serverseitige Namenssperre, `internalName` in der Antwort von `GET /groups`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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-UI-SPEC.md +@.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md +@.planning/phases/16-ad-gruppen-synchronisation/16-02-SUMMARY.md + + + + + + Task 1: GroupFormModal auf drei Zustaende zurueckbauen (D-03, D-04, D-07) + apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json + + - `apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx` — vollstaendig (255 Zeilen); der zu entfernende Umfang ist das lokale Verzeichnis-Interface am Dateikopf, der gesamte Discovery-State samt Effekt und Filter-Memo, der komplette AD-Bindungs-Block im Formular und der Folge-PATCH im Anlegen-Zweig + - `apps/web/src/app/(portal)/admin/modules/grants/page.tsx` Zeilen 180-195 — der Link-Stil, der fuer den neuen Hinweis-Link gilt + - `apps/web/src/messages/de.json` Zeilen 375-400 und die gleichen Zeilen in `en.json` — der `admin.groups`-Block + - `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Surface Contract 4 und der Copywriting Contract + - `.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md` — Abschnitt `GroupFormModal.tsx` + + +Entferne aus `GroupFormModal.tsx` restlos alles, was zur AD-Bindungsauswahl gehoert: das lokal deklarierte Verzeichnis-Eintrags-Interface, den `ldapDn`-State, die vier Discovery-States, die Lade-Funktion samt `useEffect`, das gefilterte Memo, den kompletten Formularblock mit Radio-Liste, Suchfeld, Bindungsanzeige und Bindung-entfernen-Link, sowie den zweiten `PATCH`-Aufruf im Anlegen-Zweig von `handleSubmit`. Der `useCallback`-, `useEffect`- und `useMemo`-Import faellt damit ebenfalls weg, ebenso die `tLdap`-Uebersetzungsinstanz. Passe den Docblock der Komponente an: er beschreibt jetzt einen reinen Anlege-/Umbenennen-Dialog mit optionalem internem Namen, ohne AD-Auswahl. + +Neuer State: `internalName`, initialisiert aus `group?.internalName ?? ''`. Abgeleiteter Zustand `isImported = group?.ldapDn != null` — die Unterscheidung laeuft ueber die AD-Bindung, nicht ueber den Identitaetsschluessel, damit auch eine Alt-Bindung ohne nachgetragenen Schluessel sofort als importiert gilt. + +Drei Renderzustaende im selben Container (`max-w-md`, `fixed inset-0 bg-black/50`, unveraendert): + +**(a) Anlegen** (`group === null`): nur das bestehende Namensfeld-Markup, unveraendert und pflichtig. Darunter ein Hinweisabsatz in `text-sm text-muted-foreground` mit `admin.groups.createLdapHint` und einem Inline-Link `admin.groups.goToLdap` nach `/admin/ldap` im Stil `text-primary hover:underline`. `handleSubmit` reduziert sich in diesem Zweig auf einen einzelnen `POST /groups` mit `{ name }`. + +**(b) Bearbeiten, lokale Gruppe** (`group !== null && !isImported`): Namensfeld frei editierbar wie heute, **kein** Feld fuer den internen Namen — fuer eine lokale Gruppe ist der Name bereits der volle Anzeigename, ein zweites Namensfeld waere redundant und von keiner Entscheidung gedeckt. `handleSubmit` sendet `PATCH /groups/{id}` mit `{ name }`. + +**(c) Bearbeiten, importierte Gruppe** (`group !== null && isImported`): dasselbe Namensfeld-Markup, aber `disabled`, ohne `onChange`-Wirkung auf ein zu sendendes Feld, und mit dem AD-Namen als Wert; die bestehende `disabled:opacity-50`-Konvention macht den Zustand sichtbar. Darunter ein Hinweis in `text-xs text-muted-foreground` mit `admin.groups.nameLockedHint`. Darunter eine reine Anzeigezeile in `font-mono text-xs text-muted-foreground` mit `admin.groups.adDnLabel` und dem DN — kein Steuerelement, keine Aktion. Darunter das neue Feld "Interner Name" im identischen Eingabefeld-Markup wie das Namensfeld, mit Label `admin.groups.internalName`, ohne Platzhaltertext (insbesondere ohne firmenspezifisches Beispiel) und mit dem Hinweis `admin.groups.internalNameHint` darunter. `handleSubmit` sendet in diesem Zustand `PATCH /groups/{id}` mit ausschliesslich `{ internalName }` — niemals `name`, da das Feld gesperrt ist und das Backend aus Plan 16-02 ein `name` fuer importierte Gruppen mit 400 ablehnt. Ein leerer oder nur aus Leerzeichen bestehender Wert wird als `null` gesendet. + +Fehlerbehandlung: das bereits vorhandene Fehlerbanner bleibt und wird konsequent fuer alle drei Zustaende genutzt. Neu ist die Unterscheidung der Ursache — antwortet der Server mit Status 409, wird `admin.groups.saveErrorNameTaken` gesetzt, sonst `admin.groups.saveError`. Der Dialog schliesst in keinem Fehlerfall und setzt keine Eingabe zurueck. + +i18n: die sieben neuen Schluessel unter `admin.groups` in `de.json` und `en.json` anlegen, deutsche Texte woertlich aus dem Copywriting Contract, englische sinngleich. Die verwaisten Schluessel werden erst in Task 3 entfernt — bis dahin bleibt der Baum unverletzt, damit ein Zwischenstand keinen fehlenden Schluessel erzeugt. + + + cd apps/web && npx tsc --noEmit + cd apps/web && npx vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx" + node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');for(const k of ['nameLockedHint','adDnLabel','internalName','internalNameHint','createLdapHint','goToLdap','saveErrorNameTaken']){if(!de.admin.groups[k])throw new Error('de fehlt '+k);if(!en.admin.groups[k])throw new Error('en fehlt '+k)}" + + + - Die Komponente stellt keinen Verzeichnisabruf mehr: `grep -c "ldap/groups" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` ergibt 0 + - Kein Radio-Steuerelement mehr: `grep -c "type=\"radio\"" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` ergibt 0 + - Kein zweiter Schreibaufruf im Anlegen-Zweig: `grep -c "method: 'PATCH'" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` ergibt 1 + - `grep -c "internalName" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` ist >= 4 (State, Feld, Payload, Initialisierung) + - `grep -q "disabled" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` trifft und das Namensfeld traegt es zustandsabhaengig + - `grep -q "409" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` trifft (Kollisionsfall wird von anderen Fehlern unterschieden) + - `cd apps/web && npx tsc --noEmit` ist fehlerfrei + - Der Node-Schluesselcheck oben laeuft ohne Wurf durch + + Der Dialog kennt drei klar getrennte Zustaende, bietet in keinem davon einen Weg zur AD-Bindung, sperrt den Namen importierter Gruppen sichtbar, laesst den internen Namen editieren und zeigt jeden Speicherfehler mit unterscheidbarer Ursache an. + + + + Task 2: Namensanzeige mit Fallback in der Gruppenliste (D-04) + apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx + + - `apps/web/src/app/(portal)/admin/groups/page.tsx` — das exportierte `Group`-Interface (Zeilen 12-21), die Namenszelle und die Badge-Zelle (Zeilen 179-190), das Fehlerbanner (135-140), der Empty-/Ladezustand (141-145) + - `apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx` — die vorhandene Uebersetzungs-Stub-Mechanik und der Fall `renders a populated table with name, AD-binding badge, default star and member count` (ab Zeile 163) + - `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Surface Contract 3 und die E3-Zeilen im Abschnitt UI Considerations + + +Erweitere das exportierte `Group`-Interface in `apps/web/src/app/(portal)/admin/groups/page.tsx` um `internalName: string | null` — dieselbe Nullbarkeit wie `ldapDn`, damit `GroupFormModal` den Typ ohne Zusatzarbeit mitnutzt. Das Backend liefert das Feld seit Plan 16-02 in `GET /groups`. + +In der Namenszelle den gerenderten Wert auf den internen Namen mit Rueckfall auf den gespeicherten Namen umstellen und den umschliessenden Text in ein `` mit `title={group.name}` fassen — damit bleibt der AD-Name per Hover nachvollziehbar, auch wenn ein interner Name gesetzt ist. Verwende die Nullish-Variante, nicht die Oder-Variante: das Backend normalisiert leere Werte bereits auf `null`, und eine Truthiness-Pruefung wuerde diese Normalisierung nachtraeglich umdeuten. Die Zellklassen bleiben unveraendert. + +Die Badge-Zelle daneben bleibt vollstaendig unveraendert. Sie ist nach D-07 der alleinige visuelle Marker fuer "importiert vs. lokal"; es entsteht kein neues Badge, keine neue Farbe und keine neue Spalte. + +Ergaenze in `groups-page.test.tsx` zwei Faelle: eine Gruppe mit gesetztem internem Namen wird mit diesem angezeigt und traegt den AD-Namen als `title`; eine Gruppe ohne internen Namen wird mit ihrem gespeicherten Namen angezeigt. Beide Faelle nutzen die vorhandene Fetch- und Uebersetzungs-Stub-Mechanik der Datei. + + + cd apps/web && npx vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx" + cd apps/web && npx tsc --noEmit + + + - `grep -q "internalName: string | null" "apps/web/src/app/(portal)/admin/groups/page.tsx"` trifft + - Die Namenszelle nutzt den Fallback und den Tooltip: `grep -c "internalName ?? group.name" "apps/web/src/app/(portal)/admin/groups/page.tsx"` ist >= 1 und `grep -c "title={group.name}" "apps/web/src/app/(portal)/admin/groups/page.tsx"` ist >= 1 + - Keine Truthiness-Variante: `grep -c "internalName ||" "apps/web/src/app/(portal)/admin/groups/page.tsx"` ergibt 0 + - Die Badge-Zelle ist unveraendert: `grep -c "boundBadge" "apps/web/src/app/(portal)/admin/groups/page.tsx"` ergibt weiterhin 1 + - `cd apps/web && npx vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx"` ist gruen mit mindestens 2 neuen Faellen + + Die Gruppenliste zeigt den internen Namen, sobald einer gesetzt ist, und sonst den gespeicherten Namen; der AD-Name bleibt per Tooltip nachvollziehbar; die Bindungsspalte ist unangetastet. + + + + Task 3: Verwaiste Uebersetzungsschluessel entfernen und D-07 im Test festnageln + apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx + + - `apps/web/src/messages/de.json` Zeilen 375-400 und `apps/web/src/messages/en.json` Zeilen 375-400 — der `admin.groups`-Block mit dem zu entfernenden Unterbaum + - `apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx` — die Fetch-Stub-Mechanik und die bestehenden Modal-Faelle + - `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Abschnitt "Zu entfernende Copy" im Copywriting Contract + - `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Annahme A3 (beide Sprachdateien tragen denselben Unterbaum und muessen parallel bereinigt werden) + + +Entferne in **beiden** Sprachdateien den Unterbaum unter `admin.groups`, der die sechs Schluessel der entfallenen AD-Bindungsauswahl enthaelt (Hinweistext, Bindungsanzeige, Loesen-Link, Suchplatzhalter, Ladefehler, Leerergebnis). Er ist mit dem Umbau aus Task 1 vollstaendig verwaist. Alle uebrigen Schluessel unter `admin.groups` bleiben unangetastet — insbesondere der Spaltenkopf fuer die AD-Bindung, die beiden Badge-Texte und der Checkbox-Schluessel der Freigabe-Matrix. Der Unterschied zwischen dem Spaltenkopf-Schluessel und dem entfallenden Unterbaum ist ein einziges angehaengtes Wortende; loesche gezielt den Unterbaum, nicht den Praefix-Treffer. + +Pruefe anschliessend das gesamte Web-Verzeichnis auf verbliebene Verweise. Findet sich noch einer, ist der Umbau aus Task 1 unvollstaendig — nicht den Schluessel wieder einfuegen, sondern die Referenz entfernen. + +Ergaenze in `groups-page.test.tsx` die D-07-Absicherung: ein Fall, der den Dialog im Anlegen-Zustand oeffnet und danach prueft, dass **kein** Aufruf gegen den Verzeichnis-Endpunkt gegangen ist (Assertion ueber die vorhandene Fetch-Stub-Aufrufliste), sowie ein Fall, der den Dialog fuer eine importierte Gruppe oeffnet und prueft, dass das Namensfeld deaktiviert ist und das Feld fuer den internen Namen existiert. Passe bestehende Faelle an, die noch von der Radio-Auswahl ausgehen — dieser Task darf bestehende Tests aendern, nicht nur ergaenzen. + + + cd apps/web && npx vitest run + cd apps/web && npx tsc --noEmit + grep -rc '"ldapBind"' apps/web/src/messages/de.json apps/web/src/messages/en.json | grep -qv ':[1-9]' + + + - `grep -c '"ldapBind"' apps/web/src/messages/de.json` ergibt 0 + - `grep -c '"ldapBind"' apps/web/src/messages/en.json` ergibt 0 + - Der Spaltenkopf-Schluessel bleibt erhalten: `grep -c '"ldapBinding"' apps/web/src/messages/de.json` ergibt 1 und dasselbe fuer `en.json` + - Kein Quelltext referenziert den entfernten Unterbaum mehr: `grep -rc "ldapBind\." apps/web/src --include=*.tsx --include=*.ts | grep -qv ':[1-9]'` + - Beide Sprachdateien tragen unter `admin.groups` denselben Schluesselsatz: `node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const a=JSON.stringify(Object.keys(de.admin.groups).sort()),b=JSON.stringify(Object.keys(en.admin.groups).sort());if(a!==b)throw new Error(a+' vs '+b)"` + - `cd apps/web && npx vitest run` ist vollstaendig gruen + + Beide Sprachdateien sind synchron und frei von verwaisten Schluesseln, kein Quelltext verweist mehr darauf, und der Verzicht auf jede AD-Auswahl im Dialog ist durch einen Test abgesichert statt nur behauptet. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser → API (`POST /groups`, `PATCH /groups/:id`) | Admin-Eingaben aus dem Dialog; der Dialog ist Bequemlichkeit, nicht Schutz | +| Gerenderter AD-Name/DN im Dialog und in der Liste | Werte stammen aus dem Verzeichnis und werden im Browser angezeigt | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-16-07 | Tampering | Gesperrtes Namensfeld | high | mitigate | Die Sperre ist im Browser sichtbar, aber durchgesetzt wird sie serverseitig durch die Ablehnung eines `name` fuer Gruppen mit gesetztem Identitaetsschluessel (Plan 16-02). Der Dialog sendet in diesem Zustand kein `name`; ein manipulierter Client kommt an der Backend-Invariante nicht vorbei | +| T-16-12 | Cross-Site Scripting | Anzeige von AD-Name und AD-DN | medium | mitigate | Beide Werte werden als React-Textknoten gerendert; in keiner der beiden Dateien wird eine Roh-HTML-Einbettung verwendet | +| T-16-13 | Elevation of Privilege | Zugriff auf die Gruppenverwaltung | high | accept | Die Rollenpruefung auf dieser Seite ist reine Anzeige; jede `/groups`-Route ist serverseitig durch den bestehenden `RolesGuard` geschuetzt (dokumentiert in `groups/page.tsx`, T-15-22). Dieser Plan aendert daran nichts | +| T-16-SC | Tampering | Paketinstallation | low | accept | Keine neuen Pakete in diesem Plan | + + + +1. `cd apps/web && npx vitest run` — vollstaendig gruen +2. `cd apps/web && npx tsc --noEmit` — fehlerfrei +3. Beide Sprachdateien tragen unter `admin.groups` denselben Schluesselsatz +4. Kein Quelltext im Web-Verzeichnis verweist noch auf den entfernten Unterbaum + +**Manuell nachzuholen (kein Browser-Tool in der Ausfuehrungssitzung vorgesehen):** Dialog im Browser fuer alle drei Zustaende durchklicken — Anlegen mit Hinweis-Link, lokale Gruppe umbenennen, importierte Gruppe mit gesperrtem Namen und internem Namen speichern. Nach dem Speichern eines internen Namens erscheint dieser in der Gruppenliste, der AD-Name im Tooltip. + + + +- Der Dialog bietet keinen Weg mehr, eine lokale Gruppe an eine AD-Gruppe zu binden (D-07) +- Das Namensfeld importierter Gruppen ist gesperrt und die Herkunft ist im Dialog sichtbar (D-03) +- Der interne Name ist im Dialog setz- und aenderbar und gewinnt in der Gruppenliste (D-04) +- Speicherfehler sind sichtbar und in der Ursache unterscheidbar +- Keine verwaisten Uebersetzungsschluessel in einer der beiden Sprachen + + + +Create `.planning/phases/16-ad-gruppen-synchronisation/16-04-SUMMARY.md` when done + diff --git a/.planning/phases/16-ad-gruppen-synchronisation/16-05-PLAN.md b/.planning/phases/16-ad-gruppen-synchronisation/16-05-PLAN.md new file mode 100644 index 0000000..00962fb --- /dev/null +++ b/.planning/phases/16-ad-gruppen-synchronisation/16-05-PLAN.md @@ -0,0 +1,217 @@ +--- +phase: 16-ad-gruppen-synchronisation +plan: 05 +type: execute +wave: 4 +depends_on: [16-03, 16-04] +files_modified: + - apps/web/src/app/(portal)/admin/ldap/page.tsx + - apps/web/src/app/(portal)/admin/modules/grants/page.tsx + - apps/web/src/messages/de.json + - apps/web/src/messages/en.json +autonomous: true +requirements: [PERM-02] + +estimate: + tokens: 44000 + raw_tokens: 44000 + tasks: 2 + confidence: low + +must_haves: + truths: + - "Der Sync-Bericht zeigt neben den Benutzerzahlen auch die Gruppenmitgliedschafts-Zahlen (Backend-Luecke seit D-21) und die Gruppen-Zahlen aus dieser Phase — kein vom Backend geliefertes Zaehlfeld bleibt unangezeigt." + - "Wurde die Standardgruppen-Markierung im Lauf neu vergeben, erscheint dazu eine eigene, informativ-warnende Zeile — nur dann, nicht als Dauerzeile mit 0 (D-06)." + - "Ein insgesamt fehlgeschlagener Sync-Request zeigt einen sichtbaren Fehlerzustand statt eines Berichts mit Nullen." + - "Die Spaltenkoepfe der Freigabe-Matrix zeigen internalName ?? name als Text und als title (D-04)." + - "Die Suche ueber den Gruppen-Spaltenfilter findet eine Gruppe sowohl ueber ihren internen als auch ueber ihren AD-Namen." + - "UI E2/empty: Vor dem ersten Sync-Lauf wird der Ergebnis-Container gar nicht gerendert — kein leerer Bericht mit Nullen." + - "UI E2/loading: Waehrend des Laufs traegt der Sync-Button seinen Ladetext und ist deaktiviert; der Bericht erscheint erst nach Abschluss, ohne Zwischenzustand mit Teilzahlen." + - "UI E2/error: Schlaegt der Sync-Request insgesamt fehl, erscheint ein sichtbarer Fehlerzustand statt eines stillen No-ops." + - "UI E2/populated: Der Bericht rendert die drei festen Zahlenzeilen (Benutzer, Gruppenmitgliedschaften, AD-Gruppen) immer, auch bei Werten von 0." + - "UI E2/partial: Ein teilweise fehlgeschlagener Lauf zeigt Zahlenzeilen und Fehlerliste gleichzeitig — die Zahlen werden nicht unterdrueckt, wenn Fehler vorliegen." + - "UI E2/zero-one-many: Die Zeile zur verschobenen Standardmarkierung erscheint nur bei einem Wert groesser 0; die Fehlerliste nur bei nicht-leerer Fehlerliste." + - "UI E5/empty: Bestehender Empty-State der Matrix unveraendert; dieser Plan aendert nur den Textwert der Gruppen-Spaltenkoepfe." + - "UI E5/loading: Bestehender Ladezustand der Matrix unveraendert." + - "UI E5/error: Bestehendes Fehlerverhalten der Matrix unveraendert." + - "UI E5/populated: Jeder Gruppen-Spaltenkopf traegt den Anzeigenamen als Text und als title; das aria-label der Matrix-Checkboxen folgt automatisch demselben Wert." + - "UI E5/partial: Fehlt der interne Name, greift der Fallback auf den gespeicherten Namen — kein leerer Spaltenkopf." + - "UI E5/overflow: Bestehendes sticky-Verhalten mit horizontalem Scroll unveraendert; keine zusaetzliche Spalte." + - "UI E5/zero-one-many: Die Spaltenanzahl folgt der gefilterten Gruppenliste wie bisher, unveraendert." + - "UI E5/long-text: Die bestehende Breitenbegrenzung mit Abschneiden plus title-Tooltip wirkt jetzt auf den berechneten Anzeigenamen." + - statement: "UI E2/overflow: Eine lange Fehlerliste aus einem Lauf ueber viele Gruppen sprengt den Berichts-Container nicht." + verification: backstop + - statement: "UI E2/long-text: Eine lange Fehlermeldung mit langem Gruppennamen bricht im Bericht um statt horizontal ueberzulaufen." + verification: backstop + artifacts: + - "apps/web/src/app/(portal)/admin/ldap/page.tsx — erweitertes SyncResult-Interface, drei bis vier Berichtszeilen, sichtbarer Sync-Request-Fehler" + - "apps/web/src/app/(portal)/admin/modules/grants/page.tsx — Group-Interface um internalName, Spaltenkopf und Suchfilter mit Fallback" + - "apps/web/src/messages/de.json + en.json — admin.ldap.sync.resultGroupMemberships, .resultGroups, .defaultMarkerMoved, .requestError" + key_links: + - "LdapSyncResult (Backend, Plan 16-03) ↔ SyncResult (Frontend): beide muessen im selben Schritt um dieselben Feldnamen wachsen, sonst wiederholt sich die stille Luecke aus D-21" + - "GET /module-grants/matrix liefert vollstaendige Group-Zeilen ohne select — internalName kommt seit der Migration aus Plan 16-01 automatisch mit; ein spaeter eingefuegtes select wuerde den Spaltenkopf still auf den AD-Namen zuruecksetzen" + prohibitions: + - "Der Sync-Bericht darf kein vom Backend geliefertes Zaehlfeld unterschlagen — ein Feld, das der Bericht nicht anzeigt, ist genau die stumme Luecke, die seit D-21 offen war." + - "Die Meldung ueber eine neu vergebene Standardgruppen-Markierung darf nicht wie ein Fehler aussehen — sie ist ein bemerkenswerter, aber bewusst akzeptierter Vorgang." + - "Ein fehlgeschlagener Sync-Request darf nicht als Bericht mit lauter Nullen erscheinen — das wuerde einen nie gelaufenen Sync als erfolgreichen No-Op ausgeben." +--- + + +Der Sync-Bericht sagt endlich, was der Lauf getan hat. Er schliesst dabei zwei Luecken auf einmal: die bereits seit D-21 bestehende (das Frontend kennt die Mitgliedschafts-Zahlen des Backends nicht) und die neue aus dieser Phase (uebernommene, umbenannte, geloeschte Gruppen sowie eine neu vergebene Standardmarkierung). Das ist die einzige vorgesehene Massnahme gegen den in D-05 bewusst akzeptierten Zugriffsverlust: eine sync-getriebene Loeschung ist nirgends sonst sichtbar. + +Zusaetzlich zieht die dritte Anzeigestelle des internen Namens aus D-04 nach: die Spaltenkoepfe der Freigabe-Matrix. + +Purpose: Ohne diesen Plan klickt der Admin auf "Jetzt synchronisieren", sieht drei Benutzerzahlen und hat keine Ahnung, ob Gruppen geloescht wurden. + +Output: Erweiterter Bericht mit sichtbarem Fehlerzustand, Anzeigename mit Fallback in der Freigabe-Matrix, vier neue i18n-Schluessel in beiden Sprachen. + + + +Neu entstehende Symbole in diesem Plan: + +| Art | Symbol | +|-----|--------| +| Interface-Felder | `SyncResult.groupMembershipsAdded`, `.groupMembershipsRemoved`, `.groupsAdopted`, `.groupsRenamed`, `.groupsDeleted`, `.defaultMarkerMoved` (`admin/ldap/page.tsx`) | +| Frontend-State | `syncRequestError` (`admin/ldap/page.tsx`) | +| Typfeld | `Group.internalName?: string \| null` (`admin/modules/grants/page.tsx`) | +| i18n-Schluessel | `admin.ldap.sync.resultGroupMemberships`, `admin.ldap.sync.resultGroups`, `admin.ldap.sync.defaultMarkerMoved`, `admin.ldap.sync.requestError` | + +Aus Plan 16-03 uebernommen und hier vorausgesetzt: die vier neuen Zaehlfelder auf `LdapSyncResult`. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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-UI-SPEC.md +@.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md +@.planning/phases/16-ad-gruppen-synchronisation/16-03-SUMMARY.md +@.planning/phases/16-ad-gruppen-synchronisation/16-04-SUMMARY.md + + + +- **Abweichung vom Copywriting Contract, ausdruecklich vermerkt:** Die UI-SPEC formuliert Berichtszeile 3 als "AD-Gruppen: {groupsImported} importiert, {groupsRenamed} umbenannt, {groupsDeleted} geloescht". Der Sync **importiert** aber per D-02 nie eine Gruppe — Import ist ausschliesslich die ausdrueckliche Auswahl im Import-Bereich aus Plan 16-01. Eine Zahl, die nach jedem Lauf zwingend 0 ist, waere dauerhaft bedeutungslos. Das Feld heisst `groupsAdopted` (Plan 16-03) und die Zeile lautet "AD-Gruppen: {groupsAdopted} neu uebernommen, {groupsRenamed} umbenannt, {groupsDeleted} geloescht". Die uebrigen drei Zeilen folgen dem Contract woertlich. + + + + + + Task 1: Sync-Bericht vollstaendig verdrahten und Fehlschlag sichtbar machen (D-06, RESEARCH.md Pitfall 3) + apps/web/src/app/(portal)/admin/ldap/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json + + - `apps/web/src/app/(portal)/admin/ldap/page.tsx` — das `SyncResult`-Interface (Zeilen 54-59), `handleSync` (231-249) inklusive des heutigen catch-Zweigs, der ein dreifeldriges Ergebnisobjekt konstruiert, der Sync-Button (1059-1066) und der Ergebnis-Container (1068-1088) + - `apps/api/src/ldap/ldap.service.ts` — `LdapSyncResult` in der nach Plan 16-03 gueltigen Fassung, als verbindliche Feldnamensquelle + - `apps/web/src/messages/de.json` und `en.json` — der `admin.ldap.sync`-Block mit dem bestehenden Ergebnis-Schluessel als Formatvorbild + - `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Surface Contract 2 und die E2-Zeilen im Abschnitt UI Considerations + - `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Pitfall 3 + + +Erweitere das `SyncResult`-Interface additiv um die sechs Zahlenfelder, die das Backend heute schon liefert bzw. seit Plan 16-03 liefert: die beiden Mitgliedschafts-Zaehler aus D-21 und die vier Gruppen-Zaehler dieser Phase. Die Feldnamen muessen exakt denen auf `LdapSyncResult` entsprechen — lies sie dort ab, statt sie zu raten. + +Neuer State `syncRequestError: string | null`. `handleSync` setzt ihn zu Beginn jedes Laufs auf `null`. Der heutige catch-Zweig konstruiert ein Ergebnisobjekt mit drei Nullen und einer Netzwerkfehlermeldung — das ist mit dem erweiterten Interface nicht mehr typkorrekt und inhaltlich irrefuehrend: es gibt einen nie gelaufenen Sync als erfolgreichen Lauf ohne Aenderungen aus. Ersetze ihn: sowohl im catch-Zweig als auch bei `!res.ok` wird `syncResult` auf `null` gesetzt und `syncRequestError` auf `admin.ldap.sync.requestError`. Rendere diesen Zustand als `text-sm text-destructive`-Zeile unterhalb des Sync-Buttons, anstelle des Berichts-Containers. + +Baue den bestehenden Ergebnis-Container von einem Absatz auf mehrere um — derselbe Container (`rounded-md border border-border bg-muted/30 p-4`), nur mit mehreren Zeilen: + +1. Die bestehende Benutzerzeile, unveraendert, in `text-sm font-medium text-foreground`. +2. Neu: die Mitgliedschaftszeile mit `admin.ldap.sync.resultGroupMemberships`, in `text-sm text-muted-foreground` — sekundaere Information gegenueber Zeile 1. +3. Neu: die Gruppenzeile mit `admin.ldap.sync.resultGroups`, ebenfalls `text-sm text-muted-foreground`, **immer sichtbar**, auch wenn alle drei Werte 0 sind. Dieselbe Transparenzhaltung wie bei Zeile 1: eine 0 ist eine Aussage, kein Grund zum Verschweigen. +4. Neu und **bedingt**: nur bei einem Wert groesser 0 die Zeile `admin.ldap.sync.defaultMarkerMoved` in `text-sm font-medium` mit `text-amber-700 dark:text-amber-400`. Bewusst nicht in der Destruktiv-Farbe: die Verschiebung der Standardmarkierung ist ein bemerkenswerter, aber kein fehlerhafter Vorgang. +5. Die bestehende Fehlerliste darunter, unveraendert in `text-xs text-destructive`. Gruppenbezogene Fehler tragen laut Backend-Muster bereits ein Gruppenpraefix und brauchen keine visuelle Sonderbehandlung. + +Die Zahlenzeilen werden nicht unterdrueckt, wenn die Fehlerliste gefuellt ist — ein teilweise fehlgeschlagener Lauf zeigt beides. + +i18n: die vier neuen Schluessel unter `admin.ldap.sync` in beiden Sprachdateien anlegen. Die deutschen Texte fuer die Mitgliedschaftszeile, die Markierungszeile und den Fehlertext folgen dem Copywriting Contract woertlich; die Gruppenzeile folgt der im Abschnitt `` festgehaltenen, ausdruecklich vermerkten Abweichung. Englische Texte sinngleich. Die Interpolationsnamen entsprechen exakt den Feldnamen des Interface. + + + cd apps/web && npx tsc --noEmit + cd apps/web && npx vitest run + node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');for(const k of ['resultGroupMemberships','resultGroups','defaultMarkerMoved','requestError']){if(!de.admin.ldap.sync[k])throw new Error('de fehlt '+k);if(!en.admin.ldap.sync[k])throw new Error('en fehlt '+k)}" + + + - Das Frontend-Interface kennt alle sechs neuen Zahlenfelder: `awk '/interface SyncResult/,/^}/' "apps/web/src/app/(portal)/admin/ldap/page.tsx" | grep -c 'groupMembershipsAdded\|groupMembershipsRemoved\|groupsAdopted\|groupsRenamed\|groupsDeleted\|defaultMarkerMoved'` ergibt 6 + - Der catch-Zweig konstruiert kein Ergebnisobjekt mehr: `awk '/const handleSync/,/^ };/' "apps/web/src/app/(portal)/admin/ldap/page.tsx" | grep -c 'deactivated: 0'` ergibt 0 + - Der Fehlerzustand ist verdrahtet: `awk '/const handleSync/,/^ };/' "apps/web/src/app/(portal)/admin/ldap/page.tsx" | grep -c 'setSyncRequestError'` ist >= 3 + - Die bedingte Markierungszeile haengt an einem Groesser-0-Test: `grep -c 'defaultMarkerMoved > 0' "apps/web/src/app/(portal)/admin/ldap/page.tsx"` ist >= 1 + - Die Markierungszeile nutzt die Amber-Familie, nicht die Destruktiv-Farbe: `grep -c 'text-amber-700' "apps/web/src/app/(portal)/admin/ldap/page.tsx"` ist >= 1 + - `cd apps/web && npx tsc --noEmit` ist fehlerfrei + - Der Node-Schluesselcheck oben laeuft ohne Wurf durch + + Der Sync-Bericht zeigt Benutzer-, Mitgliedschafts- und Gruppenzahlen, meldet eine neu vergebene Standardmarkierung eigens und informativ, und ein insgesamt fehlgeschlagener Lauf erscheint als sichtbarer Fehler statt als Bericht mit Nullen. + + + + Task 2: Anzeigename mit Fallback in den Spaltenkoepfen der Freigabe-Matrix (D-04) + apps/web/src/app/(portal)/admin/modules/grants/page.tsx + + - `apps/web/src/app/(portal)/admin/modules/grants/page.tsx` — das lokale `Group`-Interface (Zeilen 16-19), `fetchMatrix` (55-70), das Suchfilter-Memo `filteredGroups` (140-143) und der Spaltenkopf-Block (206-213) + - `apps/api/src/groups/module-grants.service.ts` — `getMatrix()` (Zeilen 172-202); die Gruppen-Query nutzt kein `select`, das neue Feld kommt damit ohne Backend-Aenderung mit + - `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Surface Contract 5 und die E5-Zeilen im Abschnitt UI Considerations + + +Erweitere das lokale `Group`-Interface in `apps/web/src/app/(portal)/admin/modules/grants/page.tsx` um `internalName?: string | null`. Der Matrix-Endpunkt liefert vollstaendige Gruppenzeilen ohne Feldauswahl, das Feld ist seit der Migration aus Plan 16-01 also bereits in der Antwort enthalten — es fehlt nur der Typ. + +Im Spaltenkopf-Block sowohl den `title`-Wert als auch den gerenderten Text auf den internen Namen mit Rueckfall auf den gespeicherten Namen umstellen. Kein Layoutunterschied: dieselbe Zelle mit ihrer Breitenbegrenzung, ihrem Abschneiden und ihrem Tooltip-Mechanismus, nur der zugrundeliegende Textwert aendert sich. Das `aria-label` der Matrix-Checkboxen interpoliert bereits denselben Wert und folgt damit automatisch. + +Passe zusaetzlich das Suchfilter-Memo an: es filtert heute ausschliesslich ueber den gespeicherten Namen. Nach dieser Aenderung sieht der Admin einen anderen Text, als er durchsucht. Lass den Filter auf **beide** Werte matchen — Anzeigename und gespeicherter Name — damit weder die Suche nach dem internen noch die nach dem AD-Namen ins Leere laeuft. Verwende an beiden Stellen die Nullish-Variante, nicht die Oder-Variante. + +Diese Datei bekommt in diesem Plan keine neuen Uebersetzungsschluessel. + + + cd apps/web && npx tsc --noEmit + cd apps/web && npx vitest run + + + - `grep -q "internalName" "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"` trifft + - Spaltenkopf-Text und Tooltip nutzen beide den Fallback: `awk '/filteredGroups.map/,/<\/th>/' "apps/web/src/app/(portal)/admin/modules/grants/page.tsx" | grep -c 'internalName ?? g.name'` ist >= 2 + - Keine Truthiness-Variante: `grep -c "internalName ||" "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"` ergibt 0 + - Der Suchfilter beruecksichtigt beide Namen: `awk '/const filteredGroups/,/\);/' "apps/web/src/app/(portal)/admin/modules/grants/page.tsx" | grep -c 'internalName'` ist >= 1 + - Die Zellklassen sind unveraendert: `grep -c 'min-w-\[120px\] max-w-\[160px\] truncate' "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"` ergibt weiterhin 1 + - `cd apps/web && npx vitest run` ist vollstaendig gruen + + Die Spaltenkoepfe der Freigabe-Matrix zeigen den internen Namen, sobald einer gesetzt ist, der AD-Name bleibt im Tooltip, und die Spaltensuche findet eine Gruppe unter beiden Namen. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| API → Browser (Sync-Bericht, Matrix) | Vom Backend gelieferte Zahlen, Fehlermeldungen und Gruppennamen werden angezeigt; Fehlermeldungen koennen AD-gelieferte Gruppennamen enthalten | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-16-12 | Cross-Site Scripting | Fehlerzeilen im Sync-Bericht, Spaltenkoepfe der Matrix | medium | mitigate | Alle Werte werden als React-Textknoten gerendert; in beiden Dateien wird keine Roh-HTML-Einbettung verwendet | +| T-16-14 | Information Disclosure | Fehlerzeilen enthalten Gruppennamen und ggf. DN-Fragmente aus dem Verzeichnis | low | accept | Der Bericht ist ausschliesslich fuer ADMIN und SUPER_ADMIN desselben Mandanten erreichbar; die enthaltenen Werte stammen aus dem Verzeichnis dieses Mandanten und sind fuer diese Rolle ohnehin sichtbar | +| T-16-05 | Repudiation | Sichtbarkeit sync-getriebener Loeschungen | high | mitigate | Genau dieser Plan ist die in D-05 vorgesehene Massnahme: die Zahlen fuer geloeschte Gruppen und verschobene Standardmarkierung erscheinen im Bericht. Ohne sie waere der akzeptierte Zugriffsverlust vollstaendig unsichtbar | +| T-16-SC | Tampering | Paketinstallation | low | accept | Keine neuen Pakete in diesem Plan | + + + +1. `cd apps/web && npx vitest run` — vollstaendig gruen +2. `cd apps/web && npx tsc --noEmit` — fehlerfrei +3. Beide Sprachdateien tragen unter `admin.ldap.sync` denselben Schluesselsatz +4. Die Feldnamen im Frontend-Interface stimmen zeichengenau mit denen auf `LdapSyncResult` ueberein + +**Manuell nachzuholen (Browser):** Sync ausloesen und pruefen, dass alle drei Zahlenzeilen erscheinen; einen Lauf mit verschobener Standardmarkierung provozieren und pruefen, dass die vierte Zeile in Amber erscheint; die Freigabe-Matrix mit einer Gruppe mit gesetztem internem Namen oeffnen und die Spaltensuche unter beiden Namen probieren. + + + +- Kein vom Backend geliefertes Zaehlfeld bleibt im Sync-Bericht unangezeigt +- Eine sync-getriebene Loeschung und eine verschobene Standardmarkierung sind im Bericht sichtbar (D-05-Massnahme, D-06) +- Ein fehlgeschlagener Sync-Request erscheint als Fehler, nicht als Bericht mit Nullen +- Die Spaltenkoepfe der Freigabe-Matrix zeigen den internen Namen (D-04, dritte Anzeigestelle) + + + +Create `.planning/phases/16-ad-gruppen-synchronisation/16-05-SUMMARY.md` when done +