Task 1 checkpoint resolved: approve-both, granted 2026-08-06 by the
project owner (D-04 one-way schema extension: Group.internalName +
Group.ldapObjectGuid, both nullable, one versioned migration).
Adds the Phase 16 tracer slice through every layer:
- Prisma schema: Group.internalName, Group.ldapObjectGuid,
@@unique([tenantId, ldapObjectGuid]) (Prisma client regenerated;
the versioned migration itself is Task 3, separately blocking).
- LdapService: listGroups() now reads objectGUID via
explicitBufferAttributes and flags alreadyImported per tenant;
new importGroupsByDn() creates a Group per checked DN with
name/ldapDn/ldapObjectGuid, reject-with-report on name collision
(P2002 on name -> nameCollisions, P2002 on ldapObjectGuid ->
skipped), never aborts the batch on one DN's error; new static
escapeLdapFilterBuffer() for Plan 16-03's later existence sweep.
- DTO/controller: ImportGroupsDto, POST /ldap/groups/import
(ADMIN/SUPER_ADMIN), listGroups route now tenant-scoped.
- Frontend: new "AD-Gruppen importieren" section in /admin/ldap,
own discovery/import handlers with a visible error state
(Owner decision 2026-08-06 — no silent catch{} for these two
handlers), i18n keys in de.json/en.json.
- Tests: 8 new cases covering the full <behavior> list plus
listGroups sort order and alreadyImported.
Flagged assumption (RESEARCH.md A1/A2): objectGUID rename-stability
and the binary filter syntax are unverified against a real AD —
this plan only WRITES the GUID, Plan 16-03 reads it back live.
- TenantService.create ruft nach prisma.tenant.create ensureDefaultGroup
auf; Fehler werden protokolliert, nicht propagiert (Muster aus
UserService.create)
- tenant.module.ts importiert GroupsModule (keine Zirkularitaet, wie
UserModule bereits vormacht)
- AdminSeedService.onApplicationBootstrap besteht jetzt aus zwei
sequenziellen await-Schritten: seedAdmin() (bisheriger Rumpf, plus
ensureDefaultGroup nach dem Tenant-Upsert und VOR user.create), dann
ensureDefaultGroupsForAllTenants() als abschliessende Reparatur ueber
ALLE Mandanten — laeuft unabhaengig von seedAdmin()s fruehen
Rueckkehrpfaden (fehlende ENV / Admin existiert bereits) und ist je
Mandant sowie insgesamt try/catch-gekapselt, blockiert den API-Start nie
- Reparatur sitzt bewusst NICHT als eigener onApplicationBootstrap-Hook
in GroupsModule (Ordering-Falle aus tender-scheduler.service.ts)
- tenant.service.spec.ts, admin-seed.service.spec.ts (neu): Reihenfolge,
beide fruehen Rueckkehrpfade, Fehlerisolation je Mandant, Idempotenz
ueber zwei Bootstrap-Laeufe
- Neue Methode ensureDefaultGroup: legt fuer einen Mandanten ohne jede
Gruppe die Standardgruppe 'Alle Benutzer' (isDefault:true) an, nimmt
alle Bestandsbenutzer als MANUAL-Mitglieder auf und erzeugt Grants
fuer alle aktiven Module — derselbe Endzustand wie die drei
Backfill-INSERTs der Migration 20260804130130
- Waechter prueft ausschliesslich group.count === 0, niemals die
fehlende isDefault-Markierung (D-13)
- P2002 aus dem partiellen Index Group_one_default_per_tenant wird
abgefangen und liefert null statt zu werfen (Race-Sicherheit)
- groups.service.spec.ts: Fake erweitert um group.count,
tenantModuleActivation, moduleGrant.findMany/createMany,
$transaction mit Callback-Form, plus voller ensureDefaultGroup-Testblock
- Reuses admin.groups.members.sourceManual/.sourceLdap -- no new i18n key
- Badge markup copied verbatim from GroupMembersModal.tsx (blue for LDAP,
neutral gray otherwise), same visual language on both surfaces
- Chips come from the response's groups (actual GroupMembership rows),
not the union of row.viaGroups -- closes the reproduced defect where
revoking a group's last grant hid an otherwise-unchanged membership
- React key is the group id, not the name
- Test file: moved the LDAP/MANUAL badge assertion out of this commit,
it belongs to Task 2 which reuses admin.groups.members
- New groupMembership.findMany query, tenant-scoped via group.tenantId
(GroupMembership has no own tenantId column)
- Response shape changes from an array to { groups, modules }; modules
entries stay field-identical to before
- Group without any module grant now stays visible, closing the
reproduced defect
- Regression: user in a group without any module grant stays visible
- Cross-tenant: membership in a foreign tenant's group is excluded
- Origin (MANUAL/LDAP), empty-modules case, stable alpha sort
- marketplace/page.tsx und marketplace/[slug]/page.tsx auf GET
/modules/catalog umgestellt (ein Aufruf statt zwei), beide Statusflags
(isActiveForTenant, hasAccess) kommen in einer Antwort -> kein
Zwischenzustand, in dem eine Karte kurzzeitig ohne Sperr-Badge
anklickbar erscheint
- MarketplaceCard bekommt hasAccess-Prop: drittes Badge (Bernstein,
"Kein Zugriff") bei isActive && !hasAccess, Karte opacity-60/
cursor-not-allowed, Klick loest Toast statt Navigation aus; bei
Zugriff navigiert der Klick zu /marketplace/[slug]; Badge-Reihe
bekommt flex-wrap gegen Overflow bei langen Namen
- [Rule 2] Marketplace-Ansicht war zuvor komplett isAdmin-gated
(Zugriff verweigert fuer USER) - das widersprach D-08 ("Katalog
bleibt Schaufenster fuer jeden authentifizierten Benutzer") und
haette das neue Sperr-Badge fuer USER nie sichtbar gemacht. isAdmin
gated jetzt nur noch die Aktivieren/Deaktivieren-Aktion (canManage),
nicht mehr die gesamte Seite
- bestehende Marketplace-Tests auf einaufrufiges Catalog-Mock
umgestellt, "access-denied fuer non-admin"-Test durch "Karten
sichtbar, aber ohne Manage-Button" ersetzt
- Neue UserAccessModal.tsx: laedt einmal GET /module-grants/users/:userId
und rendert daraus zwei Abschnitte -- Gruppenmitgliedschaften (read-only
Chip-Liste, dedupliziert aus allen viaGroups-Namen; Bearbeitung bleibt
ausschliesslich unter /admin/groups, D-16) und Modul-Zugriff (Modul |
erbende Gruppen als Chips oder "–" | Direkt-Checkbox)
- Direkt-Checkbox verhaelt sich identisch zur Matrix-Zelle: optimistisches
Toggle via POST/DELETE /module-grants mit moduleId+userId, Rollback samt
sichtbarer Fehlermeldung bei Fehlschlag (T-15-25), aria-label pro Zeile
aus admin.users.grants.directCheckboxLabel
- admin/users/page.tsx: vierter Aktionsbutton "Details" je Zeile oeffnet
das Modal
- user-access-modal.test.tsx: 5 Tests (Chip-Liste + Leerzustand, Modultabelle
mit geerbtem/nicht-geerbtem Modul, Rollback bei Fehler, Hinweistext ohne
aktive Module, aria-label je Checkbox)
- Neue ActivateModuleDialog.tsx: Abbrechen / "Spaeter konfigurieren" (nur
POST /modules/:id/activate) / "Sofort freigeben" (POST .../activate
gefolgt von POST /module-grants fuer die als Standard markierte Gruppe) --
zwei getrennte Aufrufe, kein neuer kombinierter Endpoint (D-10)
- Laedt GET /groups beim Oeffnen; ohne markierte Standardgruppe ist "Sofort
freigeben" disabled mit Hinweistext (D-13)
- admin/modules/page.tsx: toggleModule-Klick verzweigt -- Deaktivierung
bleibt direkt, Aktivierung oeffnet den Dialog statt sofort zu aktivieren;
Erfolg aktualisiert Aktivierungs-Map + Sidebar-Refresh wie bisher
- grants-matrix.test.tsx erweitert um 3 Tests fuer den Dialog (alle drei
Buttons vorhanden, Sofort-freigeben deaktiviert ohne Standardgruppe,
Aufrufreihenfolge activate->grant)
- Neue Client-Komponente admin/modules/grants/page.tsx: laedt GET /module-grants/matrix
einmal, rendert Module x Gruppen mit sticky erster Spalte/Kopfzeile, Kategorie-
Gruppierung, Suchfeld und Admin-Bypass-Fussnote (D-03/D-15, PERM-03)
- Jede Zelle togglet sofort optimistisch (POST/DELETE /module-grants); Fehlschlag
springt die Checkbox zurueck und zeigt die Fehlermeldung im bestehenden error-Div
(T-15-25) -- identisches Muster zu AdminModulesPage.toggleModule
- aria-label pro Checkbox aus admin.groups.grants.matrixCheckboxLabel beschreibt die
bevorstehende Aktion (freigeben/entziehen), nicht den aktuellen Zustand
- admin/modules/page.tsx: neuer Header-Button "Freigaben-Matrix" verlinkt auf die
Unterseite, kein siebter Sidebar-Eintrag
- grants-matrix.test.tsx: 5 Tests (befuellte Matrix, leerer Zustand, Rollback bei
Fehler, Suchfilter, aria-label je Checkbox)
- GroupMembersModal.tsx: chip list of current members with source badge
(MANUAL/LDAP); LDAP-sourced chips carry a disabled remove button with a
"managed via AD sync" tooltip (D-19) instead of an active one; second
section adds manual members via GET /users + POST /groups/:id/members,
already-member candidates shown disabled (upsert on the API is
folgenlos, no special-case needed)
- DeleteGroupDialog.tsx: loads GET /groups/:id/impact and interpolates
memberCount/grantCount into the confirmation text (D-17); deliberately
breaks from the project's silent-delete-failure precedent -- stays open
and shows a visible error on a failed DELETE, since a silent failure
here would leave an admin believing a group (and its grants) is gone
while it still grants access (T-15-24)
- page.tsx: wires both dialogs in, refetches the group list after any
member/delete mutation so member counts and badges stay current
- groups-page.test.tsx: disabled-vs-active remove button by membership
source, delete text shows both numbers, visible error + dialog stays
open on failed delete
- page.tsx: sixth admin route, table (Name/AD-Bindung/Standardgruppe/
Mitglieder/Aktionen), empty state matching AdminUsersPage's noUsers
pattern, optimistic default-group star toggle (PATCH /groups/:id
isDefault) with rollback + visible error div on failure, full refetch
on success since setting one group default unsets all others server-side
(D-13 transaction)
- GroupFormModal.tsx: create/rename dialog; AD binding section reuses
GET /ldap/groups (D-18) with a radio list (D-05: exactly one AD group
per Tessera group) instead of the LDAP page's checkbox multi-select;
visible discoverError/noResults states instead of a silent-empty list
(UI-SPEC backstop); create-with-binding does POST then a second PATCH
since CreateGroupDto only accepts `name`
- groups-page.test.tsx: empty state, populated table, star-toggle
optimistic PATCH + rollback-on-failure
- de.json/en.json: admin.groups.* (incl. ldapBind, members, deleteConfirm,
grants sub-namespaces), admin.users.grants.*, adminModules.grantsLink +
grants.* + activationDialog.*, modules.accessDenied.*, marketplace
statusNoAccess/toastNoAccess, header.admin.groups -- covers this plan's
/admin/groups surface plus the Wave 4 surfaces (permission matrix,
user-detail grants, activation dialog, 403 page, marketplace badge) so
15-07/15-08 can run in parallel without touching the translation files
- admin-sidebar.tsx: sixth nav entry "Gruppen" -> /admin/groups with a
roster/list icon (Lucide list glyph, distinct from the users icon)
- ModuleAccessService.getCatalogFlags(tenantId, userId, role) liefert je
aktivem Modul isActiveForTenant + hasAccess in einer Auflösung
- ModuleRegistryController.findCatalog (GET /modules/catalog), erreichbar
für jeden authentifizierten Benutzer wie GET /modules (D-08)
- ADMIN/SUPER_ADMIN: hasAccess immer wahr für aktive Module (D-03)
- 4 neue Tests für getCatalogFlags
- GET /module-grants/matrix, GET /module-grants/users/:userId,
POST /module-grants, DELETE /module-grants — alle vier rollengeschützt
(RolesGuard + Roles ADMIN/SUPER_ADMIN)
- matrix vor users/:userId deklariert (Beschattungsfehler-Vermeidung)
- GroupsModule bindet ModuleGrantsController/-Service ein; kein Import
von ModuleRegistryModule nötig, da der Service nur PrismaService braucht
- assertTargetBelongsToTenant prüft groupId/userId aus dem Request-Body
gegen tenantId aus dem JWT (T-15-01), vor jedem Grant-Insert
- grant: Entweder-oder-Regel (D-04), aktive TenantModuleActivation (D-02),
P2002 als Erfolg (Doppelklick-Schutz)
- getMatrix (D-15) und getUserAccess (D-16) für Matrix-Seite und
Benutzer-Detail, jeweils sortiert und mandantengescoped
- 20 Tests inkl. adjacency/empty/ordering/idempotency/concurrency
- LdapService.syncGroupMembershipsForTenant (neu, privat): pro AD-gebundener
Group (ldapDn gesetzt) ein memberOf-Reverse-Query je Base-DN, nie ein
Attribut-Lesen (Range-Retrieval-Pitfall). GroupMembership(source: LDAP)
wird per createMany/skipDuplicates angelegt (lässt bestehende MANUAL-Zeilen
unangetastet, D-19/D-20) und per deleteMany(source: 'LDAP', notIn: [...])
bereinigt. Jede Gruppe läuft in eigenem try/catch, ein Fehler landet als
"Gruppe <name>: <message>" in result.errors, die Schleife läuft weiter.
- Aufruf in syncUsersForTenant nach der Deaktivierungsschleife (Schritt 5)
und vor lastSyncAt (Schritt 6) — hinter dem bestehenden Base-DN-No-Op-Wächter,
kein separater Job, kein zweiter Button (D-21).
- LdapSyncResult um groupMembershipsAdded/groupMembershipsRemoved erweitert.
- ldap.service.spec.ts: neuer describe-Block mit 13 Tests (adjacency, empty,
encoding, ordering, idempotency, concurrency/backstop) plus Anpassung der
drei bestehenden Prisma-Fixtures und einer Ergebnis-Assertion an die
erweiterte LdapSyncResult-Form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- DashboardModule imports ModuleRegistryModule to inject ModuleAccessService
- getWidgets(userId, tenantId, role) runs the existing findMany unchanged
first, then calls getAccessibleModuleIds exactly once — only if a loaded
widget's type is in WIDGET_MODULE_MAP (currently always empty, so no
lookup runs today); unresolved module slugs fail closed
- DashboardController.getWidgets forwards tenantId + role from the JWT
- dashboard.service.spec.ts (8 tests, TDD-GREEN): covers every <behavior>
case incl. D-03 ADMIN bypass, adjacency/empty/ordering/idempotency, and
fail-closed on an unresolved Module slug
- pnpm --filter @tessera/api test: 457/457 green; type-check clean
- manual e2e against local API + DB container: empty WIDGET_MODULE_MAP
leaves an existing user's widget count unchanged (2/2 clock+search
survived the filter), throwaway verification user/rows removed after
- covers every <behavior> case from 15-05-PLAN.md task 2, including the
adjacency/empty/ordering/idempotency edge-probe categories and the
fail-closed unresolved-slug case
- RED confirmed: 4/8 fail against the current 1-arg getWidgets(userId)
- WIDGET_MODULE_MAP + getModuleSlugForWidgetType (apps/api/src/dashboard/widget-module-map.ts)
- table intentionally empty at end of phase: all 8 existing widget types are module-free platform widgets (D-22)
- code constant chosen over a WidgetInstance schema column — no migration for a field empty on every row
- UserService.create ruft nach der Anlage GroupsService.addUserToDefaultGroup
auf (D-11/D-12) — einziger Erzeugungspunkt für Benutzer, erbt LdapService
ohne eigene Kopie der Regel
- try/catch mit Logger: gescheiterte Gruppenzuordnung bricht weder die
Benutzeranlage noch einen LDAP-Sync-Lauf ab (T-15-14)
- UserModule importiert GroupsModule, keine Zirkularität
- 4 Tests in user.service.spec.ts; ldap.service.ts unverändert