feat(auth): typed TenantId scoping foundation + reports migration (PMS-139) #158

Merged
longjacksonle merged 1 commit from feat/pms-139-tenant-id-foundation into main 2026-06-11 05:16:38 +02:00

What

Phase 1 (foundation) of PMS-139: make a forgotten or wrong-tenant service call a compile error, and establish the migration pattern with one fully-migrated module. The remaining ~17 modules follow as separate PRs (the ticket explicitly allows splitting).

The type

src/modules/auth/tenant.rs adds TenantId:

  • #[sqlx(transparent)] newtype over Uuid, so .bind(tenant_id) and query_as decoding are unchanged, and it Derefs to Uuid + impl Display so the rare leaf that needs the raw value uses *tenant_id / tenant_id.get().
  • The only in-crate constructor TenantId::new is pub(crate), reached solely through TenantScoped::tenant() on an authenticated CurrentUser. So a TenantId always traces back to a verified tenant claim - it can't be conjured from request input.
  • pub fn from_trusted(Uuid) is the explicitly-named escape hatch for super-admin cross-tenant operations and tests (the AC permits this; it's greppable for review).
  • TenantScope.tenant_id is retyped from Uuid to TenantId.

Reference migration: reports

Fully migrated end-to-end as the pattern the sweep will follow:

  • Handlers: RequireReports { user: u, .. } -> u.tenant() passed to the service.
  • service.rs (6 methods) + custom::run take tenant_id: TenantId. Every .bind(tenant_id) and #[tracing::instrument(fields(tenant_id = %tenant_id))] works unchanged.

The guarantee, pinned

A compile_fail doctest on TenantId asserts a raw Uuid is rejected where a TenantId is required. I chose a doctest over trybuild because it needs no .stderr fixture (no rust 1.94-vs-1.95 drift). CI gains a cargo test --doc step so it actually runs (--lib does not execute doctests); the full doctest suite is green.

Acceptance criteria (this phase)

  • TenantId newtype added; TenantId::new is pub(crate), external callers go through TenantScope/from_trusted.
  • One module (reports) fully on TenantId as the reference; its public service signatures take TenantId, not Uuid.
  • Compile-fail test pins that a raw Uuid cannot be passed where a TenantId is required.
  • Existing tests pass (--lib 115, reports 8, doctests green; clippy --all-targets clean).
  • All handlers migrated + cross-cutting #8 removed - deferred to the follow-up sweep PRs (dev-docs #8 updated to in-progress with the plan).

Follow-ups

Per-module batches: contacts/tickets/billing/projects/assets/contracts/calendar/knowledge_base/rmm/time_tracking/settings/notifications/portal/sla/audit/tenants/auth. Each is the same mechanical change (u.tenant() in handlers, tenant_id: TenantId in service.rs), compiles independently, and the tests that construct services directly switch to TenantId::from_trusted(...).

🤖 Generated with Claude Code

## What Phase 1 (foundation) of PMS-139: make a forgotten or wrong-tenant service call a **compile error**, and establish the migration pattern with one fully-migrated module. The remaining ~17 modules follow as separate PRs (the ticket explicitly allows splitting). ## The type `src/modules/auth/tenant.rs` adds `TenantId`: - `#[sqlx(transparent)]` newtype over `Uuid`, so `.bind(tenant_id)` and `query_as` decoding are **unchanged**, and it `Deref`s to `Uuid` + `impl Display` so the rare leaf that needs the raw value uses `*tenant_id` / `tenant_id.get()`. - The only in-crate constructor `TenantId::new` is **`pub(crate)`**, reached solely through `TenantScoped::tenant()` on an authenticated `CurrentUser`. So a `TenantId` always traces back to a verified tenant claim - it can't be conjured from request input. - `pub fn from_trusted(Uuid)` is the **explicitly-named escape hatch** for super-admin cross-tenant operations and tests (the AC permits this; it's greppable for review). - `TenantScope.tenant_id` is retyped from `Uuid` to `TenantId`. ## Reference migration: `reports` Fully migrated end-to-end as the pattern the sweep will follow: - Handlers: `RequireReports { user: u, .. }` -> `u.tenant()` passed to the service. - `service.rs` (6 methods) + `custom::run` take `tenant_id: TenantId`. Every `.bind(tenant_id)` and `#[tracing::instrument(fields(tenant_id = %tenant_id))]` works unchanged. ## The guarantee, pinned A `compile_fail` doctest on `TenantId` asserts a raw `Uuid` is rejected where a `TenantId` is required. I chose a doctest over `trybuild` because it needs no `.stderr` fixture (no rust 1.94-vs-1.95 drift). CI gains a `cargo test --doc` step so it actually runs (`--lib` does not execute doctests); the full doctest suite is green. ## Acceptance criteria (this phase) - [x] `TenantId` newtype added; `TenantId::new` is `pub(crate)`, external callers go through `TenantScope`/`from_trusted`. - [x] One module (`reports`) fully on `TenantId` as the reference; its public service signatures take `TenantId`, not `Uuid`. - [x] Compile-fail test pins that a raw `Uuid` cannot be passed where a `TenantId` is required. - [x] Existing tests pass (`--lib` 115, `reports` 8, doctests green; clippy `--all-targets` clean). - [ ] All handlers migrated + cross-cutting #8 removed - **deferred to the follow-up sweep PRs** (dev-docs #8 updated to in-progress with the plan). ## Follow-ups Per-module batches: contacts/tickets/billing/projects/assets/contracts/calendar/knowledge_base/rmm/time_tracking/settings/notifications/portal/sla/audit/tenants/auth. Each is the same mechanical change (`u.tenant()` in handlers, `tenant_id: TenantId` in `service.rs`), compiles independently, and the tests that construct services directly switch to `TenantId::from_trusted(...)`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): typed TenantId scoping foundation + reports migration (PMS-139)
All checks were successful
E2E (staging) / Playwright against staging (pull_request) Successful in 33s
Check / fmt + clippy + compile + tests (pull_request) Successful in 1m25s
Create release / Create release from merged PR (pull_request) Has been skipped
Build OCI container / Build and push mokosh-api image (push) Successful in 3m25s
2cc6511b3f
Phase 1 of the typed tenant-scoping work (PMS-21 AC2 / cross-cutting #8). Introduce a TenantId newtype in src/modules/auth/tenant.rs: a #[sqlx(transparent)] wrapper over Uuid that binds to SQL unchanged and Derefs to Uuid, but whose only in-crate constructor (TenantId::new) is pub(crate) and is reached solely through TenantScoped::tenant() on an authenticated CurrentUser. So a TenantId always traces back to a verified tenant claim, and a service method taking tenant_id: TenantId can no longer be called with a bare Uuid - a forgotten or wrong-tenant argument is now a compile error. A pub from_trusted escape hatch is the explicitly-named seam for super-admin cross-tenant ops and tests.

TenantScope.tenant_id is retyped to TenantId. The reports module is fully migrated as the reference pattern: handlers read u.tenant() and the service + custom::run take TenantId (all .bind(tenant_id) and tracing %tenant_id work unchanged via the transparent newtype + Display). The guarantee is pinned by a compile_fail doctest on TenantId, and CI gains a `cargo test --doc` step to run it (--lib does not). dev-docs cross-cutting #8 is updated to in-progress with the remaining-modules plan; the other ~17 modules are follow-up PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/pms-139-tenant-id-foundation 2026-06-11 05:16:38 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!158
No description provided.