Multi-tenancy¶
One engine serves many clients (UIs / memorial sites), each hard-isolated: its own Qdrant collection (content cannot cross), its own Redis key-prefix (user models, events, impressions, config cannot cross), and its own bandit policy and cluster model. Shared infra, logical isolation, the standard "pooled" multi-tenant model.
The engine core (recommender, scorers, user model) is tenant-agnostic: it operates on
whatever stores it is handed. Tenancy is purely an edge concern, implemented in
recsys/tenancy.py and composition.ComponentManager.
How a request is routed¶
flowchart LR
req["request<br/>X-Tenant-Id: id"] --> mw["TenantASGIMiddleware<br/>sets current_tenant contextvar"]
mw --> proxy["TenantProxy<br/>resolves c.attr"]
proxy --> cm["ComponentManager.get(id)"]
cm --> comp["tenant-scoped Components"]
Every request and event carries X-Tenant-Id. A pure-ASGI middleware reads the header and
sets a contextvar; a TenantProxy resolves c.<attr> to that tenant's Components, so the
API endpoints stay unchanged. No header means the default tenant (single-tenant
behaviour, unchanged).
Why pure ASGI
A pure ASGI middleware runs in the same context as the request, so the contextvar
propagates into the (threadpool-run) endpoint. Starlette's BaseHTTPMiddleware runs the
endpoint in a separate task and would lose it.
What is isolated per tenant¶
| Axis | Keying |
|---|---|
| Content catalogue | its own Qdrant collection |
| User models / events / impressions | Redis prefix {tenant}:umodel / evt / imp |
| Runtime config (settings page) | Redis key {tenant}:recsys:config |
| Bandit policy (θ) | bandit_state_path per tenant |
| Cluster model | cluster_model_path per tenant |
| Durable event log | EVENT_LOG_DIR/{tenant}/... |
Redis-backed stores take a key_prefix constructor argument; the composition root builds it
from the tenant's prefix, so two tenants on the same Redis never share a key.
Registering tenants¶
Two paths, both durable. Pick by how fixed the tenant is:
| Path | How | Redeploy? | Durability |
|---|---|---|---|
| Runtime (normal) | /admin → Tenants → Add, or POST /api/tenants |
No | PVC file TENANT_STORE_PATH |
| Baseline (fixed) | add to TENANTS_PATH JSON, commit |
Yes | git-backed configmap |
New clients are onboarded at runtime: no redeploy. The baseline holds only the
truly-fixed tenants (default). The two are merged at request time; the runtime store wins.
Runtime store (TENANT_STORE_PATH)¶
Tenants created via /admin are written to a JSON file on the api PVC, so they survive pod
restarts and a Redis wipe/LRU: /admin is the permanent, zero-redeploy onboarding path.
The backing is chosen by env: TENANT_STORE_PATH (durable PVC file) → Redis → in-memory. On
the SDU deploy recsys-redis is intentionally ephemeral (--save "" + LRU), so the file
store is what makes runtime tenants permanent.
Baseline file (TENANTS_PATH)¶
Each entry maps to a TenantSpec: tenant_id, collection, optional redis_prefix
(defaults to tenant_id), bandit_state_path, cluster_model_path, and config_overrides.
default must be listed (else it loses its collection and falls back to fakes).
- No
TENANTS_PATH: a singledefaulttenant built from the existing env (COLLECTION_NAME,BANDIT_STATE_PATH,CLUSTER_MODEL_PATH). Existing deployments are unchanged. - Unknown tenant id (not in baseline or runtime store): an auto-isolated slice (its own Redis prefix, inheriting the default catalogue and config).
The contract each UI must honour¶
user_id only means something within a tenant; content_id only resolves within that
tenant's collection. So every UI must:
- send a consistent
X-Tenant-Idon every request and event, and - agree on the
user_id/content_idnamespaces for that tenant.
The serving calls are unchanged apart from that one header, e.g. recommend:
By default /api/recommend is not key-guarded; X-API-Key is required on /api/ingest and
the other guarded routes. See Authentication to lock reads too.
Authentication and per-tenant keys¶
The TenantASGIMiddleware is the trust boundary: it resolves the tenant so a client
header can't spoof it:
- A valid per-tenant key (
X-API-Key) → tenant taken from the key; anyX-Tenant-Idis ignored. A tenant's key can only ever touch its own slice. - The global
INGEST_API_KEY→ superuser; tenant fromX-Tenant-Id(admin / ops). - No / unknown key → public reads only; guarded routes
401.
| Route group | Guard |
|---|---|
/api/recommend |
public by default; SERVING_REQUIRES_KEY=1 requires a per-tenant key, then the key alone identifies the tenant and X-Tenant-Id is not needed |
/api/ingest, /api/usermodel, /api/metrics, /api/config, /api/tenants |
always require a key |
/api/search* |
public (separate router; unaffected by the flag) |
Keys at rest are sha256 hashes (api_key_hashes), never plaintext, in the durable
tenant store (TENANT_STORE_PATH), not git, not Redis. GET /api/tenants shows a count
only, never the key.
Issue a key: server mints it, returns it once, stores only the hash:
curl -X POST $API/api/tenants -H "X-API-Key: $GLOBAL_KEY" -H "Content-Type: application/json" \
-d '{"tenant_id":"westerbork-ar-ai","collection":"westerbork-ar-ai","generate_api_key":true}'
# -> { "api_key": "<256-bit token>", "note": "store now — only its hash is kept" }
In locked mode the UI sends only the key on serving calls:
Tooling¶
The control panel at /admin (also /inspector) manages tenants (list / add / delete,
set collection, content counts) and per-tenant config, and has a tenant field that sends
X-Tenant-Id, so you can inspect or configure one client at a time.
Known limits (v1)¶
- In-process
/api/metricscounters are global, not per-tenant. Fine for ops, not for per-client billing. Prometheus labels are the production upgrade. - The demographics provider is shared; per-tenant DB wiring is future work.