Skip to content

hosting: publish tenancy mode, single-tenant id and a tenant-binding dependency so modules stop re-implementing them #418

Description

@antosubash

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions