diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 634e12df..759e5b6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,6 +127,7 @@ jobs: - simple_module_permissions - simple_module_settings - simple_module_site_lock + - simple_module_tenants - simple_module_users environment: name: pypi diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ea8cb8..c754a425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,28 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] ### Added +- **Postgres test runs** (#343) — `SM_TEST_DATABASE_URL` points the + `simple_module_test` fixtures at Postgres, and `make test-py-pg` runs the + whole Python suite there. The schema is reset once per test, so `app` and + `db_session` see each other's rows as they would in production. +- **`tenants` module** — SaaS organisations: tenants, many-to-many memberships + with per-tenant roles (`owner`/`admin`/`member`, surfaced as `tenant:` + on the active tenant only), email-bound invitations, platform suspend / + reactivate, and the membership-validated tenant resolver. Ships the seams a + billing module needs: an `EntitlementProvider` on + `app.state.tenants.entitlements` (seat limits enforced, HTTP 402), lifecycle + via `TenantService.set_status`, and after-commit domain events. See + [docs/framework/multi-tenancy.md](docs/framework/multi-tenancy.md). +- `simple_module_db.tenant_context()` / `all_tenants()` and the + `all_tenants=True` execution option, for acting as one tenant — or + deliberately across tenants — outside a request. +- `TenantMiddleware` consults `app.state.tenant_resolver` when a module + registers one. +- `background_tasks` carries the enqueuing request's tenant into the Celery + task and restores it around the task body. +- Doctor check `SM024`: a unique key on a `MultiTenantMixin` table that omits + `tenant_id`. + - `InvalidationBus` — a framework-level cache-invalidation channel any module can publish on (`ModuleBase.register_invalidations`, `app.state.sm.invalidation`). In-process by default; `background_tasks` installs a Redis pub/sub transport on @@ -45,6 +67,45 @@ All notable changes to this project are documented in this file. The format is b generates real `SM_USERS_*_TOKEN_SECRET` values into `.env.example` so the production-mode containers pass `UsersSettings` boot validation. +### Changed +- **Tenant isolation fails closed.** With `multi_tenant` on, a query, bulk + `update()`/`delete()` or insert on a `MultiTenantMixin` model with no tenant + context raises `TenantIsolationError` instead of reading or writing every + tenant's rows. ORM `update()`/`delete()` are now tenant-scoped too; they were + not before. +- Changing a row's `tenant_id` is refused whether or not a tenant is bound + (it used to be checked only inside a tenant context); only an `all_tenants()` + block may move a row between tenants. +- Tenant rules now cover every ORM write path, not only `session.add`: an + ORM `insert(Model)` (bulk or `.values()`) is stamped with the bound tenant + and refused for a different one (#357); `update(Model).values(tenant_id=…)` + is refused; a flush that writes or deletes an object belonging to another + tenant (e.g. one returned from the identity map after a `tenant_context` + switch) is refused. +- `tenant_context()` nested in `all_tenants()` now scopes its block; it used + to be ignored there, so a per-tenant loop inside a platform job ran + unscoped. +- Strict mode is held per engine, so a second `DatabaseState` in the process + no longer switches it off for the first. The Celery worker's session gets the + tenant listeners and the host's `multi_tenant` setting too (#371). +- New `MissingTenantError` (a `TenantIsolationError`) for "no tenant bound". +- Tenant and soft-delete criteria reach join targets, subqueries (including a + bare Core `exists().where(...)`), `count().select_from()` and top-level Core + statements on `Model.__table__` (#332). **Behaviour change:** a join or count + over a soft-deletable model now excludes trashed rows, as a plain `select` + already did; `include_deleted=True` still reveals them. +- `HostSettings.default_tenant`: single-tenant hosts run mixin tables as one + tenant (#359). `bind_current_tenant(fn)` carries the tenant into work a + module defers past the request (#364). The `tenants` module resolves a + tenant from the subdomain (`subdomain_base`), anonymous visitors included + (#363). + +### Security +- The tenant header (`tenant_header`) is no longer honoured for an + authenticated user without a tenant of their own: such a user could name any + tenant. On the legacy path it applies to anonymous requests only; with the + `tenants` resolver it selects among the user's own memberships. + ### Fixed - Public pages no longer reload the whole document when a visitor clicks a link in authored content. A simple_module app is client-rendered — the root diff --git a/CLAUDE.md b/CLAUDE.md index f2b09feb..d18bc1c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ All day-to-day tasks go through `make`: | `make kill` | Free ports 8000/5050/5173 | | `make test` | Run `test-py` then `test-js` (e2e excluded by default) | | `make test-py` / `make test-js` | Run a single suite | +| `make test-py-pg` | Python suite on Postgres (`SM_TEST_PG_URL`, default db `sm_test`, schema is dropped per test) | | `make test-e2e` | Playwright smoke tests (requires `make dev` running + `uv run playwright install chromium`) | | `make lint` | Ruff format-check + Ruff + `ty` + Biome + per-workspace `tsc` + 300-line file cap | | `make doctor` | Module diagnostics (orphan pages, coupling violations, migration drift, locale checks) — same checks run at prod boot | @@ -81,7 +82,7 @@ hence `SM022`/`SM023`. See `docs/module-authoring.md` § Styling. **Database**: per-module `Base` via `create_module_base("")`. Every module owns its own `MetaData` (so Alembic autogenerate can attribute tables to a module), but all tables live in the host's single schema. `__tablename__` must be prefixed with the module name to avoid collisions (`orders_order`). Postgres and SQLite share the same layout. -Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass with `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`. The per-request session (`get_db`) auto-commits **only if** there are pending writes (via `after_flush` listener); otherwise rollback. Service code should **not** call `session.commit()` — flush if you need DB-assigned values. The commit fires in `CommitBeforeResponseMiddleware`, at the ASGI `http.response.start` message, so a client that creates a row and immediately reads it back in a second request sees it — FastAPI runs a `yield` dependency's exit code *after* the response is delivered, which used to make that a deterministic 404 (GH #257). `get_db` keeps the same commit in its own exit code as a fallback for when the middleware isn't in the stack; whichever runs first wins. +Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (bypass with `stmt.execution_options(include_deleted=True)`), `MultiTenantMixin`, `VersionedMixin`. **Tenancy fails closed**: with `multi_tenant` on, a query or insert on a `MultiTenantMixin` model with no `current_tenant_id` raises `TenantIsolationError` instead of reading every tenant; cross-tenant code says so with `all_tenants()` / `execution_options(all_tenants=True)`, and jobs/CLI act for one tenant with `tenant_context(id)`. Unique keys on such tables must include `tenant_id` (`SM024`). The `tenants` module owns organisations, memberships and `app.state.tenant_resolver`; tenant-level routes act on the *active* tenant, never a tenant id from the URL. See [docs/framework/multi-tenancy.md](docs/framework/multi-tenancy.md). The per-request session (`get_db`) auto-commits **only if** there are pending writes (via `after_flush` listener); otherwise rollback. Service code should **not** call `session.commit()` — flush if you need DB-assigned values. The commit fires in `CommitBeforeResponseMiddleware`, at the ASGI `http.response.start` message, so a client that creates a row and immediately reads it back in a second request sees it — FastAPI runs a `yield` dependency's exit code *after* the response is delivered, which used to make that a deterministic 404 (GH #257). `get_db` keeps the same commit in its own exit code as a fallback for when the middleware isn't in the stack; whichever runs first wins. **Migrations** live in `host/migrations/versions/` — not in module packages. `host/alembic/env.py` calls `build_module_metadata()` + `make_include_object()` so autogenerate covers every installed module and ignores host-owned tables. First migration of each module should set `branch_labels = ("",)` to enable per-module `downgrade @base`. @@ -109,12 +110,12 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b ## Diagnostic codes -Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.` (warn, fires at dev boot only), `SM013`–`SM016` locale issues, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. `SM020` multiple auth provider modules installed (error), `SM021` no auth provider module installed (warn), `SM022` `@theme`/`@custom-variant`/`@utility` in a module's `styles.css`, where `layer(components)` makes them inert (warn), `SM023` an unlayered rule in a module's `theme.css`, which outranks every Tailwind utility (warn). In production, errors fail boot. +Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.` (warn, fires at dev boot only), `SM013`–`SM016` locale issues, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. `SM020` multiple auth provider modules installed (error), `SM021` no auth provider module installed (warn), `SM022` `@theme`/`@custom-variant`/`@utility` in a module's `styles.css`, where `layer(components)` makes them inert (warn), `SM023` an unlayered rule in a module's `theme.css`, which outranks every Tailwind utility (warn). `SM024` a unique key on a `MultiTenantMixin` table that omits `tenant_id` (warn). In production, errors fail boot. ## Tests & fixtures The `simple_module_test` plugin provides app-level fixtures available to every test directory — auto-loaded via its `pytest11` entry point (defined in `framework/testing/simple_module_test/fixtures.py`), so the root `conftest.py` is intentionally thin: -- `settings` — in-memory SQLite `Settings` with `multi_tenant=True`. +- `settings` — in-memory SQLite `Settings` with `multi_tenant=True`. Set `SM_TEST_DATABASE_URL=postgresql+asyncpg://…` to run the fixtures (and the tenancy DB tests) on Postgres instead; each test then starts from an empty `public` schema, reset once per test so `app` and `db_session` share it (`simple_module_test.database`). `make test-py-pg` runs the whole suite that way, with `-p no:anyio`: an `@pytest.mark.anyio` test would run on a different event loop from its async fixtures, and an asyncpg connection cannot cross loops. - `db_state`, `engine`, `db_session` — fresh in-memory `DatabaseState` per test; `db_session` also creates all module tables and stamps `alembic_version` at head so the boot-time migration check passes. - `app` — `create_app(settings)` with lifespan started/stopped. - `client` / `authenticated_client` — `httpx.AsyncClient`; `authenticated_client` seeds an admin via `users.bootstrap.create_admin` and carries a forged session cookie. @@ -129,6 +130,16 @@ E2E tests live in `tests/e2e/` behind the `e2e` pytest marker and run against a To exempt a genuinely technical literal: wrap it in ``/`
`, or mark the line `// i18n-exempt: `; `i18n-exempt-file: ` in a file's first lines skips the whole file.
 
+## Delegating to subagents
+
+Pick the subagent's model for the task, not the most capable one available. Don't run everything on `opus` or `fable`:
+
+- **`haiku`**: search, file discovery, and mechanical work like renames, checking docs against code, or collecting test output.
+- **`sonnet`**: routine implementation, functional and end-to-end testing, and regression runs.
+- **`opus`**: design, security and isolation reasoning, and adversarial review, where a wrong answer is expensive.
+
+Pass `model` explicitly on every `Agent` call, even when the default would be correct.
+
 ## Authoritative references
 
 When conventions are unclear, these docs are the source of truth (don't reverse-engineer the code):
diff --git a/Makefile b/Makefile
index adb4f73d..f10beff7 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker
+.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker
 
 # Install
 install:
@@ -48,6 +48,16 @@ test: test-py test-js
 test-py:
 	uv run pytest
 
+# The Python suite on Postgres (#343). Needs an empty database the tests may
+# drop and recreate `public` in. `-p no:anyio`: tests marked
+# `@pytest.mark.anyio` would otherwise run on anyio's event loop while the
+# async fixtures ran on pytest-asyncio's, and an asyncpg connection cannot
+# cross loops (aiosqlite's thread hides this on SQLite). asyncio_mode=auto
+# still runs those tests.
+SM_TEST_PG_URL ?= postgresql+asyncpg://postgres:postgres@localhost:5432/sm_test
+test-py-pg:
+	SM_TEST_DATABASE_URL=$(SM_TEST_PG_URL) uv run pytest -p no:anyio
+
 test-js:
 	npm test
 
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 5567d776..b8fade0e 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -125,6 +125,7 @@ export default defineConfig({
             { text: "Public routes", link: "/framework/public-routes" },
             { text: "Events", link: "/framework/events" },
             { text: "Cache invalidation", link: "/framework/invalidation" },
+            { text: "Multi-tenancy", link: "/framework/multi-tenancy" },
             { text: "Internationalization", link: "/framework/i18n" },
           ],
         },
@@ -185,6 +186,7 @@ export default defineConfig({
             { text: "background_tasks", link: "/modules/background_tasks" },
             { text: "audit_log", link: "/modules/audit_log" },
             { text: "site_lock", link: "/modules/site_lock" },
+            { text: "tenants", link: "/modules/tenants" },
             { text: "dashboard", link: "/modules/dashboard" },
           ],
         },
diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md
index bae88cb0..bcdfcdff 100644
--- a/docs/framework-conventions.md
+++ b/docs/framework-conventions.md
@@ -233,8 +233,8 @@ Base = create_module_base("orders")
 ### Mixins
 
 - `AuditMixin` — `created_at`, `updated_at`, `created_by`, `updated_by` (auto-populated from the current user in listeners).
-- `SoftDeleteMixin` — `is_deleted`, `deleted_at`, `deleted_by`. `delete()` converts to soft-delete; `SELECT` auto-filters. Bypass with `stmt.execution_options(include_deleted=True)`.
-- `MultiTenantMixin` — `tenant_id`. Auto-populated on insert; `SELECT` auto-filters when `current_tenant_id` is set.
+- `SoftDeleteMixin` — `is_deleted`, `deleted_at`, `deleted_by`. `delete()` converts to soft-delete; reads auto-filter trashed rows wherever the table appears — joins, subqueries, counts, top-level Core statements (#332). Bypass with `stmt.execution_options(include_deleted=True)`.
+- `MultiTenantMixin` — `tenant_id`. Auto-populated on insert; `SELECT`, ORM `UPDATE` and `DELETE` are scoped to `current_tenant_id`. With `multi_tenant` on, a query with **no** tenant raises `TenantIsolationError` (fail closed) — cross-tenant code opts out with `all_tenants()` / `execution_options(all_tenants=True)`. See [multi-tenancy](/framework/multi-tenancy).
 - `VersionedMixin` — `version`, auto-incremented on update.
 
 ### Session lifecycle (`get_db`)
diff --git a/docs/framework/multi-tenancy.md b/docs/framework/multi-tenancy.md
new file mode 100644
index 00000000..9ce0f2cb
--- /dev/null
+++ b/docs/framework/multi-tenancy.md
@@ -0,0 +1,144 @@
+# Multi-tenancy
+
+One schema, row-level isolation. A model opts in with `MultiTenantMixin`; the
+framework scopes every ORM query on it to the request's tenant.
+
+## Turning it on
+
+`multi_tenant` (a `HostSettings` field, needs a restart) installs
+`TenantMiddleware` **and** turns on strict isolation
+(`DatabaseState.tenant_strict`). Install the [`tenants`](/modules/tenants)
+module for organisations, memberships and the resolver that decides which
+tenant a request acts for.
+
+## The rules
+
+| Operation on a `MultiTenantMixin` model | Tenant set | No tenant, strict (`multi_tenant` on) | No tenant, not strict |
+|---|---|---|---|
+| `SELECT` | filtered to the tenant | `MissingTenantError` | unfiltered |
+| ORM `update()` / `delete()` | filtered to the tenant; `update().values(tenant_id=…)` raises | `MissingTenantError` | unfiltered |
+| `session.add` + flush | `tenant_id` filled in; a different explicit value raises | `MissingTenantError` unless `tenant_id` is set explicitly | DB `NOT NULL` error unless set |
+| ORM `insert(Model)` (bulk / `.values()`) | `tenant_id` filled in; a different explicit value raises | `MissingTenantError` unless every row sets `tenant_id` | DB `NOT NULL` error unless set |
+| Flushing a change to, or a delete of, a loaded object | only if it belongs to the bound tenant | `MissingTenantError` | allowed |
+| Changing `tenant_id` | raises | raises | raises (only an `all_tenants()` block may move a row) |
+
+`MissingTenantError` is a `TenantIsolationError`. Fail closed is the point: a
+request, job or command that forgot to establish a tenant errors instead of
+reading every tenant's data.
+
+### What the filter can see
+
+Tenant criteria — and soft-delete criteria, on reads — are attached for
+**every** tenant-scoped / soft-deletable model, not only the entities a
+statement names, so all of these are scoped (#332):
+
+- `select(Model)`, relationship loads, ORM `update()`/`delete()`/`insert()`;
+- a table as a **join target**: `select(Project).join(Doc)`;
+- subqueries: `select(Doc.id).where(...).exists()`, a bare Core
+  `exists().where(Doc.x == ...)`, `in_(select(Doc.x))`, scalar subqueries;
+- counts: `select(func.count()).select_from(Doc)`, counts over a subquery;
+- Core statements on `Doc.__table__` at the top level — `select`, `update`,
+  `delete` get `WHERE tenant_id = …` (and `is_deleted IS false` on reads),
+  `insert` is stamped.
+
+With no tenant under strict mode, a statement that *names* a tenant model or
+table — or reads one through a bare Core `exists()` — raises; an indirect ORM
+reference (a join, a subquery) matches nothing. The one-time cost is a scan of
+the WHERE/column clauses for `exists()` (about 10 µs per statement), skipped
+when no tenant-scoped or soft-deletable model is installed.
+
+### One session, one tenant
+
+`session.get()` answers from the identity map without SQL, so a session reused
+across `tenant_context` blocks can hand back an object loaded for another
+tenant. The flush refuses to write or delete it, but reading it is not
+prevented: give each tenant its own session (or `session.expunge_all()`
+between tenants) in jobs and CLI loops. Per-request sessions are unaffected.
+
+## Acting outside a request
+
+```python
+from simple_module_db import all_tenants, tenant_context
+
+with tenant_context(tenant_id):  # a job or CLI command working for one tenant
+    ...
+
+with all_tenants():  # platform code that deliberately spans tenants
+    ...
+
+stmt = select(Order).execution_options(all_tenants=True)  # one statement
+```
+
+Every `all_tenants` call site is a place one tenant can see another's data —
+keep them in platform-admin code and review them as such. A `tenant_context()`
+nested inside `all_tenants()` wins for its block, so the usual platform job is
+safe to write:
+
+```python
+with all_tenants():
+    tenant_ids = [t.id for t in await service.list_all()]
+for tenant_id in tenant_ids:
+    with tenant_context(tenant_id):  # scoped, even if nested in all_tenants()
+        ...
+```
+
+A task or asyncio task started inside `all_tenants()` inherits the bypass
+(ordinary contextvar semantics).
+
+## Work deferred past the request
+
+`db.on_commit(...)` callbacks and FastAPI `BackgroundTasks` run inside the
+request's tenant scope — use them and nothing needs capturing. A module with
+its own queue (a middleware that drains jobs after the response, a thread
+pool, a callback registry) wraps the callable when it is *enqueued*:
+
+```python
+from simple_module_db import bind_current_tenant
+
+queue.append(bind_current_tenant(reindex))  # runs as today's tenant, later
+```
+
+It captures the tenant (and an `all_tenants()` bypass) and restores it around
+the call, sync or async (#364).
+
+## Single-tenant hosts
+
+A host with `multi_tenant` off can still install modules whose tables use the
+mixin: set `default_tenant` (a `HostSettings` field, e.g. `main`) and every
+request, and every background task with no tenant on its message, acts as
+that tenant (#359). CLI commands and scripts use
+`tenant_context(settings.default_tenant)`. It is ignored when `multi_tenant`
+is on — a multi-tenant install never falls back to a shared tenant.
+
+## Background jobs
+
+`background_tasks` stamps the enqueuing request's tenant onto the Celery
+message and restores it around the task body, so a task queued from a request
+runs as that tenant. Request code cannot enqueue as another tenant (an explicit
+`sm_tenant_id` header must match the bound one); platform code with no tenant
+bound may name one. Beat tasks have no request: wrap cross-tenant work in
+`all_tenants()`, or loop over tenants with `tenant_context()`.
+
+The worker never builds the app, so `background_tasks.sync_db` attaches the
+same listeners to its own session class and reads `multi_tenant` from the host
+settings (`scripts/run_worker.py`): task bodies get the same fail-closed rules
+as request code. A process that talks to the DB some other way must do the
+same — `attach_session_listeners(MySession)` plus
+`bind_engine_policy(engine, EngineTenancy(tenant_strict=...))`.
+
+## Resolution
+
+`TenantMiddleware` asks `app.state.tenant_resolver` (an
+`async (Request) -> str | None`) when a module registered one — `tenants`
+does — and its answer is final. The `tenants` resolver takes, in order: the
+subdomain (with its `subdomain_base` setting — the one source that also works
+for anonymous visitors, on public routes), the tenant header (members only),
+then the session's choice validated against a membership (#363). Without one it falls back to the principal's
+`tenant_id` claim, and for **anonymous** requests only, the configured
+`tenant_header`. An authenticated user can never pick a tenant by header.
+
+## Unique keys
+
+On a tenant-scoped table every business key is per tenant: put `tenant_id` in
+the unique index (`Index(..., "tenant_id", "slug", unique=True)`). `make
+doctor` reports violations as `SM024`.
diff --git a/docs/modules/index.md b/docs/modules/index.md
index 46390459..1fdfafed 100644
--- a/docs/modules/index.md
+++ b/docs/modules/index.md
@@ -1,6 +1,6 @@
 # Bundled modules
 
-simple_module_python ships with twelve first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need.
+simple_module_python ships with thirteen first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need.
 
 | Module | Depends on | What it provides |
 |---|---|---|
@@ -15,6 +15,7 @@ simple_module_python ships with twelve first-party modules. Each is a regular Py
 | [`background_tasks`](/modules/background_tasks) | `users` | Celery + Redis workers, persistent task history, retry, stuck-task sweep, live worker dashboard. |
 | [`audit_log`](/modules/audit_log) | `users` | Automatic field-level audit trail for SQLModel entities, with an admin UI to browse change history. |
 | [`dashboard`](/modules/dashboard) | `users` | Authenticated landing page with system overview (user counts, module list, health checks). |
+| [`tenants`](/modules/tenants) | `auth`, `settings` | SaaS organisations: tenants, many-to-many memberships with per-tenant roles, invitations, the membership-validated tenant resolver, and the entitlement/lifecycle seams billing plugs into. |
 | [`site_lock`](/modules/site_lock) | `settings`, `auth` | Optional site-wide shared-password gate for staging / pre-launch sites. Off by default. |
 
 ## How modules are wired in
diff --git a/docs/modules/tenants.md b/docs/modules/tenants.md
new file mode 100644
index 00000000..f54317ac
--- /dev/null
+++ b/docs/modules/tenants.md
@@ -0,0 +1,92 @@
+# tenants
+
+Organisations for [simple_module](https://github.com/antosubash/simple_module_python)
+SaaS installs: tenants, many-to-many memberships with per-tenant roles,
+email-bound invitations, and the tenant resolver that scopes every request.
+
+## Install
+
+```bash
+pip install simple_module_tenants
+```
+
+Add `simple_module_tenants` to your host's dependencies, then turn on
+`multi_tenant` (Settings screen, or `SM_MULTI_TENANT=true`) and restart.
+Without `multi_tenant` the module manages organisations but requests are not
+scoped to them.
+
+## What it does
+
+- **Resolution.** Registers `app.state.tenant_resolver`. The session stores
+  the user's chosen tenant; every request re-validates it against a
+  membership (cached per process, dropped across workers through
+  `InvalidationBus`). Suspended tenants resolve to nothing.
+- **Per-tenant roles.** A membership role (`owner`, `admin`, `member`) is added
+  to the principal as `tenant:` for the active tenant only, so tenant
+  roles never reach platform permissions.
+- **Fail-closed handling.** With `multi_tenant` on, a tenant-scoped query
+  with no tenant raises `TenantIsolationError`; this module turns that into a
+  redirect to `/tenants` (pages) or a `403 tenant_required` (API).
+
+## Usage
+
+| Route | Permission | Purpose |
+|---|---|---|
+| `GET /tenants/` | signed in | My organisations: switch, create |
+| `GET /tenants/members` | `tenants.members.view` | Members and invitations of the active tenant |
+| `GET /tenants/invitations/accept?token=` | signed in | Accept an invitation |
+| `GET /admin/tenants/` | `tenants.platform.view` | Platform list of all tenants |
+| `GET/POST /api/tenants/` | signed in | List mine / create |
+| `POST /api/tenants/{id}/switch` | member of `{id}` | Change the active tenant |
+| `/api/tenants/current/members[/{user_id}]` | `tenants.members.view` / `.manage` | List, change role, remove |
+| `DELETE /api/tenants/current/membership` | member | Leave (not the last owner) |
+| `/api/tenants/current/invitations[/{id}]` | `tenants.members.manage` | List, invite, revoke |
+| `POST /api/tenants/invitations/accept` | signed in as the invited email | Join |
+| `POST /api/tenants/admin/{id}/suspend` · `/reactivate` | `tenants.platform.manage` | Lifecycle |
+
+Tenant-level routes act on the *active* tenant (`/current`), never on an id
+from the URL.
+
+## Configuration
+
+DB-backed (Settings screen):
+
+- `allow_self_service` (default on) — any signed-in user may create an
+  organisation.
+- `invitation_ttl_hours` (default 72).
+- `subdomain_base` (default empty) — with `example.com`, `acme.example.com`
+  resolves to the tenant whose slug is `acme`. Members get their role there;
+  anonymous visitors and signed-in non-members get the tenant on **public
+  routes only**; an unknown or suspended subdomain resolves to nothing.
+- `public_base_url` (default empty) — origin invitation links are built on.
+  Empty makes them root-relative: they are never built from the request's
+  `Host` header, because the same link travels in `InvitationCreated` for a
+  mailer to send.
+
+Management routes need both the permission and an owner/admin role *in the
+active tenant*: a platform-wide grant does not make a plain member of a tenant
+its manager (`403 tenant_manager_required`).
+
+## Billing seams
+
+The module ships no billing, but a billing module needs nothing more from it:
+
+- `app.state.tenants.entitlements` — replace the default `UnlimitedEntitlements`
+  with an `EntitlementProvider` (`limit(tenant_id, key)`,
+  `has_feature(tenant_id, key)`). The module enforces `tenants.seats` on new
+  members and invitations; `EntitlementExceededError` maps to HTTP 402.
+- `TenantService.set_status(tenant_id, TenantStatus.SUSPENDED | ACTIVE)` for
+  dunning.
+- Events, published after commit: `TenantCreated`, `TenantStatusChanged`,
+  `MembershipAdded`, `MembershipRemoved`, `InvitationCreated` (also the hook a
+  mailer uses to deliver the invitation link).
+
+## Models
+
+`tenants_tenant` (id = the value stored in every `tenant_id`, slug, name,
+status), `tenants_membership` (unique `(tenant_id, user_id)`, role, email
+snapshot), `tenants_invitation` (email, role, SHA-256 of the token, expiry).
+None use `MultiTenantMixin`: they are the registry, and are scoped by hand in
+`TenantService`.
+
+See also [the design doc](https://github.com/antosubash/simple_module_python/blob/main/docs/plans/2026-09-27-saas-tenancy-design.md).
diff --git a/docs/plans/2026-09-27-saas-tenancy-design.md b/docs/plans/2026-09-27-saas-tenancy-design.md
new file mode 100644
index 00000000..7d5b93c5
--- /dev/null
+++ b/docs/plans/2026-09-27-saas-tenancy-design.md
@@ -0,0 +1,89 @@
+# SaaS tenancy — design
+
+Status: in progress (framework hardening + `tenants` module). Billing is
+deliberately out of scope; this doc fixes the seams it will plug into.
+
+## Decisions
+
+| Question | Decision | Consequence |
+|---|---|---|
+| Isolation model | One schema, row-level `tenant_id` (`MultiTenantMixin`) | Works with per-module `MetaData` and host-owned Alembic history. No schema-per-tenant. |
+| Users ↔ tenants | Many-to-many via `tenants_membership` | `users_user.tenant_id` and the `tenant_id` token claim are superseded; the *active* tenant is per session. |
+| Missing tenant context | **Fail closed** when `multi_tenant` is on | Tenant-scoped SELECT/UPDATE/DELETE/INSERT without a tenant raises `TenantIsolationError`. Cross-tenant code opts out explicitly. |
+| Billing | Not built. `tenants` exposes status, events and an entitlements seam | A `billing` module (smpy_modules) can be added without changing `tenants` or any consumer. |
+
+## Framework changes (`simple_module_db`, `simple_module_hosting`)
+
+1. **Strict isolation.** `DatabaseState.tenant_strict`, set from
+   `Settings.multi_tenant`. With it on and no `current_tenant_id`, the query
+   filter raises instead of leaving the statement unscoped. Opt-outs:
+   `stmt.execution_options(all_tenants=True)` for one statement,
+   `with all_tenants():` for a block. `with tenant_context(id):` runs code as a
+   tenant outside a request (jobs, CLI, tests).
+2. **Bulk UPDATE/DELETE are scoped.** Tenant criteria were attached to SELECT
+   only, so `update(Model)` rewrote every tenant's rows.
+3. **Tenant resolver seam.** `TenantMiddleware` consults
+   `app.state.tenant_resolver` (`async (Request) -> str | None`) when a module
+   registers one; its answer is final. Without one, the legacy claim path.
+4. **Header hardening.** The `X-Tenant-ID` header is honoured for anonymous
+   requests only on the legacy path. An authenticated user with no tenant
+   could previously name any tenant with it.
+5. **Background jobs carry their tenant.** Captured at enqueue, restored in the
+   worker; `all_tenants()` for platform jobs.
+6. **`SM024` doctor check.** A unique constraint on a `MultiTenantMixin`
+   table that does not include `tenant_id` — two tenants could not both own
+   the value.
+
+## `tenants` module
+
+- `tenants_tenant` — id, slug, name, `status` (`active` / `suspended`).
+- `tenants_membership` — `(tenant_id, user_id)` unique, `role`
+  (`owner` / `admin` / `member`). `user_id` is a string with no FK so external
+  identity providers (keycloak) work.
+- `tenants_invitation` — email, role, hashed token, expiry. Accepting while
+  signed in as that email creates the membership. Delivery is an event, so
+  the module does not depend on a mailer.
+- **Resolver**: active tenant from the session, validated against a
+  membership (per-process TTL cache, dropped through `InvalidationBus` on
+  membership change); falls back to the user's first active membership. Suspended
+  tenants resolve to nothing.
+- **Effective roles**: the membership role is added to the request's
+  principal as `tenant:` for the active tenant only, and the module maps
+  those roles onto its own permissions. A tenant `admin` is never the
+  platform `admin`.
+- **No-tenant handling**: `TenantIsolationError` raised during a request is
+  turned into a redirect to `/tenants` (pages) or a 403 (API) — the user needs
+  to pick or create an organisation, not see a 500.
+- **Screens**: `/tenants` (my organisations, create, switch),
+  `/tenants/members` (members, invitations), `/admin/tenants` (platform list,
+  suspend/reactivate).
+
+## Billing seams (what `billing` will use)
+
+- **Entitlements.** `EntitlementProvider` protocol on
+  `app.state.tenants.entitlements` — `limit(tenant_id, key) -> int | None`
+  and `has_feature(tenant_id, key) -> bool`. The default provider is
+  unlimited. `tenants` itself enforces the `tenants.seats` limit on new
+  members and invitations, so the seam is exercised, not theoretical.
+- **Lifecycle.** `TenantService.suspend/reactivate` — billing calls these on
+  failed payment / recovery.
+- **Events.** `TenantCreated`, `TenantStatusChanged`, `MembershipAdded`,
+  `MembershipRemoved`, `InvitationCreated` — billing creates the customer on
+  `TenantCreated` and syncs seat counts on membership changes.
+- Billing owns its own tables (customer, subscription, webhook-event log)
+  keyed by `tenant_id`. Nothing billing-specific goes on `tenants_tenant`.
+
+## Module adoption (tracked as issues, not in this change)
+
+Each module that stores per-customer data adopts `MultiTenantMixin`, widens
+its unique constraints to include `tenant_id`, and adds cross-tenant tests.
+Candidates: `file_storage`, `audit_log`, `background_tasks` (job rows),
+`settings` (TENANT scope → real tenant ids), `feature_flags` (tenant overrides
+UI), `branding` (per-tenant theme), and in smpy_modules `pagebuilder`, `news`,
+`ai`, `canopy_atlas`. Public sites (pagebuilder) additionally need a
+subdomain/domain resolver for anonymous requests.
+
+## Non-goals
+
+Schema-per-tenant, per-tenant databases, billing, tenant data export/delete
+(GDPR) — the last is a follow-up once modules have adopted the mixin.
diff --git a/docs/reference/diagnostic-codes.md b/docs/reference/diagnostic-codes.md
index 2efef023..fca4721c 100644
--- a/docs/reference/diagnostic-codes.md
+++ b/docs/reference/diagnostic-codes.md
@@ -32,6 +32,7 @@ The framework runs a set of static checks over installed modules at app boot. Th
 | `SM021` | WARNING | No auth-provider module is installed. | Install an auth provider (e.g. `simple-module-users` or `simple-module-keycloak`). |
 | `SM022` | WARNING | A module's `styles.css` contains a top-level `@theme`, `@custom-variant` or `@utility` block. That file is imported into `layer(components)`, where those at-rules are inert. | Move the block to the module's `theme.css`, which is imported unlayered so its tokens actually register. |
 | `SM023` | WARNING | A module's `theme.css` contains an unlayered plain rule (anything but an at-rule or a `:root`-style selector). Unlayered CSS outranks every Tailwind utility. | Move the rule to the module's `styles.css`, which is imported into `layer(components)` so utilities still win. |
+| `SM024` | WARNING | A unique column, constraint or index on a `MultiTenantMixin` table does not include `tenant_id`, so the first tenant to claim a value locks every other tenant out of it. | Make the key per tenant: add `tenant_id` to it (`Index(..., "tenant_id", "slug", unique=True)`). |
 
 `SM022`/`SM023` are the two halves of the same invariant: a module's optional [`theme.css` is imported unlayered and `styles.css` into `layer(components)`](/module-authoring#styling), and CSS put in the wrong one silently does nothing (or silently wins everything).
 
diff --git a/framework/core/simple_module_core/diagnostics/_module.py b/framework/core/simple_module_core/diagnostics/_module.py
index 2508911f..4c53062b 100644
--- a/framework/core/simple_module_core/diagnostics/_module.py
+++ b/framework/core/simple_module_core/diagnostics/_module.py
@@ -11,6 +11,7 @@
 from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls
 from simple_module_core.diagnostics._js_workspace import check_js_workspace_files
 from simple_module_core.diagnostics._pages import check_pages, find_render_calls
+from simple_module_core.diagnostics._tenancy import check_tenant_unique_keys, module_tables
 from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
 
 if TYPE_CHECKING:
@@ -29,6 +30,8 @@ def run(self, modules: list[ModuleBase]) -> list[Diagnostic]:
         diagnostics.extend(self._check_views_without_menu(modules))
         diagnostics.extend(self._check_auth_provider_conflict(modules))
         diagnostics.extend(check_framework_module_coupling(modules))
+        for mod in modules:
+            diagnostics.extend(check_tenant_unique_keys(module_tables(mod), mod.meta.name))
 
         # File-based checks (need to find module source directories)
         for mod in modules:
diff --git a/framework/core/simple_module_core/diagnostics/_tenancy.py b/framework/core/simple_module_core/diagnostics/_tenancy.py
new file mode 100644
index 00000000..64066e0e
--- /dev/null
+++ b/framework/core/simple_module_core/diagnostics/_tenancy.py
@@ -0,0 +1,108 @@
+"""SM024: a unique key on a tenant-scoped table that ignores the tenant.
+
+On a ``MultiTenantMixin`` table every business key is per tenant. A unique
+constraint without ``tenant_id`` in it means the first tenant to claim a value
+(a slug, a filename, a name) locks every other tenant out of it — and the
+resulting IntegrityError tells the second tenant the value exists elsewhere.
+
+Duck-typed on SQLAlchemy ``Table`` objects (core does not depend on
+SQLAlchemy). Only tables of models that inherit ``MultiTenantMixin`` count: a
+plain ``tenant_id`` column (``users_user``'s legacy one, the ``tenants``
+registry's own tables) carries no isolation and no per-tenant key rule.
+"""
+
+from __future__ import annotations
+
+import importlib
+import logging
+from collections.abc import Iterable, Iterator
+from typing import TYPE_CHECKING, Any
+
+from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
+
+if TYPE_CHECKING:
+    from simple_module_core.module import ModuleBase
+
+logger = logging.getLogger(__name__)
+
+TENANT_COLUMN = "tenant_id"
+
+
+def _unique_keys(table: Any) -> Iterator[tuple[str, set[str]]]:
+    """Yield ``(label, column names)`` for every unique key except the PK."""
+    for col in table.columns:
+        if col.unique and not col.primary_key:
+            yield f"column '{col.name}'", {col.name}
+    for constraint in table.constraints:
+        if type(constraint).__name__ == "UniqueConstraint":
+            cols = {c.name for c in constraint.columns}
+            yield f"unique constraint {sorted(cols)}", cols
+    for index in table.indexes:
+        if index.unique:
+            cols = {c.name for c in index.columns}
+            label = f"unique index '{index.name}'"
+            # Expression indexes (lower(email)) expose no plain columns; treat
+            # them as not containing the tenant — they are just as global.
+            yield label, cols
+
+
+def check_tenant_unique_keys(tables: Iterable[Any], module_name: str) -> list[Diagnostic]:
+    diags: list[Diagnostic] = []
+    for table in tables:
+        if TENANT_COLUMN not in table.columns:
+            continue
+        reported: set[frozenset[str]] = set()
+        for label, cols in _unique_keys(table):
+            # ``unique=True`` surfaces both as a column flag and as a
+            # UniqueConstraint; report each key once. Expression indexes have
+            # no plain columns and are always reported.
+            key = frozenset(cols)
+            if TENANT_COLUMN in cols or (cols and key in reported):
+                continue
+            reported.add(key)
+            diags.append(
+                Diagnostic(
+                    level=DiagnosticLevel.WARNING,
+                    code="SM024",
+                    message=(
+                        f"Tenant-scoped table '{table.name}' has a {label} that does "
+                        f"not include '{TENANT_COLUMN}' — two tenants cannot both own a value"
+                    ),
+                    module_name=module_name,
+                    suggestion=(
+                        "Make the key per tenant: add tenant_id to the constraint, "
+                        "e.g. Index(..., 'tenant_id', 'slug', unique=True)"
+                    ),
+                )
+            )
+    return diags
+
+
+def _is_tenant_scoped(cls: type) -> bool:
+    return any(base.__name__ == "MultiTenantMixin" for base in cls.__mro__[1:])
+
+
+def module_tables(mod: ModuleBase) -> list[Any]:
+    """Tables of the ``MultiTenantMixin`` models in the module's ``models``."""
+    pkg = type(mod).__module__.rsplit(".", 1)[0]
+    try:
+        models = importlib.import_module(f"{pkg}.models")
+    except ModuleNotFoundError:
+        return []
+    except Exception:  # pragma: no cover - a broken models module fails elsewhere, loudly
+        logger.debug("Could not import %s.models for SM024", pkg, exc_info=True)
+        return []
+    seen: dict[str, Any] = {}
+    for value in vars(models).values():
+        table = getattr(value, "__table__", None)
+        if (
+            isinstance(value, type)
+            and table is not None
+            and hasattr(table, "columns")
+            and _is_tenant_scoped(value)
+        ):
+            seen.setdefault(table.name, table)
+    return list(seen.values())
+
+
+__all__ = ["check_tenant_unique_keys", "module_tables"]
diff --git a/framework/core/tests/test_diagnostics_tenancy.py b/framework/core/tests/test_diagnostics_tenancy.py
new file mode 100644
index 00000000..60bab9a3
--- /dev/null
+++ b/framework/core/tests/test_diagnostics_tenancy.py
@@ -0,0 +1,115 @@
+"""SM024 — unique keys on tenant-scoped tables must include tenant_id."""
+
+from __future__ import annotations
+
+from simple_module_core.diagnostics._tenancy import check_tenant_unique_keys
+from sqlalchemy import Column, Index, Integer, MetaData, String, Table, UniqueConstraint, func
+
+
+def _codes(table: Table) -> list[str]:
+    return [d.code for d in check_tenant_unique_keys([table], "demo")]
+
+
+def test_global_unique_column_on_tenant_table_warns():
+    t = Table(
+        "demo_a",
+        MetaData(),
+        Column("id", Integer, primary_key=True),
+        Column("tenant_id", String(50)),
+        Column("slug", String(50), unique=True),
+    )
+    assert _codes(t) == ["SM024"]
+
+
+def test_per_tenant_unique_index_is_fine():
+    t = Table(
+        "demo_b",
+        MetaData(),
+        Column("id", Integer, primary_key=True),
+        Column("tenant_id", String(50)),
+        Column("slug", String(50)),
+        Index("ix_demo_b_tenant_slug", "tenant_id", "slug", unique=True),
+        UniqueConstraint("tenant_id", "id"),
+    )
+    assert _codes(t) == []
+
+
+def test_unique_index_without_tenant_warns():
+    t = Table(
+        "demo_c",
+        MetaData(),
+        Column("id", Integer, primary_key=True),
+        Column("tenant_id", String(50)),
+        Column("locale", String(8)),
+        Column("slug", String(50)),
+        Index("ix_demo_c_locale_slug", "locale", "slug", unique=True),
+    )
+    diags = check_tenant_unique_keys([t], "demo")
+    assert [d.code for d in diags] == ["SM024"]
+    assert "ix_demo_c_locale_slug" in diags[0].message
+
+
+def test_expression_index_counts_as_global():
+    t = Table(
+        "demo_d",
+        MetaData(),
+        Column("id", Integer, primary_key=True),
+        Column("tenant_id", String(50)),
+        Column("email", String(50)),
+    )
+    Index("ix_demo_d_email_lower", func.lower(t.c.email), unique=True)
+    assert _codes(t) == ["SM024"]
+
+
+def test_non_tenant_table_is_ignored():
+    t = Table(
+        "demo_e",
+        MetaData(),
+        Column("id", Integer, primary_key=True),
+        Column("slug", String(50), unique=True),
+    )
+    assert _codes(t) == []
+
+
+def test_module_tables_only_counts_mixin_models(monkeypatch):
+    import sys
+    import types
+
+    from simple_module_core.diagnostics._tenancy import module_tables
+
+    class MultiTenantMixin:  # stands in for simple_module_db's, matched by name
+        pass
+
+    md = MetaData()
+    scoped = type(
+        "Scoped",
+        (MultiTenantMixin,),
+        {
+            "__table__": Table(
+                "demo_scoped",
+                md,
+                Column("id", Integer, primary_key=True),
+                Column("tenant_id", String(5)),
+            )
+        },
+    )
+    plain = type(
+        "Plain",
+        (),
+        {
+            "__table__": Table(
+                "demo_plain",
+                md,
+                Column("id", Integer, primary_key=True),
+                Column("tenant_id", String(5)),
+            )
+        },
+    )
+    pkg = types.ModuleType("sm024pkg")
+    models = types.ModuleType("sm024pkg.models")
+    models.Scoped, models.Plain = scoped, plain
+    monkeypatch.setitem(sys.modules, "sm024pkg", pkg)
+    monkeypatch.setitem(sys.modules, "sm024pkg.models", models)
+
+    mod_cls = type("DemoModule", (), {"__module__": "sm024pkg.module"})
+    assert [t.name for t in module_tables(mod_cls())] == ["demo_scoped"]
diff --git a/framework/db/simple_module_db/__init__.py b/framework/db/simple_module_db/__init__.py
index 7a91b601..a9af7309 100644
--- a/framework/db/simple_module_db/__init__.py
+++ b/framework/db/simple_module_db/__init__.py
@@ -4,7 +4,6 @@
 from simple_module_db.base import create_module_base
 from simple_module_db.callbacks import OnCommitCallback
 from simple_module_db.deps import get_db
-from simple_module_db.listeners import TenantIsolationError, current_tenant_id
 from simple_module_db.migrations import (
     build_module_metadata,
     make_include_object,
@@ -15,21 +14,37 @@
 from simple_module_db.provider import DatabaseProvider, detect_provider
 from simple_module_db.search import LIKE_ESCAPE_CHAR, like_contains_pattern, like_prefix_pattern
 from simple_module_db.session import DatabaseState, RequestSession, init_db
+from simple_module_db.tenancy import (
+    ALL_TENANTS_OPTION,
+    TENANT_ID_PATTERN,
+    MissingTenantError,
+    TenantIsolationError,
+    all_tenants,
+    bind_current_tenant,
+    current_tenant_id,
+    is_valid_tenant_id,
+    tenant_context,
+)
 from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session
 
 __all__ = [
+    "ALL_TENANTS_OPTION",
     "LIKE_ESCAPE_CHAR",
+    "TENANT_ID_PATTERN",
     "AuditMixin",
     "AuditRecord",
     "CommitBeforeResponseMiddleware",
     "DatabaseProvider",
     "DatabaseState",
+    "MissingTenantError",
     "MultiTenantMixin",
     "OnCommitCallback",
     "RequestSession",
     "SoftDeleteMixin",
     "TenantIsolationError",
     "VersionedMixin",
+    "all_tenants",
+    "bind_current_tenant",
     "build_module_metadata",
     "create_module_base",
     "current_tenant_id",
@@ -37,9 +52,11 @@
     "finalize_session",
     "get_db",
     "init_db",
+    "is_valid_tenant_id",
     "like_contains_pattern",
     "like_prefix_pattern",
     "make_include_object",
     "make_process_revision_directives",
     "render_item",
+    "tenant_context",
 ]
diff --git a/framework/db/simple_module_db/flush_guard.py b/framework/db/simple_module_db/flush_guard.py
new file mode 100644
index 00000000..cd70068f
--- /dev/null
+++ b/framework/db/simple_module_db/flush_guard.py
@@ -0,0 +1,73 @@
+"""Unit-of-work tenant rules, applied in ``before_flush``.
+
+The query filter scopes what a statement *reads*; this guards what a flush
+*writes*. It covers the paths that never reach ``do_orm_execute``: objects
+added with ``session.add``, and objects that came out of the identity map —
+``session.get`` answers from it without SQL, so a session reused across a
+``tenant_context`` switch can hand back, and then write, another tenant's row.
+"""
+
+from __future__ import annotations
+
+from sqlalchemy import inspect as sa_inspect
+from sqlalchemy.orm import Session
+
+from simple_module_db.mixins import MultiTenantMixin
+from simple_module_db.query_filter import is_strict
+from simple_module_db.tenancy import (
+    TenantIsolationError,
+    current_tenant_id,
+    is_all_tenants,
+    missing_tenant_error,
+)
+
+
+def _owner(obj: MultiTenantMixin) -> str | None:
+    """The tenant the row belongs to in the database (before this flush)."""
+    hist = sa_inspect(obj).attrs.tenant_id.history
+    if hist.deleted:
+        return hist.deleted[0]
+    return obj.tenant_id
+
+
+def guard_flush(session: Session) -> None:
+    if is_all_tenants():
+        return
+    tenant_id = current_tenant_id.get()
+    strict = is_strict(session)
+
+    for obj in session.new:
+        if not isinstance(obj, MultiTenantMixin):
+            continue
+        if obj.tenant_id is None:
+            if tenant_id is not None:
+                obj.tenant_id = tenant_id
+            elif strict:
+                raise missing_tenant_error(type(obj).__name__, "INSERT")
+        elif tenant_id is not None and obj.tenant_id != tenant_id:
+            raise TenantIsolationError(
+                f"Cannot create object for tenant '{obj.tenant_id}' "
+                f"in context of tenant '{tenant_id}'"
+            )
+
+    for obj in list(session.dirty) + list(session.deleted):
+        if not isinstance(obj, MultiTenantMixin):
+            continue
+        is_deleted = obj in session.deleted
+        if not is_deleted and not session.is_modified(obj):
+            continue
+        # Moving a row between tenants is never a routine edit, bound or not
+        # (#356); only a deliberate all_tenants() block may do it.
+        if not is_deleted and sa_inspect(obj).attrs.tenant_id.history.has_changes():
+            raise TenantIsolationError("Cannot change tenant_id of an existing object")
+        owner = _owner(obj)
+        op = "DELETE" if is_deleted else "UPDATE"
+        if tenant_id is not None and owner != tenant_id:
+            raise TenantIsolationError(
+                f"Cannot {op} an object of tenant '{owner}' in context of tenant '{tenant_id}'"
+            )
+        if tenant_id is None and strict:
+            raise missing_tenant_error(type(obj).__name__, op)
+
+
+__all__ = ["guard_flush"]
diff --git a/framework/db/simple_module_db/listeners.py b/framework/db/simple_module_db/listeners.py
index 7815183c..70a99425 100644
--- a/framework/db/simple_module_db/listeners.py
+++ b/framework/db/simple_module_db/listeners.py
@@ -8,25 +8,22 @@
 
 from sqlalchemy import event
 from sqlalchemy import inspect as sa_inspect
-from sqlalchemy.orm import ORMExecuteState, Session, with_loader_criteria
+from sqlalchemy.orm import Session
 
-from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
+from simple_module_db.flush_guard import guard_flush
+from simple_module_db.mixins import AuditMixin, SoftDeleteMixin, VersionedMixin
+from simple_module_db.query_filter import bind_engine_policy, filter_statements
 from simple_module_db.session import DatabaseState
 
+# Re-exported: callers import these from here since before tenancy.py existed.
+from simple_module_db.tenancy import TenantIsolationError, current_tenant_id  # noqa: F401
+
 logger = logging.getLogger(__name__)
 _db_logger = logging.getLogger("simple_module.db")
 
 # Set by auth middleware on each request
 current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None)
 
-# Set by tenant middleware on each request
-current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
-
-
-class TenantIsolationError(Exception):
-    """Raised when a multi-tenancy isolation constraint is violated."""
-
-
 # Key on ``Session.info`` stamped by the after_flush listener so
 # ``get_db`` can distinguish read-only requests from write requests after
 # flush has cleared ``session.new/.dirty/.deleted``.
@@ -93,22 +90,36 @@ def register_listeners(db_state: DatabaseState) -> None:
 
     global _db_state
     _db_state = db_state
-
-    event.listen(db_state.sync_session_class, "before_flush", _before_flush_listener)
-    event.listen(db_state.sync_session_class, "after_flush", _mark_session_written)
-    event.listen(db_state.sync_session_class, "after_flush_postexec", _after_flush_audit)
-    event.listen(db_state.sync_session_class, "do_orm_execute", _filter_select_statements)
+    # Strict mode is read from db_state at query time, per engine.
+    bind_engine_policy(db_state.engine.sync_engine, db_state)
+    attach_session_listeners(db_state.sync_session_class)
     db_state._listeners_registered = True
     logger.info("Registered SQLAlchemy entity listeners")
 
 
+def attach_session_listeners(session_class: type[Session]) -> None:
+    """Attach the entity listeners to a session class; idempotent.
+
+    For a process with no ``DatabaseState`` (the Celery worker's sync engine),
+    call this and ``bind_engine_policy(engine, EngineTenancy(...))``.
+    """
+    for name, fn in (
+        ("before_flush", _before_flush_listener),
+        ("after_flush", _mark_session_written),
+        ("after_flush_postexec", _after_flush_audit),
+        ("do_orm_execute", filter_statements),
+    ):
+        if not event.contains(session_class, name, fn):
+            event.listen(session_class, name, fn)
+
+
 def _before_flush_listener(
     session: Session,
     flush_context: object,
     instances: object,
 ) -> None:
+    guard_flush(session)
     user_id = current_user_id.get()
-    tenant_id = current_tenant_id.get()
     now = datetime.now(UTC)
 
     for obj in session.new:
@@ -118,16 +129,6 @@ def _before_flush_listener(
             if obj.updated_by is None:
                 obj.updated_by = user_id
 
-        # Auto-populate tenant_id; reject cross-tenant creation
-        if isinstance(obj, MultiTenantMixin):
-            if obj.tenant_id is None and tenant_id is not None:
-                obj.tenant_id = tenant_id
-            elif tenant_id is not None and obj.tenant_id != tenant_id:
-                raise TenantIsolationError(
-                    f"Cannot create object for tenant '{obj.tenant_id}' "
-                    f"in context of tenant '{tenant_id}'"
-                )
-
         _db_logger.info(
             _EVENT_ENTITY_CREATED,
             extra={
@@ -148,12 +149,6 @@ def _before_flush_listener(
         if isinstance(obj, VersionedMixin):
             obj.version += 1
 
-        # Prevent tenant_id from being changed on existing objects
-        if isinstance(obj, MultiTenantMixin) and tenant_id is not None:
-            hist = sa_inspect(obj).attrs.tenant_id.history
-            if hist.has_changes():
-                raise TenantIsolationError("Cannot change tenant_id of an existing object")
-
         _db_logger.info(
             _EVENT_ENTITY_UPDATED,
             extra={
@@ -233,47 +228,3 @@ def _after_flush_audit(session: Session, flush_context: object) -> None:
     records = finalize_records(pending)
     if records:
         _db_state.audit_callback(session, records)
-
-
-# Cache ``(is_soft_delete, is_multi_tenant)`` flags per mapper class so the
-# ``do_orm_execute`` hot path skips redundant ``issubclass`` work on every query.
-_mixin_flags_cache: dict[type, tuple[bool, bool]] = {}
-
-
-def _filter_select_statements(execute_state: ORMExecuteState) -> None:
-    """Attach per-mapper ``with_loader_criteria`` for soft-delete and tenant isolation.
-
-    The criteria are attached per concrete mapper because SQLModel mixins
-    expose Pydantic ``FieldInfo`` (not SQLAlchemy ``InstrumentedAttribute``)
-    at the mixin-class level, which breaks the lambda form of
-    ``with_loader_criteria`` that was used before the SQLModel migration.
-
-    Soft-delete bypass: ``stmt.execution_options(include_deleted=True)``.
-    """
-    if not execute_state.is_select:
-        return
-
-    skip_soft_delete = execute_state.execution_options.get("include_deleted", False)
-    tenant_id = current_tenant_id.get()
-    if skip_soft_delete and tenant_id is None:
-        return
-
-    options = []
-    for mapper in execute_state.all_mappers:
-        cls = mapper.class_
-        flags = _mixin_flags_cache.get(cls)
-        if flags is None:
-            flags = (issubclass(cls, SoftDeleteMixin), issubclass(cls, MultiTenantMixin))
-            _mixin_flags_cache[cls] = flags
-        is_soft_delete, is_multi_tenant = flags
-        if is_soft_delete and not skip_soft_delete:
-            options.append(
-                with_loader_criteria(cls, cls.is_deleted.is_(False), include_aliases=True)
-            )
-        if is_multi_tenant and tenant_id is not None:
-            options.append(
-                with_loader_criteria(cls, cls.tenant_id == tenant_id, include_aliases=True)
-            )
-
-    if options:
-        execute_state.statement = execute_state.statement.options(*options)
diff --git a/framework/db/simple_module_db/model_registry.py b/framework/db/simple_module_db/model_registry.py
new file mode 100644
index 00000000..99cfefa8
--- /dev/null
+++ b/framework/db/simple_module_db/model_registry.py
@@ -0,0 +1,56 @@
+"""Every mapped tenant-scoped and soft-deletable model, known to the filters.
+
+Filled as mappers configure (and topped up from any statement that names a
+model), so criteria can be attached for models a statement does *not* name:
+a join target, a subquery, a count (#332).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from sqlalchemy import event
+from sqlalchemy.orm import Mapper
+
+from simple_module_db.mixins import MultiTenantMixin, SoftDeleteMixin
+
+tenant_classes: set[type] = set()
+soft_delete_classes: set[type] = set()
+tenant_table_names: set[str] = set()
+soft_delete_table_names: set[str] = set()
+
+# ``(is_soft_delete, is_multi_tenant)`` per class, for the hot path.
+_flags_cache: dict[type, tuple[bool, bool]] = {}
+
+
+def flags(cls: type) -> tuple[bool, bool]:
+    found = _flags_cache.get(cls)
+    if found is None:
+        found = (issubclass(cls, SoftDeleteMixin), issubclass(cls, MultiTenantMixin))
+        _flags_cache[cls] = found
+        register(cls)
+    return found
+
+
+def register(cls: type) -> None:
+    table = getattr(cls, "__table__", None)
+    if table is None:
+        return
+    if issubclass(cls, MultiTenantMixin) and cls not in tenant_classes:
+        tenant_classes.add(cls)
+        tenant_table_names.add(table.name)
+    if issubclass(cls, SoftDeleteMixin) and cls not in soft_delete_classes:
+        soft_delete_classes.add(cls)
+        soft_delete_table_names.add(table.name)
+
+
+@event.listens_for(Mapper, "mapper_configured")
+def _on_mapper_configured(mapper: Mapper, cls: type) -> None:
+    register(cls)
+
+
+def is_plain_table(from_obj: Any, names: set[str]) -> bool:
+    """A Core table (``Model.__table__``), not an ORM-annotated occurrence."""
+    return getattr(from_obj, "name", None) in names and "parententity" not in getattr(
+        from_obj, "_annotations", {}
+    )
diff --git a/framework/db/simple_module_db/query_filter.py b/framework/db/simple_module_db/query_filter.py
new file mode 100644
index 00000000..b5a9b8ba
--- /dev/null
+++ b/framework/db/simple_module_db/query_filter.py
@@ -0,0 +1,240 @@
+"""``do_orm_execute`` filter: soft-delete and tenant scoping on every ORM statement.
+
+Scope (#332). Tenant and soft-delete criteria are attached for *every*
+tenant-scoped / soft-deletable model, not only the entities a statement
+names, so a table reached through a join target, an ORM ``exists()`` /
+``in_()`` / scalar subquery or ``select(func.count()).select_from(Model)`` is
+filtered too. A Core statement on ``Model.__table__`` at the top level
+(select / update / delete) gets explicit predicates, and a bare Core
+``exists().where(...)`` has its inner SELECT rewritten (``subquery_guard``).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from weakref import WeakKeyDictionary
+
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import ORMExecuteState, Session, with_loader_criteria
+
+from simple_module_db import model_registry as registry
+from simple_module_db.subquery_guard import scope_exists_subqueries
+from simple_module_db.tenancy import (
+    ALL_TENANTS_OPTION,
+    TenantIsolationError,
+    current_tenant_id,
+    is_all_tenants,
+    missing_tenant_error,
+)
+
+TENANT_COLUMN = "tenant_id"
+
+# Per-engine tenancy policy. Keyed by engine rather than held in a module
+# global so two ``DatabaseState``s in one process (tests, a CLI next to an
+# app) cannot switch each other's strict mode off. Values expose
+# ``tenant_strict`` — a ``DatabaseState``, or an ``EngineTenancy`` for a bare
+# sync engine such as the Celery worker's.
+_engine_policy: WeakKeyDictionary[Engine, Any] = WeakKeyDictionary()
+
+
+class EngineTenancy:
+    """Tenancy policy for an engine that has no ``DatabaseState``."""
+
+    def __init__(self, *, tenant_strict: bool) -> None:
+        self.tenant_strict = tenant_strict
+
+
+def bind_engine_policy(engine: Engine, policy: Any) -> None:
+    """Attach a policy (anything with ``tenant_strict``) to a sync engine."""
+    _engine_policy[engine] = policy
+
+
+def _strict_configured(session: Session) -> bool:
+    bind = session.bind
+    policy = _engine_policy.get(bind) if isinstance(bind, Engine) else None
+    return bool(policy is not None and policy.tenant_strict)
+
+
+def is_strict(session: Session) -> bool:
+    """Strict isolation is on for this session's engine and not waived."""
+    return _strict_configured(session) and not is_all_tenants()
+
+
+def filter_statements(execute_state: ORMExecuteState) -> Any:
+    """Scope ORM statements by soft-delete and tenant.
+
+    * SELECT: soft-delete + tenant loader criteria.
+    * UPDATE / DELETE: tenant criteria, and an UPDATE may not assign
+      ``tenant_id`` (the bulk sibling of the unit-of-work rule).
+    * INSERT: explicit ``tenant_id`` values must match the bound tenant;
+      missing ones are stamped with it (#357).
+
+    Without a tenant, strict mode raises instead of leaving the statement
+    unscoped. Bypass: ``execution_options(all_tenants=True)`` or
+    ``all_tenants()``; soft-delete bypass: ``include_deleted=True``.
+    """
+    if execute_state.is_insert:
+        return _guard_insert(execute_state)
+    is_select = execute_state.is_select
+    if not (is_select or execute_state.is_update or execute_state.is_delete):
+        return None
+
+    options_in = execute_state.execution_options
+    skip_soft_delete = not is_select or options_in.get("include_deleted", False)
+    tenant_id = current_tenant_id.get()
+    skip_tenant = options_in.get(ALL_TENANTS_OPTION, False) or is_all_tenants()
+    strict = _strict_configured(execute_state.session)
+    if skip_soft_delete and (skip_tenant or (tenant_id is None and not strict)):
+        return None
+
+    options = []
+    named: list[type] = []
+    for mapper in execute_state.all_mappers:
+        if registry.flags(mapper.class_)[1]:
+            named.append(mapper.class_)
+    if not skip_soft_delete:
+        options.extend(_soft_delete_criteria(execute_state))
+    if not skip_tenant:
+        options.extend(_tenant_criteria(execute_state, named, tenant_id, strict))
+    if registry.tenant_classes or registry.soft_delete_classes:
+        execute_state.statement = scope_exists_subqueries(
+            execute_state.statement,
+            _subquery_predicates(skip_soft_delete, skip_tenant, tenant_id, strict),
+        )
+
+    if options:
+        execute_state.statement = execute_state.statement.options(*options)
+    return None
+
+
+def _soft_delete_criteria(execute_state: ORMExecuteState) -> list[Any]:
+    for table in _plain_tables(execute_state, registry.soft_delete_table_names):
+        execute_state.statement = execute_state.statement.where(table.c.is_deleted.is_(False))
+    return [
+        with_loader_criteria(c, c.is_deleted.is_(False), include_aliases=True)
+        for c in registry.soft_delete_classes
+    ]
+
+
+def _subquery_predicates(
+    skip_soft_delete: bool, skip_tenant: bool, tenant_id: str | None, strict: bool
+):
+    def predicates_for(from_obj: Any) -> list[Any]:
+        name = getattr(from_obj, "name", None)
+        preds = []
+        if not skip_soft_delete and name in registry.soft_delete_table_names:
+            preds.append(from_obj.c.is_deleted.is_(False))
+        if not skip_tenant and name in registry.tenant_table_names:
+            if tenant_id is not None:
+                preds.append(from_obj.c.tenant_id == tenant_id)
+            elif strict:
+                raise missing_tenant_error(name, "SELECT")
+        return preds
+
+    return predicates_for
+
+
+def _plain_tables(execute_state: ORMExecuteState, names: set[str]) -> list[Any]:
+    """Tables a statement uses as plain Core tables (``Model.__table__``).
+
+    Only the top level: the FROM list of a select, the target of an
+    update/delete. ORM-annotated occurrences are covered by loader criteria.
+    """
+    stmt = execute_state.statement
+    if execute_state.is_select:
+        froms = [*getattr(stmt, "columns_clause_froms", ()), *getattr(stmt, "_from_obj", ())]
+    else:
+        froms = [getattr(stmt, "table", None)]
+    return [f for f in froms if registry.is_plain_table(f, names)]
+
+
+def _tenant_criteria(
+    execute_state: ORMExecuteState, named: list[type], tenant_id: str | None, strict: bool
+) -> list[Any]:
+    core = _plain_tables(execute_state, registry.tenant_table_names)
+    if execute_state.is_update and (named or core) and _assigned_tenant_ids(execute_state):
+        raise TenantIsolationError("Cannot change tenant_id of existing rows")
+    op = (
+        "SELECT" if execute_state.is_select else ("UPDATE" if execute_state.is_update else "DELETE")
+    )
+    if tenant_id is None:
+        if not strict:
+            return []
+        if named or core:
+            raise missing_tenant_error(named[0].__name__ if named else core[0].name, op)
+        # Indirect references (joins, subqueries) with no tenant bound match
+        # nothing — tenant_id is NOT NULL — rather than every tenant.
+        return [
+            with_loader_criteria(c, c.tenant_id.is_(None), include_aliases=True)
+            for c in registry.tenant_classes
+        ]
+    for table in core:
+        execute_state.statement = execute_state.statement.where(table.c.tenant_id == tenant_id)
+    return [
+        with_loader_criteria(c, c.tenant_id == tenant_id, include_aliases=True)
+        for c in registry.tenant_classes | set(named)
+    ]
+
+
+def _bound_value(value: Any) -> Any:
+    return getattr(value, "value", value)
+
+
+def _column_name(key: Any) -> str:
+    return getattr(key, "key", None) or getattr(key, "name", None) or str(key)
+
+
+def _statement_values(execute_state: ORMExecuteState) -> dict[str, Any]:
+    raw = getattr(execute_state.statement, "_values", None) or {}
+    return {_column_name(k): _bound_value(v) for k, v in raw.items()}
+
+
+def _param_rows(execute_state: ORMExecuteState) -> list[dict[str, Any]]:
+    params = execute_state.parameters
+    if isinstance(params, dict):
+        return [params] if params else []
+    return [p for p in (params or []) if isinstance(p, dict)]
+
+
+def _assigned_tenant_ids(execute_state: ORMExecuteState) -> list[Any]:
+    found = []
+    stmt_values = _statement_values(execute_state)
+    if TENANT_COLUMN in stmt_values:
+        found.append(stmt_values[TENANT_COLUMN])
+    found.extend(row[TENANT_COLUMN] for row in _param_rows(execute_state) if TENANT_COLUMN in row)
+    return found
+
+
+def _guard_insert(execute_state: ORMExecuteState) -> Any:
+    mappers = [m.class_.__name__ for m in execute_state.all_mappers if registry.flags(m.class_)[1]]
+    target = getattr(execute_state.statement, "table", None)
+    if not mappers and getattr(target, "name", None) in registry.tenant_table_names:
+        mappers = [target.name]  # Core insert(Model.__table__)
+    if not mappers or execute_state.execution_options.get(ALL_TENANTS_OPTION, False):
+        return None
+    if is_all_tenants():
+        return None
+    tenant_id = current_tenant_id.get()
+    for value in _assigned_tenant_ids(execute_state):
+        if tenant_id is not None and value != tenant_id:
+            raise TenantIsolationError(
+                f"Cannot insert rows for tenant '{value}' in context of tenant '{tenant_id}'"
+            )
+
+    rows = _param_rows(execute_state)
+    stmt_has_tenant = TENANT_COLUMN in _statement_values(execute_state)
+    missing = (
+        [r for r in rows if TENANT_COLUMN not in r] if rows else ([] if stmt_has_tenant else [None])
+    )
+    if not missing:
+        return None
+    if tenant_id is None:
+        if _strict_configured(execute_state.session):
+            raise missing_tenant_error(mappers[0], "INSERT")
+        return None
+    if rows:
+        stamped = [{**r, TENANT_COLUMN: r.get(TENANT_COLUMN, tenant_id)} for r in rows]
+        payload = stamped if isinstance(execute_state.parameters, list) else stamped[0]
+        return execute_state.invoke_statement(params=payload)
+    execute_state.statement = execute_state.statement.values(**{TENANT_COLUMN: tenant_id})
+    return None
diff --git a/framework/db/simple_module_db/session.py b/framework/db/simple_module_db/session.py
index 4eac7a5a..7bd20a16 100644
--- a/framework/db/simple_module_db/session.py
+++ b/framework/db/simple_module_db/session.py
@@ -37,6 +37,10 @@ class DatabaseState:
     session_factory: async_sessionmaker[RequestSession]
     sync_session_class: type[Session] = field(repr=False, default=Session)
     audit_callback: Callable | None = field(default=None, repr=False)
+    # Fail closed: a query on a MultiTenantMixin model with no tenant context
+    # raises instead of reading every tenant. The host turns this on whenever
+    # it runs with ``multi_tenant``; see ``simple_module_db.tenancy``.
+    tenant_strict: bool = False
     _listeners_registered: bool = field(default=False, repr=False)
 
 
diff --git a/framework/db/simple_module_db/subquery_guard.py b/framework/db/simple_module_db/subquery_guard.py
new file mode 100644
index 00000000..65355ec7
--- /dev/null
+++ b/framework/db/simple_module_db/subquery_guard.py
@@ -0,0 +1,49 @@
+"""Scope a bare Core ``exists().where(...)`` — the one shape loader criteria miss.
+
+``select(Model.id).where(...).exists()`` is ORM-compiled and gets the tenant
+and soft-delete criteria like any other statement. ``exists().where(Model.x
+== ...)`` is not: its inner SELECT has no entity in its columns, so SQLAlchemy
+never applies loader criteria to it (#332).
+
+The check is cheap and runs on every filtered statement: a walk of the WHERE,
+column and HAVING clauses looking for an ``Exists``. Only when one is found is
+the statement rewritten — every nested SELECT reading a tenant or soft-delete
+table gains the matching predicates. Duplicates of loader criteria are
+harmless.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+from sqlalchemy.sql import visitors
+from sqlalchemy.sql.selectable import Exists, Select
+
+Predicates = Callable[[Any], list[Any]]
+
+
+def _has_exists(stmt: Any) -> bool:
+    roots = [
+        *getattr(stmt, "_where_criteria", ()),
+        *getattr(stmt, "_raw_columns", ()),
+        *getattr(stmt, "_having_criteria", ()),
+    ]
+    return any(isinstance(el, Exists) for root in roots for el in visitors.iterate(root))
+
+
+def scope_exists_subqueries(stmt: Any, predicates_for: Predicates) -> Any:
+    """Return ``stmt`` with nested SELECTs scoped, or ``stmt`` unchanged."""
+    if not _has_exists(stmt):
+        return stmt
+
+    def replace(element: Any) -> Any:
+        if element is stmt or not isinstance(element, Select):
+            return None
+        preds = [p for from_obj in element.get_final_froms() for p in predicates_for(from_obj)]
+        return element.where(*preds) if preds else None
+
+    return visitors.replacement_traverse(stmt, {}, replace)
+
+
+__all__ = ["scope_exists_subqueries"]
diff --git a/framework/db/simple_module_db/tenancy.py b/framework/db/simple_module_db/tenancy.py
new file mode 100644
index 00000000..71549ced
--- /dev/null
+++ b/framework/db/simple_module_db/tenancy.py
@@ -0,0 +1,169 @@
+"""Tenant context helpers and the fail-closed isolation policy.
+
+``current_tenant_id`` (set per request by ``TenantMiddleware``) scopes every
+query on a :class:`~simple_module_db.mixins.MultiTenantMixin` model. What
+happens when it is *unset* is the dangerous case, and depends on
+``DatabaseState.tenant_strict``:
+
+* **not strict** (single-tenant installs, the historical default): no filter
+  is applied — the query sees every tenant's rows.
+* **strict** (enabled whenever the host runs with ``multi_tenant``): the query
+  raises :class:`TenantIsolationError` instead. A request, background job or
+  CLI command that forgot to establish a tenant fails loudly rather than
+  reading across tenants.
+
+Code that legitimately spans tenants (platform admin screens, maintenance
+jobs) says so explicitly — per statement with
+``stmt.execution_options(all_tenants=True)``, or for a block with
+``with all_tenants(): ...``. Code that acts *for* one tenant outside a request
+uses ``with tenant_context(tenant_id): ...``.
+"""
+
+from __future__ import annotations
+
+import functools
+import inspect
+import re
+from collections.abc import Callable, Iterator
+from contextlib import contextmanager
+from contextvars import ContextVar
+from typing import Any
+
+# Set by tenant middleware on each request
+current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
+
+# Set by ``all_tenants()``; lets a block of platform code read across tenants
+# while strict isolation is on.
+_all_tenants: ContextVar[bool] = ContextVar("sm_all_tenants", default=False)
+
+ALL_TENANTS_OPTION = "all_tenants"
+"""Execution option that exempts one statement from tenant scoping."""
+
+TENANT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,49}$")
+"""What a tenant id may look like: fits ``MultiTenantMixin.tenant_id``
+(``VARCHAR(50)``) and is a printable identifier. Anything taken from a request
+(a header, a path) must pass this before it is bound."""
+
+
+def is_valid_tenant_id(value: object) -> bool:
+    return isinstance(value, str) and TENANT_ID_PATTERN.fullmatch(value) is not None
+
+
+class TenantIsolationError(Exception):
+    """Raised when a multi-tenancy isolation constraint is violated."""
+
+
+class MissingTenantError(TenantIsolationError):
+    """A tenant-scoped operation ran with no tenant bound (strict mode).
+
+    Distinct from its parent so a request handler can tell "this user has no
+    organisation yet" (a user state) from a cross-tenant write (a bug or an
+    attack) and answer them differently.
+    """
+
+
+@contextmanager
+def tenant_context(tenant_id: str) -> Iterator[None]:
+    """Run the block as ``tenant_id`` — for jobs, CLI commands and tests.
+
+    Wins over an enclosing ``all_tenants()``: the natural platform job is
+    ``with all_tenants(): for t in tenants: with tenant_context(t): ...``, and
+    each iteration must be scoped to ``t``, not left unscoped.
+    """
+    if not is_valid_tenant_id(tenant_id):
+        raise ValueError(f"tenant_context() needs a valid tenant id, got {tenant_id!r}")
+    token = current_tenant_id.set(tenant_id)
+    bypass_token = _all_tenants.set(False)
+    try:
+        yield
+    finally:
+        _all_tenants.reset(bypass_token)
+        current_tenant_id.reset(token)
+
+
+@contextmanager
+def all_tenants() -> Iterator[None]:
+    """Run the block unscoped: reads see every tenant, strict mode is waived.
+
+    Also clears any active tenant, so a platform job started from inside a
+    tenant's request does not silently stay scoped to it. Use sparingly —
+    every call site is a place where one tenant can see another's data.
+    """
+    tenant_token = current_tenant_id.set(None)
+    bypass_token = _all_tenants.set(True)
+    try:
+        yield
+    finally:
+        _all_tenants.reset(bypass_token)
+        current_tenant_id.reset(tenant_token)
+
+
+def bind_current_tenant[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
+    """Wrap ``fn`` so it runs as the tenant bound *now*, whenever it is called.
+
+    For work a module defers past the point where the request's tenant is
+    reset (#364) — its own after-response queue, a thread pool, a callback
+    registry. ``db.on_commit`` callbacks and FastAPI ``BackgroundTasks`` do
+    not need it: both run inside the request's tenant scope already.
+    Works for sync and async callables; captures ``all_tenants()`` too.
+    """
+    tenant_id = current_tenant_id.get()
+    bypass = _all_tenants.get()
+
+    def _enter() -> tuple[Any, Any]:
+        return current_tenant_id.set(tenant_id), _all_tenants.set(bypass)
+
+    def _exit(tokens: tuple[Any, Any]) -> None:
+        _all_tenants.reset(tokens[1])
+        current_tenant_id.reset(tokens[0])
+
+    if inspect.iscoroutinefunction(fn):
+
+        @functools.wraps(fn)
+        async def run_async(*args: P.args, **kwargs: P.kwargs) -> Any:
+            tokens = _enter()
+            try:
+                return await fn(*args, **kwargs)
+            finally:
+                _exit(tokens)
+
+        return run_async  # ty: ignore[invalid-return-type]
+
+    @functools.wraps(fn)
+    def run(*args: P.args, **kwargs: P.kwargs) -> R:
+        tokens = _enter()
+        try:
+            return fn(*args, **kwargs)
+        finally:
+            _exit(tokens)
+
+    return run
+
+
+def is_all_tenants() -> bool:
+    """True inside an ``all_tenants()`` block."""
+    return _all_tenants.get()
+
+
+def missing_tenant_error(entity: str, operation: str) -> MissingTenantError:
+    return MissingTenantError(
+        f"{operation} on tenant-scoped '{entity}' without a tenant context. "
+        "Run it inside a request that resolved a tenant, `tenant_context(id)`, "
+        "or — for deliberate cross-tenant access — `all_tenants()` / "
+        "`execution_options(all_tenants=True)`."
+    )
+
+
+__all__ = [
+    "ALL_TENANTS_OPTION",
+    "TENANT_ID_PATTERN",
+    "MissingTenantError",
+    "TenantIsolationError",
+    "all_tenants",
+    "bind_current_tenant",
+    "current_tenant_id",
+    "is_all_tenants",
+    "is_valid_tenant_id",
+    "missing_tenant_error",
+    "tenant_context",
+]
diff --git a/framework/db/tests/conftest.py b/framework/db/tests/conftest.py
index 6130d96e..28998fe1 100644
--- a/framework/db/tests/conftest.py
+++ b/framework/db/tests/conftest.py
@@ -8,15 +8,19 @@
 from _models import _TenantBase
 from simple_module_db.listeners import register_listeners
 from simple_module_db.session import init_db
+from simple_module_test.database import database_url_for_tests, init_db_kwargs, reset_schema
 from sqlalchemy.ext.asyncio import AsyncSession
 
+_URL = database_url_for_tests()
+
 
 @pytest.fixture
 async def tenant_session() -> AsyncGenerator[AsyncSession, None]:
     """Session backed by in-memory SQLite with tenant listeners registered."""
-    db_state = init_db("sqlite+aiosqlite:///:memory:")
+    db_state = init_db(_URL, **init_db_kwargs(_URL))
     try:
         register_listeners(db_state)
+        await reset_schema(db_state.engine)
         async with db_state.engine.begin() as conn:
             await conn.run_sync(_TenantBase.metadata.create_all)
         async with db_state.session_factory() as session:
diff --git a/framework/db/tests/test_soft_delete_shapes.py b/framework/db/tests/test_soft_delete_shapes.py
new file mode 100644
index 00000000..63b122d1
--- /dev/null
+++ b/framework/db/tests/test_soft_delete_shapes.py
@@ -0,0 +1,89 @@
+"""#332 (soft-delete half): trashed rows stay hidden in joins, subqueries and counts."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+import pytest
+from simple_module_db import SoftDeleteMixin, create_module_base
+from simple_module_db.listeners import register_listeners
+from simple_module_db.session import init_db
+from simple_module_test.database import database_url_for_tests, init_db_kwargs, reset_schema
+from sqlalchemy import exists, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlmodel import Field
+
+_URL = database_url_for_tests()
+_Base = create_module_base("sdshapes")
+
+
+class _Folder(_Base, table=True):  # ty: ignore[unsupported-base]
+    __tablename__ = "sdshapes_folder"
+    id: int | None = Field(default=None, primary_key=True)
+    name: str = Field(max_length=20)
+
+
+class _File(_Base, SoftDeleteMixin, table=True):  # ty: ignore[unsupported-base]
+    __tablename__ = "sdshapes_file"
+    id: int | None = Field(default=None, primary_key=True)
+    name: str = Field(max_length=20)
+    folder_id: int | None = Field(default=None, foreign_key="sdshapes_folder.id")
+
+
+@pytest.fixture
+async def db() -> AsyncGenerator[AsyncSession, None]:
+    state = init_db(_URL, **init_db_kwargs(_URL))
+    register_listeners(state)
+    await reset_schema(state.engine)
+    async with state.engine.begin() as conn:
+        await conn.run_sync(_Base.metadata.create_all)
+    async with state.session_factory() as session:
+        session.add_all([_Folder(id=1, name="live"), _Folder(id=2, name="trash")])
+        await session.flush()
+        kept = _File(name="kept", folder_id=1)
+        gone = _File(name="gone", folder_id=2)
+        session.add_all([kept, gone])
+        await session.flush()
+        await session.delete(gone)  # soft delete
+        await session.flush()
+        yield session
+    await state.engine.dispose()
+
+
+def _shapes():
+    f = _File.__table__
+    return {
+        "join target": select(_Folder.name).join(_File).order_by(_Folder.name),
+        "orm exists": select(_Folder.name).where(
+            select(_File.id).where(_File.folder_id == _Folder.id).exists()
+        ),
+        "bare core exists": select(_Folder.name).where(
+            exists().where(_File.folder_id == _Folder.id)
+        ),
+        "in_ subquery": select(_Folder.name).where(_Folder.id.in_(select(_File.folder_id))),
+        "count select_from": select(func.count()).select_from(_File),
+        "count of subquery": select(func.count()).select_from(select(_File).subquery()),
+        "core table": select(f.c.name),
+    }
+
+
+_EXPECTED = {
+    "join target": [("live",)],
+    "orm exists": [("live",)],
+    "bare core exists": [("live",)],
+    "in_ subquery": [("live",)],
+    "count select_from": [(1,)],
+    "count of subquery": [(1,)],
+    "core table": [("kept",)],
+}
+
+
+@pytest.mark.parametrize("shape", list(_EXPECTED))
+async def test_trashed_rows_stay_hidden(db: AsyncSession, shape: str):
+    rows = (await db.execute(_shapes()[shape])).all()
+    assert [tuple(r) for r in rows] == _EXPECTED[shape]
+
+
+async def test_include_deleted_still_reveals_them(db: AsyncSession):
+    stmt = select(func.count()).select_from(_File).execution_options(include_deleted=True)
+    assert (await db.execute(stmt)).scalar_one() == 2
diff --git a/framework/db/tests/test_tenant_dml.py b/framework/db/tests/test_tenant_dml.py
new file mode 100644
index 00000000..11772cee
--- /dev/null
+++ b/framework/db/tests/test_tenant_dml.py
@@ -0,0 +1,135 @@
+"""Tenant rules for bulk DML, the identity map, and nested bypass blocks.
+
+Regressions from the adversarial QA pass on the tenancy work: every test here
+failed (i.e. the leak reproduced) before the fix.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+import pytest
+from _models import _TenantBase, _TenantItem
+from simple_module_db import TenantIsolationError, all_tenants, tenant_context
+from simple_module_db.listeners import register_listeners
+from simple_module_db.session import init_db
+from simple_module_test.database import database_url_for_tests, init_db_kwargs, reset_schema
+from sqlalchemy import insert, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+_URL = database_url_for_tests()
+
+
+async def _session(strict: bool, *, fresh: bool = True) -> tuple:
+    state = init_db(_URL, **init_db_kwargs(_URL))
+    state.tenant_strict = strict
+    register_listeners(state)
+    if fresh:  # on Postgres the second state shares the first one's database
+        await reset_schema(state.engine)
+    async with state.engine.begin() as conn:
+        await conn.run_sync(_TenantBase.metadata.create_all)
+    return state, state.session_factory()
+
+
+@pytest.fixture
+async def strict_session() -> AsyncGenerator[AsyncSession, None]:
+    state, session = await _session(strict=True)
+    async with session:
+        yield session
+    await state.engine.dispose()
+
+
+async def _seed(session: AsyncSession) -> None:
+    for tenant in ("a", "b"):
+        with tenant_context(tenant):
+            session.add(_TenantItem(name=f"item-{tenant}"))
+            await session.flush()
+
+
+async def _names(session: AsyncSession) -> list[str]:
+    with all_tenants():
+        rows = (await session.execute(select(_TenantItem).order_by(_TenantItem.name))).scalars()
+        return [f"{r.tenant_id}:{r.name}" for r in rows]
+
+
+async def test_tenant_context_inside_all_tenants_is_scoped(strict_session: AsyncSession):
+    await _seed(strict_session)
+    with all_tenants(), tenant_context("a"):
+        rows = (await strict_session.execute(select(_TenantItem))).scalars().all()
+        await strict_session.execute(update(_TenantItem).values(name="renamed"))
+    assert [r.tenant_id for r in rows] == ["a"]
+    assert await _names(strict_session) == ["b:item-b", "a:renamed"]
+
+
+async def test_bulk_update_cannot_assign_tenant_id(strict_session: AsyncSession):
+    await _seed(strict_session)
+    with tenant_context("a"), pytest.raises(TenantIsolationError):
+        await strict_session.execute(update(_TenantItem).values(tenant_id="b"))
+
+
+async def test_identity_map_object_cannot_be_written_from_another_tenant(
+    strict_session: AsyncSession,
+):
+    await _seed(strict_session)
+    with tenant_context("b"):
+        foreign = (await strict_session.execute(select(_TenantItem))).scalar_one()
+    with tenant_context("a"):
+        same = await strict_session.get(_TenantItem, foreign.id)  # identity-map hit, no SQL
+        same.name = "hijacked"
+        with pytest.raises(TenantIsolationError, match="of tenant 'b' in context of tenant 'a'"):
+            await strict_session.flush()
+    await strict_session.rollback()
+
+
+async def test_identity_map_object_cannot_be_deleted_from_another_tenant(
+    strict_session: AsyncSession,
+):
+    await _seed(strict_session)
+    with tenant_context("b"):
+        foreign = (await strict_session.execute(select(_TenantItem))).scalar_one()
+    with tenant_context("a"):
+        await strict_session.delete(foreign)
+        with pytest.raises(TenantIsolationError):
+            await strict_session.flush()
+    await strict_session.rollback()
+
+
+@pytest.mark.parametrize("form", ["params", "values"])
+async def test_bulk_insert_rejects_a_foreign_tenant(strict_session: AsyncSession, form: str):
+    with tenant_context("a"), pytest.raises(TenantIsolationError):
+        if form == "params":
+            await strict_session.execute(insert(_TenantItem), [{"name": "x", "tenant_id": "b"}])
+        else:
+            await strict_session.execute(insert(_TenantItem).values(name="x", tenant_id="b"))
+
+
+@pytest.mark.parametrize("form", ["params", "values"])
+async def test_bulk_insert_is_stamped_with_the_bound_tenant(
+    strict_session: AsyncSession, form: str
+):
+    """#357: bulk and Core-style ORM inserts get tenant_id like session.add()."""
+    with tenant_context("a"):
+        if form == "params":
+            await strict_session.execute(insert(_TenantItem), [{"name": "x"}, {"name": "y"}])
+        else:
+            await strict_session.execute(insert(_TenantItem).values(name="x"))
+    assert all(n.startswith("a:") for n in await _names(strict_session))
+
+
+async def test_bulk_insert_without_tenant_fails_closed(strict_session: AsyncSession):
+    with pytest.raises(TenantIsolationError, match="INSERT"):
+        await strict_session.execute(insert(_TenantItem), [{"name": "x"}])
+
+
+async def test_second_database_state_does_not_disable_strict_mode(
+    strict_session: AsyncSession,
+):
+    await _seed(strict_session)
+    other_state, other_session = await _session(strict=False, fresh=False)
+    try:
+        with pytest.raises(TenantIsolationError):
+            await strict_session.execute(select(_TenantItem))
+        async with other_session:  # the non-strict state stays non-strict
+            await other_session.execute(select(_TenantItem))
+    finally:
+        await other_state.engine.dispose()
diff --git a/framework/db/tests/test_tenant_shapes.py b/framework/db/tests/test_tenant_shapes.py
new file mode 100644
index 00000000..77ad9b44
--- /dev/null
+++ b/framework/db/tests/test_tenant_shapes.py
@@ -0,0 +1,121 @@
+"""#332: tenant scoping reaches joins, subqueries, counts and Core statements."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+import pytest
+from simple_module_db import (
+    MissingTenantError,
+    MultiTenantMixin,
+    create_module_base,
+    tenant_context,
+)
+from simple_module_db.listeners import register_listeners
+from simple_module_db.session import init_db
+from simple_module_test.database import database_url_for_tests, init_db_kwargs, reset_schema
+from sqlalchemy import delete, exists, func, insert, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlmodel import Field
+
+_URL = database_url_for_tests()
+
+_Base = create_module_base("shapes")
+
+
+class _Project(_Base, table=True):  # ty: ignore[unsupported-base]
+    __tablename__ = "shapes_project"
+    id: int | None = Field(default=None, primary_key=True)
+    name: str = Field(max_length=20)
+
+
+class _Doc(_Base, MultiTenantMixin, table=True):  # ty: ignore[unsupported-base]
+    __tablename__ = "shapes_doc"
+    id: int | None = Field(default=None, primary_key=True)
+    name: str = Field(max_length=20)
+    project_id: int | None = Field(default=None, foreign_key="shapes_project.id")
+
+
+@pytest.fixture
+async def db() -> AsyncGenerator[AsyncSession, None]:
+    state = init_db(_URL, **init_db_kwargs(_URL))
+    state.tenant_strict = True
+    register_listeners(state)
+    await reset_schema(state.engine)
+    async with state.engine.begin() as conn:
+        await conn.run_sync(_Base.metadata.create_all)
+    async with state.session_factory() as session:
+        session.add_all([_Project(id=1, name="p1"), _Project(id=2, name="p2")])
+        await session.flush()  # parents first: no relationship() orders the flush
+        for tenant, project in (("a", 1), ("b", 2)):
+            with tenant_context(tenant):
+                session.add(_Doc(name=f"doc-{tenant}", project_id=project))
+                await session.flush()
+        yield session
+    await state.engine.dispose()
+
+
+def _shapes():
+    doc = _Doc.__table__
+    return {
+        "join target": select(_Project.name).join(_Doc).where(_Doc.name == "doc-b"),
+        "orm exists": select(_Project.id).where(
+            select(_Doc.id).where(_Doc.project_id == _Project.id, _Doc.name == "doc-b").exists()
+        ),
+        "in_ subquery": select(_Project.id).where(
+            _Project.id.in_(select(_Doc.project_id).where(_Doc.name == "doc-b"))
+        ),
+        "scalar subquery": select(select(func.count(_Doc.id)).scalar_subquery()),
+        "count select_from": select(func.count()).select_from(_Doc),
+        "count of subquery": select(func.count()).select_from(select(_Doc).subquery()),
+        "core table": select(doc.c.name),
+        "bare core exists": select(_Project.id).where(
+            exists().where(_Doc.project_id == _Project.id, _Doc.name == "doc-b")
+        ),
+    }
+
+
+# What tenant "a" must see for each shape: never tenant b's doc or project.
+_EXPECTED_FOR_A = {
+    "join target": [],
+    "orm exists": [],
+    "in_ subquery": [],
+    "scalar subquery": [(1,)],
+    "count select_from": [(1,)],
+    "count of subquery": [(1,)],
+    "core table": [("doc-a",)],
+    "bare core exists": [],
+}
+
+
+@pytest.mark.parametrize("shape", list(_EXPECTED_FOR_A))
+async def test_bound_tenant_never_sees_other_tenants(db: AsyncSession, shape: str):
+    with tenant_context("a"):
+        rows = (await db.execute(_shapes()[shape])).all()
+    assert [tuple(r) for r in rows] == _EXPECTED_FOR_A[shape]
+
+
+@pytest.mark.parametrize("shape", ["core table", "count select_from", "bare core exists"])
+async def test_strict_without_tenant_fails_closed(db: AsyncSession, shape: str):
+    stmt = _shapes()[shape]
+    if shape in ("core table", "bare core exists"):
+        with pytest.raises(MissingTenantError):
+            await db.execute(stmt)
+    else:  # indirect reference: matches nothing rather than every tenant
+        assert (await db.execute(stmt)).scalar_one() == 0
+
+
+async def test_core_update_and_delete_are_scoped(db: AsyncSession):
+    doc = _Doc.__table__
+    with tenant_context("a"):
+        await db.execute(update(doc).values(name="renamed"))
+        await db.execute(delete(doc).where(doc.c.name == "doc-b"))
+    with tenant_context("b"):
+        assert (await db.execute(select(_Doc.name))).scalars().all() == ["doc-b"]
+
+
+async def test_core_insert_is_stamped(db: AsyncSession):
+    with tenant_context("a"):
+        await db.execute(insert(_Doc.__table__).values(name="core"))
+        names = (await db.execute(select(_Doc.name).order_by(_Doc.name))).scalars().all()
+    assert names == ["core", "doc-a"]
diff --git a/framework/db/tests/test_tenant_strict.py b/framework/db/tests/test_tenant_strict.py
new file mode 100644
index 00000000..10a4bc0c
--- /dev/null
+++ b/framework/db/tests/test_tenant_strict.py
@@ -0,0 +1,147 @@
+"""Strict (fail-closed) tenant isolation: no tenant context means no query."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+import pytest
+from _models import _TenantBase, _TenantItem
+from simple_module_db import TenantIsolationError, all_tenants, current_tenant_id, tenant_context
+from simple_module_db.listeners import register_listeners
+from simple_module_db.session import init_db
+from simple_module_test.database import database_url_for_tests, init_db_kwargs, reset_schema
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+_URL = database_url_for_tests()
+
+
+@pytest.fixture
+async def strict_session() -> AsyncGenerator[AsyncSession, None]:
+    db_state = init_db(_URL, **init_db_kwargs(_URL))
+    db_state.tenant_strict = True
+    try:
+        register_listeners(db_state)
+        await reset_schema(db_state.engine)
+        async with db_state.engine.begin() as conn:
+            await conn.run_sync(_TenantBase.metadata.create_all)
+        async with db_state.session_factory() as session:
+            yield session
+    finally:
+        db_state.tenant_strict = False
+        await db_state.engine.dispose()
+
+
+async def _seed(session: AsyncSession) -> None:
+    for tenant in ("a", "b"):
+        with tenant_context(tenant):
+            session.add(_TenantItem(name=f"item-{tenant}"))
+            await session.flush()
+
+
+async def test_select_without_tenant_raises(strict_session: AsyncSession):
+    await _seed(strict_session)
+    with pytest.raises(TenantIsolationError, match="SELECT"):
+        await strict_session.execute(select(_TenantItem))
+
+
+async def test_insert_without_tenant_raises_before_db(strict_session: AsyncSession):
+    strict_session.add(_TenantItem(name="orphan"))
+    with pytest.raises(TenantIsolationError, match="INSERT"):
+        await strict_session.flush()
+    await strict_session.rollback()
+
+
+async def test_scoped_select_still_filters(strict_session: AsyncSession):
+    await _seed(strict_session)
+    with tenant_context("a"):
+        rows = (await strict_session.execute(select(_TenantItem))).scalars().all()
+    assert [r.name for r in rows] == ["item-a"]
+
+
+async def test_bulk_update_is_scoped_to_tenant(strict_session: AsyncSession):
+    await _seed(strict_session)
+    with tenant_context("a"):
+        await strict_session.execute(update(_TenantItem).values(name="renamed"))
+    with all_tenants():
+        names = (await strict_session.execute(select(_TenantItem.name))).scalars().all()
+    assert sorted(names) == ["item-b", "renamed"]
+
+
+async def test_bulk_delete_without_tenant_raises(strict_session: AsyncSession):
+    await _seed(strict_session)
+    with pytest.raises(TenantIsolationError, match="DELETE"):
+        await strict_session.execute(delete(_TenantItem))
+
+
+async def test_all_tenants_block_reads_everything(strict_session: AsyncSession):
+    await _seed(strict_session)
+    with all_tenants():
+        count = await strict_session.scalar(select(func.count()).select_from(_TenantItem))
+    assert count == 2
+
+
+async def test_all_tenants_clears_an_active_tenant(strict_session: AsyncSession):
+    await _seed(strict_session)
+    with tenant_context("a"), all_tenants():
+        count = await strict_session.scalar(select(func.count()).select_from(_TenantItem))
+    assert count == 2
+
+
+async def test_statement_option_bypasses_one_query(strict_session: AsyncSession):
+    await _seed(strict_session)
+    stmt = select(_TenantItem).execution_options(all_tenants=True)
+    rows = (await strict_session.execute(stmt)).scalars().all()
+    assert len(rows) == 2
+
+
+async def test_unscoped_code_cannot_move_rows_between_tenants(strict_session: AsyncSession):
+    await _seed(strict_session)
+    with all_tenants():
+        item = (await strict_session.execute(select(_TenantItem).limit(1))).scalar_one()
+    item.tenant_id = "c"
+    with pytest.raises(TenantIsolationError, match="Cannot change tenant_id"):
+        await strict_session.flush()
+    await strict_session.rollback()
+
+
+def test_tenant_context_rejects_empty_id():
+    with pytest.raises(ValueError), tenant_context(""):
+        pass
+
+
+@pytest.mark.parametrize("bad", ["x" * 51, "has space", "-dash-first"])
+def test_tenant_context_rejects_malformed_ids(bad):
+    with pytest.raises(ValueError), tenant_context(bad):
+        pass
+
+
+async def test_unbound_flush_still_refuses_tenant_change(tenant_session: AsyncSession):
+    """#356: without a tenant bound (non-strict), a row still cannot move tenants."""
+    token = current_tenant_id.set("tenant-a")
+    try:
+        item = _TenantItem(name="Stays")
+        tenant_session.add(item)
+        await tenant_session.flush()
+    finally:
+        current_tenant_id.reset(token)
+
+    item.tenant_id = "tenant-b"
+    with pytest.raises(TenantIsolationError, match="Cannot change tenant_id"):
+        await tenant_session.flush()
+    await tenant_session.rollback()
+
+
+async def test_all_tenants_block_may_move_a_row(tenant_session: AsyncSession):
+    token = current_tenant_id.set("tenant-a")
+    try:
+        item = _TenantItem(name="Moves")
+        tenant_session.add(item)
+        await tenant_session.flush()
+    finally:
+        current_tenant_id.reset(token)
+
+    with all_tenants():
+        item.tenant_id = "tenant-b"
+        await tenant_session.flush()
+    assert item.tenant_id == "tenant-b"
diff --git a/framework/hosting/simple_module_hosting/_phase_helpers.py b/framework/hosting/simple_module_hosting/_phase_helpers.py
index f0bcb5a4..e162be18 100644
--- a/framework/hosting/simple_module_hosting/_phase_helpers.py
+++ b/framework/hosting/simple_module_hosting/_phase_helpers.py
@@ -145,6 +145,10 @@ def install_middleware(
     )
     if settings.multi_tenant:
         app.add_middleware(TenantMiddleware, header=settings.tenant_header or None)
+    elif settings.default_tenant:
+        # Single-tenant host with tenant-scoped tables: every request is the
+        # one tenant (#359). Never used with multi_tenant — no shared fallback.
+        app.add_middleware(TenantMiddleware, fixed=settings.default_tenant)
     for mod in modules:
         mod.register_middleware(app)
     app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
diff --git a/framework/hosting/simple_module_hosting/_tenant.py b/framework/hosting/simple_module_hosting/_tenant.py
new file mode 100644
index 00000000..1f6296a0
--- /dev/null
+++ b/framework/hosting/simple_module_hosting/_tenant.py
@@ -0,0 +1,97 @@
+"""Tenant-context middleware: which tenant a request acts for."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+
+from simple_module_db import current_tenant_id, is_valid_tenant_id
+from starlette.datastructures import Headers
+from starlette.requests import Request
+from starlette.types import ASGIApp, Receive, Scope, Send
+
+_SCOPE_HTTP = "http"
+
+TENANT_HEADER = "X-Tenant-ID"
+
+
+TenantResolver = Callable[[Request], Awaitable[str | None]]
+"""Module-owned tenant resolution, registered as ``app.state.tenant_resolver``.
+
+Returns the tenant the request acts for, or ``None``. It owns *every* source —
+membership, session, subdomain, header — so it is also where each is
+validated. Without one the middleware falls back to the principal's
+``tenant_id`` claim."""
+
+
+class TenantMiddleware:
+    """Establish the request's tenant context.
+
+    Sets the ``current_tenant_id`` context var so that DB queries on
+    :class:`~simple_module_db.mixins.MultiTenantMixin` models are
+    automatically filtered, and new objects get ``tenant_id`` populated.
+
+    Also stores the resolved value on ``request.state.tenant_id``.
+
+    Resolution:
+
+    1. If a module registered ``app.state.tenant_resolver`` (the ``tenants``
+       module does), its answer is final — including ``None``.
+    2. Otherwise the authenticated user's ``tenant_id`` attribute (from the
+       auth token claims).
+    3. Otherwise, for **anonymous** requests only, the configured header —
+       useful for API clients and tests. Pass ``header=None`` (the default)
+       to disable it. An authenticated user is never allowed to pick a tenant
+       by header: a user with no tenant of their own would otherwise be able
+       to name anyone's.
+    """
+
+    def __init__(
+        self, app: ASGIApp, *, header: str | None = None, fixed: str | None = None
+    ) -> None:
+        self.app = app
+        self.header = header
+        # Single-tenant hosts: one tenant for every request, nothing resolved.
+        self.fixed = fixed
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+        if scope["type"] != _SCOPE_HTTP:
+            await self.app(scope, receive, send)
+            return
+
+        request = Request(scope)
+        tenant_id = await self._resolve(request, scope)
+        request.state.tenant_id = tenant_id
+
+        if tenant_id is not None:
+            token = current_tenant_id.set(tenant_id)
+            try:
+                await self.app(scope, receive, send)
+            finally:
+                current_tenant_id.reset(token)
+            return
+
+        await self.app(scope, receive, send)
+
+    async def _resolve(self, request: Request, scope: Scope) -> str | None:
+        if self.fixed is not None:
+            return self.fixed
+        app = scope.get("app")
+        resolver: TenantResolver | None = getattr(
+            getattr(app, "state", None), "tenant_resolver", None
+        )
+        if resolver is not None:
+            return await resolver(request)
+
+        user = getattr(request.state, "user", None)
+        if user is not None:
+            return getattr(user, "tenant_id", None)
+
+        if self.header:
+            value = Headers(scope=scope).get(self.header)
+            # Unvalidated, an over-long value is a 500 on the first stamped
+            # write (VARCHAR(50)) and any junk becomes a tenant name (#366).
+            return value if is_valid_tenant_id(value) else None
+        return None
+
+
+__all__ = ["TENANT_HEADER", "TenantMiddleware", "TenantResolver"]
diff --git a/framework/hosting/simple_module_hosting/app_builder.py b/framework/hosting/simple_module_hosting/app_builder.py
index c49486e7..9402675b 100644
--- a/framework/hosting/simple_module_hosting/app_builder.py
+++ b/framework/hosting/simple_module_hosting/app_builder.py
@@ -235,6 +235,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
         pool_pre_ping=settings.db_pool_pre_ping,
         pool_recycle=settings.db_pool_recycle,
     )
+    # Multi-tenant installs fail closed: a tenant-scoped query with no tenant
+    # context raises instead of reading every tenant's rows.
+    db_state.tenant_strict = settings.multi_tenant
     register_listeners(db_state)
     # The host's own readiness signal, and the only probe-safe check in a
     # default install — module checks reach third parties and are on-demand.
diff --git a/framework/hosting/simple_module_hosting/host_settings.py b/framework/hosting/simple_module_hosting/host_settings.py
index 24d6dfab..529de771 100644
--- a/framework/hosting/simple_module_hosting/host_settings.py
+++ b/framework/hosting/simple_module_hosting/host_settings.py
@@ -35,6 +35,12 @@ class HostSettings(BaseSettings):
 
     multi_tenant: bool = False
     tenant_header: str = ""
+    default_tenant: str = ""
+    """Single-tenant hosts (``multi_tenant`` off): the tenant every request acts
+    as, so a module's ``MultiTenantMixin`` tables work without a tenant
+    resolver. Ignored when ``multi_tenant`` is on — a multi-tenant install
+    never falls back to a shared tenant. Jobs and CLI commands use
+    ``tenant_context(settings.default_tenant)``."""
 
     maintenance_mode: bool = False
     """Serve everyone but admins a 503 page.
@@ -123,6 +129,16 @@ def _normalize_trusted_proxy(cls, value: str | None) -> str | None:
             return None
         return value.strip() or None
 
+    @field_validator("default_tenant", mode="after")
+    @classmethod
+    def _check_default_tenant(cls, value: str) -> str:
+        from simple_module_db import is_valid_tenant_id
+
+        value = value.strip()
+        if value and not is_valid_tenant_id(value):
+            raise ValueError(f"default_tenant {value!r} is not a valid tenant id")
+        return value
+
     @model_validator(mode="after")
     def _check_default_locale_supported(self) -> HostSettings:
         if self.i18n_default_locale not in self.i18n_supported_locales:
diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py
index 397e1962..f7184d94 100644
--- a/framework/hosting/simple_module_hosting/middleware.py
+++ b/framework/hosting/simple_module_hosting/middleware.py
@@ -13,8 +13,7 @@
 from collections.abc import Callable
 from typing import TYPE_CHECKING, Any
 
-from simple_module_db import current_tenant_id
-from starlette.datastructures import Headers, MutableHeaders
+from starlette.datastructures import MutableHeaders
 from starlette.requests import Request
 from starlette.types import ASGIApp, Message, Receive, Scope, Send
 
@@ -27,6 +26,7 @@
     CorrelationIdMiddleware,
     RequestLoggingMiddleware,
 )
+from simple_module_hosting._tenant import TENANT_HEADER, TenantMiddleware, TenantResolver
 from simple_module_hosting.permissions import expand_permissions, resolve_permissions
 
 if TYPE_CHECKING:
@@ -62,6 +62,7 @@
     "RequestLoggingMiddleware",
     "SecurityHeadersMiddleware",
     "TenantMiddleware",
+    "TenantResolver",
 ]
 
 
@@ -155,61 +156,6 @@ async def send_with_headers(message: Message) -> None:
         await self.app(scope, receive, send_with_headers)
 
 
-TENANT_HEADER = "X-Tenant-ID"
-
-
-class TenantMiddleware:
-    """Extract tenant context from authenticated user or request header.
-
-    Sets the ``current_tenant_id`` context var so that DB queries on
-    :class:`~simple_module_db.mixins.MultiTenantMixin` models are
-    automatically filtered, and new objects get ``tenant_id`` populated.
-
-    Also stores the resolved value on ``request.state.tenant_id``.
-
-    Tenant is resolved from (in priority order):
-
-    1. Authenticated user's ``tenant_id`` attribute (from auth token claims).
-    2. The configured request header, if any — useful for API clients
-       and tests. Pass ``header=None`` (the default) to disable the
-       header source and force tenant resolution through the auth token
-       only. Pass the header name (e.g. ``"X-Tenant-ID"``) to enable.
-    """
-
-    def __init__(self, app: ASGIApp, *, header: str | None = None) -> None:
-        self.app = app
-        self.header = header
-
-    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
-        if scope["type"] != _SCOPE_HTTP:
-            await self.app(scope, receive, send)
-            return
-
-        request = Request(scope)
-        tenant_id: str | None = None
-
-        user = getattr(request.state, "user", None)
-        if user is not None:
-            tenant_id = getattr(user, "tenant_id", None)
-
-        if tenant_id is None and self.header:
-            header_value = Headers(scope=scope).get(self.header)
-            if header_value:
-                tenant_id = header_value
-
-        request.state.tenant_id = tenant_id
-
-        if tenant_id is not None:
-            token = current_tenant_id.set(tenant_id)
-            try:
-                await self.app(scope, receive, send)
-            finally:
-                current_tenant_id.reset(token)
-            return
-
-        await self.app(scope, receive, send)
-
-
 PrincipalSerializer = Callable[[Any], dict[str, Any]]
 """Module-owned projection from ``request.state.user`` to the ``auth.user``
 shared-prop dict. Framework never inspects user fields beyond ``roles``; a
diff --git a/framework/hosting/tests/test_tenant_middleware.py b/framework/hosting/tests/test_tenant_middleware.py
index 5acb429e..33d040b1 100644
--- a/framework/hosting/tests/test_tenant_middleware.py
+++ b/framework/hosting/tests/test_tenant_middleware.py
@@ -126,8 +126,8 @@ async def failing_app(scope, receive, send):
 
         assert current_tenant_id.get() is None
 
-    async def test_user_without_tenant_id_falls_back_to_header(self):
-        """An authenticated user whose tenant_id is None shouldn't block header fallback."""
+    async def test_user_without_tenant_id_cannot_pick_tenant_by_header(self):
+        """An authenticated user with no tenant must not be able to name one by header."""
         captured: dict = {}
 
         async def inner_app(scope, receive, send):
@@ -138,7 +138,46 @@ async def inner_app(scope, receive, send):
 
         await TenantMiddleware(inner_app, header="X-Tenant-ID")(scope, _noop_receive, _noop_send)
 
-        assert captured["tenant_id"] == "from-header"
+        assert captured["tenant_id"] is None
+
+    async def test_registered_resolver_owns_resolution(self):
+        """app.state.tenant_resolver's answer is final, over user claim and header."""
+        captured: dict = {}
+        seen: dict = {}
+
+        async def inner_app(scope, receive, send):
+            captured["tenant_id"] = current_tenant_id.get()
+
+        async def resolver(request):
+            seen["path"] = request.url.path
+            return "resolved"
+
+        scope = _http_scope(headers=[(b"x-tenant-id", b"from-header")])
+        scope["state"]["user"] = SimpleNamespace(tenant_id="claim")
+        scope["app"] = SimpleNamespace(state=SimpleNamespace(tenant_resolver=resolver))
+
+        await TenantMiddleware(inner_app, header="X-Tenant-ID")(scope, _noop_receive, _noop_send)
+
+        assert captured["tenant_id"] == "resolved"
+        assert seen["path"] == "/"
+
+    async def test_resolver_returning_none_is_final(self):
+        """A resolver that answers None must not fall back to the unvalidated claim."""
+        captured: dict = {}
+
+        async def inner_app(scope, receive, send):
+            captured["tenant_id"] = current_tenant_id.get()
+
+        async def resolver(request):
+            return None
+
+        scope = _http_scope()
+        scope["state"]["user"] = SimpleNamespace(tenant_id="claim")
+        scope["app"] = SimpleNamespace(state=SimpleNamespace(tenant_resolver=resolver))
+
+        await TenantMiddleware(inner_app)(scope, _noop_receive, _noop_send)
+
+        assert captured["tenant_id"] is None
 
 
 class TestTenantMiddlewareIntegration:
@@ -158,3 +197,17 @@ async def test_tenant_middleware_absent_when_opted_out(self):
         app = create_app(single_tenant_settings)
         middleware_classes = [m.cls for m in app.user_middleware]
         assert TenantMiddleware not in middleware_classes
+
+
+@pytest.mark.parametrize("value", [b"a" * 51, b"-leading-dash", b"sp ace", b"semi;colon"])
+async def test_invalid_header_value_is_not_bound(value):
+    """#366: an over-long or malformed header must not become the tenant."""
+    captured: dict = {}
+
+    async def inner_app(scope, receive, send):
+        captured["tenant_id"] = current_tenant_id.get()
+
+    scope = _http_scope(headers=[(b"x-tenant-id", value)])
+    await TenantMiddleware(inner_app, header="X-Tenant-ID")(scope, _noop_receive, _noop_send)
+
+    assert captured["tenant_id"] is None
diff --git a/framework/hosting/tests/test_tenant_scope_helpers.py b/framework/hosting/tests/test_tenant_scope_helpers.py
new file mode 100644
index 00000000..d4314178
--- /dev/null
+++ b/framework/hosting/tests/test_tenant_scope_helpers.py
@@ -0,0 +1,99 @@
+"""#359 default tenant for single-tenant hosts; #364 work deferred past the request."""
+
+from __future__ import annotations
+
+import asyncio
+
+import httpx
+import pytest
+from fastapi import BackgroundTasks, FastAPI
+from pydantic import ValidationError
+from simple_module_db import all_tenants, bind_current_tenant, current_tenant_id, tenant_context
+from simple_module_hosting.app_builder import create_app
+from simple_module_hosting.host_settings import HostSettings
+from simple_module_hosting.middleware import TenantMiddleware
+from simple_module_hosting.settings import Settings
+
+
+def _settings(**overrides) -> Settings:
+    return Settings(
+        database_url="sqlite+aiosqlite:///:memory:",
+        environment="testing",
+        secret_key="test-secret-key",
+        **overrides,
+    )
+
+
+def _tenant_middleware(app: FastAPI):
+    return [m for m in app.user_middleware if m.cls is TenantMiddleware]
+
+
+def test_single_tenant_host_binds_the_default_tenant():
+    app = create_app(_settings(multi_tenant=False, default_tenant="main"))
+    [mw] = _tenant_middleware(app)
+    assert mw.kwargs == {"fixed": "main"}
+
+
+def test_default_tenant_is_ignored_when_multi_tenant():
+    app = create_app(_settings(multi_tenant=True, default_tenant="main"))
+    [mw] = _tenant_middleware(app)
+    assert "fixed" not in mw.kwargs
+
+
+def test_no_tenant_middleware_without_either():
+    assert _tenant_middleware(create_app(_settings(multi_tenant=False))) == []
+
+
+def test_default_tenant_must_be_a_valid_id():
+    with pytest.raises(ValidationError):
+        HostSettings(default_tenant="has space")
+
+
+async def test_fixed_tenant_middleware_binds_every_request():
+    seen = {}
+
+    async def inner(scope, receive, send):
+        seen["tenant"] = current_tenant_id.get()
+
+    scope = {"type": "http", "method": "GET", "path": "/", "headers": [], "state": {}}
+    await TenantMiddleware(inner, fixed="main")(scope, None, None)
+    assert seen["tenant"] == "main"
+
+
+async def test_background_tasks_run_inside_the_request_tenant():
+    """FastAPI BackgroundTasks run inside TenantMiddleware: no capture needed."""
+    seen = {}
+    api = FastAPI()
+
+    @api.get("/x")
+    async def endpoint(background: BackgroundTasks):
+        background.add_task(lambda: seen.setdefault("bg", current_tenant_id.get()))
+        return {}
+
+    api.add_middleware(TenantMiddleware, fixed="acme")
+    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=api), base_url="http://t") as c:
+        await c.get("/x")
+    assert seen["bg"] == "acme"
+
+
+async def test_bind_current_tenant_carries_the_tenant_past_the_request():
+    with tenant_context("acme"):
+        sync_job = bind_current_tenant(lambda: current_tenant_id.get())
+
+        async def work():
+            return current_tenant_id.get()
+
+        async_job = bind_current_tenant(work)
+    assert current_tenant_id.get() is None
+    assert sync_job() == "acme"
+    assert await async_job() == "acme"
+    assert current_tenant_id.get() is None
+
+
+async def test_bind_current_tenant_captures_the_bypass_too():
+    from simple_module_db.tenancy import is_all_tenants
+
+    with all_tenants():
+        job = bind_current_tenant(is_all_tenants)
+    assert job() is True
+    assert await asyncio.to_thread(lambda: is_all_tenants()) is False
diff --git a/framework/testing/simple_module_test/_schema.py b/framework/testing/simple_module_test/_schema.py
index 4d4973a8..f420c7f4 100644
--- a/framework/testing/simple_module_test/_schema.py
+++ b/framework/testing/simple_module_test/_schema.py
@@ -51,8 +51,11 @@ async def _create_all_tables(engine) -> None:
     """
     from sqlalchemy import text
 
+    from simple_module_test.database import reset_schema_once
+
     bases = _ensure_models_imported()
     heads = _alembic_heads()
+    await reset_schema_once(engine)  # Postgres: start empty; SQLite memory already is
 
     async with engine.begin() as conn:
 
diff --git a/framework/testing/simple_module_test/database.py b/framework/testing/simple_module_test/database.py
new file mode 100644
index 00000000..477f1692
--- /dev/null
+++ b/framework/testing/simple_module_test/database.py
@@ -0,0 +1,78 @@
+"""Which database the test fixtures run on (#343).
+
+In-memory SQLite by default. Set ``SM_TEST_DATABASE_URL`` to an async URL
+(``postgresql+asyncpg://postgres@localhost:5432/sm_test``) to run the same
+suites on Postgres — what SQLite cannot show: real concurrent transactions,
+row locks, stricter types, and SQL that SQLite happens to accept.
+
+On Postgres every fixture starts from an empty ``public`` schema, so tests
+stay independent; pooled connections are disabled because pytest-asyncio
+gives each test its own event loop.
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Any
+
+from sqlalchemy import text
+from sqlalchemy.pool import NullPool
+
+TEST_DATABASE_ENV = "SM_TEST_DATABASE_URL"
+SQLITE_MEMORY = "sqlite+aiosqlite:///:memory:"
+
+
+def database_url_for_tests() -> str:
+    return os.environ.get(TEST_DATABASE_ENV) or SQLITE_MEMORY
+
+
+def is_sqlite(url: str) -> bool:
+    return url.startswith("sqlite")
+
+
+def init_db_kwargs(url: str) -> dict[str, Any]:
+    return {} if is_sqlite(url) else {"poolclass": NullPool}
+
+
+async def reset_schema(engine: Any) -> None:
+    """Empty the database before a test (Postgres); a no-op on SQLite."""
+    if engine.dialect.name == "sqlite":
+        return
+    async with engine.begin() as conn:
+        await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
+        await conn.execute(text("CREATE SCHEMA public"))
+
+
+# The test the schema was last emptied for. On SQLite every engine is its own
+# in-memory database; on Postgres the ``app`` and ``db_session`` fixtures share
+# one, so a second reset inside the same test would wipe the first fixture's
+# rows (the seeded admin, say) from under it.
+_current_test: object | None = None
+_reset_for: object | None = None
+
+
+def begin_test(token: object) -> None:
+    """Mark the start of a test; the next ``reset_schema_once`` resets."""
+    global _current_test
+    _current_test = token
+
+
+async def reset_schema_once(engine: Any) -> None:
+    """``reset_schema`` at most once per test (see ``begin_test``)."""
+    global _reset_for
+    if _current_test is not None and _reset_for is _current_test:
+        return
+    await reset_schema(engine)
+    _reset_for = _current_test
+
+
+__all__ = [
+    "SQLITE_MEMORY",
+    "TEST_DATABASE_ENV",
+    "begin_test",
+    "database_url_for_tests",
+    "init_db_kwargs",
+    "is_sqlite",
+    "reset_schema",
+    "reset_schema_once",
+]
diff --git a/framework/testing/simple_module_test/fixtures.py b/framework/testing/simple_module_test/fixtures.py
index 011d431e..53989ab1 100644
--- a/framework/testing/simple_module_test/fixtures.py
+++ b/framework/testing/simple_module_test/fixtures.py
@@ -31,6 +31,7 @@
 from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
 
 from simple_module_test._schema import _create_all_tables
+from simple_module_test.database import begin_test, database_url_for_tests, init_db_kwargs
 from simple_module_test.session_cookie import forge_session_cookie
 
 _AUTH_PROVIDER_ENV = "SM_AUTH_PROVIDER"
@@ -63,6 +64,16 @@ def pinned_auth_provider() -> Iterator[str]:
             os.environ[_AUTH_PROVIDER_ENV] = previous
 
 
+@pytest.fixture(autouse=True)
+def fresh_test_database(request: pytest.FixtureRequest) -> None:
+    """Let the first fixture of each test empty the Postgres schema, and no later one.
+
+    A no-op on the default in-memory SQLite, where every engine is already
+    its own empty database (see ``simple_module_test.database``).
+    """
+    begin_test(request.node)
+
+
 @pytest.fixture
 def settings() -> Settings:
     """Settings configured for testing with in-memory SQLite.
@@ -78,7 +89,7 @@ def settings() -> Settings:
     that expects ``/users/login``.
     """
     return Settings(
-        database_url="sqlite+aiosqlite:///:memory:",
+        database_url=database_url_for_tests(),
         environment="testing",
         secret_key="test-secret-key",
         multi_tenant=True,
@@ -92,7 +103,8 @@ async def db_state() -> AsyncGenerator[DatabaseState, None]:
     """Create a fresh in-memory DatabaseState with listeners registered."""
     from simple_module_db.listeners import register_listeners
 
-    state = init_db("sqlite+aiosqlite:///:memory:")
+    url = database_url_for_tests()
+    state = init_db(url, **init_db_kwargs(url))
     register_listeners(state)
     yield state
     await state.engine.dispose()
diff --git a/framework/testing/simple_module_test/plugin.py b/framework/testing/simple_module_test/plugin.py
index fb05734f..45ad80f0 100644
--- a/framework/testing/simple_module_test/plugin.py
+++ b/framework/testing/simple_module_test/plugin.py
@@ -28,6 +28,7 @@
     db_session,
     db_state,
     engine,
+    fresh_test_database,
     pinned_auth_provider,
     settings,
     setup_pending_app,
diff --git a/host/migrations/versions/e36ab09c4a92_tenants_initial_schema.py b/host/migrations/versions/e36ab09c4a92_tenants_initial_schema.py
new file mode 100644
index 00000000..d8131ba5
--- /dev/null
+++ b/host/migrations/versions/e36ab09c4a92_tenants_initial_schema.py
@@ -0,0 +1,112 @@
+"""tenants initial schema
+
+Revision ID: e36ab09c4a92
+Revises:
+Create Date: 2026-09-27 11:22:13.298164
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "e36ab09c4a92"
+down_revision: str | None = None
+branch_labels: str | Sequence[str] | None = ("tenants",)
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "tenants_tenant",
+        sa.Column(
+            "created_at",
+            sa.DateTime(timezone=True),
+            server_default=sa.text("(CURRENT_TIMESTAMP)"),
+            nullable=False,
+        ),
+        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
+        sa.Column("created_by", sa.String(length=255), nullable=True),
+        sa.Column("updated_by", sa.String(length=255), nullable=True),
+        sa.Column("id", sa.String(length=32), nullable=False),
+        sa.Column("slug", sa.String(length=50), nullable=False),
+        sa.Column("name", sa.String(length=200), nullable=False),
+        sa.Column("status", sa.String(length=20), nullable=False),
+        sa.PrimaryKeyConstraint("id", name=op.f("pk_tenants_tenant")),
+    )
+    op.create_index(op.f("ix_tenants_tenant_slug"), "tenants_tenant", ["slug"], unique=True)
+    op.create_index(op.f("ix_tenants_tenant_status"), "tenants_tenant", ["status"], unique=False)
+    op.create_table(
+        "tenants_invitation",
+        sa.Column(
+            "created_at",
+            sa.DateTime(timezone=True),
+            server_default=sa.text("(CURRENT_TIMESTAMP)"),
+            nullable=False,
+        ),
+        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
+        sa.Column("created_by", sa.String(length=255), nullable=True),
+        sa.Column("updated_by", sa.String(length=255), nullable=True),
+        sa.Column("id", sa.Integer(), nullable=False),
+        sa.Column("tenant_id", sa.String(length=32), nullable=False),
+        sa.Column("email", sa.String(length=320), nullable=False),
+        sa.Column("role", sa.String(length=20), nullable=False),
+        sa.Column("token_hash", sa.String(length=64), nullable=False),
+        sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+        sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
+        sa.ForeignKeyConstraint(
+            ["tenant_id"],
+            ["tenants_tenant.id"],
+            name=op.f("fk_tenants_invitation_tenant_id_tenants_tenant"),
+            ondelete="CASCADE",
+        ),
+        sa.PrimaryKeyConstraint("id", name=op.f("pk_tenants_invitation")),
+    )
+    op.create_index(
+        op.f("ix_tenants_invitation_tenant_id"), "tenants_invitation", ["tenant_id"], unique=False
+    )
+    op.create_index(
+        op.f("ix_tenants_invitation_token_hash"), "tenants_invitation", ["token_hash"], unique=True
+    )
+    op.create_table(
+        "tenants_membership",
+        sa.Column(
+            "created_at",
+            sa.DateTime(timezone=True),
+            server_default=sa.text("(CURRENT_TIMESTAMP)"),
+            nullable=False,
+        ),
+        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
+        sa.Column("created_by", sa.String(length=255), nullable=True),
+        sa.Column("updated_by", sa.String(length=255), nullable=True),
+        sa.Column("id", sa.Integer(), nullable=False),
+        sa.Column("tenant_id", sa.String(length=32), nullable=False),
+        sa.Column("user_id", sa.String(length=64), nullable=False),
+        sa.Column("role", sa.String(length=20), nullable=False),
+        sa.Column("email", sa.String(length=320), nullable=True),
+        sa.ForeignKeyConstraint(
+            ["tenant_id"],
+            ["tenants_tenant.id"],
+            name=op.f("fk_tenants_membership_tenant_id_tenants_tenant"),
+            ondelete="CASCADE",
+        ),
+        sa.PrimaryKeyConstraint("id", name=op.f("pk_tenants_membership")),
+        sa.UniqueConstraint("tenant_id", "user_id", name="uq_tenants_membership_tenant_user"),
+    )
+    op.create_index("ix_tenants_membership_user", "tenants_membership", ["user_id"], unique=False)
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index("ix_tenants_membership_user", table_name="tenants_membership")
+    op.drop_table("tenants_membership")
+    op.drop_index(op.f("ix_tenants_invitation_token_hash"), table_name="tenants_invitation")
+    op.drop_index(op.f("ix_tenants_invitation_tenant_id"), table_name="tenants_invitation")
+    op.drop_table("tenants_invitation")
+    op.drop_index(op.f("ix_tenants_tenant_status"), table_name="tenants_tenant")
+    op.drop_index(op.f("ix_tenants_tenant_slug"), table_name="tenants_tenant")
+    op.drop_table("tenants_tenant")
+    # ### end Alembic commands ###
diff --git a/host/pyproject.toml b/host/pyproject.toml
index a4976c9c..bc3fec34 100644
--- a/host/pyproject.toml
+++ b/host/pyproject.toml
@@ -15,6 +15,7 @@ dependencies = [
     "simple_module_audit_log",
     "simple_module_branding",
     "simple_module_site_lock",
+    "simple_module_tenants",
     "python-multipart>=0.0.6",
 ]
 
@@ -30,3 +31,4 @@ simple_module_feature_flags = { workspace = true }
 simple_module_audit_log = { workspace = true }
 simple_module_branding = { workspace = true }
 simple_module_site_lock = { workspace = true }
+simple_module_tenants = { workspace = true }
diff --git a/modules/background_tasks/background_tasks/module.py b/modules/background_tasks/background_tasks/module.py
index f6e846ab..4255a68e 100644
--- a/modules/background_tasks/background_tasks/module.py
+++ b/modules/background_tasks/background_tasks/module.py
@@ -152,13 +152,17 @@ async def on_startup(self, app: FastAPI) -> None:
         from background_tasks.celery_app import build_celery
         from background_tasks.signals import bind_event_bus
         from background_tasks.sync_db import set_database_url
+        from background_tasks.tenant_context import set_default_tenant
 
         services = app.state.background_tasks
         # Pin the sync engine to the same URL the host's async settings
         # resolved — pydantic-settings reads ``.env`` but never propagates
         # to ``os.environ``, so signals would otherwise fall back to the
         # SQLite default and silently drop ``TaskExecution`` rows.
-        set_database_url(app.state.sm.settings.database_url)
+        host = app.state.sm.settings
+        multi = bool(getattr(host, "multi_tenant", False))
+        set_database_url(host.database_url, tenant_strict=multi)
+        set_default_tenant(None if multi else getattr(host, "default_tenant", "") or None)
         # build_celery imports `signals` for side effects and runs
         # `autodiscover_tasks` across every installed module.
         services.celery = build_celery(services.settings)
diff --git a/modules/background_tasks/background_tasks/signals.py b/modules/background_tasks/background_tasks/signals.py
index db7e3970..72c2e094 100644
--- a/modules/background_tasks/background_tasks/signals.py
+++ b/modules/background_tasks/background_tasks/signals.py
@@ -41,6 +41,7 @@
 from background_tasks.log_context import signal_task_finished, signal_task_started
 from background_tasks.models import TaskExecution
 from background_tasks.sync_db import sync_session
+from background_tasks.tenant_context import release_tenant, restore_tenant, stamp_tenant
 
 logger = logging.getLogger(__name__)
 
@@ -126,6 +127,7 @@ def on_task_publish(
     **_kwargs: Any,
 ) -> None:
     """Record a row the moment a task is pushed onto the broker."""
+    stamp_tenant(headers)
     task_id = (headers or {}).get("id")
     task_name = sender or (headers or {}).get("task") or "unknown"
 
@@ -179,6 +181,7 @@ def on_task_prerun(
         },
     )
     signal_task_started(task_id=task_id, task_name=name)
+    restore_tenant(task_id=task_id, task=task)
 
 
 @signals.task_postrun.connect
@@ -199,6 +202,7 @@ def on_task_postrun(
         celery_task_id=task_id,
         defaults={"task_name": task_name_of(sender, task), "heartbeat_at": now_utc()},
     )
+    release_tenant(task_id=task_id)
     signal_task_finished(task_id=task_id)
 
 
diff --git a/modules/background_tasks/background_tasks/sync_db.py b/modules/background_tasks/background_tasks/sync_db.py
index d087c495..35cda138 100644
--- a/modules/background_tasks/background_tasks/sync_db.py
+++ b/modules/background_tasks/background_tasks/sync_db.py
@@ -18,6 +18,8 @@
 from collections.abc import Iterator
 from contextlib import contextmanager
 
+from simple_module_db.listeners import attach_session_listeners
+from simple_module_db.query_filter import EngineTenancy, bind_engine_policy
 from sqlalchemy import create_engine
 from sqlalchemy.engine import Engine
 from sqlalchemy.orm import Session, sessionmaker
@@ -27,6 +29,13 @@
 _engine: Engine | None = None
 _session_factory: sessionmaker[Session] | None = None
 _url_override: str | None = None
+_tenant_strict: bool = False
+
+
+class WorkerSession(Session):
+    """This engine's own session class, so the entity listeners attached to it
+    never double up with a ``DatabaseState``'s (each state has its own
+    ``Session`` subclass; listeners on the base class would fire for both)."""
 
 
 def _sync_url(async_url: str) -> str:
@@ -38,7 +47,7 @@ def _sync_url(async_url: str) -> str:
     return async_url.replace("+aiosqlite", "").replace("+asyncpg", "+psycopg2")
 
 
-def set_database_url(url: str | None) -> None:
+def set_database_url(url: str | None, *, tenant_strict: bool = False) -> None:
     """Pin the URL used to build the sync engine.
 
     The web process loads ``.env`` via pydantic-settings, but those values
@@ -48,11 +57,15 @@ def set_database_url(url: str | None) -> None:
     calls this with the resolved ``settings.database_url`` so signals use
     the same DB the app is on. Pass ``None`` to clear the override (used in
     tests + on shutdown).
+
+    ``tenant_strict`` mirrors the host's ``multi_tenant``: task bodies get the
+    same fail-closed tenant rules as request code.
     """
-    global _url_override, _engine, _session_factory
-    if _url_override == url:
+    global _url_override, _engine, _session_factory, _tenant_strict
+    if _url_override == url and _tenant_strict == tenant_strict:
         return
     _url_override = url
+    _tenant_strict = tenant_strict
     if _engine is not None:
         _engine.dispose()
     _engine = None
@@ -68,7 +81,13 @@ def _resolve_url() -> str:
 def _build_engine() -> Engine:
     sync_url = _sync_url(_resolve_url())
     # Small pool — signals fire sequentially per worker process.
-    return create_engine(sync_url, pool_pre_ping=True, pool_size=2, max_overflow=3)
+    engine = create_engine(sync_url, pool_pre_ping=True, pool_size=2, max_overflow=3)
+    # A worker never runs create_app, so without this its sessions would have
+    # no tenant filter at all: the tenant restored around a task body would
+    # scope nothing (#371).
+    attach_session_listeners(WorkerSession)
+    bind_engine_policy(engine, EngineTenancy(tenant_strict=_tenant_strict))
+    return engine
 
 
 def get_sync_session_factory() -> sessionmaker[Session]:
@@ -76,7 +95,7 @@ def get_sync_session_factory() -> sessionmaker[Session]:
     global _engine, _session_factory
     if _session_factory is None:
         _engine = _build_engine()
-        _session_factory = sessionmaker(bind=_engine, expire_on_commit=False)
+        _session_factory = sessionmaker(bind=_engine, class_=WorkerSession, expire_on_commit=False)
     return _session_factory
 
 
@@ -87,12 +106,13 @@ def dispose_sync_engine() -> None:
     restarts within one process (test runners, uvicorn dev reload) don't
     accumulate engines against the old DB URL.
     """
-    global _engine, _session_factory, _url_override
+    global _engine, _session_factory, _url_override, _tenant_strict
     if _engine is not None:
         _engine.dispose()
     _engine = None
     _session_factory = None
     _url_override = None
+    _tenant_strict = False
 
 
 @contextmanager
diff --git a/modules/background_tasks/background_tasks/tenant_context.py b/modules/background_tasks/background_tasks/tenant_context.py
new file mode 100644
index 00000000..3bc01d55
--- /dev/null
+++ b/modules/background_tasks/background_tasks/tenant_context.py
@@ -0,0 +1,104 @@
+"""Carry the enqueuing request's tenant into the Celery task that runs later.
+
+A task body runs in a worker with no request, so ``current_tenant_id`` is
+unset there — under strict isolation any tenant-scoped query would raise, and
+without it the query would read every tenant. The tenant is stamped onto the
+message headers at publish time and restored around the task body.
+
+Platform tasks that legitimately span tenants (sweeps, purges) wrap their
+work in ``simple_module_db.all_tenants()`` instead.
+"""
+
+from __future__ import annotations
+
+import logging
+from contextvars import Token
+from typing import Any
+
+from simple_module_db import TenantIsolationError, current_tenant_id, is_valid_tenant_id
+
+TENANT_HEADER = "sm_tenant_id"
+
+_log = logging.getLogger(__name__)
+_tokens: dict[str, Token[str | None]] = {}
+# Single-tenant hosts (``default_tenant``): tasks with no tenant on the message
+# — beat jobs, tasks enqueued outside a request — run as that tenant.
+_default_tenant: str | None = None
+
+
+def set_default_tenant(tenant_id: str | None) -> None:
+    global _default_tenant
+    _default_tenant = tenant_id or None
+
+
+def stamp_tenant(headers: dict[str, Any] | None) -> None:
+    """Record the current tenant on an outgoing message (publish side).
+
+    An explicit ``headers={"sm_tenant_id": ...}`` passed to ``send_task`` is
+    kept only when no tenant is bound (platform code enqueueing work for a
+    tenant) or it names the bound tenant: request code must not be able to
+    schedule work as another tenant.
+    """
+    if headers is None:
+        return
+    tenant_id = current_tenant_id.get()
+    explicit = headers.get(TENANT_HEADER)
+    if explicit:
+        if not is_valid_tenant_id(explicit):
+            raise TenantIsolationError(f"Invalid tenant id on task headers: {explicit!r}")
+        if tenant_id is not None and explicit != tenant_id:
+            raise TenantIsolationError(
+                f"Cannot enqueue a task for tenant '{explicit}' in context of tenant '{tenant_id}'"
+            )
+        return
+    if tenant_id is not None:
+        headers[TENANT_HEADER] = tenant_id
+
+
+def _tenant_of(task: Any) -> str | None:
+    request = getattr(task, "request", None)
+    if request is None:
+        return None
+    value = getattr(request, TENANT_HEADER, None)
+    if value is None and isinstance(getattr(request, "headers", None), dict):
+        value = request.headers.get(TENANT_HEADER)
+    if not value:
+        return None
+    if not is_valid_tenant_id(value):
+        # Never run a task body as a malformed tenant; it runs unscoped and,
+        # under strict mode, fails closed on its first tenant-scoped query.
+        _log.warning("Ignoring invalid tenant id on task message: %r", str(value)[:80])
+        return None
+    return str(value)
+
+
+def restore_tenant(*, task_id: str | None, task: Any) -> None:
+    """Enter the message's tenant for the task body (prerun)."""
+    if not task_id:
+        return
+    tenant_id = _tenant_of(task) or _default_tenant
+    if tenant_id is not None:
+        _tokens[task_id] = current_tenant_id.set(tenant_id)
+
+
+def release_tenant(*, task_id: str | None) -> None:
+    """Leave the tenant entered by :func:`restore_tenant` (postrun)."""
+    token = _tokens.pop(task_id, None) if task_id else None
+    if token is None:
+        return
+    try:
+        current_tenant_id.reset(token)
+    except ValueError:
+        # Token from another context (exotic pool patching): fall back to
+        # clearing, so the next task on this worker never inherits a tenant.
+        _log.debug("Tenant reset skipped for task_id=%s; clearing", task_id)
+        current_tenant_id.set(None)
+
+
+__all__ = [
+    "TENANT_HEADER",
+    "release_tenant",
+    "restore_tenant",
+    "set_default_tenant",
+    "stamp_tenant",
+]
diff --git a/modules/background_tasks/tests/test_tenant_context.py b/modules/background_tasks/tests/test_tenant_context.py
new file mode 100644
index 00000000..84da6539
--- /dev/null
+++ b/modules/background_tasks/tests/test_tenant_context.py
@@ -0,0 +1,70 @@
+"""The enqueuing request's tenant is restored around the task body."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+from background_tasks.tenant_context import (
+    TENANT_HEADER,
+    release_tenant,
+    restore_tenant,
+    stamp_tenant,
+)
+from simple_module_db import TenantIsolationError, current_tenant_id, tenant_context
+
+
+def test_publish_stamps_current_tenant():
+    headers: dict = {}
+    with tenant_context("acme"):
+        stamp_tenant(headers)
+    assert headers[TENANT_HEADER] == "acme"
+
+
+def test_publish_without_tenant_leaves_headers_alone():
+    headers: dict = {}
+    stamp_tenant(headers)
+    assert TENANT_HEADER not in headers
+
+
+def test_platform_code_may_enqueue_for_an_explicit_tenant():
+    headers = {TENANT_HEADER: "chosen"}
+    stamp_tenant(headers)  # no tenant bound
+    assert headers[TENANT_HEADER] == "chosen"
+
+
+def test_request_code_cannot_enqueue_as_another_tenant():
+    with tenant_context("acme"), pytest.raises(TenantIsolationError):
+        stamp_tenant({TENANT_HEADER: "other"})
+
+
+def test_malformed_tenant_on_a_message_is_not_restored():
+    task = SimpleNamespace(request=SimpleNamespace(**{TENANT_HEADER: "x" * 300}))
+    restore_tenant(task_id="t9", task=task)
+    assert current_tenant_id.get() is None
+    release_tenant(task_id="t9")
+
+
+def test_prerun_enters_and_postrun_leaves_the_tenant():
+    task = SimpleNamespace(request=SimpleNamespace(**{TENANT_HEADER: "acme"}))
+    restore_tenant(task_id="t1", task=task)
+    try:
+        assert current_tenant_id.get() == "acme"
+    finally:
+        release_tenant(task_id="t1")
+    assert current_tenant_id.get() is None
+
+
+def test_tenant_read_from_request_headers_dict():
+    task = SimpleNamespace(request=SimpleNamespace(headers={TENANT_HEADER: "globex"}))
+    restore_tenant(task_id="t2", task=task)
+    try:
+        assert current_tenant_id.get() == "globex"
+    finally:
+        release_tenant(task_id="t2")
+
+
+def test_task_without_tenant_runs_unscoped():
+    restore_tenant(task_id="t3", task=SimpleNamespace(request=SimpleNamespace()))
+    assert current_tenant_id.get() is None
+    release_tenant(task_id="t3")  # no token recorded — must be a no-op
diff --git a/modules/background_tasks/tests/test_worker_tenancy.py b/modules/background_tasks/tests/test_worker_tenancy.py
new file mode 100644
index 00000000..db9bfc54
--- /dev/null
+++ b/modules/background_tasks/tests/test_worker_tenancy.py
@@ -0,0 +1,58 @@
+"""The worker's sync session enforces the tenant restored around a task body (#371)."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+from background_tasks import sync_db
+from background_tasks.tenant_context import TENANT_HEADER, release_tenant, restore_tenant
+from simple_module_db import MultiTenantMixin, TenantIsolationError, create_module_base
+from sqlalchemy import create_engine, select
+from sqlmodel import Field
+
+_Base = create_module_base("bgtenancy")
+
+
+class _Job(_Base, MultiTenantMixin, table=True):  # ty: ignore[unsupported-base]
+    __tablename__ = "bgtenancy_job"
+    id: int | None = Field(default=None, primary_key=True)
+    name: str = Field(max_length=50)
+
+
+@pytest.fixture
+def worker_db(tmp_path):
+    url = f"sqlite:///{tmp_path}/worker.db"
+    _Base.metadata.create_all(create_engine(url))
+    sync_db.set_database_url(url, tenant_strict=True)
+    with sync_db.sync_session() as s:
+        s.add_all([_Job(name="a1", tenant_id="a"), _Job(name="b1", tenant_id="b")])
+    yield
+    sync_db.dispose_sync_engine()
+
+
+def _task(tenant: str) -> SimpleNamespace:
+    return SimpleNamespace(request=SimpleNamespace(**{TENANT_HEADER: tenant}))
+
+
+def test_task_body_sees_only_its_tenant(worker_db):
+    restore_tenant(task_id="t1", task=_task("a"))
+    try:
+        with sync_db.sync_session() as s:
+            assert s.scalars(select(_Job.name)).all() == ["a1"]
+    finally:
+        release_tenant(task_id="t1")
+
+
+def test_task_body_cannot_write_another_tenant(worker_db):
+    restore_tenant(task_id="t2", task=_task("a"))
+    try:
+        with pytest.raises(TenantIsolationError), sync_db.sync_session() as s:
+            s.add(_Job(name="planted", tenant_id="b"))
+    finally:
+        release_tenant(task_id="t2")
+
+
+def test_task_without_a_tenant_fails_closed(worker_db):
+    with pytest.raises(TenantIsolationError), sync_db.sync_session() as s:
+        s.execute(select(_Job))
diff --git a/modules/tenants/LICENSE b/modules/tenants/LICENSE
new file mode 100644
index 00000000..9cf10627
--- /dev/null
+++ b/modules/tenants/LICENSE
@@ -0,0 +1,19 @@
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/modules/tenants/README.md b/modules/tenants/README.md
new file mode 100644
index 00000000..13d03483
--- /dev/null
+++ b/modules/tenants/README.md
@@ -0,0 +1,82 @@
+# simple_module_tenants
+
+Organisations for [simple_module](https://github.com/antosubash/simple_module_python)
+SaaS installs: tenants, many-to-many memberships with per-tenant roles,
+email-bound invitations, and the tenant resolver that scopes every request.
+
+## Install
+
+```bash
+pip install simple_module_tenants
+```
+
+Add `simple_module_tenants` to your host's dependencies, then turn on
+`multi_tenant` (Settings screen, or `SM_MULTI_TENANT=true`) and restart.
+Without `multi_tenant` the module manages organisations but requests are not
+scoped to them.
+
+## What it does
+
+- **Resolution.** Registers `app.state.tenant_resolver`. The session stores
+  the user's chosen tenant; every request re-validates it against a
+  membership (cached per process, dropped across workers through
+  `InvalidationBus`). Suspended tenants resolve to nothing.
+- **Per-tenant roles.** A membership role (`owner`, `admin`, `member`) is added
+  to the principal as `tenant:` for the active tenant only, so tenant
+  roles never reach platform permissions.
+- **Fail-closed handling.** With `multi_tenant` on, a tenant-scoped query
+  with no tenant raises `TenantIsolationError`; this module turns that into a
+  redirect to `/tenants` (pages) or a `403 tenant_required` (API).
+
+## Usage
+
+| Route | Permission | Purpose |
+|---|---|---|
+| `GET /tenants/` | signed in | My organisations: switch, create |
+| `GET /tenants/members` | `tenants.members.view` | Members and invitations of the active tenant |
+| `GET /tenants/invitations/accept?token=` | signed in | Accept an invitation |
+| `GET /admin/tenants/` | `tenants.platform.view` | Platform list of all tenants |
+| `GET/POST /api/tenants/` | signed in | List mine / create |
+| `POST /api/tenants/{id}/switch` | member of `{id}` | Change the active tenant |
+| `/api/tenants/current/members[/{user_id}]` | `tenants.members.view` / `.manage` | List, change role, remove |
+| `DELETE /api/tenants/current/membership` | member | Leave (not the last owner) |
+| `/api/tenants/current/invitations[/{id}]` | `tenants.members.manage` | List, invite, revoke |
+| `POST /api/tenants/invitations/accept` | signed in as the invited email | Join |
+| `POST /api/tenants/admin/{id}/suspend` · `/reactivate` | `tenants.platform.manage` | Lifecycle |
+
+Tenant-level routes act on the *active* tenant (`/current`), never on an id
+from the URL.
+
+## Configuration
+
+DB-backed (Settings screen):
+
+- `allow_self_service` (default on) — any signed-in user may create an
+  organisation.
+- `invitation_ttl_hours` (default 72).
+- `subdomain_base` (default empty) — with `example.com`, `acme.example.com`
+  resolves to the tenant whose slug is `acme`. Members get their role there;
+  anonymous visitors and signed-in non-members get the tenant on **public
+  routes only**; an unknown or suspended subdomain resolves to nothing.
+- `public_base_url` (default empty) — origin invitation links are built on.
+  Empty makes them root-relative: they are never built from the request's
+  `Host` header, because the same link travels in `InvitationCreated` for a
+  mailer to send.
+
+Management routes need both the permission and an owner/admin role *in the
+active tenant*: a platform-wide grant does not make a plain member of a tenant
+its manager (`403 tenant_manager_required`).
+
+## Billing seams
+
+The module ships no billing, but a billing module needs nothing more from it:
+
+- `app.state.tenants.entitlements` — replace the default `UnlimitedEntitlements`
+  with an `EntitlementProvider` (`limit(tenant_id, key)`,
+  `has_feature(tenant_id, key)`). The module enforces `tenants.seats` on new
+  members and invitations; `EntitlementExceededError` maps to HTTP 402.
+- `TenantService.set_status(tenant_id, TenantStatus.SUSPENDED | ACTIVE)` for
+  dunning.
+- Events, published after commit: `TenantCreated`, `TenantStatusChanged`,
+  `MembershipAdded`, `MembershipRemoved`, `InvitationCreated` (also the hook a
+  mailer uses to deliver the invitation link).
diff --git a/modules/tenants/package.json b/modules/tenants/package.json
new file mode 100644
index 00000000..9db380d1
--- /dev/null
+++ b/modules/tenants/package.json
@@ -0,0 +1,19 @@
+{
+  "name": "@simple-module-py/tenants",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Frontend assets for the Tenants module",
+  "peerDependencies": {
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "@inertiajs/react": "^3.7.0",
+    "@simple-module-py/ui": "*"
+  },
+  "devDependencies": {
+    "@simple-module-py/tsconfig": "*"
+  },
+  "dependencies": {
+    "lucide-react": "^1.43.0",
+    "sonner": "^2.0.8"
+  }
+}
diff --git a/modules/tenants/pyproject.toml b/modules/tenants/pyproject.toml
new file mode 100644
index 00000000..57190b68
--- /dev/null
+++ b/modules/tenants/pyproject.toml
@@ -0,0 +1,51 @@
+[project]
+name = "simple_module_tenants"
+version = "0.0.34"
+description = "Organisations, memberships and tenant resolution for simple_module SaaS installs"
+readme = "README.md"
+license = "MIT"
+license-files = ["LICENSE"]
+requires-python = ">=3.12"
+authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
+keywords = ["simple-module", "tenants"]
+dependencies = [
+    "simple_module_core==0.0.34",
+    "simple_module_db==0.0.34",
+    "simple_module_hosting==0.0.34",
+    "simple_module_inertia==0.0.34",
+    "simple_module_settings==0.0.34",
+    "simple_module_auth==0.0.34",
+    "cachetools>=5.3",
+]
+
+[project.entry-points.simple_module]
+tenants = "tenants.module:TenantsModule"
+
+[project.urls]
+Homepage = "https://github.com/antosubash/simple_module_python"
+Repository = "https://github.com/antosubash/simple_module_python"
+Issues = "https://github.com/antosubash/simple_module_python/issues"
+Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+# The distribution name (simple_module_tenants) doesn't match the package
+# directory (tenants), so hatchling can't infer what to ship — without
+# this the wheel builds empty and the entry point fails to import.
+[tool.hatch.build.targets.wheel]
+packages = ["tenants"]
+
+# Ship the module-root package.json inside the wheel so the host can
+# discover JS deps via importlib.resources after a pip install.
+[tool.hatch.build.targets.wheel.force-include]
+"package.json" = "tenants/package.json"
+
+[tool.uv.sources]
+simple_module_core = { workspace = true }
+simple_module_db = { workspace = true }
+simple_module_hosting = { workspace = true }
+simple_module_inertia = { workspace = true }
+simple_module_settings = { workspace = true }
+simple_module_auth = { workspace = true }
diff --git a/modules/tenants/tenants/__init__.py b/modules/tenants/tenants/__init__.py
new file mode 100644
index 00000000..1cbf34d7
--- /dev/null
+++ b/modules/tenants/tenants/__init__.py
@@ -0,0 +1 @@
+"""Tenants module."""
diff --git a/modules/tenants/tenants/_owner_guard.py b/modules/tenants/tenants/_owner_guard.py
new file mode 100644
index 00000000..d1faa343
--- /dev/null
+++ b/modules/tenants/tenants/_owner_guard.py
@@ -0,0 +1,68 @@
+"""Owner-safe demotion and removal: the "at least one owner" rule, atomically.
+
+Counting owners and then writing is a check-then-act race: two owners
+demoting each other at once both see two owners and both succeed, leaving a
+tenant nobody can ever administer again. So the rule lives in the write
+itself — the UPDATE/DELETE only matches while *another* owner exists, and
+zero affected rows means "this was the last owner".
+
+On SQLite writers serialise and the condition is evaluated at write time,
+which settles it. On Postgres (READ COMMITTED) two writes to different rows
+would not block each other, so callers also hold the tenant row lock
+(``TenantService.lock``) — the second request waits for the first to commit
+and then evaluates the condition against it.
+"""
+
+from __future__ import annotations
+
+from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
+from sqlalchemy import delete, exists, update
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import aliased
+
+from tenants.constants import MembershipRole
+from tenants.models import Membership
+
+
+def _another_owner(membership: Membership):
+    other = aliased(Membership)
+    return exists().where(
+        other.tenant_id == membership.tenant_id,
+        other.role == MembershipRole.OWNER,
+        other.id != membership.id,
+    )
+
+
+async def demote_owner(db: AsyncSession, membership: Membership, role: str) -> bool:
+    """Set ``role`` on an owner's membership unless they are the last owner."""
+    stmt = (
+        update(Membership)
+        .where(Membership.id == membership.id, _another_owner(membership))
+        .values(role=role)
+        .execution_options(synchronize_session=False)
+    )
+    applied = (await db.execute(stmt)).rowcount == 1
+    if applied:
+        _mark_written(db)
+        await db.refresh(membership)
+    return applied
+
+
+async def remove_owner(db: AsyncSession, membership: Membership) -> bool:
+    """Delete an owner's membership unless they are the last owner."""
+    stmt = (
+        delete(Membership)
+        .where(Membership.id == membership.id, _another_owner(membership))
+        .execution_options(synchronize_session=False)
+    )
+    applied = (await db.execute(stmt)).rowcount == 1
+    if applied:
+        _mark_written(db)
+        db.expunge(membership)
+    return applied
+
+
+def _mark_written(db: AsyncSession) -> None:
+    # A Core-style statement is not a flush, so the request session would
+    # otherwise roll it back as a read-only request (#336).
+    db.sync_session.info[SESSION_HAS_WRITES_KEY] = True
diff --git a/modules/tenants/tenants/components/AdminTenantsTable.tsx b/modules/tenants/tenants/components/AdminTenantsTable.tsx
new file mode 100644
index 00000000..fd0553db
--- /dev/null
+++ b/modules/tenants/tenants/components/AdminTenantsTable.tsx
@@ -0,0 +1,116 @@
+import { keys, useT } from '@simple-module-py/i18n';
+import { Badge } from '@simple-module-py/ui/components/ui/badge';
+import { Button } from '@simple-module-py/ui/components/ui/button';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@simple-module-py/ui/components/ui/table';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { useTenantErrors } from '../hooks/useTenantErrors';
+
+export interface AdminTenant {
+  id: string;
+  slug: string;
+  name: string;
+  status: 'active' | 'suspended';
+  created_at: string | null;
+  members: number;
+}
+
+interface Props {
+  tenants: AdminTenant[];
+  onChanged: () => void;
+}
+
+const STATUS_KEY = keys.tenants.status;
+
+/** The platform-wide organisation roster on the admin Browse page. */
+export function AdminTenantsTable({ tenants, onChanged }: Props) {
+  const { t } = useT();
+  const { describe } = useTenantErrors();
+  const [busyId, setBusyId] = useState(null);
+
+  async function setStatus(tenant: AdminTenant, action: 'suspend' | 'reactivate') {
+    setBusyId(tenant.id);
+    try {
+      const response = await fetch(`/api/tenants/admin/${tenant.id}/${action}`, {
+        method: 'POST',
+        credentials: 'same-origin',
+      });
+      if (!response.ok) {
+        toast.error(await describe(response));
+        return;
+      }
+      toast.success(
+        action === 'suspend'
+          ? t(keys.tenants.admin.toast_suspended)
+          : t(keys.tenants.admin.toast_reactivated),
+      );
+      onChanged();
+    } catch {
+      toast.error(t(keys.tenants.admin.toast_action_failed));
+    } finally {
+      setBusyId(null);
+    }
+  }
+
+  return (
+    
+      
+        
+          {t(keys.tenants.admin.table_name)}
+          {t(keys.tenants.admin.table_slug)}
+          {t(keys.tenants.admin.table_status)}
+          {t(keys.tenants.admin.table_members)}
+          {t(keys.tenants.admin.table_created)}
+          {t(keys.tenants.admin.table_actions)}
+        
+      
+      
+        {tenants.map((tenant) => (
+          
+            {tenant.name}
+            {tenant.slug}
+            
+              
+                {t(STATUS_KEY[tenant.status])}
+              
+            
+            {tenant.members}
+            
+              {tenant.created_at ? new Date(tenant.created_at).toLocaleDateString() : '—'}
+            
+            
+              {tenant.status === 'active' ? (
+                
+              ) : (
+                
+              )}
+            
+          
+        ))}
+      
+    
+ ); +} diff --git a/modules/tenants/tenants/components/CreateOrgForm.tsx b/modules/tenants/tenants/components/CreateOrgForm.tsx new file mode 100644 index 00000000..f7a72ad6 --- /dev/null +++ b/modules/tenants/tenants/components/CreateOrgForm.tsx @@ -0,0 +1,90 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@simple-module-py/ui/components/ui/card'; +import { Input } from '@simple-module-py/ui/components/ui/input'; +import { Label } from '@simple-module-py/ui/components/ui/label'; +import type React from 'react'; +import { useState } from 'react'; +import { useTenantErrors } from '../hooks/useTenantErrors'; + +interface Props { + /** Called after the API confirms creation, so the page can reload its props. */ + onCreated: () => void; +} + +/** The self-service "create an organisation" form on the Index page. */ +export function CreateOrgForm({ onCreated }: Props) { + const { t } = useT(); + const { describe } = useTenantErrors(); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + try { + const response = await fetch('/api/tenants/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ name, slug: slug || undefined }), + }); + if (!response.ok) { + setError(await describe(response)); + return; + } + setName(''); + setSlug(''); + onCreated(); + } catch { + setError(t(keys.tenants.errors.generic)); + } finally { + setSubmitting(false); + } + } + + return ( + + + {t(keys.tenants.index.create_title)} + + +
+
+ + setName(e.target.value)} + placeholder={t(keys.tenants.index.create_name_placeholder)} + required + maxLength={200} + /> +
+
+ + setSlug(e.target.value.toLowerCase())} + placeholder={t(keys.tenants.index.create_slug_placeholder)} + maxLength={50} + /> +

+ {t(keys.tenants.index.create_slug_hint)} +

+
+ {error &&

{error}

} + +
+
+
+ ); +} diff --git a/modules/tenants/tenants/components/InvitationsList.tsx b/modules/tenants/tenants/components/InvitationsList.tsx new file mode 100644 index 00000000..70f11cd6 --- /dev/null +++ b/modules/tenants/tenants/components/InvitationsList.tsx @@ -0,0 +1,90 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@simple-module-py/ui/components/ui/card'; +import { X } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useTenantErrors } from '../hooks/useTenantErrors'; + +export interface Invitation { + id: number; + email: string; + role: 'owner' | 'admin' | 'member'; + expires_at: string; + accepted_at: string | null; +} + +interface Props { + invitations: Invitation[]; + onChanged: () => void; +} + +const ROLE_KEY = keys.tenants.roles; + +/** Pending invitations on the Members page, with a revoke action per row. */ +export function InvitationsList({ invitations, onChanged }: Props) { + const { t } = useT(); + const { describe } = useTenantErrors(); + const [revokingId, setRevokingId] = useState(null); + + const pending = invitations.filter((i) => !i.accepted_at); + + async function revoke(id: number) { + setRevokingId(id); + try { + const response = await fetch(`/api/tenants/current/invitations/${id}`, { + method: 'DELETE', + credentials: 'same-origin', + }); + if (!response.ok) { + toast.error(await describe(response)); + return; + } + toast.success(t(keys.tenants.members.toast_revoked)); + onChanged(); + } catch { + toast.error(t(keys.tenants.errors.generic)); + } finally { + setRevokingId(null); + } + } + + return ( + + + {t(keys.tenants.members.pending_title)} + + + {pending.length === 0 ? ( +

{t(keys.tenants.members.pending_empty)}

+ ) : ( +
    + {pending.map((invitation) => ( +
  • +
    +
    {invitation.email}
    +
    + {t(ROLE_KEY[invitation.role])} ·{' '} + {t(keys.tenants.members.pending_expires, { + date: new Date(invitation.expires_at).toLocaleDateString(), + })} +
    +
    + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/modules/tenants/tenants/components/InviteForm.tsx b/modules/tenants/tenants/components/InviteForm.tsx new file mode 100644 index 00000000..4e2386ad --- /dev/null +++ b/modules/tenants/tenants/components/InviteForm.tsx @@ -0,0 +1,138 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@simple-module-py/ui/components/ui/card'; +import { Input } from '@simple-module-py/ui/components/ui/input'; +import { Label } from '@simple-module-py/ui/components/ui/label'; +import { NativeSelect, NativeSelectOption } from '@simple-module-py/ui/components/ui/native-select'; +import { Copy } from 'lucide-react'; +import type React from 'react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useTenantErrors } from '../hooks/useTenantErrors'; + +interface Props { + onInvited: () => void; +} + +const ROLE_KEY = keys.tenants.roles; + +/** + * The invite form and the one-time reveal of the accept link it mints. + * + * The link is shown only right after creation — the API never returns the + * raw token again — so it stays in local state rather than in the reloaded + * `invitations` prop. + */ +export function InviteForm({ onInvited }: Props) { + const { t } = useT(); + const { describe } = useTenantErrors(); + const [email, setEmail] = useState(''); + const [role, setRole] = useState<'admin' | 'member'>('member'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [acceptUrl, setAcceptUrl] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitting(true); + setError(null); + try { + const response = await fetch('/api/tenants/current/invitations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ email, role }), + }); + if (!response.ok) { + setError(await describe(response)); + return; + } + const data = (await response.json()) as { accept_url: string }; + // Root-relative unless public_base_url is configured. + setAcceptUrl(new URL(data.accept_url, window.location.origin).toString()); + setEmail(''); + toast.success(t(keys.tenants.members.toast_invited)); + onInvited(); + } catch { + setError(t(keys.tenants.errors.generic)); + } finally { + setSubmitting(false); + } + } + + async function copyLink() { + if (!acceptUrl) return; + try { + await navigator.clipboard.writeText(acceptUrl); + toast.success(t(keys.tenants.members.toast_link_copied)); + } catch { + toast.error(t(keys.tenants.members.toast_copy_failed)); + } + } + + return ( + + + {t(keys.tenants.members.invite_title)} + + +
+
+ + setEmail(e.target.value)} + placeholder={t(keys.tenants.members.invite_email_placeholder)} + required + /> +
+
+ + setRole(e.target.value as 'admin' | 'member')} + > + {t(ROLE_KEY.member)} + {t(ROLE_KEY.admin)} + +
+ +
+ {error &&

{error}

} + + {acceptUrl && ( +
+

+ {t(keys.tenants.members.invite_link_title)} +

+

+ {t(keys.tenants.members.invite_link_description)} +

+
+ + {acceptUrl} + + +
+
+ )} +
+
+ ); +} diff --git a/modules/tenants/tenants/components/MembersTable.tsx b/modules/tenants/tenants/components/MembersTable.tsx new file mode 100644 index 00000000..5a7b9a70 --- /dev/null +++ b/modules/tenants/tenants/components/MembersTable.tsx @@ -0,0 +1,215 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActionDialog'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { NativeSelect, NativeSelectOption } from '@simple-module-py/ui/components/ui/native-select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simple-module-py/ui/components/ui/table'; +import { UserMinus } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useTenantErrors } from '../hooks/useTenantErrors'; + +export type MembershipRole = 'owner' | 'admin' | 'member'; + +export interface Member { + user_id: string; + email: string | null; + role: MembershipRole; + joined_at: string | null; +} + +interface Props { + members: Member[]; + myRole: MembershipRole; + myUserId: string; + canManage: boolean; + tenantName: string; + onChanged: () => void; +} + +const ROLE_KEY = keys.tenants.roles; + +/** The member roster on the Members page, with inline role edit / remove for managers. */ +export function MembersTable({ + members, + myRole, + myUserId, + canManage, + tenantName, + onChanged, +}: Props) { + const { t } = useT(); + const { describe } = useTenantErrors(); + const [savingId, setSavingId] = useState(null); + const [removing, setRemoving] = useState(null); + const [leavingSelf, setLeavingSelf] = useState(false); + + async function changeRole(userId: string, role: MembershipRole) { + setSavingId(userId); + try { + const response = await fetch(`/api/tenants/current/members/${userId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ role }), + }); + if (!response.ok) { + toast.error(await describe(response)); + return; + } + toast.success(t(keys.tenants.members.toast_role_updated)); + onChanged(); + } catch { + toast.error(t(keys.tenants.errors.generic)); + } finally { + setSavingId(null); + } + } + + async function confirmRemove() { + if (!removing) return; + const target = removing; + setRemoving(null); + try { + const isSelf = target.user_id === myUserId; + const url = isSelf + ? '/api/tenants/current/membership' + : `/api/tenants/current/members/${target.user_id}`; + const response = await fetch(url, { method: 'DELETE', credentials: 'same-origin' }); + if (!response.ok) { + toast.error(await describe(response)); + return; + } + if (isSelf) { + toast.success(t(keys.tenants.members.toast_left)); + window.location.href = '/tenants/'; + return; + } + toast.success(t(keys.tenants.members.toast_removed)); + onChanged(); + } catch { + toast.error(t(keys.tenants.errors.generic)); + } finally { + setLeavingSelf(false); + } + } + + return ( + <> + + + + {t(keys.tenants.members.table_email)} + {t(keys.tenants.members.table_role)} + {t(keys.tenants.members.table_joined)} + {canManage && ( + {t(keys.tenants.members.table_actions)} + )} + + + + {members.map((member) => { + const isSelf = member.user_id === myUserId; + const canEditThisRole = canManage && (myRole === 'owner' || member.role !== 'owner'); + return ( + + + {member.email ?? member.user_id} + {isSelf && t(keys.tenants.members.you_suffix)} + + + {canEditThisRole ? ( + changeRole(member.user_id, e.target.value as MembershipRole)} + > + {myRole === 'owner' && ( + {t(ROLE_KEY.owner)} + )} + {t(ROLE_KEY.admin)} + {t(ROLE_KEY.member)} + + ) : ( + t(ROLE_KEY[member.role]) + )} + + + {member.joined_at ? new Date(member.joined_at).toLocaleDateString() : '—'} + + {canManage && ( + + {isSelf ? ( + + ) : ( + member.role !== 'owner' && ( + + ) + )} + + )} + + ); + })} + +
+ + { + if (!open) { + setRemoving(null); + setLeavingSelf(false); + } + }} + icon={UserMinus} + title={ + leavingSelf + ? t(keys.tenants.members.leave_confirm_title) + : t(keys.tenants.members.remove_confirm_title) + } + description={ + leavingSelf + ? t(keys.tenants.members.leave_confirm_description, { name: tenantName }) + : t(keys.tenants.members.remove_confirm_description, { name: tenantName }) + } + confirmLabel={ + leavingSelf + ? t(keys.tenants.members.leave_confirm_confirm) + : t(keys.tenants.members.remove_confirm_confirm) + } + cancelLabel={ + leavingSelf + ? t(keys.tenants.members.leave_confirm_cancel) + : t(keys.tenants.members.remove_confirm_cancel) + } + onConfirm={confirmRemove} + /> + + ); +} diff --git a/modules/tenants/tenants/components/MembershipList.tsx b/modules/tenants/tenants/components/MembershipList.tsx new file mode 100644 index 00000000..b4be81dc --- /dev/null +++ b/modules/tenants/tenants/components/MembershipList.tsx @@ -0,0 +1,98 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { Badge } from '@simple-module-py/ui/components/ui/badge'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { Card } from '@simple-module-py/ui/components/ui/card'; +import { Building2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useTenantErrors } from '../hooks/useTenantErrors'; + +export interface Membership { + id: string; + slug: string; + name: string; + status: 'active' | 'suspended'; + role: 'owner' | 'admin' | 'member'; + created_at: string | null; +} + +interface Props { + memberships: Membership[]; + activeId: string | null; + onSwitched: () => void; +} + +const ROLE_KEY = keys.tenants.roles; +const STATUS_KEY = keys.tenants.status; + +/** The list of organisations the current user belongs to, on the Index page. */ +export function MembershipList({ memberships, activeId, onSwitched }: Props) { + const { t } = useT(); + const { describe } = useTenantErrors(); + const [switching, setSwitching] = useState(null); + + async function handleSwitch(id: string) { + setSwitching(id); + try { + const response = await fetch(`/api/tenants/${id}/switch`, { + method: 'POST', + credentials: 'same-origin', + }); + if (!response.ok) { + toast.error(await describe(response)); + return; + } + toast.success(t(keys.tenants.index.toast_switched)); + onSwitched(); + } catch { + toast.error(t(keys.tenants.index.toast_switch_failed)); + } finally { + setSwitching(null); + } + } + + return ( +
+ {memberships.map((membership) => { + const isActive = membership.id === activeId; + const isSuspended = membership.status === 'suspended'; + return ( + +
+
+
+ {isActive && ( + + )} + {!isActive && !isSuspended && ( + + )} +
+
+ ); + })} +
+ ); +} diff --git a/modules/tenants/tenants/constants.py b/modules/tenants/tenants/constants.py new file mode 100644 index 00000000..92e6c058 --- /dev/null +++ b/modules/tenants/tenants/constants.py @@ -0,0 +1,61 @@ +"""Tenants module constants.""" + +from __future__ import annotations + +from enum import StrEnum + +MODULE_PACKAGE = "tenants" +DISPLAY_NAME = "Tenants" + +# Session key holding the tenant the signed-in user last switched to. Only a +# preference: the resolver re-validates it against a membership every request. +SESSION_ACTIVE_TENANT = "sm_active_tenant" + +# Effective-role prefix: a membership role becomes ``tenant:`` on the +# request principal for the active tenant only, so a tenant ``admin`` can +# never be confused with the platform ``admin`` role. +TENANT_ROLE_PREFIX = "tenant:" + +INVALIDATION_CHANNEL = "tenants.membership" + +# Entitlement keys this module enforces (see contracts.entitlements). +ENTITLEMENT_SEATS = "tenants.seats" + +MAX_NAME_LEN = 200 +MAX_SLUG_LEN = 50 +MAX_EMAIL_LEN = 320 +TENANT_ID_LEN = 32 + + +class TenantStatus(StrEnum): + ACTIVE = "active" + SUSPENDED = "suspended" + + +class MembershipRole(StrEnum): + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" + + +MANAGER_ROLES = frozenset({MembershipRole.OWNER, MembershipRole.ADMIN}) + +# Permissions. ``tenants.*`` within the active tenant; ``tenants.platform.*`` +# spans tenants and is granted to no tenant role — only the platform admin +# (wildcard) or an explicit role grant. +PERM_MEMBERS_VIEW = "tenants.members.view" +PERM_MEMBERS_MANAGE = "tenants.members.manage" +PERM_SETTINGS_MANAGE = "tenants.settings.manage" +PERM_PLATFORM_VIEW = "tenants.platform.view" +PERM_PLATFORM_MANAGE = "tenants.platform.manage" + +ROLE_PERMISSIONS: dict[MembershipRole, list[str]] = { + MembershipRole.OWNER: [PERM_MEMBERS_VIEW, PERM_MEMBERS_MANAGE, PERM_SETTINGS_MANAGE], + MembershipRole.ADMIN: [PERM_MEMBERS_VIEW, PERM_MEMBERS_MANAGE], + MembershipRole.MEMBER: [PERM_MEMBERS_VIEW], +} + +PAGE_INDEX = "Tenants/Index" +PAGE_MEMBERS = "Tenants/Members" +PAGE_ACCEPT = "Tenants/AcceptInvitation" +PAGE_ADMIN = "Tenants/AdminBrowse" diff --git a/modules/tenants/tenants/contracts/__init__.py b/modules/tenants/tenants/contracts/__init__.py new file mode 100644 index 00000000..6ec62af3 --- /dev/null +++ b/modules/tenants/tenants/contracts/__init__.py @@ -0,0 +1,15 @@ +"""Public contracts of the Tenants module.""" + +from tenants.contracts.entitlements import ( + EntitlementExceededError, + EntitlementProvider, + UnlimitedEntitlements, + ensure_within_limit, +) + +__all__ = [ + "EntitlementExceededError", + "EntitlementProvider", + "UnlimitedEntitlements", + "ensure_within_limit", +] diff --git a/modules/tenants/tenants/contracts/entitlements.py b/modules/tenants/tenants/contracts/entitlements.py new file mode 100644 index 00000000..5b69a0ff --- /dev/null +++ b/modules/tenants/tenants/contracts/entitlements.py @@ -0,0 +1,53 @@ +"""Entitlements — the seam a billing module plugs its plans into. + +Consumers ask *what a tenant may do* without knowing why: a plan, a trial, a +manual override. ``tenants`` ships :class:`UnlimitedEntitlements`, so an +install without billing has no limits. A billing module replaces it:: + + app.state.tenants.entitlements = PlanEntitlements(...) + +Consumers depend on this protocol, never on the billing module. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class EntitlementProvider(Protocol): + async def limit(self, tenant_id: str, key: str) -> int | None: + """Maximum allowed for ``key`` (e.g. ``tenants.seats``); ``None`` = unlimited.""" + ... + + async def has_feature(self, tenant_id: str, key: str) -> bool: + """Whether the tenant's plan includes a feature.""" + ... + + +class UnlimitedEntitlements: + """Default provider: no limits, every feature. Used until billing is installed.""" + + async def limit(self, tenant_id: str, key: str) -> int | None: + return None + + async def has_feature(self, tenant_id: str, key: str) -> bool: + return True + + +class EntitlementExceededError(Exception): + """A tenant hit a plan limit. Mapped to HTTP 402 by the module.""" + + def __init__(self, key: str, limit: int) -> None: + super().__init__(f"Plan limit reached for '{key}' ({limit})") + self.key = key + self.limit = limit + + +async def ensure_within_limit( + provider: EntitlementProvider, tenant_id: str, key: str, current: int, adding: int = 1 +) -> None: + """Raise :class:`EntitlementExceededError` if ``current + adding`` would exceed the limit.""" + limit = await provider.limit(tenant_id, key) + if limit is not None and current + adding > limit: + raise EntitlementExceededError(key, limit) diff --git a/modules/tenants/tenants/contracts/events.py b/modules/tenants/tenants/contracts/events.py new file mode 100644 index 00000000..136c5a4f --- /dev/null +++ b/modules/tenants/tenants/contracts/events.py @@ -0,0 +1,57 @@ +"""Domain events published by the Tenants module. + +These are the seams a billing module hangs off: create the customer on +``TenantCreated``, sync seat counts on ``MembershipAdded``/``MembershipRemoved``, +and drive ``TenantStatusChanged`` itself through ``TenantService.set_status``. +Published after the unit of work commits, so a handler never sees a row that +was rolled back. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from simple_module_core.events import Event + + +@dataclass +class TenantCreated(Event): + tenant_id: str + slug: str + name: str + owner_user_id: str + + +@dataclass +class TenantStatusChanged(Event): + tenant_id: str + status: str + previous: str + + +@dataclass +class MembershipAdded(Event): + tenant_id: str + user_id: str + role: str + + +@dataclass +class MembershipRemoved(Event): + tenant_id: str + user_id: str + + +@dataclass +class InvitationCreated(Event): + """Delivery hook: a mailer module sends ``accept_url`` to ``email``. + + ``accept_url`` is absolute only when the ``public_base_url`` setting is + set; otherwise it is root-relative and the mailer must prefix its origin. + """ + + tenant_id: str + tenant_name: str + email: str + role: str + accept_url: str diff --git a/modules/tenants/tenants/contracts/schemas.py b/modules/tenants/tenants/contracts/schemas.py new file mode 100644 index 00000000..8e225ec2 --- /dev/null +++ b/modules/tenants/tenants/contracts/schemas.py @@ -0,0 +1,113 @@ +"""Public DTOs for the Tenants module.""" + +from __future__ import annotations + +import re +from datetime import datetime + +from pydantic import field_validator +from sqlmodel import Field, SQLModel + +from tenants.constants import MAX_EMAIL_LEN, MAX_NAME_LEN, MembershipRole + +_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$") + + +class TenantCreate(SQLModel): + name: str = Field(min_length=1, max_length=MAX_NAME_LEN) + # Validated below: SQLModel's Field swallows a v1-style ``regex=`` without + # enforcing it, and rejects pydantic v2's ``pattern=``. + slug: str | None = Field(default=None) + + @field_validator("name") + @classmethod + def _strip(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("name must not be blank") + return value + + @field_validator("slug") + @classmethod + def _slug_shape(cls, value: str | None) -> str | None: + if value is not None and not _SLUG_RE.fullmatch(value): + raise ValueError("slug must be 1-50 lowercase letters, digits or inner dashes") + return value + + +class TenantView(SQLModel): + id: str + slug: str + name: str + status: str + created_at: datetime | None = None + + +class MyTenantView(TenantView): + """A tenant as seen by one of its members.""" + + role: str + + +class MemberView(SQLModel): + user_id: str + email: str | None = None + role: str + joined_at: datetime | None = None + + +class RoleChange(SQLModel): + role: MembershipRole + + +class InvitationCreate(SQLModel): + email: str = Field(min_length=3, max_length=MAX_EMAIL_LEN) + role: MembershipRole = MembershipRole.MEMBER + + @field_validator("email") + @classmethod + def _normalise(cls, value: str) -> str: + value = value.strip().lower() + if "@" not in value: + raise ValueError("invalid email") + return value + + @field_validator("role") + @classmethod + def _no_owner_invites(cls, value: MembershipRole) -> MembershipRole: + # Ownership is transferred by an owner on an existing member, never + # handed to whoever holds a link. + if value == MembershipRole.OWNER: + raise ValueError("invitations cannot grant the owner role") + return value + + +class InvitationView(SQLModel): + id: int + email: str + role: str + expires_at: datetime + accepted_at: datetime | None = None + + +class InvitationIssued(InvitationView): + """Returned once, at creation — the only time the raw token exists.""" + + token: str + accept_url: str + + +class InvitationPreview(SQLModel): + tenant_name: str + email: str + role: str + expired: bool + accepted: bool + + +class ActiveTenant(SQLModel): + """The ``tenant`` shared Inertia prop.""" + + active: MyTenantView | None = None + memberships: list[MyTenantView] = [] + suspended: bool = False diff --git a/modules/tenants/tenants/deps.py b/modules/tenants/tenants/deps.py new file mode 100644 index 00000000..93fe210f --- /dev/null +++ b/modules/tenants/tenants/deps.py @@ -0,0 +1,82 @@ +"""FastAPI dependencies for the Tenants module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, Request +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +from tenants.constants import MANAGER_ROLES +from tenants.errors import TenantError +from tenants.invitations import InvitationService +from tenants.resolver import make_invalidator +from tenants.service import TenantService + + +def get_tenant_service(request: Request, db: AsyncSession = Depends(get_db)) -> TenantService: + app = request.app + return TenantService( + db, + bus=app.state.sm.event_bus, + invalidate=make_invalidator(app), + entitlements=app.state.tenants.entitlements, + ) + + +TenantServiceDep = Annotated[TenantService, Depends(get_tenant_service)] + + +def get_invitation_service(request: Request, tenants: TenantServiceDep) -> InvitationService: + ttl = request.app.state.tenants.settings.invitation_ttl_hours + return InvitationService(tenants, ttl_hours=ttl) + + +InvitationServiceDep = Annotated[InvitationService, Depends(get_invitation_service)] + + +@dataclass(frozen=True) +class ActiveTenantContext: + tenant_id: str + role: str + user_id: str + + +def require_user_id(request: Request) -> str: + user = getattr(request.state, "user", None) + if user is None: + raise TenantError("not_authenticated", status_code=401) + return str(user.id) + + +def require_active_tenant(request: Request) -> ActiveTenantContext: + """The tenant resolved for this request. Operations act on it — never on an + id from the URL — so a tenant-level permission cannot reach another tenant.""" + user_id = require_user_id(request) + tenant_id = getattr(request.state, "tenant_id", None) + role = getattr(request.state, "tenant_role", None) + if tenant_id is None or role is None: + raise TenantError("tenant_required", status_code=403) + return ActiveTenantContext(tenant_id=tenant_id, role=role, user_id=user_id) + + +def require_tenant_manager( + ctx: Annotated[ActiveTenantContext, Depends(require_active_tenant)], +) -> ActiveTenantContext: + """An owner or admin *of the active tenant*. + + Checked on top of the permission gate: a platform-wide grant of + ``tenants.members.manage`` (the wildcard admin, say) must not turn a plain + member of this tenant into its manager. Platform staff act through + ``/admin/tenants`` instead. + """ + if ctx.role not in MANAGER_ROLES: + raise TenantError("tenant_manager_required", status_code=403) + return ctx + + +UserIdDep = Annotated[str, Depends(require_user_id)] +ActiveTenantDep = Annotated[ActiveTenantContext, Depends(require_active_tenant)] +ManagerDep = Annotated[ActiveTenantContext, Depends(require_tenant_manager)] diff --git a/modules/tenants/tenants/endpoints/__init__.py b/modules/tenants/tenants/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/tenants/tenants/endpoints/admin.py b/modules/tenants/tenants/endpoints/admin.py new file mode 100644 index 00000000..4acacc5f --- /dev/null +++ b/modules/tenants/tenants/endpoints/admin.py @@ -0,0 +1,64 @@ +"""Platform administration of tenants: /admin/tenants (page) and /api/tenants/admin. + +Spans tenants, so every route is gated by a ``tenants.platform.*`` permission, +which no tenant role grants. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.permissions import RequiresPermission +from simple_module_inertia import InertiaResponse + +from tenants.constants import PAGE_ADMIN, PERM_PLATFORM_MANAGE, PERM_PLATFORM_VIEW, TenantStatus +from tenants.contracts.schemas import TenantView +from tenants.deps import TenantServiceDep + +views = APIRouter() +api = APIRouter(prefix="/admin") + +_VIEW = [Depends(RequiresPermission(PERM_PLATFORM_VIEW))] +_MANAGE = [Depends(RequiresPermission(PERM_PLATFORM_MANAGE))] +_PAGE_SIZE = 50 + + +@views.get("/", response_model=None, dependencies=_VIEW) +async def browse( + inertia: InertiaDep, service: TenantServiceDep, q: str = "", page: int = 1 +) -> InertiaResponse: + page = max(page, 1) + tenants = await service.list_all(search=q, limit=_PAGE_SIZE, offset=(page - 1) * _PAGE_SIZE) + counts = await service.member_counts([t.id for t in tenants]) + return await inertia.render( + PAGE_ADMIN, + { + "tenants": [ + { + **TenantView(**t.model_dump()).model_dump(mode="json"), + "members": counts.get(t.id, 0), + } + for t in tenants + ], + "q": q, + "page": page, + "has_more": len(tenants) == _PAGE_SIZE, + }, + ) + + +@api.get("/", response_model=list[TenantView], dependencies=_VIEW) +async def list_tenants(service: TenantServiceDep, q: str = "") -> list[TenantView]: + return [TenantView(**t.model_dump()) for t in await service.list_all(search=q)] + + +@api.post("/{tenant_id}/suspend", response_model=TenantView, dependencies=_MANAGE) +async def suspend(tenant_id: str, service: TenantServiceDep) -> TenantView: + tenant = await service.set_status(tenant_id, TenantStatus.SUSPENDED) + return TenantView(**tenant.model_dump()) + + +@api.post("/{tenant_id}/reactivate", response_model=TenantView, dependencies=_MANAGE) +async def reactivate(tenant_id: str, service: TenantServiceDep) -> TenantView: + tenant = await service.set_status(tenant_id, TenantStatus.ACTIVE) + return TenantView(**tenant.model_dump()) diff --git a/modules/tenants/tenants/endpoints/api.py b/modules/tenants/tenants/endpoints/api.py new file mode 100644 index 00000000..2d14b5a4 --- /dev/null +++ b/modules/tenants/tenants/endpoints/api.py @@ -0,0 +1,175 @@ +"""JSON API for tenants, memberships and invitations (mounted at /api/tenants). + +Tenant-level operations act on the request's *active* tenant (``/current``), +never on a tenant id from the URL: tenant roles grant permissions only inside +the active tenant, and taking the id from the path would let them reach any +other. Cross-tenant operations live in ``admin_api`` behind platform +permissions. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request, Response +from simple_module_core.permissions import grants +from simple_module_hosting.permissions import RequiresPermission, resolved_permissions_for + +from tenants.constants import ( + PERM_MEMBERS_MANAGE, + PERM_MEMBERS_VIEW, + PERM_PLATFORM_MANAGE, + MembershipRole, + TenantStatus, +) +from tenants.contracts.schemas import ( + InvitationCreate, + InvitationIssued, + InvitationView, + MemberView, + MyTenantView, + RoleChange, + TenantCreate, +) +from tenants.deps import ( + ActiveTenantDep, + InvitationServiceDep, + ManagerDep, + TenantServiceDep, + UserIdDep, +) +from tenants.errors import TenantError +from tenants.resolver import switch_active + +router = APIRouter() + +_VIEW = [Depends(RequiresPermission(PERM_MEMBERS_VIEW))] +_MANAGE = [Depends(RequiresPermission(PERM_MEMBERS_MANAGE))] + + +def _email_of(request: Request) -> str | None: + return getattr(getattr(request.state, "user", None), "email", None) + + +@router.get("/", response_model=list[MyTenantView]) +async def list_mine(user_id: UserIdDep, service: TenantServiceDep) -> list[MyTenantView]: + rows = await service.list_for_user(user_id) + return [MyTenantView(**t.model_dump(), role=role) for t, role in rows] + + +@router.post("/", response_model=MyTenantView, status_code=201) +async def create_tenant( + data: TenantCreate, request: Request, user_id: UserIdDep, service: TenantServiceDep +) -> MyTenantView: + settings = request.app.state.tenants.settings + if not settings.allow_self_service and not grants( + resolved_permissions_for(request), PERM_PLATFORM_MANAGE + ): + raise TenantError("self_service_disabled", status_code=403) + tenant = await service.create_tenant( + data, owner_user_id=user_id, owner_email=_email_of(request) + ) + switch_active(request, tenant.id) + return MyTenantView(**tenant.model_dump(), role=MembershipRole.OWNER) + + +@router.post("/{tenant_id}/switch", status_code=204) +async def switch_tenant( + tenant_id: str, request: Request, user_id: UserIdDep, service: TenantServiceDep +) -> Response: + membership = await service.get_membership(tenant_id, user_id) + tenant = await service.get(tenant_id) if membership else None + # Same answer for "no such tenant" and "not yours": ids are not enumerable. + if membership is None or tenant is None: + raise TenantError("not_found", status_code=404) + if tenant.status != TenantStatus.ACTIVE: + raise TenantError("tenant_suspended", status_code=409) + switch_active(request, tenant_id) + return Response(status_code=204) + + +# ── active tenant: members ───────────────────────────────────── + + +@router.get("/current/members", response_model=list[MemberView], dependencies=_VIEW) +async def list_members(ctx: ActiveTenantDep, service: TenantServiceDep) -> list[MemberView]: + return [ + MemberView(user_id=m.user_id, email=m.email, role=m.role, joined_at=m.created_at) + for m in await service.list_members(ctx.tenant_id) + ] + + +@router.patch("/current/members/{user_id}", response_model=MemberView, dependencies=_MANAGE) +async def change_role( + user_id: str, data: RoleChange, ctx: ManagerDep, service: TenantServiceDep +) -> MemberView: + m = await service.change_role(ctx.tenant_id, user_id, data.role, actor_role=ctx.role) + return MemberView(user_id=m.user_id, email=m.email, role=m.role, joined_at=m.created_at) + + +@router.delete("/current/members/{user_id}", status_code=204, dependencies=_MANAGE) +async def remove_member(user_id: str, ctx: ManagerDep, service: TenantServiceDep) -> Response: + await service.remove_member(ctx.tenant_id, user_id, actor_role=ctx.role) + return Response(status_code=204) + + +@router.delete("/current/membership", status_code=204) +async def leave(ctx: ActiveTenantDep, service: TenantServiceDep) -> Response: + """Leave the active tenant. The last owner cannot leave.""" + await service.remove_member(ctx.tenant_id, ctx.user_id, actor_role=ctx.role) + return Response(status_code=204) + + +# ── active tenant: invitations ───────────────────────────────── + + +@router.get("/current/invitations", response_model=list[InvitationView], dependencies=_MANAGE) +async def list_invitations( + ctx: ManagerDep, invitations: InvitationServiceDep +) -> list[InvitationView]: + return [InvitationView(**i.model_dump()) for i in await invitations.list_pending(ctx.tenant_id)] + + +@router.post( + "/current/invitations", + response_model=InvitationIssued, + status_code=201, + dependencies=_MANAGE, +) +async def invite( + data: InvitationCreate, + request: Request, + ctx: ManagerDep, + invitations: InvitationServiceDep, +) -> InvitationIssued: + invitation, token, url = await invitations.create( + # Never request.base_url: it comes from the Host header, and this link + # also travels in InvitationCreated for a mailer to send. + ctx.tenant_id, + data, + base_url=request.app.state.tenants.settings.public_base_url, + ) + return InvitationIssued(**invitation.model_dump(), token=token, accept_url=url) + + +@router.delete("/current/invitations/{invitation_id}", status_code=204, dependencies=_MANAGE) +async def revoke_invitation( + invitation_id: int, ctx: ManagerDep, invitations: InvitationServiceDep +) -> Response: + await invitations.revoke(ctx.tenant_id, invitation_id) + return Response(status_code=204) + + +@router.post("/invitations/accept", response_model=MyTenantView) +async def accept_invitation( + payload: dict[str, str], + request: Request, + user_id: UserIdDep, + invitations: InvitationServiceDep, +) -> MyTenantView: + email = _email_of(request) + if not email: + raise TenantError("invitation_email_mismatch", status_code=403) + tenant = await invitations.accept(payload.get("token", ""), user_id=user_id, user_email=email) + switch_active(request, tenant.id) + membership = await invitations.tenants.get_membership(tenant.id, user_id) + role = membership.role if membership else MembershipRole.MEMBER + return MyTenantView(**tenant.model_dump(), role=role) diff --git a/modules/tenants/tenants/endpoints/views.py b/modules/tenants/tenants/endpoints/views.py new file mode 100644 index 00000000..40b728cf --- /dev/null +++ b/modules/tenants/tenants/endpoints/views.py @@ -0,0 +1,117 @@ +"""Inertia pages for organisations (mounted at /tenants). + +Pages read; mutations go through the JSON API with ``fetch`` followed by an +Inertia reload (``SM018``: Inertia's router rejects JSON responses). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import RedirectResponse +from simple_module_core.permissions import grants +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.permissions import RequiresPermission, resolved_permissions_for +from simple_module_inertia import InertiaResponse + +from tenants.constants import ( + ENTITLEMENT_SEATS, + MANAGER_ROLES, + PAGE_ACCEPT, + PAGE_INDEX, + PAGE_MEMBERS, + PERM_MEMBERS_MANAGE, + PERM_MEMBERS_VIEW, + PERM_PLATFORM_MANAGE, +) +from tenants.deps import InvitationServiceDep, TenantServiceDep, UserIdDep +from tenants.resolver import memberships_for + +router = APIRouter() + + +def _perm(request: Request, permission: str) -> bool: + return grants(resolved_permissions_for(request), permission) + + +@router.get("/", response_model=None) +async def index( + request: Request, inertia: InertiaDep, user_id: UserIdDep, reason: str | None = None +) -> InertiaResponse: + memberships = await memberships_for(request.app, user_id) + settings = request.app.state.tenants.settings + return await inertia.render( + PAGE_INDEX, + { + "memberships": [m.model_dump(mode="json") for m in memberships], + "active_id": getattr(request.state, "tenant_id", None), + "suspended": bool(getattr(request.state, "tenant_suspended", False)), + "suspended_name": getattr(request.state, "suspended_tenant_name", None), + "can_create": settings.allow_self_service or _perm(request, PERM_PLATFORM_MANAGE), + "reason": reason, + }, + ) + + +@router.get( + "/members", + response_model=None, + dependencies=[Depends(RequiresPermission(PERM_MEMBERS_VIEW))], +) +async def members( + request: Request, + inertia: InertiaDep, + service: TenantServiceDep, + invitations: InvitationServiceDep, +) -> InertiaResponse | RedirectResponse: + tenant_id = getattr(request.state, "tenant_id", None) + tenant = await service.get(tenant_id) if tenant_id else None + if tenant is None: + return RedirectResponse("/tenants/?reason=tenant_required", status_code=303) + can_manage = ( + _perm(request, PERM_MEMBERS_MANAGE) + and getattr(request.state, "tenant_role", None) in MANAGER_ROLES + ) + pending = await invitations.list_pending(tenant.id) if can_manage else [] + limit = await service.entitlements.limit(tenant.id, ENTITLEMENT_SEATS) + return await inertia.render( + PAGE_MEMBERS, + { + "tenant": {"id": tenant.id, "name": tenant.name, "slug": tenant.slug}, + "my_role": request.state.tenant_role, + "my_user_id": str(request.state.user.id), + "can_manage": can_manage, + "members": [ + { + "user_id": m.user_id, + "email": m.email, + "role": m.role, + "joined_at": m.created_at.isoformat() if m.created_at else None, + } + for m in await service.list_members(tenant.id) + ], + "invitations": [i.model_dump(mode="json", exclude={"token_hash"}) for i in pending], + "seats": {"used": await service.seats_used(tenant.id), "limit": limit}, + }, + ) + + +@router.get("/invitations/accept", response_model=None) +async def accept_invitation( + request: Request, inertia: InertiaDep, invitations: InvitationServiceDep, token: str = "" +) -> InertiaResponse: + found = await invitations.lookup(token) + preview = None + if found is not None: + invitation, tenant = found + preview = { + "tenant_name": tenant.name, + "email": invitation.email, + "role": invitation.role, + "expired": invitations.is_expired(invitation), + "accepted": invitation.accepted_at is not None, + } + user = getattr(request.state, "user", None) + return await inertia.render( + PAGE_ACCEPT, + {"token": token, "invitation": preview, "signed_in_as": getattr(user, "email", None)}, + ) diff --git a/modules/tenants/tenants/errors.py b/modules/tenants/tenants/errors.py new file mode 100644 index 00000000..286d2892 --- /dev/null +++ b/modules/tenants/tenants/errors.py @@ -0,0 +1,70 @@ +"""Tenants errors and their HTTP mapping.""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, RedirectResponse, Response +from simple_module_db import MissingTenantError, TenantIsolationError + +from tenants.contracts.entitlements import EntitlementExceededError + +logger = logging.getLogger(__name__) + +_INDEX_URL = "/tenants/" + + +class TenantError(Exception): + """A rejected tenant operation. ``code`` is a stable, translatable key.""" + + def __init__(self, code: str, *, status_code: int = 400) -> None: + super().__init__(code) + self.code = code + self.status_code = status_code + + +def _is_api(request: Request) -> bool: + return request.url.path.startswith("/api/") + + +async def _tenant_error(request: Request, exc: Exception) -> Response: + assert isinstance(exc, TenantError) + return JSONResponse({"detail": exc.code}, status_code=exc.status_code) + + +async def _entitlement_exceeded(request: Request, exc: Exception) -> Response: + assert isinstance(exc, EntitlementExceededError) + return JSONResponse( + {"detail": "plan_limit", "key": exc.key, "limit": exc.limit}, status_code=402 + ) + + +async def _isolation_error(request: Request, exc: Exception) -> Response: + """A tenant isolation rule fired during a request. + + Only a *missing* tenant for a signed-in user is a user state — no + organisation yet, or theirs is suspended — and gets the picker instead of + a 500. Anything else (a cross-tenant write, a tenant_id change) is a bug + or an attack: refused with 403 and logged as an error, never dressed up + as "pick an organisation". + """ + user = getattr(request.state, "user", None) + tenant_id = getattr(request.state, "tenant_id", None) + if not isinstance(exc, MissingTenantError) or tenant_id is not None or user is None: + logger.error( + "Tenant isolation violation on %s %s: %s", + request.method, + request.url.path, + exc, + ) + return JSONResponse({"detail": "tenant_isolation"}, status_code=403) + if _is_api(request) or request.method not in ("GET", "HEAD"): + return JSONResponse({"detail": "tenant_required"}, status_code=403) + return RedirectResponse(f"{_INDEX_URL}?reason=tenant_required", status_code=303) + + +def install_exception_handlers(app: FastAPI) -> None: + app.add_exception_handler(TenantError, _tenant_error) + app.add_exception_handler(EntitlementExceededError, _entitlement_exceeded) + app.add_exception_handler(TenantIsolationError, _isolation_error) diff --git a/modules/tenants/tenants/hooks/useTenantErrors.ts b/modules/tenants/tenants/hooks/useTenantErrors.ts new file mode 100644 index 00000000..c23b8143 --- /dev/null +++ b/modules/tenants/tenants/hooks/useTenantErrors.ts @@ -0,0 +1,44 @@ +import { keys, useT } from '@simple-module-py/i18n'; + +/** + * Maps the API's stable `{detail: code}` error codes to translated copy. + * + * Built inside a hook (never at module scope) so it stays bound to the + * active locale rather than freezing against whatever locale was live on + * first render. + */ +export function useTenantErrors() { + const { t } = useT(); + + const messages: Record = { + slug_taken: t(keys.tenants.errors.slug_taken), + self_service_disabled: t(keys.tenants.errors.self_service_disabled), + tenant_suspended: t(keys.tenants.errors.tenant_suspended), + not_found: t(keys.tenants.errors.not_found), + last_owner: t(keys.tenants.errors.last_owner), + owner_required: t(keys.tenants.errors.owner_required), + member_not_found: t(keys.tenants.errors.member_not_found), + already_invited: t(keys.tenants.errors.already_invited), + already_member: t(keys.tenants.errors.already_member), + tenant_manager_required: t(keys.tenants.errors.tenant_manager_required), + tenant_isolation: t(keys.tenants.errors.tenant_isolation), + invitation_not_found: t(keys.tenants.errors.invitation_not_found), + invitation_expired: t(keys.tenants.errors.invitation_expired), + invitation_used: t(keys.tenants.errors.invitation_used), + invitation_email_mismatch: t(keys.tenants.errors.invitation_email_mismatch), + }; + + /** Reads a fetch Response body's `{detail}` (and `limit` for plan_limit) and + * returns translated copy, falling back to a generic message. */ + async function describe(response: Response): Promise { + if (response.status === 422) return t(keys.tenants.errors.validation_failed); + const data = await response.json().catch(() => ({}) as Record); + const code = typeof data.detail === 'string' ? data.detail : ''; + if (code === 'plan_limit') { + return t(keys.tenants.errors.plan_limit, { limit: data.limit ?? '?' }); + } + return messages[code] ?? t(keys.tenants.errors.generic); + } + + return { describe }; +} diff --git a/modules/tenants/tenants/host_resolver.py b/modules/tenants/tenants/host_resolver.py new file mode 100644 index 00000000..80986a70 --- /dev/null +++ b/modules/tenants/tenants/host_resolver.py @@ -0,0 +1,86 @@ +"""Subdomain tenant resolution (#363): ``acme.example.com`` -> tenant ``acme``. + +The one source that works for anonymous visitors, so a public site (pagebuilder, +a records viewer) can serve the right tenant without a sign-in. Enabled by the +``subdomain_base`` setting (e.g. ``example.com``); empty disables it. + +Binding rules, by who is asking: + +* **member** of the subdomain's tenant — bound with their membership role, + exactly as if they had switched to it; +* **anonymous** — bound; ``AuthMiddleware`` already limits them to public + routes, so they see only what the tenant publishes; +* **signed-in non-member** — bound on public routes only. On an authenticated + route a platform-wide permission (a ``user`` role mapped to ``x.view``) + would otherwise read a tenant the user does not belong to. + +An unknown or suspended subdomain binds nothing and never falls back to the +session's tenant: the host named a tenant, and it is not that one. +""" + +from __future__ import annotations + +from cachetools import TTLCache +from fastapi import FastAPI +from sqlalchemy import select +from starlette.requests import Request + +from tenants.constants import TenantStatus +from tenants.models import Tenant + +HOST_TTL_SECONDS = 60 +_BY_SLUG: TTLCache[str, tuple[str, str]] = TTLCache(maxsize=10_000, ttl=HOST_TTL_SECONDS) + + +def forget_hosts() -> None: + _BY_SLUG.clear() + + +def subdomain_slug(request: Request) -> str | None: + """The tenant slug named by the request's host, if subdomains are enabled.""" + base = request.app.state.tenants.settings.subdomain_base.strip().lower().strip(".") + host = (request.url.hostname or "").lower() + if not base or not host.endswith("." + base): + return None + slug = host[: -len(base) - 1] + return slug if slug and "." not in slug else None + + +async def tenant_for_slug(app: FastAPI, slug: str) -> tuple[str, str] | None: + """``(tenant_id, status)`` for a slug, cached briefly.""" + hit = _BY_SLUG.get(slug) + if hit is not None: + return hit + async with app.state.sm.db.session_factory() as db: + row = ( + await db.execute(select(Tenant.id, Tenant.status).where(Tenant.slug == slug)) + ).first() + if row is None: + return None + found = (row[0], row[1]) + _BY_SLUG[slug] = found + return found + + +def is_public_route(request: Request) -> bool: + registry = getattr(request.app.state, "public_routes", None) + return registry is not None and registry.matches(request.method, request.url.path) + + +async def resolve_from_host(request: Request, slug: str) -> tuple[str | None, bool]: + """``(tenant_id, active)`` for the subdomain; ``(None, False)`` if unusable.""" + found = await tenant_for_slug(request.app, slug) + if found is None or found[1] != TenantStatus.ACTIVE: + if found is not None: + request.state.tenant_suspended = True + return None, False + return found[0], True + + +__all__ = [ + "forget_hosts", + "is_public_route", + "resolve_from_host", + "subdomain_slug", + "tenant_for_slug", +] diff --git a/modules/tenants/tenants/invitations.py b/modules/tenants/tenants/invitations.py new file mode 100644 index 00000000..a5fc79b3 --- /dev/null +++ b/modules/tenants/tenants/invitations.py @@ -0,0 +1,141 @@ +"""Invitations: an owner/admin invites an email; that user accepts while signed in. + +Provider-agnostic on purpose — no user lookup, no mailer. The raw token is +returned once to the inviter (to copy) and carried on ``InvitationCreated`` +(for a mailer module to send); only its SHA-256 is stored. +""" + +from __future__ import annotations + +import hashlib +import secrets +from datetime import UTC, datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from tenants.constants import MembershipRole, TenantStatus +from tenants.contracts.events import InvitationCreated +from tenants.contracts.schemas import InvitationCreate +from tenants.errors import TenantError +from tenants.models import Invitation, Tenant +from tenants.service import TenantService + +ACCEPT_PATH = "/tenants/invitations/accept" + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _aware(value: datetime) -> datetime: + # SQLite hands back naive datetimes even for timezone=True columns. + return value if value.tzinfo else value.replace(tzinfo=UTC) + + +def _pending_clause(tenant_id: str): + return ( + (Invitation.tenant_id == tenant_id) + & Invitation.accepted_at.is_(None) + & (Invitation.expires_at > _now()) + ) + + +async def pending_invitation_count(db: AsyncSession, tenant_id: str) -> int: + stmt = select(func.count()).select_from(Invitation).where(_pending_clause(tenant_id)) + return int(await db.scalar(stmt) or 0) + + +class InvitationService: + def __init__(self, tenants: TenantService, *, ttl_hours: int) -> None: + self.tenants = tenants + self.db = tenants.db + self.ttl = timedelta(hours=ttl_hours) + + async def create( + self, tenant_id: str, data: InvitationCreate, *, base_url: str + ) -> tuple[Invitation, str, str]: + """Return ``(invitation, raw_token, accept_url)``.""" + tenant = await self.tenants.lock(tenant_id) + if await self.tenants.has_member_email(tenant_id, data.email): + raise TenantError("already_member", status_code=409) + duplicate = await self.db.scalar( + select(Invitation.id).where(_pending_clause(tenant_id), Invitation.email == data.email) + ) + if duplicate is not None: + raise TenantError("already_invited", status_code=409) + await self.tenants.ensure_seat_available(tenant_id) + token = secrets.token_urlsafe(32) + invitation = Invitation( + tenant_id=tenant_id, + email=data.email, + role=data.role, + token_hash=hash_token(token), + expires_at=_now() + self.ttl, + ) + self.db.add(invitation) + await self.db.flush() + accept_url = f"{base_url.rstrip('/')}{ACCEPT_PATH}?token={token}" + self.tenants._after_commit( + InvitationCreated(tenant_id, tenant.name, data.email, data.role, accept_url) + ) + return invitation, token, accept_url + + async def list_pending(self, tenant_id: str) -> list[Invitation]: + stmt = select(Invitation).where(_pending_clause(tenant_id)).order_by(Invitation.created_at) + return list((await self.db.execute(stmt)).scalars().all()) + + async def revoke(self, tenant_id: str, invitation_id: int) -> None: + invitation = await self.db.get(Invitation, invitation_id) + if invitation is None or invitation.tenant_id != tenant_id: + raise TenantError("invitation_not_found", status_code=404) + await self.db.delete(invitation) + await self.db.flush() + + async def lookup( + self, token: str, *, for_update: bool = False + ) -> tuple[Invitation, Tenant] | None: + if not token: + return None + stmt = ( + select(Invitation, Tenant) + .join(Tenant, Tenant.id == Invitation.tenant_id) + .where(Invitation.token_hash == hash_token(token)) + ) + if for_update: # two concurrent accepts of one link must not both pass + stmt = stmt.with_for_update(of=Invitation) + row = (await self.db.execute(stmt)).first() + return (row[0], row[1]) if row else None + + @staticmethod + def is_expired(invitation: Invitation) -> bool: + return _aware(invitation.expires_at) <= _now() + + async def accept(self, token: str, *, user_id: str, user_email: str) -> Tenant: + found = await self.lookup(token, for_update=True) + if found is None: + raise TenantError("invitation_not_found", status_code=404) + invitation, tenant = found + if invitation.accepted_at is not None: + raise TenantError("invitation_used", status_code=409) + if tenant.status != TenantStatus.ACTIVE: + raise TenantError("tenant_suspended", status_code=409) + if self.is_expired(invitation): + raise TenantError("invitation_expired", status_code=410) + # The link is a bearer secret; binding it to the invited address means + # a forwarded or leaked link cannot enrol a different account. + if user_email.strip().lower() != invitation.email: + raise TenantError("invitation_email_mismatch", status_code=403) + invitation.accepted_at = _now() + await self.tenants.add_member( + tenant.id, + user_id, + MembershipRole(invitation.role), + email=invitation.email, + seat_reserved=True, + ) + return tenant diff --git a/modules/tenants/tenants/locales/en.json b/modules/tenants/tenants/locales/en.json new file mode 100644 index 00000000..89894da5 --- /dev/null +++ b/modules/tenants/tenants/locales/en.json @@ -0,0 +1,139 @@ +{ + "nav": { + "organisations": "Organisations", + "members": "Members", + "tenants": "Tenants" + }, + "roles": { + "owner": "Owner", + "admin": "Admin", + "member": "Member" + }, + "status": { + "active": "Active", + "suspended": "Suspended" + }, + "index": { + "head_title": "Organisations", + "title": "Organisations", + "description": "Organisations you belong to, and where to create a new one.", + "active_badge": "Active", + "switch_button": "Switch", + "members_link": "Members", + "notice_tenant_required": "You need to create or pick an organisation to continue.", + "notice_suspended": "This organisation has been suspended. Contact your administrator, or create a new one.", + "empty_title": "No organisations yet", + "empty_description": "You are not a member of any organisation yet.", + "create_title": "Create an organisation", + "create_name_label": "Name", + "create_name_placeholder": "Acme Inc.", + "create_slug_label": "Slug (optional)", + "create_slug_placeholder": "acme", + "create_slug_hint": "Lowercase letters, numbers and hyphens. Generated from the name if left blank.", + "create_submit": "Create organisation", + "create_submitting": "Creating…", + "toast_created": "Organisation created", + "toast_switched": "Switched organisation", + "toast_switch_failed": "Could not switch organisation", + "notice_suspended_switched": "{name} has been suspended, so you are now working in {active}. Contact your administrator to restore it." + }, + "members": { + "head_title": "Members", + "title": "Members", + "description": "Manage who belongs to {name} and what they can do.", + "seats_used": "{used} / {limit} seats used", + "seats_unlimited": "{used} seats used (unlimited)", + "table_email": "Email", + "table_role": "Role", + "table_joined": "Joined", + "table_actions": "Actions", + "you_suffix": " (you)", + "remove_button": "Remove", + "leave_button": "Leave organisation", + "leave_confirm_title": "Leave this organisation?", + "leave_confirm_description": "You will lose access to {name} and its resources.", + "leave_confirm_confirm": "Leave", + "leave_confirm_cancel": "Cancel", + "remove_confirm_title": "Remove this member?", + "remove_confirm_description": "They will immediately lose access to {name}.", + "remove_confirm_confirm": "Remove", + "remove_confirm_cancel": "Cancel", + "toast_role_updated": "Role updated", + "toast_removed": "Member removed", + "toast_left": "You left the organisation", + "invite_title": "Invite someone", + "invite_email_label": "Email", + "invite_email_placeholder": "person@example.com", + "invite_role_label": "Role", + "invite_submit": "Send invitation", + "invite_submitting": "Sending…", + "invite_link_title": "Invitation link", + "invite_link_description": "Share this link with the invitee — it will not be shown again.", + "invite_link_copy": "Copy link", + "toast_link_copied": "Invitation link copied", + "toast_copy_failed": "Could not copy the link", + "toast_invited": "Invitation sent", + "pending_title": "Pending invitations", + "pending_empty": "No pending invitations.", + "pending_expires": "Expires {date}", + "revoke_button": "Revoke", + "toast_revoked": "Invitation revoked" + }, + "accept": { + "head_title": "Accept invitation", + "invalid_title": "Invalid invitation link", + "invalid_description": "This invitation link is not valid. Ask whoever invited you to send a new one.", + "expired_title": "This invitation has expired", + "expired_description": "Ask whoever invited you to {tenant} to send a new invitation.", + "accepted_title": "This invitation was already used", + "accepted_description": "This invitation to {tenant} has already been accepted.", + "title": "Join {tenant}", + "description": "You have been invited to join {tenant} as {role}.", + "mismatch_title": "Signed in with a different email", + "mismatch_description": "This invitation was sent to {email}, but you are signed in as {signed_in_as}. Sign out and sign back in as {email} to accept it.", + "accept_button": "Accept invitation", + "accepting": "Accepting…", + "toast_accepted": "You joined {tenant}" + }, + "admin": { + "head_title": "Organisations", + "title": "Organisations", + "description": "All organisations on this platform.", + "search_placeholder": "Search organisations…", + "table_name": "Name", + "table_slug": "Slug", + "table_status": "Status", + "table_members": "Members", + "table_created": "Created", + "table_actions": "Actions", + "suspend_button": "Suspend", + "reactivate_button": "Reactivate", + "empty_title": "No organisations found", + "empty_description": "No organisation matches your search.", + "previous": "Previous", + "next": "Next", + "toast_suspended": "Organisation suspended", + "toast_reactivated": "Organisation reactivated", + "toast_action_failed": "Could not update the organisation" + }, + "errors": { + "slug_taken": "That slug is already in use.", + "self_service_disabled": "Creating organisations is disabled. Ask a platform administrator to create one for you.", + "validation_failed": "Please check the form and try again.", + "tenant_suspended": "That organisation is suspended.", + "not_found": "That organisation could not be found.", + "last_owner": "The last owner cannot be removed or leave. Transfer ownership first.", + "owner_required": "Only an owner can do that.", + "member_not_found": "That member could not be found.", + "already_invited": "That email address already has a pending invitation.", + "plan_limit": "Seat limit reached ({limit}). Upgrade your plan to add more members.", + "invitation_not_found": "That invitation could not be found.", + "invitation_expired": "That invitation has expired.", + "invitation_used": "That invitation has already been used.", + "invitation_email_mismatch": "This invitation must be accepted by the invited email address.", + "generic": "Something went wrong. Please try again.", + "already_member": "That person is already a member of this organisation.", + "tenant_manager_required": "Only an owner or admin of this organisation can do that.", + "tenant_isolation": "That action is not allowed across organisations." + } +} diff --git a/modules/tenants/tenants/models.py b/modules/tenants/tenants/models.py new file mode 100644 index 00000000..92fbcd32 --- /dev/null +++ b/modules/tenants/tenants/models.py @@ -0,0 +1,85 @@ +"""SQLModel tables for the Tenants module. + +None of these use ``MultiTenantMixin``: they *are* the tenant registry, and +the queries that matter ("which tenants am I in", "which invitation is this +token") span tenants by nature. Each carries an explicit ``tenant_id`` column +and every service method filters on it. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin +from sqlalchemy import Column, DateTime, Index, UniqueConstraint +from sqlmodel import Field + +from tenants.constants import ( + MAX_EMAIL_LEN, + MAX_NAME_LEN, + MAX_SLUG_LEN, + TENANT_ID_LEN, + MembershipRole, + TenantStatus, +) + +Base = create_module_base("tenants") + + +def _new_tenant_id() -> str: + return uuid.uuid4().hex + + +class Tenant(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] + """An organisation. Its ``id`` is the value stored in every ``tenant_id``.""" + + __tablename__ = "tenants_tenant" + + id: str = Field(default_factory=_new_tenant_id, primary_key=True, max_length=TENANT_ID_LEN) + slug: str = Field(max_length=MAX_SLUG_LEN, unique=True, index=True) + name: str = Field(max_length=MAX_NAME_LEN) + status: str = Field(default=TenantStatus.ACTIVE, max_length=20, index=True) + + +class Membership(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] + """A user's membership in a tenant, with their role there. + + ``user_id`` is a plain string, not a foreign key: principals may come from + an external identity provider (keycloak) with no local users row. + """ + + __tablename__ = "tenants_membership" + __table_args__ = ( + UniqueConstraint("tenant_id", "user_id", name="uq_tenants_membership_tenant_user"), + Index("ix_tenants_membership_user", "user_id"), + ) + + id: int | None = Field(default=None, primary_key=True) + tenant_id: str = Field( + foreign_key="tenants_tenant.id", max_length=TENANT_ID_LEN, ondelete="CASCADE" + ) + user_id: str = Field(max_length=64) + role: str = Field(default=MembershipRole.MEMBER, max_length=20) + # Snapshot of the principal's email when they joined, for display only — + # there is no provider-agnostic user lookup (keycloak users are not local). + email: str | None = Field(default=None, max_length=MAX_EMAIL_LEN) + + +class Invitation(Base, AuditMixin, table=True): # ty: ignore[unsupported-base] + """A pending invitation. Only the token's SHA-256 is stored.""" + + __tablename__ = "tenants_invitation" + + id: int | None = Field(default=None, primary_key=True) + tenant_id: str = Field( + foreign_key="tenants_tenant.id", max_length=TENANT_ID_LEN, ondelete="CASCADE", index=True + ) + email: str = Field(max_length=MAX_EMAIL_LEN) + role: str = Field(default=MembershipRole.MEMBER, max_length=20) + token_hash: str = Field(max_length=64, unique=True, index=True) + expires_at: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) + accepted_at: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True), nullable=True) + ) diff --git a/modules/tenants/tenants/module.py b/modules/tenants/tenants/module.py new file mode 100644 index 00000000..f096bf8c --- /dev/null +++ b/modules/tenants/tenants/module.py @@ -0,0 +1,141 @@ +"""Tenants module — organisations, memberships and tenant resolution. + +Installing it replaces the framework's claim-based tenant resolution with a +membership-validated one (``app.state.tenant_resolver``). It only takes effect +when the host runs with ``multi_tenant`` on, which also turns on strict +(fail-closed) isolation in the DB layer. +""" + +from __future__ import annotations + +import importlib +import importlib.resources +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta + +from tenants import constants as c + +if TYPE_CHECKING: + from fastapi import APIRouter, FastAPI + from simple_module_core.invalidation import InvalidationBus + from simple_module_core.permissions import PermissionRegistry + +logger = logging.getLogger(__name__) + + +class TenantsModule(ModuleBase): + meta = ModuleMeta( + name=c.DISPLAY_NAME, + route_prefix="/api/tenants", + view_prefix="/tenants", + admin_view_prefix="/admin/tenants", + # Auth: its middleware must have set request.state.user before the + # resolver runs. Settings: register_module_settings. + depends_on=["Auth", "Settings"], + ) + + def register_settings(self, app: FastAPI) -> None: + from simple_module_hosting.shared_props import register_inertia_shared_provider + + from tenants.resolver import resolve_tenant + from tenants.services import TenantsServices + from tenants.settings import TenantsSettings + from tenants.shared_props import tenant_shared_props + + register_module_settings = importlib.import_module( + "settings.registration" + ).register_module_settings + register_module_settings( + app, c.MODULE_PACKAGE, TenantsSettings, lambda s: TenantsServices(settings=s) + ) + app.state.tenant_resolver = resolve_tenant + register_inertia_shared_provider(app, tenant_shared_props) + + def register_exception_handlers(self, app: FastAPI) -> None: + from tenants.errors import install_exception_handlers + + install_exception_handlers(app) + + def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None: + from tenants.resolver import subscribe + + subscribe(bus) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from tenants.endpoints.admin import api as admin_api + from tenants.endpoints.api import router as api + from tenants.endpoints.views import router as views + + # Admin first: "/admin" must not be captured by "/{tenant_id}/…". + api_router.include_router(admin_api) + api_router.include_router(api) + view_router.include_router(views) + + def register_admin_routes(self, admin_router: APIRouter) -> None: + from tenants.endpoints.admin import views as admin_views + + admin_router.include_router(admin_views) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Organisations", + label_key="tenants.nav.organisations", + url="/tenants/", + icon="briefcase", + order=90, + section=MenuSection.SIDEBAR, + ) + ) + registry.add( + MenuItem( + label="Members", + label_key="tenants.nav.members", + url="/tenants/members", + icon="users", + order=91, + section=MenuSection.SIDEBAR, + permissions=[c.PERM_MEMBERS_VIEW], + ) + ) + registry.add( + MenuItem( + label="Tenants", + label_key="tenants.nav.tenants", + url="/admin/tenants/", + icon="briefcase", + order=105, + section=MenuSection.ADMIN_SIDEBAR, + permissions=[c.PERM_PLATFORM_VIEW], + group="Access", + group_key="ui.nav_groups.access", + ) + ) + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group( + c.DISPLAY_NAME, + [ + c.PERM_MEMBERS_VIEW, + c.PERM_MEMBERS_MANAGE, + c.PERM_SETTINGS_MANAGE, + c.PERM_PLATFORM_VIEW, + c.PERM_PLATFORM_MANAGE, + ], + ) + for role, perms in c.ROLE_PERMISSIONS.items(): + registry.map_role(f"{c.TENANT_ROLE_PREFIX}{role}", perms) + + async def on_startup(self, app: FastAPI) -> None: + if not getattr(app.state.sm.settings, "multi_tenant", False): + logger.warning( + "tenants module is installed but multi_tenant is off: organisations can be " + "managed, but requests are not scoped to them until it is enabled" + ) + + def locale_dirs(self) -> dict[str, Path]: + return {c.MODULE_PACKAGE: Path(str(importlib.resources.files(__package__) / "locales"))} diff --git a/modules/tenants/tenants/pages/AcceptInvitation.tsx b/modules/tenants/tenants/pages/AcceptInvitation.tsx new file mode 100644 index 00000000..ef704e73 --- /dev/null +++ b/modules/tenants/tenants/pages/AcceptInvitation.tsx @@ -0,0 +1,114 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; +import { TimerOff } from 'lucide-react'; +import { useState } from 'react'; +import { useTenantErrors } from '../hooks/useTenantErrors'; + +interface InvitationPreview { + tenant_name: string; + email: string; + role: string; + expired: boolean; + accepted: boolean; +} + +interface Props { + token: string; + invitation: InvitationPreview | null; + signed_in_as: string | null; +} + +const ROLE_KEY = keys.tenants.roles; + +function AcceptInvitation() { + const { token, invitation, signed_in_as } = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); + const { describe } = useTenantErrors(); + const [accepting, setAccepting] = useState(false); + const [error, setError] = useState(null); + + async function accept() { + setAccepting(true); + setError(null); + try { + const response = await fetch('/api/tenants/invitations/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ token }), + }); + if (!response.ok) { + setError(await describe(response)); + return; + } + router.visit('/tenants/'); + } catch { + setError(t(keys.tenants.errors.generic)); + } finally { + setAccepting(false); + } + } + + if (invitation === null || invitation.expired || invitation.accepted) { + const title = !invitation + ? t(keys.tenants.accept.invalid_title) + : invitation.expired + ? t(keys.tenants.accept.expired_title) + : t(keys.tenants.accept.accepted_title); + const description = !invitation + ? t(keys.tenants.accept.invalid_description) + : invitation.expired + ? t(keys.tenants.accept.expired_description, { tenant: invitation.tenant_name }) + : t(keys.tenants.accept.accepted_description, { tenant: invitation.tenant_name }); + return ( + + +
+
+
+ ); + } + + const mismatch = !!signed_in_as && signed_in_as.toLowerCase() !== invitation.email.toLowerCase(); + + return ( + + +

+ {t(keys.tenants.accept.title, { tenant: invitation.tenant_name })} +

+

+ {t(keys.tenants.accept.description, { + tenant: invitation.tenant_name, + role: t(ROLE_KEY[invitation.role as 'owner' | 'admin' | 'member']), + })} +

+ + {mismatch ? ( +
+

{t(keys.tenants.accept.mismatch_title)}

+

+ {t(keys.tenants.accept.mismatch_description, { + email: invitation.email, + signed_in_as: signed_in_as ?? '', + })} +

+
+ ) : ( + <> + {error &&

{error}

} + + + )} +
+ ); +} + +export default AcceptInvitation; diff --git a/modules/tenants/tenants/pages/AdminBrowse.tsx b/modules/tenants/tenants/pages/AdminBrowse.tsx new file mode 100644 index 00000000..5496823b --- /dev/null +++ b/modules/tenants/tenants/pages/AdminBrowse.tsx @@ -0,0 +1,109 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { EmptyState } from '@simple-module-py/ui/components/EmptyState'; +import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { Card } from '@simple-module-py/ui/components/ui/card'; +import { Input } from '@simple-module-py/ui/components/ui/input'; +import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; +import { Building2, Search } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { type AdminTenant, AdminTenantsTable } from '../components/AdminTenantsTable'; + +interface Props { + tenants: AdminTenant[]; + q: string; + page: number; + has_more: boolean; +} + +const VIEW_URL = '/admin/tenants/'; + +function AdminBrowse() { + const { tenants, q, page, has_more } = usePage<{ props: Props }>().props as unknown as Props; + const { t } = useT(); + const [search, setSearch] = useState(q); + + const navigate = useCallback( + (next: Partial<{ q: string; page: number }>) => { + const params: Record = {}; + const query = next.q ?? q; + const target = next.page ?? 1; + if (query) params.q = query; + if (target > 1) params.page = String(target); + router.get(VIEW_URL, params, { preserveState: true, preserveScroll: true }); + }, + [q], + ); + + // Debounced search — see settings' Browse page for the same pattern. + useEffect(() => { + if (search === q) return; + const timeout = setTimeout(() => navigate({ q: search, page: 1 }), 300); + return () => clearTimeout(timeout); + }, [search, q, navigate]); + + function reload() { + router.reload(); + } + + return ( + <> + + +
+
+ + + {tenants.length === 0 ? ( + + ) : ( + + )} + +
+ + +
+
+
+ + ); +} + +AdminBrowse.layout = [AdminLayout]; +export default AdminBrowse; diff --git a/modules/tenants/tenants/pages/Index.tsx b/modules/tenants/tenants/pages/Index.tsx new file mode 100644 index 00000000..ecdf4ca4 --- /dev/null +++ b/modules/tenants/tenants/pages/Index.tsx @@ -0,0 +1,71 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { EmptyState } from '@simple-module-py/ui/components/EmptyState'; +import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; +import { Building2 } from 'lucide-react'; +import { CreateOrgForm } from '../components/CreateOrgForm'; +import { type Membership, MembershipList } from '../components/MembershipList'; + +interface Props { + memberships: Membership[]; + active_id: string | null; + suspended: boolean; + suspended_name: string | null; + can_create: boolean; + reason: string | null; +} + +function Index() { + const { memberships, active_id, suspended, suspended_name, can_create, reason } = usePage<{ + props: Props; + }>().props as unknown as Props; + const { t } = useT(); + + function reload() { + router.reload(); + } + + return ( + <> + + + {reason === 'tenant_required' && ( +
+ {t(keys.tenants.index.notice_tenant_required)} +
+ )} + {suspended && ( +
+ {active_id && suspended_name + ? t(keys.tenants.index.notice_suspended_switched, { + name: suspended_name, + active: memberships.find((m) => m.id === active_id)?.name ?? '', + }) + : t(keys.tenants.index.notice_suspended)} +
+ )} + +
+ {memberships.length === 0 ? ( + + ) : ( + + )} + + {can_create && } +
+
+ + ); +} + +Index.layout = [AuthenticatedLayout]; +export default Index; diff --git a/modules/tenants/tenants/pages/Members.tsx b/modules/tenants/tenants/pages/Members.tsx new file mode 100644 index 00000000..7c9757e3 --- /dev/null +++ b/modules/tenants/tenants/pages/Members.tsx @@ -0,0 +1,79 @@ +import { Head, router, usePage } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { PageShell } from '@simple-module-py/ui/components/PageShell'; +import { Card } from '@simple-module-py/ui/components/ui/card'; +import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; +import { type Invitation, InvitationsList } from '../components/InvitationsList'; +import { InviteForm } from '../components/InviteForm'; +import { type Member, type MembershipRole, MembersTable } from '../components/MembersTable'; + +interface Tenant { + id: string; + name: string; + slug: string; +} + +interface Seats { + used: number; + limit: number | null; +} + +interface Props { + tenant: Tenant; + my_role: MembershipRole; + my_user_id: string; + can_manage: boolean; + members: Member[]; + invitations: Invitation[]; + seats: Seats; +} + +function Members() { + const { tenant, my_role, my_user_id, can_manage, members, invitations, seats } = usePage<{ + props: Props; + }>().props as unknown as Props; + const { t } = useT(); + + function reload() { + router.reload(); + } + + const seatsLine = + seats.limit === null + ? t(keys.tenants.members.seats_unlimited, { used: seats.used }) + : t(keys.tenants.members.seats_used, { used: seats.used, limit: seats.limit }); + + return ( + <> + + +
+ + +
{seatsLine}
+
+ + {can_manage && ( + <> + + + + )} +
+
+ + ); +} + +Members.layout = [AuthenticatedLayout]; +export default Members; diff --git a/modules/tenants/tenants/py.typed b/modules/tenants/tenants/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/modules/tenants/tenants/resolver.py b/modules/tenants/tenants/resolver.py new file mode 100644 index 00000000..e391907d --- /dev/null +++ b/modules/tenants/tenants/resolver.py @@ -0,0 +1,219 @@ +"""Tenant resolution: which tenant does this request act for? + +Registered as ``app.state.tenant_resolver`` and called by the framework's +``TenantMiddleware``. The session only stores a *preference* +(``SESSION_ACTIVE_TENANT``); every request re-validates it against the user's +memberships, so removing a member or suspending a tenant takes effect on the +next request after the cache entry drops (immediately in-process, via +``InvalidationBus`` across workers, ``MEMBERSHIP_TTL_SECONDS`` at worst). + +The principal gains ``tenant:`` for the active tenant only; that is how +membership roles reach the permission registry without ever touching the +platform-wide roles. +""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any + +from cachetools import TTLCache +from fastapi import FastAPI +from simple_module_core.invalidation import Invalidation, InvalidationBus +from simple_module_db import is_valid_tenant_id +from starlette.requests import Request + +from tenants.constants import ( + INVALIDATION_CHANNEL, + SESSION_ACTIVE_TENANT, + TENANT_ROLE_PREFIX, + TenantStatus, +) +from tenants.contracts.schemas import MyTenantView +from tenants.host_resolver import ( + forget_hosts, + is_public_route, + resolve_from_host, + subdomain_slug, +) +from tenants.service import TenantService + +logger = logging.getLogger(__name__) + +MEMBERSHIP_TTL_SECONDS = 60 + +_MEMBERSHIPS: TTLCache[str, list[MyTenantView]] = TTLCache( + maxsize=10_000, ttl=MEMBERSHIP_TTL_SECONDS +) + + +# Bumped by every invalidation. A read that started before an invalidation +# must not store its (possibly stale) result afterwards: otherwise a member +# removed while their own request was mid-read is re-cached as a member for a +# whole TTL. +_epoch = 0 + + +def forget(key: str | None) -> None: + global _epoch + _epoch += 1 + if key is None: + _MEMBERSHIPS.clear() + forget_hosts() # a status change: cached subdomain lookups are stale too + else: + _MEMBERSHIPS.pop(key, None) + + +def _apply_invalidation(inv: Invalidation) -> None: + forget(inv.key) + + +def subscribe(bus: InvalidationBus) -> None: + bus.subscribe(INVALIDATION_CHANNEL, _apply_invalidation) + + +def make_invalidator(app: FastAPI): + async def invalidate(key: str | None) -> None: + forget(key) # this worker, now — the bus reaches the others + bus = getattr(getattr(app.state, "sm", None), "invalidation", None) + if bus is not None: + await bus.publish(INVALIDATION_CHANNEL, key=key) + + return invalidate + + +def cached_memberships(user_id: str) -> list[MyTenantView] | None: + return _MEMBERSHIPS.get(user_id) + + +async def memberships_for(app: FastAPI, user_id: str) -> list[MyTenantView]: + cached = _MEMBERSHIPS.get(user_id) + if cached is not None: + return cached + started = _epoch + async with app.state.sm.db.session_factory() as db: + rows = await TenantService(db).list_for_user(user_id) + views = [ + MyTenantView( + id=t.id, slug=t.slug, name=t.name, status=t.status, created_at=t.created_at, role=role + ) + for t, role in rows + ] + if _epoch == started: + _MEMBERSHIPS[user_id] = views + return views + + +def pick_active(memberships: list[MyTenantView], preferred: str | None) -> MyTenantView | None: + """The preferred tenant if it is an active membership, else the first active one.""" + usable = [m for m in memberships if m.status == TenantStatus.ACTIVE] + for m in usable: + if m.id == preferred: + return m + return usable[0] if usable else None + + +def _with_tenant_role(user: Any, tenant_id: str, role: str) -> Any: + if not dataclasses.is_dataclass(user) or isinstance(user, type): + return user + roles = [r for r in getattr(user, "roles", []) if not r.startswith(TENANT_ROLE_PREFIX)] + changes: dict[str, Any] = {"roles": [*roles, f"{TENANT_ROLE_PREFIX}{role}"]} + if "tenant_id" in {f.name for f in dataclasses.fields(user)}: + changes["tenant_id"] = tenant_id + return dataclasses.replace(user, **changes) + + +async def resolve_tenant(request: Request) -> str | None: + """``TenantResolver`` for the framework's ``TenantMiddleware``.""" + request.state.tenant_role = None + request.state.tenant_suspended = False + request.state.suspended_tenant_name = None + user = getattr(request.state, "user", None) + slug = subdomain_slug(request) + if slug is not None: + return await _resolve_subdomain(request, user, slug) + if user is None: + return None + user_id = str(user.id) + memberships = await memberships_for(request.app, user_id) + + requested = _header_tenant(request) + if requested is not None: + # An explicit per-request choice (API clients). Never fall back to + # another tenant: a client that asked for X — or sent junk — must not + # act on Y. + if not is_valid_tenant_id(requested): + return None + active = next( + (m for m in memberships if m.id == requested and m.status == TenantStatus.ACTIVE), + None, + ) + if active is None: + return None + return _enter(request, user, active) + + session = request.scope.get("session") + preferred = session.get(SESSION_ACTIVE_TENANT) if session is not None else None + active = pick_active(memberships, preferred) + chosen = next((m for m in memberships if m.id == preferred), None) + chosen_suspended = chosen is not None and chosen.status == TenantStatus.SUSPENDED + if chosen_suspended: + # Falling back to another membership must not be silent: say which + # organisation was suspended, and keep the choice in the session so + # the notice stays until the user switches deliberately. + request.state.tenant_suspended = True + request.state.suspended_tenant_name = chosen.name + + if active is None: + request.state.tenant_suspended = any( + m.status == TenantStatus.SUSPENDED for m in memberships + ) + return None + if session is not None and preferred != active.id and not chosen_suspended: + session[SESSION_ACTIVE_TENANT] = active.id + return _enter(request, user, active) + + +async def _resolve_subdomain(request: Request, user: Any, slug: str) -> str | None: + tenant_id, active = await resolve_from_host(request, slug) + if not active or tenant_id is None: + return None + if user is not None: + memberships = await memberships_for(request.app, str(user.id)) + own = next((m for m in memberships if m.id == tenant_id), None) + if own is not None: + return _enter(request, user, own) + if not is_public_route(request): + return None + return tenant_id + + +def _header_tenant(request: Request) -> str | None: + settings = getattr(getattr(request.app.state, "sm", None), "settings", None) + header = getattr(settings, "tenant_header", "") or "" + return request.headers.get(header) if header else None + + +def _enter(request: Request, user: Any, active: MyTenantView) -> str: + request.state.tenant_role = active.role + request.state.user = _with_tenant_role(user, active.id, active.role) + return active.id + + +def switch_active(request: Request, tenant_id: str) -> None: + """Record the user's choice; ``resolve_tenant`` validates it next request.""" + request.session[SESSION_ACTIVE_TENANT] = tenant_id + + +__all__ = [ + "MEMBERSHIP_TTL_SECONDS", + "cached_memberships", + "forget", + "make_invalidator", + "memberships_for", + "pick_active", + "resolve_tenant", + "subscribe", + "switch_active", +] diff --git a/modules/tenants/tenants/service.py b/modules/tenants/tenants/service.py new file mode 100644 index 00000000..8492f78b --- /dev/null +++ b/modules/tenants/tenants/service.py @@ -0,0 +1,291 @@ +"""Tenant and membership business logic. + +Every membership operation takes ``tenant_id`` explicitly and filters on it — +these tables are not ``MultiTenantMixin`` (see ``models``), so the scoping the +listener would otherwise add is done here, by hand, in one place. + +Side effects (domain events, cache invalidation) run *after commit* through +``_after_commit``: a billing handler must never create a customer for a tenant +whose transaction rolled back. +""" + +from __future__ import annotations + +import re +import secrets +from collections.abc import Awaitable, Callable, Sequence +from typing import Any + +from simple_module_core.events import Event, EventBus +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from tenants._owner_guard import demote_owner, remove_owner +from tenants.constants import ( + ENTITLEMENT_SEATS, + MAX_SLUG_LEN, + MembershipRole, + TenantStatus, +) +from tenants.contracts.entitlements import ( + EntitlementProvider, + UnlimitedEntitlements, + ensure_within_limit, +) +from tenants.contracts.events import ( + MembershipAdded, + MembershipRemoved, + TenantCreated, + TenantStatusChanged, +) +from tenants.contracts.schemas import TenantCreate +from tenants.errors import TenantError +from tenants.models import Membership, Tenant + +Invalidate = Callable[[str | None], Awaitable[None]] + + +async def _no_invalidate(_key: str | None) -> None: + return None + + +def slugify(name: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return slug[:MAX_SLUG_LEN].strip("-") or "org" + + +class TenantService: + def __init__( + self, + db: AsyncSession, + *, + bus: EventBus | None = None, + invalidate: Invalidate = _no_invalidate, + entitlements: EntitlementProvider | None = None, + ) -> None: + self.db = db + self.bus = bus + self.invalidate = invalidate + self.entitlements = entitlements or UnlimitedEntitlements() + self.pending: list[Callable[[], Awaitable[Any]]] = [] + + # ── side effects ──────────────────────────────────────────── + + def _after_commit(self, *events: Event, invalidate: Sequence[str | None] = ()) -> None: + async def run() -> None: + for key in invalidate: + await self.invalidate(key) + if self.bus is not None: + for event in events: + await self.bus.publish(event) + + on_commit = getattr(self.db, "on_commit", None) + if on_commit is not None: + on_commit(run) + else: # plain AsyncSession (scripts, tests): caller drains ``pending`` + self.pending.append(run) + + # ── tenants ───────────────────────────────────────────────── + + async def get(self, tenant_id: str) -> Tenant | None: + return await self.db.get(Tenant, tenant_id) + + async def _free_slug(self, wanted: str) -> str: + slug = wanted + while await self.db.scalar(select(Tenant.id).where(Tenant.slug == slug)): + suffix = secrets.token_hex(2) + slug = f"{wanted[: MAX_SLUG_LEN - len(suffix) - 1]}-{suffix}" + return slug + + async def create_tenant( + self, data: TenantCreate, *, owner_user_id: str, owner_email: str | None = None + ) -> Tenant: + if data.slug: + if await self.db.scalar(select(Tenant.id).where(Tenant.slug == data.slug)): + raise TenantError("slug_taken", status_code=409) + slug = data.slug + else: + slug = await self._free_slug(slugify(data.name)) + tenant = Tenant(name=data.name, slug=slug) + self.db.add(tenant) + try: + # A concurrent create can claim the slug between the check above + # and this insert: a 409, not a 500. The request fails as a whole, + # so its transaction is rolled back — no savepoint needed. + await self.db.flush() + except IntegrityError as exc: + raise TenantError("slug_taken", status_code=409) from exc + owner = Membership( + tenant_id=tenant.id, user_id=owner_user_id, role=MembershipRole.OWNER, email=owner_email + ) + self.db.add(owner) + await self.db.flush() + self._after_commit( + TenantCreated(tenant.id, tenant.slug, tenant.name, owner_user_id), + MembershipAdded(tenant.id, owner_user_id, MembershipRole.OWNER), + invalidate=[owner_user_id], + ) + return tenant + + async def set_status(self, tenant_id: str, status: TenantStatus) -> Tenant: + """Suspend or reactivate — the call a billing module makes on dunning.""" + tenant = await self._require(tenant_id) + previous = tenant.status + if previous != status: + tenant.status = status + await self.db.flush() + # Every member's cached view of this tenant is now wrong. + self._after_commit(TenantStatusChanged(tenant_id, status, previous), invalidate=[None]) + return tenant + + async def list_all(self, *, search: str = "", limit: int = 50, offset: int = 0) -> list[Tenant]: + stmt = select(Tenant).order_by(Tenant.created_at.desc()).limit(limit).offset(offset) + if search: + like = f"%{search.lower()}%" + stmt = stmt.where(func.lower(Tenant.name).like(like) | Tenant.slug.like(like)) + return list((await self.db.execute(stmt)).scalars().all()) + + async def member_counts(self, tenant_ids: Sequence[str]) -> dict[str, int]: + if not tenant_ids: + return {} + stmt = ( + select(Membership.tenant_id, func.count()) + .where(Membership.tenant_id.in_(tenant_ids)) + .group_by(Membership.tenant_id) + ) + return dict((await self.db.execute(stmt)).tuples().all()) + + async def lock(self, tenant_id: str) -> Tenant: + """Row-lock the tenant for the rest of the transaction. + + Serialises check-then-act rules per tenant — "at least one owner", + "within the seat limit" — so two concurrent requests cannot both pass + the check and together break the rule. On SQLite ``FOR UPDATE`` + compiles away; there the database's own writer lock serialises the + two transactions (see ``tests/test_owner_race.py``). + """ + stmt = select(Tenant).where(Tenant.id == tenant_id).with_for_update() + tenant = (await self.db.execute(stmt)).scalar_one_or_none() + if tenant is None: + raise TenantError("not_found", status_code=404) + return tenant + + async def _require(self, tenant_id: str) -> Tenant: + tenant = await self.get(tenant_id) + if tenant is None: + raise TenantError("not_found", status_code=404) + return tenant + + # ── memberships ───────────────────────────────────────────── + + async def list_for_user(self, user_id: str) -> list[tuple[Tenant, str]]: + stmt = ( + select(Tenant, Membership.role) + .join(Membership, Membership.tenant_id == Tenant.id) + .where(Membership.user_id == user_id) + .order_by(Membership.created_at, Tenant.name) + ) + return [(t, role) for t, role in (await self.db.execute(stmt)).all()] + + async def has_member_email(self, tenant_id: str, email: str) -> bool: + stmt = select(Membership.id).where( + Membership.tenant_id == tenant_id, func.lower(Membership.email) == email.lower() + ) + return await self.db.scalar(stmt) is not None + + async def get_membership(self, tenant_id: str, user_id: str) -> Membership | None: + stmt = select(Membership).where( + Membership.tenant_id == tenant_id, Membership.user_id == user_id + ) + return (await self.db.execute(stmt)).scalar_one_or_none() + + async def list_members(self, tenant_id: str) -> list[Membership]: + stmt = ( + select(Membership) + .where(Membership.tenant_id == tenant_id) + .order_by(Membership.created_at) + ) + return list((await self.db.execute(stmt)).scalars().all()) + + async def seats_used(self, tenant_id: str) -> int: + """Members plus pending invitations — an invite reserves a seat.""" + from tenants.invitations import pending_invitation_count + + members = await self.db.scalar( + select(func.count()).select_from(Membership).where(Membership.tenant_id == tenant_id) + ) + return int(members or 0) + await pending_invitation_count(self.db, tenant_id) + + async def ensure_seat_available(self, tenant_id: str) -> None: + await self.lock(tenant_id) + used = await self.seats_used(tenant_id) + await ensure_within_limit(self.entitlements, tenant_id, ENTITLEMENT_SEATS, used) + + async def add_member( + self, + tenant_id: str, + user_id: str, + role: MembershipRole, + *, + email: str | None = None, + seat_reserved: bool = False, + ) -> Membership: + """Add ``user_id``. ``seat_reserved``: the seat was held by an invitation.""" + await self._require(tenant_id) + existing = await self.get_membership(tenant_id, user_id) + if existing is not None: + return existing + if not seat_reserved: + await self.ensure_seat_available(tenant_id) + membership = Membership(tenant_id=tenant_id, user_id=user_id, role=role, email=email) + self.db.add(membership) + try: + await self.db.flush() # a concurrent join of the same user loses here + except IntegrityError as exc: + raise TenantError("already_member", status_code=409) from exc + self._after_commit(MembershipAdded(tenant_id, user_id, role), invalidate=[user_id]) + return membership + + async def _owner_count(self, tenant_id: str) -> int: + stmt = ( + select(func.count()) + .select_from(Membership) + .where(Membership.tenant_id == tenant_id, Membership.role == MembershipRole.OWNER) + ) + return int(await self.db.scalar(stmt) or 0) + + async def change_role( + self, tenant_id: str, user_id: str, role: MembershipRole, *, actor_role: str + ) -> Membership: + await self.lock(tenant_id) + membership = await self.get_membership(tenant_id, user_id) + if membership is None: + raise TenantError("member_not_found", status_code=404) + touches_owner = MembershipRole.OWNER in (role, membership.role) + if touches_owner and actor_role != MembershipRole.OWNER: + raise TenantError("owner_required", status_code=403) + demotes_owner = membership.role == MembershipRole.OWNER and role != MembershipRole.OWNER + if demotes_owner: + if not await demote_owner(self.db, membership, role): + raise TenantError("last_owner", status_code=409) + else: + membership.role = role + await self.db.flush() + self._after_commit(invalidate=[user_id]) + return membership + + async def remove_member(self, tenant_id: str, user_id: str, *, actor_role: str) -> None: + await self.lock(tenant_id) + membership = await self.get_membership(tenant_id, user_id) + if membership is None: + raise TenantError("member_not_found", status_code=404) + if membership.role == MembershipRole.OWNER: + if actor_role != MembershipRole.OWNER: + raise TenantError("owner_required", status_code=403) + if not await remove_owner(self.db, membership): + raise TenantError("last_owner", status_code=409) + else: + await self.db.delete(membership) + await self.db.flush() + self._after_commit(MembershipRemoved(tenant_id, user_id), invalidate=[user_id]) diff --git a/modules/tenants/tenants/services.py b/modules/tenants/tenants/services.py new file mode 100644 index 00000000..e2c551af --- /dev/null +++ b/modules/tenants/tenants/services.py @@ -0,0 +1,20 @@ +"""Module-scoped state container, stored as ``app.state.tenants``.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from tenants.contracts.entitlements import EntitlementProvider, UnlimitedEntitlements +from tenants.settings import TenantsSettings + + +@dataclass +class TenantsServices: + """Tenants module singletons. + + ``entitlements`` is the billing seam: a billing module assigns its own + :class:`EntitlementProvider` here during its ``on_startup``. + """ + + settings: TenantsSettings + entitlements: EntitlementProvider = field(default_factory=UnlimitedEntitlements) diff --git a/modules/tenants/tenants/settings.py b/modules/tenants/tenants/settings.py new file mode 100644 index 00000000..47e4a3d4 --- /dev/null +++ b/modules/tenants/tenants/settings.py @@ -0,0 +1,33 @@ +"""Tenants module settings (DB-backed, edited on the Settings screen).""" + +from __future__ import annotations + +from pydantic import Field +from simple_module_core.settings_base import DbBackedSettings + + +class TenantsSettings(DbBackedSettings): + """Configuration for the tenants module.""" + + allow_self_service: bool = Field( + default=True, + description="Let any signed-in user create an organisation. Off: platform admins only.", + ) + public_base_url: str = Field( + default="", + description=( + "Origin invitation links are built on, e.g. https://app.example.com. " + "Empty: links are root-relative and never taken from the request's Host header." + ), + ) + subdomain_base: str = Field( + default="", + description=( + "Resolve the tenant from the host: with 'example.com', acme.example.com " + "is the tenant whose slug is 'acme' — also for anonymous visitors on " + "public routes. Empty: off." + ), + ) + invitation_ttl_hours: int = Field( + default=72, ge=1, le=24 * 30, description="How long an invitation link stays valid." + ) diff --git a/modules/tenants/tenants/shared_props.py b/modules/tenants/tenants/shared_props.py new file mode 100644 index 00000000..2c0cd1dc --- /dev/null +++ b/modules/tenants/tenants/shared_props.py @@ -0,0 +1,29 @@ +"""The ``tenant`` shared Inertia prop: active tenant + the user's memberships. + +Providers are synchronous, so this reads the resolver's cache only — the +resolver has always populated it earlier in the same request. +""" + +from __future__ import annotations + +from typing import Any + +from starlette.requests import Request + +from tenants.resolver import cached_memberships + + +def tenant_shared_props(request: Request) -> dict[str, Any]: + user = getattr(request.state, "user", None) + if user is None: + return {} + memberships = cached_memberships(str(user.id)) or [] + active_id = getattr(request.state, "tenant_id", None) + active = next((m for m in memberships if m.id == active_id), None) + return { + "tenant": { + "active": active.model_dump(mode="json") if active else None, + "memberships": [m.model_dump(mode="json") for m in memberships], + "suspended": bool(getattr(request.state, "tenant_suspended", False)), + } + } diff --git a/modules/tenants/tests/conftest.py b/modules/tenants/tests/conftest.py new file mode 100644 index 00000000..adeebbbf --- /dev/null +++ b/modules/tenants/tests/conftest.py @@ -0,0 +1,59 @@ +"""Helpers: real users with their own signed-session clients.""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager + +import httpx +import pytest +from simple_module_test.session_cookie import forge_session_cookie +from sqlalchemy import select +from tenants.resolver import forget + + +@pytest.fixture(autouse=True) +def _fresh_membership_cache(): + forget(None) + yield + forget(None) + + +async def _make_user(app, email: str) -> str: + from users.models import Role, User, UserRole + + async with app.state.sm.db.session_factory() as session: + user = User( + id=uuid.uuid4(), + email=email, + hashed_password="x", + is_active=True, + is_superuser=False, + is_verified=True, + ) + session.add(user) + await session.flush() + role = (await session.execute(select(Role).where(Role.name == "user"))).scalar_one_or_none() + if role is not None: + session.add(UserRole(user_id=user.id, role_id=role.id)) + await session.commit() + return str(user.id) + + +@pytest.fixture +def user_client(app) -> Callable: + """``async with user_client("a@x.io") as (client, user_id): ...``""" + + @asynccontextmanager + async def factory(email: str) -> AsyncGenerator[tuple[httpx.AsyncClient, str], None]: + user_id = await _make_user(app, email) + cookie = forge_session_cookie(app.state.sm.settings.secret_key, {"user_id": user_id}) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + cookies={"session": cookie}, + ) as client: + yield client, user_id + + return factory diff --git a/modules/tenants/tests/test_boot.py b/modules/tenants/tests/test_boot.py new file mode 100644 index 00000000..8430fd72 --- /dev/null +++ b/modules/tenants/tests/test_boot.py @@ -0,0 +1,8 @@ +from __future__ import annotations + + +async def test_module_registers_resolver(app): + from tenants.resolver import resolve_tenant + + assert app.state.tenant_resolver is resolve_tenant + assert app.state.tenants.entitlements is not None diff --git a/modules/tenants/tests/test_hardening.py b/modules/tenants/tests/test_hardening.py new file mode 100644 index 00000000..06533fec --- /dev/null +++ b/modules/tenants/tests/test_hardening.py @@ -0,0 +1,146 @@ +"""Regressions from the tenancy QA pass (adversarial + browser).""" + +from __future__ import annotations + +import asyncio + +from simple_module_test.fixtures import SETUP_ADMIN_EMAIL +from tenants import resolver +from tenants.service import TenantService + + +async def _org(client, name: str) -> dict: + return (await client.post("/api/tenants/", json={"name": name})).json() + + +async def _invite(owner, member, email: str, role: str = "member") -> None: + token = ( + await owner.post("/api/tenants/current/invitations", json={"email": email, "role": role}) + ).json()["token"] + assert ( + await member.post("/api/tenants/invitations/accept", json={"token": token}) + ).status_code == 200 + + +async def test_invite_link_ignores_the_host_header(user_client): + async with user_client("o@x.io") as (owner, _): + await _org(owner, "Acme") + resp = await owner.post( + "/api/tenants/current/invitations", + json={"email": "n@x.io"}, + headers={"Host": "evil.example"}, + ) + assert resp.json()["accept_url"].startswith("/tenants/invitations/accept?token=") + + +async def test_invite_link_uses_the_configured_origin(app, user_client): + app.state.tenants.settings.public_base_url = "https://app.example.com/" + try: + async with user_client("o@x.io") as (owner, _): + await _org(owner, "Acme") + resp = await owner.post("/api/tenants/current/invitations", json={"email": "n@x.io"}) + assert resp.json()["accept_url"].startswith("https://app.example.com/tenants/") + finally: + app.state.tenants.settings.public_base_url = "" + + +async def test_platform_admin_who_is_a_plain_member_cannot_manage( + user_client, authenticated_client +): + async with user_client("o@x.io") as (owner, owner_id): + await _org(owner, "Acme") + # The platform admin (wildcard permissions) joins as a plain member. + token = ( + await owner.post("/api/tenants/current/invitations", json={"email": SETUP_ADMIN_EMAIL}) + ).json()["token"] + await authenticated_client.post("/api/tenants/invitations/accept", json={"token": token}) + resp = await authenticated_client.delete(f"/api/tenants/current/members/{owner_id}") + assert resp.status_code == 403 + assert resp.json()["detail"] == "tenant_manager_required" + + +async def test_cannot_accept_into_a_suspended_tenant(user_client, authenticated_client): + async with user_client("o@x.io") as (owner, _), user_client("n@x.io") as (new, _): + tenant = await _org(owner, "Late") + token = ( + await owner.post("/api/tenants/current/invitations", json={"email": "n@x.io"}) + ).json()["token"] + await authenticated_client.post(f"/api/tenants/admin/{tenant['id']}/suspend") + resp = await new.post("/api/tenants/invitations/accept", json={"token": token}) + assert resp.status_code == 409 + assert resp.json()["detail"] == "tenant_suspended" + + +async def test_cannot_invite_an_existing_member(user_client): + async with user_client("o@x.io") as (owner, _), user_client("m@x.io") as (member, _): + await _org(owner, "Acme") + await _invite(owner, member, "m@x.io") + resp = await owner.post("/api/tenants/current/invitations", json={"email": "M@x.io"}) + assert resp.status_code == 409 + assert resp.json()["detail"] == "already_member" + + +async def test_suspended_active_org_is_not_switched_away_silently( + user_client, authenticated_client +): + async with user_client("o@x.io") as (owner, _): + acme = await _org(owner, "Acme") + globex = await _org(owner, "Globex") + await owner.post(f"/api/tenants/{acme['id']}/switch") + await authenticated_client.post(f"/api/tenants/admin/{acme['id']}/suspend") + props = (await owner.get("/tenants/", headers={"X-Inertia": "true"})).json()["props"] + assert props["active_id"] == globex["id"] + assert props["suspended"] is True + assert props["suspended_name"] == "Acme" + + +async def test_inflight_read_does_not_recache_a_removed_member(app, monkeypatch): + """F5: a read that started before an invalidation must not store its result.""" + resolver.forget(None) + started, release = asyncio.Event(), asyncio.Event() + real = TenantService.list_for_user + + async def slow(self, user_id): + rows = await real(self, user_id) + started.set() + await release.wait() + return rows + + monkeypatch.setattr(TenantService, "list_for_user", slow) + read = asyncio.create_task(resolver.memberships_for(app, "u-1")) + await started.wait() + resolver.forget("u-1") # the membership changed while the read was in flight + release.set() + await read + assert resolver.cached_memberships("u-1") is None + + +async def test_slug_shape_is_enforced(user_client): + async with user_client("o@x.io") as (owner, _): + for bad in ["ABC", "-abc", "abc-", "a" * 51, "a/b", "ab c"]: + resp = await owner.post("/api/tenants/", json={"name": "X", "slug": bad}) + assert resp.status_code == 422, bad + ok = await owner.post("/api/tenants/", json={"name": "X", "slug": "a-b-1"}) + assert ok.status_code == 201 + + +async def test_concurrent_creates_with_one_slug_give_201_and_409(user_client): + async with user_client("a@x.io") as (a, _), user_client("b@x.io") as (b, _): + results = await asyncio.gather( + a.post("/api/tenants/", json={"name": "A", "slug": "dupe"}), + b.post("/api/tenants/", json={"name": "B", "slug": "dupe"}), + ) + assert sorted(r.status_code for r in results) == [201, 409] + + +async def test_concurrent_accepts_of_one_invitation_give_200_and_409(user_client): + async with user_client("o@x.io") as (owner, _), user_client("n@x.io") as (new, _): + await _org(owner, "Acme") + token = ( + await owner.post("/api/tenants/current/invitations", json={"email": "n@x.io"}) + ).json()["token"] + results = await asyncio.gather( + new.post("/api/tenants/invitations/accept", json={"token": token}), + new.post("/api/tenants/invitations/accept", json={"token": token}), + ) + assert sorted(r.status_code for r in results) == [200, 409] diff --git a/modules/tenants/tests/test_invitations.py b/modules/tenants/tests/test_invitations.py new file mode 100644 index 00000000..127263b3 --- /dev/null +++ b/modules/tenants/tests/test_invitations.py @@ -0,0 +1,100 @@ +"""Invitations: issue, accept (email-bound), revoke, seat entitlements.""" + +from __future__ import annotations + +from tenants.contracts.entitlements import UnlimitedEntitlements + + +class _Seats: + def __init__(self, n: int) -> None: + self.n = n + + async def limit(self, tenant_id: str, key: str) -> int | None: + return self.n if key == "tenants.seats" else None + + async def has_feature(self, tenant_id: str, key: str) -> bool: + return True + + +async def _setup(client) -> dict: + return (await client.post("/api/tenants/", json={"name": "Acme"})).json() + + +async def test_invite_and_accept(user_client): + async with user_client("owner@x.io") as (owner, _), user_client("new@x.io") as (new, new_id): + tenant = await _setup(owner) + issued = await owner.post( + "/api/tenants/current/invitations", json={"email": "New@X.io", "role": "admin"} + ) + assert issued.status_code == 201, issued.text + token = issued.json()["token"] + assert token in issued.json()["accept_url"] + + accepted = await new.post("/api/tenants/invitations/accept", json={"token": token}) + assert accepted.status_code == 200, accepted.text + assert accepted.json()["id"] == tenant["id"] + assert accepted.json()["role"] == "admin" + + members = (await owner.get("/api/tenants/current/members")).json() + assert {m["user_id"] for m in members} >= {new_id} + + again = await new.post("/api/tenants/invitations/accept", json={"token": token}) + assert again.status_code == 409 + + +async def test_invitation_is_bound_to_its_email(user_client): + async with user_client("owner@x.io") as (owner, _), user_client("thief@x.io") as (thief, _): + await _setup(owner) + token = ( + await owner.post("/api/tenants/current/invitations", json={"email": "friend@x.io"}) + ).json()["token"] + resp = await thief.post("/api/tenants/invitations/accept", json={"token": token}) + assert resp.status_code == 403 + + +async def test_invitations_cannot_grant_owner(user_client): + async with user_client("owner@x.io") as (owner, _): + await _setup(owner) + resp = await owner.post( + "/api/tenants/current/invitations", json={"email": "x@x.io", "role": "owner"} + ) + assert resp.status_code == 422 + + +async def test_plain_member_cannot_invite(user_client): + async with user_client("owner@x.io") as (owner, _), user_client("m@x.io") as (member, _): + await _setup(owner) + token = ( + await owner.post("/api/tenants/current/invitations", json={"email": "m@x.io"}) + ).json()["token"] + await member.post("/api/tenants/invitations/accept", json={"token": token}) + resp = await member.post("/api/tenants/current/invitations", json={"email": "z@x.io"}) + assert resp.status_code == 403 + + +async def test_seat_limit_blocks_invites(app, user_client): + app.state.tenants.entitlements = _Seats(2) + try: + async with user_client("owner@x.io") as (owner, _): + await _setup(owner) + ok = await owner.post("/api/tenants/current/invitations", json={"email": "a@x.io"}) + assert ok.status_code == 201 + # owner + one pending invitation = 2 seats used + full = await owner.post("/api/tenants/current/invitations", json={"email": "b@x.io"}) + assert full.status_code == 402 + assert full.json()["key"] == "tenants.seats" + finally: + app.state.tenants.entitlements = UnlimitedEntitlements() + + +async def test_revoked_invitation_cannot_be_accepted(user_client): + async with user_client("owner@x.io") as (owner, _), user_client("n@x.io") as (new, _): + await _setup(owner) + issued = ( + await owner.post("/api/tenants/current/invitations", json={"email": "n@x.io"}) + ).json() + assert ( + await owner.delete(f"/api/tenants/current/invitations/{issued['id']}") + ).status_code == 204 + resp = await new.post("/api/tenants/invitations/accept", json={"token": issued["token"]}) + assert resp.status_code == 404 diff --git a/modules/tenants/tests/test_isolation_e2e.py b/modules/tenants/tests/test_isolation_e2e.py new file mode 100644 index 00000000..d23ec97c --- /dev/null +++ b/modules/tenants/tests/test_isolation_e2e.py @@ -0,0 +1,127 @@ +"""End to end: resolver → TenantMiddleware → strict query filter.""" + +from __future__ import annotations + +import pytest +from fastapi import Depends, Request +from simple_module_db import MultiTenantMixin, create_module_base +from simple_module_db.deps import get_db +from sqlalchemy import select +from sqlmodel import Field +from tenants.contracts.events import MembershipAdded, TenantCreated + +_Base = create_module_base("tenantse2e") + + +class _Note(_Base, MultiTenantMixin, table=True): # ty: ignore[unsupported-base] + __tablename__ = "tenantse2e_note" + id: int | None = Field(default=None, primary_key=True) + body: str = Field(max_length=100) + + +@pytest.fixture +async def notes_app(app): + async with app.state.sm.db.engine.begin() as conn: + await conn.run_sync(_Base.metadata.create_all) + + async def add_note(payload: dict, db=Depends(get_db)): + db.add(_Note(body=payload["body"])) + await db.flush() + return {"ok": True} + + async def list_notes(request: Request, db=Depends(get_db)): + rows = (await db.execute(select(_Note))).scalars().all() + return [{"body": n.body, "tenant_id": n.tenant_id} for n in rows] + + app.add_api_route("/api/e2e/notes", add_note, methods=["POST"]) + app.add_api_route("/api/e2e/notes", list_notes, methods=["GET"]) + app.add_api_route("/e2e/notes", list_notes, methods=["GET"]) + return app + + +async def _org(client, name): + return (await client.post("/api/tenants/", json={"name": name})).json() + + +async def test_each_tenant_sees_only_its_rows(notes_app, user_client): + async with user_client("a@x.io") as (a, _), user_client("b@x.io") as (b, _): + ta = await _org(a, "Alpha") + tb = await _org(b, "Beta") + assert (await a.post("/api/e2e/notes", json={"body": "a1"})).status_code == 200 + assert (await b.post("/api/e2e/notes", json={"body": "b1"})).status_code == 200 + + assert (await a.get("/api/e2e/notes")).json() == [{"body": "a1", "tenant_id": ta["id"]}] + assert (await b.get("/api/e2e/notes")).json() == [{"body": "b1", "tenant_id": tb["id"]}] + + +async def test_switching_changes_the_visible_data(notes_app, user_client): + async with user_client("a@x.io") as (a, _): + one = await _org(a, "One") + await a.post("/api/e2e/notes", json={"body": "in-one"}) + await _org(a, "Two") # creating switches to it + assert (await a.get("/api/e2e/notes")).json() == [] + await a.post(f"/api/tenants/{one['id']}/switch") + assert [n["body"] for n in (await a.get("/api/e2e/notes")).json()] == ["in-one"] + + +async def test_no_tenant_fails_closed(notes_app, user_client): + async with user_client("nobody@x.io") as (client, _): + api = await client.get("/api/e2e/notes") + assert api.status_code == 403 + assert api.json()["detail"] == "tenant_required" + page = await client.get("/e2e/notes") + assert page.status_code == 303 + assert page.headers["location"].startswith("/tenants/?reason=tenant_required") + + +async def test_removed_member_loses_access_immediately(notes_app, user_client): + async with user_client("o@x.io") as (owner, _), user_client("m@x.io") as (member, member_id): + await _org(owner, "Acme") + token = ( + await owner.post("/api/tenants/current/invitations", json={"email": "m@x.io"}) + ).json()["token"] + await member.post("/api/tenants/invitations/accept", json={"token": token}) + await owner.post("/api/e2e/notes", json={"body": "secret"}) + assert len((await member.get("/api/e2e/notes")).json()) == 1 + + assert (await owner.delete(f"/api/tenants/current/members/{member_id}")).status_code == 204 + assert (await member.get("/api/e2e/notes")).status_code == 403 + + +async def test_suspended_tenant_is_not_resolved(notes_app, user_client, authenticated_client): + async with user_client("o@x.io") as (owner, _): + tenant = await _org(owner, "Late Payer") + assert (await owner.get("/api/e2e/notes")).status_code == 200 + + resp = await authenticated_client.post(f"/api/tenants/admin/{tenant['id']}/suspend") + assert resp.status_code == 200 and resp.json()["status"] == "suspended" + assert (await owner.get("/api/e2e/notes")).status_code == 403 + page = await owner.get("/tenants/", headers={"X-Inertia": "true"}) + assert page.json()["props"]["suspended"] is True + + await authenticated_client.post(f"/api/tenants/admin/{tenant['id']}/reactivate") + assert (await owner.get("/api/e2e/notes")).status_code == 200 + + +async def test_events_fire_after_commit(app, user_client): + seen: list = [] + + async def record(event): + seen.append(event) + + app.state.sm.event_bus.subscribe(TenantCreated, record) + app.state.sm.event_bus.subscribe(MembershipAdded, record) + async with user_client("o@x.io") as (owner, _): + tenant = await _org(owner, "Evented") + kinds = {(type(e).__name__, e.tenant_id) for e in seen} + assert ("TenantCreated", tenant["id"]) in kinds + assert ("MembershipAdded", tenant["id"]) in kinds + + +async def test_shared_prop_lists_memberships(user_client): + async with user_client("o@x.io") as (owner, _): + tenant = await _org(owner, "Shared") + page = await owner.get("/tenants/", headers={"X-Inertia": "true"}) + shared = page.json()["props"]["tenant"] + assert shared["active"]["id"] == tenant["id"] + assert [m["id"] for m in shared["memberships"]] == [tenant["id"]] diff --git a/modules/tenants/tests/test_owner_race.py b/modules/tenants/tests/test_owner_race.py new file mode 100644 index 00000000..9a3092a3 --- /dev/null +++ b/modules/tenants/tests/test_owner_race.py @@ -0,0 +1,73 @@ +"""Two owners demoting each other at once must leave one owner. + +Runs on a file-backed SQLite database with the real connection pool, so the +two sessions really are two connections — and on Postgres too when +``SM_TEST_DATABASE_URL`` points at one. The ``app`` fixture's in-memory +database shares one connection between sessions, which cannot model two +concurrent transactions at all. +""" + +from __future__ import annotations + +import asyncio + +import pytest +from simple_module_db.listeners import register_listeners +from simple_module_db.session import init_db +from simple_module_test.database import ( + database_url_for_tests, + init_db_kwargs, + is_sqlite, + reset_schema, +) +from tenants.constants import MembershipRole +from tenants.contracts.schemas import TenantCreate +from tenants.errors import TenantError +from tenants.models import Base +from tenants.service import TenantService + + +def _backends() -> list[str]: + backends = ["sqlite-file"] + if not is_sqlite(database_url_for_tests()): + backends.append("postgres") + return backends + + +@pytest.mark.parametrize("backend", _backends()) +async def test_concurrent_demotion_of_the_two_owners_keeps_one(tmp_path, backend: str): + if backend == "postgres": + url = database_url_for_tests() + state = init_db(url, **init_db_kwargs(url)) + await reset_schema(state.engine) + else: + state = init_db(f"sqlite+aiosqlite:///{tmp_path}/race.db") + register_listeners(state) + try: + async with state.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with state.session_factory() as db: + service = TenantService(db) + tenant = await service.create_tenant(TenantCreate(name="Race"), owner_user_id="a") + await service.add_member(tenant.id, "b", MembershipRole.OWNER, seat_reserved=True) + await db.commit() + + async def demote(user_id: str) -> str: + async with state.session_factory() as db: + try: + await TenantService(db).change_role( + tenant.id, user_id, MembershipRole.ADMIN, actor_role=MembershipRole.OWNER + ) + await db.commit() + return "ok" + except TenantError as exc: + await db.rollback() + return exc.code + + outcomes = await asyncio.gather(demote("a"), demote("b")) + async with state.session_factory() as db: + owners = await TenantService(db)._owner_count(tenant.id) + assert sorted(outcomes) == ["last_owner", "ok"] + assert owners == 1 + finally: + await state.engine.dispose() diff --git a/modules/tenants/tests/test_subdomains.py b/modules/tenants/tests/test_subdomains.py new file mode 100644 index 00000000..0eb870b4 --- /dev/null +++ b/modules/tenants/tests/test_subdomains.py @@ -0,0 +1,95 @@ +"""#363: tenant from the subdomain — anonymous visitors, members, non-members.""" + +from __future__ import annotations + +import httpx +import pytest +from fastapi import Depends +from simple_module_db import MultiTenantMixin, create_module_base, tenant_context +from simple_module_db.deps import get_db +from sqlalchemy import select +from sqlmodel import Field +from tenants.host_resolver import forget_hosts + +_Base = create_module_base("subdom") + + +class _Page(_Base, MultiTenantMixin, table=True): # ty: ignore[unsupported-base] + __tablename__ = "subdom_page" + id: int | None = Field(default=None, primary_key=True) + title: str = Field(max_length=50) + + +@pytest.fixture +async def site(app): + app.state.tenants.settings.subdomain_base = "example.com" + forget_hosts() + async with app.state.sm.db.engine.begin() as conn: + await conn.run_sync(_Base.metadata.create_all) + + async def titles(db=Depends(get_db)): + return (await db.execute(select(_Page.title))).scalars().all() + + app.add_api_route("/public/pages", titles, methods=["GET"]) + app.add_api_route("/api/private/pages", titles, methods=["GET"]) + app.state.public_routes.add_prefix("/public/", methods={"GET"}) + yield app + app.state.tenants.settings.subdomain_base = "" + forget_hosts() + + +async def _org_with_page(client, slug: str, app) -> dict: + tenant = (await client.post("/api/tenants/", json={"name": slug, "slug": slug})).json() + async with app.state.sm.db.session_factory() as db: + with tenant_context(tenant["id"]): + db.add(_Page(title=f"{slug}-home")) + await db.commit() + return tenant + + +def _anon(app, host: str) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url=f"http://{host}") + + +async def test_anonymous_visitor_sees_the_subdomain_tenant(site, user_client): + async with user_client("o@x.io") as (owner, _): + await _org_with_page(owner, "acme", site) + await _org_with_page(owner, "globex", site) + async with _anon(site, "acme.example.com") as anon: + assert (await anon.get("/public/pages")).json() == ["acme-home"] + async with _anon(site, "globex.example.com") as anon: + assert (await anon.get("/public/pages")).json() == ["globex-home"] + + +async def test_unknown_subdomain_binds_nothing(site, user_client): + async with user_client("o@x.io") as (owner, _): + await _org_with_page(owner, "acme", site) + async with _anon(site, "nope.example.com") as anon: + resp = await anon.get("/public/pages") + assert resp.status_code == 403 # strict: no tenant -> tenant_required + + +async def test_suspended_subdomain_binds_nothing(site, user_client, authenticated_client): + async with user_client("o@x.io") as (owner, _): + tenant = await _org_with_page(owner, "acme", site) + await authenticated_client.post(f"/api/tenants/admin/{tenant['id']}/suspend") + async with _anon(site, "acme.example.com") as anon: + assert (await anon.get("/public/pages")).status_code == 403 + + +async def test_non_member_is_bound_only_on_public_routes(site, user_client): + async with user_client("o@x.io") as (owner, _), user_client("n@x.io") as (outsider, _): + await _org_with_page(owner, "acme", site) + outsider.base_url = httpx.URL("http://acme.example.com") + assert (await outsider.get("/public/pages")).json() == ["acme-home"] + assert (await outsider.get("/api/private/pages")).status_code == 403 + + +async def test_member_on_their_subdomain_gets_their_role(site, user_client): + async with user_client("o@x.io") as (owner, _): + await _org_with_page(owner, "acme", site) + await _org_with_page(owner, "globex", site) # active in the session now + owner.base_url = httpx.URL("http://acme.example.com") + assert (await owner.get("/api/private/pages")).json() == ["acme-home"] + members = await owner.get("/api/tenants/current/members") + assert members.status_code == 200 and members.json()[0]["role"] == "owner" diff --git a/modules/tenants/tests/test_tenants_api.py b/modules/tenants/tests/test_tenants_api.py new file mode 100644 index 00000000..1e44500c --- /dev/null +++ b/modules/tenants/tests/test_tenants_api.py @@ -0,0 +1,112 @@ +"""Tenants, switching and membership management through the HTTP API.""" + +from __future__ import annotations + + +async def _create(client, name: str) -> dict: + resp = await client.post("/api/tenants/", json={"name": name}) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def test_creator_becomes_owner_and_active(user_client): + async with user_client("owner@acme.io") as (client, user_id): + tenant = await _create(client, "Acme Inc") + assert tenant["role"] == "owner" + assert tenant["slug"] == "acme-inc" + + members = (await client.get("/api/tenants/current/members")).json() + assert [(m["user_id"], m["role"], m["email"]) for m in members] == [ + (user_id, "owner", "owner@acme.io") + ] + + +async def test_slug_collision_gets_suffix(user_client): + async with user_client("a@x.io") as (a, _), user_client("b@x.io") as (b, _): + first = await _create(a, "Same Name") + second = await _create(b, "Same Name") + assert first["slug"] == "same-name" + assert second["slug"].startswith("same-name-") + + +async def test_user_sees_only_their_tenants(user_client): + async with user_client("a@x.io") as (a, _), user_client("b@x.io") as (b, _): + await _create(a, "Alpha") + await _create(b, "Beta") + names = [t["name"] for t in (await a.get("/api/tenants/")).json()] + assert names == ["Alpha"] + + +async def test_cannot_switch_into_foreign_tenant(user_client): + async with user_client("a@x.io") as (a, _), user_client("b@x.io") as (b, _): + foreign = await _create(b, "Beta") + resp = await a.post(f"/api/tenants/{foreign['id']}/switch") + assert resp.status_code == 404 + + +async def test_switch_between_own_tenants(user_client): + async with user_client("a@x.io") as (a, _): + one = await _create(a, "One") + two = await _create(a, "Two") + assert (await a.post(f"/api/tenants/{one['id']}/switch")).status_code == 204 + page = await a.get("/tenants/", headers={"X-Inertia": "true"}) + assert page.json()["props"]["active_id"] == one["id"] + assert (await a.post(f"/api/tenants/{two['id']}/switch")).status_code == 204 + page = await a.get("/tenants/", headers={"X-Inertia": "true"}) + assert page.json()["props"]["active_id"] == two["id"] + + +async def test_member_without_tenant_gets_403_on_tenant_api(user_client): + async with user_client("lonely@x.io") as (client, _): + resp = await client.get("/api/tenants/current/members") + assert resp.status_code == 403 + + +async def test_last_owner_cannot_leave_or_be_demoted(user_client): + async with user_client("solo@x.io") as (client, user_id): + await _create(client, "Solo") + assert (await client.delete("/api/tenants/current/membership")).status_code == 409 + resp = await client.patch(f"/api/tenants/current/members/{user_id}", json={"role": "admin"}) + assert resp.status_code == 409 + + +async def test_tenant_admin_is_not_platform_admin(user_client): + async with user_client("owner@x.io") as (client, _): + await _create(client, "Mine") + assert (await client.get("/api/tenants/admin/")).status_code == 403 + assert (await client.get("/admin/tenants/")).status_code in (302, 303, 403) + + +async def test_self_service_can_be_disabled(app, user_client): + app.state.tenants.settings.allow_self_service = False + try: + async with user_client("a@x.io") as (client, _): + resp = await client.post("/api/tenants/", json={"name": "Nope"}) + assert resp.status_code == 403 + finally: + app.state.tenants.settings.allow_self_service = True + + +async def test_header_selects_a_tenant_the_user_belongs_to(user_client): + # The test settings configure tenant_header="X-Tenant-ID". + async with user_client("a@x.io") as (a, _), user_client("b@x.io") as (b, _): + one = await _create(a, "One") + two = await _create(a, "Two") # active in the session now + foreign = await _create(b, "Foreign") + + page = await a.get("/tenants/", headers={"X-Inertia": "true", "X-Tenant-ID": one["id"]}) + assert page.json()["props"]["active_id"] == one["id"] + # The header is per request: the session choice is untouched. + page = await a.get("/tenants/", headers={"X-Inertia": "true"}) + assert page.json()["props"]["active_id"] == two["id"] + + # A tenant the user is not in resolves to nothing — no silent fallback. + resp = await a.get("/api/tenants/current/members", headers={"X-Tenant-ID": foreign["id"]}) + assert resp.status_code == 403 + + +async def test_malformed_header_resolves_to_nothing(user_client): + async with user_client("a@x.io") as (a, _): + await _create(a, "One") + resp = await a.get("/api/tenants/current/members", headers={"X-Tenant-ID": "x" * 60}) + assert resp.status_code == 403 diff --git a/modules/tenants/tsconfig.json b/modules/tenants/tsconfig.json new file mode 100644 index 00000000..cd85ecee --- /dev/null +++ b/modules/tenants/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@simple-module-py/tsconfig/base.json", + "compilerOptions": { + "paths": { + "@/*": ["./tenants/*"], + "@simple-module-py/ui/*": ["../../packages/ui/src/*"] + } + }, + "include": ["tenants/**/*.ts", "tenants/**/*.tsx"] +} diff --git a/modules/users/tests/test_user_role_model.py b/modules/users/tests/test_user_role_model.py index fd10f517..0d427ebe 100644 --- a/modules/users/tests/test_user_role_model.py +++ b/modules/users/tests/test_user_role_model.py @@ -47,7 +47,11 @@ async def test_user_role_composite_pk(db_session): role = Role(id=role_id, name="testrole") link = UserRole(user_id=user_id, role_id=role_id) - db_session.add_all([user, role, link]) + # No relationship() links these, so the unit of work does not order the + # inserts by foreign key; flush the parents first (Postgres enforces it). + db_session.add_all([user, role]) + await db_session.flush() + db_session.add(link) await db_session.commit() result = await db_session.execute( @@ -80,11 +84,16 @@ async def test_fk_cascade_delete_user_removes_user_role(db_session): role = Role(id=role_id, name="cascade_role") link = UserRole(user_id=user_id, role_id=role_id) - db_session.add_all([user, role, link]) + # No relationship() links these, so the unit of work does not order the + # inserts by foreign key; flush the parents first (Postgres enforces it). + db_session.add_all([user, role]) + await db_session.flush() + db_session.add(link) await db_session.commit() - # Enable FK enforcement for SQLite (it's off by default) - await db_session.execute(text("PRAGMA foreign_keys=ON")) + # Enable FK enforcement for SQLite (it's off by default; Postgres always enforces) + if db_session.bind.dialect.name == "sqlite": + await db_session.execute(text("PRAGMA foreign_keys=ON")) await db_session.delete(user) await db_session.commit() diff --git a/package-lock.json b/package-lock.json index 8635b928..b9f3df4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -221,6 +221,23 @@ "react-dom": "^19.0.0" } }, + "modules/tenants": { + "name": "@simple-module-py/tenants", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^1.43.0", + "sonner": "^2.0.8" + }, + "devDependencies": { + "@simple-module-py/tsconfig": "*" + }, + "peerDependencies": { + "@inertiajs/react": "^3.7.0", + "@simple-module-py/ui": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "modules/users": { "name": "@simple-module-py/users", "version": "0.1.0", @@ -2870,6 +2887,10 @@ "resolved": "modules/settings", "link": true }, + "node_modules/@simple-module-py/tenants": { + "resolved": "modules/tenants", + "link": true + }, "node_modules/@simple-module-py/tsconfig": { "resolved": "packages/tsconfig", "link": true diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 9e041912..d170d116 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -687,6 +687,127 @@ export default { 'settings.value_types_short.int': '', 'settings.value_types_short.json': '', 'settings.value_types_short.string': '', + 'tenants.accept.accept_button': '', + 'tenants.accept.accepted_description': '', + 'tenants.accept.accepted_title': '', + 'tenants.accept.accepting': '', + 'tenants.accept.description': '', + 'tenants.accept.expired_description': '', + 'tenants.accept.expired_title': '', + 'tenants.accept.head_title': '', + 'tenants.accept.invalid_description': '', + 'tenants.accept.invalid_title': '', + 'tenants.accept.mismatch_description': '', + 'tenants.accept.mismatch_title': '', + 'tenants.accept.title': '', + 'tenants.accept.toast_accepted': '', + 'tenants.admin.description': '', + 'tenants.admin.empty_description': '', + 'tenants.admin.empty_title': '', + 'tenants.admin.head_title': '', + 'tenants.admin.next': '', + 'tenants.admin.previous': '', + 'tenants.admin.reactivate_button': '', + 'tenants.admin.search_placeholder': '', + 'tenants.admin.suspend_button': '', + 'tenants.admin.table_actions': '', + 'tenants.admin.table_created': '', + 'tenants.admin.table_members': '', + 'tenants.admin.table_name': '', + 'tenants.admin.table_slug': '', + 'tenants.admin.table_status': '', + 'tenants.admin.title': '', + 'tenants.admin.toast_action_failed': '', + 'tenants.admin.toast_reactivated': '', + 'tenants.admin.toast_suspended': '', + 'tenants.errors.already_invited': '', + 'tenants.errors.already_member': '', + 'tenants.errors.generic': '', + 'tenants.errors.invitation_email_mismatch': '', + 'tenants.errors.invitation_expired': '', + 'tenants.errors.invitation_not_found': '', + 'tenants.errors.invitation_used': '', + 'tenants.errors.last_owner': '', + 'tenants.errors.member_not_found': '', + 'tenants.errors.not_found': '', + 'tenants.errors.owner_required': '', + 'tenants.errors.plan_limit': '', + 'tenants.errors.self_service_disabled': '', + 'tenants.errors.slug_taken': '', + 'tenants.errors.tenant_isolation': '', + 'tenants.errors.tenant_manager_required': '', + 'tenants.errors.tenant_suspended': '', + 'tenants.errors.validation_failed': '', + 'tenants.index.active_badge': '', + 'tenants.index.create_name_label': '', + 'tenants.index.create_name_placeholder': '', + 'tenants.index.create_slug_hint': '', + 'tenants.index.create_slug_label': '', + 'tenants.index.create_slug_placeholder': '', + 'tenants.index.create_submit': '', + 'tenants.index.create_submitting': '', + 'tenants.index.create_title': '', + 'tenants.index.description': '', + 'tenants.index.empty_description': '', + 'tenants.index.empty_title': '', + 'tenants.index.head_title': '', + 'tenants.index.members_link': '', + 'tenants.index.notice_suspended': '', + 'tenants.index.notice_suspended_switched': '', + 'tenants.index.notice_tenant_required': '', + 'tenants.index.switch_button': '', + 'tenants.index.title': '', + 'tenants.index.toast_created': '', + 'tenants.index.toast_switch_failed': '', + 'tenants.index.toast_switched': '', + 'tenants.members.description': '', + 'tenants.members.head_title': '', + 'tenants.members.invite_email_label': '', + 'tenants.members.invite_email_placeholder': '', + 'tenants.members.invite_link_copy': '', + 'tenants.members.invite_link_description': '', + 'tenants.members.invite_link_title': '', + 'tenants.members.invite_role_label': '', + 'tenants.members.invite_submit': '', + 'tenants.members.invite_submitting': '', + 'tenants.members.invite_title': '', + 'tenants.members.leave_button': '', + 'tenants.members.leave_confirm_cancel': '', + 'tenants.members.leave_confirm_confirm': '', + 'tenants.members.leave_confirm_description': '', + 'tenants.members.leave_confirm_title': '', + 'tenants.members.pending_empty': '', + 'tenants.members.pending_expires': '', + 'tenants.members.pending_title': '', + 'tenants.members.remove_button': '', + 'tenants.members.remove_confirm_cancel': '', + 'tenants.members.remove_confirm_confirm': '', + 'tenants.members.remove_confirm_description': '', + 'tenants.members.remove_confirm_title': '', + 'tenants.members.revoke_button': '', + 'tenants.members.seats_unlimited': '', + 'tenants.members.seats_used': '', + 'tenants.members.table_actions': '', + 'tenants.members.table_email': '', + 'tenants.members.table_joined': '', + 'tenants.members.table_role': '', + 'tenants.members.title': '', + 'tenants.members.toast_copy_failed': '', + 'tenants.members.toast_invited': '', + 'tenants.members.toast_left': '', + 'tenants.members.toast_link_copied': '', + 'tenants.members.toast_removed': '', + 'tenants.members.toast_revoked': '', + 'tenants.members.toast_role_updated': '', + 'tenants.members.you_suffix': '', + 'tenants.nav.members': '', + 'tenants.nav.organisations': '', + 'tenants.nav.tenants': '', + 'tenants.roles.admin': '', + 'tenants.roles.member': '', + 'tenants.roles.owner': '', + 'tenants.status.active': '', + 'tenants.status.suspended': '', 'ui.admin.back_to_app': '', 'ui.admin.panel_badge': '', 'ui.command_palette.description': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 12d5b801..f9c2a15a 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -892,6 +892,145 @@ export const keys = { string: 'settings.value_types_short.string', }, }, + tenants: { + accept: { + accept_button: 'tenants.accept.accept_button', + accepted_description: 'tenants.accept.accepted_description', + accepted_title: 'tenants.accept.accepted_title', + accepting: 'tenants.accept.accepting', + description: 'tenants.accept.description', + expired_description: 'tenants.accept.expired_description', + expired_title: 'tenants.accept.expired_title', + head_title: 'tenants.accept.head_title', + invalid_description: 'tenants.accept.invalid_description', + invalid_title: 'tenants.accept.invalid_title', + mismatch_description: 'tenants.accept.mismatch_description', + mismatch_title: 'tenants.accept.mismatch_title', + title: 'tenants.accept.title', + toast_accepted: 'tenants.accept.toast_accepted', + }, + admin: { + description: 'tenants.admin.description', + empty_description: 'tenants.admin.empty_description', + empty_title: 'tenants.admin.empty_title', + head_title: 'tenants.admin.head_title', + next: 'tenants.admin.next', + previous: 'tenants.admin.previous', + reactivate_button: 'tenants.admin.reactivate_button', + search_placeholder: 'tenants.admin.search_placeholder', + suspend_button: 'tenants.admin.suspend_button', + table_actions: 'tenants.admin.table_actions', + table_created: 'tenants.admin.table_created', + table_members: 'tenants.admin.table_members', + table_name: 'tenants.admin.table_name', + table_slug: 'tenants.admin.table_slug', + table_status: 'tenants.admin.table_status', + title: 'tenants.admin.title', + toast_action_failed: 'tenants.admin.toast_action_failed', + toast_reactivated: 'tenants.admin.toast_reactivated', + toast_suspended: 'tenants.admin.toast_suspended', + }, + errors: { + already_invited: 'tenants.errors.already_invited', + already_member: 'tenants.errors.already_member', + generic: 'tenants.errors.generic', + invitation_email_mismatch: 'tenants.errors.invitation_email_mismatch', + invitation_expired: 'tenants.errors.invitation_expired', + invitation_not_found: 'tenants.errors.invitation_not_found', + invitation_used: 'tenants.errors.invitation_used', + last_owner: 'tenants.errors.last_owner', + member_not_found: 'tenants.errors.member_not_found', + not_found: 'tenants.errors.not_found', + owner_required: 'tenants.errors.owner_required', + plan_limit: 'tenants.errors.plan_limit', + self_service_disabled: 'tenants.errors.self_service_disabled', + slug_taken: 'tenants.errors.slug_taken', + tenant_isolation: 'tenants.errors.tenant_isolation', + tenant_manager_required: 'tenants.errors.tenant_manager_required', + tenant_suspended: 'tenants.errors.tenant_suspended', + validation_failed: 'tenants.errors.validation_failed', + }, + index: { + active_badge: 'tenants.index.active_badge', + create_name_label: 'tenants.index.create_name_label', + create_name_placeholder: 'tenants.index.create_name_placeholder', + create_slug_hint: 'tenants.index.create_slug_hint', + create_slug_label: 'tenants.index.create_slug_label', + create_slug_placeholder: 'tenants.index.create_slug_placeholder', + create_submit: 'tenants.index.create_submit', + create_submitting: 'tenants.index.create_submitting', + create_title: 'tenants.index.create_title', + description: 'tenants.index.description', + empty_description: 'tenants.index.empty_description', + empty_title: 'tenants.index.empty_title', + head_title: 'tenants.index.head_title', + members_link: 'tenants.index.members_link', + notice_suspended: 'tenants.index.notice_suspended', + notice_suspended_switched: 'tenants.index.notice_suspended_switched', + notice_tenant_required: 'tenants.index.notice_tenant_required', + switch_button: 'tenants.index.switch_button', + title: 'tenants.index.title', + toast_created: 'tenants.index.toast_created', + toast_switch_failed: 'tenants.index.toast_switch_failed', + toast_switched: 'tenants.index.toast_switched', + }, + members: { + description: 'tenants.members.description', + head_title: 'tenants.members.head_title', + invite_email_label: 'tenants.members.invite_email_label', + invite_email_placeholder: 'tenants.members.invite_email_placeholder', + invite_link_copy: 'tenants.members.invite_link_copy', + invite_link_description: 'tenants.members.invite_link_description', + invite_link_title: 'tenants.members.invite_link_title', + invite_role_label: 'tenants.members.invite_role_label', + invite_submit: 'tenants.members.invite_submit', + invite_submitting: 'tenants.members.invite_submitting', + invite_title: 'tenants.members.invite_title', + leave_button: 'tenants.members.leave_button', + leave_confirm_cancel: 'tenants.members.leave_confirm_cancel', + leave_confirm_confirm: 'tenants.members.leave_confirm_confirm', + leave_confirm_description: 'tenants.members.leave_confirm_description', + leave_confirm_title: 'tenants.members.leave_confirm_title', + pending_empty: 'tenants.members.pending_empty', + pending_expires: 'tenants.members.pending_expires', + pending_title: 'tenants.members.pending_title', + remove_button: 'tenants.members.remove_button', + remove_confirm_cancel: 'tenants.members.remove_confirm_cancel', + remove_confirm_confirm: 'tenants.members.remove_confirm_confirm', + remove_confirm_description: 'tenants.members.remove_confirm_description', + remove_confirm_title: 'tenants.members.remove_confirm_title', + revoke_button: 'tenants.members.revoke_button', + seats_unlimited: 'tenants.members.seats_unlimited', + seats_used: 'tenants.members.seats_used', + table_actions: 'tenants.members.table_actions', + table_email: 'tenants.members.table_email', + table_joined: 'tenants.members.table_joined', + table_role: 'tenants.members.table_role', + title: 'tenants.members.title', + toast_copy_failed: 'tenants.members.toast_copy_failed', + toast_invited: 'tenants.members.toast_invited', + toast_left: 'tenants.members.toast_left', + toast_link_copied: 'tenants.members.toast_link_copied', + toast_removed: 'tenants.members.toast_removed', + toast_revoked: 'tenants.members.toast_revoked', + toast_role_updated: 'tenants.members.toast_role_updated', + you_suffix: 'tenants.members.you_suffix', + }, + nav: { + members: 'tenants.nav.members', + organisations: 'tenants.nav.organisations', + tenants: 'tenants.nav.tenants', + }, + roles: { + admin: 'tenants.roles.admin', + member: 'tenants.roles.member', + owner: 'tenants.roles.owner', + }, + status: { + active: 'tenants.status.active', + suspended: 'tenants.status.suspended', + }, + }, ui: { admin: { back_to_app: 'ui.admin.back_to_app', diff --git a/pyproject.toml b/pyproject.toml index c874d746..6b0157ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ extra-paths = [ "modules/audit_log", "modules/site_lock", "modules/branding", + "modules/tenants", "host", "scripts", ] @@ -117,7 +118,7 @@ invalid-assignment = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/inertia/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "modules/keycloak/tests", "modules/audit_log/tests", "modules/branding/tests", "modules/site_lock/tests", "scripts/tests", "tests"] +testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/inertia/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "modules/keycloak/tests", "modules/audit_log/tests", "modules/branding/tests", "modules/site_lock/tests", "modules/tenants/tests", "scripts/tests", "tests"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", diff --git a/scripts/run_worker.py b/scripts/run_worker.py index 8db0c5c6..7e4f58a2 100644 --- a/scripts/run_worker.py +++ b/scripts/run_worker.py @@ -33,6 +33,16 @@ from background_tasks.celery_app import build_celery # noqa: E402 from background_tasks.settings import BackgroundTasksSettings # noqa: E402 +from background_tasks.sync_db import set_database_url # noqa: E402 +from background_tasks.tenant_context import set_default_tenant # noqa: E402 +from simple_module_hosting import merge_host_settings # noqa: E402 + +# The worker never builds the app, so resolve the host settings the same way +# host/main.py does (env → DB → default): task bodies must hit the same +# database with the same fail-closed tenant rules as request code. +_host = merge_host_settings() +set_database_url(_host.database_url, tenant_strict=_host.multi_tenant) +set_default_tenant(None if _host.multi_tenant else _host.default_tenant or None) # Module-level name ``celery`` is what ``celery -A scripts.run_worker:celery`` # looks for. Keep it stable.