Skip to content

Latest commit

 

History

History
186 lines (154 loc) · 9.63 KB

File metadata and controls

186 lines (154 loc) · 9.63 KB

Logical multi-tenancy (Step 6.1)

Make per-tenant rag.yaml config actually drive each request. A TenantResolver turns a tenant id into a frozen TenantSettings — namespace, PII policy, ACL labels — which the gateway applies once at the request boundary. See ADR-0033 and the design in architecture/multi-tenancy.md.

Overview

  • TenantConfig (in rag.yaml, under tenants:) gains namespace and acl_labels alongside the existing pii_policy, quota, and corpus_ids.
  • TenantResolver (rag_config.tenancy) resolves a tenant id → TenantSettings (rag_core.types), merging the tenant entry with safe defaults. An unknown tenant (absent from tenants:) resolves to defaults — namespace = the tenant id, the default PII action (redact), no labels — so it is isolated, never privileged.
  • The gateway builds the resolver from config, and the request-context middleware applies the resolved settings: namespace + pii_policy land on the RequestContext, and the tenant's acl_labels are unioned into the principal's.
  • GET /v1/status/tenant surfaces the resolved settings for a tenant.

RequestContext now carries a namespace field (the logical-isolation key, threaded to every SPI). It defaults to the tenant id, so every pre-6.1 call site is isolated exactly as before.

Usage

Configuration (tenants:)

tenants:
  - id: acme
    name: Acme Corp
    pii_policy: block          # block | redact | allow → the request's PII action
    namespace: acme-prod       # logical-isolation key (optional; defaults to id)
    acl_labels: [region:eu]    # unioned into every acme principal's labels
    quota: { qps: 50 }         # per-tenant caps (Step 4.5)
  - id: beta
    name: Beta LLC             # no namespace → defaults to "beta"
Field Default Meaning
pii_policy block The request's PII action (block / redact / allow).
namespace id Logical-isolation key; backends that namespace natively (e.g. Pinecone) partition on it.
acl_labels [] Tenant-wide ACL labels unioned into the principal's labels.
dedicated_index false Physical tenancy (Step 6.2): give this tenant its own vector index/collection.
dedicated_index_name null Optional key for the dedicated index; defaults to the tenant id.
quota uncapped Per-tenant rate / usage caps (Step 4.5).
corpus_ids [] Corpora visible to the tenant.

Resolver

from rag_config import RagConfig, TenantResolver

resolver = TenantResolver.from_config(cfg)
s = resolver.resolve("acme")     # → TenantSettings
s.namespace                       # "acme-prod"
s.pii_policy.action               # PiiAction.block
s.acl_labels                      # frozenset({"region:eu"})
resolver.known("acme")            # True; resolver.known("ghost") → False

HTTP

GET /v1/status/tenant{ tenant_id, known, namespace, pii_action, acl_labels }. Tenant resolves from the tenant_id query param, else the X-Tenant-Id header, else the gateway default. known=false when the tenant is absent from rag.yaml (or no resolver is wired) — the response then reports the safe defaults.

CLI

ragctl tenant list -f rag.yaml          # the declared tenants
ragctl tenant resolve acme -f rag.yaml  # the effective settings the gateway applies

Internals

  • Resolved once, at the boundary. RequestContext is "constructed exactly once at the gateway boundary", so the resolution happens there (build_gateway_context) — downstream code treats the namespace / PII policy / ACL labels as trusted and immutable.
  • PII enum maps one-for-one. The config PIIPolicy (block/redact/allow) maps directly onto the core PiiAction; the richer core actions (mask / encrypt / tag_only) aren't expressible from the config enum yet.
  • Inert by default. build_app wires no resolver (pre-6.1 behaviour: namespace = tenant id, default PII); build_app_from_config always builds one from cfg.tenants.

Scope & boundaries

Step 6.1 is the logical-tenancy foundation. It resolves and threads the per-tenant isolation + governance primitives; the enforcement that builds on them lands in later Phase-6 steps:

  • Physical tenancy (a dedicated index per tenant) — 6.2 (below).
  • ACL push-down at retrieval (inject the labels into every backend query) — 6.3 ✅: enable cfg.acl.enabled to wrap the PolicyEngine in an AclPolicyEngine that And-merges any_in("acl_labels", principal.acl_labels) into every read_chunk push-down. Fail-closed (a label-less principal retrieves nothing; model "public" as a shared label) and emits acl.egress_denied. See ADR-0035.
  • ACL egress verifier — a post-retrieval re-check that backstops the push-down — 6.4 ✅ (below).
  • PII egress enforcement (block / redact / allow per tenant) — 6.5 ✅: enable cfg.pii.enabled to wrap the PolicyEngine in a PiiPolicyEngine that answers egress_text over the context + answer leaving the system, applying the tenant's pii_policy; emits pii.egress_blocked. See ADR-0037 and reference/pii.md.

Physical tenancy (Step 6.2)

Logical tenancy shares one index and isolates by tenant_id filter (+ namespace). Physical tenancy gives a tenant its own vector index/collection, so its data is separate even if a filter were bypassed — for tenants that require hard isolation.

  • Set dedicated_index: true on the tenant. The resolver produces a physical_index key (the dedicated_index_name if set, else the tenant id) on TenantSettings, threaded onto RequestContext.physical_index at the boundary. A logical-only tenant resolves to physical_index = None.
  • A vector backend namespaces its base index under the key: <base>-<key>, and creates the dedicated index/collection lazily on first use. When the key is None, the backend uses its shared base index unchanged.
  • Supported backends: the in-memory Noop store (the CI conformance oracle, keyed by physical_index), Pinecone (dedicated index), and Qdrant (dedicated collection). pgvector / Elasticsearch / Weaviate follow the same pattern later.
  • Backends read only ctx — never the resolver/config (the dependency graph is backends → core), so the per-tenant decision arrives via ctx.physical_index.
tenants:
  - id: acme
    name: Acme Corp
    dedicated_index: true                 # → index "<base>-acme"
  - id: vault
    name: Vault
    dedicated_index: true
    dedicated_index_name: vault-secure    # → index "<base>-vault-secure"

GET /v1/status/tenant and ragctl tenant resolve <id> report dedicated_index

  • the resolved physical_index. The cross-tenant probe gate (tests/redteam/test_cross_tenant_dedicated_index.py) proves on the Noop oracle that a dedicated tenant's data is invisible to another independent of the tenant filter; live-service isolation is covered by the Pinecone / Qdrant integration tests. See architecture/multi-tenancy.md and ADR-0034.

ACL egress verifier (Step 6.4)

The 6.3 push-down filters ACLs at the source. The egress verifier is the defense-in-depth second layer: it re-checks the chunks that actually came back and drops any whose labels don't overlap the principal's — so a filter translation bug, a backend that ignores the predicate, or a retrieval path wired without the policy engine can't leak an over-privileged chunk past the boundary.

  • Same overlap semantics as the push-down (ref.acl_labels ∩ principal.acl_labels ≠ ∅, same fail-closed / "public is a shared label" model), so it is a no-op on correctly-filtered results and only acts on a real leak.
  • Cheap + hydration-free — it reads ChunkRef.acl_labels (which every backend populates regardless of the filter applied), an O(results) set-intersection per query.
  • Layered at the retrieval router boundary — a SupportsRoute wrapper around app.state.retrieval_router, so query / retrieve / corpus / OpenAI / agent all inherit it, a layer above the HybridRetriever where the push-down lives.
  • Emits acl.egress_violation (PII-free: ids + counts) when it catches a leak — the unexpected push-down failure, distinct from 6.3's expected acl.egress_denied.
acl:
  enabled: true          # 6.3 push-down
  verify_egress: true    # 6.4 egress verifier (default true; only fires when enabled)

verify_egress defaults on but is gated on enabled — turning ACLs on gives both layers; set it false to run the push-down alone. The zero-violation-rate gate (tests/redteam/test_acl_egress_verifier.py) bypasses the push-down (no policy engine, and a backend that drops the filter) and proves the verifier reduces the escaped-violation rate to zero across a battery of principals. See ADR-0036.

Extension points

  • Namespace strategy — backends that namespace natively partition on ctx.namespace (Pinecone today); a filter-only backend keeps tenant-id scoping (equivalent, since the namespace defaults to the tenant id).
  • Richer per-tenant overrides — the resolver is the single place to add resolved behavioural overrides (retrieval weights, guard threshold) for the components that consume them.
  • A real PolicyEngine — consumes the tenant's acl_labels in filter_pushdown once ACL push-down lands (6.3).