tenants
Organisations for simple_module SaaS installs: tenants, many-to-many memberships with per-tenant roles, email-bound invitations, and the tenant resolver that scopes every request.
Install
pip install simple_module_tenantsAdd 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 throughInvalidationBus). Suspended tenants resolve to nothing. - Per-tenant roles. A membership role (
owner,admin,member) is added to the principal astenant:<role>for the active tenant only, so tenant roles never reach platform permissions. - Fail-closed handling. With
multi_tenanton, a tenant-scoped query with no tenant raisesTenantIsolationError; this module turns that into a redirect to/tenants(pages) or a403 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/settings | settings.tenant.edit | The active tenant's overrides of tenant_overridable settings |
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.
Organisation settings use settings.tenant.edit, which the settings module owns and maps onto owner and admin (#382); the page writes through /api/settings/tenant/current/{key}. The former tenants.settings.manage permission — declared, owner-only, and checked by nothing — is retired, so there is one permission for the one surface.
Invitations are emailed through the mail module. With background_tasks installed, delivery uses the outbox and retries failures; otherwise it runs after the request commits. Set public_base_url for absolute accept links: relative links are not emailed. Nothing is queued unless the mail module is installed with a delivering backend (SMTP); with the console backend the invite form hands the link back instead. The UI shows the link and copy button either way, and says whether the link was emailed.
An outbox-delivered invitation keeps its accept link, one-time token included, in the outbox row until the background_tasks retention purge. The token is single-use and expires with the invitation (invitation_ttl_hours), but treat outbox rows and database backups as holding live invitation links until then.
Configuration
DB-backed (Settings screen):
allow_self_service(default on) — any signed-in user may create an organisation.invitation_ttl_hours(default 72).subdomain_base(default empty) — withexample.com,acme.example.comresolves to the tenant whose slug isacme. 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'sHostheader, because the same link travels inInvitationCreatedfor a mailer to send.
Management routes need both the permission and an owner/admin role in the active tenant: a platform-wide grant does not make a plain member of a tenant its manager (403 tenant_manager_required).
Billing seams
The module ships no billing, but a billing module needs nothing more from it:
app.state.tenants.entitlements— replace the defaultUnlimitedEntitlementswith anEntitlementProvider(limit(tenant_id, key),has_feature(tenant_id, key)). The module enforcestenants.seatson new members and invitations;EntitlementExceededErrormaps to HTTP 402. Admission holds a Postgres row lock or a SQLite writer lock before counting seats until commit. SQLite lock contention or a stale WAL snapshot returns409 admission_conflict; retry the whole operation in a fresh transaction.TenantService.set_status(tenant_id, TenantStatus.SUSPENDED | ACTIVE)for dunning.- Events, published after commit:
TenantCreated,TenantStatusChanged,TenantDeleted(tenant_id,slug,name; fromTenantService.delete_tenant— subscribe to clean up per-tenant state),MembershipAdded,MembershipRemoved,InvitationCreated.
Usage metering
tenants.contracts exports the runtime-checkable UsageMeter protocol and frozen value objects UsageOutcome, UsageReservation, and UsageSummaryRow. Consumers import these contracts instead of importing billing. A billing module installs its implementation during startup with app.state.tenants.usage = provider. get_usage_meter(app) returns that provider, or an UnlimitedUsageMeter when tenants state is absent. The tenants services container also defaults to this unlimited provider: every check and consumption is allowed with limit=None and used=0, reservations are issued, accounting operations do nothing, and summaries are empty. No billing storage or enforcement is included here.
All protocol methods are async and use string tenant IDs and meter keys such as "jobs.concurrent":
check(tenant_id, meter, quantity=1)inspects capacity without side effects.consume(tenant_id, meter, quantity=1, *, idempotency_key=None)atomically checks and increments usage. A rejection returnsallowed=Falseand records nothing; a successful outcome'susedincludes the consumed quantity.record(tenant_id, meter, quantity, *, idempotency_key=None)unconditionally records usage after the fact;refundhas the same signature and returns usage.reserve(tenant_id, meter, quantity=1, *, ttl=None)holds capacity and returns aUsageReservation, orNoneif the limit would be exceeded.ttlis an optionaltimedelta. Pass the reservation back to the same provider'scommit(reservation)to record it, orrelease(reservation)to return capacity.summary(tenant_id)returns a sequence ofUsageSummaryRowobjects withmeter,used,limit, andresets_at.
UsageOutcome carries meter, allowed, limit, used, requested, and resets_at. remaining is None for unlimited meters, otherwise max(0, limit - used). Reset and expiry timestamps may be None when absent. Providers own persistence, period boundaries, idempotency and hold expiry. UsageExceededError(outcome) carries a denied outcome for implementers that choose to raise it and map it to HTTP 402; the protocol's consume returns a decision rather than requiring an exception.
Member pagination and resolver caches
GET /api/tenants/current/members accepts page (1-based) and per_page (default 25, maximum 200). It returns {items, total, page, per_page, pages}; clients previously reading a bare array must read items and traverse the pages. Invalid API parameters return 422. The Members page accepts the same parameters, normalizes malformed values, and provides Previous/Next controls. Both order by (created_at, id) and explicitly scope rows and totals to the active tenant. Requests past the end clamp to the last page, including after a member removal; mutations reload the current page and page size. Offset pagination reflects the current roster, so concurrent additions/removals can shift page boundaries.
Membership cache fills serialize concurrent misses per app/user, recheck the cache when waiting ends, and retry if invalidated during a DB read. After three reads that each raced an invalidation, the request answers with the last read without caching it. That read is not guaranteed to be newer than an invalidation that landed during it: it is equivalent to an uncached read racing a concurrent change, and it is never cached. The 60-second membership TTL and invalidation bus remain the fallback for changes in other workers. Permission grants and subdomain lookups use the same simple_module_core.singleflight.fill loop; grants keep their 30-second TTL.
Unknown subdomain slugs are negatively cached for five seconds (bounded to 10,000 entries); known slugs keep their 60-second TTL. Two invalidation channels carry the evictions, both published after commit and delivered to every worker when a transport is installed:
| Channel | Key | Drops |
|---|---|---|
tenants.membership (INVALIDATION_CHANNEL) | a user id | that user's memberships |
None | every membership and every host lookup | |
tenants.hosts (HOSTS_INVALIDATION_CHANNEL) | a slug | that slug's host lookups |
None | every host lookup |
Membership keys are always user ids, whatever their shape (an OIDC subject can be any string), so slugs never share their channel. Committed tenant creation drops the owner's memberships and the new slug's host lookups; other users' caches stay warm. Status changes and deletion publish None on the membership channel. Construct services with both invalidators outside request dependencies: TenantService(db, invalidate=make_invalidator(app), invalidate_hosts=make_host_invalidator(app)) (from tenants.resolver and tenants.host_resolver). Direct SQL writes must publish tenants.membership with key=None after commit or wait for TTL expiry.
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.