Files
tessera-ctl/.planning/quick/260910-das-mandantentrennung-etappe-2-bereich-user-/260910-das-VERIFICATION.md
T

202 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)*