diff --git a/TRACKER.md b/TRACKER.md index f9a6ff9..ab24740 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -14,12 +14,13 @@ | | | |---|---| | **Last updated** | 2026-06-08 | -| **Current phase** | Phase 6 — Governance & Tenancy (**1 / 10 steps**) | -| **Overall** | **65 / 84 steps** — Phases 0–5 complete | -| **Next action** | **Step 6.2 — Physical tenancy (dedicated index)**: dedicated vector index per tenant; cross-tenant probe gate. Builds on the 6.1 namespace primitive. | +| **Current phase** | Phase 6 — Governance & Tenancy (**2 / 10 steps**) | +| **Overall** | **66 / 84 steps** — Phases 0–5 complete | +| **Next action** | **Step 6.3 — ACL push-down at retrieval**: inject the ACL filter into every vector / BM25 / graph query; `acl.egress_denied` event. Consumes the per-tenant `acl_labels` resolved in 6.1. | **Recently shipped** +- **6.2** ✅ Physical tenancy — per-tenant *dedicated* vector index/collection; `TenantConfig.dedicated_index` → `ctx.physical_index` → backend `-` (Noop/Pinecone/Qdrant); cross-tenant probe gate — [#149](https://github.com/officialCodeWork/AgentContextOS/pull/149) - **6.1** ✅ Logical multi-tenancy — `TenantResolver` resolves per-tenant `rag.yaml` config (namespace / PII / ACL labels) → `TenantSettings` applied at the gateway boundary; `RequestContext.namespace`; `GET /v1/status/tenant`; `ragctl tenant` — [#148](https://github.com/officialCodeWork/AgentContextOS/pull/148) - **5.7d** ✅ Experiments console card + Phase-5 close-out — admin Live-Status A/B card over `GET /v1/status/experiments` (lift + CI per experiment) — [#147](https://github.com/officialCodeWork/AgentContextOS/pull/147) - **5.7c** ✅ A/B routing — `ABRouter` deterministically *serves* the candidate to a fraction of users (variant-partitioned cache, `ExperimentAssignment` response tag) — [#146](https://github.com/officialCodeWork/AgentContextOS/pull/146) @@ -55,9 +56,9 @@ | 3 | Gateway & Agent Runtime | 11 | **11** | 0 | | 4 | Reliability | 6 | **6** | 0 | | 5 | Eval & Observability | 7 | **7** | 0 | -| 6 | Governance & Tenancy | 10 | **1** | 9 | +| 6 | Governance & Tenancy | 10 | **2** | 8 | | 7 | Pilot, Harden, GA | 10 | 0 | 10 | -| **Total** | | **84** | **65** | **19** | +| **Total** | | **84** | **66** | **18** | --- @@ -640,12 +641,12 @@ - **Phase-5 close-out:** Step 5.7 ✅ → **Phase 5 complete (7 / 7)**; deferred items remain documented (per-tenant drift / per-dimension embedding PSI; feedback/breaker/quota Grafana export + Loki-events dashboard; gRPC proto mirror of `corpus_decision` + `experiment`; sequential / multi-metric experiments) - [reference/experiments.md](docs/reference/experiments.md), [reference/admin-ui.md](docs/reference/admin-ui.md) -## Phase 6 — Governance & Tenancy (Weeks 28–34) 🚧 (1 / 10) +## Phase 6 — Governance & Tenancy (Weeks 28–34) 🚧 (2 / 10) | Step | Title | Status | Planned deliverables | |------|-------|:------:|----------------------| | 6.1 | Logical multi-tenancy | ✅ | [#148](https://github.com/officialCodeWork/AgentContextOS/pull/148) — `TenantResolver` → `TenantSettings` (namespace / PII / ACL labels) applied at the boundary; `RequestContext.namespace`; `GET /v1/status/tenant`; `ragctl tenant` | -| 6.2 | Physical tenancy (dedicated index) | ⏳ | Dedicated vector index per tenant; cross-tenant probe gate | +| 6.2 | Physical tenancy (dedicated index) | ✅ | [#149](https://github.com/officialCodeWork/AgentContextOS/pull/149) — `dedicated_index` → `ctx.physical_index` → backend `-` (Noop/Pinecone/Qdrant, lazy create); cross-tenant probe gate | | 6.3 | ACL push-down at retrieval | ⏳ | ACL filter injected into every vector / BM25 / graph query; `acl.egress_denied` event | | 6.4 | ACL egress verifier | ⏳ | Post-retrieval re-check; defense-in-depth; zero-ACL-violation-rate gate | | 6.5 | PII policies | ⏳ | Per-tenant PII enforcement (block / redact / allow); egress redaction; `pii.egress_blocked` event | @@ -665,6 +666,15 @@ - **Scope:** resolution + threading only — ACL push-down enforcement is 6.3, PII egress is 6.5, physical tenancy is 6.2; ~21 new tests (resolver, config, gateway, cross-tenant red-team, CLI); all gates green (ruff, mypy --strict 293 files, RAG001, schema/openapi-drift, proto-compat, policy-coverage) - [ADR-0033](docs/adr/ADR-0033-logical-multi-tenancy.md), [reference/tenancy.md](docs/reference/tenancy.md), [architecture/multi-tenancy.md](docs/architecture/multi-tenancy.md) +### 6.2 — Physical tenancy (dedicated index) ✅ [#149](https://github.com/officialCodeWork/AgentContextOS/pull/149) + +- *Logical* tenancy (6.1) shares one index + a `tenant_id` filter; **physical** tenancy gives a tenant its **own** vector index/collection, so its data is separate even if a filter were bypassed. Opt-in per tenant via **`TenantConfig.dedicated_index`** (+ optional `dedicated_index_name`) +- Same resolve-once-at-the-boundary pattern as 6.1: the resolver computes a **`physical_index`** key (`dedicated_index_name or tenant_id`, else `None`) on `TenantSettings`, threaded onto new **`RequestContext.physical_index`**; the gateway middleware applies it +- **Backends read only `ctx`** (graph is backends→core, never rag-config), so the decision arrives via `ctx.physical_index`; each namespaces its base under the key (**`-`**) and **lazily creates** the dedicated index/collection on first use, falling back to the base index when `None`. Implemented for **Noop** (CI oracle, partitioned by `physical_index`) + **Pinecone** (dedicated index) + **Qdrant** (dedicated collection); pgvector/ES/Weaviate follow later +- **Cross-tenant probe gate** (`tests/redteam/test_cross_tenant_dedicated_index.py`, CI, Noop) proves a dedicated tenant's data is invisible to another **independent of the tenant filter** (a matching `tenant_id` pointed at a different index still returns nothing); live backends are integration-tested +- One instance + per-tenant derivation (**no** per-tenant backend instances, **no** SPI change); `GET /v1/status/tenant` + `ragctl tenant resolve` report `dedicated_index` / `physical_index`; `TenantConfig` fields → `rag.schema`, `TenantSettings` + `RequestContext` regenerated, `/v1/status/tenant` → `dist/openapi`; ~30 new/updated tests; all gates green +- [ADR-0034](docs/adr/ADR-0034-physical-multi-tenancy.md), [reference/tenancy.md](docs/reference/tenancy.md#physical-tenancy-step-62), [architecture/multi-tenancy.md](docs/architecture/multi-tenancy.md) + --- ## Phase 7 — Pilot, Harden, GA (Weeks 34–40) ⏳ @@ -817,6 +827,7 @@ Complete log of every PR. Routine Dependabot bumps are grouped; everything else | [#146](https://github.com/officialCodeWork/AgentContextOS/pull/146) | 2026-06-07 | feat(experiments): A/B routing — serve the candidate to a fraction of users (Step 5.7c) | | [#147](https://github.com/officialCodeWork/AgentContextOS/pull/147) | 2026-06-07 | feat(admin-ui): A/B experiments console card + Phase-5 close-out (Step 5.7d) | | [#148](https://github.com/officialCodeWork/AgentContextOS/pull/148) | 2026-06-08 | feat(tenancy): logical multi-tenancy — per-tenant config resolution (Step 6.1) | +| [#149](https://github.com/officialCodeWork/AgentContextOS/pull/149) | 2026-06-08 | feat(tenancy): physical tenancy — dedicated index per tenant (Step 6.2) | | #78–#80, #116–#118 | Open | Dependabot bumps — awaiting merge | | #81 | Closed | Dependabot bump — superseded | diff --git a/apps/gateway/src/rag_gateway/middleware.py b/apps/gateway/src/rag_gateway/middleware.py index 3b625b9..b5e4937 100644 --- a/apps/gateway/src/rag_gateway/middleware.py +++ b/apps/gateway/src/rag_gateway/middleware.py @@ -167,10 +167,12 @@ async def build_gateway_context( if principal is not None and tenant_id is not None: namespace = str(tenant_id) pii_policy = None + physical_index: str | None = None if tenant_resolver is not None: tenant_settings = tenant_resolver.resolve(str(tenant_id)) namespace = tenant_settings.namespace pii_policy = tenant_settings.pii_policy + physical_index = tenant_settings.physical_index if tenant_settings.acl_labels: principal = principal.model_copy( update={"acl_labels": principal.acl_labels | tenant_settings.acl_labels} @@ -180,6 +182,7 @@ async def build_gateway_context( tenant_id=tenant_id, principal=principal, namespace=namespace, + physical_index=physical_index, **({"pii_policy": pii_policy} if pii_policy is not None else {}), trace=trace, ) diff --git a/apps/gateway/src/rag_gateway/status.py b/apps/gateway/src/rag_gateway/status.py index 62ce603..b2aefa5 100644 --- a/apps/gateway/src/rag_gateway/status.py +++ b/apps/gateway/src/rag_gateway/status.py @@ -332,6 +332,10 @@ class TenantStatusResponse(BaseModel): namespace: str = "" pii_action: str = "redact" acl_labels: list[str] = Field(default_factory=list) + # Physical tenancy (Step 6.2): whether this tenant has a dedicated index, and + # its key (``None`` when it shares the base index). + dedicated_index: bool = False + physical_index: str | None = None class ExperimentsStatusResponse(BaseModel): @@ -898,6 +902,8 @@ async def status_tenant(request: Request, tenant_id: str | None = None) -> Tenan namespace=settings.namespace, pii_action=settings.pii_policy.action.value, acl_labels=sorted(settings.acl_labels), + dedicated_index=settings.physical_index is not None, + physical_index=settings.physical_index, ) @router.websocket("/v1/status/ws") diff --git a/apps/gateway/tests/test_tenancy.py b/apps/gateway/tests/test_tenancy.py index ec8130b..7f8401f 100644 --- a/apps/gateway/tests/test_tenancy.py +++ b/apps/gateway/tests/test_tenancy.py @@ -100,9 +100,22 @@ def test_status_tenant_known() -> None: "namespace": "acme-prod", "pii_action": "block", "acl_labels": ["region:eu"], + "dedicated_index": False, + "physical_index": None, } +def test_status_tenant_reports_dedicated_index() -> None: + cfg = { + "version": "1", + "tenants": [{"id": "vault", "name": "Vault", "dedicated_index": True}], + } + client = TestClient(build_app_from_config(RagConfig.model_validate(cfg))) + body = client.get("/v1/status/tenant", headers={"X-Tenant-Id": "vault"}).json() + assert body["dedicated_index"] is True + assert body["physical_index"] == "vault" # auto-derived from the id + + def test_status_tenant_unknown_is_defaulted() -> None: client = TestClient(build_app_from_config(RagConfig.model_validate(_resolver_cfg()))) body = client.get("/v1/status/tenant", headers={"X-Tenant-Id": "ghost"}).json() diff --git a/dist/openapi.json b/dist/openapi.json index 39563b5..d034717 100644 --- a/dist/openapi.json +++ b/dist/openapi.json @@ -3674,6 +3674,11 @@ "title": "Acl Labels", "type": "array" }, + "dedicated_index": { + "default": false, + "title": "Dedicated Index", + "type": "boolean" + }, "known": { "default": false, "title": "Known", @@ -3684,6 +3689,17 @@ "title": "Namespace", "type": "string" }, + "physical_index": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Physical Index" + }, "pii_action": { "default": "redact", "title": "Pii Action", diff --git a/dist/openapi.yaml b/dist/openapi.yaml index 94eec7f..6aebdf4 100644 --- a/dist/openapi.yaml +++ b/dist/openapi.yaml @@ -3255,6 +3255,10 @@ components: type: string title: Acl Labels type: array + dedicated_index: + default: false + title: Dedicated Index + type: boolean known: default: false title: Known @@ -3263,6 +3267,11 @@ components: default: '' title: Namespace type: string + physical_index: + anyOf: + - type: string + - type: 'null' + title: Physical Index pii_action: default: redact title: Pii Action diff --git a/dist/rag.schema.json b/dist/rag.schema.json index e40ad74..98896e3 100644 --- a/dist/rag.schema.json +++ b/dist/rag.schema.json @@ -1325,6 +1325,24 @@ }, "title": "Acl Labels", "type": "array" + }, + "dedicated_index": { + "default": false, + "title": "Dedicated Index", + "type": "boolean" + }, + "dedicated_index_name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Dedicated Index Name" } }, "required": [ diff --git a/dist/rag.schema.yaml b/dist/rag.schema.yaml index 989a042..b3ace92 100644 --- a/dist/rag.schema.yaml +++ b/dist/rag.schema.yaml @@ -1231,6 +1231,17 @@ $defs: type: string title: Acl Labels type: array + dedicated_index: + default: false + title: Dedicated Index + type: boolean + dedicated_index_name: + anyOf: + - minLength: 1 + type: string + - type: 'null' + default: null + title: Dedicated Index Name required: - id - name diff --git a/dist/schemas/RequestContext.json b/dist/schemas/RequestContext.json index 0f0292b..18579f2 100644 --- a/dist/schemas/RequestContext.json +++ b/dist/schemas/RequestContext.json @@ -206,6 +206,18 @@ "title": "Namespace", "type": "string" }, + "physical_index": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Physical Index" + }, "pii_policy": { "$ref": "#/$defs/PiiPolicy" }, diff --git a/dist/schemas/TenantSettings.json b/dist/schemas/TenantSettings.json index a92ebea..55dc760 100644 --- a/dist/schemas/TenantSettings.json +++ b/dist/schemas/TenantSettings.json @@ -58,6 +58,18 @@ "title": "Acl Labels", "type": "array", "uniqueItems": true + }, + "physical_index": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Physical Index" } }, "required": [ diff --git a/docs/README.md b/docs/README.md index 0bc34c4..e37f465 100644 --- a/docs/README.md +++ b/docs/README.md @@ -172,6 +172,7 @@ broken, and what to fix before committing to the next phase. | [ADR-0027-offline-eval-harness.md](adr/ADR-0027-offline-eval-harness.md) | Decision (Step 5.2): complete the Step 0.8 eval skeleton with a deterministic, offline harness — synthetic 5-domain corpus on the noop SPIs (a `HashingEmbedder` gives the zero-vector dense path real signal, fused with `NoopKeywordStore` token overlap via the real `HybridRetriever`); 500-query golden set committed under `tests/eval/golden/` and generated reproducibly from the corpus (drift-gated), 3 passages/concept so nDCG + Citation Precision aren't degenerate; nDCG added + dependency-free `lexical_faithfulness` default (RAGAS optional) keep CI ML-free; harness in `eval/golden_set_v0/` (not `rag_config`) so config stays light; additive report fields + self-contained HTML; `--check` enforces the threshold floor now (regression-delta is 5.3); rejected real backends in CI, RAGAS-as-default, hand-authored queries | | [ADR-0032-ab-testing-shadow-mode.md](adr/ADR-0032-ab-testing-shadow-mode.md) | Decision (Step 5.7): compare two configs on live traffic, delivered in slices (5.7a analyzer+tracker+surface, 5.7b shadow, 5.7c routing, 5.7d console); the analyzer is pure + stdlib-only in `rag_config.eval` (normal-approx Welch via `statistics.NormalDist`, no numpy/scipy — same spirit as drift PSI / cost z-score); the `ABExperimentTracker` is a pure *sample holder* in `rag_observability` (so it doesn't import `rag_config`), and the gateway composes the two for `GET /v1/status/experiments`; opt-in by default (A/B routing can change responses); `ABAnalysisResult` additive in `rag_core.eval` (not in `dist/schemas`); rejected numpy/scipy, a new package, putting the analyzer in observability, defaulting on | | [ADR-0033-logical-multi-tenancy.md](adr/ADR-0033-logical-multi-tenancy.md) | Decision (Step 6.1): make per-tenant `rag.yaml` config drive requests. `TenantResolver` (`rag_config`) maps a tenant id → frozen `TenantSettings` (`rag_core`), applied once at the gateway boundary; resolver in config / settings type in core keeps the `config → core` direction; unknown tenants → safe defaults (isolated not privileged); `RequestContext.namespace` defaults to `tenant_id` (a backend-partition primitive — Pinecone uses it — not a chunk field, so `filter_pushdown` is unchanged); scope stops at resolution + threading (ACL push-down 6.3, PII egress 6.5, physical tenancy 6.2); additive + inert in `build_app` | +| [ADR-0034-physical-multi-tenancy.md](adr/ADR-0034-physical-multi-tenancy.md) | Decision (Step 6.2): a *dedicated* vector index/collection per tenant. `TenantConfig.dedicated_index` resolves to a `physical_index` key on `TenantSettings`, threaded onto `RequestContext.physical_index`; backends read only `ctx` (graph is backends→core, never rag-config) and namespace their base under it (`-`), lazily creating it; one instance + per-tenant derivation (no per-tenant instances, no SPI change); Noop is the CI conformance oracle (keyed by `physical_index`) for a cross-tenant probe gate that proves isolation independent of the tenant filter; Noop + Pinecone + Qdrant this step, others later | | [ADR-0031-cost-anomaly.md](adr/ADR-0031-cost-anomaly.md) | Decision (Step 5.6c): detect per-tenant spend spikes with a rolling `CostTracker` (not the cumulative quota counter); detect scale-free on the token series (cost = tokens × a constant price) so detection is decoupled from quota pricing and works with quotas off; two gates (ratio + z-score, z relaxed on a flat baseline) → tri-state verdict; put it in `rag-observability` as a `dataclass` (gateway wraps it in a Pydantic `CostStatusResponse`) so there's **no `rag-core` type / `dist/schemas` churn**; feed O(1) from `record_request_usage` before the quota block; pull-based `GET /v1/status/cost` (no per-request span/event); rejected folding into the infra-scoped drift registry, a new package, a `cost.anomaly_detected` push event (deferred), per-model pricing, a time-series DB | | [ADR-0030-drift-monitors.md](adr/ADR-0030-drift-monitors.md) | Decision (Step 5.5): detect retrieval degradation with five drift monitors in a new `rag-drift` package (mirroring rag-feedback); two statistics — PSI (pure, binned, dependency-free) for the distribution monitors + mean-drop for the rate/score monitors — over one scalar-window `DriftMonitor`; infra-scoped registry (like breakers) fed via `observe` from the signals the gateway already computes (query length / retrieval score / HyDE-embedding norm / guard grounded-claim fraction / feedback citation clicks); detection on dashboard-poll `evaluate()` with transition-edge `drift.detected` (structured event + the Step 3.9 webhook, targeting `alert_tenant`); observe-only / inert-by-default / rebaseline; rejected per-tenant monitors, per-dimension embedding PSI, a stats library, a background scheduler, hot-path detection | | [ADR-0029-online-feedback.md](adr/ADR-0029-online-feedback.md) | Decision (Step 5.4): capture online feedback + implicit signals in a new `rag-feedback` package mirroring `rag-provenance` (SPI + types in rag-core; recorder + pure aggregator in the package); one polymorphic `POST /v1/feedback` (a `signal` enum spanning explicit thumbs/rating/comment + implicit citation-click/copy/regenerate/dwell, `kind` inferred); normalise every signal to a `[-1,1]` score so the dashboard has one satisfaction number; **redact-don't-hash** free-text comments via an injected `PIIDetector` (default `NoopPIIDetector` seam, `comment_redacted` flag, PII-free event) — opposite of provenance's hashing; body identity like `/v1/query`; degrade-open + inert-by-default; `GET /v1/status/feedback` dashboard (event-only, no per-call span); admin-UI card deferred to 5.6; rejected separate per-signal endpoints, header-auth, hashing/raw comments, an OTel span per submission, folding into provenance | diff --git a/docs/adr/ADR-0034-physical-multi-tenancy.md b/docs/adr/ADR-0034-physical-multi-tenancy.md new file mode 100644 index 0000000..6ca86e3 --- /dev/null +++ b/docs/adr/ADR-0034-physical-multi-tenancy.md @@ -0,0 +1,71 @@ +# ADR-0034 — Physical multi-tenancy (dedicated index) + +**Status:** Accepted +**Date:** 2026-06-08 +**Step:** 6.2 — Physical tenancy (dedicated index) (Phase 6 — Governance & Tenancy) +**Related:** [ADR-0033](ADR-0033-logical-multi-tenancy.md) (logical multi-tenancy), [reference/tenancy.md](../reference/tenancy.md), [architecture/multi-tenancy.md](../architecture/multi-tenancy.md) + +## Context + +Step 6.1 delivered *logical* multi-tenancy: one shared vector index, isolated by a +`tenant_id` filter (+ the `ctx.namespace` partition). For tenants that require hard +isolation — regulatory, contractual, or blast-radius — a filter is not enough; they +need their data in a **physically separate** index/collection, so a bug or a bypassed +filter still can't surface another tenant's vectors. That is Step 6.2. + +## Decision + +**1. A resolved `physical_index` key, threaded on the `RequestContext`.** Mirroring how +6.1 threaded `namespace`: `TenantConfig.dedicated_index` (bool) + optional +`dedicated_index_name` resolve to a `physical_index` key on `TenantSettings` +(`dedicated_index_name or tenant_id`, else `None`), applied once at the gateway boundary +onto `RequestContext.physical_index`. A logical-only tenant resolves to `None`. + +**2. Backends derive `-`; they read only `ctx`.** `rag-backends` may import +`rag-core` only — never `rag-config`/`TenantResolver` — so the per-tenant decision must +arrive via `ctx`. Each backend namespaces its own base index under the key +(`-`) and falls back to the base index when `None`. This keeps the +naming uniform and collision-safe (everything under the deployment's base) and needs **no +SPI change** — the methods already take `ctx`. + +**3. One backend instance, per-tenant index derivation — not per-tenant instances.** The +single shared backend selects/creates the per-tenant index from `ctx`. Per-tenant backend +*instances* (and their memory/wiring cost) are deferred; callers still see one +`HybridRetriever`. + +**4. Lazy check-and-create.** A dedicated index/collection is created on first use and +cached (Pinecone index handle / Qdrant ensured-set), reusing the dimension from +`initialize()`. No eager provisioning step is required for correctness; an operator can +still pre-create out of band. + +**5. The Noop store is the CI conformance oracle.** Real per-index isolation lives in +Pinecone/Qdrant (live-service integration tests, skipped in CI). To get a *CI-runnable* +cross-tenant probe gate, `NoopVectorStore` partitions its in-memory store by +`ctx.physical_index`, so a read only ever scans its own partition. The probe gate asserts +isolation **independent of the tenant filter** (a matching `tenant_id` pointed at a +different index still returns nothing). + +**6. Backends in this step: Noop + Pinecone + Qdrant.** Pinecone (dedicated index) and +Qdrant (dedicated collection) are the cleanest native mappings; pgvector / Elasticsearch / +Weaviate follow the same `-` pattern in later PRs. + +## Consequences + +**Positive** +- Hard isolation for tenants that need it, opt-in per tenant in `rag.yaml`, with the same + resolve-once-at-the-boundary discipline as 6.1. +- No SPI change, no per-tenant instances, no `rag-config` dependency in backends. +- The probe gate runs in CI on the Noop oracle and proves physical (not just filter) + separation; live backends are integration-tested. + +**Negative / deferred** +- The backend now knows how to derive a per-tenant index (a small multi-tenancy concern in + the impl) — accepted because `ctx` is already threaded everywhere. +- pgvector / Elasticsearch / Weaviate dedicated indexes; per-tenant backend instances; + per-tenant *provider* selection; and data migration shared→dedicated are all deferred. +- Lazy creation makes a tenant's first request slightly slower; an operator can pre-create + to avoid it. + +## See also +- [reference/tenancy.md](../reference/tenancy.md) — config + `physical_index` + probe gate +- [architecture/multi-tenancy.md](../architecture/multi-tenancy.md) — design + boundaries diff --git a/docs/architecture/multi-tenancy.md b/docs/architecture/multi-tenancy.md index 601c35e..ce61e4c 100644 --- a/docs/architecture/multi-tenancy.md +++ b/docs/architecture/multi-tenancy.md @@ -50,22 +50,35 @@ request (X-Tenant-Id: acme) ## Governance & boundaries -This is the *foundation* for Phase 6; it resolves and threads the primitives that -later steps enforce: +Step 6.1 is the *foundation* for Phase 6; it resolves and threads the primitives +that later steps enforce: -| Concern | Step | This step's part | -|---------|------|------------------| -| Per-tenant config / namespace / ACL+PII *model* | **6.1** | resolve + thread | -| Physical tenancy (dedicated index) | 6.2 | — | +| Concern | Step | Status | +|---------|------|--------| +| Per-tenant config / namespace / ACL+PII *model* | **6.1** | resolve + thread ✅ | +| Physical tenancy (dedicated index) | **6.2** | resolved `physical_index` → backend `-` ✅ | | ACL push-down at retrieval | 6.3 | labels are on the principal, ready to inject | | ACL egress verifier | 6.4 | — | | PII egress enforcement | 6.5 | `ctx.pii_policy` is resolved, ready to enforce | +## Physical tenancy (Step 6.2) + +The same resolve-once-at-the-boundary pattern extends to *physical* isolation. A +tenant with `dedicated_index: true` resolves to a `physical_index` key on +`TenantSettings`, threaded onto `RequestContext.physical_index`. A vector backend — +reading only `ctx` (backends import `rag-core`, never `rag-config`) — namespaces its +base index under the key (`-`) and creates it lazily on first use; `None` +keeps the shared base index. Supported on Noop (the CI oracle, keyed by +`physical_index`), Pinecone (dedicated index), and Qdrant (dedicated collection). The +cross-tenant probe gate proves isolation *independent of the tenant filter* on the +Noop oracle; live backends are integration-tested. See +[ADR-0034](../adr/ADR-0034-physical-multi-tenancy.md). + ## Observability - `GET /v1/status/tenant` returns the resolved view for a tenant (namespace, PII - action, ACL labels, and whether it's `known` in config) — so an operator can see - exactly what config a tenant's requests run under. + action, ACL labels, `dedicated_index` / `physical_index`, and whether it's `known` + in config) — so an operator can see exactly what config a tenant runs under. - `ragctl tenant resolve ` prints the same resolution against a `rag.yaml`. ## Inert by default diff --git a/docs/architecture/request-context.md b/docs/architecture/request-context.md index b273c17..b4731c5 100644 --- a/docs/architecture/request-context.md +++ b/docs/architecture/request-context.md @@ -29,6 +29,7 @@ class RequestContext(BaseModel): tenant_id: TenantId # required; matched against principal.tenant_id principal: Principal # user/service identity + ACL labels namespace: str # logical-isolation key (Step 6.1); defaults to tenant_id + physical_index: str | None # dedicated-index key (Step 6.2); None = shared base index pii_policy: PiiPolicy # per-tenant: redact | mask | encrypt | tag_only | block | allow trace: TraceContext # OTel span + correlation IDs budget: Budget # tokens, dollars, wall_ms, max_iter diff --git a/docs/reference/tenancy.md b/docs/reference/tenancy.md index 6dffec6..3506395 100644 --- a/docs/reference/tenancy.md +++ b/docs/reference/tenancy.md @@ -45,6 +45,8 @@ tenants: | `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. | @@ -90,13 +92,51 @@ ragctl tenant resolve acme -f rag.yaml # the effective settings the gateway app ## Scope & boundaries -This step is the *logical-tenancy foundation*. It resolves and threads the +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**. - **PII egress** enforcement (block / redact answers per policy) — **6.5**. -- **Physical tenancy** (a dedicated index per tenant) — **6.2**. + +## 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: **`-`**, 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`. + +```yaml +tenants: + - id: acme + name: Acme Corp + dedicated_index: true # → index "-acme" + - id: vault + name: Vault + dedicated_index: true + dedicated_index_name: vault-secure # → index "-vault-secure" +``` + +`GET /v1/status/tenant` and `ragctl tenant resolve ` 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](../architecture/multi-tenancy.md) and +[ADR-0034](../adr/ADR-0034-physical-multi-tenancy.md). ## Extension points diff --git a/packages/backends/src/rag_backends/vector/pinecone.py b/packages/backends/src/rag_backends/vector/pinecone.py index b0f4495..134c531 100644 --- a/packages/backends/src/rag_backends/vector/pinecone.py +++ b/packages/backends/src/rag_backends/vector/pinecone.py @@ -17,6 +17,12 @@ tenant-id filter. ACL labels and other metadata are stored alongside each vector and pushed down via the FilterExpr translator. +For *physical* tenancy (Step 6.2), a tenant with ``ctx.physical_index`` set gets a +**dedicated index** (``-``), created lazily on first use — so its data +lives in a separate Pinecone index, not just a namespace within the shared one. +``ctx.physical_index`` is ``None`` for logical-only tenants, who share the base +index. + The ``pinecone`` Python SDK is an optional extra (``rag-backends[pinecone]``) — this module imports it lazily inside ``__init__`` so installing the package without the extra still allows the rest of ``rag_backends.vector`` @@ -114,6 +120,9 @@ def __init__( self._metric = metric self._index: _IndexAsyncio | None = None self._dimension: int | None = None + # Physical tenancy (Step 6.2): handles to per-tenant *dedicated* indexes, + # keyed by full index name, lazily created + cached on first use. + self._tenant_indexes: dict[str, _IndexAsyncio] = {} # ------------------------------------------------------------------ # Lifecycle @@ -165,6 +174,9 @@ async def close(self) -> None: if self._index is not None: await self._index.close() self._index = None + for handle in self._tenant_indexes.values(): + await handle.close() + self._tenant_indexes.clear() await self._client.close() # ------------------------------------------------------------------ @@ -180,7 +192,7 @@ async def bulk_index( ) -> None: if not embeddings: return - index = self._require_index() + index = await self._index_for(ctx) tenant_id = str(ctx.tenant_id) namespace = str(ctx.namespace) @@ -208,7 +220,7 @@ async def retrieve_ids( corpus_ids: list[CorpusId], filters: FilterExpr | None = None, ) -> list[ChunkRef]: - index = self._require_index() + index = await self._index_for(ctx) tenant_id = str(ctx.tenant_id) namespace = str(ctx.namespace) @@ -258,7 +270,7 @@ async def retrieve_ids( async def bulk_delete(self, ctx: RequestContext, chunk_ids: list[ChunkId]) -> None: if not chunk_ids: return - index = self._require_index() + index = await self._index_for(ctx) tenant_id = str(ctx.tenant_id) ids = [_vector_id(tenant_id, str(cid)) for cid in chunk_ids] await index.delete(ids=ids, namespace=str(ctx.namespace)) @@ -285,6 +297,40 @@ def _require_index(self) -> _IndexAsyncio: raise RetrievalError("PineconeVectorStore.initialize() not called") return self._index + def _index_name_for(self, ctx: RequestContext) -> str: + """The index name for this request — dedicated (``-``) or base.""" + key = ctx.physical_index + return f"{self._index_name}-{key}" if key else self._index_name + + async def _index_for(self, ctx: RequestContext) -> _IndexAsyncio: + """Return the index handle for this request, lazily creating a dedicated one. + + Physical tenancy (Step 6.2): a tenant with ``ctx.physical_index`` set gets + its own index ``-``, created on first use and cached. Everyone + else shares the base index opened by :meth:`initialize`. + """ + name = self._index_name_for(ctx) + if name == self._index_name: + return self._require_index() + handle = self._tenant_indexes.get(name) + if handle is None: + if self._dimension is None: + raise RetrievalError("PineconeVectorStore.initialize() not called") + existing = await self._client.list_indexes() + if name not in {ix.name for ix in existing}: + from pinecone import ServerlessSpec + + await self._client.create_index( + name=name, + dimension=self._dimension, + metric=self._metric, + spec=ServerlessSpec(cloud=self._cloud, region=self._region), + ) + _log.info("pinecone dedicated index created", extra={"index": name}) + handle = self._client.IndexAsyncio(name=name) + self._tenant_indexes[name] = handle + return handle + def _hit_metadata(hit: Any) -> dict[str, Any]: """Extract metadata from a Pinecone match (dict or pydantic-like).""" diff --git a/packages/backends/src/rag_backends/vector/qdrant.py b/packages/backends/src/rag_backends/vector/qdrant.py index b0b9f75..66ad71f 100644 --- a/packages/backends/src/rag_backends/vector/qdrant.py +++ b/packages/backends/src/rag_backends/vector/qdrant.py @@ -7,10 +7,15 @@ await store.bulk_index(ctx, embeddings) refs = await store.retrieve_ids(ctx, vector, top_k=10, corpus_ids=[]) -A single Qdrant collection (``rag_embeddings`` by default) stores all tenants. -Tenant isolation is enforced via a ``tenant_id`` payload filter on every query -and delete operation. This avoids collection-proliferation and makes +A single Qdrant collection (``rag_embeddings`` by default) stores all +logical-tenancy tenants, isolated via a ``tenant_id`` payload filter on every +query and delete operation — avoiding collection-proliferation and keeping cross-corpus queries efficient. + +For *physical* tenancy (Step 6.2), a tenant with ``ctx.physical_index`` set gets a +**dedicated collection** (``-``), created lazily on first use — so its +data lives in a separate Qdrant collection, not just a payload-filtered slice of +the shared one. ``ctx.physical_index`` is ``None`` for logical-only tenants. """ from __future__ import annotations @@ -32,6 +37,7 @@ ScalarType, VectorParams, ) +from rag_core.errors import RetrievalError from rag_core.filter import FilterExpr from rag_core.spi.vector_store import VectorStore from rag_core.types import ( @@ -114,6 +120,10 @@ def __init__( self._url = url self._collection = collection self._client: AsyncQdrantClient = AsyncQdrantClient(url=url, api_key=api_key) + self._dimension: int | None = None + # Physical tenancy (Step 6.2): dedicated per-tenant collections we've + # already ensured exist, so we create each at most once. + self._ensured: set[str] = set() # ------------------------------------------------------------------ # Lifecycle @@ -136,6 +146,7 @@ async def initialize( deliberately not done here so re-running ``initialize()`` is safe. """ + self._dimension = dimension exists = await self._client.collection_exists(self._collection) if not exists: variant = select_index_variant(hint) @@ -164,6 +175,35 @@ async def close(self) -> None: """Close the underlying HTTP client.""" await self._client.close() + def _collection_name_for(self, ctx: RequestContext) -> str: + """The collection for this request — dedicated (``-``) or base.""" + key = ctx.physical_index + return f"{self._collection}-{key}" if key else self._collection + + async def _collection_for(self, ctx: RequestContext) -> str: + """Return the collection name for this request, creating a dedicated one lazily. + + Physical tenancy (Step 6.2): a tenant with ``ctx.physical_index`` set gets + its own collection ``-``, created on first use; everyone else + uses the base collection from :meth:`initialize`. + """ + name = self._collection_name_for(ctx) + if name == self._collection or name in self._ensured: + return name + if not await self._client.collection_exists(name): + if self._dimension is None: + raise RetrievalError("QdrantVectorStore.initialize() not called") + hnsw_config, quantization = _qdrant_index_config(select_index_variant(None)) + await self._client.create_collection( + collection_name=name, + vectors_config=VectorParams(size=self._dimension, distance=Distance.COSINE), + hnsw_config=hnsw_config, + quantization_config=quantization, + ) + _log.info("Qdrant dedicated collection created", extra={"collection": name}) + self._ensured.add(name) + return name + # ------------------------------------------------------------------ # VectorStore SPI (Step 1.1b split: bulk_index / retrieve_ids / bulk_delete) # ------------------------------------------------------------------ @@ -196,7 +236,7 @@ async def bulk_index( ) for emb in embeddings ] - await self._client.upsert(collection_name=self._collection, points=points) + await self._client.upsert(collection_name=await self._collection_for(ctx), points=points) async def retrieve_ids( self, @@ -231,7 +271,7 @@ async def retrieve_ids( extra_should = list(extra.should) response = await self._client.query_points( - collection_name=self._collection, + collection_name=await self._collection_for(ctx), query=vector, query_filter=Filter( must=must, @@ -264,7 +304,7 @@ async def bulk_delete(self, ctx: RequestContext, chunk_ids: list[ChunkId]) -> No point_ids = [_chunk_uuid(str(ctx.tenant_id), str(cid)) for cid in chunk_ids] await self._client.delete( - collection_name=self._collection, + collection_name=await self._collection_for(ctx), points_selector=point_ids, # type: ignore[arg-type] ) diff --git a/packages/config/src/rag_config/schema.py b/packages/config/src/rag_config/schema.py index 9bc02cb..6ef0ebf 100644 --- a/packages/config/src/rag_config/schema.py +++ b/packages/config/src/rag_config/schema.py @@ -333,6 +333,12 @@ class TenantConfig(_StrictBase): # Tenant-wide ACL labels unioned into every principal of this tenant. The # config side of ACLs (Step 6.1); retrieval-time push-down enforcement is 6.3. acl_labels: list[str] = Field(default_factory=list) + # Physical tenancy (Step 6.2): when true, this tenant's vectors live in a + # *dedicated* index/collection (``-``), not the shared one — so the + # data is separate even if a filter were bypassed. The key is + # ``dedicated_index_name`` when set, else the tenant ``id``. + dedicated_index: bool = False + dedicated_index_name: Annotated[str, Field(min_length=1)] | None = None # --------------------------------------------------------------------------- diff --git a/packages/config/src/rag_config/tenancy.py b/packages/config/src/rag_config/tenancy.py index 57f40bd..e65e02e 100644 --- a/packages/config/src/rag_config/tenancy.py +++ b/packages/config/src/rag_config/tenancy.py @@ -38,9 +38,22 @@ def settings_for_tenant(tenant: TenantConfig) -> TenantSettings: namespace=tenant.namespace or tenant.id, pii_policy=_pii_policy_from(tenant.pii_policy), acl_labels=frozenset(tenant.acl_labels), + physical_index=_physical_index_for(tenant), ) +def _physical_index_for(tenant: TenantConfig) -> str | None: + """The per-tenant physical-index key (Step 6.2), or ``None`` for shared. + + ``None`` when the tenant isn't physically isolated. Otherwise the explicit + ``dedicated_index_name`` if set, else the tenant ``id`` — the backend + namespaces its base index under this key (``-``). + """ + if not tenant.dedicated_index: + return None + return tenant.dedicated_index_name or tenant.id + + class TenantResolver: """Resolves a tenant id → :class:`TenantSettings` from a ``RagConfig``. diff --git a/packages/core/src/rag_core/spi/noop/vector_store.py b/packages/core/src/rag_core/spi/noop/vector_store.py index 8305581..536cdc6 100644 --- a/packages/core/src/rag_core/spi/noop/vector_store.py +++ b/packages/core/src/rag_core/spi/noop/vector_store.py @@ -34,11 +34,23 @@ class NoopVectorStore(VectorStore): fields are treated as missing (predicates referencing them never match); this is intentionally permissive for the in-memory test backend — real backends (pgvector, Qdrant) translate the AST natively. + + Step 6.2 (physical tenancy): entries are partitioned by ``ctx.physical_index`` + — a tenant with a *dedicated* index lives in its own partition, separate from + the shared one and from every other dedicated tenant. This makes the + in-memory store the conformance oracle for the cross-tenant probe gate: a + read only ever scans its own partition, so isolation holds even if the + ``tenant_id`` filter were bypassed. """ def __init__(self) -> None: - # (tenant_id, chunk_id) -> Embedding - self._store: dict[tuple[str, str], Embedding] = {} + # (physical_index, tenant_id, chunk_id) -> Embedding + self._store: dict[tuple[str, str, str], Embedding] = {} + + @staticmethod + def _index_key(ctx: RequestContext) -> str: + """Physical-index partition (Step 6.2): the dedicated key, or shared.""" + return ctx.physical_index or "__shared__" @staticmethod def _embedding_attrs(emb: Embedding) -> dict[str, Any]: @@ -57,8 +69,9 @@ async def bulk_index( *, hint: IndexHint | None = None, ) -> None: + index = self._index_key(ctx) for emb in embeddings: - self._store[(ctx.tenant_id, emb.chunk_id)] = emb + self._store[(index, ctx.tenant_id, emb.chunk_id)] = emb async def retrieve_ids( self, @@ -68,9 +81,12 @@ async def retrieve_ids( corpus_ids: list[CorpusId], filters: FilterExpr | None = None, ) -> list[ChunkRef]: + index = self._index_key(ctx) scored: list[tuple[float, Embedding]] = [] - for (tid, _cid), emb in self._store.items(): - if tid != ctx.tenant_id: + for (idx, tid, _cid), emb in self._store.items(): + # Physical isolation (Step 6.2) first, then the tenant filter — a read + # never scans another partition, independent of the tenant filter. + if idx != index or tid != ctx.tenant_id: continue # Filter push-down (Step 2.1) — reference semantics. if filters is not None and not evaluate(filters, self._embedding_attrs(emb)): @@ -93,8 +109,9 @@ async def bulk_delete( ctx: RequestContext, chunk_ids: list[ChunkId], ) -> None: + index = self._index_key(ctx) for cid in chunk_ids: - self._store.pop((ctx.tenant_id, cid), None) + self._store.pop((index, ctx.tenant_id, cid), None) async def health(self) -> bool: return True diff --git a/packages/core/src/rag_core/types.py b/packages/core/src/rag_core/types.py index 195fb82..f363414 100644 --- a/packages/core/src/rag_core/types.py +++ b/packages/core/src/rag_core/types.py @@ -364,6 +364,11 @@ class RequestContext(BaseModel): tenant_id: TenantId principal: Principal namespace: str = "" + # Per-tenant physical-isolation key (Step 6.2). ``None`` → the shared base + # index/collection (logical tenancy only); a string → the tenant has a + # *dedicated* index and a backend namespaces its base under it + # (``-``). Resolved from ``rag.yaml`` at the boundary. + physical_index: str | None = None pii_policy: PiiPolicy = Field(default_factory=PiiPolicy) trace: TraceContext = Field(default_factory=TraceContext) budget: Budget = Field(default_factory=Budget) @@ -435,6 +440,10 @@ class TenantSettings(BaseModel): namespace: str pii_policy: PiiPolicy = Field(default_factory=PiiPolicy) acl_labels: frozenset[str] = Field(default_factory=frozenset) + # Per-tenant physical-isolation key (Step 6.2): the index/collection key a + # backend namespaces its base under (``-``) when the + # tenant has a *dedicated* index. ``None`` → the shared base index. + physical_index: str | None = None # --------------------------------------------------------------------------- diff --git a/packages/ragctl/src/ragctl/main.py b/packages/ragctl/src/ragctl/main.py index 2d93548..d5ee401 100644 --- a/packages/ragctl/src/ragctl/main.py +++ b/packages/ragctl/src/ragctl/main.py @@ -4675,6 +4675,10 @@ def tenant_resolve( typer.echo(f"pii_action: {settings.pii_policy.action.value}") labels = ", ".join(sorted(settings.acl_labels)) or "(none)" typer.echo(f"acl_labels: {labels}") + if settings.physical_index is not None: + typer.echo(f"index: dedicated (key={settings.physical_index})") + else: + typer.echo("index: shared (base index)") # --------------------------------------------------------------------------- diff --git a/packages/ragctl/tests/test_tenant.py b/packages/ragctl/tests/test_tenant.py index 5036d97..18576f2 100644 --- a/packages/ragctl/tests/test_tenant.py +++ b/packages/ragctl/tests/test_tenant.py @@ -18,6 +18,7 @@ pii_policy: block namespace: acme-prod acl_labels: [region:eu, pii:cleared] + dedicated_index: true - id: beta name: Beta LLC pii_policy: allow @@ -48,6 +49,14 @@ def test_tenant_resolve_known(cfg: Path) -> None: assert "namespace: acme-prod" in result.output assert "pii_action: block" in result.output assert "pii:cleared" in result.output and "region:eu" in result.output + # acme is physically isolated → dedicated index keyed by its id. + assert "index: dedicated (key=acme)" in result.output + + +def test_tenant_resolve_shared_index(cfg: Path) -> None: + result = runner.invoke(app, ["tenant", "resolve", "beta", "-f", str(cfg)]) + assert result.exit_code == 0, result.output + assert "index: shared (base index)" in result.output def test_tenant_resolve_unknown_uses_safe_defaults(cfg: Path) -> None: @@ -58,6 +67,7 @@ def test_tenant_resolve_unknown_uses_safe_defaults(cfg: Path) -> None: assert "namespace: ghost" in result.output assert "pii_action: redact" in result.output assert "acl_labels: (none)" in result.output + assert "index: shared (base index)" in result.output def test_tenant_help_works() -> None: diff --git a/tests/config/test_tenancy.py b/tests/config/test_tenancy.py index eaf482d..7d0548a 100644 --- a/tests/config/test_tenancy.py +++ b/tests/config/test_tenancy.py @@ -84,3 +84,39 @@ def test_pii_enum_maps_one_for_one() -> None: ]: s = settings_for_tenant(TenantConfig(id="t", name="T", pii_policy=cfg_policy)) assert s.pii_policy.action is action + + +# --------------------------------------------------------------------------- +# Physical tenancy (Step 6.2) +# --------------------------------------------------------------------------- +def test_physical_index_defaults_to_none() -> None: + t = TenantConfig(id="x", name="X") + assert t.dedicated_index is False + assert settings_for_tenant(t).physical_index is None + + +def test_dedicated_index_auto_derives_key_from_id() -> None: + s = settings_for_tenant(TenantConfig(id="acme", name="Acme", dedicated_index=True)) + assert s.physical_index == "acme" + + +def test_dedicated_index_name_overrides_the_key() -> None: + s = settings_for_tenant( + TenantConfig( + id="acme", name="Acme", dedicated_index=True, dedicated_index_name="acme-vault" + ) + ) + assert s.physical_index == "acme-vault" + + +def test_dedicated_index_name_ignored_when_flag_off() -> None: + # A name without the flag does not opt the tenant into a dedicated index. + s = settings_for_tenant(TenantConfig(id="acme", name="Acme", dedicated_index_name="acme-vault")) + assert s.physical_index is None + + +def test_unknown_tenant_has_no_dedicated_index() -> None: + r = TenantResolver.from_config( + RagConfig(tenants=[TenantConfig(id="acme", name="Acme", dedicated_index=True)]) + ) + assert r.resolve("ghost").physical_index is None diff --git a/tests/redteam/test_cross_tenant_dedicated_index.py b/tests/redteam/test_cross_tenant_dedicated_index.py new file mode 100644 index 0000000..7431c92 --- /dev/null +++ b/tests/redteam/test_cross_tenant_dedicated_index.py @@ -0,0 +1,120 @@ +"""Cross-tenant probe gate: a dedicated index is *physically* isolated (Step 6.2). + +Physical tenancy gives a tenant its own vector index/collection (``-``), +so its data is separate even if the ``tenant_id`` filter were bypassed. This gate +proves that on the in-memory ``NoopVectorStore`` (the conformance oracle, keyed by +``ctx.physical_index``): a read only ever scans its own physical partition, so +isolation does **not** depend on the logical tenant filter. The same guarantee on +Pinecone / Qdrant is covered by the live-service integration tests. +""" + +from __future__ import annotations + +from rag_config import RagConfig, TenantConfig, TenantResolver +from rag_core.spi.noop.vector_store import NoopVectorStore +from rag_core.types import ( + ChunkId, + Embedding, + Principal, + PrincipalId, + PrincipalKind, + RequestContext, + TenantId, +) + +_RESOLVER = TenantResolver.from_config( + RagConfig( + tenants=[ + TenantConfig(id="acme", name="Acme", dedicated_index=True), + TenantConfig(id="globex", name="Globex", dedicated_index=True), + TenantConfig(id="shared", name="Shared"), # logical-only + ] + ) +) + + +def _ctx(tenant: str, *, physical_index: str | None) -> RequestContext: + tid = TenantId(tenant) + return RequestContext( + tenant_id=tid, + principal=Principal( + id=PrincipalId("p"), + kind=PrincipalKind.service, + display_name="p", + tenant_id=tid, + ), + physical_index=physical_index, + ) + + +def _resolved_ctx(tenant: str) -> RequestContext: + """Build a ctx with the resolver-computed physical_index (the gateway's path).""" + return _ctx(tenant, physical_index=_RESOLVER.resolve(tenant).physical_index) + + +def _emb(chunk_id: str, tenant: str) -> Embedding: + return Embedding( + chunk_id=ChunkId(chunk_id), + tenant_id=TenantId(tenant), + vector=[1.0, 0.0], + dimension=2, + model="m", + ) + + +async def test_resolver_assigns_distinct_dedicated_indexes() -> None: + assert _RESOLVER.resolve("acme").physical_index == "acme" + assert _RESOLVER.resolve("globex").physical_index == "globex" + assert _RESOLVER.resolve("shared").physical_index is None + + +async def test_dedicated_tenant_data_invisible_to_another_dedicated_tenant() -> None: + store = NoopVectorStore() + await store.bulk_index(_resolved_ctx("acme"), [_emb("a1", "acme")]) + # globex queries its own dedicated index → never sees acme's chunk. + refs = await store.retrieve_ids(_resolved_ctx("globex"), [1.0, 0.0], top_k=10, corpus_ids=[]) + assert refs == [] + + +async def test_isolation_is_physical_not_just_the_tenant_filter() -> None: + """Even with a matching tenant_id, querying a *different* index returns nothing.""" + store = NoopVectorStore() + await store.bulk_index(_resolved_ctx("acme"), [_emb("a1", "acme")]) + # Same tenant_id (acme) — so the tenant filter WOULD match — but pointed at the + # shared index instead of acme's dedicated one: the data is physically elsewhere. + refs = await store.retrieve_ids( + _ctx("acme", physical_index=None), [1.0, 0.0], top_k=10, corpus_ids=[] + ) + assert refs == [] + # ...and pointed at globex's index: still nothing. + refs2 = await store.retrieve_ids( + _ctx("acme", physical_index="globex"), [1.0, 0.0], top_k=10, corpus_ids=[] + ) + assert refs2 == [] + + +async def test_dedicated_tenant_sees_its_own_data() -> None: + store = NoopVectorStore() + await store.bulk_index(_resolved_ctx("acme"), [_emb("a1", "acme")]) + refs = await store.retrieve_ids(_resolved_ctx("acme"), [1.0, 0.0], top_k=10, corpus_ids=[]) + assert [str(r.chunk_id) for r in refs] == ["a1"] + + +async def test_shared_and_dedicated_partitions_do_not_cross() -> None: + store = NoopVectorStore() + # A logical-only tenant writes into the shared partition. + await store.bulk_index(_resolved_ctx("shared"), [_emb("s1", "shared")]) + # A dedicated tenant cannot see shared-partition data... + assert await store.retrieve_ids(_resolved_ctx("acme"), [1.0, 0.0], 10, []) == [] + # ...and the shared tenant only sees its own. + refs = await store.retrieve_ids(_resolved_ctx("shared"), [1.0, 0.0], 10, []) + assert [str(r.chunk_id) for r in refs] == ["s1"] + + +async def test_delete_is_scoped_to_the_tenants_index() -> None: + store = NoopVectorStore() + await store.bulk_index(_resolved_ctx("acme"), [_emb("a1", "acme")]) + # A delete issued against the shared index must not touch acme's dedicated data. + await store.bulk_delete(_ctx("acme", physical_index=None), [ChunkId("a1")]) + refs = await store.retrieve_ids(_resolved_ctx("acme"), [1.0, 0.0], 10, []) + assert [str(r.chunk_id) for r in refs] == ["a1"] # still there