Multi-tenancy¶
Sandloom provides fail-closed tenant scoping so you never hand-write
where(Model.tenant_id == ...) on every query: forget it once and you leak data
across tenants. The framework filters automatically.
What you get by default, and what you declare¶
| You write | Sandloom does for you |
|---|---|
a model that inherits TenantScoped |
an indexed tenant_id column, filled from the bound tenant on insert; every read, update and delete filtered to the bound tenant; fails closed with no tenant bound |
router = module_router() in a module with a TenantScoped model |
binds the tenant on every route from the signed token (400 tenant context required when missing) |
sandloom migrate new <module> "<msg>" |
enable_tenant_rls / disable_tenant_rls in the revision for every new TenantScoped table |
with unscoped(): |
lifts the ORM guard for deliberate cross-tenant system work (never PostgreSQL RLS) |
| a global table | a tenant_scope_exempt entry with a written reason in architecture_rules.yml |
Opt a model into scoping¶
A tenant-owned model inherits TenantScoped, which declares the indexed
tenant_id column and opts into automatic filtering:
from sandloom.sdk import Base, TenantScoped
class TicketsTicket(TenantScoped, Base):
__tablename__ = "tickets_tickets"
...
A model (or Core Table) that declares its own tenant_id without
TenantScoped fails sandloom check
(SANDLOOM-C014); a deliberately global
table goes under tenant_scope_exempt in architecture_rules.yml with a
written reason (stale entries fail too).
Bind the tenant¶
module_router() binds the tenant on every route of a module that declares a
TenantScoped model (tenant="derive"; a route's own tenant= overrides).
A signed tenant_id / org_id access-token claim binds directly.
X-Tenant-Id is only a selector: the requested id must also be in the token's
signed tenant_ids allowlist, otherwise the request is rejected with 403.
A missing tenant answers 400 tenant context required
(SANDLOOM-R001).
from sandloom.sdk import CurrentUser, db, module_router
router = module_router() # the tenant is bound on every route
@router.get("/tickets")
async def list_tickets(user: CurrentUser) -> list[TicketOut]:
rows = await db().scalars(select(TicketsTicket)) # filtered to the bound tenant
return [TicketOut.model_validate(r) for r in rows]
A route that needs the tenant id takes TenantId (advanced, sandloom.sdk.web).
A plain router binds with TenantScope() (sandloom.sdk.web). Workers, consumers
and scripts bind explicitly with bind_tenant(id) (sandloom.sdk.tenant); a
consumer of an event with a tenant_id: UUID field gets it bound
automatically (Events).
How filtering works¶
Every ORM select, update, or delete that reaches a TenantScoped table fails
if no tenant is bound. Once bound, it is auto-filtered (a do_orm_execute
hook). The guard looks at the whole statement, not only the top-level
entity: joins (including relationship joins), select_from, EXISTS / IN /
scalar subqueries, eager-load options, and relationship lazy/selectin loads.
Session.get() is covered too — an identity-map hit for another tenant's row
is refused. Normal ORM inserts inherit the current tenant and reject a
conflicting tenant_id. Bulk inserts and bulk ownership changes are reserved
for explicit unscoped() system work.
Raw text() and Core statements carry no ORM criteria. Core SQL on a
recognised tenant table is refused when it cannot be safely scoped, and
runtime raw SQL in a business module needs an explicit unscoped() admission
(SANDLOOM-C015). Use the explicit helpers
there, or enable row-level security.
For explicit, hard-to-misuse reads:
from sandloom.sdk.tenant import current_tenant_id, scoped, tenant_filter # sandloom: advanced explicit scope
await db().execute(scoped(select(TicketsTicket), TicketsTicket)) # explicit scope
tenant_filter(TicketsTicket) # the filter clause
current_tenant_id() # the bound tenant id
All of these fail closed — they raise MissingTenantError if no tenant is
bound.
Deliberate cross-tenant access¶
Admin or system code that genuinely must cross tenants does so explicitly:
from sandloom.sdk.tenant import unscoped # sandloom: advanced system maintenance
with unscoped():
... # the ORM guard is lifted for this block
unscoped() disables the ORM guard only. On a database with
Row-Level Security the database still
scopes every statement to the bound tenant; cross-tenant work there runs on
the framework's audited system channel.
Single-tenant deployments¶
Single-tenant is the strict one-tenant case of the same model, not an isolation bypass:
TENANCY_MODE=single
SINGLE_TENANT_ID=00000000-0000-0000-0000-000000000001
Request and worker UoWs automatically bind that canonical tenant. Migrations
and deliberate cross-tenant maintenance must use unscoped() (with
tenant_rls, the system channel — see below). Every projection
row also carries tenant_id; filter on it first.
PostgreSQL Row-Level Security (optional)¶
For defense in depth the same invariant can be enforced inside the database. Enable it per database instance:
{"database": {"default": {"tenant_rls": true}}}
(the INFRA setting). The framework then keeps the transaction-local setting
sandloom.tenant_id in sync with the bound tenant. Add the policy to each
tenant-owned table from the module's migration:
sandloom migrate new renders these calls for every new TenantScoped table
(system_access=True only for a model with __system_access__ = True):
from sandloom.sdk.data import disable_tenant_rls, enable_tenant_rls # sandloom: advanced RLS
def upgrade() -> None:
op.create_table("notes_notes", ...) # has a tenant_id uuid column
if op.get_context().config.attributes.get("sandloom_tenant_rls", False):
enable_tenant_rls(op, "notes_notes")
# only for tables that privileged system jobs must reach across tenants:
# enable_tenant_rls(op, "notes_notes", system_access=True)
def downgrade() -> None:
if op.get_context().config.attributes.get("sandloom_tenant_rls", False):
disable_tenant_rls(op, "notes_notes")
op.drop_table("notes_notes")
The policy is fail-closed and has no bypass: the application role sees and
writes only rows of the tenant in sandloom.tenant_id; no tenant, an empty or a
malformed value → no rows, refused writes. unscoped() lifts only the ORM
guard — on a tenant_rls database it does not open other tenants' rows
(a warning is logged when it runs with no tenant bound). Cross-tenant work goes
through the framework's system channel (below).
Roles¶
RLS only confines a role that cannot step outside it. Use three roles:
| Role | Used by | Must be |
|---|---|---|
| migration/owner role | sandloom migrate (owns the tables) |
anything; never the app role |
| application role | DATABASE_URL: every request, worker and contract unit |
NOSUPERUSER NOBYPASSRLS, not the owner, not a member of the owner/system/superuser roles; DML grants only |
system role (sandloom_system) |
DATABASE_SYSTEM_URL — framework system channel only |
NOSUPERUSER NOBYPASSRLS; DML grants only on system_access tables, INSERT on audit_log |
CREATE ROLE sandloom_app LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;
CREATE ROLE sandloom_system LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sandloom_app;
GRANT INSERT ON audit_log TO sandloom_system; -- system sessions are audited
-- per-table system grants come from enable_tenant_rls(..., system_access=True)
At startup the API process verifies the application role (superuser,
BYPASSRLS, ownership of an RLS table, membership in a privileged or the
system role) and the system role (superuser,
BYPASSRLS) and refuses to start on any violation, in every environment.
Instance options: system_url (the default instance falls back to
DATABASE_SYSTEM_URL, SECRETS_DIR-aware), require_system_channel (fail fast
when no system DSN is configured). The role checks always run on a
tenant_rls instance (the former verify_roles option is removed; setting it
fails startup). The Celery worker runs the same checks at boot.
If the app role still owns the tables, transfer ownership (ALTER TABLE ...
OWNER TO <migration role>) and re-grant it DML.
Threat model¶
RLS guards against a forgotten tenant filter (raw SQL included) and removes
every privileged bypass from the application role. It does not
authenticate the tenant: code that can send arbitrary SQL as the application
role can still set sandloom.tenant_id to another tenant. See
the RLS threat model
for the exact guarantees, residual risks and the architectural options.
Production profile¶
PRODUCTION_PROFILE=multi_tenant_strict (production only) makes the trust
model non-optional: tenant_rls and role verification on every database, the
tenant policy on every TenantScoped table (a startup check), fail-closed
revocation, SECURITY_CACHE_INSTANCE on a separate Redis, and the contract
compatibility check when split. See
the security model.
Trust model. Business modules are trusted code; trust comes from admission (architecture checks and review) before execution, never automatically, AI-generated code included. Sandloom protects against developer mistakes, not against malicious Python or SQL inside its process.
What breaks it¶
| Code | What it catches |
|---|---|
| SANDLOOM-C014 | a per-tenant table that is not TenantScoped |
| SANDLOOM-C015 | runtime raw SQL or Core table access that can bypass the tenant guard |
| SANDLOOM-C024 | a raw query on a non-relational store outside unscoped() |
| SANDLOOM-R001 | no tenant bound where one is required (400) |
Guarantees, precisely
- Fail-closed scoping of every ORM path, inserts inheriting the bound tenant (G-TENANT-01 to G-TENANT-08 and related entries).
- A missing tenant is
400 tenant context required, also when the error reaches the request boundary (G-TENANT-12). - RLS policies are fail-closed, forced and without bypass; the role checks run at startup (G-RLS-*).
The ledger with the enforcing tests: Guarantees & capabilities.