274 lines
26 KiB
Markdown
274 lines
26 KiB
Markdown
---
|
|
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."
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<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>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Standardgruppen-Handoff als eigener Baustein (D-06)</name>
|
|
<files>apps/api/src/groups/groups.service.ts, apps/api/src/groups/groups.service.spec.ts</files>
|
|
<read_first>
|
|
- `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
|
|
</read_first>
|
|
<behavior>
|
|
- 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")
|
|
</behavior>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && npx vitest run src/groups/groups.service.spec.ts -t "reassignDefaultBeforeDelete"</automated>
|
|
<automated>cd apps/api && npx tsc --noEmit</automated>
|
|
</verify>
|
|
<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>
|
|
<done>`reassignDefaultBeforeDelete` verschiebt die Standardmarkierung deterministisch, meldet den Ausgang per Rueckgabewert, wirft in keinem der sechs Faelle und ist getestet.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Namenssperre fuer importierte Gruppen und internalName im GroupsService (D-03, D-04, D-07)</name>
|
|
<files>apps/api/src/groups/groups.service.ts, apps/api/src/groups/dto/update-group.dto.ts, apps/api/src/groups/groups.service.spec.ts</files>
|
|
<read_first>
|
|
- `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
|
|
</read_first>
|
|
<behavior>
|
|
- 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
|
|
</behavior>
|
|
<action>
|
|
**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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && npx vitest run src/groups/groups.service.spec.ts</automated>
|
|
<automated>cd apps/api && npx tsc --noEmit</automated>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 3: Anzeigename mit Fallback im Benutzer-Detail (D-04, UI-SPEC Surface Contract 6)</name>
|
|
<files>apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts</files>
|
|
<read_first>
|
|
- `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)
|
|
</read_first>
|
|
<behavior>
|
|
- 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`
|
|
</behavior>
|
|
<action>
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && npx vitest run src/groups/module-grants.service.spec.ts</automated>
|
|
<automated>cd apps/api && npx vitest run</automated>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/phases/16-ad-gruppen-synchronisation/16-02-SUMMARY.md` when done
|
|
</output>
|