The existence sweep in syncBoundGroupsForTenant() built its filter by
interpolating a byte-wise \xx escape of the stored objectGUID into a filter
string. ldapts parses that string before encoding it and does not turn the
escape sequences back into the bytes they stand for, so the assertion value
that reached the directory was a different value and matched nothing.
Measured read-only against a real Active Directory on 2026-08-11, probing a
group whose GUID had just been read from that same directory:
(objectGUID=\1e\4b...) 0 hits
(objectGUID=\1E\4B...) 0 hits
EqualityFilter{attribute, value: <16 bytes>} 1 hit, correct DN
(cn=Domain Admins) [control] 1 hit
Both the narrow base-DN sweep and the wider WR-03 move-detection sweep shared
that filter, so neither could ever hit: every AD-bound group looked deleted and
would have been removed together with its GroupMembership and ModuleGrant rows
on the first real sync, after handing off the default-group marker.
Build the filter as an EqualityFilter over the raw Buffer instead, and drop
escapeLdapFilterBuffer() -- it has no remaining caller and is the trap the code
walked into. escapeLdapFilterValue() is untouched: escaping STRING values into
a filter is correct and still in use.
The existing spec mocks matched on the escaped string, which is how the broken
shape passed review. They now match on the filter object's Buffer value, and
two added tests fail if a stringly-typed objectGUID filter ever comes back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
syncBoundGroupsForTenant()'s existence sweep only searched the configured
base DNs, so an AD group MOVED to an OU outside that subtree (still
present in the directory) was indistinguishable from a genuine
disappearance and got deleted along with its memberships/module grants —
a silent access loss from a non-destructive AD operation, and a bigger
blast radius than D-05 ("group genuinely gone") was accepted for.
Before concluding disappearance, a second (objectGUID=...) sweep now runs
against each base DN's own domain root (skipped when a base DN already IS
its domain root — the common case, nothing wider to search). A hit there
is reported as an error line and the group is left untouched; only when
the wide sweep also finds nothing is deletion (SC-4/D-05/D-06) actually
established — mirroring the existing conservative stance already taken
for a legacy binding whose DN no longer resolves. Deleting on uncertainty
was the failure mode; this closes it without widening it.
syncBoundGroupsForTenant() compared cn/dn for byte equality, so any
casing difference AD returns between two runs (e.g. after a
domain-controller switch) would look like a rename and re-write
name/ldapDn every single sync — violating the 'sync twice over an
unchanged AD state = no-op' idempotency guarantee. The comparison used
to DECIDE 'is this a rename' is now case-insensitive; the value written
on an actual rename is still stored byte-for-byte as the directory
reports it, per D-03.
syncBoundGroupsForTenant()'s rename write updates name and ldapDn in one
call, so a P2002 there can come from either @@unique([tenantId, name])
or @@unique([tenantId, ldapDn]). The catch previously reported every
P2002 as a name collision unconditionally; it now inspects
err.meta.target the same way importGroupsByDn() already does for its
own create() call, so a non-name unique violation is no longer
mislabelled and sent the admin down the wrong troubleshooting path.
A legacy binding from plan 15-06 has ldapDn set but ldapObjectGuid stays
null until the first syncBoundGroupsForTenant() run backfills it. Until
then, GroupsService.update() let a direct PATCH rename through even
though GroupFormModal.tsx already treats the same group as AD-bound
(isImported = ldapDn != null) — the D-03 name lock was only a UI
convention for that window, not the backend invariant the 16-02 summary
claimed.
- Local Group interface gains internalName?: string | null — GET
/module-grants/matrix already returns the field (no select on the
groups query), only the frontend type was missing it.
- Column header text and title tooltip now use internalName ?? name
(nullish, not truthy) — same fallback pattern as admin/groups and
UserAccessModal. No layout change: same truncate-with-tooltip cell.
- Search filter now matches both the display name and the stored AD
name, so neither the internal nor the original AD name search goes
empty after the header text changed.
- SyncResult interface grows additively: groupMembershipsAdded/Removed
(D-21 backend gap, existed since Phase 15 but never wired into the
frontend) plus groupsAdopted/Renamed/Deleted and defaultMarkerMoved
(Plan 16-03).
- New syncRequestError state: a failed sync request (network error or
!res.ok) now renders a visible text-sm text-destructive line instead
of silently reporting a three-zero result as a successful no-op.
- Sync report container gains three new lines: group-membership counts,
AD-group counts (always visible, even at zero), and a conditional
amber "default marker reassigned" line shown only when
defaultMarkerMoved > 0.
- Four new i18n keys under admin.ldap.sync in de.json/en.json.
Delete the admin.groups.ldapBind.* subtree (hint/bound/unbind/
searchPlaceholder/discoverError/noResults) from de.json and en.json —
fully orphaned since GroupFormModal.tsx no longer has an AD-binding
codepath. admin.groups.ldapBinding (column header) and every other
admin.groups.* key are untouched; key sets stay in parity across both
languages.
Test file's translation stub loses the same dead ldapBind block and
gains the seven Task 1 keys. Two new cases lock down D-07: opening the
create dialog issues no /ldap/groups request, and editing an imported
group renders a disabled name input plus the internal-name field
instead of any AD-search UI.
Name column renders group.internalName ?? group.name (nullish, not
truthiness, since the backend already normalizes blank values to null)
inside a span carrying title={group.name} so the AD name stays
discoverable on hover once an internal name is set. Badge column is
untouched — it remains the sole imported-vs-local marker per D-07.
Adds two component-test cases covering the fallback and its title
attribute (D-04).
- Remove AD radio-selection block, discovery effect/state, and the
two-step create-then-PATCH-bind flow from GroupFormModal.tsx (D-07):
a local group can no longer be bound to an AD group from this dialog.
- Add locked name field with provenance hint + AD-DN read-only line and
an editable internalName field for imported groups (D-03/D-04); create
state gets a hint linking to the LDAP import area.
- Visible save-error line (never a silent catch{}) that distinguishes a
409 name collision from a generic failure; dialog stays open, inputs
are preserved.
- Add Group.internalName to the page.tsx interface now (Rule 3 — the
modal cannot typecheck without it; Task 2 adds the display-cell usage).
- Add seven new admin.groups i18n keys to de.json/en.json (orphaned
ldapBind.* keys removed in Task 3).
- syncUsersForTenant() now calls syncBoundGroupsForTenant() (5a) BEFORE
syncGroupMembershipsForTenant() (5b) — the central correctness ordering
of Phase 16 (RESEARCH.md Pitfall 1): a rename detected in the same run
must be written back before the memberOf filter is built, or the
membership sync would misreport a rename as a membership wipeout
- Add observable ordering test (call-order spies), a no-op-guard test, and
a regression test proving a memberOf search never uses the stale
pre-rename DN
- Update the D-21 membership-sync test fixtures to resolve step 5a as a
deterministic no-op (DN-derived identity GUID), since the wiring now
runs 5a ahead of every syncUsersForTenant() call those tests exercise
A1 (objectGUID survives an AD rename) and A2 (binary filter escape syntax)
remain unverified against a real directory — no reachable AD in this
sandbox. Documented as an outstanding live verification in the plan
SUMMARY, not silently skipped.
- New private LdapService.syncBoundGroupsForTenant(): rename detection
(SC-3), disappearance deletion with default-marker handoff before delete
(SC-4/D-05/D-06), legacy ldapDn-only binding GUID backfill (D-07), and a
32-hex-char guard before any objectGUID filter interpolation (T-16-01)
- LdapSyncResult grows additively: groupsAdopted, groupsRenamed,
groupsDeleted, defaultMarkerMoved
- LdapService constructor takes GroupsService; LdapModule imports
GroupsModule (no cycle)
- 14 new test cases covering the full behavior matrix plus idempotency
- ModuleGrantsService.getUserAccess() now selects internalName on the
membership query's group projection (already present via `include:
{ group: true }` on the grant query)
- Both display points (viaGroups names, membership chips' name field)
use internalName ?? name; groups[] sorting now runs over the
displayed name as a result, distinct from GroupsService.listForTenant()
which still sorts by the raw name column
- 3 new test cases: fallback set/unset, sort-by-displayed-name
- No apps/web/ changes (verified via git diff --name-only)
- GroupsService.update() rejects `name` with BadRequestException when the
loaded group carries a set ldapObjectGuid (imported groups) — a real
backend invariant, not a UI-only disable
- internalName is settable/clearable on any group; empty/whitespace-only
values normalize to null instead of an empty display name
- listForTenant() now projects internalName alongside name
- UpdateGroupDto drops ldapDn (D-07: no more codepath binds a local group
to AD via this route) and gains internalName?: string | null
- 9 new test cases in groups.service.spec.ts (name lock, internalName
set/clear/idempotent/local-group/unicode, listForTenant projection);
stale ldapDn update() test removed (behavior intentionally deleted)
- DEFAULT_GROUP_NAME extracted as shared constant between
ensureDefaultGroup() and the new reassignDefaultBeforeDelete()
- reassignDefaultBeforeDelete(tenantId, groupId) moves the default
marker deterministically (DEFAULT_GROUP_NAME first, else oldest
other group by createdAt asc), never deletes, never throws
- 6 test cases covering handoff, fallback ordering, no-other-group,
non-default no-op, cross-tenant no-op, and P2002 race
- Migration 20260806133916_add_group_internal_name_and_object_guid applied
against the local Postgres container (baselined 24 prior migrations first
— _prisma_migrations was missing, unrelated to this task's DDL)
- New describe block in migration-sql.spec.ts pins internalName,
ldapObjectGuid, and the (tenantId, ldapObjectGuid) unique index
- Full API test suite green (40 files, 526 tests)
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
- Second, deliberately separate migration (pure hand-SQL, no Prisma-
generated DDL): ENABLE/FORCE ROW LEVEL SECURITY plus a
tenant_isolation_policy for each of the three new tables, following
the pattern of 20260618112133_rls_policies (Auth-Kerntabellen)
rather than the RLS-exempt Tender* app-layer tables
- Group/ModuleGrant compare tenantId directly against
current_tenant_id(); GroupMembership has no own tenantId and follows
the PasswordResetToken join pattern (groupId IN (SELECT id FROM
Group WHERE tenantId = ...))
- migration-sql.spec.ts extended with a second describe block covering
both migration files (6x ROW LEVEL SECURITY, 3x CREATE POLICY, the
join vs. direct-comparison shape)
- Re-ran the Task-2 end-to-end proof after applying this migration:
identical result (USER without grant 403 + empty list, USER with
direct grant 200 + slug present, ADMIN 200) — the app's DB role
(tessera) is a Postgres superuser with rolbypassrls=true, so it
bypasses RLS as documented as an acceptable outcome by the plan;
RLS remains the defense-in-depth net for any future non-superuser
connection
- ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role):
ADMIN/SUPER_ADMIN bypass (D-03) via one query, otherwise a single
Promise.all of direct + group ModuleGrant lookups intersected against
active TenantModuleActivation (D-02) — no N+1 over the user's groups
- findAccessibleModules() adds the name-asc sort for stable sidebar order
- ModuleGuard now resolves userId/role from request.user (JWT-sourced,
never body/params) and calls getAccessibleModuleIds instead of the
tenant-only isModuleActive check; caches the result on
request.moduleAccessIds for same-request reuse (D-09, no cross-request
caching)
- ModuleRegistryController.findActive delegates to
ModuleAccessService.findAccessibleModules instead of
findActiveForTenant, which stays untouched for Plan 15-03's
tenant-wide marketplace catalog
- ModuleRegistryModule exports ModuleAccessService for Plan 15-03/15-05
- module-access.service.spec.ts / module.guard.spec.ts cover every case
in the plan's <behavior> list with a hand-rolled Prisma mock
- End-to-end verified against the running local API: a USER without a
grant gets 403 on a @UseModule-protected endpoint and an empty
/modules/active list; the same USER with a direct grant gets 200 plus
the slug in the list; an ADMIN without any grant also gets 200 (D-03)
- Group/GroupMembership/ModuleGrant models plus MembershipSource enum
(D-05), placed under TenantModuleActivation with German block comment
- Hand-SQL appended to the generated migration: partial unique index for
one default group per tenant (D-13), CHECK num_nonnulls xor-constraint
plus two partial unique indexes for ModuleGrant (D-04), and the D-06
backfill (Group -> GroupMembership -> ModuleGrant, each INSERT guarded
by WHERE NOT EXISTS for idempotent re-runs on `prisma migrate deploy`)
- apps/api/src/groups/migration-sql.spec.ts verifies the hand-SQL by
reading migration.sql directly, no DB required
- Verified against the local DB: default-group count matches tenant
count, membership/grant counts match existing users/active
activations, and the XOR constraint rejects a group+user-less insert
- Base-DN admin field is now a multi-line textarea (one DN per line),
value stays a single newline-separated string, no schema change
- baseDnHint key added (de/en) explaining the Base-DN(s) sync scope
- groupFilter.description/emptyMeansAll reworded: group filter is an
optional extra restriction; empty selection means all users under
the base DN(s) are synced (drops the old "nothing is synced"
framing)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- parseBaseDns() splits the newline-separated baseDn field into a list
- syncUsersForTenant no-op guard re-keyed on empty parsed base-DN list
(was empty groupFilterDns) — the sole condition that skips search +
the deactivation loop, preventing mass-deactivation on an
unconfigured config
- collectSearchEntries/listGroups/searchUsers loop every base DN and
merge/dedupe results by entry dn
- empty groupFilterDns is no longer a no-op: it now performs a normal
multi-base search with no memberOf restriction
- groupFilterDns ou= entries stay additional search bases; group DNs
become an optional memberOf constraint applied to every base search
- spec: replaced empty-groupFilterDns no-op test with empty-base-DN
no-op test, added multi-base merge/dedup test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New-config create form now defaults syncIntervalMin to 0 (matches
backend default, auto-sync off by default)
- de+en groupFilter.description + emptyMeansAll reworded: empty
selection now says "nothing is synced" instead of "imports everyone
under the base DN" (matches the backend semantic change)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- collectSearchEntries() returns [] on empty/undefined groupFilterDns
instead of scanning the whole baseDn subtree
- syncUsersForTenant() early-returns an empty successful result before
any LDAP search or the deactivation loop when groupFilterDns is empty,
so an empty selection can never mass-deactivate existing LDAP users
- Updated exclude-list tests to use a non-empty groupFilterDns; added a
dedicated no-op test proving empty selection performs zero search/
create/update/deactivate operations
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>