fastplace-tenancy
First-party, opt-in multi-tenancy for Fastplace. The core framework stays tenant-agnostic; applications that need company scoping install this package and get the same class of guarantees the core gives soft deletes — invariants enforced by the framework, not by developer discipline.
pip install fastplace-tenancy
The isolation boundary
Single database, company-scoped rows: Account → Company → company_id → company-scoped records. Every company-owned table carries a company_id
column, and this package keeps that column meaningful at every layer a
request touches (blueprint §8, "Multi-Tenant Data Architecture"):
| Layer | Strategy |
|---|---|
| ORM | Automatic company global scope on CompanyScopedModel |
| API | CompanyContextMiddleware binds the request to one company |
| Authorization | Membership checks (require_membership) for services |
| Database | PostgreSQL Row-Level Security helpers (fastplace_tenancy.rls) |
| Cache | company:{id}:… key prefix (CompanyCacheStore) |
| Queue | Company context carried in job metadata (TenantQueue + @tenant_job) |
| Files | Company prefixes under storage/ with traversal guards |
| Search | Company filter applied over the active SearchService |
| Vector store | Company filter on similarity results |
| MongoDB | Automatic company_id filters (CompanyDocument) |
Automatic company scoping
Company-owned models extend CompanyScopedModel; the package ships the
tenant entities themselves:
from fastplace_tenancy import Company, CompanyMembership, CompanyScopedModel
class Project(CompanyScopedModel):
__tablename__ = "projects"
__unique_per_company__ = [("slug",)] # UNIQUE(company_id, slug)
title: str
slug: str
Every read now receives the tenant filter — SELECT … WHERE company_id = ? —
and every create() stamps the column from the active company context. The
company_id column is guarded from mass assignment: a payload cannot move a
row into (or read it as) another company.
Fail-closed by design
Querying a CompanyScopedModel without a company context raises
MissingCompanyContext — a missing tenant is a bug, not "return everything".
Cross-company work (admin shells, exports) is an explicit escape:
await Project.without_global_scope("company").where(...) # this query only
async with company_context(company.id): # bind a context
await Project.all()
Request binding
# config/app.py
MIDDLEWARE = [
"app.http.middleware.resolve_user.ResolveUserMiddleware",
"fastplace_tenancy.middleware.CompanyContextMiddleware",
"app.http.middleware.csrf.CsrfMiddleware",
]
The middleware resolves the company for the authenticated user:
- a session
company_id— validated against membership. The stored value is a request, not a grant: it binds only when the user actually holds a membership in that company, otherwise it is ignored; - else the user's earliest membership (ordered by membership id — a documented key, not whatever the query planner returns first);
- else nothing — the request stays unbound and company-scoped queries fail closed.
Controllers read the binding through the helper (there is no
request.state.company_id):
from fastplace_tenancy.middleware import request_company_id
company_id = request_company_id(request) # None when unbound
A company-switch endpoint should pair the session write with a membership check, so a forged or stale value never even reaches the middleware:
async def switch(self, request):
body = await request.json()
await require_membership(request.user.id, body["company_id"])
request.session["company_id"] = body["company_id"]
Override _resolve_company_id() to source the company differently
(subdomain, header, JWT claim).
Services check membership
from fastplace_tenancy import require_membership
async def invite(request, company_id: int):
await require_membership(request.user.id, company_id, roles={"owner", "admin"})
...
Queue jobs carry the company
Wrap the driver once and decorate handlers; the context travels as job metadata and re-binds inside the worker. Install the wrapper as the process-wide queue so domain-event auto-dispatch flows through it too:
from fastplace.queue import set_queue
from fastplace_tenancy import TenantQueue, tenant_job
set_queue(TenantQueue(existing_driver))
@Job()
@tenant_job
async def rebuild_report(project_id: int): ...
@tenant_job requires an async def handler — a sync function fails at
decoration with a TypeError, the same guard @Job itself applies.
Multi-tenant indexing & uniqueness
High-volume company tables index with company_id first — the base model's
company_id column is indexed; declare composites with
__index_per_company__. Uniqueness distinguishes globally unique
(Field(unique=True)) from unique-within-a-company
(__unique_per_company__ → UNIQUE(company_id, …)).
Row-Level Security (PostgreSQL)
Where the backend supports native RLS (see the capability registry — MySQL
and SQLite never claim it), fastplace_tenancy.rls can push the same policy
into the database itself as defense-in-depth below the ORM scope:
from fastplace_tenancy.rls import enable_company_rls, set_rls_company
await enable_company_rls(Project) # once, at migration time
async with db.connection(): # per transaction
await set_rls_company(company.id)
...
set_rls_company uses SET LOCAL, so the setting dies with the
transaction — a pooled connection carries nothing into the next borrower's
transaction. Deployment duties the DDL cannot do for you: the connecting role
must not own the table or carry BYPASSRLS (owners bypass policies
silently), and every transaction must set the company before touching
company tables.
Tenant-isolation contract suite
tests/tenancy/ is the contract: two companies, one code path — cross-company
reads come back empty, writes stamp the right tenant, cache keys never
collide, jobs re-bind their company, and uniqueness is per-company. The suite
runs on the same portable matrix as the core (SQLite always; PostgreSQL /
MySQL when TEST_*_URL is set).
Release files for fastplace-tenancy 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fastplace_tenancy-0.1.0.tar.gz | 19.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fastplace_tenancy-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 41.6 kB
Release files / fastplace_tenancy-0.1.0.tar.gz
| Download URL | fastplace_tenancy-0.1.0.tar.gz |
|---|---|
| Size | 19.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
339951054e406e48bbb490bcf3d79f8a4040723e839c432e354c5b67b2e80e60
|
|
BLAKE2b-256 checksum How to use checksums |
1e52faac103996ce1a2a0198c179e53a40c4c4b3a1aba8e00bf128cdfc99048a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / fastplace_tenancy-0.1.0-py3-none-any.whl
| Download URL | fastplace_tenancy-0.1.0-py3-none-any.whl |
|---|---|
| Size | 22.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a5d867e4c701b68125b41eddcf68c6189233db805e02988e4cbd951cdcb056ad
|
|
BLAKE2b-256 checksum How to use checksums |
aabc2b1f216608c304669e25611e8904580fd53408145184562f65ee9963783b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|