Skip to content

feat(tenancy): fail-closed isolation + tenants module (SaaS groundwork) - #370

Draft
antosubash wants to merge 13 commits into
mainfrom
claude/saas-module-planning-xd7pl4
Draft

antosubash wants to merge 13 commits into
mainfrom
claude/saas-module-planning-xd7pl4

Conversation

@antosubash

@antosubash antosubash commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Groundwork for running SaaS installs: tenant isolation now fails closed in the framework, and a new tenants module adds organisations, memberships (one user can belong to many) and invitations, plus the hooks a billing module will plug into. Billing itself is out of scope. Design: docs/plans/2026-09-27-saas-tenancy-design.md.

Closes #332, closes #343, closes #355, closes #356, closes #357, closes #358, closes #359, closes #363, closes #364, closes #365, closes #366.
Partly addresses #362 and #371 (details below).

Why

MultiTenantMixin existed, but tenancy was open by default:

  • No model used the mixin.
  • Queries with no tenant set returned every tenant's rows.
  • ORM update()/delete() weren't tenant-scoped at all.
  • A row could move to another tenant whenever no tenant was set.
  • A signed-in user with no tenant could pick any tenant through the X-Tenant-ID header.
  • Background jobs ran without a tenant.
  • There was no tenant entity, and each user could belong to only one tenant.

Framework

tenants module

  • Tables: tenant, membership (unique on (tenant_id, user_id), role owner/admin/member) and invitation (stores only a SHA-256 of the token; can only be accepted by the invited email).
  • Resolver: the session stores the preferred tenant, which is checked against a membership on every request.
  • Per-tenant roles: the membership role becomes tenant:<role> for the active tenant only.
    • Management routes also require an owner or admin role in the active tenant, so a platform-wide permission alone doesn't make a plain member a manager.
    • Tenant-level routes always act on the active tenant, never on an id taken from the URL.
  • Invariants that hold under concurrency:
    • The last-owner rule is enforced inside the UPDATE/DELETE itself, with a row lock on the tenant for Postgres.
    • Seat checks lock the tenant row.
    • Concurrent creates with the same slug, or accepts of the same invitation, return 409.
  • Invitation links: built from the public_base_url setting, never from the Host header.
  • Invitation rules: no accepting into a suspended tenant, and no re-inviting an existing member.
  • Billing hooks: an EntitlementProvider (seat limits return 402), TenantService.set_status, and events published after commit.
  • Pages: /tenants/, /tenants/members, /tenants/invitations/accept, /admin/tenants/.
  • Migration: a new independent tenants branch.

QA

Five agents tested this in parallel. Each finding was verified before being fixed, and each fix has a regression test. 18 confirmed bugs, all fixed.

  • Adversarial isolation: 10 bugs found and fixed:
    • tenant_context was ignored inside all_tenants().
    • update().values(tenant_id=…) could move rows to another tenant.
    • A cached object from another tenant could be written after a tenant switch.
    • The worker ran with no tenant enforcement.
    • A membership-cache race let a removed member keep access.
    • The last-owner and seat checks were check-then-act races.
    • Invitation links were built from the Host header.
    • A platform permission gave a plain member management rights in a tenant.
    • A second database connection setup turned strict mode off.
    • Invitations could be accepted into a suspended tenant.
  • API functional (~100 cases): the slug format wasn't enforced, and concurrent writes returned 500s. The last-owner race was reproduced on a real SQLite file, which led to the atomic rule above.
  • Browser E2E: 9 scenarios at desktop width and 375px. One bug: a suspended organisation was switched away from silently.
  • Regression: full suite, all lint and typecheck gates, Alembic up/down/check, make doctor compared with main (no new warnings), production-mode boot with multi_tenant on and off, a real Celery run, and back-compat checks.
  • Docs vs code: one wording fix.

Decisions for a reviewer

Not in this PR

Testing

  • SQLite: 3,276 passed (CI green on 9eba456).
  • Postgres 16: 3,277 passed, 0 failed via make test-py-pg.
    • The first Postgres run had 76 failures and errors, none from tenancy:
      • 73 were @pytest.mark.anyio tests running on a different event loop from their fixtures.
      • One was the fixture schema-reset bug fixed here.
      • Two were a test that relied on insert order.
  • Concurrent last-owner test: 15/15 on file SQLite (was 4/12 before the fix) and 10/10 on Postgres.
  • New tests:
    • DB: strict mode, DML scoping, statement shapes (joins, subqueries, counts, Core exists()), soft-delete shapes, the session cache, nested bypass blocks.
    • Middleware and header rules.
    • SM024.
    • Celery: tenant propagation and worker enforcement.
    • tenants: 42 tests, including subdomains and the concurrency test.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE

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:<role> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6741f8f
Status: ✅  Deploy successful!
Preview URL: https://f7f4987d.simple-module-python.pages.dev
Branch Preview URL: https://claude-saas-module-planning.simple-module-python.pages.dev

View logs

/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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
test_multi_tenancy.py went over the 300-line cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
…on Postgres

- #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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment