--- phase: quick-260910-das verified: 2026-09-10T08:43:00Z status: passed score: 10/10 must-haves verified covered_files: - .planning/WINDOWS.md - .planning/quick/260910-das-mandantentrennung-etappe-2-bereich-user-/260910-das-PLAN.md - .planning/quick/260910-das-mandantentrennung-etappe-2-bereich-user-/260910-das-SUMMARY.md - apps/api/scripts/rls-scratch-check.mjs - apps/api/src/user/admin-seed.service.spec.ts - apps/api/src/user/admin-seed.service.ts - apps/api/src/user/user.controller.spec.ts - apps/api/src/user/user.controller.ts - apps/api/src/user/user.service.spec.ts - apps/api/src/user/user.service.ts - docs/mandantentrennung-etappe2-fehlerrichtung.md - docs/mandantentrennung-zugriffsklassifikation.md covered_digest: "v1:sha256:57beb919b493cdad90d7e21464d014838272020b4459348b970c7c9f0fec2bed" behavior_unverified: 0 overrides_applied: 0 --- # Phase quick-260910-das: Mandantentrennung Etappe 2, Bereich `user` — Verification Report **Phase Goal:** Bind the tenant-bound administration paths in `apps/api/src/user/` while deliberately NOT binding the platform-wide uniqueness/lookup paths, defuse the post-cutover startup blocker, and leave the classification document's four hand-maintained sections in sync. **Verified:** 2026-09-10T08:43:00Z **Status:** passed **Re-verification:** No — initial verification ## Independent Re-Measurement Summary All ten investigation items from the verification brief were independently re-measured against the live codebase and a live throwaway-database run — not read off the SUMMARY. Findings: 1. **Startup blocker genuinely defused, and only it.** `admin-seed.service.ts` binds admin creation to the just-created tenant (`forTenant(this.prisma, tenant.id)`) and catches `err?.code === 'P2002'` specifically, logging and returning instead of throwing. Every other error still throws — confirmed by running Test 10 (P2002 absorbed, no throw) and Test 11 (`connection refused` still rejects `onApplicationBootstrap()`) individually; both pass. No over-broad catch exists. 2. **Bind/don't-bind line drawn per method, with reason at each site.** Read every method of `user.service.ts`, `admin-seed.service.ts`, and `user.controller.ts`. All administration paths (`findById`, `create`, `update`, `deactivate`, `delete`, both platform-admin methods, all seven controller accesses) run through `tenantPrisma`. `findByUsername` and the seed-check lookup are the only deliberately unbound paths, each carrying an in-code comment naming the reason (platform-wide uniqueness of `username`) and the `resolveEmailForWrite` precedent. 3. **`findByUsername` caller count.** `grep -rn "findByUsername" apps/api/src packages` returns exactly one hit — the definition itself (`user.service.ts:51`). No production caller. The comment at the definition now states this measured fact and correctly attributes the login path to the three SECURITY DEFINER functions (Etappe 1, 260909-eor) instead of claiming cross-tenant login still depends on this method. 4. **Self-delete guard.** Confirmed the code now compares `user.id === currentUser.id` (not `.sub`). Independently reverted the comparison back to `currentUser.sub` and re-ran the single named test (`user.controller.spec.ts`, "Test 6: der Riegel gegen das Löschen des eigenen Kontos greift") — it failed with `promise resolved "{ message: 'User deleted' }" instead of rejecting`, exactly the failure mode described in the SUMMARY. Reverted the temporary change back (file now matches the committed state, `git diff` clean). The falsification claim holds. 5. **SUPER_ADMIN view.** `UserService.findAllForPlatformAdmin` / `findByIdForPlatformAdmin` loop over `this.prisma.tenant.findMany()` (unbound, `Tenant` carries no RLS — confirmed via the scratch check's `pg_class.relrowsecurity` measurement) and issue one bound `tenantPrisma.user.*` call per tenant inside the loop, matching the `ensureDefaultGroupsForAllTenants()` precedent. `UserController.findAll` and `resolveTargetUser` only route to these methods when `currentUser.role === Role.SUPER_ADMIN`; a non-SUPER_ADMIN caller always goes through the tenant-bound branch. No cross-tenant leak to a non-SUPER_ADMIN caller. 6. **Mid-task deviation (Rule 3).** `git show 888f660 -- docs/... klassifikation.md` shows the task-2 correction updated only the `Stand` column (to `gemischt`) and added the new `(user.service.ts, tenant)` row — an honest, accurate description of the intermediate state, not a loosened check. The full class corrections followed in task 3 as planned. Legitimate. 7. **Four hand-maintained sections.** Recomputed the class distribution directly from the 63 Bestandsaufnahme rows via `awk` (independent of the document's own summary table): `muss-mandantengebunden=31`, `keine-mandantengebundene-tabelle=17`, `beides=13`, `bewusst-uebergreifend=2`, total `63` — matches the document's "Klassen-Verteilung" table exactly. The "Übersicht je Bereich" row for `user` (8 ungebunden / 14 gebunden) matches a fresh `grep -ro "this\.prisma\.[a-zA-Z]*"` / `tenantPrisma\.[a-zA-Z]*\.` count. "Der Hintergrunddienst als Falle" section lists five cases (was four), with the `admin-seed.service.ts` case correctly described as the first already-correct-on-both-halves case. 8. **Measurements committed, not merely described.** Ran `apps/api/scripts/rls-scratch-check.mjs` myself against a freshly resolved `tessera-ctl-db-1` IP (`172.19.0.2`, resolved fresh via `docker inspect`, not copied from any document). Output: **"Alle 53 Pruefungen bestanden."** — 41 prior + 12 new, all twelve named `user-*` checks present and passed, including `user-eindeutigkeit-greift-trotz-unsichtbarkeit`, which explicitly distinguishes SQLSTATE 23505 (uniqueness violation) from 42501 (row-security rejection) in its own message text. 9. **Falsification proofs in SUMMARY.** Present for four sites, each naming the exact broken binding and the exact named test that went red (`UserService.findById`, `AdminSeedService.seedAdmin`, `UserController.uploadAvatar`, and the self-delete-guard red-before-fix). Independently reproduced the self-delete-guard proof (item 4 above); the other three read as specific and plausible given the test code inspected. 10. **Constraints held.** `git diff --name-only 7e7a697..HEAD` touches exactly the 10 files listed in `files_modified` — none under `apps/api/prisma`, no compose file, no env file, nothing under `auth/` or `ldap/` (confirmed via `git diff --stat` against those directories: empty). `docker-compose.yml` still defaults `DATABASE_URL` to the `tessera` role (BYPASSRLS, switch off). WINDOWS #22 records the platform-wide uniqueness question as `open`, not decided, with no schema/migration change. ## Goal Achievement ### Observable Truths | # | Truth | Status | Evidence | |---|---|---|---| | 1 | Bind/don't-bind line drawn per method, justified in code, no direction silently decided | ✓ VERIFIED | `user.service.ts`, `admin-seed.service.ts`, `user.controller.ts` — every method inspected; unbound paths carry written reasons | | 2 | Chain (invisible row → false "free" → hard uniqueness error) measured at the real shipped policy, distinguishing 23505 from 42501 | ✓ VERIFIED | `rls-scratch-check.mjs` run live: `user-eindeutigkeit-greift-trotz-unsichtbarkeit` passes with SQLSTATE 23505, explicitly not 42501 | | 3 | Worst inverse-error-direction case (startup blocker) found, measured, and defused in application code only | ✓ VERIFIED | `admin-seed.service.ts` catches P2002 specifically; Test 10/11 individually run and pass; no schema change | | 4 | Classification line for admin first-creation corrected (tenant is known, not structurally absent) | ✓ VERIFIED | `docs/mandantentrennung-zugriffsklassifikation.md` row for `(admin-seed.service.ts, user)`, class `beides`, with Befund-J correction text | | 5 | Etappe-1 login path untouched; `findByUsername`'s stale comment corrected with measured caller count | ✓ VERIFIED | `git diff --stat` empty for `auth/`/`ldap/`; `findByUsername` comment states "genau EINEN Treffer... kein Aufrufer", confirmed via fresh grep | | 6 | Platform-admin overview preserved as a bound loop over all tenants, not silently degraded or broken | ✓ VERIFIED | `findAllForPlatformAdmin`/`findByIdForPlatformAdmin`; Test 6/7 in `user.service.spec.ts`; scratch check `user-fan-out-je-mandant-gebunden-liefert-alle-zeilen` passes | | 7 | Existing self-delete gap closed | ✓ VERIFIED | Code compares `currentUser.id`; independently reverted and confirmed Test 6 in `user.controller.spec.ts` goes red, then restored | | 8 | Test coverage repaired across all three files with two-client proof | ✓ VERIFIED | `user.service.spec.ts` (13 tests), `admin-seed.service.spec.ts` (10 tests), `user.controller.spec.ts` (8 tests, new file) — all inspected and run | | 9 | Classification doc and `rls-access-inventory.spec.ts` in sync, including all four hand-maintained sections plus the fifth background-service case | ✓ VERIFIED | Recomputed 63/31/17/13/2 from raw Bestandsaufnahme rows; matches document; `rls-access-inventory.spec.ts` green (part of 810/810) | | 10 | 789+ tests and type-check green, tool reports all checks passed, schema/migrations/compose/env unchanged, switch stays off | ✓ VERIFIED | 810/810 tests green (independently re-run), `type-check` exit 0, scratch tool "Alle 53 Pruefungen bestanden.", `git diff --name-only` = exactly the 10 declared files | **Score:** 10/10 truths verified (0 present-but-behavior-unverified) ### Required Artifacts | Artifact | Expected | Status | Details | |---|---|---|---| | `apps/api/scripts/rls-scratch-check.mjs` | Seventh section `runUserAreaChecks`, 12 new named checks | ✓ VERIFIED | Present, run live, all 12 pass alongside the prior 41 | | `docs/mandantentrennung-etappe2-fehlerrichtung.md` | `## Bereich user` section (u1–u5) | ✓ VERIFIED | All five subsections present; (u1) contains the actual pasted measurement output, not a narration | | `apps/api/src/user/user.service.ts` | Bound admin methods, unbound `findByUsername` with corrected comment | ✓ VERIFIED | Inspected in full | | `apps/api/src/user/user.service.spec.ts` | Two-client proof, 13 tests | ✓ VERIFIED | Present, run, passes | | `apps/api/src/user/admin-seed.service.ts` | Bound first-admin creation, P2002 absorption | ✓ VERIFIED | Inspected in full | | `apps/api/src/user/admin-seed.service.spec.ts` | Two-client proof, 10 tests | ✓ VERIFIED | Present, run, passes | | `apps/api/src/user/user.controller.ts` | All 7 accesses bound, self-delete guard fixed | ✓ VERIFIED | Inspected in full | | `apps/api/src/user/user.controller.spec.ts` | New file, two-client proof, 8 tests | ✓ VERIFIED | Present, run, passes | | `docs/mandantentrennung-zugriffsklassifikation.md` | All four hand-maintained sections updated | ✓ VERIFIED | Recomputed arithmetic matches | | `.planning/WINDOWS.md` | Open entry for platform-wide uniqueness | ✓ VERIFIED | Entry #22, status `open`, recorded not decided | ### Key Link Verification | From | To | Via | Status | |---|---|---|---| | bound client | `tenant_isolation_policy` on `User` (from migration `20260618112133_rls_policies`) | `user-policy-aus-migration-wortgleich` | ✓ WIRED — check passes, policies wordidentical | | platform-wide unique `username`/`email` | bound collision check | `user-gebundene-suche-nach-fremdem-benutzernamen-liefert-keine-zeile` + `user-eindeutigkeit-greift-trotz-unsichtbarkeit` | ✓ WIRED — chain measured end to end | | Erstanlage-check | platform-wide uniqueness | uncapsulated `seedAdmin()` | ✓ WIRED — Test 10/11 individually confirm both halves | | freshly created tenant | first-admin insert | `tenant.id` passed into `forTenant()` | ✓ WIRED — code + Test 9 | | `Tenant` table without RLS | platform-admin view + default-group repair loop drivers | `this.prisma.tenant.findMany()` | ✓ WIRED — `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar` passes | | Etappe-1 SECURITY DEFINER functions | `findByUsername` boundary | corrected comment + caller-count measurement | ✓ WIRED — grep confirms zero callers | | `rls-access-inventory.spec.ts` | classification doc's Stand/Klassen-Verteilung/Summenzeilen | machine check | ✓ WIRED — green in full suite run, arithmetic independently recomputed | ### Behavioral Spot-Checks | Behavior | Command | Result | Status | |---|---|---|---| | Self-delete guard actually guards | revert to `currentUser.sub`, run named test | Test failed with described error, then restored | ✓ PASS | | P2002 absorbed, other errors abort | run Test 10 and Test 11 individually | Both pass independently | ✓ PASS | | Scratch DB tool reports the full check set | `TESSERA_SCRATCH_ADMIN_URL=... node rls-scratch-check.mjs` against freshly resolved container IP | "Alle 53 Pruefungen bestanden." | ✓ PASS | | Full test suite | `npm run test` (apps/api) | 810/810 passed | ✓ PASS | | Type check | `npm run type-check` (apps/api) | exit 0 | ✓ PASS | ### Anti-Patterns Found None. Scanned all 9 modified/created code and doc files for `TBD`/`FIXME`/`XXX`/`TODO`/`HACK`/`PLACEHOLDER` — zero hits. ### Requirements Coverage | Requirement | Description | Status | Evidence | |---|---|---|---| | WINDOWS-18 | Chain measured at real deployed policy, SQLSTATE distinction | ✓ SATISFIED | `runUserAreaChecks`, live run, `user-eindeutigkeit-greift-trotz-unsichtbarkeit` | | ETAPPE-2-USER | User area bound per the bind/don't-bind rule, startup blocker defused, docs in sync | ✓ SATISFIED | All 10 truths above | No orphaned requirements found for this phase in `.planning/WINDOWS.md`/`REQUIREMENTS.md` cross-reference. ### Human Verification Required None. All must-haves are verifiable via code inspection, a live database run, and test execution — no UI, visual, or external-service behavior is in scope for this phase. ### Gaps Summary No gaps found. All ten must-have truths, all ten required artifacts, and all seven key links independently re-verified against the live codebase and a live throwaway-database run — not accepted from the SUMMARY. The self-delete-guard falsification claim was independently reproduced (revert → red → restore → clean diff). The class-distribution arithmetic (63 pairs: 31/17/13/2) was independently recomputed from raw table rows, not copied from the document's own summary line. The scratch-check tool was re-run against a freshly resolved container address and reports 53/53 passing, matching the claimed 41+12. Constraints (no schema/migration/compose/env change, login path untouched, switch off) all hold under independent `git diff` inspection. --- *Verified: 2026-09-10T08:43:00Z* *Verifier: Claude (gsd-verifier)*