Multi-tenancy¶
Every tenant's data is isolated at three layers.
1. Database (row-level)¶
Every tenant-owned model has organization_id. Every router uses the
tenant_select(Model, tenant) helper which pre-filters on it:
def tenant_select(model, tenant: TenantContext):
return sa_select(model).where(
model.organization_id == tenant.organization_id
)
Additional filters are appended on top. No handcrafted SELECT in any
tenant-owned table escapes this pattern in OSS v0.1.
Direct integration tests in backend/tests/test_tenant_isolation.py
prove:
test_audit_log_invisible_cross_orgtest_documents_invisible_cross_orgtest_hash_chain_independent_per_orgtest_tampering_audit_log_breaks_chain
These run on every backend-tests CI job against a real Postgres service container.
2. Vector DB (Qdrant)¶
Each org gets its own collection: org_{uuid}_documents. All Tier-1
RAG writes and reads go to the per-org collection.
The shared legal knowledge base (shared_legal_knowledge_base
collection) is explicitly public — any writes to it require an
is_public_legal_source=True flag in the ingestion path
(rag_service.upsert_shared_chunks).
Belt and braces: every Qdrant filter includes org_id in the payload
match even when the collection itself is per-org. A corrupt code path
that forgot to scope by collection still couldn't leak.
3. Redis (rate limits + budget)¶
Keys are prefixed by scope:
user:{uuid}:…for authenticated-user rate limits.ip:{addr}:…for anonymous / auth-endpoint rate limits.budget:chat:{org}:{YYYYMMDD}for the per-org daily chat counter.
No unscoped Redis key exists for any per-tenant data.
4. Storage (uploads, reports)¶
Files under backend/uploads/{org_id}/{doc_id}/{filename} and
backend/reports/{org_id}/{report_id}.pdf. The filename is sanitised
(alphanumeric + ._- only) and the resolved path is asserted to stay
under the org's directory via _resolve_within() in
document_processor.py.
A compromise-of-one-org-credentials attacker cannot read another org's
documents because every FileResponse goes through tenant_select on
the documents or compliance_reports table first.
5. Hash-chain cryptography¶
Each org has its own HMAC subkey derived via HKDF. Even if an attacker reconstructs one org's key from a side-channel attack, they cannot forge entries in another org's chain — the master key is held outside the DB.
What tenants share¶
- The Postgres instance (shared tables, row-level scoping).
- The backend process (rate-limited per org + per user).
- The Redis instance (per-tenant key prefixes).
- The Qdrant instance (per-org collections).
- The BGE-M3 embedding model (stateless, no data crossing).
What tenants do NOT share¶
- Documents, audit logs, incidents, conversations, reports — all scoped.
- LLM budget — each org has its own
ORG_DAILY_CHAT_CAP. - LLM provider configuration —
organization.llm_provider+organization.llm_modelcolumns. - Hash-chain state — each org's chain is cryptographically independent.
Escalating isolation in commercial edition¶
Commercial-tier customers can move to a dedicated Postgres and dedicated Qdrant under the same container image, or deploy in their own EU Kubernetes via a Helm chart that we operate over a restricted VPN peering. Contact contact@lexcustis.eu for details.