feat(tenancy): fail-closed isolation + tenants module (SaaS groundwork) - #370
Draft
antosubash wants to merge 13 commits into
Draft
antosubash wants to merge 13 commits into
antosubash wants to merge 13 commits into
Conversation
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE
Deploying simple-module-python with
|
| 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 |
/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
This was referenced Sep 27, 2026
) 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for running SaaS installs: tenant isolation now fails closed in the framework, and a new
tenantsmodule 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
MultiTenantMixinexisted, but tenancy was open by default:update()/delete()weren't tenant-scoped at all.X-Tenant-IDheader.Framework
multi_tenantis on, and is stored per engine. A query or write on a tenant-scoped model with no tenant set raisesMissingTenantError, a subclass ofTenantIsolationError.all_tenants()orexecution_options(all_tenants=True).tenant_context(id). It takes effect even when nested insideall_tenants().select(func.count()), subquery counts) #332):exists()/in_()/scalar subqueries andcount().select_from()are covered.Model.__table__get an explicit predicate, and a bare Coreexists().where(…)has its inner select rewritten.include_deleted=Truestill bypasses it.update()anddelete()are tenant-scoped, andupdate().values(tenant_id=…)is refused.insert(Model), bulk or with.values(), is stamped with the tenant and refused for a different tenant (MultiTenantMixin: bulk insert() is not stamped with tenant_id #357).tenant_idis refused whether or not a tenant is set. Only code inside anall_tenants()block may move a row.TenantMiddlewareusesapp.state.tenant_resolverwhen a module registers one. On the legacy path the header is honoured only for anonymous requests. Every tenant id taken from a request must matchTENANT_ID_PATTERN.HostSettings.default_tenantruns mixin tables as one tenant for requests and background tasks. It is ignored whenmulti_tenantis on.db.on_commitcallbacks and FastAPIBackgroundTasksalready run inside the request's tenant (now tested). For work a module middleware defers pastTenantMiddleware,bind_current_tenant(fn)captures the tenant when the work is queued and restores it when it runs. Middleware order is unchanged.background_tasks:multi_tenantsetting (background_tasks: tenant-scope TaskExecution, and register the tenant listeners in the Celery worker process #371).SM024: warns when a unique key on a tenant-scoped table doesn't includetenant_id.SM_TEST_DATABASE_URLpoints the test fixtures at Postgres, andmake test-py-pgruns the whole Python suite there. The schema is reset once per test, soappanddb_sessionshare the same data.tenantsmodule(tenant_id, user_id), roleowner/admin/member) and invitation (stores only a SHA-256 of the token; can only be accepted by the invited email).InvalidationBus, and a read already in flight cannot re-cache a removed member.subdomain_baseset, the tenant comes from the subdomain. It applies to anonymous visitors on public routes and to members, with their role, and never to a non-member on an authenticated route.tenantsinstalled, a membership or role change applies on the next request, with no re-login (users: tenant changes are invisible until re login because UserContext is cached in the session #362).tenant:<role>for the active tenant only.public_base_urlsetting, never from theHostheader.EntitlementProvider(seat limits return 402),TenantService.set_status, and events published after commit./tenants/,/tenants/members,/tenants/invitations/accept,/admin/tenants/.tenantsbranch.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.
tenant_contextwas ignored insideall_tenants().update().values(tenant_id=…)could move rows to another tenant.Hostheader.make doctorcompared withmain(no new warnings), production-mode boot withmulti_tenanton and off, a real Celery run, and back-compat checks.Decisions for a reviewer
listeners.py, and fix(db): close four silent data-correctness gaps in the DB layer (#332, #335, #336, #339) #344 also fixes the soft-delete half of Soft-delete and tenant filters are silently skipped for selects that name no mapper (select(func.count()), subquery counts) #332, which this PR now covers too. Suggested order:select(func.count()), subquery counts) #332 part.SoftDeleteMixinrow:session.delete()is silently rewritten to a soft delete, with no opt-out #335,get_dbauto-commit ignores Core DML: a request whose only write issession.execute(update(...))is rolled back at request end #336, SQLite engine is left at driver defaults: rollback journal, implicit 5 s busy timeout, foreign keys off #339).maininto this PR.simple_module_tenantsis added to the PyPI publish matrix.multi_tenant=Trueget strict mode. No bundled model uses the mixin.merge_host_settings()at start-up (scripts/run_worker.py).exists()scan.Not in this PR
tenant_idJWT claim, and check register_setup_steps opt-out still holds #376 (keycloak), permissions: role→permission grants stay global, but must support mapping the synthetictenant:<role>principal roles #377 (permissions), antosubash/smpy_modules#38 (pagebuilder), antosubash/smpy_modules#39 (news).make test-py-pg).@pytest.mark.anyio. Those markers are whymake test-py-pgpasses-p no:anyio; removing them is a separate cleanup.Testing
9eba456).make test-py-pg.@pytest.mark.anyiotests running on a different event loop from their fixtures.exists()), soft-delete shapes, the session cache, nested bypass blocks.SM024.tenants: 42 tests, including subdomains and the concurrency test.🤖 Generated with Claude Code
https://claude.ai/code/session_01FB4wFRUXWGHY6qp8wtDJCE