# Multi-tenant foundation (books & the tenant switcher)

**Status:** in production (code-complete, pending deploy)
**Lives at:** the AppShell header switcher, `GET /api/me`, `POST /api/tenant`;
`app/services/authz/context.py` (membership + current-tenant resolution),
`app/routers/deps.py` (the NO_TENANT gate), `app/routers/tenant.py`,
`app/services/authz/view_as.py` (intersection rule),
`frontend/src/components/TenantSwitcher.tsx` +
`frontend/src/features/tenant/tenantSwitch.ts`
**Spec:** new (R2); supersedes ADR #004 #13 — see ADR #015.

## Summary

Each set of books is a **tenant**. A user only ever sees the tenants they
belong to; a super admin (rian) sees all of them and can switch between them
with a header dropdown. Everything a user reads or writes is scoped to the
one **current tenant** for that request — so a PlusROI-only partner can never
see a Bowden Works row, and vice versa.

## Why

2026-07-11 — the app is going multi-agency. rian needs to run both the Bowden
Works and (incoming) PlusROI books from one login and switch between them,
while partner users (`rob`, `danielle`) must land ONLY in PlusROI and never
see a single Bowden Works row. R1 had pinned every query to one tenant via a
process setting (`APP_TENANT_SLUG`); that had to become per-user + per-request.

## Behavior

- **Membership is derived, not stored.** A user's allowed tenants are the
  tenants where their person party holds an ACTIVE role-bearing engagement
  (owner via `official_partnership`, manager/worker via `subcontract` — the
  same engagements the authz grid reads), PLUS every tenant for super admins.
  An ended engagement grants nothing.
- **The current tenant** is held in an HttpOnly `with_tenant` cookie (the
  tenant slug). Per request: the cookie's tenant if the user is a member,
  else their first allowed tenant, else none.
- **The switcher** (`TenantSwitcher`) shows in the header ONLY when the user
  belongs to more than one tenant. It shows the current book, and picking
  another `POST`s `/api/tenant`, drops the whole react-query cache, and
  navigates home so every view refetches under the new book. A one-book user
  never sees it.
- **`GET /api/me`** carries `tenants: [{slug,name}]` (the menu) and
  `tenant: {slug,name}|null` (current), alongside the existing identity +
  capabilities + view-as fields.
- **Under view-as**, the tenant context is the real user's allowed set
  intersected with the target's — impersonation never widens tenant access.
  Viewing as a PlusROI-only user moves the context to PlusROI.

## Constraints & edge cases

- **No allowed tenants → `403 NO_TENANT`** on tenant-scoped `/api` data
  routes (the SPA shows a "no books here yet" state). `/api/me`,
  `/api/tenant`, `/api/view-as`, and `/api/saved-views` still answer for such
  a user (identity, the switch endpoint, exiting view-as, and per-user nav
  prefs are not tenant data).
- **Switching to a book you don't belong to → `403 TENANT_FORBIDDEN`**; the
  cookie is not changed.
- **A stale cookie** (points at a tenant you've since lost / that was
  deleted) falls back silently to your first allowed tenant — never an error.
- **Entering view-as a user who shares no tenant with you → `409
  VIEW_AS_TENANT_MISMATCH`** (e.g. a brand-new account with zero tenants).
- **Direct GET of another tenant's object id is a `404`** (existence hidden),
  never a `403` — the same tenant-scoped-resolve behavior every service
  already had; the current-tenant change just makes which tenant is "yours"
  request-scoped.
- **Switching invalidates nothing server-side** — every query already flows
  through `scope(actor)` with the actor's `tenant_id`; the change only makes
  that id come from the request instead of a setting.
- `settings.app_tenant_slug` survives ONLY as the default-tenant ordering
  hint (a super admin's own books open first) and the single-tenant-DB / test
  default. It is no longer a hard pin; nothing else reads it.

## Permissions

Tenant membership is orthogonal to the capability grid ([read-mirror.md](read-mirror.md)):
`tenant_id` selects WHICH books, the grid selects WHAT you may do within them.
Switching the current tenant needs no capability (it's a nav preference); a
super admin sees the switcher because super admins are members of every
tenant. Roles (owner/manager/worker) are re-derived per current tenant, so a
user who is an owner in one book and a worker in another gets the right
capabilities in each.

## Open considerations

- No timed default — the cookie persists ~1 year (a nav preference). If a
  user's membership is revoked mid-session their next request falls back
  safely, but the app session's role cache updates at the ≤12h re-mint (as
  today, ADR #002 #5).
- A per-tenant "last used" memory / deep-link `?tenant=` is deferred; today
  the switcher + cookie are enough. Saved views are per-user and NOT
  tenant-scoped yet (a view's filter is portable), which is fine for R2.
- The NO_TENANT / TENANT_FORBIDDEN SPA states are structured errors today;
  the friendly full-page "no books" empty state is a thin follow-up.

## Tests

- `tests/tenancy/test_membership.py` — engagement-derived membership
  (owner/manager/worker → tenant appears; ended → gone; super admin → all,
  default first), cookie selection + fallback.
- `tests/tenancy/test_isolation.py` — a tenant-B-only user sees ZERO tenant-A
  rows across `/api/entries`, `/summary`, `/projects`, `/invoices`,
  `/import/batches`, `/comments`, `/cc/batches`; direct GET of a tenant-A id →
  404; zero-shape totals for a member who can see nothing.
- `tests/tenancy/test_cookie_and_view_as.py` — `/api/me` shape, valid switch,
  forbidden switch (403), stale-cookie fallback, NO_TENANT gate, and the
  view-as tenant-intersection rule incl. the 409.
- `tests/authz/test_tenant_context.py` — the same context rules over the
  authz mini-graph (SQLite): super-admin-sees-all, cookie switch, fallback.
- `frontend/src/features/tenant/tenantSwitch.test.ts` — switcher visibility
  (hide at ≤1 tenant) and the switch → invalidate → navigate sequence.

## Changelog

- 2026-07-11 — Feature created (0.12.0). Request-scoped current tenant +
  engagement-derived membership + the header tenant switcher; supersedes the
  R1 tenant-is-a-setting model (ADR #015).
