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.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.
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.