Files
ITWorx_MCP_Hub-Public/docs/02-security/SESSION_STORE_DECISION.md
T

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 plain dict guarded by a threading.RLock).
  • A restart, redeploy, or crash of control-api invalidates 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-api replicas 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.yml and docker-compose.prod.yml do not declare deploy.replicas for control-api (or anywhere); Compose's default is one container per service, so the constraint already holds today.
  • scripts/compose_release_check.py::check_compose now fails the release gate if control-api ever declares deploy.replicas != 1. This is a static, automated check (tests/test_wp150_operations.py and the new tests/test_wp230_session_store.py exercise it), run as part of python scripts/compose_release_check.py and the release-readiness gate -- so a future change that tries to scale control-api fails 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:

  1. uv lock can be run and verified in the environment doing the work, so the new dependency's lock entry is trustworthy, and
  2. there is an actual operational need for more than one control-api replica (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.