Skip to content

simple_module_db: no tenant_scope helper, so current_tenant_id hygiene is every caller's problem #365

Description

@antosubash

Summary

current_tenant_id is a bare ContextVar with no accompanying helper for setting and resetting it safely. TenantMiddleware gets this right internally (set in a try, reset in finally), but nothing ships that pattern for the many other places a module needs to bind a tenant outside a request — CLI commands, deferred jobs, health checks, seed scripts. A bare current_tenant_id.set(...) with no matching reset leaks into whatever task runs next, which under httpx.ASGITransport in tests is the test's own task — a forgotten reset in one test can silently poison tenant state in the next.

Observed

simple_module_db/listeners.py:23:

current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)

Nothing else in simple_module_db sets or resets it — every set/reset pairing is left to the caller.

Minimal reproduction (mirrors the spike's test_1f_contextvar_propagation):

import asyncio
from simple_module_db.listeners import current_tenant_id

async def naive_binding():
    current_tenant_id.set("LEAK")   # no matching reset — easy to write, nothing warns

async def main():
    assert current_tenant_id.get() is None
    await naive_binding()
    leaked = current_tenant_id.get()
    print(leaked)  # 'LEAK' — visible in the CALLER's task, not just naive_binding's
    assert leaked == "LEAK"

asyncio.run(main())

Confirmed on Postgres in the spike (FACT 1f, test_spike_orm_tenancy.py::test_1f_contextvar_propagation): "a bare current_tenant_id.set() in awaited code (same task) is still 'LEAK' after it returns: every set must be paired with reset". The same file also confirms the contextvar correctly reaches AsyncSession.run_sync's sync greenlet and copies into asyncio.create_task children — so propagation itself is fine; only the set/reset discipline is unowned.

Expected

A single framework-provided helper for binding current_tenant_id for the duration of a block, used everywhere the framework itself needs to bind a tenant outside request middleware (CLI, jobs, tests), so "always reset" isn't a convention every module has to remember and re-implement.

Why a module cannot work around it

A module can write its own context manager (records' design does, as tenant_scope()), but the contextvar itself is simple_module_db's, and every other module doing the same thing multiplies the chance one of them gets it wrong — exactly the class of bug FACT 1f demonstrates.

Proposed API

Ship tenant_scope(tenant_id: str) in simple_module_db (or simple_module_hosting) as a context manager that sets current_tenant_id, always resets on exit (even on exception), and documents "never call current_tenant_id.set() directly" as the supported contract.

Context

Found while making the records module (antosubash/smpy_modules#37) multi-tenant; design doc docs/plans/2026-09-23-records-multitenancy.md there.

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