- Neue Migration 20260911120000_rls_user_dimension_personal_tables: current_user_id()
(NULLIF-gefaltet), zehn persoenliche Tabellen umgestellt (acht als eine Regel,
SearchProvider/TenderRssFeedSource als je vier befehlsgetrennte Regeln), vier
Verwaltungstabellen bewusst unveraendert. Lokal angewendet (migrate deploy,
Prisma-Binary aus apps/api/node_modules/.bin), schema.prisma unveraendert.
- forTenant(prisma, tenantId, userId?): beide set_config in EINER getaggten
Anweisung, $transaction-Array bleibt bei zwei Eintraegen (WINDOWS #20),
Leerstring ohne Benutzer statt Weglassen.
- tender-saved-search.service.ts: alle vier forTenant()-Aufrufe reichen userId
durch; Detektor-Regex bestaetigt 4 Treffer.
- rls-scratch-check.mjs: current_user_id() aus der neuen Migration geschnitten
(nicht getippt), drei Funktionsfaelle gemessen, neue runUserDimensionChecks()
mit generiertem Client fuer TenderSavedSearch (vier Wahrheiten + Spaltenabgleich),
die alte Loch-Pruefung tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar
umgedreht (alte Messung unter neuem Namen erhalten, neue Umkehrung MIT Benutzer).
sqlStateOf() um Message-Fallback ergaenzt (RLS-Ablehnung ueber generierten
Client traegt den SQLSTATE nur im Fehlertext, nicht in .meta.code).
- Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 146/146 bestanden.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
- TenderRssFeedSourceService.listForUser() nimmt jetzt (userId, tenantId)
entgegen und laeuft ueber einen gebundenen Klienten (forTenant) — die neue
Leseregel schliesst plattformweite Zeilen ein, die Reparatur haette den
ungebundenen Pfad sonst still auf nur die plattformweiten Zeilen reduziert
(Befund F). createPlatform/remove bleiben bewusst ungebunden, Kommentare an
der neuen Regel richtiggestellt.
- TendersController.listRssFeeds reicht die Mandantenkennung aus dem
Aufrufzusammenhang durch.
- Vier Aufzeichnungen im Quelltext (module-access.service.ts,
groups.service.ts, module-grants.service.ts, rls-coverage.spec.ts) sagen
jetzt, dass die Datenbankregel seit 20260910120000_rls_widen_membership_
grant_and_platform_read beide Seiten prueft; die Anwendungspruefungen
bleiben unveraendert bestehen (zweites Netz, wirkt vor dem Scharfschalten
als einziger Schutz).
- Zwei-Klienten-Nachweis in module-grants.service.spec.ts ergaenzt (Kommentar,
warum die beiden Cross-Tenant-Tests nach der Regelaenderung nicht entfallen
duerfen) und in tender-rss-feed.service.spec.ts umgekehrt (listForUser
bindet jetzt).
- Rule 1: implizites any beim Destrukturieren in listRssFeeds (feeds ist seit
der Bindung `any`) mit expliziter Annotation behoben.
- Falsifizierungsnachweis durchgefuehrt: Bindungsaufruf zurueckgenommen,
genau ein Test wurde rot (AssertionError, 0 statt der erwarteten
Aufrufe), Ruecknahme rueckgaengig gemacht.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
- Neue, handgeschriebene Migration 20260910120000_rls_widen_membership_grant_and_platform_read:
GroupMembership prueft jetzt beide Seiten der Beziehung (Gruppe UND Benutzer),
ModuleGrant prueft zusaetzlich die referenzierte Gruppe/den referenzierten Benutzer
(mit Leer-Zulassung, D-04), TenderRssFeedSource bekommt vier nach Befehl getrennte
Regeln statt einer (Lesen schliesst plattformweite Zeilen ein, Schreiben verlangt
weiterhin einen Mandanten). SearchProvider bewusst unveraendert (Befund E: Praemisse
widerlegt). Lokal angewandt und gegen den Systemkatalog der lebenden Datenbank
gemessen. Der Schalter bleibt aus (Rolle tessera).
- rls-scratch-check.mjs: die drei loch-behauptenden Pruefungen umgekehrt (nicht
geloescht), Gegenmessungen ueber die Wartungsrolle ergaenzt, vier Befehlsrichtungen
fuer TenderRssFeedSource gemessen, neuer Abschnitt fuer SearchProvider, Extraktion
auf die neue Migration umgeleitet und um eine mehrfach-treffer-faehige Form ergaenzt
(extractAllPolicySql).
- migration-sql.spec.ts: neuer Beschreibungsblock fuer die neue Migration.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
Alle fuenf Methoden von ModuleGrantsService (assertTargetBelongsToTenant,
grant, revoke, getMatrix, getUserAccess) laufen jetzt ueber forTenant(); bei
den beiden Datenlieferungen teilen sich alle parallel abgesetzten
Teilabfragen denselben gebundenen Client. Die Mandanten-Gegenpruefung vor
jedem Erteilen bleibt ausdruecklich bestehen und bekommt einen Verweis auf
Befund F/T-JTS-03: die Regel auf ModuleGrant prueft nur die
Mandantenkennung der Zeile, nicht die referenzierte Gruppe. Der veraltete
Kommentar ueber der Mitgliedschaftsabfrage im Benutzer-Detail ("kein
forTenant hier") ist durch den neuen Stand ersetzt.
module-grants.service.spec.ts bekommt denselben Bindungsnachweis-Mock wie
groups.service.spec.ts (zwei unterscheidbare Clients ueber demselben
Speicher) und sechs neue Bindungsnachweise; alle 28 Bestandstests bleiben
gruen.
Beide Dokumente geschlossen: die Bereichsuebersicht fuer groups ist neu
gemessen (0 ungebunden, 31 gebunden — ein dokumentierter methodischer
Bodensatz, da die einfache Rohtrefferzaehlung die neun ueber `tx` gebundenen
Zugriffe innerhalb der drei Transaktionen nicht sieht), die
Klassen-Verteilung auf 62 Paare aktualisiert, und der als offen gefuehrte
Befund D aus dem ldap-Abschnitt der Fehlerrichtung ist mit Verweis auf
diesen Durchlauf als erledigt vermerkt (Nachtrag, nicht Neuschrieb). 743
Tests und die Typpruefung gruen; Schema, Migrationen und alle vier
Compose-Dateien unveraendert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
prisma-tenant.extension.ts bekommt withTenantTransaction(prisma, tenantId, fn)
— die interaktive Transaktion auf dem UNgebundenen Client mit set_config als
erster Anweisung direkt auf tx, die in Aufgabe 1 als einzige der drei
gemessenen Formen sowohl die Einzelmessung als auch eine Lastprobe unter
echter Nebenlaeufigkeit bestand (die Array-Form auf dem gebundenen Client
verteilt jede Operation auf eine eigene Teiltransaktion; die interaktive Form
auf dem gebundenen Client brach unter 40 parallelen Aufrufen mit P2028 ab).
rls-access-inventory.spec.ts bekommt eine dritte Erkennung fuer
Modellzugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion
(zwei Formen: direkter Empfaenger.$transaction(async...) und das neue
Hilfsmittel withTenantTransaction(...)) — macht das Paar
(groups.service.ts, tenantModuleActivation) erstmals sichtbar, das bislang
keine Pruefung dieses Projekts je gesehen hat.
groups.service.ts: alle zwoelf Methoden inklusive der drei Transaktionen
(update() isDefault:true, reassignDefaultBeforeDelete(), ensureDefaultGroup())
laufen jetzt ueber den Mandantenkontext. Zaehler und Transaktion in
ensureDefaultGroup() sind gemeinsam gebunden (T-JTS-05). addUserToDefaultGroup()
prueft neu, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) —
die Regel auf GroupMembership prueft nachweislich nur die Gruppenseite.
groups.service.spec.ts bekommt zwei unterscheidbare Clients ueber demselben
Speicher-Fake (Muster aus 260909-ipc, auf die interaktive Form uebertragen)
und 13 neue Bindungsnachweise; alle 42 Bestandstests bleiben gruen.
Klassifikationsdokument nachgezogen. 737 Tests und die Typpruefung gruen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
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.
- 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)
- 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
- 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
- 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
- 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
- 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