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

26 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
16-ad-gruppen-synchronisation 02 execute 2
16-01
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
true
PERM-02
tokens raw_tokens tasks confidence
50000 50000 3 low
truths artifacts key_links prohibitions
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 verification
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. backstop
statement verification
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. backstop
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
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
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.

<artifacts_this_phase_produces> 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<boolean>
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. </artifacts_this_phase_produces>

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/16-ad-gruppen-synchronisation/16-CONTEXT.md @.planning/phases/16-ad-gruppen-synchronisation/16-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<boolean>. 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: <ziel> }, 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 <behavior>-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 <acceptance_criteria> - 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 </acceptance_criteria> 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 <behavior>-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 <acceptance_criteria> - 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 </acceptance_criteria> 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 <acceptance_criteria> - 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 </acceptance_criteria> 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.

<threat_model>

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

<success_criteria>

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