Summary
UsersAuthProvider.resolve_user caches the full UserContext (including tenant_id) inside the signed session cookie and returns it on every subsequent request without re-checking the DB, as long as the cached id matches. Changing a logged-in user's tenant_id in the database (the only way to assign one at all today — see the companion UserCreate/UserUpdate issue) has no effect on that user's active session: they keep acting as their old tenant until they log out and back in.
Observed
users/provider.py:40-43 (UsersAuthProvider.resolve_user):
cached = UserContext.from_session_dict(session.get(_SESSION_USER_CTX_KEY))
if cached is not None and cached.id == user_id_str:
return cached
No version/timestamp check against the DB row — a cache hit on id alone short-circuits the DB lookup entirely. UserContext.tenant_id is part of what's cached (auth/contracts/schemas.py, to_session_dict/from_session_dict, and from_user's tenant_id=user.tenant_id).
Minimal reproduction (no DB, demonstrates the cache contract directly):
from auth.contracts.schemas import UserContext
# What gets written into the session cookie right after login, tenant A:
ctx = UserContext(id="u1", email="a@example.com", name="A", roles=["admin"], tenant_id="A")
cached_payload = ctx.to_session_dict()
# ... an operator now runs `UPDATE users_user SET tenant_id='B' WHERE id='u1'` ...
# provider.py:40-43's fast path: cached.id == user_id_str is still true, so this is
# returned as-is with NO query against the row that was just changed.
still_cached = UserContext.from_session_dict(cached_payload)
assert still_cached.tenant_id == "A" # stale — the DB now says 'B'
print("session still carries the old tenant until logout/login")
Confirmed on the real stack in the spike (FACT 3″, test_spike_app_tenancy.py::test_3_multi_tenant_host_resolution): after UPDATE users_user SET tenant_id='A' WHERE id=<admin> on the same browser session, /whoami kept reporting the pre-update state; only a fresh session cookie picked up the new tenant.
Expected
A tenant_id (or role) change on a user should take effect on their next request, not wait for them to log out and back in.
Why a module cannot work around it
The session cache and its invalidation live entirely in users/auth; records (or any consuming module) has no hook into UsersAuthProvider.resolve_user's cache-hit path to force a re-check.
Proposed API
A version stamp on the user row (bumped whenever tenant_id or role membership changes) included in the cached payload; resolve_user's fast path compares it against the current DB value (a cheap indexed lookup) before trusting the cache, instead of trusting id alone.
Context
Found while making the records module (antosubash/smpy_modules#37) multi-tenant; design doc docs/plans/2026-09-23-records-multitenancy.md there.
Summary
UsersAuthProvider.resolve_usercaches the fullUserContext(includingtenant_id) inside the signed session cookie and returns it on every subsequent request without re-checking the DB, as long as the cachedidmatches. Changing a logged-in user'stenant_idin the database (the only way to assign one at all today — see the companionUserCreate/UserUpdateissue) has no effect on that user's active session: they keep acting as their old tenant until they log out and back in.Observed
users/provider.py:40-43(UsersAuthProvider.resolve_user):No version/timestamp check against the DB row — a cache hit on
idalone short-circuits the DB lookup entirely.UserContext.tenant_idis part of what's cached (auth/contracts/schemas.py,to_session_dict/from_session_dict, andfrom_user'stenant_id=user.tenant_id).Minimal reproduction (no DB, demonstrates the cache contract directly):
Confirmed on the real stack in the spike (FACT 3″,
test_spike_app_tenancy.py::test_3_multi_tenant_host_resolution): afterUPDATE users_user SET tenant_id='A' WHERE id=<admin>on the same browser session,/whoamikept reporting the pre-update state; only a fresh session cookie picked up the new tenant.Expected
A
tenant_id(or role) change on a user should take effect on their next request, not wait for them to log out and back in.Why a module cannot work around it
The session cache and its invalidation live entirely in
users/auth;records(or any consuming module) has no hook intoUsersAuthProvider.resolve_user's cache-hit path to force a re-check.Proposed API
A version stamp on the user row (bumped whenever
tenant_idor role membership changes) included in the cached payload;resolve_user's fast path compares it against the current DB value (a cheap indexed lookup) before trusting the cache, instead of trustingidalone.Context
Found while making the
recordsmodule (antosubash/smpy_modules#37) multi-tenant; design docdocs/plans/2026-09-23-records-multitenancy.mdthere.