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.
TenantConfig(inrag.yaml, undertenants:) gainsnamespaceandacl_labelsalongside the existingpii_policy,quota, andcorpus_ids.TenantResolver(rag_config.tenancy) resolves a tenant id →TenantSettings(rag_core.types), merging the tenant entry with safe defaults. An unknown tenant (absent fromtenants:) 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_policyland on theRequestContext, and the tenant'sacl_labelsare unioned into the principal's. GET /v1/status/tenantsurfaces 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.
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. |
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") → FalseGET /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.
ragctl tenant list -f rag.yaml # the declared tenants
ragctl tenant resolve acme -f rag.yaml # the effective settings the gateway applies- Resolved once, at the boundary.
RequestContextis "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 corePiiAction; the richer core actions (mask/encrypt/tag_only) aren't expressible from the config enum yet. - Inert by default.
build_appwires no resolver (pre-6.1 behaviour: namespace = tenant id, default PII);build_app_from_configalways builds one fromcfg.tenants.
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.enabledto wrap the PolicyEngine in anAclPolicyEnginethat And-mergesany_in("acl_labels", principal.acl_labels)into everyread_chunkpush-down. Fail-closed (a label-less principal retrieves nothing; model "public" as a shared label) and emitsacl.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.enabledto wrap the PolicyEngine in aPiiPolicyEnginethat answersegress_textover the context + answer leaving the system, applying the tenant'spii_policy; emitspii.egress_blocked. See ADR-0037 and reference/pii.md.
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: trueon the tenant. The resolver produces aphysical_indexkey (thededicated_index_nameif set, else the tenantid) onTenantSettings, threaded ontoRequestContext.physical_indexat the boundary. A logical-only tenant resolves tophysical_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 isNone, 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 viactx.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.
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
SupportsRoutewrapper aroundapp.state.retrieval_router, so query / retrieve / corpus / OpenAI / agent all inherit it, a layer above theHybridRetrieverwhere 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 expectedacl.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.
- 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_labelsinfilter_pushdownonce ACL push-down lands (6.3).