Summary
Every module that adopts MultiTenantMixin has to answer the same four questions at its own entry points, and the framework answers none of them publicly. pagebuilder, records and news in antosubash/smpy_modules now carry three near-identical copies of the same ~150 lines. #384 (closing #367) added request.state.tenant_source and automatic Vary for Host and the tenant header, which covers part of the caching question. This issue is about what remains.
Observed
Permalinks are at antosubash/smpy_modules@a83e923 (branch of #57).
1. Is this host single- or multi-tenant? No public API. Each module introspects the built middleware stack for TenantMiddleware and its fixed kwarg, which is the private shape _phase_helpers.py L155-160 happens to produce:
def _tenant_middleware(app):
from simple_module_hosting.middleware import TenantMiddleware
entries = getattr(app, "user_middleware", ())
return next((e for e in entries if getattr(e, "cls", None) is TenantMiddleware), None)
def detect_mode(app):
entry = _tenant_middleware(app)
return SINGLE if entry is None or (entry.kwargs or {}).get("fixed") else MULTI
Each also warns when HostSettings.multi_tenant is on but the stack has no TenantMiddleware (the setting changed after boot), and records reads the middleware's header kwarg the same way (_tenancy_mode.py L63-68).
2. Which tenant does a single-tenant host run in? The framework knows (DatabaseState.default_tenant_id, set from default_tenant in app_builder.py L213-217, else DEFAULT_TENANT_ID), but nothing exposes it to a request. All three modules hardcode "default" and refuse to boot on a host pinned to any other fixed tenant, because their adoption migrations backfilled "default":
- news
configure L96-132, pagebuilder configure L95-121, records configure (_tenancy_mode.py L71-104).
3. Bind a tenant for the whole request. On a host with multi_tenant off and no default_tenant, no TenantMiddleware is installed, nothing is bound, and a read of a mixin table is unfiltered. So each module ships bind_admin / bind_public FastAPI yield-dependencies: single mode binds the default tenant; multi mode uses request.state.tenant_id, answering 403 tenant_required (admin) or the surface's ordinary 404 (public, so it is no oracle) when none was resolved; then with tenant_context(tenant): yield tenant. They must be ordered before get_db, because yield dependencies exit in reverse and get_db's commit has to run while the tenant is still bound.
Each module also needs a test that walks its routers and fails if one forgot the dependency (news tests/test_tenancy_routes.py).
4. Vary for cacheable public responses. All three add, in multi mode only, the headers that picked the tenant:
- news
tenant_vary / vary_on_tenant L135-156, pagebuilder L124-145: ("Cookie", <tenant header>)
- records
endpoints/api/_public_cache.py L115-130: the tenant header, plus private when a signed-in user's tenant came from their account
After #384 the tenant-header and Host part is redundant, since TenantMiddleware now merges the resolver's vary into every response. What #384 does not cover: when the tenants resolver picks a member's tenant from the session (modules/tenants/tenants/resolver.py L171-191, source "session"), the answer depended on the Cookie header, but vary reports only Host / the tenant header. The same URL on the apex host therefore answers with different tenants' content per cookie, without Vary: Cookie. Likewise the no-resolver claim path depends on the credential. That is why the modules still append Cookie themselves.
Expected
A module adopting MultiTenantMixin should not need to reverse-engineer the middleware stack or re-implement request binding. The framework already decides the mode, the single-tenant id and the resolved tenant; it should publish them.
Proposed API
In simple_module_hosting (e.g. simple_module_hosting.tenancy):
class TenancyMode(StrEnum):
SINGLE = "single" # no TenantMiddleware, or fixed=...
MULTI = "multi"
def tenancy_mode(app) -> TenancyMode: ...
# recorded by the app builder when it installs (or does not install)
# TenantMiddleware, e.g. app.state.sm.tenancy, rather than inferred from
# app.user_middleware
def single_tenant_id(app) -> str:
# DatabaseState.default_tenant_id: default_tenant, else DEFAULT_TENANT_ID
def require_tenant(*, on_missing: int | Callable[[Request], Exception] = 403,
detail: str = "tenant_required") -> Callable[..., AsyncIterator[str]]:
# FastAPI yield-dependency factory. SINGLE: single_tenant_id(app).
# MULTI: request.state.tenant_id (validated), else raise on_missing.
# Enters tenant_context(tenant) for the rest of the request.
# Documented (and ideally asserted) to precede get_db.
def tenant_vary(request) -> tuple[str, ...]:
# what the resolved tenant depended on, for a route that sets its own
# cache headers (e.g. a 304 built before the middleware sees it)
Usage: APIRouter(dependencies=[Depends(require_tenant())]) for an admin surface, Depends(require_tenant(on_missing=lambda r: HTTPException(404, "Page not found"))) for a public one.
Plus, in tenants' resolver: report "Cookie" in TenantResolution.vary when the source is "session" (and the credential header for "claim"), so TenantMiddleware's automatic Vary is complete and modules can drop their own.
Optional, worth deciding alongside: a simple_module_test helper that walks an app's routes and fails for any route touching a tenant-scoped surface without the binding dependency.
With these, each module's tenancy.py shrinks to its own 404 text and the configure refusal disappears, since single mode would bind whatever tenant the host actually pins instead of a hardcoded "default". That last point needs the adoption-migration backfill to use the same value, which already holds for DEFAULT_TENANT_ID.
Context
Found while making news multi-tenant: antosubash/smpy_modules#57 (third copy after pagebuilder #53 and records #37 in that repo). Related: #367 / #384 (tenant source and Vary), #380 (DEFAULT_TENANT_ID), #359 (TenantMiddleware(fixed=...)).
https://claude.ai/code/session_017uTbtobjCKQYnAxF5tgK8t
Summary
Every module that adopts
MultiTenantMixinhas to answer the same four questions at its own entry points, and the framework answers none of them publicly. pagebuilder, records and news in antosubash/smpy_modules now carry three near-identical copies of the same ~150 lines. #384 (closing #367) addedrequest.state.tenant_sourceand automaticVaryforHostand the tenant header, which covers part of the caching question. This issue is about what remains.Observed
Permalinks are at antosubash/smpy_modules@a83e923 (branch of #57).
1. Is this host single- or multi-tenant? No public API. Each module introspects the built middleware stack for
TenantMiddlewareand itsfixedkwarg, which is the private shape_phase_helpers.pyL155-160 happens to produce:modules/news/news/tenancy.pyL76-93modules/pagebuilder/pagebuilder/tenancy.pyL75-92modules/records/sm_records/_tenancy_mode.pyL38-60Each also warns when
HostSettings.multi_tenantis on but the stack has noTenantMiddleware(the setting changed after boot), and records reads the middleware'sheaderkwarg the same way (_tenancy_mode.pyL63-68).2. Which tenant does a single-tenant host run in? The framework knows (
DatabaseState.default_tenant_id, set fromdefault_tenantinapp_builder.pyL213-217, elseDEFAULT_TENANT_ID), but nothing exposes it to a request. All three modules hardcode"default"and refuse to boot on a host pinned to any otherfixedtenant, because their adoption migrations backfilled"default":configureL96-132, pagebuilderconfigureL95-121, recordsconfigure(_tenancy_mode.pyL71-104).3. Bind a tenant for the whole request. On a host with
multi_tenantoff and nodefault_tenant, noTenantMiddlewareis installed, nothing is bound, and a read of a mixin table is unfiltered. So each module shipsbind_admin/bind_publicFastAPI yield-dependencies: single mode binds the default tenant; multi mode usesrequest.state.tenant_id, answering 403tenant_required(admin) or the surface's ordinary 404 (public, so it is no oracle) when none was resolved; thenwith tenant_context(tenant): yield tenant. They must be ordered beforeget_db, because yield dependencies exit in reverse andget_db's commit has to run while the tenant is still bound.modules/records/sm_records/tenancy.pyL158-214Each module also needs a test that walks its routers and fails if one forgot the dependency (news
tests/test_tenancy_routes.py).4. Vary for cacheable public responses. All three add, in multi mode only, the headers that picked the tenant:
tenant_vary/vary_on_tenantL135-156, pagebuilder L124-145:("Cookie", <tenant header>)endpoints/api/_public_cache.pyL115-130: the tenant header, plusprivatewhen a signed-in user's tenant came from their accountAfter #384 the tenant-header and
Hostpart is redundant, sinceTenantMiddlewarenow merges the resolver'svaryinto every response. What #384 does not cover: when the tenants resolver picks a member's tenant from the session (modules/tenants/tenants/resolver.pyL171-191, source"session"), the answer depended on theCookieheader, butvaryreports onlyHost/ the tenant header. The same URL on the apex host therefore answers with different tenants' content per cookie, withoutVary: Cookie. Likewise the no-resolverclaimpath depends on the credential. That is why the modules still appendCookiethemselves.Expected
A module adopting
MultiTenantMixinshould not need to reverse-engineer the middleware stack or re-implement request binding. The framework already decides the mode, the single-tenant id and the resolved tenant; it should publish them.Proposed API
In
simple_module_hosting(e.g.simple_module_hosting.tenancy):Usage:
APIRouter(dependencies=[Depends(require_tenant())])for an admin surface,Depends(require_tenant(on_missing=lambda r: HTTPException(404, "Page not found")))for a public one.Plus, in
tenants' resolver: report"Cookie"inTenantResolution.varywhen the source is"session"(and the credential header for"claim"), soTenantMiddleware's automaticVaryis complete and modules can drop their own.Optional, worth deciding alongside: a
simple_module_testhelper that walks an app's routes and fails for any route touching a tenant-scoped surface without the binding dependency.With these, each module's
tenancy.pyshrinks to its own 404 text and theconfigurerefusal disappears, since single mode would bind whatever tenant the host actually pins instead of a hardcoded"default". That last point needs the adoption-migration backfill to use the same value, which already holds forDEFAULT_TENANT_ID.Context
Found while making news multi-tenant: antosubash/smpy_modules#57 (third copy after pagebuilder #53 and records #37 in that repo). Related: #367 / #384 (tenant source and Vary), #380 (
DEFAULT_TENANT_ID), #359 (TenantMiddleware(fixed=...)).https://claude.ai/code/session_017uTbtobjCKQYnAxF5tgK8t