Skip to content

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_org
  • test_documents_invisible_cross_org
  • test_hash_chain_independent_per_org
  • test_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_model columns.
  • 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.