181 lines
16 KiB
Markdown
181 lines
16 KiB
Markdown
---
|
||
phase: quick-260910-exd
|
||
plan: 01
|
||
subsystem: database
|
||
tags: [prisma, postgresql, row-level-security, multi-tenancy, nestjs, vitest]
|
||
|
||
requires:
|
||
- phase: quick-260909-jts
|
||
provides: forTenant()/withTenantTransaction(), the Group/GroupMembership/ModuleGrant/TenantModuleActivation RLS policies, and module-grants.service.ts as the reference pattern for two-client bound tests
|
||
provides:
|
||
- ModuleAccessService (getAccessibleModuleIds, findAccessibleModules, getCatalogFlags) bound to forTenant() on every mandate-scoped access
|
||
- ModuleRegistryService (findActiveForTenant, activateForTenant, deactivateForTenant, isModuleActive) bound to forTenant()
|
||
- A new test file for module-registry.service.ts (previously had none) covering 11 of the area's 17 raw accesses, including every write path
|
||
- Both falsely-worded header comments from Befund G corrected
|
||
- docs/mandantentrennung-zugriffsklassifikation.md fully reconciled (5 hand-maintained sections) at 108 ungebunden / 134 gebunden
|
||
- WINDOWS #23: the recorded absence of a signal distinguishing "genuinely no grant" from "query found nothing" in the module-access hot path
|
||
affects: [module-registry, dashboard (picks up the binding transitively via getAccessibleModuleIds), etappe-3-rls-policy-tightening, etappe-4-cutover]
|
||
|
||
actuals:
|
||
tokens: 25541
|
||
tasks: 3
|
||
commits: 3
|
||
plan_head_before: a2516a9
|
||
|
||
tech-stack:
|
||
added: []
|
||
patterns:
|
||
- "EIN gebundener Klient je Methode unter dem Namen tenantPrisma, existing where-filters kept as a second net (T-JTS-02/T-JTS-03 precedent from module-grants.service.ts)"
|
||
- "Nested method calls (getCatalogFlags calling getAccessibleModuleIds) each create their own forTenant() client — bound clients are never passed between methods"
|
||
- "Catalog access (Module model) stays deliberately unbound with a comment separating today's measurement (no RLS on the table) from the future condition (Etappe 3 adding a policy would make binding catastrophic)"
|
||
|
||
key-files:
|
||
created:
|
||
- apps/api/src/module-registry/module-registry.service.spec.ts
|
||
modified:
|
||
- apps/api/src/module-registry/module-access.service.ts
|
||
- apps/api/src/module-registry/module-access.service.spec.ts
|
||
- apps/api/src/module-registry/module.guard.spec.ts
|
||
- apps/api/src/module-registry/module-registry.service.ts
|
||
- apps/api/src/tenders/tender-scheduler.service.spec.ts
|
||
- apps/api/scripts/rls-scratch-check.mjs
|
||
- docs/mandantentrennung-etappe2-fehlerrichtung.md
|
||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||
- .planning/WINDOWS.md
|
||
|
||
key-decisions:
|
||
- "Module catalog binding decision separates MEASUREMENT (no RLS on Module today, so binding it would be inert) from CONDITION (it becomes catastrophic once Etappe 3 adds a policy) — corrects the planning brief's premise that binding would be catastrophic today"
|
||
- "The one absent signal (genuinely-no-grant vs query-found-nothing) is recorded as unsolved, not runtime-warned-around — same reasoning as getAllActiveConfigs in ldap and the five spots in tenders: a warning on a routine empty-result path is noise, not signal"
|
||
- "A cross-area test break (tender-scheduler.service.spec.ts, unmocked ModuleRegistryService against a $extends-less fake) was fixed with the same identity-mock convention already used in ldap.service.spec.ts, not by reshaping the production code"
|
||
|
||
requirements-completed: [WINDOWS-18, ETAPPE-2-MODULE-REGISTRY]
|
||
|
||
coverage: []
|
||
|
||
duration: ~75min
|
||
completed: 2026-09-10
|
||
status: complete
|
||
---
|
||
|
||
# Quick Task 260910-exd: Etappe 2, Bereich module-registry Summary
|
||
|
||
**The two-stage module-access decision path (TenantModuleActivation + ModuleGrant) is now fully forTenant()-bound in both services of the area, with a machine-verified measurement that the platform module catalog stays deliberately unbound and a recorded absence of a signal distinguishing a real access denial from a silently-broken query.**
|
||
|
||
## Performance
|
||
|
||
- **Duration:** ~75 min
|
||
- **Tasks:** 3
|
||
- **Files modified:** 9 (1 created, 8 modified)
|
||
|
||
## Accomplishments
|
||
|
||
- `ModuleAccessService.getAccessibleModuleIds` (ADMIN/SUPER_ADMIN short-circuit, direct grant path, group grant path, D-02 intersection) and `getCatalogFlags`'s own activation read now run through `forTenant()`, one client per method — existing `tenantId` where-filters remain as the second net (T-JTS-02/T-JTS-03).
|
||
- `ModuleRegistryService.findActiveForTenant`, `activateForTenant`, `deactivateForTenant` (both activation accesses over one client), and `isModuleActive` are bound the same way; the six catalog accesses (`findAll`, `findBySlug`, both existence checks, `isModuleActive`'s catalog lookup, `seedModule`) stay deliberately unbound.
|
||
- `module-registry.service.spec.ts` created from scratch — the file previously had zero tests despite holding 11 of the area's 17 raw accesses and every write path. Covers both the loud direction (deactivating an unactivated module throws) and the silent direction (`isModuleActive` without an activation returns `false`) as named, deliberately-preserved properties.
|
||
- `module-access.service.spec.ts` rebuilt onto the two-client proof (`__makeBoundClient`, bound-call log) with a watchdog that fails if the catalog access ever appears in the bound-call log.
|
||
- One new case in `module.guard.spec.ts` pins down that "genuinely no grant" and "the resolution found nothing" produce the identical `ForbiddenException` message today — the machine record of the area's central finding.
|
||
- `isModuleActive`'s header comment corrected: it claimed `ModuleGuard` calls it; measured zero callers exist (the guard uses `findBySlug` + `getAccessibleModuleIds` instead).
|
||
- `rls-scratch-check.mjs` gained an eighth section (`runModuleRegistryAreaChecks`, 13 named checks) run against the real, delivered migrations — 66/66 checks pass. The measurement that the module catalog is genuinely unprotected today (no RLS, `pg_class.relrowsecurity = false`) and that the activation/grant unique keys structurally cannot repeat the tenders/user visible-row-collision chain (both lead with `tenantId`) is now committed evidence, not an assertion.
|
||
- `docs/mandantentrennung-zugriffsklassifikation.md` fully reconciled: all five hand-maintained sections (inventory rows, overview line 7/10, sum line 108/134, class distribution unchanged at 63 pairs, background-service-trap section recording the absence of a sixth case) — all machine-gated against the source.
|
||
- `docs/mandantentrennung-etappe2-fehlerrichtung.md` gained the `## Bereich module-registry` section (m1–m5) plus a Task-3 addendum naming both falsification proofs with test name and failure message.
|
||
- `.planning/WINDOWS.md` #23 records the missing distinguishing signal as an open deviation, with the concrete Etappe-4 preflight check and the reasoned rejection of a runtime warning.
|
||
|
||
## Task Commits
|
||
|
||
Each task was committed atomically:
|
||
|
||
1. **Aufgabe 1: measure the error direction, no production code** — `7d45e2f` (test)
|
||
2. **Aufgabe 2: bind ModuleAccessService** — `3df7268` (feat)
|
||
3. **Aufgabe 3: bind ModuleRegistryService, finish the classification doc** — `9c0eefe` (feat)
|
||
|
||
_Note: no separate docs-only metadata commit yet — the orchestrator adds that after this SUMMARY._
|
||
|
||
## Files Created/Modified
|
||
|
||
- `apps/api/src/module-registry/module-access.service.ts` — `getAccessibleModuleIds`/`getCatalogFlags` bound to `forTenant()`, catalog access left unbound with a measurement+condition comment
|
||
- `apps/api/src/module-registry/module-access.service.spec.ts` — rebuilt onto the two-client bound-call-log proof, all 15 pre-existing cases retained plus 6 new binding cases (die Zahl stand hier zunaechst als 7; vom Verifizierer nachgezaehlt und berichtigt — 6 entspricht den im Plan benannten sechs Verhaltensweisen. Dieselbe Fehlerart wie in 260909-laa, wo eine Zusammenfassung drei Uebersetzungen behauptete und zwei geliefert waren)
|
||
- `apps/api/src/module-registry/module.guard.spec.ts` — one new case pinning the absence of a distinguishing signal
|
||
- `apps/api/src/module-registry/module-registry.service.ts` — `findActiveForTenant`/`activateForTenant`/`deactivateForTenant`/`isModuleActive` bound; `isModuleActive`'s header comment corrected
|
||
- `apps/api/src/module-registry/module-registry.service.spec.ts` — new, 16 cases
|
||
- `apps/api/src/tenders/tender-scheduler.service.spec.ts` — `forTenant()` mocked to identity (same convention as `ldap.service.spec.ts`) to fix a cross-area break caused by the `activateForTenant` conversion
|
||
- `apps/api/scripts/rls-scratch-check.mjs` — eighth section `runModuleRegistryAreaChecks`, 13 named checks
|
||
- `docs/mandantentrennung-etappe2-fehlerrichtung.md` — new `## Bereich module-registry` section (m1–m5) plus Task-3 addendum
|
||
- `docs/mandantentrennung-zugriffsklassifikation.md` — 5 inventory rows updated/reconciled, overview/sum/class-distribution/background-service-trap sections all reconciled
|
||
- `.planning/WINDOWS.md` — new open entry #23
|
||
|
||
## Decisions Made
|
||
|
||
- **Module catalog binding stays a two-part statement, not a single claim.** The planning brief's premise ("binding the catalog would be catastrophic today") was measured and found FALSE — `Module` carries no RLS policy at all, so binding it today would be inert. The action (don't bind it) is unchanged, but the written reason now separates the measurement (no policy today) from the condition (it becomes catastrophic once Etappe 3 gives the table a policy).
|
||
- **The absent distinguishing signal is recorded, not engineered around.** There is no way today to tell "the user genuinely has no grant" from "a query silently found nothing" — both produce the identical 403, the identical empty 200 list, and no log line. A runtime warning at these spots was considered and rejected (same reasoning as `getAllActiveConfigs` in `ldap` and the five spots in `tenders`): a warning on a routine "no access" case would be constant noise on a fresh install.
|
||
- **Cross-area test break fixed with the existing convention, not a code reshape.** `tender-scheduler.service.spec.ts` drives the real `ModuleRegistryService` against a hand-rolled fake without `$extends`. Rather than adding `$extends`/`$transaction` support to that fake or weakening the production binding, `forTenant` was mocked to identity in that one file — the exact pattern `ldap.service.spec.ts` already established for tests that don't care about RLS binding mechanics.
|
||
|
||
## Deviations from Plan
|
||
|
||
### Auto-fixed Issues
|
||
|
||
**1. [Rule 1/3 - Blocking bug in a dependent area] `tender-scheduler.service.spec.ts` broke after `ModuleRegistryService.activateForTenant` started calling `forTenant()`**
|
||
- **Found during:** Task 3 (full-suite green check after converting `module-registry.service.ts`)
|
||
- **Issue:** This spec instantiates the real, unmocked `ModuleRegistryService` against a hand-rolled fake prisma object that has no `$extends` method (by design — it predates any RLS binding in this service). `forTenant()` calls `prisma.$extends(...)`, so the test failed with `prisma.$extends is not a function`.
|
||
- **Fix:** Mocked `forTenant` to an identity function (`vi.fn((p) => p)`) in this one file, matching the exact convention `ldap.service.spec.ts` already uses for the same reason (the test verifies poll-once-fan-out-many scheduler invariants, not RLS binding mechanics).
|
||
- **Files modified:** `apps/api/src/tenders/tender-scheduler.service.spec.ts`
|
||
- **Verification:** `npm --prefix apps/api run test -- src/tenders/tender-scheduler.service.spec.ts` green (4/4); full suite green afterward.
|
||
- **Committed in:** `9c0eefe` (Task 3 commit)
|
||
|
||
**2. [Rule 3 - Blocking, full-suite gate] `rls-access-inventory.spec.ts` went red immediately after binding `module-access.service.ts` in Task 2, before Task 3 (which owns the classification doc) had run**
|
||
- **Found during:** Task 2 (the plan's own verify block runs the full `npm --prefix apps/api run test` suite, which includes this cross-check between the classification doc and the source)
|
||
- **Issue:** `module-access.service.ts`'s `moduleGrant` and `tenantModuleActivation` rows in `docs/mandantentrennung-zugriffsklassifikation.md` still said `Stand: ungebunden` the moment the service code became bound — the doc and source diverged mid-plan, and Task 2's own verify gate (full test suite) demanded they match.
|
||
- **Fix:** Updated only the `Stand` column (and a one-sentence addition to the existing Begründung) for those two specific rows — not the overview line, sum line, class distribution, or background-service-trap section, all of which stayed correctly assigned to Task 3's full reconciliation pass.
|
||
- **Files modified:** `docs/mandantentrennung-zugriffsklassifikation.md`
|
||
- **Verification:** `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts` green; full suite green (817/817) at the end of Task 2.
|
||
- **Committed in:** `3df7268` (Task 2 commit)
|
||
|
||
---
|
||
|
||
**Total deviations:** 2 auto-fixed (1 cross-area blocking bug, 1 blocking full-suite-gate correction split across tasks by necessity)
|
||
**Impact on plan:** Both were forced by the plan's own verify gates (full test suite must stay green after every task) rather than scope creep. No production behavior outside the two converted services was changed; the tender-scheduler fix only affects test wiring.
|
||
|
||
## Falsification Proofs (required, per plan)
|
||
|
||
**Task 2 — group path binding rolled back and restored:**
|
||
- Rolled `tenantPrisma.moduleGrant.findMany(...)` (group path inside `getAccessibleModuleIds`) back to `this.prisma.moduleGrant.findMany(...)`.
|
||
- `module-access.service.spec.ts` test **"ModuleAccessService — Bindung an forTenant() (260910-exd) > USER-Zweig bindet BEIDE Freigabe-Lesezugriffe (Direktweg und Gruppenweg) UND den Schnittmengen-Lesezugriff an DIESELBE Mandantenkennung"** went red: `AssertionError: expected 1 to be 2`.
|
||
- Reverted the rollback (file byte-identical to before the probe, confirmed via `diff`); the same test run went green again (23/23).
|
||
|
||
**Task 3 — deactivation write binding rolled back and restored:**
|
||
- Rolled `tenantPrisma.tenantModuleActivation.update(...)` (in `deactivateForTenant`) back to `this.prisma.tenantModuleActivation.update(...)`.
|
||
- `module-registry.service.spec.ts` test **"ModuleRegistryService.deactivateForTenant > bindet beide Aktivierungszugriffe (Lesen, Schreiben) an denselben Mandanten, über einen Klienten"** went red: `erwarteter gebundener Aufruf tenantModuleActivation.update(tenant=t1) fehlt im Protokoll: [{"tenantId":"t1","model":"tenantModuleActivation","method":"findUnique"}]: expected false to be true`.
|
||
- Reverted the rollback (file byte-identical to before the probe, confirmed via `diff`); the same test run went green again (16/16).
|
||
|
||
## Issues Encountered
|
||
|
||
None beyond the two deviations above — both handled inline without blocking task progress.
|
||
|
||
## User Setup Required
|
||
|
||
None — no external service configuration required. `DATABASE_URL` remains unchanged, pointed at the `tessera` role with `BYPASSRLS`. The switch stays off; this was measurement and application-layer binding work only.
|
||
|
||
## Self-Check
|
||
|
||
- `apps/api/src/module-registry/module-registry.service.spec.ts` — FOUND
|
||
- `apps/api/src/module-registry/module-access.service.spec.ts` — FOUND (modified)
|
||
- `apps/api/src/tenders/tender-scheduler.service.spec.ts` — FOUND (modified)
|
||
- Commit `7d45e2f` — FOUND in `git log`
|
||
- Commit `3df7268` — FOUND in `git log`
|
||
- Commit `9c0eefe` — FOUND in `git log`
|
||
- `npm --prefix apps/api run test` — 833/833 green, 56 files (was 810/55 at plan start)
|
||
- `npm --prefix apps/api run type-check` — clean
|
||
- `node apps/api/scripts/rls-scratch-check.mjs` — 66/66 checks passed, exit 0
|
||
- WINDOWS #23 present in both the table and the JSON block of `.planning/WINDOWS.md`
|
||
|
||
## Next Phase Readiness
|
||
|
||
Five bereiche remain in Etappe 2, by today's raw-hit count: `dashboard` (13), `calendar` (12), `tenant` (8), `favorites` (7), `auth` (5, gemischt), `settings` (4). Two order conditions carried forward from this run: `dashboard`'s module-access filter is already correct because the binding sits in `ModuleAccessService` (no file in `dashboard` needs touching for that); `settings` remains the open order condition for `tenders` (SMTP credentials) and is the smallest remaining area at 4 raw hits.
|
||
|
||
## Self-Check: PASSED
|
||
|
||
All created/modified files and all three task commits verified present via `[ -f ... ]` and `git log --oneline --all | grep`; no missing items.
|
||
|
||
---
|
||
*Phase: quick-260910-exd*
|
||
*Completed: 2026-09-10*
|