From f8b4f963be18db71688f9be60bd03c4d322e7db8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:29:07 +0000 Subject: [PATCH 01/13] feat(tenancy): fail-closed isolation and the tenants module Framework: - Strict tenant isolation: with multi_tenant on, a query, ORM bulk update/delete or insert on a MultiTenantMixin model with no tenant context raises TenantIsolationError instead of spanning tenants. ORM update()/delete() are now tenant-scoped (they were not). - tenant_context() / all_tenants() and the all_tenants=True execution option for acting as, or deliberately across, tenants. - TenantMiddleware consults app.state.tenant_resolver; the tenant header is no longer honoured for an authenticated user without a tenant. - background_tasks carries the enqueuing request's tenant into tasks. - Doctor check SM024: unique keys on tenant tables must include tenant_id. tenants module: tenants, many-to-many memberships with per-tenant roles (tenant: on the active tenant only), email-bound invitations, suspend/reactivate, membership-validated resolver, and the billing seams (EntitlementProvider, lifecycle, after-commit events). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- .github/workflows/release.yml | 1 + CHANGELOG.md | 31 +++ CLAUDE.md | 4 +- docs/.vitepress/config.ts | 2 + docs/framework-conventions.md | 2 +- docs/framework/multi-tenancy.md | 62 +++++ docs/modules/index.md | 3 +- docs/modules/tenants.md | 77 ++++++ docs/plans/2026-09-27-saas-tenancy-design.md | 89 ++++++ docs/reference/diagnostic-codes.md | 1 + .../simple_module_core/diagnostics/_module.py | 3 + .../diagnostics/_tenancy.py | 108 ++++++++ .../core/tests/test_diagnostics_tenancy.py | 115 ++++++++ framework/db/simple_module_db/__init__.py | 11 +- framework/db/simple_module_db/listeners.py | 71 ++--- framework/db/simple_module_db/query_filter.py | 86 ++++++ framework/db/simple_module_db/session.py | 4 + framework/db/simple_module_db/tenancy.py | 94 +++++++ framework/db/tests/test_tenant_strict.py | 106 ++++++++ .../hosting/simple_module_hosting/_tenant.py | 88 ++++++ .../simple_module_hosting/app_builder.py | 3 + .../simple_module_hosting/middleware.py | 60 +---- .../hosting/tests/test_tenant_middleware.py | 45 +++- .../e36ab09c4a92_tenants_initial_schema.py | 112 ++++++++ host/pyproject.toml | 2 + .../background_tasks/signals.py | 4 + .../background_tasks/tenant_context.py | 72 +++++ .../tests/test_tenant_context.py | 58 ++++ modules/tenants/LICENSE | 19 ++ modules/tenants/README.md | 67 +++++ modules/tenants/package.json | 19 ++ modules/tenants/pyproject.toml | 51 ++++ modules/tenants/tenants/__init__.py | 1 + modules/tenants/tenants/constants.py | 61 +++++ modules/tenants/tenants/contracts/__init__.py | 15 ++ .../tenants/tenants/contracts/entitlements.py | 53 ++++ modules/tenants/tenants/contracts/events.py | 53 ++++ modules/tenants/tenants/contracts/schemas.py | 103 +++++++ modules/tenants/tenants/deps.py | 65 +++++ modules/tenants/tenants/endpoints/__init__.py | 0 modules/tenants/tenants/endpoints/admin.py | 64 +++++ modules/tenants/tenants/endpoints/api.py | 165 ++++++++++++ modules/tenants/tenants/endpoints/views.py | 112 ++++++++ modules/tenants/tenants/errors.py | 64 +++++ modules/tenants/tenants/invitations.py | 135 ++++++++++ modules/tenants/tenants/models.py | 85 ++++++ modules/tenants/tenants/module.py | 141 ++++++++++ modules/tenants/tenants/py.typed | 0 modules/tenants/tenants/resolver.py | 171 ++++++++++++ modules/tenants/tenants/service.py | 253 ++++++++++++++++++ modules/tenants/tenants/services.py | 20 ++ modules/tenants/tenants/settings.py | 18 ++ modules/tenants/tenants/shared_props.py | 29 ++ modules/tenants/tests/conftest.py | 59 ++++ modules/tenants/tests/test_boot.py | 8 + modules/tenants/tests/test_invitations.py | 100 +++++++ modules/tenants/tests/test_isolation_e2e.py | 127 +++++++++ modules/tenants/tests/test_tenants_api.py | 105 ++++++++ modules/tenants/tsconfig.json | 11 + pyproject.toml | 3 +- 60 files changed, 3369 insertions(+), 122 deletions(-) create mode 100644 docs/framework/multi-tenancy.md create mode 100644 docs/modules/tenants.md create mode 100644 docs/plans/2026-09-27-saas-tenancy-design.md create mode 100644 framework/core/simple_module_core/diagnostics/_tenancy.py create mode 100644 framework/core/tests/test_diagnostics_tenancy.py create mode 100644 framework/db/simple_module_db/query_filter.py create mode 100644 framework/db/simple_module_db/tenancy.py create mode 100644 framework/db/tests/test_tenant_strict.py create mode 100644 framework/hosting/simple_module_hosting/_tenant.py create mode 100644 host/migrations/versions/e36ab09c4a92_tenants_initial_schema.py create mode 100644 modules/background_tasks/background_tasks/tenant_context.py create mode 100644 modules/background_tasks/tests/test_tenant_context.py create mode 100644 modules/tenants/LICENSE create mode 100644 modules/tenants/README.md create mode 100644 modules/tenants/package.json create mode 100644 modules/tenants/pyproject.toml create mode 100644 modules/tenants/tenants/__init__.py create mode 100644 modules/tenants/tenants/constants.py create mode 100644 modules/tenants/tenants/contracts/__init__.py create mode 100644 modules/tenants/tenants/contracts/entitlements.py create mode 100644 modules/tenants/tenants/contracts/events.py create mode 100644 modules/tenants/tenants/contracts/schemas.py create mode 100644 modules/tenants/tenants/deps.py create mode 100644 modules/tenants/tenants/endpoints/__init__.py create mode 100644 modules/tenants/tenants/endpoints/admin.py create mode 100644 modules/tenants/tenants/endpoints/api.py create mode 100644 modules/tenants/tenants/endpoints/views.py create mode 100644 modules/tenants/tenants/errors.py create mode 100644 modules/tenants/tenants/invitations.py create mode 100644 modules/tenants/tenants/models.py create mode 100644 modules/tenants/tenants/module.py create mode 100644 modules/tenants/tenants/py.typed create mode 100644 modules/tenants/tenants/resolver.py create mode 100644 modules/tenants/tenants/service.py create mode 100644 modules/tenants/tenants/services.py create mode 100644 modules/tenants/tenants/settings.py create mode 100644 modules/tenants/tenants/shared_props.py create mode 100644 modules/tenants/tests/conftest.py create mode 100644 modules/tenants/tests/test_boot.py create mode 100644 modules/tenants/tests/test_invitations.py create mode 100644 modules/tenants/tests/test_isolation_e2e.py create mode 100644 modules/tenants/tests/test_tenants_api.py create mode 100644 modules/tenants/tsconfig.json 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..6f775e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,24 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] ### Added +- **`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 +63,19 @@ 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. + +### 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..b52040ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,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,7 +109,7 @@ 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 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..91580d45 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -234,7 +234,7 @@ Base = create_module_base("orders") - `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. +- `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..f0003d34 --- /dev/null +++ b/docs/framework/multi-tenancy.md @@ -0,0 +1,62 @@ +# 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` / `session.get` | filtered to the tenant | `TenantIsolationError` | unfiltered | +| ORM `update()` / `delete()` | filtered to the tenant | `TenantIsolationError` | unfiltered | +| `INSERT` | `tenant_id` filled in; a different explicit value raises | `TenantIsolationError` unless `tenant_id` is set explicitly | DB `NOT NULL` error unless set | +| Changing `tenant_id` | raises | raises | allowed | + +Fail closed is the point: a request, job or command that forgot to establish a +tenant errors instead of reading every tenant's data. + +## 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. + +## 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. Beat tasks have no request: wrap cross-tenant work in +`all_tenants()`, or loop over tenants with `tenant_context()`. + +## Resolution + +`TenantMiddleware` asks `app.state.tenant_resolver` (an +`async (Request) -> str | None`) when a module registered one — `tenants` +does — and its answer is final. 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..ff62ed9d --- /dev/null +++ b/docs/modules/tenants.md @@ -0,0 +1,77 @@ +# 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) and `invitation_ttl_hours` (default 72). + +## 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..d0f6e051 --- /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 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..188e7c85 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,9 +14,17 @@ 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, + TenantIsolationError, + all_tenants, + current_tenant_id, + tenant_context, +) from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session __all__ = [ + "ALL_TENANTS_OPTION", "LIKE_ESCAPE_CHAR", "AuditMixin", "AuditRecord", @@ -30,6 +37,7 @@ "SoftDeleteMixin", "TenantIsolationError", "VersionedMixin", + "all_tenants", "build_module_metadata", "create_module_base", "current_tenant_id", @@ -42,4 +50,5 @@ "make_include_object", "make_process_revision_directives", "render_item", + "tenant_context", ] diff --git a/framework/db/simple_module_db/listeners.py b/framework/db/simple_module_db/listeners.py index 7815183c..e8c63a07 100644 --- a/framework/db/simple_module_db/listeners.py +++ b/framework/db/simple_module_db/listeners.py @@ -8,10 +8,16 @@ 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.query_filter import configure_query_filter, filter_statements, is_strict from simple_module_db.session import DatabaseState +from simple_module_db.tenancy import ( + TenantIsolationError, + current_tenant_id, + missing_tenant_error, +) logger = logging.getLogger(__name__) _db_logger = logging.getLogger("simple_module.db") @@ -19,14 +25,6 @@ # 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,11 +91,12 @@ def register_listeners(db_state: DatabaseState) -> None: global _db_state _db_state = db_state + configure_query_filter(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) + event.listen(db_state.sync_session_class, "do_orm_execute", filter_statements) db_state._listeners_registered = True logger.info("Registered SQLAlchemy entity listeners") @@ -122,6 +121,8 @@ def _before_flush_listener( if isinstance(obj, MultiTenantMixin): if obj.tenant_id is None and tenant_id is not None: obj.tenant_id = tenant_id + elif obj.tenant_id is None and is_strict(): + raise missing_tenant_error(_entity_label(obj), "INSERT") elif tenant_id is not None and obj.tenant_id != tenant_id: raise TenantIsolationError( f"Cannot create object for tenant '{obj.tenant_id}' " @@ -148,8 +149,10 @@ 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: + # Prevent tenant_id from being changed on existing objects — also for + # unscoped platform code under strict mode: moving a row between + # tenants is never a routine edit. + if isinstance(obj, MultiTenantMixin) and (tenant_id is not None or is_strict()): hist = sa_inspect(obj).attrs.tenant_id.history if hist.has_changes(): raise TenantIsolationError("Cannot change tenant_id of an existing object") @@ -233,47 +236,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/query_filter.py b/framework/db/simple_module_db/query_filter.py new file mode 100644 index 00000000..335ebb43 --- /dev/null +++ b/framework/db/simple_module_db/query_filter.py @@ -0,0 +1,86 @@ +"""``do_orm_execute`` filter: soft-delete and tenant scoping on every ORM query.""" + +from __future__ import annotations + +from sqlalchemy.orm import ORMExecuteState, with_loader_criteria + +from simple_module_db.mixins import MultiTenantMixin, SoftDeleteMixin +from simple_module_db.session import DatabaseState +from simple_module_db.tenancy import ( + ALL_TENANTS_OPTION, + current_tenant_id, + is_all_tenants, + missing_tenant_error, +) + +_db_state: DatabaseState | None = None + + +def configure_query_filter(db_state: DatabaseState) -> None: + """Bind the filter to the app's ``DatabaseState`` (read for ``tenant_strict``).""" + global _db_state + _db_state = db_state + + +# 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 is_strict() -> bool: + """Strict isolation is on for this DB and not waived by ``all_tenants()``.""" + return _db_state is not None and _db_state.tenant_strict and not is_all_tenants() + + +def filter_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 applies to SELECT only. Tenant scoping applies to ORM-enabled + UPDATE and DELETE too — a bulk ``update(Model)`` would otherwise rewrite + every tenant's rows. Without a tenant context, strict mode raises instead + of leaving the statement unscoped. + + Soft-delete bypass: ``stmt.execution_options(include_deleted=True)``. + Tenant bypass: ``stmt.execution_options(all_tenants=True)`` or ``all_tenants()``. + """ + is_select = execute_state.is_select + if not (is_select or execute_state.is_update or execute_state.is_delete): + return + + 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 = _db_state is not None and _db_state.tenant_strict + if skip_soft_delete and (skip_tenant or (tenant_id is None and not strict)): + 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 not is_multi_tenant or skip_tenant: + continue + if tenant_id is not None: + options.append( + with_loader_criteria(cls, cls.tenant_id == tenant_id, include_aliases=True) + ) + elif strict: + op = "SELECT" if is_select else ("UPDATE" if execute_state.is_update else "DELETE") + raise missing_tenant_error(cls.__name__, op) + + if options: + execute_state.statement = execute_state.statement.options(*options) 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/tenancy.py b/framework/db/simple_module_db/tenancy.py new file mode 100644 index 00000000..dd31d699 --- /dev/null +++ b/framework/db/simple_module_db/tenancy.py @@ -0,0 +1,94 @@ +"""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 + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar + +# 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.""" + + +class TenantIsolationError(Exception): + """Raised when a multi-tenancy isolation constraint is violated.""" + + +@contextmanager +def tenant_context(tenant_id: str) -> Iterator[None]: + """Run the block as ``tenant_id`` — for jobs, CLI commands and tests.""" + if not tenant_id: + raise ValueError("tenant_context() needs a non-empty tenant id") + token = current_tenant_id.set(tenant_id) + try: + yield + finally: + 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 is_all_tenants() -> bool: + """True inside an ``all_tenants()`` block.""" + return _all_tenants.get() + + +def missing_tenant_error(entity: str, operation: str) -> TenantIsolationError: + return TenantIsolationError( + 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", + "TenantIsolationError", + "all_tenants", + "current_tenant_id", + "is_all_tenants", + "missing_tenant_error", + "tenant_context", +] diff --git a/framework/db/tests/test_tenant_strict.py b/framework/db/tests/test_tenant_strict.py new file mode 100644 index 00000000..cad70928 --- /dev/null +++ b/framework/db/tests/test_tenant_strict.py @@ -0,0 +1,106 @@ +"""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, tenant_context +from simple_module_db.listeners import register_listeners +from simple_module_db.session import init_db +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + + +@pytest.fixture +async def strict_session() -> AsyncGenerator[AsyncSession, None]: + db_state = init_db("sqlite+aiosqlite:///:memory:") + db_state.tenant_strict = True + try: + register_listeners(db_state) + 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 diff --git a/framework/hosting/simple_module_hosting/_tenant.py b/framework/hosting/simple_module_hosting/_tenant.py new file mode 100644 index 00000000..37227366 --- /dev/null +++ b/framework/hosting/simple_module_hosting/_tenant.py @@ -0,0 +1,88 @@ +"""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 +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) -> 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 = 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: + 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: + return Headers(scope=scope).get(self.header) or 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/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..bc940643 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: 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/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/tenant_context.py b/modules/background_tasks/background_tasks/tenant_context.py new file mode 100644 index 00000000..c98e3283 --- /dev/null +++ b/modules/background_tasks/background_tasks/tenant_context.py @@ -0,0 +1,72 @@ +"""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 current_tenant_id + +TENANT_HEADER = "sm_tenant_id" + +_log = logging.getLogger(__name__) +_tokens: dict[str, Token[str | None]] = {} + + +def stamp_tenant(headers: dict[str, Any] | None) -> None: + """Record the current tenant on an outgoing message (publish side). + + An explicit value already present — a caller passing + ``headers={"sm_tenant_id": ...}`` to ``send_task`` — is kept. + """ + if headers is None or headers.get(TENANT_HEADER): + return + tenant_id = current_tenant_id.get() + 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) + return str(value) if value else None + + +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) + 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", "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..e17f75ee --- /dev/null +++ b/modules/background_tasks/tests/test_tenant_context.py @@ -0,0 +1,58 @@ +"""The enqueuing request's tenant is restored around the task body.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from background_tasks.tenant_context import ( + TENANT_HEADER, + release_tenant, + restore_tenant, + stamp_tenant, +) +from simple_module_db import 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_explicit_header_is_not_overwritten(): + headers = {TENANT_HEADER: "chosen"} + with tenant_context("acme"): + stamp_tenant(headers) + assert headers[TENANT_HEADER] == "chosen" + + +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/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..04f72c30 --- /dev/null +++ b/modules/tenants/README.md @@ -0,0 +1,67 @@ +# 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) and `invitation_ttl_hours` (default 72). + +## 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/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..fdb62277 --- /dev/null +++ b/modules/tenants/tenants/contracts/events.py @@ -0,0 +1,53 @@ +"""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``.""" + + 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..c68f211a --- /dev/null +++ b/modules/tenants/tenants/contracts/schemas.py @@ -0,0 +1,103 @@ +"""Public DTOs for the Tenants module.""" + +from __future__ import annotations + +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_PATTERN = 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) + slug: str | None = Field(default=None, regex=_SLUG_PATTERN) + + @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 + + +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..0bdb9457 --- /dev/null +++ b/modules/tenants/tenants/deps.py @@ -0,0 +1,65 @@ +"""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.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) + + +UserIdDep = Annotated[str, Depends(require_user_id)] +ActiveTenantDep = Annotated[ActiveTenantContext, Depends(require_active_tenant)] 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..ec14e60e --- /dev/null +++ b/modules/tenants/tenants/endpoints/api.py @@ -0,0 +1,165 @@ +"""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, 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: ActiveTenantDep, 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: ActiveTenantDep, 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: ActiveTenantDep, 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: ActiveTenantDep, + invitations: InvitationServiceDep, +) -> InvitationIssued: + invitation, token, url = await invitations.create( + ctx.tenant_id, data, base_url=str(request.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: ActiveTenantDep, 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..bf976ec4 --- /dev/null +++ b/modules/tenants/tenants/endpoints/views.py @@ -0,0 +1,112 @@ +"""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, + 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)), + "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) + 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..9b454b13 --- /dev/null +++ b/modules/tenants/tenants/errors.py @@ -0,0 +1,64 @@ +"""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 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-scoped query ran without a tenant. + + With no active tenant this is a user state, not a bug: the user has no + organisation yet, or theirs is suspended — send pages to the picker + instead of a 500. With a tenant resolved it is a cross-tenant write + attempt, which is refused and logged. + """ + user = getattr(request.state, "user", None) + tenant_id = getattr(request.state, "tenant_id", None) + if tenant_id is not None or user is None: + logger.warning("Tenant isolation violation on %s: %s", 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/invitations.py b/modules/tenants/tenants/invitations.py new file mode 100644 index 00000000..cfc555a0 --- /dev/null +++ b/modules/tenants/tenants/invitations.py @@ -0,0 +1,135 @@ +"""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 +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.get(tenant_id) + if tenant is None: + raise TenantError("not_found", status_code=404) + 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) -> 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)) + ) + 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) + 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 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/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..86a82511 --- /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="building", + 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="building", + 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/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..e9555f24 --- /dev/null +++ b/modules/tenants/tenants/resolver.py @@ -0,0 +1,171 @@ +"""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 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.service import TenantService + +logger = logging.getLogger(__name__) + +MEMBERSHIP_TTL_SECONDS = 60 + +_MEMBERSHIPS: TTLCache[str, list[MyTenantView]] = TTLCache( + maxsize=10_000, ttl=MEMBERSHIP_TTL_SECONDS +) + + +def forget(key: str | None) -> None: + if key is None: + _MEMBERSHIPS.clear() + 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 + 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 + ] + _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 + user = getattr(request.state, "user", None) + 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 must not act on Y. + 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) + + 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: + session[SESSION_ACTIVE_TENANT] = active.id + return _enter(request, user, active) + + +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) or None) 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..19db6e12 --- /dev/null +++ b/modules/tenants/tenants/service.py @@ -0,0 +1,253 @@ +"""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.ext.asyncio import AsyncSession + +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) + await self.db.flush() + 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 _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 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: + 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) + await self.db.flush() + 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: + 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 and await self._owner_count(tenant_id) <= 1: + raise TenantError("last_owner", status_code=409) + 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: + 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 await self._owner_count(tenant_id) <= 1: + raise TenantError("last_owner", status_code=409) + 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..a8b86c68 --- /dev/null +++ b/modules/tenants/tenants/settings.py @@ -0,0 +1,18 @@ +"""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.", + ) + 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_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..f9511a23 --- /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_tenants_api.py b/modules/tenants/tests/test_tenants_api.py new file mode 100644 index 00000000..bee126fa --- /dev/null +++ b/modules/tenants/tests/test_tenants_api.py @@ -0,0 +1,105 @@ +"""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 diff --git a/modules/tenants/tsconfig.json b/modules/tenants/tsconfig.json new file mode 100644 index 00000000..532b5847 --- /dev/null +++ b/modules/tenants/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@simple-module-py/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./tenants/*"], + "@simple-module-py/ui/*": ["../../packages/ui/src/*"] + } + }, + "include": ["tenants/**/*.ts", "tenants/**/*.tsx"] +} 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`)", From 5678b812596724921a4324b6764e215a5b166e3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:35:22 +0000 Subject: [PATCH 02/13] feat(tenants): organisation, members, invitation and admin pages Inertia pages for the tenants module (Index, Members, AcceptInvitation, AdminBrowse) with extracted components, translated copy and API error mapping; regenerated i18n keys; tenants workspace in the lockfile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- .../tenants/components/AdminTenantsTable.tsx | 116 ++++++++++ .../tenants/components/CreateOrgForm.tsx | 90 ++++++++ .../tenants/components/InvitationsList.tsx | 90 ++++++++ .../tenants/tenants/components/InviteForm.tsx | 137 +++++++++++ .../tenants/components/MembersTable.tsx | 215 ++++++++++++++++++ .../tenants/components/MembershipList.tsx | 98 ++++++++ .../tenants/tenants/hooks/useTenantErrors.ts | 41 ++++ modules/tenants/tenants/locales/en.json | 135 +++++++++++ .../tenants/pages/AcceptInvitation.tsx | 114 ++++++++++ modules/tenants/tenants/pages/AdminBrowse.tsx | 109 +++++++++ modules/tenants/tenants/pages/Index.tsx | 64 ++++++ modules/tenants/tenants/pages/Members.tsx | 79 +++++++ modules/tenants/tsconfig.json | 1 - package-lock.json | 21 ++ packages/i18n/src/generated-resources.ts | 117 ++++++++++ packages/i18n/src/keys.generated.ts | 135 +++++++++++ 16 files changed, 1561 insertions(+), 1 deletion(-) create mode 100644 modules/tenants/tenants/components/AdminTenantsTable.tsx create mode 100644 modules/tenants/tenants/components/CreateOrgForm.tsx create mode 100644 modules/tenants/tenants/components/InvitationsList.tsx create mode 100644 modules/tenants/tenants/components/InviteForm.tsx create mode 100644 modules/tenants/tenants/components/MembersTable.tsx create mode 100644 modules/tenants/tenants/components/MembershipList.tsx create mode 100644 modules/tenants/tenants/hooks/useTenantErrors.ts create mode 100644 modules/tenants/tenants/locales/en.json create mode 100644 modules/tenants/tenants/pages/AcceptInvitation.tsx create mode 100644 modules/tenants/tenants/pages/AdminBrowse.tsx create mode 100644 modules/tenants/tenants/pages/Index.tsx create mode 100644 modules/tenants/tenants/pages/Members.tsx 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..6b8a1b72 --- /dev/null +++ b/modules/tenants/tenants/components/InviteForm.tsx @@ -0,0 +1,137 @@ +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 }; + setAcceptUrl(data.accept_url); + 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..5318e6e8 --- /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/hooks/useTenantErrors.ts b/modules/tenants/tenants/hooks/useTenantErrors.ts new file mode 100644 index 00000000..40e7e1fe --- /dev/null +++ b/modules/tenants/tenants/hooks/useTenantErrors.ts @@ -0,0 +1,41 @@ +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), + 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/locales/en.json b/modules/tenants/tenants/locales/en.json new file mode 100644 index 00000000..534e34a1 --- /dev/null +++ b/modules/tenants/tenants/locales/en.json @@ -0,0 +1,135 @@ +{ + "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" + }, + "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." + } +} diff --git a/modules/tenants/tenants/pages/AcceptInvitation.tsx b/modules/tenants/tenants/pages/AcceptInvitation.tsx new file mode 100644 index 00000000..4ccb8083 --- /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..08525818 --- /dev/null +++ b/modules/tenants/tenants/pages/Index.tsx @@ -0,0 +1,64 @@ +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; + can_create: boolean; + reason: string | null; +} + +function Index() { + const { memberships, active_id, suspended, 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 && ( +
+ {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/tsconfig.json b/modules/tenants/tsconfig.json index 532b5847..cd85ecee 100644 --- a/modules/tenants/tsconfig.json +++ b/modules/tenants/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "@simple-module-py/tsconfig/base.json", "compilerOptions": { - "baseUrl": ".", "paths": { "@/*": ["./tenants/*"], "@simple-module-py/ui/*": ["../../packages/ui/src/*"] 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..d273b511 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -687,6 +687,123 @@ 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.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_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_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..26e9a8b9 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -892,6 +892,141 @@ 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', + 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_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_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', From e472766220225bcee3ef041abc4ca0c8e7db023f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:36:28 +0000 Subject: [PATCH 03/13] docs(tenancy): ruff-format the multi-tenancy code sample Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- docs/framework/multi-tenancy.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/framework/multi-tenancy.md b/docs/framework/multi-tenancy.md index f0003d34..8366f89f 100644 --- a/docs/framework/multi-tenancy.md +++ b/docs/framework/multi-tenancy.md @@ -28,10 +28,10 @@ tenant errors instead of reading every tenant's data. ```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 tenant_context(tenant_id): # a job or CLI command working for one tenant ... -with all_tenants(): # platform code that deliberately spans tenants +with all_tenants(): # platform code that deliberately spans tenants ... stmt = select(Order).execution_options(all_tenants=True) # one statement From 6b80308f47c8fefa95671cc388d39bfc168f01f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:39:10 +0000 Subject: [PATCH 04/13] fix(tenants): canonical menu URLs and a mapped nav icon /tenants and /admin/tenants 307'd to their trailing-slash form on every navigation; 'building' has no NavIcon entry and rendered blank. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- modules/tenants/tenants/components/MembersTable.tsx | 2 +- modules/tenants/tenants/endpoints/views.py | 2 +- modules/tenants/tenants/errors.py | 2 +- modules/tenants/tenants/module.py | 8 ++++---- modules/tenants/tenants/pages/AcceptInvitation.tsx | 2 +- modules/tenants/tests/test_isolation_e2e.py | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/tenants/tenants/components/MembersTable.tsx b/modules/tenants/tenants/components/MembersTable.tsx index 5318e6e8..5a7b9a70 100644 --- a/modules/tenants/tenants/components/MembersTable.tsx +++ b/modules/tenants/tenants/components/MembersTable.tsx @@ -88,7 +88,7 @@ export function MembersTable({ } if (isSelf) { toast.success(t(keys.tenants.members.toast_left)); - window.location.href = '/tenants'; + window.location.href = '/tenants/'; return; } toast.success(t(keys.tenants.members.toast_removed)); diff --git a/modules/tenants/tenants/endpoints/views.py b/modules/tenants/tenants/endpoints/views.py index bf976ec4..4bc845e1 100644 --- a/modules/tenants/tenants/endpoints/views.py +++ b/modules/tenants/tenants/endpoints/views.py @@ -64,7 +64,7 @@ async def members( 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) + return RedirectResponse("/tenants/?reason=tenant_required", status_code=303) can_manage = _perm(request, PERM_MEMBERS_MANAGE) pending = await invitations.list_pending(tenant.id) if can_manage else [] limit = await service.entitlements.limit(tenant.id, ENTITLEMENT_SEATS) diff --git a/modules/tenants/tenants/errors.py b/modules/tenants/tenants/errors.py index 9b454b13..0ab8aba7 100644 --- a/modules/tenants/tenants/errors.py +++ b/modules/tenants/tenants/errors.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) -_INDEX_URL = "/tenants" +_INDEX_URL = "/tenants/" class TenantError(Exception): diff --git a/modules/tenants/tenants/module.py b/modules/tenants/tenants/module.py index 86a82511..f096bf8c 100644 --- a/modules/tenants/tenants/module.py +++ b/modules/tenants/tenants/module.py @@ -85,8 +85,8 @@ def register_menu_items(self, registry: MenuRegistry) -> None: MenuItem( label="Organisations", label_key="tenants.nav.organisations", - url="/tenants", - icon="building", + url="/tenants/", + icon="briefcase", order=90, section=MenuSection.SIDEBAR, ) @@ -106,8 +106,8 @@ def register_menu_items(self, registry: MenuRegistry) -> None: MenuItem( label="Tenants", label_key="tenants.nav.tenants", - url="/admin/tenants", - icon="building", + url="/admin/tenants/", + icon="briefcase", order=105, section=MenuSection.ADMIN_SIDEBAR, permissions=[c.PERM_PLATFORM_VIEW], diff --git a/modules/tenants/tenants/pages/AcceptInvitation.tsx b/modules/tenants/tenants/pages/AcceptInvitation.tsx index 4ccb8083..ef704e73 100644 --- a/modules/tenants/tenants/pages/AcceptInvitation.tsx +++ b/modules/tenants/tenants/pages/AcceptInvitation.tsx @@ -43,7 +43,7 @@ function AcceptInvitation() { setError(await describe(response)); return; } - router.visit('/tenants'); + router.visit('/tenants/'); } catch { setError(t(keys.tenants.errors.generic)); } finally { diff --git a/modules/tenants/tests/test_isolation_e2e.py b/modules/tenants/tests/test_isolation_e2e.py index f9511a23..d23ec97c 100644 --- a/modules/tenants/tests/test_isolation_e2e.py +++ b/modules/tenants/tests/test_isolation_e2e.py @@ -71,7 +71,7 @@ async def test_no_tenant_fails_closed(notes_app, user_client): 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") + assert page.headers["location"].startswith("/tenants/?reason=tenant_required") async def test_removed_member_loses_access_immediately(notes_app, user_client): From 3d3e1cc99c8668dabbb17b4defa8e3d9c50c89da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:42:25 +0000 Subject: [PATCH 05/13] fix(tenancy): validate tenant ids taken from requests (#366) The legacy header path bound any string, so a value over 50 characters was a 500 on the first stamped write and any junk became a tenant name. TENANT_ID_PATTERN/is_valid_tenant_id in simple_module_db are now the one rule the middleware, the tenants resolver and tenant_context() share. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- framework/db/simple_module_db/__init__.py | 4 ++++ framework/db/simple_module_db/tenancy.py | 16 ++++++++++++++-- framework/db/tests/test_tenant_strict.py | 6 ++++++ .../hosting/simple_module_hosting/_tenant.py | 7 +++++-- .../hosting/tests/test_tenant_middleware.py | 14 ++++++++++++++ modules/tenants/tenants/resolver.py | 8 ++++++-- modules/tenants/tests/test_tenants_api.py | 7 +++++++ 7 files changed, 56 insertions(+), 6 deletions(-) diff --git a/framework/db/simple_module_db/__init__.py b/framework/db/simple_module_db/__init__.py index 188e7c85..b8c9d999 100644 --- a/framework/db/simple_module_db/__init__.py +++ b/framework/db/simple_module_db/__init__.py @@ -16,9 +16,11 @@ from simple_module_db.session import DatabaseState, RequestSession, init_db from simple_module_db.tenancy import ( ALL_TENANTS_OPTION, + TENANT_ID_PATTERN, TenantIsolationError, all_tenants, current_tenant_id, + is_valid_tenant_id, tenant_context, ) from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session @@ -26,6 +28,7 @@ __all__ = [ "ALL_TENANTS_OPTION", "LIKE_ESCAPE_CHAR", + "TENANT_ID_PATTERN", "AuditMixin", "AuditRecord", "CommitBeforeResponseMiddleware", @@ -45,6 +48,7 @@ "finalize_session", "get_db", "init_db", + "is_valid_tenant_id", "like_contains_pattern", "like_prefix_pattern", "make_include_object", diff --git a/framework/db/simple_module_db/tenancy.py b/framework/db/simple_module_db/tenancy.py index dd31d699..e3baabad 100644 --- a/framework/db/simple_module_db/tenancy.py +++ b/framework/db/simple_module_db/tenancy.py @@ -21,6 +21,7 @@ from __future__ import annotations +import re from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar @@ -35,6 +36,15 @@ 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.""" @@ -43,8 +53,8 @@ class TenantIsolationError(Exception): @contextmanager def tenant_context(tenant_id: str) -> Iterator[None]: """Run the block as ``tenant_id`` — for jobs, CLI commands and tests.""" - if not tenant_id: - raise ValueError("tenant_context() needs a non-empty tenant id") + 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) try: yield @@ -85,10 +95,12 @@ def missing_tenant_error(entity: str, operation: str) -> TenantIsolationError: __all__ = [ "ALL_TENANTS_OPTION", + "TENANT_ID_PATTERN", "TenantIsolationError", "all_tenants", "current_tenant_id", "is_all_tenants", + "is_valid_tenant_id", "missing_tenant_error", "tenant_context", ] diff --git a/framework/db/tests/test_tenant_strict.py b/framework/db/tests/test_tenant_strict.py index cad70928..9ba4c32e 100644 --- a/framework/db/tests/test_tenant_strict.py +++ b/framework/db/tests/test_tenant_strict.py @@ -104,3 +104,9 @@ async def test_unscoped_code_cannot_move_rows_between_tenants(strict_session: As 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 diff --git a/framework/hosting/simple_module_hosting/_tenant.py b/framework/hosting/simple_module_hosting/_tenant.py index 37227366..913cb39c 100644 --- a/framework/hosting/simple_module_hosting/_tenant.py +++ b/framework/hosting/simple_module_hosting/_tenant.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable -from simple_module_db import current_tenant_id +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 @@ -81,7 +81,10 @@ async def _resolve(self, request: Request, scope: Scope) -> str | None: return getattr(user, "tenant_id", None) if self.header: - return Headers(scope=scope).get(self.header) or None + 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 diff --git a/framework/hosting/tests/test_tenant_middleware.py b/framework/hosting/tests/test_tenant_middleware.py index bc940643..33d040b1 100644 --- a/framework/hosting/tests/test_tenant_middleware.py +++ b/framework/hosting/tests/test_tenant_middleware.py @@ -197,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/modules/tenants/tenants/resolver.py b/modules/tenants/tenants/resolver.py index e9555f24..17b4ea51 100644 --- a/modules/tenants/tenants/resolver.py +++ b/modules/tenants/tenants/resolver.py @@ -21,6 +21,7 @@ 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 ( @@ -118,7 +119,10 @@ async def resolve_tenant(request: Request) -> str | None: 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 must not act on Y. + # 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, @@ -144,7 +148,7 @@ async def resolve_tenant(request: Request) -> str | None: 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) or None) if header else None + return request.headers.get(header) if header else None def _enter(request: Request, user: Any, active: MyTenantView) -> str: diff --git a/modules/tenants/tests/test_tenants_api.py b/modules/tenants/tests/test_tenants_api.py index bee126fa..1e44500c 100644 --- a/modules/tenants/tests/test_tenants_api.py +++ b/modules/tenants/tests/test_tenants_api.py @@ -103,3 +103,10 @@ async def test_header_selects_a_tenant_the_user_belongs_to(user_client): # 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 From ff9fd822a0ba90c44d012f9b47e7956e3d46d64e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:39:26 +0000 Subject: [PATCH 06/13] fix(db): refuse tenant_id changes whether or not a tenant is bound (#356) An unbound flush skipped the check, so platform code or a job with no tenant could silently move a row to another tenant. Only an explicit all_tenants() block may now do that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- CHANGELOG.md | 3 ++ docs/framework/multi-tenancy.md | 2 +- framework/db/simple_module_db/listeners.py | 9 +++--- framework/db/tests/test_multi_tenancy.py | 33 ++++++++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f775e0b..88808965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,9 @@ All notable changes to this project are documented in this file. The format is b 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. ### Security - The tenant header (`tenant_header`) is no longer honoured for an diff --git a/docs/framework/multi-tenancy.md b/docs/framework/multi-tenancy.md index 8366f89f..26ff99de 100644 --- a/docs/framework/multi-tenancy.md +++ b/docs/framework/multi-tenancy.md @@ -18,7 +18,7 @@ tenant a request acts for. | `SELECT` / `session.get` | filtered to the tenant | `TenantIsolationError` | unfiltered | | ORM `update()` / `delete()` | filtered to the tenant | `TenantIsolationError` | unfiltered | | `INSERT` | `tenant_id` filled in; a different explicit value raises | `TenantIsolationError` unless `tenant_id` is set explicitly | DB `NOT NULL` error unless set | -| Changing `tenant_id` | raises | raises | allowed | +| Changing `tenant_id` | raises | raises | raises (only an `all_tenants()` block may move a row) | Fail closed is the point: a request, job or command that forgot to establish a tenant errors instead of reading every tenant's data. diff --git a/framework/db/simple_module_db/listeners.py b/framework/db/simple_module_db/listeners.py index e8c63a07..34bebc18 100644 --- a/framework/db/simple_module_db/listeners.py +++ b/framework/db/simple_module_db/listeners.py @@ -16,6 +16,7 @@ from simple_module_db.tenancy import ( TenantIsolationError, current_tenant_id, + is_all_tenants, missing_tenant_error, ) @@ -149,10 +150,10 @@ def _before_flush_listener( if isinstance(obj, VersionedMixin): obj.version += 1 - # Prevent tenant_id from being changed on existing objects — also for - # unscoped platform code under strict mode: moving a row between - # tenants is never a routine edit. - if isinstance(obj, MultiTenantMixin) and (tenant_id is not None or is_strict()): + # Prevent tenant_id from being changed on existing objects, bound or + # not (#356): moving a row between tenants is never a routine edit. + # Only a deliberate ``all_tenants()`` block may do it. + if isinstance(obj, MultiTenantMixin) and not is_all_tenants(): hist = sa_inspect(obj).attrs.tenant_id.history if hist.has_changes(): raise TenantIsolationError("Cannot change tenant_id of an existing object") diff --git a/framework/db/tests/test_multi_tenancy.py b/framework/db/tests/test_multi_tenancy.py index c5d5cacc..1f2fd9aa 100644 --- a/framework/db/tests/test_multi_tenancy.py +++ b/framework/db/tests/test_multi_tenancy.py @@ -271,3 +271,36 @@ async def read_tenant() -> None: assert current_tenant_id.get() is None assert seen == ["tenant-a"] + + +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): + from simple_module_db import all_tenants + + 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" From 9ad21142973e822c0b9d1ce07b683f28ad279d78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:40:40 +0000 Subject: [PATCH 07/13] test(db): move the #356 tests next to the other strict-mode tests test_multi_tenancy.py went over the 300-line cap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- framework/db/tests/test_multi_tenancy.py | 33 ------------------------ framework/db/tests/test_tenant_strict.py | 33 +++++++++++++++++++++++- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/framework/db/tests/test_multi_tenancy.py b/framework/db/tests/test_multi_tenancy.py index 1f2fd9aa..c5d5cacc 100644 --- a/framework/db/tests/test_multi_tenancy.py +++ b/framework/db/tests/test_multi_tenancy.py @@ -271,36 +271,3 @@ async def read_tenant() -> None: assert current_tenant_id.get() is None assert seen == ["tenant-a"] - - -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): - from simple_module_db import all_tenants - - 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/db/tests/test_tenant_strict.py b/framework/db/tests/test_tenant_strict.py index 9ba4c32e..9ad8413c 100644 --- a/framework/db/tests/test_tenant_strict.py +++ b/framework/db/tests/test_tenant_strict.py @@ -6,7 +6,7 @@ import pytest from _models import _TenantBase, _TenantItem -from simple_module_db import TenantIsolationError, all_tenants, tenant_context +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 sqlalchemy import delete, func, select, update @@ -110,3 +110,34 @@ def test_tenant_context_rejects_empty_id(): 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" From 64c430c083999d4047571ce95ce978a1568c68bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:32:36 +0000 Subject: [PATCH 08/13] fix(tenancy): close the isolation gaps found in adversarial QA DB layer - tenant_context() nested in all_tenants() now scopes its block; it was ignored, so a per-tenant loop inside a platform job ran unscoped. - ORM update().values(tenant_id=...) is refused; bulk/Core-style ORM insert(Model) is stamped with the bound tenant and refused for another one (#357). - A flush that writes or deletes an object of another tenant (e.g. one handed back by the identity map after a tenant switch) is refused. - Strict mode is held per engine, not in a module global, so a second DatabaseState cannot switch it off. New MissingTenantError. - The Celery worker's sync session gets the tenant listeners and the host's multi_tenant setting (#371); task headers are validated and request code cannot enqueue as another tenant. tenants module - Membership cache: a read in flight during an invalidation no longer re-caches a removed member. - Last-owner and seat checks row-lock the tenant; concurrent creates and accepts give 409 instead of a 500. - Invitation links come from a public_base_url setting (root-relative when unset), never from the Host header. - Manage routes need an owner/admin role in the active tenant, not just a platform-wide permission. - No accepting into a suspended tenant, no re-inviting a member; the slug format is actually enforced (SQLModel ignored regex=). - A suspended active org is no longer switched away from silently. - Only a missing tenant becomes the org picker; other isolation errors are 403 and logged as errors. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- CHANGELOG.md | 13 ++ docs/framework/multi-tenancy.md | 57 +++++- docs/modules/tenants.md | 15 +- docs/plans/2026-09-27-saas-tenancy-design.md | 2 +- framework/db/simple_module_db/__init__.py | 2 + framework/db/simple_module_db/flush_guard.py | 73 ++++++++ framework/db/simple_module_db/listeners.py | 61 +++---- framework/db/simple_module_db/query_filter.py | 163 ++++++++++++++---- framework/db/simple_module_db/tenancy.py | 23 ++- framework/db/tests/test_tenant_dml.py | 130 ++++++++++++++ .../background_tasks/module.py | 5 +- .../background_tasks/sync_db.py | 32 +++- .../background_tasks/tenant_context.py | 28 ++- .../tests/test_tenant_context.py | 20 ++- .../tests/test_worker_tenancy.py | 58 +++++++ modules/tenants/README.md | 15 +- .../tenants/tenants/components/InviteForm.tsx | 3 +- modules/tenants/tenants/contracts/events.py | 6 +- modules/tenants/tenants/contracts/schemas.py | 14 +- modules/tenants/tenants/deps.py | 17 ++ modules/tenants/tenants/endpoints/api.py | 24 ++- modules/tenants/tenants/endpoints/views.py | 7 +- modules/tenants/tenants/errors.py | 22 ++- .../tenants/tenants/hooks/useTenantErrors.ts | 3 + modules/tenants/tenants/invitations.py | 18 +- modules/tenants/tenants/locales/en.json | 8 +- modules/tenants/tenants/pages/Index.tsx | 15 +- modules/tenants/tenants/resolver.py | 24 ++- modules/tenants/tenants/service.py | 37 +++- modules/tenants/tenants/settings.py | 7 + modules/tenants/tests/test_hardening.py | 146 ++++++++++++++++ packages/i18n/src/generated-resources.ts | 4 + packages/i18n/src/keys.generated.ts | 4 + scripts/run_worker.py | 8 + 34 files changed, 930 insertions(+), 134 deletions(-) create mode 100644 framework/db/simple_module_db/flush_guard.py create mode 100644 framework/db/tests/test_tenant_dml.py create mode 100644 modules/background_tasks/tests/test_worker_tenancy.py create mode 100644 modules/tenants/tests/test_hardening.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 88808965..59daee54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,19 @@ All notable changes to this project are documented in this file. The format is b - 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". ### Security - The tenant header (`tenant_header`) is no longer honoured for an diff --git a/docs/framework/multi-tenancy.md b/docs/framework/multi-tenancy.md index 26ff99de..bd0e9746 100644 --- a/docs/framework/multi-tenancy.md +++ b/docs/framework/multi-tenancy.md @@ -15,13 +15,34 @@ tenant a request acts for. | Operation on a `MultiTenantMixin` model | Tenant set | No tenant, strict (`multi_tenant` on) | No tenant, not strict | |---|---|---|---| -| `SELECT` / `session.get` | filtered to the tenant | `TenantIsolationError` | unfiltered | -| ORM `update()` / `delete()` | filtered to the tenant | `TenantIsolationError` | unfiltered | -| `INSERT` | `tenant_id` filled in; a different explicit value raises | `TenantIsolationError` unless `tenant_id` is set explicitly | DB `NOT NULL` error unless set | +| `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) | -Fail closed is the point: a request, job or command that forgot to establish a -tenant errors instead of reading every tenant's data. +`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 + +Scoping works on the ORM entities a statement *names*: `select(Model)`, +`update(Model)`, `delete(Model)`, `insert(Model)`, relationship loads. It does +**not** reach a tenant table that appears only as a join target, inside an +`exists()` / `in_()` / scalar subquery, as `select(func.count()).select_from(Model)`, +or in a Core statement on `Model.__table__` (#332). Such statements are neither +filtered nor, under strict mode, refused — name the entity, or add the +`tenant_id` predicate yourself. + +### 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 @@ -38,15 +59,37 @@ 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. +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). ## 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. Beat tasks have no request: wrap cross-tenant work in +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 diff --git a/docs/modules/tenants.md b/docs/modules/tenants.md index ff62ed9d..773d53c0 100644 --- a/docs/modules/tenants.md +++ b/docs/modules/tenants.md @@ -49,8 +49,19 @@ from the URL. ## Configuration -DB-backed (Settings screen): `allow_self_service` (default on — any signed-in -user may create an organisation) and `invitation_ttl_hours` (default 72). +DB-backed (Settings screen): + +- `allow_self_service` (default on) — any signed-in user may create an + organisation. +- `invitation_ttl_hours` (default 72). +- `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 diff --git a/docs/plans/2026-09-27-saas-tenancy-design.md b/docs/plans/2026-09-27-saas-tenancy-design.md index d0f6e051..7d5b93c5 100644 --- a/docs/plans/2026-09-27-saas-tenancy-design.md +++ b/docs/plans/2026-09-27-saas-tenancy-design.md @@ -45,7 +45,7 @@ deliberately out of scope; this doc fixes the seams it will plug into. 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 membership. Suspended + 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 diff --git a/framework/db/simple_module_db/__init__.py b/framework/db/simple_module_db/__init__.py index b8c9d999..a0885b83 100644 --- a/framework/db/simple_module_db/__init__.py +++ b/framework/db/simple_module_db/__init__.py @@ -17,6 +17,7 @@ from simple_module_db.tenancy import ( ALL_TENANTS_OPTION, TENANT_ID_PATTERN, + MissingTenantError, TenantIsolationError, all_tenants, current_tenant_id, @@ -34,6 +35,7 @@ "CommitBeforeResponseMiddleware", "DatabaseProvider", "DatabaseState", + "MissingTenantError", "MultiTenantMixin", "OnCommitCallback", "RequestSession", 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 34bebc18..70a99425 100644 --- a/framework/db/simple_module_db/listeners.py +++ b/framework/db/simple_module_db/listeners.py @@ -10,15 +10,13 @@ from sqlalchemy import inspect as sa_inspect from sqlalchemy.orm import Session -from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin -from simple_module_db.query_filter import configure_query_filter, filter_statements, is_strict +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 -from simple_module_db.tenancy import ( - TenantIsolationError, - current_tenant_id, - is_all_tenants, - missing_tenant_error, -) + +# 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") @@ -92,23 +90,36 @@ def register_listeners(db_state: DatabaseState) -> None: global _db_state _db_state = db_state - configure_query_filter(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_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,18 +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 obj.tenant_id is None and is_strict(): - raise missing_tenant_error(_entity_label(obj), "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}'" - ) - _db_logger.info( _EVENT_ENTITY_CREATED, extra={ @@ -150,14 +149,6 @@ def _before_flush_listener( if isinstance(obj, VersionedMixin): obj.version += 1 - # Prevent tenant_id from being changed on existing objects, bound or - # not (#356): moving a row between tenants is never a routine edit. - # Only a deliberate ``all_tenants()`` block may do it. - if isinstance(obj, MultiTenantMixin) and not is_all_tenants(): - 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={ diff --git a/framework/db/simple_module_db/query_filter.py b/framework/db/simple_module_db/query_filter.py index 335ebb43..afb7fda4 100644 --- a/framework/db/simple_module_db/query_filter.py +++ b/framework/db/simple_module_db/query_filter.py @@ -1,25 +1,62 @@ -"""``do_orm_execute`` filter: soft-delete and tenant scoping on every ORM query.""" +"""``do_orm_execute`` filter: soft-delete and tenant scoping on every ORM statement. + +Scope — what this filter can and cannot see. It works on the ORM entities a +statement names (``execute_state.all_mappers``): ``select(Model)``, +``update(Model)``, ``delete(Model)``, ``insert(Model)``, relationship loads. +A tenant-scoped table reached only through a join target, an ``exists()`` / +``in_()`` subquery, ``select(func.count()).select_from(Model)`` or a Core +statement on ``Model.__table__`` is NOT scoped (#332) — module code must name +the entity or add the ``tenant_id`` predicate itself. +""" from __future__ import annotations -from sqlalchemy.orm import ORMExecuteState, with_loader_criteria +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.mixins import MultiTenantMixin, SoftDeleteMixin -from simple_module_db.session import DatabaseState from simple_module_db.tenancy import ( ALL_TENANTS_OPTION, + TenantIsolationError, current_tenant_id, is_all_tenants, missing_tenant_error, ) -_db_state: DatabaseState | None = None +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 configure_query_filter(db_state: DatabaseState) -> None: - """Bind the filter to the app's ``DatabaseState`` (read for ``tenant_strict``).""" - global _db_state - _db_state = db_state + +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() # Cache ``(is_soft_delete, is_multi_tenant)`` flags per mapper class so the @@ -27,53 +64,53 @@ def configure_query_filter(db_state: DatabaseState) -> None: _mixin_flags_cache: dict[type, tuple[bool, bool]] = {} -def is_strict() -> bool: - """Strict isolation is on for this DB and not waived by ``all_tenants()``.""" - return _db_state is not None and _db_state.tenant_strict and not is_all_tenants() +def _flags(cls: type) -> tuple[bool, bool]: + flags = _mixin_flags_cache.get(cls) + if flags is None: + flags = (issubclass(cls, SoftDeleteMixin), issubclass(cls, MultiTenantMixin)) + _mixin_flags_cache[cls] = flags + return flags -def filter_statements(execute_state: ORMExecuteState) -> None: - """Attach per-mapper ``with_loader_criteria`` for soft-delete and tenant isolation. +def filter_statements(execute_state: ORMExecuteState) -> Any: + """Scope ORM statements by soft-delete and tenant. - 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. + * 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). - Soft-delete applies to SELECT only. Tenant scoping applies to ORM-enabled - UPDATE and DELETE too — a bulk ``update(Model)`` would otherwise rewrite - every tenant's rows. Without a tenant context, strict mode raises instead - of leaving the statement unscoped. - - Soft-delete bypass: ``stmt.execution_options(include_deleted=True)``. - Tenant bypass: ``stmt.execution_options(all_tenants=True)`` or ``all_tenants()``. + 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 + 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 = _db_state is not None and _db_state.tenant_strict + strict = _strict_configured(execute_state.session) if skip_soft_delete and (skip_tenant or (tenant_id is None and not strict)): - return + return None 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 + is_soft_delete, is_multi_tenant = _flags(cls) if is_soft_delete and not skip_soft_delete: options.append( with_loader_criteria(cls, cls.is_deleted.is_(False), include_aliases=True) ) if not is_multi_tenant or skip_tenant: continue + if execute_state.is_update and _assigned_tenant_ids(execute_state): + raise TenantIsolationError("Cannot change tenant_id of existing rows") if tenant_id is not None: options.append( with_loader_criteria(cls, cls.tenant_id == tenant_id, include_aliases=True) @@ -84,3 +121,65 @@ def filter_statements(execute_state: ORMExecuteState) -> None: if options: execute_state.statement = execute_state.statement.options(*options) + return None + + +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_ for m in execute_state.all_mappers if _flags(m.class_)[1]] + 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].__name__, "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/tenancy.py b/framework/db/simple_module_db/tenancy.py index e3baabad..9656793e 100644 --- a/framework/db/simple_module_db/tenancy.py +++ b/framework/db/simple_module_db/tenancy.py @@ -50,15 +50,31 @@ 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.""" + """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) @@ -84,8 +100,8 @@ def is_all_tenants() -> bool: return _all_tenants.get() -def missing_tenant_error(entity: str, operation: str) -> TenantIsolationError: - return TenantIsolationError( +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()` / " @@ -96,6 +112,7 @@ def missing_tenant_error(entity: str, operation: str) -> TenantIsolationError: __all__ = [ "ALL_TENANTS_OPTION", "TENANT_ID_PATTERN", + "MissingTenantError", "TenantIsolationError", "all_tenants", "current_tenant_id", diff --git a/framework/db/tests/test_tenant_dml.py b/framework/db/tests/test_tenant_dml.py new file mode 100644 index 00000000..95210c94 --- /dev/null +++ b/framework/db/tests/test_tenant_dml.py @@ -0,0 +1,130 @@ +"""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 sqlalchemy import insert, select, update +from sqlalchemy.ext.asyncio import AsyncSession + + +async def _session(strict: bool) -> tuple: + state = init_db("sqlite+aiosqlite:///:memory:") + state.tenant_strict = strict + register_listeners(state) + 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) + 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/modules/background_tasks/background_tasks/module.py b/modules/background_tasks/background_tasks/module.py index f6e846ab..5300ad77 100644 --- a/modules/background_tasks/background_tasks/module.py +++ b/modules/background_tasks/background_tasks/module.py @@ -158,7 +158,10 @@ async def on_startup(self, app: FastAPI) -> None: # 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 + set_database_url( + host.database_url, tenant_strict=bool(getattr(host, "multi_tenant", False)) + ) # 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/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 index c98e3283..25bbfa92 100644 --- a/modules/background_tasks/background_tasks/tenant_context.py +++ b/modules/background_tasks/background_tasks/tenant_context.py @@ -15,7 +15,7 @@ from contextvars import Token from typing import Any -from simple_module_db import current_tenant_id +from simple_module_db import TenantIsolationError, current_tenant_id, is_valid_tenant_id TENANT_HEADER = "sm_tenant_id" @@ -26,12 +26,23 @@ def stamp_tenant(headers: dict[str, Any] | None) -> None: """Record the current tenant on an outgoing message (publish side). - An explicit value already present — a caller passing - ``headers={"sm_tenant_id": ...}`` to ``send_task`` — is kept. + 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 or headers.get(TENANT_HEADER): + 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 @@ -43,7 +54,14 @@ def _tenant_of(task: Any) -> str | None: value = getattr(request, TENANT_HEADER, None) if value is None and isinstance(getattr(request, "headers", None), dict): value = request.headers.get(TENANT_HEADER) - return str(value) if value else None + 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: diff --git a/modules/background_tasks/tests/test_tenant_context.py b/modules/background_tasks/tests/test_tenant_context.py index e17f75ee..84da6539 100644 --- a/modules/background_tasks/tests/test_tenant_context.py +++ b/modules/background_tasks/tests/test_tenant_context.py @@ -4,13 +4,14 @@ 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 current_tenant_id, tenant_context +from simple_module_db import TenantIsolationError, current_tenant_id, tenant_context def test_publish_stamps_current_tenant(): @@ -26,13 +27,24 @@ def test_publish_without_tenant_leaves_headers_alone(): assert TENANT_HEADER not in headers -def test_explicit_header_is_not_overwritten(): +def test_platform_code_may_enqueue_for_an_explicit_tenant(): headers = {TENANT_HEADER: "chosen"} - with tenant_context("acme"): - stamp_tenant(headers) + 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) 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/README.md b/modules/tenants/README.md index 04f72c30..9b538181 100644 --- a/modules/tenants/README.md +++ b/modules/tenants/README.md @@ -49,8 +49,19 @@ from the URL. ## Configuration -DB-backed (Settings screen): `allow_self_service` (default on — any signed-in -user may create an organisation) and `invitation_ttl_hours` (default 72). +DB-backed (Settings screen): + +- `allow_self_service` (default on) — any signed-in user may create an + organisation. +- `invitation_ttl_hours` (default 72). +- `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 diff --git a/modules/tenants/tenants/components/InviteForm.tsx b/modules/tenants/tenants/components/InviteForm.tsx index 6b8a1b72..4e2386ad 100644 --- a/modules/tenants/tenants/components/InviteForm.tsx +++ b/modules/tenants/tenants/components/InviteForm.tsx @@ -48,7 +48,8 @@ export function InviteForm({ onInvited }: Props) { return; } const data = (await response.json()) as { accept_url: string }; - setAcceptUrl(data.accept_url); + // 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(); diff --git a/modules/tenants/tenants/contracts/events.py b/modules/tenants/tenants/contracts/events.py index fdb62277..136c5a4f 100644 --- a/modules/tenants/tenants/contracts/events.py +++ b/modules/tenants/tenants/contracts/events.py @@ -44,7 +44,11 @@ class MembershipRemoved(Event): @dataclass class InvitationCreated(Event): - """Delivery hook: a mailer module sends ``accept_url`` to ``email``.""" + """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 diff --git a/modules/tenants/tenants/contracts/schemas.py b/modules/tenants/tenants/contracts/schemas.py index c68f211a..8e225ec2 100644 --- a/modules/tenants/tenants/contracts/schemas.py +++ b/modules/tenants/tenants/contracts/schemas.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from datetime import datetime from pydantic import field_validator @@ -9,12 +10,14 @@ from tenants.constants import MAX_EMAIL_LEN, MAX_NAME_LEN, MembershipRole -_SLUG_PATTERN = r"^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$" +_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) - slug: str | None = Field(default=None, regex=_SLUG_PATTERN) + # 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 @@ -24,6 +27,13 @@ def _strip(cls, value: str) -> str: 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 diff --git a/modules/tenants/tenants/deps.py b/modules/tenants/tenants/deps.py index 0bdb9457..93fe210f 100644 --- a/modules/tenants/tenants/deps.py +++ b/modules/tenants/tenants/deps.py @@ -9,6 +9,7 @@ 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 @@ -61,5 +62,21 @@ def require_active_tenant(request: Request) -> ActiveTenantContext: 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/api.py b/modules/tenants/tenants/endpoints/api.py index ec14e60e..2d14b5a4 100644 --- a/modules/tenants/tenants/endpoints/api.py +++ b/modules/tenants/tenants/endpoints/api.py @@ -29,7 +29,13 @@ RoleChange, TenantCreate, ) -from tenants.deps import ActiveTenantDep, InvitationServiceDep, TenantServiceDep, UserIdDep +from tenants.deps import ( + ActiveTenantDep, + InvitationServiceDep, + ManagerDep, + TenantServiceDep, + UserIdDep, +) from tenants.errors import TenantError from tenants.resolver import switch_active @@ -93,14 +99,14 @@ async def list_members(ctx: ActiveTenantDep, service: TenantServiceDep) -> list[ @router.patch("/current/members/{user_id}", response_model=MemberView, dependencies=_MANAGE) async def change_role( - user_id: str, data: RoleChange, ctx: ActiveTenantDep, service: TenantServiceDep + 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: ActiveTenantDep, service: TenantServiceDep) -> Response: +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) @@ -117,7 +123,7 @@ async def leave(ctx: ActiveTenantDep, service: TenantServiceDep) -> Response: @router.get("/current/invitations", response_model=list[InvitationView], dependencies=_MANAGE) async def list_invitations( - ctx: ActiveTenantDep, invitations: InvitationServiceDep + ctx: ManagerDep, invitations: InvitationServiceDep ) -> list[InvitationView]: return [InvitationView(**i.model_dump()) for i in await invitations.list_pending(ctx.tenant_id)] @@ -131,18 +137,22 @@ async def list_invitations( async def invite( data: InvitationCreate, request: Request, - ctx: ActiveTenantDep, + ctx: ManagerDep, invitations: InvitationServiceDep, ) -> InvitationIssued: invitation, token, url = await invitations.create( - ctx.tenant_id, data, base_url=str(request.base_url) + # 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: ActiveTenantDep, invitations: InvitationServiceDep + invitation_id: int, ctx: ManagerDep, invitations: InvitationServiceDep ) -> Response: await invitations.revoke(ctx.tenant_id, invitation_id) return Response(status_code=204) diff --git a/modules/tenants/tenants/endpoints/views.py b/modules/tenants/tenants/endpoints/views.py index 4bc845e1..40b728cf 100644 --- a/modules/tenants/tenants/endpoints/views.py +++ b/modules/tenants/tenants/endpoints/views.py @@ -15,6 +15,7 @@ from tenants.constants import ( ENTITLEMENT_SEATS, + MANAGER_ROLES, PAGE_ACCEPT, PAGE_INDEX, PAGE_MEMBERS, @@ -44,6 +45,7 @@ async def 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, }, @@ -65,7 +67,10 @@ async def members( 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) + 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( diff --git a/modules/tenants/tenants/errors.py b/modules/tenants/tenants/errors.py index 0ab8aba7..286d2892 100644 --- a/modules/tenants/tenants/errors.py +++ b/modules/tenants/tenants/errors.py @@ -6,7 +6,7 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse, RedirectResponse, Response -from simple_module_db import TenantIsolationError +from simple_module_db import MissingTenantError, TenantIsolationError from tenants.contracts.entitlements import EntitlementExceededError @@ -41,17 +41,23 @@ async def _entitlement_exceeded(request: Request, exc: Exception) -> Response: async def _isolation_error(request: Request, exc: Exception) -> Response: - """A tenant-scoped query ran without a tenant. + """A tenant isolation rule fired during a request. - With no active tenant this is a user state, not a bug: the user has no - organisation yet, or theirs is suspended — send pages to the picker - instead of a 500. With a tenant resolved it is a cross-tenant write - attempt, which is refused and logged. + 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 tenant_id is not None or user is None: - logger.warning("Tenant isolation violation on %s: %s", request.url.path, exc) + 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) diff --git a/modules/tenants/tenants/hooks/useTenantErrors.ts b/modules/tenants/tenants/hooks/useTenantErrors.ts index 40e7e1fe..c23b8143 100644 --- a/modules/tenants/tenants/hooks/useTenantErrors.ts +++ b/modules/tenants/tenants/hooks/useTenantErrors.ts @@ -19,6 +19,9 @@ export function useTenantErrors() { 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), diff --git a/modules/tenants/tenants/invitations.py b/modules/tenants/tenants/invitations.py index cfc555a0..a5fc79b3 100644 --- a/modules/tenants/tenants/invitations.py +++ b/modules/tenants/tenants/invitations.py @@ -14,7 +14,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from tenants.constants import MembershipRole +from tenants.constants import MembershipRole, TenantStatus from tenants.contracts.events import InvitationCreated from tenants.contracts.schemas import InvitationCreate from tenants.errors import TenantError @@ -60,9 +60,9 @@ 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.get(tenant_id) - if tenant is None: - raise TenantError("not_found", status_code=404) + 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) ) @@ -96,7 +96,9 @@ async def revoke(self, tenant_id: str, invitation_id: int) -> None: await self.db.delete(invitation) await self.db.flush() - async def lookup(self, token: str) -> tuple[Invitation, Tenant] | None: + async def lookup( + self, token: str, *, for_update: bool = False + ) -> tuple[Invitation, Tenant] | None: if not token: return None stmt = ( @@ -104,6 +106,8 @@ async def lookup(self, token: str) -> tuple[Invitation, Tenant] | None: .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 @@ -112,12 +116,14 @@ 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) + 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 diff --git a/modules/tenants/tenants/locales/en.json b/modules/tenants/tenants/locales/en.json index 534e34a1..89894da5 100644 --- a/modules/tenants/tenants/locales/en.json +++ b/modules/tenants/tenants/locales/en.json @@ -34,7 +34,8 @@ "create_submitting": "Creating…", "toast_created": "Organisation created", "toast_switched": "Switched organisation", - "toast_switch_failed": "Could not switch 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", @@ -130,6 +131,9 @@ "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." + "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/pages/Index.tsx b/modules/tenants/tenants/pages/Index.tsx index 08525818..ecdf4ca4 100644 --- a/modules/tenants/tenants/pages/Index.tsx +++ b/modules/tenants/tenants/pages/Index.tsx @@ -11,13 +11,15 @@ 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, can_create, reason } = usePage<{ props: Props }>() - .props as unknown as Props; + const { memberships, active_id, suspended, suspended_name, can_create, reason } = usePage<{ + props: Props; + }>().props as unknown as Props; const { t } = useT(); function reload() { @@ -36,9 +38,14 @@ function Index() { {t(keys.tenants.index.notice_tenant_required)} )} - {suspended && !active_id && ( + {suspended && (
- {t(keys.tenants.index.notice_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)}
)} diff --git a/modules/tenants/tenants/resolver.py b/modules/tenants/tenants/resolver.py index 17b4ea51..d00ab307 100644 --- a/modules/tenants/tenants/resolver.py +++ b/modules/tenants/tenants/resolver.py @@ -42,7 +42,16 @@ ) +# 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() else: @@ -75,6 +84,7 @@ 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 = [ @@ -83,7 +93,8 @@ async def memberships_for(app: FastAPI, user_id: str) -> list[MyTenantView]: ) for t, role in rows ] - _MEMBERSHIPS[user_id] = views + if _epoch == started: + _MEMBERSHIPS[user_id] = views return views @@ -110,6 +121,7 @@ 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) if user is None: return None @@ -134,13 +146,21 @@ async def resolve_tenant(request: Request) -> str | None: 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: + if session is not None and preferred != active.id and not chosen_suspended: session[SESSION_ACTIVE_TENANT] = active.id return _enter(request, user, active) diff --git a/modules/tenants/tenants/service.py b/modules/tenants/tenants/service.py index 19db6e12..1b91daa5 100644 --- a/modules/tenants/tenants/service.py +++ b/modules/tenants/tenants/service.py @@ -18,6 +18,7 @@ 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.constants import ( @@ -107,7 +108,13 @@ async def create_tenant( slug = await self._free_slug(slugify(data.name)) tenant = Tenant(name=data.name, slug=slug) self.db.add(tenant) - await self.db.flush() + 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 ) @@ -148,6 +155,20 @@ async def member_counts(self, tenant_ids: Sequence[str]) -> dict[str, int]: ) 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. (No-op on SQLite, which + serialises writers anyway.) + """ + 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: @@ -165,6 +186,12 @@ async def list_for_user(self, user_id: str) -> list[tuple[Tenant, str]]: ) 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 @@ -189,6 +216,7 @@ async def seats_used(self, tenant_id: str) -> int: 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) @@ -210,7 +238,10 @@ async def add_member( 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) - await self.db.flush() + 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 @@ -225,6 +256,7 @@ async def _owner_count(self, tenant_id: str) -> int: 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) @@ -240,6 +272,7 @@ async def change_role( 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) diff --git a/modules/tenants/tenants/settings.py b/modules/tenants/tenants/settings.py index a8b86c68..0a046c79 100644 --- a/modules/tenants/tenants/settings.py +++ b/modules/tenants/tenants/settings.py @@ -13,6 +13,13 @@ class TenantsSettings(DbBackedSettings): 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." + ), + ) 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/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/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index d273b511..d170d116 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -721,6 +721,7 @@ export default { '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': '', @@ -733,6 +734,8 @@ export default { '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': '', @@ -750,6 +753,7 @@ export default { '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': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 26e9a8b9..f9c2a15a 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -932,6 +932,7 @@ export const keys = { }, 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', @@ -944,6 +945,8 @@ export const keys = { 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', }, @@ -963,6 +966,7 @@ export const keys = { 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', diff --git a/scripts/run_worker.py b/scripts/run_worker.py index 8db0c5c6..109f2df0 100644 --- a/scripts/run_worker.py +++ b/scripts/run_worker.py @@ -33,6 +33,14 @@ 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 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) # Module-level name ``celery`` is what ``celery -A scripts.run_worker:celery`` # looks for. Keep it stable. From b5e401d66e7e391e4b99ac78d5d3632472b8e60b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:42:54 +0000 Subject: [PATCH 09/13] fix(tenants): make the last-owner rule atomic on every backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA showed two owners demoting each other at once still left a tenant with no owners on a real SQLite file (8 of 12 runs): pysqlite reads outside a transaction and FOR UPDATE compiles away, so both requests counted two owners. The rule now lives in the write itself — the UPDATE/DELETE only matches while another owner exists — and the tenant row lock stays for Postgres READ COMMITTED. Regression test runs on a file-backed database (15/15 after, 4/12 before). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- modules/tenants/tenants/_owner_guard.py | 68 ++++++++++++++++++++++++ modules/tenants/tenants/service.py | 23 ++++---- modules/tenants/tests/test_owner_race.py | 52 ++++++++++++++++++ 3 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 modules/tenants/tenants/_owner_guard.py create mode 100644 modules/tenants/tests/test_owner_race.py 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/service.py b/modules/tenants/tenants/service.py index 1b91daa5..8492f78b 100644 --- a/modules/tenants/tenants/service.py +++ b/modules/tenants/tenants/service.py @@ -21,6 +21,7 @@ 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, @@ -160,8 +161,9 @@ async def lock(self, tenant_id: str) -> Tenant: 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. (No-op on SQLite, which - serialises writers anyway.) + 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() @@ -264,10 +266,12 @@ async def change_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 and await self._owner_count(tenant_id) <= 1: - raise TenantError("last_owner", status_code=409) - membership.role = role - await self.db.flush() + 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 @@ -279,8 +283,9 @@ async def remove_member(self, tenant_id: str, user_id: str, *, actor_role: str) if membership.role == MembershipRole.OWNER: if actor_role != MembershipRole.OWNER: raise TenantError("owner_required", status_code=403) - if await self._owner_count(tenant_id) <= 1: + if not await remove_owner(self.db, membership): raise TenantError("last_owner", status_code=409) - await self.db.delete(membership) - await self.db.flush() + 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/tests/test_owner_race.py b/modules/tenants/tests/test_owner_race.py new file mode 100644 index 00000000..effe31b3 --- /dev/null +++ b/modules/tenants/tests/test_owner_race.py @@ -0,0 +1,52 @@ +"""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. 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 + +from simple_module_db.listeners import register_listeners +from simple_module_db.session import init_db +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 + + +async def test_concurrent_demotion_of_the_two_owners_keeps_one(tmp_path): + 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() From 9000899117298ceddb5ff422b9988eb7ae6837ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:31:43 +0000 Subject: [PATCH 10/13] feat(tenancy): close #332 (tenant half), #359, #363, #364; run tests on Postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #332: tenant criteria are attached for every tenant-scoped model, so a tenant table reached through a join target, an ORM exists()/in_()/scalar subquery or count().select_from() is scoped; top-level Core statements on Model.__table__ get an explicit tenant_id predicate (insert: stamped). With no tenant under strict mode, direct references raise and indirect ones match nothing. Only a bare Core exists().where() stays unscoped (documented). - #359: HostSettings.default_tenant — single-tenant hosts run mixin tables as one tenant for requests and background tasks; ignored when multi_tenant is on. - #364: bind_current_tenant(fn) carries the tenant into work a module defers past the request; db.on_commit and BackgroundTasks already run in scope (tested). - #363: the tenants module resolves the tenant from the subdomain (subdomain_base), for anonymous visitors on public routes, members with their role, never for a non-member on an authenticated route. - #343: SM_TEST_DATABASE_URL runs the fixtures and the tenancy DB tests on Postgres, each test on an empty schema. Tenancy suites pass there, including the concurrent last-owner test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- CHANGELOG.md | 8 ++ CLAUDE.md | 2 +- docs/framework/multi-tenancy.md | 53 ++++++-- docs/modules/tenants.md | 4 + framework/db/simple_module_db/__init__.py | 2 + framework/db/simple_module_db/query_filter.py | 106 ++++++++++++---- framework/db/simple_module_db/tenancy.py | 48 ++++++- framework/db/tests/conftest.py | 6 +- framework/db/tests/test_tenant_dml.py | 11 +- framework/db/tests/test_tenant_shapes.py | 117 ++++++++++++++++++ framework/db/tests/test_tenant_strict.py | 6 +- .../simple_module_hosting/_phase_helpers.py | 4 + .../hosting/simple_module_hosting/_tenant.py | 8 +- .../simple_module_hosting/host_settings.py | 16 +++ .../tests/test_tenant_scope_helpers.py | 99 +++++++++++++++ .../testing/simple_module_test/_schema.py | 3 + .../testing/simple_module_test/database.py | 53 ++++++++ .../testing/simple_module_test/fixtures.py | 6 +- .../background_tasks/module.py | 7 +- .../background_tasks/tenant_context.py | 18 ++- modules/tenants/README.md | 4 + modules/tenants/tenants/host_resolver.py | 86 +++++++++++++ modules/tenants/tenants/resolver.py | 24 ++++ modules/tenants/tenants/settings.py | 8 ++ modules/tenants/tests/test_owner_race.py | 27 +++- modules/tenants/tests/test_subdomains.py | 95 ++++++++++++++ scripts/run_worker.py | 2 + 27 files changed, 776 insertions(+), 47 deletions(-) create mode 100644 framework/db/tests/test_tenant_shapes.py create mode 100644 framework/hosting/tests/test_tenant_scope_helpers.py create mode 100644 framework/testing/simple_module_test/database.py create mode 100644 modules/tenants/tenants/host_resolver.py create mode 100644 modules/tenants/tests/test_subdomains.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 59daee54..ef955174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,14 @@ All notable changes to this project are documented in this file. The format is b 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 criteria reach join targets, ORM subqueries, `count().select_from()` + and top-level Core statements on `Model.__table__` (#332 — the tenant half; + soft-delete is #344's). +- `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 diff --git a/CLAUDE.md b/CLAUDE.md index b52040ab..482631fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ Meaningful codes when reading `make doctor` output: `SM001` missing meta (error) ## 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 (`simple_module_test.database`). - `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. diff --git a/docs/framework/multi-tenancy.md b/docs/framework/multi-tenancy.md index bd0e9746..87beccf2 100644 --- a/docs/framework/multi-tenancy.md +++ b/docs/framework/multi-tenancy.md @@ -28,13 +28,22 @@ reading every tenant's data. ### What the filter can see -Scoping works on the ORM entities a statement *names*: `select(Model)`, -`update(Model)`, `delete(Model)`, `insert(Model)`, relationship loads. It does -**not** reach a tenant table that appears only as a join target, inside an -`exists()` / `in_()` / scalar subquery, as `select(func.count()).select_from(Model)`, -or in a Core statement on `Model.__table__` (#332). Such statements are neither -filtered nor, under strict mode, refused — name the entity, or add the -`tenant_id` predicate yourself. +Tenant criteria are attached for **every** tenant-scoped model, not only the +entities a statement names, so these are all scoped (#332): + +- `select(Model)`, relationship loads, ORM `update()`/`delete()`/`insert()`; +- a tenant table as a **join target**: `select(Project).join(Doc)`; +- ORM subqueries: `select(Doc.id).where(...).exists()`, `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 = …`, `insert` is stamped. + +With no tenant under strict mode, a statement that *names* a tenant model or +table raises; an indirect reference (a join, a subquery) matches nothing. + +The one shape left unscoped is a bare Core `exists().where(Doc.x == ...)`; +write `select(Doc.id).where(...).exists()` instead. ### One session, one tenant @@ -74,6 +83,31 @@ for tenant_id in tenant_ids: 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 @@ -94,7 +128,10 @@ same — `attach_session_listeners(MySession)` plus `TenantMiddleware` asks `app.state.tenant_resolver` (an `async (Request) -> str | None`) when a module registered one — `tenants` -does — and its answer is final. Without one it falls back to the principal's +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. diff --git a/docs/modules/tenants.md b/docs/modules/tenants.md index 773d53c0..f54317ac 100644 --- a/docs/modules/tenants.md +++ b/docs/modules/tenants.md @@ -54,6 +54,10 @@ 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 diff --git a/framework/db/simple_module_db/__init__.py b/framework/db/simple_module_db/__init__.py index a0885b83..a9af7309 100644 --- a/framework/db/simple_module_db/__init__.py +++ b/framework/db/simple_module_db/__init__.py @@ -20,6 +20,7 @@ MissingTenantError, TenantIsolationError, all_tenants, + bind_current_tenant, current_tenant_id, is_valid_tenant_id, tenant_context, @@ -43,6 +44,7 @@ "TenantIsolationError", "VersionedMixin", "all_tenants", + "bind_current_tenant", "build_module_metadata", "create_module_base", "current_tenant_id", diff --git a/framework/db/simple_module_db/query_filter.py b/framework/db/simple_module_db/query_filter.py index afb7fda4..73bb092d 100644 --- a/framework/db/simple_module_db/query_filter.py +++ b/framework/db/simple_module_db/query_filter.py @@ -1,12 +1,12 @@ """``do_orm_execute`` filter: soft-delete and tenant scoping on every ORM statement. -Scope — what this filter can and cannot see. It works on the ORM entities a -statement names (``execute_state.all_mappers``): ``select(Model)``, -``update(Model)``, ``delete(Model)``, ``insert(Model)``, relationship loads. -A tenant-scoped table reached only through a join target, an ``exists()`` / -``in_()`` subquery, ``select(func.count()).select_from(Model)`` or a Core -statement on ``Model.__table__`` is NOT scoped (#332) — module code must name -the entity or add the ``tenant_id`` predicate itself. +Scope (#332). Tenant criteria are attached for *every* tenant-scoped model, +not only the entities a statement names, so a tenant 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 an +explicit ``WHERE tenant_id = ...``. The one shape left unscoped is a bare Core +``exists().where(...)``; write ``select(Model...).where(...).exists()``. """ from __future__ import annotations @@ -14,8 +14,9 @@ from typing import Any from weakref import WeakKeyDictionary +from sqlalchemy import event from sqlalchemy.engine import Engine -from sqlalchemy.orm import ORMExecuteState, Session, with_loader_criteria +from sqlalchemy.orm import Mapper, ORMExecuteState, Session, with_loader_criteria from simple_module_db.mixins import MultiTenantMixin, SoftDeleteMixin from simple_module_db.tenancy import ( @@ -64,6 +65,24 @@ def is_strict(session: Session) -> bool: _mixin_flags_cache: dict[type, tuple[bool, bool]] = {} +# Every mapped MultiTenantMixin class, and its table names — filled as mappers +# configure, so criteria can be attached for models a statement doesn't name. +_tenant_classes: set[type] = set() +_tenant_table_names: set[str] = set() + + +def _register(cls: type) -> None: + if cls not in _tenant_classes: + _tenant_classes.add(cls) + _tenant_table_names.add(cls.__table__.name) + + +@event.listens_for(Mapper, "mapper_configured") +def _on_mapper_configured(mapper: Mapper, cls: type) -> None: + if issubclass(cls, MultiTenantMixin): + _register(cls) + + def _flags(cls: type) -> tuple[bool, bool]: flags = _mixin_flags_cache.get(cls) if flags is None: @@ -100,6 +119,7 @@ def filter_statements(execute_state: ORMExecuteState) -> Any: return None options = [] + named: list[type] = [] for mapper in execute_state.all_mappers: cls = mapper.class_ is_soft_delete, is_multi_tenant = _flags(cls) @@ -107,23 +127,64 @@ def filter_statements(execute_state: ORMExecuteState) -> Any: options.append( with_loader_criteria(cls, cls.is_deleted.is_(False), include_aliases=True) ) - if not is_multi_tenant or skip_tenant: - continue - if execute_state.is_update and _assigned_tenant_ids(execute_state): - raise TenantIsolationError("Cannot change tenant_id of existing rows") - if tenant_id is not None: - options.append( - with_loader_criteria(cls, cls.tenant_id == tenant_id, include_aliases=True) - ) - elif strict: - op = "SELECT" if is_select else ("UPDATE" if execute_state.is_update else "DELETE") - raise missing_tenant_error(cls.__name__, op) + if is_multi_tenant: + _register(cls) + named.append(cls) + if not skip_tenant: + options.extend(_tenant_criteria(execute_state, named, tenant_id, strict)) if options: execute_state.statement = execute_state.statement.options(*options) return None +def _core_tenant_tables(execute_state: ORMExecuteState) -> list[Any]: + """Tenant 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 getattr(f, "name", None) in _tenant_table_names + and "parententity" not in getattr(f, "_annotations", {}) + ] + + +def _tenant_criteria( + execute_state: ORMExecuteState, named: list[type], tenant_id: str | None, strict: bool +) -> list[Any]: + core = _core_tenant_tables(execute_state) + 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 _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 _tenant_classes | set(named) + ] + + def _bound_value(value: Any) -> Any: return getattr(value, "value", value) @@ -154,7 +215,10 @@ def _assigned_tenant_ids(execute_state: ORMExecuteState) -> list[Any]: def _guard_insert(execute_state: ORMExecuteState) -> Any: - mappers = [m.class_ for m in execute_state.all_mappers if _flags(m.class_)[1]] + mappers = [m.class_.__name__ for m in execute_state.all_mappers if _flags(m.class_)[1]] + target = getattr(execute_state.statement, "table", None) + if not mappers and getattr(target, "name", None) in _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(): @@ -175,7 +239,7 @@ def _guard_insert(execute_state: ORMExecuteState) -> Any: return None if tenant_id is None: if _strict_configured(execute_state.session): - raise missing_tenant_error(mappers[0].__name__, "INSERT") + 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] diff --git a/framework/db/simple_module_db/tenancy.py b/framework/db/simple_module_db/tenancy.py index 9656793e..71549ced 100644 --- a/framework/db/simple_module_db/tenancy.py +++ b/framework/db/simple_module_db/tenancy.py @@ -21,10 +21,13 @@ from __future__ import annotations +import functools +import inspect import re -from collections.abc import Iterator +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) @@ -95,6 +98,48 @@ def all_tenants() -> Iterator[None]: 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() @@ -115,6 +160,7 @@ def missing_tenant_error(entity: str, operation: str) -> MissingTenantError: "MissingTenantError", "TenantIsolationError", "all_tenants", + "bind_current_tenant", "current_tenant_id", "is_all_tenants", "is_valid_tenant_id", 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_tenant_dml.py b/framework/db/tests/test_tenant_dml.py index 95210c94..11772cee 100644 --- a/framework/db/tests/test_tenant_dml.py +++ b/framework/db/tests/test_tenant_dml.py @@ -13,14 +13,19 @@ 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) -> tuple: - state = init_db("sqlite+aiosqlite:///:memory:") + +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() @@ -120,7 +125,7 @@ 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) + other_state, other_session = await _session(strict=False, fresh=False) try: with pytest.raises(TenantIsolationError): await strict_session.execute(select(_TenantItem)) diff --git a/framework/db/tests/test_tenant_shapes.py b/framework/db/tests/test_tenant_shapes.py new file mode 100644 index 00000000..46a76e3a --- /dev/null +++ b/framework/db/tests/test_tenant_shapes.py @@ -0,0 +1,117 @@ +"""#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, 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), + } + + +# 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",)], +} + + +@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"]) +async def test_strict_without_tenant_fails_closed(db: AsyncSession, shape: str): + stmt = _shapes()[shape] + if shape == "core table": + 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 index 9ad8413c..10a4bc0c 100644 --- a/framework/db/tests/test_tenant_strict.py +++ b/framework/db/tests/test_tenant_strict.py @@ -9,16 +9,20 @@ 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("sqlite+aiosqlite:///:memory:") + 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: 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 index 913cb39c..1f6296a0 100644 --- a/framework/hosting/simple_module_hosting/_tenant.py +++ b/framework/hosting/simple_module_hosting/_tenant.py @@ -45,9 +45,13 @@ class TenantMiddleware: to name anyone's. """ - def __init__(self, app: ASGIApp, *, header: str | None = None) -> None: + 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: @@ -69,6 +73,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 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 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/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..21ee9435 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 + bases = _ensure_models_imported() heads = _alembic_heads() + await reset_schema(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..bcaed48a --- /dev/null +++ b/framework/testing/simple_module_test/database.py @@ -0,0 +1,53 @@ +"""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")) + + +__all__ = [ + "SQLITE_MEMORY", + "TEST_DATABASE_ENV", + "database_url_for_tests", + "init_db_kwargs", + "is_sqlite", + "reset_schema", +] diff --git a/framework/testing/simple_module_test/fixtures.py b/framework/testing/simple_module_test/fixtures.py index 011d431e..317953d6 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 database_url_for_tests, init_db_kwargs from simple_module_test.session_cookie import forge_session_cookie _AUTH_PROVIDER_ENV = "SM_AUTH_PROVIDER" @@ -78,7 +79,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 +93,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/modules/background_tasks/background_tasks/module.py b/modules/background_tasks/background_tasks/module.py index 5300ad77..4255a68e 100644 --- a/modules/background_tasks/background_tasks/module.py +++ b/modules/background_tasks/background_tasks/module.py @@ -152,6 +152,7 @@ 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 @@ -159,9 +160,9 @@ async def on_startup(self, app: FastAPI) -> None: # to ``os.environ``, so signals would otherwise fall back to the # SQLite default and silently drop ``TaskExecution`` rows. host = app.state.sm.settings - set_database_url( - host.database_url, tenant_strict=bool(getattr(host, "multi_tenant", False)) - ) + 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/tenant_context.py b/modules/background_tasks/background_tasks/tenant_context.py index 25bbfa92..3bc01d55 100644 --- a/modules/background_tasks/background_tasks/tenant_context.py +++ b/modules/background_tasks/background_tasks/tenant_context.py @@ -21,6 +21,14 @@ _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: @@ -68,7 +76,7 @@ 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) + tenant_id = _tenant_of(task) or _default_tenant if tenant_id is not None: _tokens[task_id] = current_tenant_id.set(tenant_id) @@ -87,4 +95,10 @@ def release_tenant(*, task_id: str | None) -> None: current_tenant_id.set(None) -__all__ = ["TENANT_HEADER", "release_tenant", "restore_tenant", "stamp_tenant"] +__all__ = [ + "TENANT_HEADER", + "release_tenant", + "restore_tenant", + "set_default_tenant", + "stamp_tenant", +] diff --git a/modules/tenants/README.md b/modules/tenants/README.md index 9b538181..13d03483 100644 --- a/modules/tenants/README.md +++ b/modules/tenants/README.md @@ -54,6 +54,10 @@ 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 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/resolver.py b/modules/tenants/tenants/resolver.py index d00ab307..e391907d 100644 --- a/modules/tenants/tenants/resolver.py +++ b/modules/tenants/tenants/resolver.py @@ -31,6 +31,12 @@ 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__) @@ -54,6 +60,7 @@ def forget(key: str | None) -> None: _epoch += 1 if key is None: _MEMBERSHIPS.clear() + forget_hosts() # a status change: cached subdomain lookups are stale too else: _MEMBERSHIPS.pop(key, None) @@ -123,6 +130,9 @@ async def resolve_tenant(request: Request) -> str | 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) @@ -165,6 +175,20 @@ async def resolve_tenant(request: Request) -> str | None: 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 "" diff --git a/modules/tenants/tenants/settings.py b/modules/tenants/tenants/settings.py index 0a046c79..47e4a3d4 100644 --- a/modules/tenants/tenants/settings.py +++ b/modules/tenants/tenants/settings.py @@ -20,6 +20,14 @@ class TenantsSettings(DbBackedSettings): "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/tests/test_owner_race.py b/modules/tenants/tests/test_owner_race.py index effe31b3..9a3092a3 100644 --- a/modules/tenants/tests/test_owner_race.py +++ b/modules/tenants/tests/test_owner_race.py @@ -1,7 +1,8 @@ """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. The ``app`` fixture's in-memory +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. """ @@ -10,8 +11,15 @@ 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 @@ -19,8 +27,21 @@ from tenants.service import TenantService -async def test_concurrent_demotion_of_the_two_owners_keeps_one(tmp_path): - state = init_db(f"sqlite+aiosqlite:///{tmp_path}/race.db") +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: 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/scripts/run_worker.py b/scripts/run_worker.py index 109f2df0..7e4f58a2 100644 --- a/scripts/run_worker.py +++ b/scripts/run_worker.py @@ -34,6 +34,7 @@ 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 @@ -41,6 +42,7 @@ # 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. From 6da3e10388aaf6ae05db50f8c1f33224dff2034d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:46:30 +0000 Subject: [PATCH 11/13] fix(db): scope bare Core exists() and extend soft-delete to joins and counts (#332) The two gaps left after the tenant half of #332: - A bare Core `exists().where(Model.x == ...)` is never ORM-compiled, so loader criteria never reached it. A cheap scan of the WHERE/column/HAVING clauses (~10 us, skipped when no tenant or soft-delete model exists) finds an Exists and only then rewrites nested SELECTs with the tenant and soft-delete predicates; under strict mode with no tenant it raises. - Soft-delete criteria are attached for every soft-deletable model on reads, and top-level Core statements get `is_deleted IS false`, so joins, subqueries and counts no longer surface trashed rows. Behaviour change, documented; include_deleted=True still bypasses it. The model registry moves to model_registry.py. New shape tests fail on the previous filter (9 of 21) and pass on SQLite and Postgres. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- CHANGELOG.md | 8 +- docs/framework-conventions.md | 2 +- docs/framework/multi-tenancy.md | 22 ++-- .../db/simple_module_db/model_registry.py | 56 +++++++++ framework/db/simple_module_db/query_filter.py | 117 ++++++++---------- .../db/simple_module_db/subquery_guard.py | 49 ++++++++ framework/db/tests/test_soft_delete_shapes.py | 89 +++++++++++++ framework/db/tests/test_tenant_shapes.py | 10 +- 8 files changed, 273 insertions(+), 80 deletions(-) create mode 100644 framework/db/simple_module_db/model_registry.py create mode 100644 framework/db/simple_module_db/subquery_guard.py create mode 100644 framework/db/tests/test_soft_delete_shapes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ef955174..8e69aaaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,9 +85,11 @@ All notable changes to this project are documented in this file. The format is b 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 criteria reach join targets, ORM subqueries, `count().select_from()` - and top-level Core statements on `Model.__table__` (#332 — the tenant half; - soft-delete is #344's). +- 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 diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 91580d45..bcdfcdff 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -233,7 +233,7 @@ 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)`. +- `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. diff --git a/docs/framework/multi-tenancy.md b/docs/framework/multi-tenancy.md index 87beccf2..9ce0f2cb 100644 --- a/docs/framework/multi-tenancy.md +++ b/docs/framework/multi-tenancy.md @@ -28,22 +28,24 @@ reading every tenant's data. ### What the filter can see -Tenant criteria are attached for **every** tenant-scoped model, not only the -entities a statement names, so these are all scoped (#332): +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 tenant table as a **join target**: `select(Project).join(Doc)`; -- ORM subqueries: `select(Doc.id).where(...).exists()`, `in_(select(Doc.x))`, - scalar subqueries; +- 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 = …`, `insert` is stamped. + `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 raises; an indirect reference (a join, a subquery) matches nothing. - -The one shape left unscoped is a bare Core `exists().where(Doc.x == ...)`; -write `select(Doc.id).where(...).exists()` instead. +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 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 index 73bb092d..b5a9b8ba 100644 --- a/framework/db/simple_module_db/query_filter.py +++ b/framework/db/simple_module_db/query_filter.py @@ -1,12 +1,12 @@ """``do_orm_execute`` filter: soft-delete and tenant scoping on every ORM statement. -Scope (#332). Tenant criteria are attached for *every* tenant-scoped model, -not only the entities a statement names, so a tenant 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 an -explicit ``WHERE tenant_id = ...``. The one shape left unscoped is a bare Core -``exists().where(...)``; write ``select(Model...).where(...).exists()``. +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 @@ -14,11 +14,11 @@ from typing import Any from weakref import WeakKeyDictionary -from sqlalchemy import event from sqlalchemy.engine import Engine -from sqlalchemy.orm import Mapper, ORMExecuteState, Session, with_loader_criteria +from sqlalchemy.orm import ORMExecuteState, Session, with_loader_criteria -from simple_module_db.mixins import MultiTenantMixin, SoftDeleteMixin +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, @@ -60,37 +60,6 @@ def is_strict(session: Session) -> bool: return _strict_configured(session) and not is_all_tenants() -# 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]] = {} - - -# Every mapped MultiTenantMixin class, and its table names — filled as mappers -# configure, so criteria can be attached for models a statement doesn't name. -_tenant_classes: set[type] = set() -_tenant_table_names: set[str] = set() - - -def _register(cls: type) -> None: - if cls not in _tenant_classes: - _tenant_classes.add(cls) - _tenant_table_names.add(cls.__table__.name) - - -@event.listens_for(Mapper, "mapper_configured") -def _on_mapper_configured(mapper: Mapper, cls: type) -> None: - if issubclass(cls, MultiTenantMixin): - _register(cls) - - -def _flags(cls: type) -> tuple[bool, bool]: - flags = _mixin_flags_cache.get(cls) - if flags is None: - flags = (issubclass(cls, SoftDeleteMixin), issubclass(cls, MultiTenantMixin)) - _mixin_flags_cache[cls] = flags - return flags - - def filter_statements(execute_state: ORMExecuteState) -> Any: """Scope ORM statements by soft-delete and tenant. @@ -121,25 +90,52 @@ def filter_statements(execute_state: ORMExecuteState) -> Any: options = [] named: list[type] = [] for mapper in execute_state.all_mappers: - cls = mapper.class_ - is_soft_delete, is_multi_tenant = _flags(cls) - 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: - _register(cls) - named.append(cls) + 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 _core_tenant_tables(execute_state: ORMExecuteState) -> list[Any]: - """Tenant tables a statement uses as plain Core tables (``Model.__table__``). +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. @@ -149,18 +145,13 @@ def _core_tenant_tables(execute_state: ORMExecuteState) -> list[Any]: froms = [*getattr(stmt, "columns_clause_froms", ()), *getattr(stmt, "_from_obj", ())] else: froms = [getattr(stmt, "table", None)] - return [ - f - for f in froms - if getattr(f, "name", None) in _tenant_table_names - and "parententity" not in getattr(f, "_annotations", {}) - ] + 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 = _core_tenant_tables(execute_state) + 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 = ( @@ -175,13 +166,13 @@ def _tenant_criteria( # 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 _tenant_classes + 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 _tenant_classes | set(named) + for c in registry.tenant_classes | set(named) ] @@ -215,9 +206,9 @@ def _assigned_tenant_ids(execute_state: ORMExecuteState) -> list[Any]: def _guard_insert(execute_state: ORMExecuteState) -> Any: - mappers = [m.class_.__name__ for m in execute_state.all_mappers if _flags(m.class_)[1]] + 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 _tenant_table_names: + 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 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/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_shapes.py b/framework/db/tests/test_tenant_shapes.py index 46a76e3a..77ad9b44 100644 --- a/framework/db/tests/test_tenant_shapes.py +++ b/framework/db/tests/test_tenant_shapes.py @@ -14,7 +14,7 @@ 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, insert, select, update +from sqlalchemy import delete, exists, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field @@ -69,6 +69,9 @@ def _shapes(): "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") + ), } @@ -81,6 +84,7 @@ def _shapes(): "count select_from": [(1,)], "count of subquery": [(1,)], "core table": [("doc-a",)], + "bare core exists": [], } @@ -91,10 +95,10 @@ async def test_bound_tenant_never_sees_other_tenants(db: AsyncSession, shape: st assert [tuple(r) for r in rows] == _EXPECTED_FOR_A[shape] -@pytest.mark.parametrize("shape", ["core table", "count select_from"]) +@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 == "core table": + 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 From 9eba45609812fa698d357ee12548f9a98e02fc3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:07:18 +0000 Subject: [PATCH 12/13] test: make the full Python suite pass on Postgres (#343) The first full run with SM_TEST_DATABASE_URL gave 26 failed and 50 errors, none of them caused by tenancy: - 73 were from @pytest.mark.anyio tests running on anyio's event loop while their async fixtures ran on pytest-asyncio's. An asyncpg connection cannot cross loops; aiosqlite's worker thread hides this on SQLite. The new `make test-py-pg` target passes -p no:anyio, and asyncio_mode=auto still runs those tests. - `app` and `db_session` each reset the Postgres schema, so a test asking for both lost the seeded admin (a 401). The reset now happens once per test, through an autouse marker fixture. - test_user_role_model relied on insert order with no relationship(), and ran a SQLite-only PRAGMA. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- CHANGELOG.md | 4 +++ CLAUDE.md | 3 ++- Makefile | 12 ++++++++- .../testing/simple_module_test/_schema.py | 4 +-- .../testing/simple_module_test/database.py | 25 +++++++++++++++++++ .../testing/simple_module_test/fixtures.py | 12 ++++++++- .../testing/simple_module_test/plugin.py | 1 + modules/users/tests/test_user_role_model.py | 17 ++++++++++--- 8 files changed, 69 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e69aaaf..c754a425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ 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 / diff --git a/CLAUDE.md b/CLAUDE.md index 482631fc..ee51ca98 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 | @@ -114,7 +115,7 @@ Meaningful codes when reading `make doctor` output: `SM001` missing meta (error) ## 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`. 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 (`simple_module_test.database`). +- `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. 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/framework/testing/simple_module_test/_schema.py b/framework/testing/simple_module_test/_schema.py index 21ee9435..f420c7f4 100644 --- a/framework/testing/simple_module_test/_schema.py +++ b/framework/testing/simple_module_test/_schema.py @@ -51,11 +51,11 @@ async def _create_all_tables(engine) -> None: """ from sqlalchemy import text - from simple_module_test.database import reset_schema + from simple_module_test.database import reset_schema_once bases = _ensure_models_imported() heads = _alembic_heads() - await reset_schema(engine) # Postgres: start empty; SQLite memory already is + 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 index bcaed48a..477f1692 100644 --- a/framework/testing/simple_module_test/database.py +++ b/framework/testing/simple_module_test/database.py @@ -43,11 +43,36 @@ async def reset_schema(engine: Any) -> None: 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 317953d6..53989ab1 100644 --- a/framework/testing/simple_module_test/fixtures.py +++ b/framework/testing/simple_module_test/fixtures.py @@ -31,7 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from simple_module_test._schema import _create_all_tables -from simple_module_test.database import database_url_for_tests, init_db_kwargs +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" @@ -64,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. 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/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() From 6741f8fd5b6f9c062db63e0feca496aff247d0ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:29:23 +0000 Subject: [PATCH 13/13] docs(claude): pick subagent models by task haiku for search and mechanical work, sonnet for routine implementation and testing, opus only for design and security reasoning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ee51ca98..d18bc1c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,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):