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