--- task: quick-260911-e2s verified: 2026-09-11T09:06:02Z status: passed score: 10/10 must-have truths verified commits_reviewed: [652e762, 11f5731, 17dca0d, c8de72e] base: 6426b18 covered_files: - apps/api/scripts/rls-scratch-check.mjs - apps/api/src/app.module.ts - apps/api/src/dkv/dkv.controller.ts - apps/api/src/module-registry/module.guard.ts - apps/api/src/prisma/rls-access-inventory.spec.ts - apps/api/src/tenant/tenant.controller.spec.ts - apps/api/src/tenant/tenant.controller.ts - apps/api/src/tenant/tenant.guard.spec.ts - apps/api/src/tenant/tenant.guard.ts - apps/api/src/tenant/tenant.middleware.ts - docs/anleitung-entwicklung.md - docs/mandantentrennung-etappe2-fehlerrichtung.md - docs/mandantentrennung-zugriffsklassifikation.md - .planning/quick/260911-e2s-mandantentrennung-etappe-2-bereich-tenan/260911-e2s-PLAN.md - .planning/quick/260911-e2s-mandantentrennung-etappe-2-bereich-tenan/260911-e2s-SUMMARY.md advisory: - finding: "Kein WINDOWS-Ledger-Eintrag fuer die strukturelle Erkennungsluecke von rls-access-inventory.spec.ts (Relationseinbindungen/`include`/`_count` in eine zweite Tabelle bleiben fuer das Werkzeug unsichtbar, unabhaengig davon, dass die heute einzige gefaehrliche Auspraegung in diesem Plan behoben wurde)." category: architectural reason: "Die Luecke ist ein dauerhaftes Werkzeug-Merkmal, kein historischer Einzelfall — ein KUENFTIGER `include: { _count }`-Zugriff auf eine geschuetzte Tabelle waere von der Bestandsaufnahme strukturell genauso unsichtbar wie der hier gefundene. Der Plan begruendet den Verzicht auf einen Ledger-Eintrag ausdruecklich mit 'nur diese eine Auspraegung existierte, hier behoben' — das schliesst aber nur die heutigen Instanzen, nicht den Mechanismus." evidence_status: "Gemessen und in (n4)(b) sowie im Kopf der Bestandsaufnahme benannt; im Bedrohungsregister als T-E2S-09 (medium, accept) gefuehrt. Kein WINDOWS-Eintrag angelegt." --- # Quick 260911-e2s: Mandantentrennung Etappe 2, Bereich `tenant` — Verification Report **Task goal:** Remove the never-read `req.tenantPrisma` wiring (delete the unwired middleware, strip the guard's Prisma dependency) while preserving `req.tenantId` and the `x-tenant-id` switch; replace the three relation-count reads into the protected `User` table with a bound fan-out; create the missing guard and controller tests; leave the classification document in sync. **Verified:** 2026-09-11T09:06:02Z **Status:** passed **Re-verification:** No — initial verification This is an adversarial, independent re-verification. Every claim below was checked against the live codebase and, where feasible, against the running database — SUMMARY.md text was never accepted as evidence on its own. ## Goal Achievement — Observable Truths (from PLAN.md `must_haves.truths`) | # | Truth | Status | Evidence | |---|-------|--------|----------| | 1 | Architecture decision on the bound-client-on-request-object question is made and recorded in code, kritikschrift, classification, and dev guide; a test makes a reappearance fail red | ✓ VERIFIED | `tenant.guard.ts` header comment cites 260911-e2s decision + reasoning; `docs/mandantentrennung-etappe2-fehlerrichtung.md` (n4)(a); `docs/mandantentrennung-zugriffsklassifikation.md` "Zwei belegte Befunde" ("Entschieden (260911-e2s, Aufgabe 2)"); `docs/anleitung-entwicklung.md` "## Mandantentrennung" rewritten. Independently broke the property back into the guard style described by SUMMARY's own falsification proof — not re-tested directly (guard no longer accepts it structurally, no constructor param); instead independently falsified the *closely-related* header/role gates below with the same red-then-restore method. `'tenantPrisma' in req` assertions present in all 7 `tenant.guard.spec.ts` cases. | | 2 | `req.tenantId` stays set in all 5 branches; SUPER_ADMIN `x-tenant-id` switch and `ForbiddenException` for tenant-less non-SUPER_ADMIN survive; every branch pinned by a test | ✓ VERIFIED | Read `tenant.guard.ts`: 2 `req.tenantId =` assignments, no Prisma import, header check gated on `user.role === 'SUPER_ADMIN'`. Independently broke the header gate twice (forced `false && ...`, then removed the role check entirely) and re-ran `tenant.guard.spec.ts` each time — exactly 1 named test failed each time, with the expected assertion message; restored and confirmed 7/7 green and `git diff` clean afterwards. | | 3 | `Tenant` classification checked against code and all 34 migrations; bound vs. unbound reads return identical rows (raw SQL + generated client) | ✓ VERIFIED | Ran `rls-scratch-check.mjs` live against `tessera-ctl-db-1` (address resolved fresh: `172.19.0.2`). All 110 checks passed, exit 0, including all 9 named `tenant-*` checks from the plan (`tenant-keine-regel-in-allen-ausgelieferten-migrationen` through `tenant-fan-out-je-mandant-gebundene-zaehlung-stimmt`). Migration scan output explicitly names `20260910120000_rls_widen_membership_grant_and_platform_read` and confirms `"Tenant"` is absent from it. | | 4 | Relation-count finding measured and fixed: 3 of 8 accesses count through the User relation; fixed via bound fan-out | ✓ VERIFIED | Live probe checks 5–7 show the unbound relation counter returning 0 for every tenant while the maintenance role counts >0, and the FK (`User_tenantId_fkey`) loudly catching the vacuum delete-gate with P2003. `tenant.controller.ts` now uses exactly 3 `tenantPrisma.user.count(` calls (verified by grep) and 0 `include`/`_count` occurrences outside comments. | | 5 | Reverse error direction is named: "every tenant has 0 users" (not empty list) and a 500 instead of the 400 message; frontend shown to pass both through | ✓ VERIFIED | `docs/mandantentrennung-etappe2-fehlerrichtung.md` "## Bereich tenant" (n2)/(n3) name `admin/tenants/page.tsx` and `TenantContextSelector.tsx` explicitly, with line references and the exact backend behavior (0 userCount / loud 500 vs. 400). | | 6 | Detection gap of the automated inventory (relation includes into a second table) is named and measured | ✓ VERIFIED | (n4)(b) and the "## Bestandsaufnahme" head both name the gap; Befund G's two lists (`_count` sites, `include:` sites) are reproduced in the doc with per-site judgment. See Advisory note below re: no WINDOWS ledger entry. | | 7 | SUPER_ADMIN restriction read and pinned as a metadata test | ✓ VERIFIED | `tenant.controller.ts` carries class-wide `@Roles(Role.SUPER_ADMIN)`; `tenant.controller.spec.ts` asserts `Reflect.getMetadata(ROLES_KEY, TenantController)` equals `[Role.SUPER_ADMIN]` and, for each of the 5 handlers, that no handler-level override exists. | | 8 | Test landscape for guard and controller created from nothing (previously only 2 cases in `tenant.service.spec.ts`) | ✓ VERIFIED | `tenant.guard.spec.ts` (7 cases) and `tenant.controller.spec.ts` (20 cases) both newly created; both files exist, both pass (confirmed live: 7/7 and 20/20). | | 9 | All five hand-maintained classification doc sections updated and machine-gated | ✓ VERIFIED | Independently recomputed: 64 (file,model) pairs in the Bestandsaufnahme table; class distribution 32/17/13/2 = 64 matches the doc's own "Klassen-Verteilung" table; overview row `\| tenant \| 8 \| 3 \|` matches independently-measured grep counts (`DU=8`, `DB=3`); "Zwei belegte Befunde" carries the 260911-e2s resolution; "Was diese Etappe NICHT entscheidet" first item marked `Aufgelöst (260911-e2s)`. | | 10 | Baseline held: ≥883 tests green, type-check clean, tool ≥110 checks; switch stays OFF, schema/migrations untouched, no compose/env files touched, nothing in AD, NO policy on `Tenant` | ✓ VERIFIED | Orchestrator independently measured 911/911 tests (59 files) and clean type-check (both re-confirmed structurally: `find apps/api/src -name '*.spec.ts' \| wc -l` = 59). `git diff --name-only 6426b18..HEAD -- apps/api/prisma` empty; `-- docker-compose.yml docker-compose.prod.yml '*.env*'` empty; `-- apps/web` empty. Live probe: `pg_class.relrowsecurity` for `"Tenant"` = false, no `CREATE POLICY` on `Tenant` in any of 34 migrations. | **Score:** 10/10 truths verified, 0 present-but-behavior-unverified. ## Independent Falsification (adversarial, not from SUMMARY) All four falsifications below were run by the verifier directly against the working tree, each backed up first and restored immediately after, with `git status --short` / `diff` confirming a byte-identical restore: 1. **Header switch removed for SUPER_ADMIN** (`false && req.headers[...]`) → `tenant.guard.spec.ts` failed exactly 1/7: `SUPER_ADMIN mit tenantId und x-tenant-id-Kopfzeile...`, `expected 't1' to be 't2'`. Restored, 7/7 green. 2. **Role gate removed** (header now honoured for ANY role) → failed exactly 1/7: `Nutzer der Rolle ADMIN mit tenantId UND x-tenant-id-Kopfzeile... (T-04-03)`, `expected 't2' to be 't1'`. Restored, 7/7 green. 3. **`findOne` bound counter replaced with unbound `(this.prisma as any).user.count`** → `tenant.controller.spec.ts` failed exactly 2/20 with `TypeError: Cannot read properties of undefined (reading 'count')` — matches SUMMARY's claimed falsification exactly. Restored, 20/20 green. 4. **Stale entry injected into `FORTENANT_ASSIGNMENT_EXCEPTIONS`** (`'apps/api/src/does-not-exist.ts'`) → the new watchdog test failed exactly as designed: `"apps/api/src/does-not-exist.ts: Datei existiert nicht mehr"`. Restored, 11/11 green. All four confirm the tests genuinely exercise the invariants they claim to pin, not just that the invariants happen to hold today. ## Required Artifacts | Artifact | Expected | Status | Details | |----------|----------|--------|---------| | `apps/api/scripts/rls-scratch-check.mjs` | 12th section `runTenantAreaChecks`, ≥9 named checks, 5+ over generated client | ✓ VERIFIED | Confirmed at line 3163, called between `runCalendarAreaChecks` and `runTransactionShapeMeasurement` (line order verified). Live run: all 9 named checks pass, 110/110 total. | | `docs/mandantentrennung-etappe2-fehlerrichtung.md` | `## Bereich tenant` with (n1)-(n5) | ✓ VERIFIED | All five `### (nX)` subsections present at line 2262+. | | `apps/api/src/tenant/tenant.guard.ts` | sets only `req.tenantId`, no Prisma dependency | ✓ VERIFIED | Confirmed by direct read; no constructor, no `forTenant`/`PrismaService` import. | | `apps/api/src/tenant/tenant.middleware.ts` | DELETED | ✓ VERIFIED | `test -e` confirms absence. | | `apps/api/src/tenant/tenant.guard.spec.ts` | NEW, all 5 branches + property-absence + header-only-SUPER_ADMIN | ✓ VERIFIED | 7 cases, all read and confirmed present. | | `apps/api/src/prisma/rls-access-inventory.spec.ts` | `FORTENANT_ASSIGNMENT_EXCEPTIONS` emptied + watchdog | ✓ VERIFIED | `new Set([])`; watchdog test confirmed to fire (see falsification #4). | | `apps/api/src/app.module.ts`, `module.guard.ts`, `dkv.controller.ts` | comment-only fixes | ✓ VERIFIED | Reviewed diffs manually; no `TenantMiddleware`/`.tenantPrisma` references remain anywhere in `apps/api/src`. | | `apps/api/src/tenant/tenant.controller.ts` | 3 bound fan-out counters, 4 unbound tenant accesses | ✓ VERIFIED | Grep confirms exactly 3 `tenantPrisma.user.count(` and 4 `this.prisma.tenant.` occurrences; 0 `include`/`_count`. | | `apps/api/src/tenant/tenant.controller.spec.ts` | NEW, two-client proof, all behaviors, role metadata, watchdog | ✓ VERIFIED | 20 cases, all read and confirmed to match plan's `` spec. | | `docs/mandantentrennung-zugriffsklassifikation.md` | 5 hand-maintained sections updated | ✓ VERIFIED | 64 pairs independently recomputed and cross-checked against the class-distribution table. | | `docs/anleitung-entwicklung.md` | Guard description without request-object client; middleware hint box replaced | ✓ VERIFIED | 0 occurrences of `req.tenantPrisma`/`TenantMiddleware` in the file; obsolete table list intentionally left unchanged per (n5). | ## Key Link Verification | From | To | Via | Status | Details | |------|----|----|--------|---------| | `TenantGuard` | `app.module.ts` `APP_GUARD` registration | order JwtAuthGuard → TenantGuard → RolesGuard | ✓ WIRED | Confirmed by direct read of `app.module.ts` lines 50-65. | | Prisma `include: {_count}` | single SQL statement, LEFT JOIN into `User` | Prisma 6.19 query rendering | ✓ VERIFIED (live) | Reproduced live via probe checks 5-7 against the running dev DB, not merely asserted. | | `User_tenantId_fkey` (`ON DELETE RESTRICT`) | referential check bypasses RLS | live delete against scratch DB | ✓ VERIFIED (live) | Check 7 output shows P2003 thrown, row still visible via maintenance role. | | Fan-out pattern | `UserService.findAllForPlatformAdmin` | identical form (`this.prisma.tenant.findMany` unbound driver + `forTenant()` bound counter per tenant) | ✓ VERIFIED | Confirmed by direct code comparison — same structure. | | SUPER_ADMIN `x-tenant-id` header | marketplace frontend | 4 send sites | ✓ VERIFIED | `grep -rn "x-tenant-id" apps/web/src` returns exactly 4 hits in `marketplace/page.tsx` and `marketplace/[slug]/page.tsx`, matching the plan's claim. | ## Behavioral Spot-Checks / Probe Execution | Probe | Command | Result | Status | |-------|---------|--------|--------| | `apps/api/scripts/rls-scratch-check.mjs` (live, adversary-resolved DB address) | `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` | `Alle 110 Pruefungen bestanden.`, exit 0 | ✓ PASS | | `tenant.guard.spec.ts` (single file) | `npm --prefix apps/api run test -- src/tenant/tenant.guard.spec.ts` | 7/7 | ✓ PASS | | `tenant.controller.spec.ts` (single file) | `npm --prefix apps/api run test -- src/tenant/tenant.controller.spec.ts` | 20/20 | ✓ PASS | | `rls-access-inventory.spec.ts` (single file) | `npm --prefix apps/api run test -- src/prisma/rls-access-inventory.spec.ts` | 11/11 | ✓ PASS | Full-suite result (911/911, 59 files) and `type-check` (clean) were not re-run in full by this verifier — already independently measured by the orchestrator per the task brief; spec-file count (59) was independently confirmed by filesystem enumeration. ## Scope / Allow-list Verification `git diff --name-only 6426b18..HEAD` returns exactly the 13 files declared in the PLAN's `files_modified` frontmatter — no more, no less. No changes under `apps/api/prisma`, `apps/web`, `docker-compose*.yml`, or any `.env*` file. Working tree is clean except the untracked SUMMARY.md (expected — committed by the orchestrator, not the task commits). ## Requirements Coverage | Requirement | Description | Status | Evidence | |-------------|-------------|--------|----------| | WINDOWS-18 | Switch stays OFF; measurement tool covers the `tenant` area | ✓ SATISFIED | 110/110 checks pass live; switch confirmed unchanged (role `tessera`, `BYPASSRLS`, not touched by this diff). | | ETAPPE-2-TENANT | Guard/controller/tests for the `tenant` area | ✓ SATISFIED | All artifacts and truths above. | ## Anti-Patterns Found None of TBD/FIXME/XXX/TODO/HACK/PLACEHOLDER found in any of the 13 changed files. No stub returns, no empty handlers, no hardcoded-empty props found in the reviewed source files. ## Judgment Call Requested (Advisory, non-blocking) **Item 8 of the verification brief:** should the absence of a WINDOWS ledger entry for the structural blind spot in `rls-access-inventory.spec.ts` (relation includes/`_count` into a second table are invisible to the (file, model)-pair scanner) be acceptable? **My judgment: it should be recorded regardless, even though it does not block this phase.** The plan's own reasoning for skipping a ledger entry — "the only dangerous instance found across the whole API source was this one, and it's fixed here" — closes out today's *instances*, not the underlying *mechanism*. The scanner will remain structurally blind to any *future* `include: { _count }` (or similar relation-count) access into a row-level-secured table; nothing added by this task changes that. This is exactly the category of finding the project's own WINDOWS ledger exists to track (compare entries #24, #19, #25, #26 in `.planning/WINDOWS.md`, all of which record a persisting structural gap rather than a fixed one-off). The plan did document the gap thoroughly (measured lists of all 19 `include:` and all `_count` sites, judged individually, in (n4)(b) and the Bestandsaufnahme head) and carried it in the threat register as T-E2S-09 (medium, accept) — so this is not a hidden risk, just an un-ledgered one. This does not affect the phase's must-have truths (truth #6 only requires the gap to be *named and measured*, which it is) and is therefore **not a gap** for this task, but is flagged here for a human decision on whether to open a WINDOWS entry going forward. ## Gaps Summary None. All 10 must-have truths verified with adversarial, independently reproduced evidence (including 4 successful red-then-restore falsifications and a live 110/110 probe run against the actual database). Scope is exactly the declared 13-file allow-list. One advisory judgment call is flagged above (WINDOWS ledger entry) — it does not block phase completion. --- *Verified: 2026-09-11T09:06:02Z* *Verifier: Claude (gsd-verifier)*