4.6 KiB
Session store: production decision (WP-230E)
Decision
control-api's management-session store (services/control_api/auth/bff.py
SessionStore) is an in-memory, single-process store, and this is the
accepted v1 production design -- not a placeholder awaiting a Redis
migration. control-api MUST run as exactly one instance/replica. This
document is the load-bearing artifact for that constraint: code and Compose
must stay consistent with it, and scripts/compose_release_check.py enforces
the Compose side of it statically (see below), not just the docstring.
Why this, not a Redis-backed store
redis is already a hard Compose dependency of control-api
(docker-compose.yml's control-api.depends_on.redis), which reads as an
implied promise that sessions are Redis-backed. They are not: no
HUB_REDIS_* setting exists, and no code path talks to Redis for sessions.
That gap is exactly what this decision closes.
A real Redis-backed store was evaluated and deferred rather than built now,
for a concrete, checkable reason: it requires a new production runtime
dependency (redis, or an async client) added to pyproject.toml and
locked via uv lock. At the time of this decision uv was not reliably
resolvable in the environment this work was executed in, so a lockfile
change could not be produced with real confidence -- and a session store
touches authentication, the most security-sensitive path in this service.
Shipping unlocked or hand-edited dependency pins for that specific subsystem
is a worse outcome than shipping the honestly-documented current design.
This is a tooling-availability constraint on when, not a judgment that
Redis-backed sessions are undesirable; re-scoping this work is the correct
next step once dependency tooling is confirmed reliable (see "Revisiting
this decision" below).
What "single instance" actually means here
- Every session and every CSRF token lives in one process's memory
(
SessionStore._records, a plaindictguarded by athreading.RLock). - A restart, redeploy, or crash of
control-apiinvalidates every active session. Users see this as being signed out and must re-authenticate through Authentik; no data is lost, no state is corrupted, and the BFF's same-origin cookie/CSRF model means there is nothing to reconcile on the client side. - Running two or more
control-apireplicas behind a load balancer would silently split sessions and CSRF tokens across disjoint in-memory stores. A request routed to instance B with a session cookie issued by instance A would see "session expired" even though the session is, from the user's perspective, still valid -- and worse, CSRF token mismatches on that same split would be indistinguishable from a real CSRF attack in the logs. This is a correctness and security-signal problem, not just an HA gap, so it is enforced, not left to operational discipline.
Enforcement (not just documentation)
docker-compose.ymlanddocker-compose.prod.ymldo not declaredeploy.replicasforcontrol-api(or anywhere); Compose's default is one container per service, so the constraint already holds today.scripts/compose_release_check.py::check_composenow fails the release gate ifcontrol-apiever declaresdeploy.replicas != 1. This is a static, automated check (tests/test_wp150_operations.pyand the newtests/test_wp230_session_store.pyexercise it), run as part ofpython scripts/compose_release_check.pyand the release-readiness gate -- so a future change that tries to scalecontrol-apifails loudly before it reaches production, instead of failing silently at 2am when a real user hits the split-brain session bug above.SessionStore's docstring states the single-instance constraint directly at the point where a future change is most likely to be made, and points back to this document.
Revisiting this decision
Move to a real shared store (Redis-backed, encrypted-at-rest session payloads, TTL-matched key expiry, explicit logout invalidation, and defined behavior for a Redis outage -- fail closed, forcing re-authentication, rather than silently falling back to per-instance memory) when:
uv lockcan be run and verified in the environment doing the work, so the new dependency's lock entry is trustworthy, and- there is an actual operational need for more than one
control-apireplica (there is none today -- Tower runs a single instance of every owned service).
Until then, this document and the compose_release_check.py gate are the
enforced contract: single instance, in-memory, sessions do not survive a
restart, and that is by design.