diff --git a/TRACKER.md b/TRACKER.md index 46262e9..103dff4 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -14,12 +14,13 @@ | | | |---|---| | **Last updated** | 2026-06-08 | -| **Current phase** | Phase 6 — Governance & Tenancy (**9 / 10 steps**) | -| **Overall** | **73 / 84 steps** — Phases 0–5 complete | -| **Next action** | **Step 6.10 — Compliance posture**: SOC 2 Type II control mapping; GDPR data-residency config; data-retention policies. | +| **Current phase** | Phase 7 — Pilot, Harden, GA (**0 / 10 steps**) — Phase 6 ✅ complete | +| **Overall** | **74 / 84 steps** — Phases 0–6 complete | +| **Next action** | **Step 7.1 — Load testing**: Locust suite; 1000 QPS sustained; p99 < 500 ms; chaos under load. | **Recently shipped** +- **6.10** ✅ Compliance posture (Phase 6 capstone) — new `rag-compliance` package: `RetentionEnforcer` drives tenant-scoped `purge_*` (data retention + GDPR right-to-erasure) over the Feedback/Provenance stores (non-abstract `purge_before`/`purge_tenant` SPI capability with `dry_run` so a preview counts-without-deleting; audit chain never purged in place — its retention is the 6.6b WORM export); `POST /v1/compliance/erase` (tenant-scoped, dry-run default + two-flag delete); per-tenant `data_region` enforced at ingest (`ResidencyViolationError` → 403); `GET /v1/status/compliance` posture + `compliance_posture`/`residency_ok`; PII-free `compliance.*` events; `cfg.compliance` + `tenants[].data_region`/`retention_days`; `ragctl compliance report`/`demo`; SOC 2 + GDPR control-mapping docs backed by the live posture — [#162](https://github.com/officialCodeWork/AgentContextOS/pull/162) - **6.9** ✅ Air-gapped install bundle — `ragctl airgap build`/`inspect`/`verify`/`install`: one signed `.tar.gz` of all runtime images (`docker save`) + the packaged Helm chart + `rag.yaml` + a standalone installer, for networks with no registry / internet. Integrity = a standard `SHA256SUMS` (verifiable with `sha256sum -c`, no cosign/network) pinned as `manifest.content_hash` + an optional cosign signature over it (the 6.6b content-hash + optional-signature pattern); the same `SHA256SUMS` drives the Python verifier and the standalone `install.{sh,ps1}` (need only docker+helm). Typed/tested logic in `ragctl.airgap` (pure core + stubbable docker/helm/cosign seam; `--dry-run` = verifiable bundle minus blobs); digest-pinned manifest-driven image set (`infra/airgap/images.txt`); `task airgap:*`; `release-airgap.yml` on tags — [#161](https://github.com/officialCodeWork/AgentContextOS/pull/161) - **6.8** ✅ SSO / SCIM — `rag-sso`: `FederatedAuth` (an `Auth` SPI backend over per-tenant `OidcProvider` / `SamlProvider`) federates a bearer token / SAML assertion → `Principal` at the existing `authenticate` seam (group claims → `acl_labels`); dependency-free defaults (stdlib HS256 JWT + `defusedxml` SAML), asymmetric OIDC / XML-DSig behind `[oidc]` / `[saml]` extras; algorithm-allowlist downgrade defense; SCIM 2.0 `ScimService` over the new tenant-scoped `ScimStore` SPI driving `/scim/v2/{Users,Groups}` (per-tenant bearer token); per-tenant IdP on `tenants[].sso`; `cfg.sso` / `cfg.scim`; `GET /v1/status/sso`; PII-free `sso.*` / `scim.*` events; `ragctl sso` / `scim` — [#159](https://github.com/officialCodeWork/AgentContextOS/pull/159) - **6.7** ✅ BYOK envelope encryption — `KeyManager` SPI + `EncryptingStorage` + `EnvelopeKeyManager` (AES-256-GCM DEK + tenant AAD); `LocalKeyManager` + four cloud KMS providers (`Aws`/`Gcp`/`AzureKeyVault`/`Vault`, behind `[kms-*]` extras); `cfg.kms` + per-tenant key + factory; per-tenant isolation + sealing + tamper-evidence; zero-downtime rotation (`RotatingKeyManager` + `rewrap`); `ragctl kms` — [#155](https://github.com/officialCodeWork/AgentContextOS/pull/155)–[#158](https://github.com/officialCodeWork/AgentContextOS/pull/158) @@ -63,9 +64,9 @@ | 3 | Gateway & Agent Runtime | 11 | **11** | 0 | | 4 | Reliability | 6 | **6** | 0 | | 5 | Eval & Observability | 7 | **7** | 0 | -| 6 | Governance & Tenancy | 10 | **9** | 1 | +| 6 | Governance & Tenancy | 10 | **10** | 0 | | 7 | Pilot, Harden, GA | 10 | 0 | 10 | -| **Total** | | **84** | **73** | **11** | +| **Total** | | **84** | **74** | **10** | --- @@ -648,7 +649,7 @@ - **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) ✅ | Step | Title | Status | Planned deliverables | |------|-------|:------:|----------------------| @@ -661,7 +662,7 @@ | 6.7 | BYOK (Bring Your Own Key) | ✅ | **6.7a** ✅ [#155](https://github.com/officialCodeWork/AgentContextOS/pull/155) — `KeyManager` SPI + envelope encryption (`LocalKeyManager`) + `EncryptingStorage` + sealing/isolation/tamper. **6.7b** ✅ [#156](https://github.com/officialCodeWork/AgentContextOS/pull/156) — `cfg.kms` + per-tenant key + `build_key_manager_from_config` factory + `AwsKmsKeyManager`. **6.7c** ✅ [#157](https://github.com/officialCodeWork/AgentContextOS/pull/157) — `GcpKmsKeyManager` / `AzureKeyVaultKeyManager` / `VaultKeyManager` behind `[kms-*]` extras. **6.7d** ✅ [#158](https://github.com/officialCodeWork/AgentContextOS/pull/158) — `RotatingKeyManager` zero-downtime rotation + `rewrap` | | 6.8 | SSO / SCIM | ✅ | [#159](https://github.com/officialCodeWork/AgentContextOS/pull/159) — `rag-sso`: `FederatedAuth` (`Auth` SPI backend over per-tenant `OidcProvider` / `SamlProvider`) federates a token / assertion → `Principal` at the existing `authenticate` seam (groups → `acl_labels`); stdlib HS256 + `defusedxml` defaults, asymmetric OIDC / XML-DSig behind `[oidc]` / `[saml]` extras; SCIM 2.0 `ScimService` + `ScimStore` SPI → `/scim/v2/*` (per-tenant bearer); `tenants[].sso` + `cfg.sso` / `cfg.scim`; `GET /v1/status/sso`; `ragctl sso` / `scim` | | 6.9 | Air-gapped install bundle | ✅ | [#161](https://github.com/officialCodeWork/AgentContextOS/pull/161) — `ragctl airgap build`/`verify`/`install`: signed `.tar.gz` of all images (`docker save`) + Helm chart + config + standalone installer; `SHA256SUMS` content-hash (offline-verifiable) + optional cosign; `infra/airgap/images.txt`; `install.{sh,ps1}`; `task airgap:*`; `release-airgap.yml` | -| 6.10 | Compliance posture | ⏳ | SOC 2 Type II control mapping; GDPR data-residency config; data-retention policies | +| 6.10 | Compliance posture | ✅ | [#162](https://github.com/officialCodeWork/AgentContextOS/pull/162) — `rag-compliance`: `RetentionEnforcer` (data retention + GDPR erasure via `purge_*` SPI capability, `dry_run`) + `POST /v1/compliance/erase`; per-tenant `data_region` enforced at ingest (403); `GET /v1/status/compliance` posture; `cfg.compliance`; PII-free `compliance.*`; `ragctl compliance`; SOC 2 + GDPR mapping docs | ### 6.1 — Logical multi-tenancy ✅ [#148](https://github.com/officialCodeWork/AgentContextOS/pull/148) @@ -780,6 +781,16 @@ New ground — the only prior crypto was HMAC signing. The V1 plan calls for en - **Digest-pinned, manifest-driven image set:** `infra/airgap/images.txt` lists the third-party images (tags); the build resolves each to its digest into `manifest.json`. The gateway image is added from the chart's `image.repository` + `--version`, so the app image is always release-versioned. **Key-based cosign** is the air-gap recommendation (keyless needs Rekor + a deployment-specific identity policy); the release path (`release-airgap.yml` on `v*` tags) signs keyless and attaches the tarball to the GitHub Release - **Scope:** docker-based image load + the gateway chart. **Deferred:** `ctr`/podman load, registry re-tag/push, multi-arch selection, bundling the *backend* charts (chart deploys the gateway; backends stay external), TUF-rooted offline keyless verify. `task airgap:build` / `build-dry` / `verify`; ~17 tests (manifest / hashing / tamper detection / tarball round-trip / signature paths via stubbed cosign / CLI on a dry-run bundle), shell installer syntax-checked + exercised via `--verify-only`. All gates green (ruff, mypy --strict, RAG001, schema/openapi/config-drift, policy-coverage, log-schema). [ADR-0041](docs/adr/ADR-0041-airgap-bundle.md), [reference/airgap.md](docs/reference/airgap.md), [architecture/airgap-bundle.md](docs/architecture/airgap-bundle.md), [guides/airgap-install.md](docs/guides/airgap-install.md) +### 6.10 — Compliance posture ✅ [#162](https://github.com/officialCodeWork/AgentContextOS/pull/162) + +- **Phase 6 capstone.** The platform already shipped the *controls* an auditor maps to (audit / ACL / PII / BYOK / SSO / quotas / breakers / guard); 6.10 adds the three missing pieces — **data retention**, **GDPR erasure / residency**, and a **machine-readable posture** that backs the SOC 2 / GDPR mapping +- **New `rag-compliance` package** (deps rag-core + rag-observability only, config-free — like rag-feedback / rag-drift): **`RetentionEnforcer`** drives tenant-scoped `purge_*` (`purge` = per-class windows, `erase_tenant` = GDPR right-to-erasure); **`compliance_posture`** / **`residency_ok`** are pure +- **Retention is a *capability on the existing stores*, not a new SPI**: non-abstract **`purge_before(ctx, cutoff, *, dry_run)`** + **`purge_tenant(ctx, *, dry_run)`** on `FeedbackStore` + `ProvenanceStore` (default no-op → no backend breaks). `dry_run` lives in the SPI so a preview **counts without deleting** uniformly (`ProvenanceStore` has no `list`). The **audit chain is never purged in place** (it would break tamper-evidence) — audit retention is the Step 6.6b WORM export; `RetentionPolicy.audit_days` is advisory +- **Right-to-erasure** — `POST /v1/compliance/erase` erases the **calling tenant's** data (scope from the principal, never the body); always available (a tenant can always erase its own data), **dry-run by default**, real delete needs `dry_run=false` **and** `confirm=true` +- **Data residency** — `tenants[].data_region` resolves onto `TenantSettings` (reuses the 6.1 resolver) and is enforced at ingest against `cfg.compliance.region`; a mismatch raises **`ResidencyViolationError`** (→ 403) + `compliance.residency_violation`. Opt-in (`cfg.compliance.enabled`), lenient when no region declared — a single-deployment assertion + guard, not multi-region routing +- **The SOC 2 / GDPR mapping is a doc *backed by a live posture*** — **`GET /v1/status/compliance`** serves a `CompliancePosture` (controls + region + retention) computed from the build_app flags; the mapping docs map each Trust Service Criterion / GDPR article to a control, and the posture reports whether it is **on in this deployment** (so the mapping is checkable, not just prose) +- New core types **`DataClass` / `RetentionPolicy` / `ErasureResult` / `CompliancePosture`** + **`ComplianceError`** (400) / **`ResidencyViolationError`** (403); `ComplianceEraseRequest` wire type; `cfg.compliance` + `tenants[].data_region` / `retention_days` (`dist/rag.schema`); PII-free **`compliance.*`** events; **`ragctl compliance report`** (posture from cfg) + **`compliance demo`**. **Scope:** tenant-level erasure + feedback/provenance retention. **Deferred:** subject-level chunk/document erasure (needs an index delete-by-document path), an admin retention-*sweep* endpoint (today the sweep is `ragctl`/cron; erasure is the API), multi-region routing, automated audit-evidence bundles. ~30 tests (enforcer purge/erase/dry-run + posture + residency unit; `purge_*` contract suite; gateway posture + erase + residency-403; ragctl). All gates green (ruff, mypy --strict, RAG001, schema/openapi/config-drift, policy-coverage, log-schema/event-registry/PII). [ADR-0042](docs/adr/ADR-0042-compliance-posture.md), [reference/compliance.md](docs/reference/compliance.md), [architecture/compliance.md](docs/architecture/compliance.md), [compliance/soc2-control-mapping.md](docs/compliance/soc2-control-mapping.md), [compliance/gdpr-mapping.md](docs/compliance/gdpr-mapping.md) + --- ## Phase 7 — Pilot, Harden, GA (Weeks 34–40) ⏳ diff --git a/apps/gateway/pyproject.toml b/apps/gateway/pyproject.toml index 5822aae..cc98735 100644 --- a/apps/gateway/pyproject.toml +++ b/apps/gateway/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "rag-agent", "rag-webhooks", "rag-sso", + "rag-compliance", "fastapi>=0.115", "uvicorn[standard]>=0.30", "python-multipart>=0.0.9", @@ -80,6 +81,7 @@ rag-drift = { workspace = true } rag-agent = { workspace = true } rag-webhooks = { workspace = true } rag-sso = { workspace = true } +rag-compliance = { workspace = true } [tool.hatch.build.targets.wheel] packages = ["src/rag_gateway"] diff --git a/apps/gateway/src/rag_gateway/app.py b/apps/gateway/src/rag_gateway/app.py index 977ac25..a4fa610 100644 --- a/apps/gateway/src/rag_gateway/app.py +++ b/apps/gateway/src/rag_gateway/app.py @@ -115,6 +115,7 @@ from rag_gateway.agent import build_default_agent_loop, make_agent_router from rag_gateway.audit import make_audit_router +from rag_gateway.compliance import make_compliance_router from rag_gateway.corpora import make_corpora_router from rag_gateway.feedback import make_feedback_router from rag_gateway.middleware import install_request_context_middleware @@ -137,6 +138,43 @@ _log = get_logger(__name__) +def _enforce_residency(state: Any, tenant_id: str) -> None: + """Refuse ingest when a tenant's required region != the deployment region (Step 6.10). + + No-op unless ``cfg.compliance.enabled``. Resolves the tenant's ``data_region`` + via the Step 6.1 resolver and compares against ``cfg.compliance.region``; + a mismatch raises :class:`~rag_core.errors.ResidencyViolationError` (HTTP 403) + and emits a PII-free ``compliance.residency_violation`` event. + """ + if not getattr(state, "compliance_enabled", False): + return + resolver = getattr(state, "tenant_resolver", None) + if resolver is None: + return + from rag_compliance import residency_ok + from rag_core.errors import ResidencyViolationError + from rag_observability.events import EVT_COMPLIANCE_RESIDENCY_VIOLATION, ComplianceEvent + + region = getattr(state, "compliance_region", "") + tenant_region = resolver.resolve(str(tenant_id)).data_region + if residency_ok(tenant_region, region): + return + _log.warning( + ComplianceEvent( + event_name=EVT_COMPLIANCE_RESIDENCY_VIOLATION, + tenant_id=str(tenant_id), + operation="residency_violation", + region=region, + tenant_region=tenant_region or "", + ).model_dump_json() + ) + raise ResidencyViolationError( + "tenant data region does not match this deployment's region", + tenant_region=tenant_region, + deployment_region=region, + ) + + # --------------------------------------------------------------------------- # Default wiring — dev / smoke; production replaces this from rag.yaml in 3.5 # --------------------------------------------------------------------------- @@ -382,6 +420,11 @@ def build_app( scim_enabled: bool = False, scim_tokens: dict[str, str] | None = None, sso_enabled: bool = False, + compliance_enabled: bool = False, + compliance_region: str = "", + retention_policy: Any | None = None, + retention_enforcer: Any | None = None, + compliance_posture: Any | None = None, enable_cors: bool = True, default_tenant_id: TenantId | None = None, ) -> FastAPI: @@ -441,6 +484,13 @@ def build_app( "create / update / deactivate (per-tenant bearer token)." ), }, + { + "name": "compliance", + "description": ( + "Compliance posture — GDPR right-to-erasure of the calling " + "tenant's data (data retention + residency configure via rag.yaml)." + ), + }, ], ) @@ -635,6 +685,40 @@ def build_app( app.state.feedback_recorder = feedback_recorder app.state.feedback_store = feedback_store + # Compliance posture (Step 6.10) — the RetentionEnforcer (GDPR right-to-erasure + # via POST /v1/compliance/erase + the operator-run retention sweep) over the + # wired feedback / provenance stores; the deployment data region for the ingest + # residency guard; and the live posture for GET /v1/status/compliance (the + # SOC 2 / GDPR control mapping). ``compliance_enabled`` gates residency + # enforcement; build_app_from_config builds the posture from cfg.compliance. + from rag_compliance import RetentionEnforcer + from rag_compliance import compliance_posture as _build_posture + from rag_core.types import RetentionPolicy + + app.state.compliance_enabled = compliance_enabled + app.state.compliance_region = compliance_region + app.state.retention_enforcer = retention_enforcer or RetentionEnforcer( + feedback_store=app.state.feedback_store, + provenance_store=app.state.provenance_store, + ) + app.state.compliance_posture = compliance_posture or _build_posture( + region=compliance_region, + controls={ + "audit": audit_enabled, + "acl": acl_enabled, + "pii": pii_enabled, + "byok": app.state.kms_enabled, + "sso": sso_enabled, + "scim": scim_enabled, + "quotas": quota_enforcer is not None, + "guard": guard_enabled, + "breakers": breaker_registry is not None, + "retention": compliance_enabled, + "residency": compliance_enabled and bool(compliance_region), + }, + retention=retention_policy or RetentionPolicy(), + ) + # Drift monitors (Step 5.5) — when a registry is wired, the query path feeds # the PSI monitors (query length / retrieval score / embedding norm), the # feedback path feeds citation-clickthrough, and ``GET /v1/status/drift`` @@ -796,6 +880,10 @@ async def ingest_document( ), ) + # GDPR data-residency (Step 6.10) — refuse ingest when the tenant's required + # region does not match this deployment's region (no-op unless enabled). + _enforce_residency(request.app.state, tenant_id) + # Storage quota (Step 4.5) — admit-or-reject the upload against the # tenant's storage-bytes gauge before the pipeline runs. A successful # admission advances the gauge by the upload size. @@ -847,6 +935,7 @@ async def ingest_document( app.include_router(make_status_router()) app.include_router(make_audit_router()) app.include_router(make_scim_router()) + app.include_router(make_compliance_router()) return app diff --git a/apps/gateway/src/rag_gateway/compliance.py b/apps/gateway/src/rag_gateway/compliance.py new file mode 100644 index 0000000..a73ca0e --- /dev/null +++ b/apps/gateway/src/rag_gateway/compliance.py @@ -0,0 +1,58 @@ +"""Compliance surface — ``POST /v1/compliance/erase`` (GDPR right-to-erasure, Step 6.10). + +The erasure endpoint is tenant-scoped: it erases the **calling tenant's** data +across the retention enforcer's wired stores. It is always available (a tenant +can always request erasure of its own data — the GDPR posture), but defaults to a +**dry-run preview**: actually deleting requires both ``dry_run=false`` *and* +``confirm=true``, so an erasure is never a one-flag accident. The read-only +posture (``GET /v1/status/compliance``) lives in the status router. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Request +from rag_core.errors import AuthError +from rag_core.gateway_types import ComplianceEraseRequest, GatewayError +from rag_core.types import ErasureResult, RequestContext + +__all__ = ["make_compliance_router"] + + +def _require_ctx(request: Request) -> RequestContext: + gateway = getattr(request.state, "gateway", None) + if gateway is None: + raise AuthError("request did not pass through the gateway middleware") + if gateway.auth_error is not None: + raise gateway.auth_error + ctx: RequestContext | None = gateway.ctx + if ctx is None: + raise AuthError("missing tenant / principal credentials") + return ctx + + +def make_compliance_router() -> APIRouter: + """Build the compliance router (Step 6.10).""" + router = APIRouter(tags=["compliance"]) + + @router.post( + "/v1/compliance/erase", + response_model=ErasureResult, + responses={401: {"model": GatewayError, "description": "Missing or invalid auth"}}, + summary="Erase the calling tenant's data — GDPR right-to-erasure (Step 6.10)", + ) + async def erase(request: Request, body: ComplianceEraseRequest) -> ErasureResult: + """Erase (or, by default, preview) the calling tenant's data across all stores. + + Returns an :class:`ErasureResult` with per-data-class counts. Deletes only + when ``dry_run=false`` **and** ``confirm=true``; otherwise it is a counting + preview (``dry_run=true`` in the result). + """ + ctx = _require_ctx(request) + enforcer: Any = request.app.state.retention_enforcer + do_delete = (not body.dry_run) and body.confirm + result: ErasureResult = await enforcer.erase_tenant(ctx, dry_run=not do_delete) + return result + + return router diff --git a/apps/gateway/src/rag_gateway/query.py b/apps/gateway/src/rag_gateway/query.py index 017827f..93b32eb 100644 --- a/apps/gateway/src/rag_gateway/query.py +++ b/apps/gateway/src/rag_gateway/query.py @@ -36,9 +36,11 @@ ACLDeniedError, AuditNotFoundError, AuthError, + ComplianceError, ProvenanceNotFoundError, RagError, RateLimitError, + ResidencyViolationError, RetrievalError, ScimConflictError, ScimError, @@ -98,6 +100,9 @@ def _http_status_for(exc: BaseException) -> int: :class:`~rag_core.errors.RagError` subclasses get specific statuses. Anything else falls through to 500. """ + if isinstance(exc, ResidencyViolationError): + # GDPR data-residency: tenant region != deployment region (Step 6.10). + return 403 if isinstance(exc, ACLDeniedError): return 403 if isinstance(exc, AuthError): @@ -119,6 +124,9 @@ def _http_status_for(exc: BaseException) -> int: if isinstance(exc, ScimError): # Other SCIM provisioning failure (e.g. unsupported filter) (Step 6.8). return 400 + if isinstance(exc, ComplianceError): + # Compliance-posture operation failure (Step 6.10); residency is 403 above. + return 400 if isinstance(exc, RetrievalError): # Retrieval errors are bad-gateway because they indicate a # downstream backend failure, not bad input. diff --git a/apps/gateway/src/rag_gateway/status.py b/apps/gateway/src/rag_gateway/status.py index 544b8d7..97088cf 100644 --- a/apps/gateway/src/rag_gateway/status.py +++ b/apps/gateway/src/rag_gateway/status.py @@ -64,6 +64,7 @@ from rag_core.types import ( BreakerSnapshot, CircuitState, + CompliancePosture, DriftMetric, DriftReport, DriftSnapshot, @@ -938,6 +939,19 @@ async def status_sso(request: Request, tenant_id: str | None = None) -> SsoStatu ), ) + @router.get("/v1/status/compliance", response_model=CompliancePosture) + async def status_compliance(request: Request) -> CompliancePosture: + """The live compliance posture (Step 6.10). + + Reports which governance controls are enabled (audit / ACL / PII / BYOK / + SSO / SCIM / quotas / retention / residency), the deployment's data region, + and the retention windows — the machine-readable backing for the SOC 2 / + GDPR control-mapping docs. Carries only configuration booleans, no tenant + data, so it is safe on the status surface. + """ + posture = getattr(request.app.state, "compliance_posture", None) + return posture if posture is not None else CompliancePosture() + @router.websocket("/v1/status/ws") async def status_ws(websocket: WebSocket) -> None: """Push health + metrics snapshots until the client disconnects. diff --git a/apps/gateway/src/rag_gateway/wiring.py b/apps/gateway/src/rag_gateway/wiring.py index 18b7684..f3486fe 100644 --- a/apps/gateway/src/rag_gateway/wiring.py +++ b/apps/gateway/src/rag_gateway/wiring.py @@ -849,11 +849,30 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI: scim_enabled = overrides.pop("scim_enabled", cfg.scim.enabled) scim_tokens = overrides.pop("scim_tokens", dict(cfg.scim.tokens)) + # Compliance posture (Step 6.10) — global retention windows + data region. + # ``build_app`` derives the live CompliancePosture from the control flags it + # already receives (audit / acl / pii / kms / sso / scim / quotas), so only the + # compliance-specific knobs are threaded here. Residency enforcement (ingest) + # is gated on ``cfg.compliance.enabled``. + from rag_core.types import RetentionPolicy + + _r = cfg.compliance.retention + retention_policy = RetentionPolicy( + chunks_days=_r.chunks_days, + feedback_days=_r.feedback_days, + provenance_days=_r.provenance_days, + traces_days=_r.traces_days, + audit_days=_r.audit_days, + ) + return build_app( auth=auth_backend, sso_enabled=cfg.sso.enabled, scim_enabled=scim_enabled, scim_tokens=scim_tokens, + compliance_enabled=cfg.compliance.enabled, + compliance_region=cfg.compliance.region, + retention_policy=retention_policy, corpus_store=corpus_store, retrieval_router=retrieval_router, corpus_router=corpus_router, diff --git a/apps/gateway/tests/test_compliance_routes.py b/apps/gateway/tests/test_compliance_routes.py new file mode 100644 index 0000000..9be00f6 --- /dev/null +++ b/apps/gateway/tests/test_compliance_routes.py @@ -0,0 +1,98 @@ +"""Tests for the gateway compliance surface (Step 6.10). + +Covers ``GET /v1/status/compliance`` (the live posture), ``POST /v1/compliance/ +erase`` (GDPR right-to-erasure — dry-run default + confirmed delete), and the +data-residency guard at ingest (refuses a tenant whose region differs). +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient +from rag_config.schema import RagConfig +from rag_gateway.wiring import build_app_from_config + + +def _client() -> TestClient: + cfg = RagConfig.model_validate( + { + "version": "1", + "compliance": { + "enabled": True, + "region": "eu-west-1", + "retention": {"feedback_days": 30, "provenance_days": 90}, + }, + "audit": {"enabled": True}, + "acl": {"enabled": True}, + "tenants": [ + {"id": "acme", "name": "Acme", "data_region": "eu-west-1"}, + {"id": "globex", "name": "Globex", "data_region": "us-east-1"}, + ], + } + ) + return TestClient(build_app_from_config(cfg)) + + +def test_status_compliance_posture() -> None: + body = _client().get("/v1/status/compliance").json() + assert body["region"] == "eu-west-1" + assert "audit" in body["enabled_controls"] + assert "acl" in body["enabled_controls"] + assert "residency" in body["enabled_controls"] + assert body["retention"]["feedback_days"] == 30 + + +def test_erase_defaults_to_dry_run() -> None: + client = _client() + h = {"X-Tenant-Id": "acme", "X-Principal-Id": "alice"} + r = client.post("/v1/compliance/erase", json={}, headers=h) + assert r.status_code == 200 + body = r.json() + assert body["dry_run"] is True + assert body["scope"] == "erasure" + assert body["tenant_id"] == "acme" + + +def test_erase_requires_confirm_to_delete() -> None: + client = _client() + h = {"X-Tenant-Id": "acme", "X-Principal-Id": "alice"} + # dry_run=false but confirm=false → still a preview (safety). + r = client.post("/v1/compliance/erase", json={"dry_run": False, "confirm": False}, headers=h) + assert r.json()["dry_run"] is True + # dry_run=false AND confirm=true → real delete. + r2 = client.post("/v1/compliance/erase", json={"dry_run": False, "confirm": True}, headers=h) + assert r2.json()["dry_run"] is False + + +def test_erase_requires_auth() -> None: + assert _client().post("/v1/compliance/erase", json={}).status_code == 401 + + +def test_residency_blocks_mismatched_region() -> None: + client = _client() + files = {"file": ("a.txt", b"hello", "text/plain")} + # acme (eu) on an eu deployment → residency OK (ingest proceeds). + ok = client.post( + "/v1/ingest/document", + data={"tenant_id": "acme", "corpus_id": "c", "principal_id": "p"}, + files=files, + ) + assert ok.status_code != 403 + # globex (us) on an eu deployment → residency violation. + blocked = client.post( + "/v1/ingest/document", + data={"tenant_id": "globex", "corpus_id": "c", "principal_id": "p"}, + files=files, + ) + assert blocked.status_code == 403 + assert blocked.json()["error"]["code"] == "residency_violation" + + +def test_residency_off_by_default() -> None: + # A gateway without cfg.compliance.enabled never blocks on residency. + plain = TestClient(build_app_from_config(RagConfig.model_validate({"version": "1"}))) + r = plain.post( + "/v1/ingest/document", + data={"tenant_id": "anyone", "corpus_id": "c", "principal_id": "p"}, + files={"file": ("a.txt", b"hello", "text/plain")}, + ) + assert r.status_code != 403 diff --git a/dist/openapi.json b/dist/openapi.json index da8e898..82f9b45 100644 --- a/dist/openapi.json +++ b/dist/openapi.json @@ -1130,6 +1130,57 @@ "title": "ClaimVerdict", "type": "object" }, + "ComplianceEraseRequest": { + "description": "``POST /v1/compliance/erase`` body — GDPR right-to-erasure (Step 6.10).\n\nErases the **calling tenant's** data across the retention-enforcer's wired\nstores (tenant scope comes from the authenticated principal, never the body).\n``dry_run`` defaults to ``True`` (a safe preview that *counts* without\ndeleting); set it ``false`` **and** ``confirm`` ``true`` to actually delete —\nboth are required so an erasure is never a one-flag accident.", + "properties": { + "confirm": { + "default": false, + "title": "Confirm", + "type": "boolean" + }, + "dry_run": { + "default": true, + "title": "Dry Run", + "type": "boolean" + } + }, + "title": "ComplianceEraseRequest", + "type": "object" + }, + "CompliancePosture": { + "description": "The platform's live compliance posture (Step 6.10).\n\nA point-in-time view of which governance controls are enabled, the deployment's\ndata region (GDPR residency), and the retention windows — computed from ``cfg``\nby ``rag_compliance.compliance_posture`` and served by\n``GET /v1/status/compliance`` + ``ragctl compliance report``. It is the\nmachine-readable backing for the SOC 2 / GDPR control-mapping docs: each control\nmaps to a Trust Service Criterion, and this reports whether it is on in *this*\ndeployment. Carries no tenant data — only configuration booleans — so it is\nsafe on the status surface.", + "properties": { + "controls": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Controls", + "type": "object" + }, + "enabled_controls": { + "items": { + "type": "string" + }, + "title": "Enabled Controls", + "type": "array" + }, + "generated_at": { + "format": "date-time", + "title": "Generated At", + "type": "string" + }, + "region": { + "default": "", + "title": "Region", + "type": "string" + }, + "retention": { + "$ref": "#/components/schemas/RetentionPolicy" + } + }, + "title": "CompliancePosture", + "type": "object" + }, "ComponentHealth": { "description": "Liveness of one gateway component.", "properties": { @@ -1868,6 +1919,47 @@ "title": "EmbeddingsUsage", "type": "object" }, + "ErasureResult": { + "description": "Outcome of a retention purge or a GDPR right-to-erasure run (Step 6.10).\n\n``purged`` maps each :class:`DataClass` (by value) to the number of records\nremoved; ``total`` is their sum. ``dry_run`` is ``True`` when the run only\n*counted* what would be purged without deleting (the default for a preview).", + "properties": { + "dry_run": { + "default": false, + "title": "Dry Run", + "type": "boolean" + }, + "generated_at": { + "format": "date-time", + "title": "Generated At", + "type": "string" + }, + "purged": { + "additionalProperties": { + "type": "integer" + }, + "title": "Purged", + "type": "object" + }, + "scope": { + "default": "retention", + "title": "Scope", + "type": "string" + }, + "tenant_id": { + "title": "Tenant Id", + "type": "string" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "tenant_id" + ], + "title": "ErasureResult", + "type": "object" + }, "ExperimentAssignment": { "description": "Which A/B variant served this response (Step 5.7c).\n\nPresent on a :class:`QueryResponse` / :class:`RetrieveResponse` only when\nA/B *routing* is active and assigned the request to an experiment — the\ndeterministic, ``request_id``-hashed slice that can actually change which\nretrieval config a user gets (shadow mode, Step 5.7b, never tags a response).\n``None`` otherwise (routing off, or the request was not in the experiment).\n\nCarries no query or answer text — only the experiment id and the assigned\nvariant label, so the tag is safe to log and surface. ``is_candidate``\nflags whether the *candidate* (alternative) config served the response, so a\nconsumer can split traffic without re-deriving it from the variant name.", "properties": { @@ -3526,6 +3618,68 @@ "title": "RagOptions", "type": "object" }, + "RetentionPolicy": { + "description": "Per-data-class retention windows in days (Step 6.10).\n\n``None`` on a class means **retain indefinitely** (no purge for that class).\nThe runtime counterpart of ``cfg.compliance.retention`` — resolved per tenant\nat the boundary and consumed by the ``RetentionEnforcer``. ``audit_days`` is\nadvisory: the tamper-evident hash chain (Step 6.6) is not purged in place —\naudit retention is the WORM **export** lifecycle — so the enforcer never drops\naudit events, it only reports the configured window.", + "properties": { + "audit_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Audit Days" + }, + "chunks_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Chunks Days" + }, + "feedback_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Feedback Days" + }, + "provenance_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Provenance Days" + }, + "traces_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Traces Days" + } + }, + "title": "RetentionPolicy", + "type": "object" + }, "RetrieveRequest": { "description": "Full ``POST /v1/retrieve`` body — retrieval-only.\n\nSame identifying triple + corpus / top_k / filters as\n:class:`QueryRequest`, but no rerank / pack / generate fields:\nthis endpoint is for callers (other services, custom rerankers)\nthat want only the router's ChunkRef output.", "properties": { @@ -5597,6 +5751,58 @@ ] } }, + "/v1/compliance/erase": { + "post": { + "description": "Erase (or, by default, preview) the calling tenant's data across all stores.\n\nReturns an :class:`ErasureResult` with per-data-class counts. Deletes only\nwhen ``dry_run=false`` **and** ``confirm=true``; otherwise it is a counting\npreview (``dry_run=true`` in the result).", + "operationId": "erase_v1_compliance_erase_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ComplianceEraseRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErasureResult" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayError" + } + } + }, + "description": "Missing or invalid auth" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Erase the calling tenant's data — GDPR right-to-erasure (Step 6.10)", + "tags": [ + "compliance" + ] + } + }, "/v1/connectors/status": { "get": { "operationId": "connectors_status_v1_connectors_status_get", @@ -6215,6 +6421,28 @@ ] } }, + "/v1/status/compliance": { + "get": { + "description": "The live compliance posture (Step 6.10).\n\nReports which governance controls are enabled (audit / ACL / PII / BYOK /\nSSO / SCIM / quotas / retention / residency), the deployment's data region,\nand the retention windows — the machine-readable backing for the SOC 2 /\nGDPR control-mapping docs. Carries only configuration booleans, no tenant\ndata, so it is safe on the status surface.", + "operationId": "status_compliance_v1_status_compliance_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompliancePosture" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Status Compliance", + "tags": [ + "status" + ] + } + }, "/v1/status/cost": { "get": { "description": "One tenant's recent-spend anomaly verdict (Step 5.6c).\n\nTenant resolves from the ``tenant_id`` query param, else the\n``X-Tenant-Id`` header, else ``\"default\"``. Returns\n``insufficient_data`` when cost tracking is disabled or the tenant has\nseen too few requests. Detection only — never rejects a request.", @@ -7138,6 +7366,10 @@ { "description": "SCIM 2.0 directory provisioning — IdP-driven user / group create / update / deactivate (per-tenant bearer token).", "name": "scim" + }, + { + "description": "Compliance posture — GDPR right-to-erasure of the calling tenant's data (data retention + residency configure via rag.yaml).", + "name": "compliance" } ] } diff --git a/dist/openapi.yaml b/dist/openapi.yaml index 981ce8c..794db52 100644 --- a/dist/openapi.yaml +++ b/dist/openapi.yaml @@ -972,6 +972,75 @@ components: - supported title: ClaimVerdict type: object + ComplianceEraseRequest: + description: '``POST /v1/compliance/erase`` body — GDPR right-to-erasure (Step + 6.10). + + + Erases the **calling tenant''s** data across the retention-enforcer''s wired + + stores (tenant scope comes from the authenticated principal, never the body). + + ``dry_run`` defaults to ``True`` (a safe preview that *counts* without + + deleting); set it ``false`` **and** ``confirm`` ``true`` to actually delete + — + + both are required so an erasure is never a one-flag accident.' + properties: + confirm: + default: false + title: Confirm + type: boolean + dry_run: + default: true + title: Dry Run + type: boolean + title: ComplianceEraseRequest + type: object + CompliancePosture: + description: 'The platform''s live compliance posture (Step 6.10). + + + A point-in-time view of which governance controls are enabled, the deployment''s + + data region (GDPR residency), and the retention windows — computed from ``cfg`` + + by ``rag_compliance.compliance_posture`` and served by + + ``GET /v1/status/compliance`` + ``ragctl compliance report``. It is the + + machine-readable backing for the SOC 2 / GDPR control-mapping docs: each control + + maps to a Trust Service Criterion, and this reports whether it is on in *this* + + deployment. Carries no tenant data — only configuration booleans — so it + is + + safe on the status surface.' + properties: + controls: + additionalProperties: + type: boolean + title: Controls + type: object + enabled_controls: + items: + type: string + title: Enabled Controls + type: array + generated_at: + format: date-time + title: Generated At + type: string + region: + default: '' + title: Region + type: string + retention: + $ref: '#/components/schemas/RetentionPolicy' + title: CompliancePosture + type: object ComponentHealth: description: Liveness of one gateway component. properties: @@ -1639,6 +1708,45 @@ components: type: integer title: EmbeddingsUsage type: object + ErasureResult: + description: 'Outcome of a retention purge or a GDPR right-to-erasure run (Step + 6.10). + + + ``purged`` maps each :class:`DataClass` (by value) to the number of records + + removed; ``total`` is their sum. ``dry_run`` is ``True`` when the run only + + *counted* what would be purged without deleting (the default for a preview).' + properties: + dry_run: + default: false + title: Dry Run + type: boolean + generated_at: + format: date-time + title: Generated At + type: string + purged: + additionalProperties: + type: integer + title: Purged + type: object + scope: + default: retention + title: Scope + type: string + tenant_id: + title: Tenant Id + type: string + total: + default: 0 + title: Total + type: integer + required: + - tenant_id + title: ErasureResult + type: object ExperimentAssignment: description: 'Which A/B variant served this response (Step 5.7c). @@ -3125,6 +3233,51 @@ components: type: integer title: RagOptions type: object + RetentionPolicy: + description: 'Per-data-class retention windows in days (Step 6.10). + + + ``None`` on a class means **retain indefinitely** (no purge for that class). + + The runtime counterpart of ``cfg.compliance.retention`` — resolved per tenant + + at the boundary and consumed by the ``RetentionEnforcer``. ``audit_days`` + is + + advisory: the tamper-evident hash chain (Step 6.6) is not purged in place + — + + audit retention is the WORM **export** lifecycle — so the enforcer never drops + + audit events, it only reports the configured window.' + properties: + audit_days: + anyOf: + - type: integer + - type: 'null' + title: Audit Days + chunks_days: + anyOf: + - type: integer + - type: 'null' + title: Chunks Days + feedback_days: + anyOf: + - type: integer + - type: 'null' + title: Feedback Days + provenance_days: + anyOf: + - type: integer + - type: 'null' + title: Provenance Days + traces_days: + anyOf: + - type: integer + - type: 'null' + title: Traces Days + title: RetentionPolicy + type: object RetrieveRequest: description: 'Full ``POST /v1/retrieve`` body — retrieval-only. @@ -4645,6 +4798,46 @@ paths: summary: Create a chat completion with retrieval pre-fetch (OpenAI-compatible) tags: - openai + /v1/compliance/erase: + post: + description: 'Erase (or, by default, preview) the calling tenant''s data across + all stores. + + + Returns an :class:`ErasureResult` with per-data-class counts. Deletes only + + when ``dry_run=false`` **and** ``confirm=true``; otherwise it is a counting + + preview (``dry_run=true`` in the result).' + operationId: erase_v1_compliance_erase_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ComplianceEraseRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ErasureResult' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayError' + description: Missing or invalid auth + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Erase the calling tenant's data — GDPR right-to-erasure (Step 6.10) + tags: + - compliance /v1/connectors/status: get: operationId: connectors_status_v1_connectors_status_get @@ -5086,6 +5279,31 @@ paths: summary: Force Close Breaker tags: - status + /v1/status/compliance: + get: + description: 'The live compliance posture (Step 6.10). + + + Reports which governance controls are enabled (audit / ACL / PII / BYOK / + + SSO / SCIM / quotas / retention / residency), the deployment''s data region, + + and the retention windows — the machine-readable backing for the SOC 2 / + + GDPR control-mapping docs. Carries only configuration booleans, no tenant + + data, so it is safe on the status surface.' + operationId: status_compliance_v1_status_compliance_get + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CompliancePosture' + description: Successful Response + summary: Status Compliance + tags: + - status /v1/status/cost: get: description: 'One tenant''s recent-spend anomaly verdict (Step 5.6c). @@ -5712,3 +5930,6 @@ tags: - description: SCIM 2.0 directory provisioning — IdP-driven user / group create / update / deactivate (per-tenant bearer token). name: scim +- description: Compliance posture — GDPR right-to-erasure of the calling tenant's + data (data retention + residency configure via rag.yaml). + name: compliance diff --git a/dist/rag.schema.json b/dist/rag.schema.json index 3e3b070..1418a57 100644 --- a/dist/rag.schema.json +++ b/dist/rag.schema.json @@ -239,6 +239,27 @@ "title": "CandidateConfig", "type": "object" }, + "ComplianceConfig": { + "additionalProperties": false, + "description": "Compliance posture \u2014 data retention + GDPR residency (Step 6.10).\n\nWhen ``enabled`` the gateway exposes ``GET /v1/status/compliance`` (the live\ncontrol posture backing the SOC 2 / GDPR mapping) and ``POST /v1/compliance/\nerase`` (tenant-scoped right-to-erasure), runs the ``RetentionEnforcer`` over\nthe wired stores, and enforces data-residency: a tenant whose\n``tenants[].data_region`` differs from ``region`` is refused at ingest\n(``ResidencyViolationError`` \u2192 HTTP 403).\n\n**Disabled by default** \u2014 residency enforcement can refuse ingest and erasure\ndeletes data, so it is opt-in. ``region`` is this deployment's declared data\nregion (e.g. ``eu-west-1``); leave it empty to declare no region (residency\nchecks then pass for every tenant).", + "properties": { + "enabled": { + "default": false, + "title": "Enabled", + "type": "boolean" + }, + "region": { + "default": "", + "title": "Region", + "type": "string" + }, + "retention": { + "$ref": "#/$defs/RetentionConfig" + } + }, + "title": "ComplianceConfig", + "type": "object" + }, "ConfigVersion": { "enum": [ "1" @@ -1303,6 +1324,79 @@ "title": "RerankerProvider", "type": "string" }, + "RetentionConfig": { + "additionalProperties": false, + "description": "Per-data-class retention windows in days (Step 6.10).\n\nEach field is the number of days to retain that data class; ``None`` (default)\nmeans **retain indefinitely**. Resolved into a ``rag_core.types.RetentionPolicy``\nand enforced by the ``RetentionEnforcer`` (purges expired feedback / provenance\n/ trace records). ``audit_days`` is advisory only \u2014 the tamper-evident audit\nchain (Step 6.6) is retained via its WORM **export**, never purged in place.\nA per-tenant ``tenants[].retention_days`` caps every class for that tenant.", + "properties": { + "chunks_days": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Chunks Days" + }, + "feedback_days": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Feedback Days" + }, + "provenance_days": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provenance Days" + }, + "traces_days": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Traces Days" + }, + "audit_days": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audit Days" + } + }, + "title": "RetentionConfig", + "type": "object" + }, "RetrievalConfig": { "additionalProperties": false, "properties": { @@ -1634,6 +1728,31 @@ } ], "default": null + }, + "data_region": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data Region" + }, + "retention_days": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Retention Days" } }, "required": [ @@ -1943,6 +2062,9 @@ "scim": { "$ref": "#/$defs/ScimConfig" }, + "compliance": { + "$ref": "#/$defs/ComplianceConfig" + }, "webhooks": { "$ref": "#/$defs/WebhooksConfig" }, diff --git a/dist/rag.schema.yaml b/dist/rag.schema.yaml index 58d2996..390b6da 100644 --- a/dist/rag.schema.yaml +++ b/dist/rag.schema.yaml @@ -282,6 +282,44 @@ $defs: type: number title: CandidateConfig type: object + ComplianceConfig: + additionalProperties: false + description: 'Compliance posture — data retention + GDPR residency (Step 6.10). + + + When ``enabled`` the gateway exposes ``GET /v1/status/compliance`` (the live + + control posture backing the SOC 2 / GDPR mapping) and ``POST /v1/compliance/ + + erase`` (tenant-scoped right-to-erasure), runs the ``RetentionEnforcer`` over + + the wired stores, and enforces data-residency: a tenant whose + + ``tenants[].data_region`` differs from ``region`` is refused at ingest + + (``ResidencyViolationError`` → HTTP 403). + + + **Disabled by default** — residency enforcement can refuse ingest and erasure + + deletes data, so it is opt-in. ``region`` is this deployment''s declared data + + region (e.g. ``eu-west-1``); leave it empty to declare no region (residency + + checks then pass for every tenant).' + properties: + enabled: + default: false + title: Enabled + type: boolean + region: + default: '' + title: Region + type: string + retention: + $ref: '#/$defs/RetentionConfig' + title: ComplianceConfig + type: object ConfigVersion: enum: - '1' @@ -1335,6 +1373,60 @@ $defs: - bge_api title: RerankerProvider type: string + RetentionConfig: + additionalProperties: false + description: 'Per-data-class retention windows in days (Step 6.10). + + + Each field is the number of days to retain that data class; ``None`` (default) + + means **retain indefinitely**. Resolved into a ``rag_core.types.RetentionPolicy`` + + and enforced by the ``RetentionEnforcer`` (purges expired feedback / provenance + + / trace records). ``audit_days`` is advisory only — the tamper-evident audit + + chain (Step 6.6) is retained via its WORM **export**, never purged in place. + + A per-tenant ``tenants[].retention_days`` caps every class for that tenant.' + properties: + chunks_days: + anyOf: + - minimum: 1 + type: integer + - type: 'null' + default: null + title: Chunks Days + feedback_days: + anyOf: + - minimum: 1 + type: integer + - type: 'null' + default: null + title: Feedback Days + provenance_days: + anyOf: + - minimum: 1 + type: integer + - type: 'null' + default: null + title: Provenance Days + traces_days: + anyOf: + - minimum: 1 + type: integer + - type: 'null' + default: null + title: Traces Days + audit_days: + anyOf: + - minimum: 1 + type: integer + - type: 'null' + default: null + title: Audit Days + title: RetentionConfig + type: object RetrievalConfig: additionalProperties: false properties: @@ -1647,6 +1739,19 @@ $defs: - $ref: '#/$defs/TenantSsoConfig' - type: 'null' default: null + data_region: + anyOf: + - type: string + - type: 'null' + default: null + title: Data Region + retention_days: + anyOf: + - minimum: 1 + type: integer + - type: 'null' + default: null + title: Retention Days required: - id - name @@ -1890,6 +1995,8 @@ properties: $ref: '#/$defs/SsoConfig' scim: $ref: '#/$defs/ScimConfig' + compliance: + $ref: '#/$defs/ComplianceConfig' webhooks: $ref: '#/$defs/WebhooksConfig' provenance: diff --git a/dist/schemas/ComplianceEraseRequest.json b/dist/schemas/ComplianceEraseRequest.json new file mode 100644 index 0000000..d562b50 --- /dev/null +++ b/dist/schemas/ComplianceEraseRequest.json @@ -0,0 +1,17 @@ +{ + "description": "``POST /v1/compliance/erase`` body \u2014 GDPR right-to-erasure (Step 6.10).\n\nErases the **calling tenant's** data across the retention-enforcer's wired\nstores (tenant scope comes from the authenticated principal, never the body).\n``dry_run`` defaults to ``True`` (a safe preview that *counts* without\ndeleting); set it ``false`` **and** ``confirm`` ``true`` to actually delete \u2014\nboth are required so an erasure is never a one-flag accident.", + "properties": { + "dry_run": { + "default": true, + "title": "Dry Run", + "type": "boolean" + }, + "confirm": { + "default": false, + "title": "Confirm", + "type": "boolean" + } + }, + "title": "ComplianceEraseRequest", + "type": "object" +} diff --git a/dist/schemas/CompliancePosture.json b/dist/schemas/CompliancePosture.json new file mode 100644 index 0000000..a0cce47 --- /dev/null +++ b/dist/schemas/CompliancePosture.json @@ -0,0 +1,103 @@ +{ + "$defs": { + "RetentionPolicy": { + "description": "Per-data-class retention windows in days (Step 6.10).\n\n``None`` on a class means **retain indefinitely** (no purge for that class).\nThe runtime counterpart of ``cfg.compliance.retention`` \u2014 resolved per tenant\nat the boundary and consumed by the ``RetentionEnforcer``. ``audit_days`` is\nadvisory: the tamper-evident hash chain (Step 6.6) is not purged in place \u2014\naudit retention is the WORM **export** lifecycle \u2014 so the enforcer never drops\naudit events, it only reports the configured window.", + "properties": { + "chunks_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Chunks Days" + }, + "feedback_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Feedback Days" + }, + "provenance_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provenance Days" + }, + "traces_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Traces Days" + }, + "audit_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audit Days" + } + }, + "title": "RetentionPolicy", + "type": "object" + } + }, + "description": "The platform's live compliance posture (Step 6.10).\n\nA point-in-time view of which governance controls are enabled, the deployment's\ndata region (GDPR residency), and the retention windows \u2014 computed from ``cfg``\nby ``rag_compliance.compliance_posture`` and served by\n``GET /v1/status/compliance`` + ``ragctl compliance report``. It is the\nmachine-readable backing for the SOC 2 / GDPR control-mapping docs: each control\nmaps to a Trust Service Criterion, and this reports whether it is on in *this*\ndeployment. Carries no tenant data \u2014 only configuration booleans \u2014 so it is\nsafe on the status surface.", + "properties": { + "region": { + "default": "", + "title": "Region", + "type": "string" + }, + "controls": { + "additionalProperties": { + "type": "boolean" + }, + "title": "Controls", + "type": "object" + }, + "enabled_controls": { + "items": { + "type": "string" + }, + "title": "Enabled Controls", + "type": "array" + }, + "retention": { + "$ref": "#/$defs/RetentionPolicy" + }, + "generated_at": { + "format": "date-time", + "title": "Generated At", + "type": "string" + } + }, + "title": "CompliancePosture", + "type": "object" +} diff --git a/dist/schemas/ErasureResult.json b/dist/schemas/ErasureResult.json new file mode 100644 index 0000000..c8c0aa1 --- /dev/null +++ b/dist/schemas/ErasureResult.json @@ -0,0 +1,41 @@ +{ + "description": "Outcome of a retention purge or a GDPR right-to-erasure run (Step 6.10).\n\n``purged`` maps each :class:`DataClass` (by value) to the number of records\nremoved; ``total`` is their sum. ``dry_run`` is ``True`` when the run only\n*counted* what would be purged without deleting (the default for a preview).", + "properties": { + "tenant_id": { + "title": "Tenant Id", + "type": "string" + }, + "scope": { + "default": "retention", + "title": "Scope", + "type": "string" + }, + "purged": { + "additionalProperties": { + "type": "integer" + }, + "title": "Purged", + "type": "object" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + }, + "dry_run": { + "default": false, + "title": "Dry Run", + "type": "boolean" + }, + "generated_at": { + "format": "date-time", + "title": "Generated At", + "type": "string" + } + }, + "required": [ + "tenant_id" + ], + "title": "ErasureResult", + "type": "object" +} diff --git a/dist/schemas/RetentionPolicy.json b/dist/schemas/RetentionPolicy.json new file mode 100644 index 0000000..d66082a --- /dev/null +++ b/dist/schemas/RetentionPolicy.json @@ -0,0 +1,67 @@ +{ + "description": "Per-data-class retention windows in days (Step 6.10).\n\n``None`` on a class means **retain indefinitely** (no purge for that class).\nThe runtime counterpart of ``cfg.compliance.retention`` \u2014 resolved per tenant\nat the boundary and consumed by the ``RetentionEnforcer``. ``audit_days`` is\nadvisory: the tamper-evident hash chain (Step 6.6) is not purged in place \u2014\naudit retention is the WORM **export** lifecycle \u2014 so the enforcer never drops\naudit events, it only reports the configured window.", + "properties": { + "chunks_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Chunks Days" + }, + "feedback_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Feedback Days" + }, + "provenance_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provenance Days" + }, + "traces_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Traces Days" + }, + "audit_days": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Audit Days" + } + }, + "title": "RetentionPolicy", + "type": "object" +} diff --git a/dist/schemas/TenantSettings.json b/dist/schemas/TenantSettings.json index 55dc760..2050318 100644 --- a/dist/schemas/TenantSettings.json +++ b/dist/schemas/TenantSettings.json @@ -70,6 +70,18 @@ ], "default": null, "title": "Physical Index" + }, + "data_region": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data Region" } }, "required": [ diff --git a/docs/README.md b/docs/README.md index 7848aee..c15157d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ | [byok.md](architecture/byok.md) | BYOK / envelope encryption (Step 6.7): what's encrypted (chunk content at rest) vs not (embedding vectors — search needs plaintext); DEK+KEK envelope (client-side AES-GCM DEK, provider wraps the DEK); per-tenant isolation via KEK + `tenant_id` AAD; sealing as a typed error; rag-core/rag-backends split; slicing (6.7a library, 6.7b config + factory + AWS KMS, 6.7c GCP/Azure/Vault, 6.7d rotation) | | [sso-scim.md](architecture/sso-scim.md) | SSO / SCIM (Step 6.8): OIDC + SAML federation + SCIM 2.0 provisioning + per-tenant IdP config. `FederatedAuth` *is* an `Auth` SPI backend (the `authenticate(token, tenant_id) → Principal` seam — no middleware change); group claims → `acl_labels` so Step 6.3/6.5 govern federated users; dependency-free defaults (stdlib HS256 JWT, `defusedxml` SAML) with asymmetric OIDC / XML-DSig behind `[oidc]` / `[saml]` extras; algorithm-allowlist (`alg:none`/downgrade defense); per-tenant IdP on `tenants[].sso`; SCIM is a separate surface with its own per-tenant bearer token + tenant-scoped `ScimStore`; PII-free `sso.*`/`scim.*` events (hashed subject); deferred (JWKS rotation, SP-initiated SAML, directory-backed deprovisioning) | | [airgap-bundle.md](architecture/airgap-bundle.md) | Air-gapped install bundle (Step 6.9): one signed `.tar.gz` of all runtime images (`docker save`) + the packaged Helm chart + `rag.yaml` + a standalone installer, for networks with no registry / internet. Integrity = a standard `SHA256SUMS` (verifiable with `sha256sum -c`, no cosign/network) whose hash is pinned as `manifest.content_hash`, plus an optional cosign signature over it (the 6.6b content-hash + optional-signature pattern); the same `SHA256SUMS` drives the Python verifier and the standalone shell installer. Logic in `ragctl.airgap` (typed/tested, subprocess seam stubbable; `--dry-run` = verifiable bundle minus blobs); standalone `install.{sh,ps1}` need only docker+helm; digest-pinned manifest-driven image set; key-based cosign for air-gap, keyless for connected releases; deferred (ctr/podman load, registry re-tag/push, backend charts) | +| [compliance.md](architecture/compliance.md) | Compliance posture (Step 6.10, the Phase-6 capstone): data retention + GDPR erasure/residency + the live control posture that backs the SOC 2 / GDPR mapping. New `rag-compliance` package (`RetentionEnforcer` + `compliance_posture` / `residency_ok`, config-free); retention is a non-abstract `purge_before` / `purge_tenant` *capability* on the Feedback/Provenance stores (`dry_run` in the SPI so a preview counts-without-deleting; audit chain never purged in place — its retention is the 6.6b WORM export); `POST /v1/compliance/erase` (tenant-scoped right-to-erasure, dry-run default + two-flag delete); per-tenant `data_region` enforced at ingest (`ResidencyViolationError` → 403); `GET /v1/status/compliance` posture computed from the control flags; PII-free `compliance.*` events; deferred (subject-level erasure, multi-region routing) | | [caching.md](architecture/caching.md) | Three-cache split: `EmbeddingCache`, `RetrievalCache`, `AnswerCache` — distinct invalidation rules | | [performance.md](architecture/performance.md) | Hot-path discipline, per-SPI p99 budgets, async telemetry, reviewer checklist | | [pipeline-batcher.md](architecture/pipeline-batcher.md) | `Pipeline` (async DAG, bounded queues, per-stage workers) + `Batcher` (DataLoader-pattern coalescing) primitives — Step 1.1d | @@ -112,6 +113,7 @@ | [encryption.md](reference/encryption.md) | BYOK envelope encryption (Step 6.7) — `KeyManager` SPI + `NoopKeyManager`; `EnvelopeKeyManager` (AES-256-GCM DEK + `tenant_id` AAD) + `LocalKeyManager` + cloud providers **`AwsKmsKeyManager`** / **`GcpKmsKeyManager`** / **`AzureKeyVaultKeyManager`** / **`VaultKeyManager`** (behind `[kms-*]` extras); `EncryptingStorage` decorator; `EncryptionError` / `KeyUnavailableError` (sealing); provider table; `cfg.kms` + `tenants[].kms_key_id` + `build_key_manager_from_config` factory; **`RotatingKeyManager`** + `RetiredKey` + `rewrap` (6.7d zero-downtime rotation); `ragctl kms [--rotate]` | | [sso.md](reference/sso.md) | SSO / SCIM (Step 6.8, `rag_sso`) — `FederatedAuth` (`Auth` backend) + `OidcProvider`/`OidcSettings` + `SamlProvider`/`SamlSettings`/`signxml_verifier` + `identity_to_principal`; `ScimService`/`parse_eq_filter`; low-level `verify_jwt`/`encode_jwt_hs256` (stdlib HS256, `[oidc]` RS256); `ScimStore` SPI + `NoopScimStore`; `FederatedIdentity`/`SsoProtocol`/`ScimUser`/`ScimGroup` core types + `ScimListResponse`/`ScimPatchOp`/`ScimErrorBody`/`SsoStatusResponse` wire types; `SsoError`/`ScimError`/`ScimNotFoundError`/`ScimConflictError`; `/scim/v2/*` + `GET /v1/status/sso`; `cfg.sso`/`cfg.scim`/`tenants[].sso`; `sso.*`/`scim.*` events; `ragctl sso`/`ragctl scim`; extension points | | [airgap.md](reference/airgap.md) | Air-gapped install bundle (Step 6.9) — `ragctl airgap build`/`inspect`/`verify`/`install`; `ragctl.airgap` models (`BundleManifest`/`BundleImage`/`BundleFile`/`BundleVerification`) + pure helpers (`parse_image_list`/`render_sha256sums`/`content_hash`/`build_manifest`/`verify_bundle`/`gateway_image_ref`) + the docker/helm/cosign seam (`assemble_bundle`/`save_images`/`package_chart`/`load_images`/`install_chart`/`cosign_*`); bundle layout + `SHA256SUMS`/`content_hash` integrity (+ `reason` codes); `infra/airgap/images.txt`; standalone `install.{sh,ps1}` (`--verify-only`); `--dry-run`; `task airgap:build`/`build-dry`/`verify`; `release-airgap.yml` | +| [compliance.md](reference/compliance.md) | Compliance (Step 6.10, `rag_compliance`) — `RetentionEnforcer` (`purge` / `erase_tenant`, `dry_run`) + `compliance_posture` + `residency_ok`; `DataClass`/`RetentionPolicy`/`ErasureResult`/`CompliancePosture` core types; `ComplianceError`/`ResidencyViolationError`; `purge_before`/`purge_tenant` SPI methods; `GET /v1/status/compliance` + `POST /v1/compliance/erase`; `cfg.compliance` + `tenants[].data_region`/`retention_days`; `compliance.*` events; `ragctl compliance report`/`demo`; extension points | | [webhooks.md](reference/webhooks.md) | Outbound webhooks (Step 3.9) — event catalogue (`ingest.completed` / `audit.policy_violation` / `drift.detected` / `eval.regression`), event envelope, HMAC signing + `verify()`, at-least-once delivery, `/v1/webhooks/subscriptions` CRUD + test, `rag.yaml` block, `ragctl webhooks demo`, internals + extension points | | [integrations.md](reference/integrations.md) | Framework adapters (Step 3.8) — `agentcontextos.integrations.*` for LangChain / LlamaIndex / Haystack / DSPy / LangGraph / CrewAI / AutoGen / Semantic Kernel; per-framework extras, shared config + chunk metadata, usage per framework, internals + extension points | | [status-api.md](reference/status-api.md) | Status & Metrics API (Step 3.11) — `/v1/status/health` / `metrics` / `logs` (+ SSE `logs/stream`), `WS /v1/status/ws`, `/v1/connectors/status`; metric catalogue + request-timing middleware, the `MetricsCollector` / `LogTail` read-side, CORS + query-param identity for browser streams, extension points | @@ -184,6 +186,7 @@ broken, and what to fix before committing to the next phase. | [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-0035-acl-pushdown.md](adr/ADR-0035-acl-pushdown.md) | Decision (Step 6.3): label-based ACL push-down at retrieval. `AclPolicyEngine` (a decorator like `QuotaPolicyEngine`) And-merges `any_in("acl_labels", principal.acl_labels)` into every `read_chunk` push-down at the canonical `HybridRetriever` PDP site — overlap semantics via the existing `AnyIn` predicate (zero backend/translator changes), **fail-closed** (label-less principal matches nothing; "public" = a shared label), **opt-in** via `cfg.acl.enabled`; emits `acl.egress_denied` on a request-level denial; graph edge ACLs + post-retrieval re-verification (6.4) deferred | | [ADR-0036-acl-egress-verifier.md](adr/ADR-0036-acl-egress-verifier.md) | Decision (Step 6.4): a post-retrieval ACL re-check as a **defense-in-depth second layer** behind the 6.3 push-down. `AclEgressVerifier.verify(ctx, refs)` drops any returned `ChunkRef` whose labels don't overlap the principal's — same overlap semantics (no-op on correct results), reading `ChunkRef.acl_labels` (no re-hydration), **independent of the PDP** (consults only `ctx.principal.acl_labels`) so a push-down bug/bypass can't disable both; wired at the gateway as a `SupportsRoute` wrapper around `app.state.retrieval_router` (covers query/retrieve/corpus/OpenAI/agent); `cfg.acl.verify_egress` default on but gated by `enabled`; emits `acl.egress_violation` on a caught leak; a red-team gate proves a zero escaped-violation rate when the push-down is bypassed; backend-mislabel re-hydration + per-tenant violation metrics deferred | +| [ADR-0042-compliance-posture.md](adr/ADR-0042-compliance-posture.md) | Decision (Step 6.10, Phase-6 capstone): add the three compliance pieces on top of the controls the platform already ships (audit/ACL/PII/BYOK/SSO/quotas). New `rag-compliance` package (config-free, like rag-feedback/rag-drift): `RetentionEnforcer` drives tenant-scoped `purge_*`; `compliance_posture`/`residency_ok` are pure. **Retention is a capability on the existing stores, not a new SPI** — non-abstract `purge_before`/`purge_tenant` (default no-op) on Feedback/Provenance, with `dry_run` in the SPI so a preview counts-without-deleting uniformly (ProvenanceStore has no `list`). **Audit is never purged in place** (the hash chain would break) — audit retention is the 6.6b WORM export; `audit_days` is advisory. **Right-to-erasure is always-on, tenant-self-service, two-flag** — `POST /v1/compliance/erase` erases the *calling* tenant's data (scope from the principal, never the body), dry-run by default, delete needs `dry_run=false` AND `confirm=true`. **Residency = declared per tenant + enforced at ingest** (`tenants[].data_region` vs `cfg.compliance.region` → 403), opt-in, a single-deployment assertion not multi-region routing. **The SOC 2 / GDPR mapping is a doc backed by a live posture** (`GET /v1/status/compliance` reports which controls are on, so the mapping is checkable). Deferred: subject-level (vs tenant-level) erasure, an admin retention-sweep endpoint, multi-region routing, automated audit-evidence bundles; rejected purging the audit chain, a `Purgeable` SPI mixin, a static doc with no live backing | | [ADR-0041-airgap-bundle.md](adr/ADR-0041-airgap-bundle.md) | Decision (Step 6.9): ship the platform as one signed, self-contained offline bundle (all runtime images + Helm chart + config + installer). Integrity reuses the WORM-export pattern (6.6b): a standard `SHA256SUMS` whose hash is pinned as `manifest.content_hash` is the **hard gate** — verifiable with nothing but `sha256sum`, no network/cosign — and a cosign signature **over `SHA256SUMS`** adds authenticity; the *same* `SHA256SUMS` drives the Python verifier and the standalone shell installer so they can't diverge. The shell/pwsh `install.{sh,ps1}` (shipped inside the bundle) need only docker+helm (air-gap hosts lack uv/the workspace); `ragctl airgap` holds the typed/tested build+verify logic (pure core separated from a stubbable docker/helm/cosign subprocess seam; `--dry-run` = a verifiable bundle minus image blobs, so the path is testable with no Docker). Digest-pinned manifest-driven image set (`infra/airgap/images.txt` + the chart-derived gateway image); key-based cosign is the air-gap recommendation (keyless needs Rekor + an identity policy), keyless is the connected-release path (`release-airgap.yml` on tags). Deferred: ctr/podman load, registry re-tag/push, multi-arch selection, bundling backend charts, TUF-rooted offline keyless verify; rejected `oras`/OCI (no registry to pull from in an air-gap), a second HMAC scheme (cosign already the signer), a pure-shell build (would escape mypy/tests) | | [ADR-0040-sso-scim.md](adr/ADR-0040-sso-scim.md) | Decision (Step 6.8): enterprise identity in two surfaces. **Federation** — `FederatedAuth` *is* an `Auth` SPI backend (the `authenticate(token, tenant_id) → Principal` seam already runs at the boundary, so wiring it is the whole integration — no middleware change); group claims → `acl_labels` so Step 6.3 push-down + 6.5 PII egress govern federated users unchanged (`authorize` stays a coarse allow — federation establishes *who*, the PDP decides *what*). Dependency-free defaults (stdlib HS256 JWT with full `exp`/`nbf`/`iss`/`aud` + constant-time compare; `defusedxml` SAML validating Issuer/Conditions/Audience) with asymmetric OIDC (PyJWT, `[oidc]`) + SAML XML-DSig (signxml, `[saml]`, injected verifier → fail-closed) behind extras; **algorithm-allowlist** designs out `alg:none`/RS↔HS confusion. Per-tenant IdP on `tenants[].sso` (reuses Step 6.1 config; no provider → bearer rejected, header-identity still works). **Provisioning** — SCIM 2.0 is a separate surface with its own per-tenant bearer token (`cfg.scim.tokens`, not a user JWT), a tenant-scoped `ScimStore` SPI (`NoopScimStore`) + `ScimService`, SCIM-shaped errors, disabled→404; no new governed SPI call (linter passes). PII-free `sso.*`/`scim.*` events (hashed subject, never email/userName). Deferred: JWKS rotation, SP-initiated SAML + metadata, SCIM bulk/`/Me`/ETag, directory-backed deprovisioning, admin-console card; rejected Authlib/python3-saml (heavy lxml/xmlsec on the default install), a dedicated SSO middleware, SCIM token on `TenantConfig` | | [ADR-0037-pii-egress-policies.md](adr/ADR-0037-pii-egress-policies.md) | Decision (Step 6.5): per-tenant PII enforcement at egress via a `PiiPolicyEngine` `egress_text` decorator (mirrors `QuotaPolicyEngine` / `AclPolicyEngine`), living in `rag-pii` (gains a `rag-policy` dep, like `rag-quota`). Handles both subject shapes the gateway already passes — `list[Chunk]` context + `str` answer — so it plugs into the existing `egress_text` call sites with no route change; maps `ctx.pii_policy.action` allow→delegate / redact·mask→`transform` / block→`deny`, reusing the Step 1.7 detector + rewriters and the same `min_score`+`entities` filter (no-op on clean text); opt-in `cfg.pii.enabled` (injects `RegexPIIDetector`); PII-free `pii.egress_blocked` (block) / `pii.detected` (redact·mask); post-gen answer re-check for query/OpenAI/gRPC + citation egress deferred (stored chunks are ingest-sanitised) | @@ -193,6 +196,13 @@ broken, and what to fix before committing to the next phase. | [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 | | [ADR-0028-ci-eval-gate.md](adr/ADR-0028-ci-eval-gate.md) | Decision (Step 5.3): gate PRs on the golden-set metrics vs a committed baseline — a compact byte-stable `EvalBaseline` (headline means + breakdowns, no `run_id` / `created_at` / `samples`) instead of the churny `report.json`; two checks (absolute floor + regression delta) both from `thresholds.yaml` `hard_gates`, the harness `--check` and the gate sharing one `load_gate_thresholds`; only recall/mrr/faithfulness gate (acl/pii are red-team's); standalone always-run `eval-gate.yml` for least-privilege `pull-requests: write` + a clean required check; one sticky marker comment posted even on failure, fork-PR tolerant; comparison in `rag_config.eval`, result types in `rag_core.eval` (additive, not in `dist/schemas`), runner in `eval/`; rejected folding into `ci.yml`/`ci-pass`, path-filtering the gate, `report.json` as baseline, floor-only, `pull_request_target` | +## compliance/ + +| File | Description | +|------|-------------| +| [soc2-control-mapping.md](compliance/soc2-control-mapping.md) | SOC 2 Type II — maps each AICPA Trust Service Criterion (CC / Availability / Processing Integrity / Confidentiality / Privacy) to the platform control that implements it (SSO, ACL, BYOK, audit, quotas, breakers, guard, retention, residency, …), the config that enables it, and the `GET /v1/status/compliance` posture key — so the mapping is checkable against the running config | +| [gdpr-mapping.md](compliance/gdpr-mapping.md) | GDPR — article-by-article mapping (Art. 5 storage limitation → retention; Art. 17 erasure → `/v1/compliance/erase`; Art. 25 by-design → PII redaction at ingest; Art. 30 records → audit log; Art. 32 security → BYOK/ACL; Art. 44–50 transfers → data residency) + the erasure / residency flows + deferred caveats | + ## research/ | File | Description | diff --git a/docs/adr/ADR-0042-compliance-posture.md b/docs/adr/ADR-0042-compliance-posture.md new file mode 100644 index 0000000..7221408 --- /dev/null +++ b/docs/adr/ADR-0042-compliance-posture.md @@ -0,0 +1,81 @@ +# ADR-0042 — Compliance posture: retention, GDPR erasure/residency, SOC 2 mapping + +**Status:** Accepted +**Date:** 2026-06-08 +**Step:** 6.10 — Compliance posture (Phase 6 — Governance & Tenancy) +**Related:** [ADR-0033](ADR-0033-logical-multi-tenancy.md) (per-tenant config), [ADR-0038](ADR-0038-immutable-audit-log.md) (audit WORM lifecycle), [architecture/compliance.md](../architecture/compliance.md), [reference/compliance.md](../reference/compliance.md), [compliance/soc2-control-mapping.md](../compliance/soc2-control-mapping.md) + +## Context + +The V1 plan (Step 6.10, the final Phase-6 step) calls for a **compliance posture**: +SOC 2 Type II control mapping, GDPR **data-residency** config, and +**data-retention** policies. The platform already ships the *controls* an auditor +maps to (audit log, ACL, PII, BYOK, SSO, quotas, …); 6.10 adds the three missing +pieces — retention/erasure mechanics, residency enforcement, and a +machine-readable posture that backs the mapping doc. + +## Decision + +**1. A new `rag-compliance` package — retention enforcer + posture, config-free.** +`RetentionEnforcer` drives tenant-scoped `purge_*` on the wired stores; `purge` +applies per-class retention windows, `erase_tenant` is the GDPR right-to-erasure +primitive. `compliance_posture` + `residency_ok` are pure. The package depends only +on `rag-core` + `rag-observability` (it takes the resolved `RetentionPolicy` + a +`RequestContext`, never `rag-config`), mirroring `rag-feedback` / `rag-drift`. + +**2. Retention is a *capability on the existing stores*, not a new SPI.** Added +non-abstract `purge_before(ctx, cutoff, *, dry_run)` + `purge_tenant(ctx, *, +dry_run)` to `FeedbackStore` + `ProvenanceStore` (default no-op, so no backend +breaks). `dry_run` lives in the SPI so a preview *counts without deleting* +uniformly — `ProvenanceStore` has no `list`, so a store-level dry-run is the only +way to preview it. The audit chain is deliberately **not** purged in place (the +hash chain would break) — audit retention is the Step 6.6b WORM **export** +lifecycle; `RetentionPolicy.audit_days` is advisory. + +**3. Right-to-erasure is always-on + tenant-self-service; deletion is two-flag.** +`POST /v1/compliance/erase` erases the *calling tenant's* data (scope from the +authenticated principal, never the body) — a tenant can always request erasure of +its own data (the GDPR posture). It defaults to a **dry-run preview**; actually +deleting requires `dry_run=false` **and** `confirm=true`, so an erasure is never a +one-flag accident. + +**4. Residency is declared per tenant + enforced at the ingest boundary.** +`tenants[].data_region` (resolved onto `TenantSettings`, reusing the Step 6.1 +mechanism) vs `cfg.compliance.region`. A mismatch refuses ingest +(`ResidencyViolationError` → 403, `compliance.residency_violation` event). It is +**opt-in** (gated on `cfg.compliance.enabled`) and lenient when no region is +declared — a single-deployment residency assertion, not multi-region routing. + +**5. The SOC 2 / GDPR mapping is a doc *backed by a live posture*.** +`CompliancePosture` (controls + region + retention, computed from `cfg`) is served +by `GET /v1/status/compliance` and `ragctl compliance report`. The mapping docs +(`docs/compliance/*.md`) map each Trust Service Criterion / GDPR article to the +platform control, and the posture reports whether that control is **on in this +deployment** — so the mapping is verifiable, not just prose. + +## Consequences + +- New `rag-compliance` package; `DataClass` / `RetentionPolicy` / `ErasureResult` + / `CompliancePosture` core types + `ComplianceError` / `ResidencyViolationError`; + `purge_*` on Feedback/Provenance stores; `cfg.compliance` + `tenants[].data_region` + / `retention_days`; `GET /v1/status/compliance` + `POST /v1/compliance/erase`; + ingest residency guard; PII-free `compliance.*` events; `ragctl compliance + report` / `demo`; the SOC 2 + GDPR mapping docs. +- ~30 tests; all gates green. **Phase 6 closes** (10/10). +- **Deferred:** chunk/document purge by subject (needs an index-side + delete-by-document path), an admin retention-*sweep* endpoint (today the sweep + is `ragctl`/cron over the live stores; erasure is the API), multi-region data + routing, a per-control evidence-collection export, automated SOC 2 audit-evidence + bundles. + +## Alternatives considered + +- **A `Purgeable` SPI mixin / a new `RetentionStore` SPI.** Heavier than adding two + default methods to the two stores that actually hold expirable data; revisit if + many backends need bespoke retention. +- **Purge the audit chain for retention.** Rejected — it breaks tamper-evidence; + the WORM export (6.6b) is the audit retention story. +- **Residency as hard multi-region routing.** Out of scope for a single deployment; + the declared-region + ingest guard is the honest v1 (documented as such). +- **A static SOC 2 doc with no live backing.** Rejected — a posture endpoint makes + the mapping checkable against the running config, which is what an auditor wants. diff --git a/docs/architecture/compliance.md b/docs/architecture/compliance.md new file mode 100644 index 0000000..bbefe5f --- /dev/null +++ b/docs/architecture/compliance.md @@ -0,0 +1,112 @@ +# Compliance posture (Step 6.10) + +## Overview + +The final Phase-6 step adds the three things an enterprise / regulated buyer asks +for on top of the controls the platform already ships: + +- **Data retention** — per-class windows + a purge mechanism. +- **GDPR right-to-erasure** — erase a tenant's data on request. +- **Data residency** — pin a tenant to a region, enforced at ingest. +- **A live compliance posture** — the machine-readable backing for the **SOC 2 / + GDPR control-mapping** docs. + +It is the capstone of the governance phase: SSO/SCIM (6.8), BYOK (6.7), audit +(6.6), PII (6.5), ACL (6.3–6.4), and tenancy (6.1–6.2) are the *controls*; 6.10 +adds retention/erasure/residency and a posture that reports them all. + +## Usage + +```yaml +compliance: + enabled: true + region: eu-west-1 + retention: { feedback_days: 30, provenance_days: 90 } +tenants: + - { id: acme, name: Acme, data_region: eu-west-1 } +``` + +```bash +ragctl compliance report -f rag.yaml # the posture +curl -s $GATEWAY/v1/status/compliance | jq # same, live +curl -X POST $GATEWAY/v1/compliance/erase -H "X-Tenant-Id: acme" \ + -d '{"dry_run": false, "confirm": true}' # GDPR erasure +``` + +## Internals + +### `rag-compliance` package + +Mirrors `rag-feedback` / `rag-drift`: depends only on `rag-core` + +`rag-observability` (no `rag-config`). Two pieces: + +- **`RetentionEnforcer`** — drives the tenant-scoped `purge_*` SPI methods. `purge` + applies a `RetentionPolicy` (per-class windows → cutoffs → `purge_before`); + `erase_tenant` calls `purge_tenant` (the GDPR primitive). Both take a + `RequestContext` (tenant scope) per call and support `dry_run`; both emit PII-free + `compliance.*` events. +- **`compliance_posture` / `residency_ok`** — pure functions: assemble the + `CompliancePosture` from control booleans, and the data-residency check. + +### Retention as a store capability + +`purge_before(ctx, cutoff, *, dry_run)` + `purge_tenant(ctx, *, dry_run)` are +**non-abstract** methods on `FeedbackStore` + `ProvenanceStore` (default no-op, so +no backend breaks). `dry_run` is in the SPI so a preview *counts without deleting* +uniformly — `ProvenanceStore` has no `list`, so a store-level dry-run is the only +way to preview it. The in-memory stores delete from their `OrderedDict` by +`(tenant, id)` key + record timestamp; cross-tenant purge is impossible (the tenant +is part of the key). + +**Audit is not purged in place.** The Step 6.6 hash chain would break on deletion — +audit retention is the WORM **export** lifecycle (6.6b); `RetentionPolicy.audit_days` +is advisory and reported, never enforced by the purge. + +### Right-to-erasure (the endpoint) + +`POST /v1/compliance/erase` erases the **calling tenant's** data — the tenant scope +comes from the authenticated principal, never the request body. It is always +available (a tenant can always erase its own data) and defaults to a **dry-run +preview**; deleting requires `dry_run=false` **and** `confirm=true`, so an erasure +is never a one-flag accident. + +### Data residency (the guard) + +`tenants[].data_region` resolves onto `TenantSettings` (reusing the Step 6.1 +resolver). At `POST /v1/ingest/document`, `_enforce_residency` compares it against +`cfg.compliance.region` via `residency_ok`; a mismatch raises +`ResidencyViolationError` (→ 403) and emits `compliance.residency_violation`. It is +opt-in (gated on `cfg.compliance.enabled`) and lenient when no region is declared — +a single-deployment assertion + guard, **not** multi-region data routing. + +### The posture (the mapping backing) + +`build_app` derives a `CompliancePosture` from the control flags it already +receives (audit / acl / pii / byok / sso / scim / quotas / guard / breakers / +retention / residency) + the region + retention windows, and serves it at +`GET /v1/status/compliance`. The SOC 2 / GDPR mapping docs map each criterion to a +control; the posture reports whether that control is **on in this deployment** — so +the mapping is checkable against the running config. + +``` +cfg.compliance ──build_app_from_config──▶ build_app + ├─ RetentionEnforcer(feedback_store, provenance_store) → POST /v1/compliance/erase + ragctl purge + ├─ compliance_region + compliance_enabled → ingest residency guard (403) + └─ CompliancePosture(controls, region, retention) → GET /v1/status/compliance +``` + +## Extension points + +- **Durable retention backend** — implement `purge_*` in a real store; conformance: + `tests/contract/test_retention_purge.py`. +- **Subject-level erasure** — add an index-side delete-by-document path to erase a + specific subject's chunks (today erasure is tenant-level; deferred). +- **Custom controls in the posture** — pass extra control booleans to + `compliance_posture`. + +## Boundaries / deferred + +Subject-level (vs tenant-level) erasure, an admin retention-*sweep* endpoint (today +the sweep is `ragctl`/cron; erasure is the API), multi-region data routing, and +automated SOC 2 audit-evidence bundles are deferred. See +[ADR-0042](../adr/ADR-0042-compliance-posture.md). diff --git a/docs/compliance/gdpr-mapping.md b/docs/compliance/gdpr-mapping.md new file mode 100644 index 0000000..1fefad0 --- /dev/null +++ b/docs/compliance/gdpr-mapping.md @@ -0,0 +1,54 @@ +# GDPR — article mapping (Step 6.10) + +How AgentContextOS platform features support the relevant GDPR articles. Companion +to the [SOC 2 control mapping](soc2-control-mapping.md) and the live posture at +`GET /v1/status/compliance`. + +> **Scope.** The platform is a **processor**-side toolkit. Lawful basis, consent, +> DPAs, and controller obligations are organisational and out of scope here; this +> maps the **technical measures** GDPR Art. 24/25/32 expect. + +| Article | Requirement | Platform feature(s) | Enable via | +|---------|-------------|---------------------|-----------| +| **Art. 5(1)(e)** — Storage limitation | Keep personal data no longer than necessary | Per-class data-retention windows + scheduled purge | `cfg.compliance.retention` (Step 6.10) | +| **Art. 5(1)(f)** / **Art. 32** — Integrity & confidentiality (security) | Encryption, access control, resilience | BYOK encryption at rest; ACL push-down + egress verifier; tenant isolation; circuit breakers | `cfg.kms` / `cfg.acl` / `cfg.breakers` (Steps 6.7 / 6.3–6.4 / 6.1–6.2 / 4.4) | +| **Art. 15** — Right of access | Provide a copy / account of processing | Per-query signed **provenance** + the tamper-evident **audit log** | `cfg.provenance` / `cfg.audit` (Steps 5.1 / 6.6) | +| **Art. 17** — Right to erasure ("right to be forgotten") | Erase a subject's data on request | `POST /v1/compliance/erase` (tenant-scoped) → `RetentionEnforcer.erase_tenant` purges across the wired stores | Step 6.10 | +| **Art. 25** — Data protection by design & by default | Minimise + protect by default | PII detection + **redaction at ingest** (before vectors encode it); privacy-by-default hashing in provenance/feedback; controls off-by-default | Steps 1.7 / 5.1 / 5.4 | +| **Art. 30** — Records of processing activities | Maintain processing records | Immutable hash-chain **audit log** + WORM signed export | `cfg.audit` (Step 6.6) | +| **Art. 33/34** — Breach detection & notification (support) | Detect anomalies feeding breach response | Structured logging; drift + cost anomaly monitors; PII-free security events | Steps 0.7 / 5.5 / 5.6c | +| **Art. 44–50** — International transfers | Keep data within an approved region | Per-tenant **data-residency** pinning enforced at ingest (`ResidencyViolationError`) | `cfg.compliance.region` + `tenants[].data_region` (Step 6.10) | + +## Right-to-erasure flow + +```bash +# A tenant erases its own data (preview by default; confirm to delete): +curl -X POST $GATEWAY/v1/compliance/erase \ + -H "X-Tenant-Id: acme" -H "Authorization: Bearer $TOKEN" \ + -d '{"dry_run": false, "confirm": true}' +# → {"tenant_id":"acme","scope":"erasure","purged":{"feedback":N,"provenance":M},"total":...} +``` + +## Data-residency flow + +```yaml +compliance: + enabled: true + region: eu-west-1 # this deployment serves eu-west-1 +tenants: + - id: acme + data_region: eu-west-1 # OK — ingest proceeds + - id: globex + data_region: us-east-1 # ingest refused (403 residency_violation) +``` + +## Caveats (deferred) + +- **Chunk/document-level erasure by subject** is not yet automatic — `erase_tenant` + purges the feedback / provenance stores; deleting a specific subject's *documents* + needs an index-side delete-by-document path (a follow-up). Tenant-level erasure + + short retention windows cover the common case. +- **Data residency** is a single-deployment *assertion + ingest guard*, not + multi-region data routing. + +See [ADR-0042](../adr/ADR-0042-compliance-posture.md) for the rationale. diff --git a/docs/compliance/soc2-control-mapping.md b/docs/compliance/soc2-control-mapping.md new file mode 100644 index 0000000..9fe691a --- /dev/null +++ b/docs/compliance/soc2-control-mapping.md @@ -0,0 +1,81 @@ +# SOC 2 Type II — control mapping (Step 6.10) + +This document maps the AICPA **Trust Service Criteria** (TSC) to the AgentContextOS +platform features that implement them. It is the human-readable companion to the +**live posture** at `GET /v1/status/compliance` (and `ragctl compliance report`), +which reports whether each control is *enabled in your deployment* — so an auditor +can check the mapping against the running configuration, not just the prose. + +> **Scope note.** AgentContextOS is the *software platform*; a SOC 2 Type II report +> covers your **organisation's operation** of it (policies, personnel, physical +> security, vendor management, etc.). This map covers the **technical controls the +> platform provides**; organisational controls are out of scope here. + +## How to read this + +Each row: a TSC point → the platform control(s) → the config that enables it → the +posture key reported by `GET /v1/status/compliance`. "Posture key on" means the +control is active in this deployment. + +## Common Criteria (CC) — Security + +| TSC | Control area | Platform feature(s) | Enable via | Posture key | +|-----|--------------|---------------------|-----------|-------------| +| CC6.1 | Logical access — authentication | SSO (OIDC / SAML) federation; the `Auth` SPI | `cfg.sso` + `tenants[].sso` (Step 6.8) | `sso` | +| CC6.1 | Logical access — authorization | PolicyEngine PDP; label-based ACL push-down + egress verifier | `cfg.acl` (Steps 1.1c / 6.3 / 6.4) | `acl` | +| CC6.2 | User provisioning / de-provisioning | SCIM 2.0 directory (`/scim/v2`); deactivate = revoke | `cfg.scim` (Step 6.8) | `scim` | +| CC6.3 | Least privilege / segregation | Per-tenant ACL labels; logical + physical tenancy | `tenants[].acl_labels` / `dedicated_index` (Steps 6.1–6.4) | `acl` | +| CC6.6 | Encryption at rest (BYOK) | Envelope encryption (AES-256-GCM DEK + customer KMS KEK); per-tenant sealing | `cfg.kms` (Step 6.7) | `byok` | +| CC6.7 | Restrict data movement / egress | PII egress policies; data-residency guard | `cfg.pii` / `cfg.compliance.region` (Steps 6.5 / 6.10) | `pii`, `residency` | +| CC7.1 | Detect config drift | Schema / OpenAPI / proto drift CI gates | always-on CI | — | +| CC7.2 | Monitoring / anomaly detection | Structured logging; metrics; drift monitors; cost anomaly | `cfg.drift` / `cfg.cost` (Steps 0.7 / 5.5 / 5.6c) | — | +| CC7.3 | Security event evaluation (audit) | Immutable hash-chain audit log + WORM signed export | `cfg.audit` (Step 6.6) | `audit` | +| CC8.1 | Change management | CI matrix; SBOM + cosign image signing; signed release bundle | CI / Steps 0.5 / 6.9 | — | + +## Availability (A1) + +| TSC | Control area | Platform feature(s) | Enable via | Posture key | +|-----|--------------|---------------------|-----------|-------------| +| A1.1 | Capacity / rate management | Per-tenant quotas & rate limiting | `cfg.quotas` (Step 4.5) | `quotas` | +| A1.2 | Fault tolerance | Per-backend circuit breakers; retrieval fallback ladder | `cfg.breakers` / `cfg.retrieval.fallback` (Steps 4.4 / 4.2) | `breakers` | +| A1.2 | Horizontal scaling / disruption budget | Helm HPA + PodDisruptionBudget | Helm chart (Step 0.9) | — | + +## Processing Integrity (PI1) + +| TSC | Control area | Platform feature(s) | Enable via | Posture key | +|-----|--------------|---------------------|-----------|-------------| +| PI1.1 | Output correctness | Hallucination guard (per-claim NLI faithfulness) | `cfg.guard` (Step 4.3) | `guard` | +| PI1.2 | Traceable processing | Per-query signed provenance + trace capture | `cfg.provenance` (Step 5.1) | — | +| PI1.4 | Quality regression control | CI eval gate (golden-set recall / MRR / faithfulness vs baseline) | CI (Step 5.3) | — | + +## Confidentiality (C1) + +| TSC | Control area | Platform feature(s) | Enable via | Posture key | +|-----|--------------|---------------------|-----------|-------------| +| C1.1 | Confidential data identification + protection | Tenant isolation; ACL labels; BYOK; PII detection | Steps 6.1–6.7 | `acl`, `byok` | +| C1.2 | Disposal of confidential data | Data-retention purge + right-to-erasure | `cfg.compliance.retention` (Step 6.10) | `retention` | + +## Privacy (P) — GDPR-aligned + +| TSC | Control area | Platform feature(s) | Enable via | Posture key | +|-----|--------------|---------------------|-----------|-------------| +| P3 | Collection minimisation | PII detection + redaction at **ingest** (before embedding) | Step 1.7 (always in the ingest pipeline) | `pii` | +| P4 | Use / retention / disposal | Per-class retention windows; scheduled purge | `cfg.compliance.retention` (Step 6.10) | `retention` | +| P5 | Access + correction (data subject) | Provenance + audit trail of what produced an answer | Steps 5.1 / 6.6 | `audit` | +| P5 | Right to erasure | `POST /v1/compliance/erase` (tenant-scoped) | Step 6.10 | `retention` | +| P6 | Disclosure to third parties | PII egress policies (redact / mask / block) | `cfg.pii` (Step 6.5) | `pii` | +| P7 | Data residency / cross-border transfer | Per-tenant region pinning enforced at ingest | `cfg.compliance.region` + `tenants[].data_region` (Step 6.10) | `residency` | + +See the [GDPR article mapping](gdpr-mapping.md) for an article-by-article view, and +[`reference/compliance.md`](../reference/compliance.md) for the config + API. + +## Verifying the posture + +```bash +ragctl compliance report -f rag.yaml # the controls on/off + region + retention +curl -s $GATEWAY/v1/status/compliance | jq # the same posture, live from the running gateway +``` + +The posture is computed from `cfg`, so an enabled control in the report corresponds +to a control that is *actually active* — the evidence an auditor checks the mapping +against. diff --git a/docs/reference/compliance.md b/docs/reference/compliance.md new file mode 100644 index 0000000..2424fd0 --- /dev/null +++ b/docs/reference/compliance.md @@ -0,0 +1,105 @@ +# Compliance reference (`rag-compliance`, Step 6.10) + +Data retention, GDPR right-to-erasure, data-residency, and the live compliance +posture. Import root: `rag_compliance`. + +## Overview + +| Concern | Entry point | +|---------|-------------| +| Retention purge + GDPR erasure | `RetentionEnforcer` | +| Live control posture | `compliance_posture(...)` → `CompliancePosture` | +| Data-residency check | `residency_ok(tenant_region, deployment_region)` | + +Core types (`rag_core.types`): `DataClass`, `RetentionPolicy`, `ErasureResult`, +`CompliancePosture`. Errors: `ComplianceError` (400), `ResidencyViolationError` +(403). SPI: `purge_before` / `purge_tenant` on `FeedbackStore` + `ProvenanceStore`. + +## HTTP surfaces + +| Method / path | Purpose | +|---------------|---------| +| `GET /v1/status/compliance` | The live posture (controls + region + retention) — the SOC 2 / GDPR mapping backing | +| `POST /v1/compliance/erase` | GDPR right-to-erasure of the **calling tenant's** data | + +```bash +curl -s $GATEWAY/v1/status/compliance | jq +# erase: dry-run preview by default; delete needs dry_run=false AND confirm=true +curl -X POST $GATEWAY/v1/compliance/erase \ + -H "X-Tenant-Id: acme" -d '{"dry_run": false, "confirm": true}' +``` + +The residency guard runs at `POST /v1/ingest/document`: when `cfg.compliance.enabled` +and a tenant's `data_region` differs from `cfg.compliance.region`, ingest is refused +with HTTP 403 (`residency_violation`). + +## Public API + +```python +from rag_compliance import RetentionEnforcer, compliance_posture, residency_ok +from rag_core.types import RetentionPolicy + +enforcer = RetentionEnforcer(feedback_store=fs, provenance_store=ps) +preview = await enforcer.purge(ctx, RetentionPolicy(feedback_days=30), dry_run=True) # count +applied = await enforcer.purge(ctx, RetentionPolicy(feedback_days=30)) # delete +erased = await enforcer.erase_tenant(ctx) # GDPR erasure + +posture = compliance_posture(region="eu-west-1", controls={"audit": True}, retention=...) +assert residency_ok("eu-west-1", "eu-west-1") +``` + +- `RetentionEnforcer.purge(ctx, policy, *, dry_run=False)` — purge each data class + past its window; returns `ErasureResult` (per-class counts). Emits + `compliance.data_purged` when ≥1 removed. +- `RetentionEnforcer.erase_tenant(ctx, *, dry_run=False)` — erase all of the + tenant's data; emits `compliance.tenant_erased`. +- `dry_run` counts without deleting (the SPI `purge_*` methods honour it). + +## Configuration + +```yaml +compliance: + enabled: false # off by default (residency can refuse ingest; erasure deletes) + region: "" # this deployment's data region (GDPR residency); empty = none + retention: # per-class windows in days; omit/null = retain indefinitely + chunks_days: null + feedback_days: 30 + provenance_days: 90 + traces_days: 30 + audit_days: 365 # advisory — audit retention is the WORM export, not in-place purge +tenants: + - id: acme + data_region: eu-west-1 # must match compliance.region or ingest is refused + retention_days: 60 # per-tenant cap across all classes (tightens the global windows) +``` + +## `ragctl` + +```bash +ragctl compliance report -f rag.yaml # the posture: controls on/off, region, retention windows +ragctl compliance demo --tenant acme # in-process retention purge + erasure demo +``` + +## Events (PII-free) + +| Event | When | +|-------|------| +| `compliance.data_purged` | A retention purge removed ≥1 record | +| `compliance.tenant_erased` | A GDPR right-to-erasure run | +| `compliance.residency_violation` | An ingest refused on a region mismatch | + +All carry only operation / counts / region labels — never subject data. + +## Extension points + +- **Durable retention** — implement `purge_before` / `purge_tenant` on a real + `FeedbackStore` / `ProvenanceStore` (delete-by-age / by-tenant in the backend); + the contract suite is `tests/contract/test_retention_purge.py`. +- **More data classes** — wire additional stores into the `RetentionEnforcer` + (chunk/document erasure needs an index-side delete-by-document path — deferred). +- **The posture** — `compliance_posture(...)` takes the control booleans, so a + custom deployment can report extra controls. + +See [architecture/compliance.md](../architecture/compliance.md), +[compliance/soc2-control-mapping.md](../compliance/soc2-control-mapping.md), and +[ADR-0042](../adr/ADR-0042-compliance-posture.md). diff --git a/packages/compliance/README.md b/packages/compliance/README.md new file mode 100644 index 0000000..0db7010 --- /dev/null +++ b/packages/compliance/README.md @@ -0,0 +1,20 @@ +# rag-compliance + +Compliance posture for AgentContextOS (Step 6.10): **data retention**, **GDPR +right-to-erasure**, **data-residency**, and the live **control posture** that backs +the SOC 2 / GDPR control-mapping docs. + +- `RetentionEnforcer` — drives the tenant-scoped `purge_*` SPI methods to apply + per-data-class retention windows (`purge`) and erase a tenant entirely + (`erase_tenant`, GDPR right-to-erasure). Both support a `dry_run` preview and + emit PII-free `compliance.*` events. +- `compliance_posture(...)` → `CompliancePosture` — which governance controls are + on + the deployment region + retention windows. +- `residency_ok(tenant_region, deployment_region)` — the data-residency check + applied at the ingest boundary. + +Depends only on `rag-core` + `rag-observability`. See +[`docs/reference/compliance.md`](../../docs/reference/compliance.md), +[`docs/architecture/compliance.md`](../../docs/architecture/compliance.md), +[`docs/compliance/soc2-control-mapping.md`](../../docs/compliance/soc2-control-mapping.md), +and [ADR-0042](../../docs/adr/ADR-0042-compliance-posture.md). diff --git a/packages/compliance/pyproject.toml b/packages/compliance/pyproject.toml new file mode 100644 index 0000000..0bd707c --- /dev/null +++ b/packages/compliance/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "rag-compliance" +version = "0.1.0" +description = "AgentContextOS — compliance posture: data retention, GDPR erasure/residency, SOC 2 control mapping" +readme = "README.md" +requires-python = ">=3.12" +# rag-compliance depends only on rag-core (the RetentionPolicy / CompliancePosture +# / ErasureResult types + the store SPIs whose purge methods it drives) and +# rag-observability (structured logger + the compliance.* events). No heavy deps: +# retention runs over whatever stores are wired (in-memory by default). +dependencies = [ + "rag-core", + "rag-observability", +] + +[project.optional-dependencies] +dev = [ + "pytest>=9.0", + "pytest-asyncio>=1.3", + "mypy>=2.1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/rag_compliance"] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.uv.sources] +rag-core = { workspace = true } +rag-observability = { workspace = true } diff --git a/packages/compliance/src/rag_compliance/__init__.py b/packages/compliance/src/rag_compliance/__init__.py new file mode 100644 index 0000000..a822b39 --- /dev/null +++ b/packages/compliance/src/rag_compliance/__init__.py @@ -0,0 +1,18 @@ +"""rag-compliance — compliance posture: retention, GDPR erasure/residency (Step 6.10). + +Two pieces: + +* ``RetentionEnforcer`` — drives the tenant-scoped ``purge_*`` SPI methods to apply + per-class data-retention windows (:meth:`purge`) and the GDPR right-to-erasure + (:meth:`erase_tenant`); both support a ``dry_run`` preview and emit PII-free + ``compliance.*`` events. +* ``compliance_posture`` / ``residency_ok`` — the live control posture (the + machine-readable backing for the SOC 2 / GDPR mapping) + the data-residency check. +""" + +from __future__ import annotations + +from rag_compliance.posture import compliance_posture, residency_ok +from rag_compliance.retention import RetentionEnforcer + +__all__ = ["RetentionEnforcer", "compliance_posture", "residency_ok"] diff --git a/packages/compliance/src/rag_compliance/posture.py b/packages/compliance/src/rag_compliance/posture.py new file mode 100644 index 0000000..1737a08 --- /dev/null +++ b/packages/compliance/src/rag_compliance/posture.py @@ -0,0 +1,56 @@ +"""Compliance posture + data-residency helpers (Step 6.10). + +``compliance_posture`` assembles the live :class:`~rag_core.types.CompliancePosture` +from the control booleans the gateway derives from ``cfg`` — the machine-readable +backing for the SOC 2 / GDPR control-mapping docs. ``residency_ok`` is the pure +GDPR data-residency check applied at the ingest boundary. + +Kept config-free (control booleans are passed in) so the package stays in the +``compliance → core/observability`` dependency lane. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +from rag_core.types import CompliancePosture, RetentionPolicy + +__all__ = ["compliance_posture", "residency_ok"] + + +def compliance_posture( + *, + region: str = "", + controls: Mapping[str, bool] | None = None, + retention: RetentionPolicy | None = None, +) -> CompliancePosture: + """Assemble the :class:`CompliancePosture` from the deployment's control flags. + + ``controls`` maps a governance-control name (``audit`` / ``acl`` / ``pii`` / + ``byok`` / ``sso`` / ``scim`` / ``quotas`` / ``retention`` / ``residency``) to + whether it is enabled in this deployment; ``enabled_controls`` is the sorted + subset that are on. + """ + resolved = dict(controls or {}) + enabled = tuple(sorted(name for name, on in resolved.items() if on)) + return CompliancePosture( + region=region, + controls=resolved, + enabled_controls=enabled, + retention=retention or RetentionPolicy(), + ) + + +def residency_ok(tenant_region: str | None, deployment_region: str) -> bool: + """GDPR data-residency check: may this deployment serve *tenant_region*? + + * A tenant with **no** required region (``None`` / empty) always passes. + * A deployment that declares **no** region (empty ``deployment_region``) + enforces nothing — every tenant passes (residency is opt-in). + * Otherwise the deployment's region must equal the tenant's required region. + """ + if not tenant_region: + return True + if not deployment_region: + return True + return tenant_region == deployment_region diff --git a/packages/compliance/src/rag_compliance/py.typed b/packages/compliance/src/rag_compliance/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/compliance/src/rag_compliance/retention.py b/packages/compliance/src/rag_compliance/retention.py new file mode 100644 index 0000000..910066d --- /dev/null +++ b/packages/compliance/src/rag_compliance/retention.py @@ -0,0 +1,112 @@ +"""Data-retention enforcement + GDPR right-to-erasure (Step 6.10). + +The ``RetentionEnforcer`` drives the tenant-scoped ``purge_*`` methods on the +wired stores: :meth:`purge` removes records past their per-class retention window +(driven by a :class:`~rag_core.types.RetentionPolicy`), and :meth:`erase_tenant` +removes *all* of a tenant's records (the GDPR right-to-erasure primitive). Both +support ``dry_run`` — the stores count matches without deleting — so an operator +can preview a purge. Every run emits a PII-free ``compliance.*`` event. + +The enforcer holds no config: it takes the resolved :class:`RetentionPolicy` and a +``RequestContext`` (the tenant scope) per call, so it stays in the +``compliance → core/observability`` dependency lane (no ``rag-config``). +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +from rag_core.types import DataClass, ErasureResult, RequestContext, RetentionPolicy +from rag_observability.events import ( + EVT_COMPLIANCE_DATA_PURGED, + EVT_COMPLIANCE_TENANT_ERASED, + ComplianceEvent, +) +from rag_observability.logging import get_logger + +__all__ = ["RetentionEnforcer"] + +_log = get_logger(__name__) + + +class RetentionEnforcer: + """Purges expired / erased tenant data across the wired stores (Step 6.10). + + Wire the stores that support purge (feedback, provenance today); a ``None`` + store is simply skipped. ``clock`` is injectable for deterministic tests. + """ + + def __init__( + self, + *, + feedback_store: Any = None, + provenance_store: Any = None, + clock: Callable[[], datetime] | None = None, + ) -> None: + self._feedback = feedback_store + self._provenance = provenance_store + self._clock = clock or (lambda: datetime.now(tz=UTC)) + + def _stores(self) -> dict[DataClass, Any]: + out: dict[DataClass, Any] = {} + if self._feedback is not None: + out[DataClass.feedback] = self._feedback + if self._provenance is not None: + out[DataClass.provenance] = self._provenance + return out + + async def purge( + self, ctx: RequestContext, policy: RetentionPolicy, *, dry_run: bool = False + ) -> ErasureResult: + """Purge ``ctx.tenant_id``'s records past their per-class retention window.""" + now = self._clock() + purged: dict[str, int] = {} + for data_class, store in self._stores().items(): + days = policy.days_for(data_class) + if days is None: + continue # retain indefinitely + cutoff = now - timedelta(days=days) + n = await store.purge_before(ctx, cutoff, dry_run=dry_run) + if n: + purged[data_class.value] = n + result = ErasureResult( + tenant_id=ctx.tenant_id, + scope="retention", + purged=purged, + total=sum(purged.values()), + dry_run=dry_run, + ) + if result.total: + self._emit(EVT_COMPLIANCE_DATA_PURGED, "purge", result) + return result + + async def erase_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> ErasureResult: + """Erase **all** of ``ctx.tenant_id``'s records (GDPR right-to-erasure).""" + purged: dict[str, int] = {} + for data_class, store in self._stores().items(): + n = await store.purge_tenant(ctx, dry_run=dry_run) + if n: + purged[data_class.value] = n + result = ErasureResult( + tenant_id=ctx.tenant_id, + scope="erasure", + purged=purged, + total=sum(purged.values()), + dry_run=dry_run, + ) + self._emit(EVT_COMPLIANCE_TENANT_ERASED, "erase", result) + return result + + def _emit(self, event_name: str, operation: str, result: ErasureResult) -> None: + _log.info( + ComplianceEvent( + event_name=event_name, + tenant_id=str(result.tenant_id), + operation=operation, + purged_total=result.total, + data_classes=",".join(sorted(result.purged)), + dry_run=result.dry_run, + ).model_dump_json() + ) diff --git a/packages/compliance/tests/test_retention.py b/packages/compliance/tests/test_retention.py new file mode 100644 index 0000000..4e90cad --- /dev/null +++ b/packages/compliance/tests/test_retention.py @@ -0,0 +1,129 @@ +"""Unit tests for rag-compliance — RetentionEnforcer + posture + residency (Step 6.10).""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from rag_compliance import RetentionEnforcer, compliance_posture, residency_ok +from rag_core.spi.noop import NoopFeedbackStore, NoopProvenanceStore +from rag_core.types import ( + FeedbackRecord, + FeedbackSignal, + Principal, + PrincipalId, + PrincipalKind, + ProvenanceRecord, + RequestContext, + RequestId, + RetentionPolicy, + SignedProvenanceRecord, + TenantId, +) + +NOW = datetime(2026, 6, 8, tzinfo=UTC) + + +def _ctx(tenant: str = "acme") -> RequestContext: + tid = TenantId(tenant) + return RequestContext( + tenant_id=tid, + principal=Principal( + id=PrincipalId("p"), kind=PrincipalKind.service, display_name="p", tenant_id=tid + ), + ) + + +def _feedback(ctx: RequestContext, fid: str, *, age_days: int) -> FeedbackRecord: + return FeedbackRecord( + feedback_id=fid, + request_id=RequestId(fid), + tenant_id=ctx.tenant_id, + signal=FeedbackSignal.thumb_up, + created_at=NOW - timedelta(days=age_days), + ) + + +def _prov(ctx: RequestContext, rid: str, *, age_days: int) -> SignedProvenanceRecord: + return SignedProvenanceRecord( + record=ProvenanceRecord( + request_id=RequestId(rid), + trace_id=rid, + tenant_id=ctx.tenant_id, + created_at=NOW - timedelta(days=age_days), + ) + ) + + +async def test_purge_applies_per_class_windows() -> None: + fs, ps = NoopFeedbackStore(), NoopProvenanceStore() + ctx = _ctx() + await fs.put(ctx, _feedback(ctx, "f-old", age_days=100)) + await fs.put(ctx, _feedback(ctx, "f-new", age_days=1)) + await ps.put(ctx, _prov(ctx, "p-old", age_days=200)) + enf = RetentionEnforcer(feedback_store=fs, provenance_store=ps, clock=lambda: NOW) + + result = await enf.purge(ctx, RetentionPolicy(feedback_days=30, provenance_days=90)) + assert result.scope == "retention" + assert result.purged == {"feedback": 1, "provenance": 1} + assert result.total == 2 + assert result.dry_run is False + + +async def test_purge_dry_run_counts_without_deleting() -> None: + fs = NoopFeedbackStore() + ctx = _ctx() + await fs.put(ctx, _feedback(ctx, "old", age_days=100)) + enf = RetentionEnforcer(feedback_store=fs, clock=lambda: NOW) + + preview = await enf.purge(ctx, RetentionPolicy(feedback_days=30), dry_run=True) + assert preview.total == 1 and preview.dry_run is True + assert len(await fs.list(ctx)) == 1 # nothing deleted + + +async def test_purge_skips_classes_with_no_window() -> None: + fs = NoopFeedbackStore() + ctx = _ctx() + await fs.put(ctx, _feedback(ctx, "old", age_days=999)) + enf = RetentionEnforcer(feedback_store=fs, clock=lambda: NOW) + # No feedback_days configured → retain indefinitely → nothing purged. + result = await enf.purge(ctx, RetentionPolicy(provenance_days=30)) + assert result.total == 0 + assert len(await fs.list(ctx)) == 1 + + +async def test_erase_tenant_removes_everything() -> None: + fs, ps = NoopFeedbackStore(), NoopProvenanceStore() + ctx = _ctx() + await fs.put(ctx, _feedback(ctx, "a", age_days=1)) + await ps.put(ctx, _prov(ctx, "b", age_days=1)) + enf = RetentionEnforcer(feedback_store=fs, provenance_store=ps) + + result = await enf.erase_tenant(ctx) + assert result.scope == "erasure" + assert result.total == 2 + assert await fs.list(ctx) == [] + + +async def test_enforcer_with_no_stores_is_noop() -> None: + enf = RetentionEnforcer() + result = await enf.purge(_ctx(), RetentionPolicy(feedback_days=1)) + assert result.total == 0 + + +def test_compliance_posture() -> None: + posture = compliance_posture( + region="eu-west-1", + controls={"audit": True, "pii": False, "sso": True}, + retention=RetentionPolicy(feedback_days=30), + ) + assert posture.region == "eu-west-1" + assert posture.enabled_controls == ("audit", "sso") + assert posture.controls["pii"] is False + assert posture.retention.feedback_days == 30 + + +def test_residency_ok() -> None: + assert residency_ok("eu-west-1", "eu-west-1") is True + assert residency_ok("eu-west-1", "us-east-1") is False + assert residency_ok(None, "us-east-1") is True # tenant has no requirement + assert residency_ok("eu-west-1", "") is True # deployment declares no region diff --git a/packages/config/src/rag_config/schema.py b/packages/config/src/rag_config/schema.py index 2847f4b..d319534 100644 --- a/packages/config/src/rag_config/schema.py +++ b/packages/config/src/rag_config/schema.py @@ -350,6 +350,15 @@ class TenantConfig(_StrictBase): # block, so a bearer token / SAML assertion for the tenant is verified against # its own IdP. ``None`` → the tenant has no federated IdP. sso: TenantSsoConfig | None = None + # Compliance (Step 6.10): GDPR data-residency — the region this tenant's data + # must stay in. When ``compliance.enabled`` and this differs from + # ``compliance.region``, ingest for the tenant is refused (residency violation). + # ``None`` → no per-tenant residency requirement. + data_region: str | None = None + # Per-tenant retention cap (days) applied across every data class — overrides + # (tightens) the global ``compliance.retention`` for this tenant. ``None`` → + # inherit the global per-class windows. + retention_days: Annotated[int, Field(ge=1)] | None = None # --------------------------------------------------------------------------- @@ -818,6 +827,45 @@ class ScimConfig(_StrictBase): tokens: dict[str, str] = Field(default_factory=dict) +class RetentionConfig(_StrictBase): + """Per-data-class retention windows in days (Step 6.10). + + Each field is the number of days to retain that data class; ``None`` (default) + means **retain indefinitely**. Resolved into a ``rag_core.types.RetentionPolicy`` + and enforced by the ``RetentionEnforcer`` (purges expired feedback / provenance + / trace records). ``audit_days`` is advisory only — the tamper-evident audit + chain (Step 6.6) is retained via its WORM **export**, never purged in place. + A per-tenant ``tenants[].retention_days`` caps every class for that tenant. + """ + + chunks_days: Annotated[int, Field(ge=1)] | None = None + feedback_days: Annotated[int, Field(ge=1)] | None = None + provenance_days: Annotated[int, Field(ge=1)] | None = None + traces_days: Annotated[int, Field(ge=1)] | None = None + audit_days: Annotated[int, Field(ge=1)] | None = None + + +class ComplianceConfig(_StrictBase): + """Compliance posture — data retention + GDPR residency (Step 6.10). + + When ``enabled`` the gateway exposes ``GET /v1/status/compliance`` (the live + control posture backing the SOC 2 / GDPR mapping) and ``POST /v1/compliance/ + erase`` (tenant-scoped right-to-erasure), runs the ``RetentionEnforcer`` over + the wired stores, and enforces data-residency: a tenant whose + ``tenants[].data_region`` differs from ``region`` is refused at ingest + (``ResidencyViolationError`` → HTTP 403). + + **Disabled by default** — residency enforcement can refuse ingest and erasure + deletes data, so it is opt-in. ``region`` is this deployment's declared data + region (e.g. ``eu-west-1``); leave it empty to declare no region (residency + checks then pass for every tenant). + """ + + enabled: bool = False + region: str = "" + retention: RetentionConfig = Field(default_factory=RetentionConfig) + + # --------------------------------------------------------------------------- # Corpus registry section (Step 3.5) # --------------------------------------------------------------------------- @@ -1099,6 +1147,7 @@ class RagConfig(_StrictBase): kms: KmsConfig = Field(default_factory=KmsConfig) sso: SsoConfig = Field(default_factory=SsoConfig) scim: ScimConfig = Field(default_factory=ScimConfig) + compliance: ComplianceConfig = Field(default_factory=ComplianceConfig) webhooks: WebhooksConfig = Field(default_factory=WebhooksConfig) provenance: ProvenanceConfig = Field(default_factory=ProvenanceConfig) feedback: FeedbackConfig = Field(default_factory=FeedbackConfig) diff --git a/packages/config/src/rag_config/tenancy.py b/packages/config/src/rag_config/tenancy.py index e65e02e..8413203 100644 --- a/packages/config/src/rag_config/tenancy.py +++ b/packages/config/src/rag_config/tenancy.py @@ -39,6 +39,7 @@ def settings_for_tenant(tenant: TenantConfig) -> TenantSettings: pii_policy=_pii_policy_from(tenant.pii_policy), acl_labels=frozenset(tenant.acl_labels), physical_index=_physical_index_for(tenant), + data_region=tenant.data_region, ) diff --git a/packages/core/src/rag_core/errors.py b/packages/core/src/rag_core/errors.py index b98b4ef..c8ae570 100644 --- a/packages/core/src/rag_core/errors.py +++ b/packages/core/src/rag_core/errors.py @@ -369,3 +369,23 @@ class ScimConflictError(ScimError): """ code = "scim_conflict" + + +# --------------------------------------------------------------------------- +# Compliance posture (Step 6.10) +# --------------------------------------------------------------------------- +class ComplianceError(RagError): + """A compliance-posture operation could not be satisfied (Step 6.10).""" + + code = "compliance_error" + + +class ResidencyViolationError(ComplianceError): + """A tenant's data would be placed outside its declared region (Step 6.10). + + Raised at the ingest boundary when a tenant's ``data_region`` does not match + the deployment's ``cfg.compliance.region`` — GDPR data-residency enforcement. + Maps to HTTP 403 at the gateway (the request is refused, not retried). + """ + + code = "residency_violation" diff --git a/packages/core/src/rag_core/events.py b/packages/core/src/rag_core/events.py index af4c7f2..4133fa9 100644 --- a/packages/core/src/rag_core/events.py +++ b/packages/core/src/rag_core/events.py @@ -11,6 +11,15 @@ from rag_observability.events import EVT_CACHE_HIT as EVT_CACHE_HIT from rag_observability.events import EVT_CACHE_INVALIDATED as EVT_CACHE_INVALIDATED from rag_observability.events import EVT_CACHE_MISS as EVT_CACHE_MISS +from rag_observability.events import ( + EVT_COMPLIANCE_DATA_PURGED as EVT_COMPLIANCE_DATA_PURGED, +) +from rag_observability.events import ( + EVT_COMPLIANCE_RESIDENCY_VIOLATION as EVT_COMPLIANCE_RESIDENCY_VIOLATION, +) +from rag_observability.events import ( + EVT_COMPLIANCE_TENANT_ERASED as EVT_COMPLIANCE_TENANT_ERASED, +) from rag_observability.events import EVT_DRIFT_DETECTED as EVT_DRIFT_DETECTED from rag_observability.events import EVT_FALLBACK_ENGAGED as EVT_FALLBACK_ENGAGED from rag_observability.events import ( @@ -43,6 +52,7 @@ from rag_observability.events import EVT_SSO_AUTHENTICATED as EVT_SSO_AUTHENTICATED from rag_observability.events import BreakerEvent as BreakerEvent from rag_observability.events import CacheEvent as CacheEvent +from rag_observability.events import ComplianceEvent as ComplianceEvent from rag_observability.events import DriftEvent as DriftEvent from rag_observability.events import FeedbackEvent as FeedbackEvent from rag_observability.events import GuardEvent as GuardEvent @@ -72,6 +82,7 @@ "DriftEvent", "SsoEvent", "ScimEvent", + "ComplianceEvent", "EVT_INGEST_STARTED", "EVT_INGEST_COMPLETED", "EVT_INGEST_FAILED", @@ -100,5 +111,8 @@ "EVT_SCIM_USER_PROVISIONED", "EVT_SCIM_USER_DEPROVISIONED", "EVT_SCIM_GROUP_CHANGED", + "EVT_COMPLIANCE_DATA_PURGED", + "EVT_COMPLIANCE_TENANT_ERASED", + "EVT_COMPLIANCE_RESIDENCY_VIOLATION", "check_pii", ] diff --git a/packages/core/src/rag_core/gateway_types.py b/packages/core/src/rag_core/gateway_types.py index 09986ce..efaa1ca 100644 --- a/packages/core/src/rag_core/gateway_types.py +++ b/packages/core/src/rag_core/gateway_types.py @@ -551,6 +551,22 @@ class SsoStatusResponse(BaseModel): tenant: SsoTenantStatus +class ComplianceEraseRequest(BaseModel): + """``POST /v1/compliance/erase`` body — GDPR right-to-erasure (Step 6.10). + + Erases the **calling tenant's** data across the retention-enforcer's wired + stores (tenant scope comes from the authenticated principal, never the body). + ``dry_run`` defaults to ``True`` (a safe preview that *counts* without + deleting); set it ``false`` **and** ``confirm`` ``true`` to actually delete — + both are required so an erasure is never a one-flag accident. + """ + + model_config = {"frozen": True} + + dry_run: bool = True + confirm: bool = False + + class GatewayError(BaseModel): """JSON error body returned for 4xx / 5xx responses. @@ -577,6 +593,7 @@ class GatewayError(BaseModel): "Answer", "AuditListResponse", "AuditVerifyResponse", + "ComplianceEraseRequest", "Corpus", "CorpusList", "ExperimentAssignment", diff --git a/packages/core/src/rag_core/gen_schemas.py b/packages/core/src/rag_core/gen_schemas.py index c49c00e..4065d08 100644 --- a/packages/core/src/rag_core/gen_schemas.py +++ b/packages/core/src/rag_core/gen_schemas.py @@ -26,6 +26,7 @@ from rag_core.filter import And, AnyIn, Eq, Not, Or, TrueExpr from rag_core.gateway_types import ( Answer, + ComplianceEraseRequest, Corpus, CorpusList, ExperimentAssignment, @@ -68,6 +69,7 @@ ClaimVerdict, Community, CommunitySummary, + CompliancePosture, ConflictAnnotation, ConnectorState, CorpusRoutingDecision, @@ -77,6 +79,7 @@ DriftReport, DriftSnapshot, Embedding, + ErasureResult, FallbackResult, FederatedIdentity, FeedbackRecord, @@ -104,6 +107,7 @@ QuotaSnapshot, QuotaVerdict, RequestContext, + RetentionPolicy, RetrievalResult, RoutingDecision, ScimGroup, @@ -190,6 +194,11 @@ FederatedIdentity, ScimUser, ScimGroup, + # Compliance posture — retention / residency / control mapping (Step 6.10). + RetentionPolicy, + ErasureResult, + CompliancePosture, + ComplianceEraseRequest, CorpusScore, CorpusRoutingDecision, AuditEvent, diff --git a/packages/core/src/rag_core/spi/feedback_store.py b/packages/core/src/rag_core/spi/feedback_store.py index 92a7d39..87a5c1e 100644 --- a/packages/core/src/rag_core/spi/feedback_store.py +++ b/packages/core/src/rag_core/spi/feedback_store.py @@ -3,6 +3,7 @@ from __future__ import annotations import abc +from datetime import datetime from rag_core.spi._base import HealthCheckMixin from rag_core.types import FeedbackRecord, RequestContext, RequestId @@ -35,3 +36,23 @@ async def list( Filtered to *request_id* when given; capped at *limit*. """ + + async def purge_before( + self, ctx: RequestContext, cutoff: datetime, *, dry_run: bool = False + ) -> int: + """Delete ``ctx.tenant_id``'s records older than *cutoff*; return the count. + + With *dry_run* the matching records are **counted but not deleted** (a + retention preview). The Step 6.10 data-retention primitive. Non-abstract + default (no-op, returns 0) so existing backends are unaffected; the + in-memory store and retention-aware backends override it. + """ + return 0 + + async def purge_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> int: + """Delete **all** of ``ctx.tenant_id``'s records; return the count. + + With *dry_run* the records are counted but not deleted. The Step 6.10 GDPR + right-to-erasure primitive. Non-abstract default (no-op). + """ + return 0 diff --git a/packages/core/src/rag_core/spi/noop/feedback_store.py b/packages/core/src/rag_core/spi/noop/feedback_store.py index 09d7aa5..c4c9f0b 100644 --- a/packages/core/src/rag_core/spi/noop/feedback_store.py +++ b/packages/core/src/rag_core/spi/noop/feedback_store.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import OrderedDict +from datetime import datetime from rag_core.spi.feedback_store import FeedbackStore from rag_core.types import FeedbackRecord, RequestContext, RequestId @@ -52,5 +53,27 @@ async def list( break return out + async def purge_before( + self, ctx: RequestContext, cutoff: datetime, *, dry_run: bool = False + ) -> int: + tenant = str(ctx.tenant_id) + doomed = [ + key + for key, rec in self._records.items() + if key[0] == tenant and rec.created_at < cutoff + ] + if not dry_run: + for key in doomed: + del self._records[key] + return len(doomed) + + async def purge_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> int: + tenant = str(ctx.tenant_id) + doomed = [key for key in self._records if key[0] == tenant] + if not dry_run: + for key in doomed: + del self._records[key] + return len(doomed) + async def health(self) -> bool: return True diff --git a/packages/core/src/rag_core/spi/noop/provenance_store.py b/packages/core/src/rag_core/spi/noop/provenance_store.py index 78fdf5e..7db441f 100644 --- a/packages/core/src/rag_core/spi/noop/provenance_store.py +++ b/packages/core/src/rag_core/spi/noop/provenance_store.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import OrderedDict +from datetime import datetime from rag_core.spi.provenance_store import ProvenanceStore from rag_core.types import RequestContext, RequestId, SignedProvenanceRecord @@ -36,5 +37,27 @@ async def get( ) -> SignedProvenanceRecord | None: return self._records.get((str(ctx.tenant_id), str(request_id))) + async def purge_before( + self, ctx: RequestContext, cutoff: datetime, *, dry_run: bool = False + ) -> int: + tenant = str(ctx.tenant_id) + doomed = [ + key + for key, rec in self._records.items() + if key[0] == tenant and rec.record.created_at < cutoff + ] + if not dry_run: + for key in doomed: + del self._records[key] + return len(doomed) + + async def purge_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> int: + tenant = str(ctx.tenant_id) + doomed = [key for key in self._records if key[0] == tenant] + if not dry_run: + for key in doomed: + del self._records[key] + return len(doomed) + async def health(self) -> bool: return True diff --git a/packages/core/src/rag_core/spi/provenance_store.py b/packages/core/src/rag_core/spi/provenance_store.py index 07a649b..d92a23e 100644 --- a/packages/core/src/rag_core/spi/provenance_store.py +++ b/packages/core/src/rag_core/spi/provenance_store.py @@ -3,6 +3,7 @@ from __future__ import annotations import abc +from datetime import datetime from rag_core.spi._base import HealthCheckMixin from rag_core.types import RequestContext, RequestId, SignedProvenanceRecord @@ -27,3 +28,23 @@ async def get( self, ctx: RequestContext, request_id: RequestId ) -> SignedProvenanceRecord | None: """Return the record for *request_id* within ``ctx.tenant_id``, else ``None``.""" + + async def purge_before( + self, ctx: RequestContext, cutoff: datetime, *, dry_run: bool = False + ) -> int: + """Delete ``ctx.tenant_id``'s records older than *cutoff*; return the count. + + With *dry_run* the matching records are **counted but not deleted** (a + retention preview). The Step 6.10 data-retention primitive. Non-abstract + default (no-op, returns 0) so existing backends are unaffected; the + in-memory store and retention-aware backends override it. + """ + return 0 + + async def purge_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> int: + """Delete **all** of ``ctx.tenant_id``'s records; return the count. + + With *dry_run* the records are counted but not deleted. The Step 6.10 GDPR + right-to-erasure primitive. Non-abstract default (no-op). + """ + return 0 diff --git a/packages/core/src/rag_core/types.py b/packages/core/src/rag_core/types.py index 9b41368..81a1fcd 100644 --- a/packages/core/src/rag_core/types.py +++ b/packages/core/src/rag_core/types.py @@ -444,6 +444,10 @@ class TenantSettings(BaseModel): # backend namespaces its base under (``-``) when the # tenant has a *dedicated* index. ``None`` → the shared base index. physical_index: str | None = None + # GDPR data-residency (Step 6.10): the region this tenant's data must stay in. + # Checked at the ingest boundary against ``cfg.compliance.region``; ``None`` → + # no per-tenant residency requirement. + data_region: str | None = None # --------------------------------------------------------------------------- @@ -2188,3 +2192,86 @@ class ScimGroup(BaseModel): display_name: str = Field(alias="displayName") members: tuple[ScimMember, ...] = Field(default_factory=tuple) meta: ScimResourceMeta | None = None + + +# --------------------------------------------------------------------------- +# Compliance posture — retention / residency / control mapping (Step 6.10) +# --------------------------------------------------------------------------- +class DataClass(StrEnum): + """A category of tenant data a retention policy applies to (Step 6.10).""" + + chunks = "chunks" # indexed document content (the system of record) + audit = "audit" # tamper-evident audit events + feedback = "feedback" # online feedback / implicit signals + provenance = "provenance" # per-query signed provenance records + traces = "traces" # captured per-query span trees + + +class RetentionPolicy(BaseModel): + """Per-data-class retention windows in days (Step 6.10). + + ``None`` on a class means **retain indefinitely** (no purge for that class). + The runtime counterpart of ``cfg.compliance.retention`` — resolved per tenant + at the boundary and consumed by the ``RetentionEnforcer``. ``audit_days`` is + advisory: the tamper-evident hash chain (Step 6.6) is not purged in place — + audit retention is the WORM **export** lifecycle — so the enforcer never drops + audit events, it only reports the configured window. + """ + + model_config = {"frozen": True} + + chunks_days: int | None = None + feedback_days: int | None = None + provenance_days: int | None = None + traces_days: int | None = None + audit_days: int | None = None + + def days_for(self, data_class: DataClass) -> int | None: + """Retention window (days) for *data_class*, or ``None`` (keep forever).""" + return { + DataClass.chunks: self.chunks_days, + DataClass.feedback: self.feedback_days, + DataClass.provenance: self.provenance_days, + DataClass.traces: self.traces_days, + DataClass.audit: self.audit_days, + }[data_class] + + +class ErasureResult(BaseModel): + """Outcome of a retention purge or a GDPR right-to-erasure run (Step 6.10). + + ``purged`` maps each :class:`DataClass` (by value) to the number of records + removed; ``total`` is their sum. ``dry_run`` is ``True`` when the run only + *counted* what would be purged without deleting (the default for a preview). + """ + + model_config = {"frozen": True} + + tenant_id: TenantId + scope: str = "retention" # "retention" (age-based) | "erasure" (whole-tenant) + purged: dict[str, int] = Field(default_factory=dict) + total: int = 0 + dry_run: bool = False + generated_at: datetime = Field(default_factory=_utcnow) + + +class CompliancePosture(BaseModel): + """The platform's live compliance posture (Step 6.10). + + A point-in-time view of which governance controls are enabled, the deployment's + data region (GDPR residency), and the retention windows — computed from ``cfg`` + by ``rag_compliance.compliance_posture`` and served by + ``GET /v1/status/compliance`` + ``ragctl compliance report``. It is the + machine-readable backing for the SOC 2 / GDPR control-mapping docs: each control + maps to a Trust Service Criterion, and this reports whether it is on in *this* + deployment. Carries no tenant data — only configuration booleans — so it is + safe on the status surface. + """ + + model_config = {"frozen": True} + + region: str = "" # the deployment's declared data region (GDPR residency) + controls: dict[str, bool] = Field(default_factory=dict) + enabled_controls: tuple[str, ...] = Field(default_factory=tuple) + retention: RetentionPolicy = Field(default_factory=RetentionPolicy) + generated_at: datetime = Field(default_factory=_utcnow) diff --git a/packages/observability/src/rag_observability/events.py b/packages/observability/src/rag_observability/events.py index 33a0447..5068e84 100644 --- a/packages/observability/src/rag_observability/events.py +++ b/packages/observability/src/rag_observability/events.py @@ -43,6 +43,7 @@ "DriftEvent", "SsoEvent", "ScimEvent", + "ComplianceEvent", # event name constants — ingest "EVT_INGEST_STARTED", "EVT_INGEST_COMPLETED", @@ -80,6 +81,10 @@ "EVT_SCIM_USER_PROVISIONED", "EVT_SCIM_USER_DEPROVISIONED", "EVT_SCIM_GROUP_CHANGED", + # event name constants — compliance (Step 6.10) + "EVT_COMPLIANCE_DATA_PURGED", + "EVT_COMPLIANCE_TENANT_ERASED", + "EVT_COMPLIANCE_RESIDENCY_VIOLATION", # utilities "check_pii", ] @@ -191,6 +196,11 @@ def _register(cls, name: str) -> str: EVT_SCIM_USER_DEPROVISIONED: str = RagEvent._register("scim.user_deprovisioned") EVT_SCIM_GROUP_CHANGED: str = RagEvent._register("scim.group_changed") +# Compliance (Step 6.10) — retention purge / GDPR erasure / residency enforcement +EVT_COMPLIANCE_DATA_PURGED: str = RagEvent._register("compliance.data_purged") +EVT_COMPLIANCE_TENANT_ERASED: str = RagEvent._register("compliance.tenant_erased") +EVT_COMPLIANCE_RESIDENCY_VIOLATION: str = RagEvent._register("compliance.residency_violation") + # --------------------------------------------------------------------------- # Typed event subtypes @@ -398,3 +408,23 @@ class ScimEvent(RagEvent): resource_type: str = "" # "User" | "Group" resource_id: str = "" # server-assigned SCIM id (opaque UUID) active: bool | None = None # the resulting active state, for User mutations + + +class ComplianceEvent(RagEvent): + """Emitted on a compliance action — retention purge / erasure / residency (Step 6.10). + + Carries only the operation, counts, and region labels — never any purged + record content / subject data — so compliance telemetry is naturally PII-free. + ``compliance.data_purged`` (per retention sweep that removed ≥1 record), + ``compliance.tenant_erased`` (a GDPR right-to-erasure run), and + ``compliance.residency_violation`` (an ingest refused because a tenant's region + did not match the deployment's). The tenant is tagged via + :attr:`RagEvent.tenant_id`. + """ + + operation: str = "" # "purge" | "erase" | "residency_violation" + purged_total: int = 0 + data_classes: str = "" # comma-joined DataClass values touched + region: str = "" # the deployment region (residency) + tenant_region: str = "" # the tenant's required region (on a violation) + dry_run: bool = False diff --git a/packages/ragctl/pyproject.toml b/packages/ragctl/pyproject.toml index b013c36..4c84093 100644 --- a/packages/ragctl/pyproject.toml +++ b/packages/ragctl/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "rag-drift>=0.1.0", "rag-webhooks>=0.1.0", "rag-sso>=0.1.0", + "rag-compliance>=0.1.0", "rag-gateway>=0.4.0", "httpx>=0.27", ] @@ -65,6 +66,7 @@ rag-feedback = { workspace = true } rag-drift = { workspace = true } rag-webhooks = { workspace = true } rag-sso = { workspace = true } +rag-compliance = { workspace = true } rag-gateway = { workspace = true } [project.scripts] diff --git a/packages/ragctl/src/ragctl/main.py b/packages/ragctl/src/ragctl/main.py index b64ff2c..1b06451 100644 --- a/packages/ragctl/src/ragctl/main.py +++ b/packages/ragctl/src/ragctl/main.py @@ -5067,6 +5067,161 @@ def airgap_install( typer.echo(f"installed release '{release}' in namespace '{namespace}'") +# --------------------------------------------------------------------------- +# Compliance posture (Step 6.10) +# --------------------------------------------------------------------------- +compliance_app = typer.Typer( + help="Report the compliance posture + demo data-retention / GDPR erasure (Step 6.10).", + no_args_is_help=True, +) +app.add_typer(compliance_app, name="compliance") + + +@compliance_app.command("report") +def compliance_report( + config: Path = typer.Option( + ..., + "--config", + "-f", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Path to the rag.yaml to report the posture of.", + ), +) -> None: + """Print the live compliance posture from ``rag.yaml`` (the SOC 2 / GDPR mapping). + + Shows which governance controls are enabled, the deployment data region, and + the retention windows — the same posture ``GET /v1/status/compliance`` serves. + """ + try: + cfg = load(config) + except ConfigError as exc: + typer.echo(f"ERROR: {exc.message}", err=True) + raise typer.Exit(1) # noqa: B904 + + from rag_compliance import compliance_posture + from rag_core.types import RetentionPolicy + + r = cfg.compliance.retention + policy = RetentionPolicy( + chunks_days=r.chunks_days, + feedback_days=r.feedback_days, + provenance_days=r.provenance_days, + traces_days=r.traces_days, + audit_days=r.audit_days, + ) + posture = compliance_posture( + region=cfg.compliance.region, + controls={ + "audit": cfg.audit.enabled, + "acl": cfg.acl.enabled, + "pii": cfg.pii.enabled, + "byok": cfg.kms.enabled, + "sso": cfg.sso.enabled, + "scim": cfg.scim.enabled, + "quotas": cfg.quotas.enabled, + "guard": cfg.guard.enabled, + "breakers": cfg.breakers.enabled, + "retention": cfg.compliance.enabled, + "residency": cfg.compliance.enabled and bool(cfg.compliance.region), + }, + retention=policy, + ) + + typer.echo("\ncompliance posture") + typer.echo("─" * 64) + typer.echo(f" data region: {posture.region or '(none declared)'}") + typer.echo(f" controls on: {', '.join(posture.enabled_controls) or '(none)'}") + off = sorted(name for name, on in posture.controls.items() if not on) + typer.echo(f" controls off: {', '.join(off) or '(none)'}") + windows = { + cls: days + for cls, days in ( + ("chunks", policy.chunks_days), + ("feedback", policy.feedback_days), + ("provenance", policy.provenance_days), + ("traces", policy.traces_days), + ("audit", policy.audit_days), + ) + if days is not None + } + typer.echo( + " retention: " + + (", ".join(f"{c}={d}d" for c, d in windows.items()) or "(retain indefinitely)") + ) + + +@compliance_app.command("demo") +def compliance_demo( + tenant: str = typer.Option("acme", "--tenant", help="Tenant to run the demo for."), +) -> None: + """Demo Step 6.10 data retention + GDPR erasure over in-memory stores. + + Seeds aged feedback records, previews then applies an age-based retention + purge, then erases the whole tenant — showing the ``dry_run`` preview and the + tenant-scoped purge counts. No external services. + + Example:: + + ragctl compliance demo --tenant acme + """ + import asyncio + from datetime import UTC, datetime, timedelta + + from rag_compliance import RetentionEnforcer + from rag_core.spi.noop import NoopFeedbackStore + from rag_core.types import ( + FeedbackRecord, + FeedbackSignal, + Principal, + PrincipalId, + PrincipalKind, + RequestContext, + RequestId, + RetentionPolicy, + TenantId, + ) + + now = datetime(2026, 6, 8, tzinfo=UTC) + tid = TenantId(tenant) + ctx = RequestContext( + tenant_id=tid, + principal=Principal( + id=PrincipalId("cli"), kind=PrincipalKind.service, display_name="cli", tenant_id=tid + ), + ) + store = NoopFeedbackStore() + enforcer = RetentionEnforcer(feedback_store=store, clock=lambda: now) + + async def _run() -> None: + for i, age in enumerate((1, 45, 100, 200)): + await store.put( + ctx, + FeedbackRecord( + feedback_id=f"f{i}", + request_id=RequestId(f"f{i}"), + tenant_id=tid, + signal=FeedbackSignal.thumb_up, + created_at=now - timedelta(days=age), + ), + ) + policy = RetentionPolicy(feedback_days=30) + typer.echo(f"\ndata retention + erasure — tenant={tenant}") + typer.echo("─" * 64) + typer.echo(f" seeded: {len(await store.list(ctx))} feedback records") + preview = await enforcer.purge(ctx, policy, dry_run=True) + still = len(await store.list(ctx)) + typer.echo(f" purge (dry): would remove {preview.total} (still {still})") + applied = await enforcer.purge(ctx, policy) + typer.echo(f" purge: removed {applied.total} (now {len(await store.list(ctx))})") + erased = await enforcer.erase_tenant(ctx) + typer.echo(f" erase tenant: removed {erased.total} (now {len(await store.list(ctx))})") + + asyncio.run(_run()) + + # --------------------------------------------------------------------------- # Scaffold sub-apps — print a "delivered in Step X.Y" notice and exit 0. # diff --git a/packages/ragctl/tests/test_compliance.py b/packages/ragctl/tests/test_compliance.py new file mode 100644 index 0000000..3613b67 --- /dev/null +++ b/packages/ragctl/tests/test_compliance.py @@ -0,0 +1,50 @@ +"""Tests for ``ragctl compliance`` — Step 6.10. + +``compliance demo`` runs the retention + erasure flow over in-memory stores; +``compliance report`` prints the posture from a rag.yaml. No infrastructure. +""" + +from __future__ import annotations + +from pathlib import Path + +from ragctl.main import app +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_compliance_demo_retention_and_erasure() -> None: + result = runner.invoke(app, ["compliance", "demo", "--tenant", "acme"]) + assert result.exit_code == 0, result.output + assert "seeded: 4 feedback records" in result.output + assert "would remove 3 (still 4)" in result.output # dry-run preview + assert "purge: removed 3 (now 1)" in result.output + assert "erase tenant: removed 1 (now 0)" in result.output + + +def test_compliance_report_from_config(tmp_path: Path) -> None: + cfg = tmp_path / "rag.yaml" + cfg.write_text( + """ +version: "1" +compliance: + enabled: true + region: eu-west-1 + retention: + feedback_days: 30 + provenance_days: 90 +audit: + enabled: true +acl: + enabled: true +""", + encoding="utf-8", + ) + result = runner.invoke(app, ["compliance", "report", "-f", str(cfg)]) + assert result.exit_code == 0, result.output + assert "data region: eu-west-1" in result.output + assert "acl, audit" in result.output # enabled controls + assert "retention" in result.output + assert "feedback=30d" in result.output + assert "provenance=90d" in result.output diff --git a/pyproject.toml b/pyproject.toml index 5a43233..403c4ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ members = [ "packages/agent", "packages/webhooks", "packages/sso", + "packages/compliance", "apps/gateway", "sdks/python", ] @@ -214,7 +215,7 @@ ignore_errors = true [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests", "packages"] -pythonpath = ["packages/core/src", "packages/config/src", "packages/observability/src", "packages/policy/src", "packages/ragctl/src", "packages/backends/src", "packages/parsers/src", "packages/ocr/src", "packages/chunker/src", "packages/enricher/src", "packages/pii/src", "packages/embedders/src", "packages/ingest/src", "packages/retrieval/src", "packages/query/src", "packages/reranker/src", "packages/packer/src", "packages/graphrag/src", "packages/cache/src", "packages/guard/src", "packages/breaker/src", "packages/quota/src", "packages/provenance/src", "packages/feedback/src", "packages/drift/src", "packages/agent/src", "packages/webhooks/src", "packages/sso/src", "apps/gateway/src", "."] +pythonpath = ["packages/core/src", "packages/config/src", "packages/observability/src", "packages/policy/src", "packages/ragctl/src", "packages/backends/src", "packages/parsers/src", "packages/ocr/src", "packages/chunker/src", "packages/enricher/src", "packages/pii/src", "packages/embedders/src", "packages/ingest/src", "packages/retrieval/src", "packages/query/src", "packages/reranker/src", "packages/packer/src", "packages/graphrag/src", "packages/cache/src", "packages/guard/src", "packages/breaker/src", "packages/quota/src", "packages/provenance/src", "packages/feedback/src", "packages/drift/src", "packages/agent/src", "packages/webhooks/src", "packages/sso/src", "packages/compliance/src", "apps/gateway/src", "."] # spi_signature.py is the SPI signature linter (Step 1.1a); referenced by name in # docs/architecture/request-context.md. Collected alongside test_*.py files. # coverage.py is the PolicyEngine coverage linter (Step 1.1c). diff --git a/tests/contract/test_retention_purge.py b/tests/contract/test_retention_purge.py new file mode 100644 index 0000000..0ddd93b --- /dev/null +++ b/tests/contract/test_retention_purge.py @@ -0,0 +1,109 @@ +"""Conformance suite for the retention-purge SPI methods (Step 6.10). + +Exercises ``purge_before`` (age-based retention) and ``purge_tenant`` (GDPR +right-to-erasure) on the in-memory Feedback + Provenance stores: tenant-scoped, +age-correct, and isolated. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from rag_core.spi.noop import NoopFeedbackStore, NoopProvenanceStore +from rag_core.types import ( + FeedbackRecord, + FeedbackSignal, + ProvenanceRecord, + RequestContext, + RequestId, + SignedProvenanceRecord, +) + +pytestmark = pytest.mark.contract + +_NOW = datetime(2026, 6, 8, tzinfo=UTC) + + +def _feedback(ctx: RequestContext, fid: str, *, age_days: int) -> FeedbackRecord: + return FeedbackRecord( + feedback_id=fid, + request_id=RequestId(fid), + tenant_id=ctx.tenant_id, + signal=FeedbackSignal.thumb_up, + created_at=_NOW - timedelta(days=age_days), + ) + + +def _provenance(ctx: RequestContext, rid: str, *, age_days: int) -> SignedProvenanceRecord: + return SignedProvenanceRecord( + record=ProvenanceRecord( + request_id=RequestId(rid), + trace_id=rid, + tenant_id=ctx.tenant_id, + created_at=_NOW - timedelta(days=age_days), + ) + ) + + +async def test_feedback_purge_before_age(ctx: RequestContext) -> None: + store = NoopFeedbackStore() + await store.put(ctx, _feedback(ctx, "old", age_days=100)) + await store.put(ctx, _feedback(ctx, "new", age_days=1)) + + cutoff = _NOW - timedelta(days=30) + purged = await store.purge_before(ctx, cutoff) + assert purged == 1 + remaining = await store.list(ctx) + assert [r.feedback_id for r in remaining] == ["new"] + + +async def test_feedback_purge_tenant_erasure( + ctx: RequestContext, other_ctx: RequestContext +) -> None: + store = NoopFeedbackStore() + await store.put(ctx, _feedback(ctx, "a", age_days=1)) + await store.put(ctx, _feedback(ctx, "b", age_days=1)) + await store.put(other_ctx, _feedback(other_ctx, "c", age_days=1)) + + purged = await store.purge_tenant(ctx) + assert purged == 2 + assert await store.list(ctx) == [] + # Other tenant untouched (erasure is tenant-scoped). + assert len(await store.list(other_ctx)) == 1 + + +async def test_feedback_purge_is_tenant_scoped( + ctx: RequestContext, other_ctx: RequestContext +) -> None: + store = NoopFeedbackStore() + await store.put(ctx, _feedback(ctx, "mine", age_days=100)) + await store.put(other_ctx, _feedback(other_ctx, "theirs", age_days=100)) + # Purging ctx's old records never touches another tenant's. + assert await store.purge_before(ctx, _NOW) == 1 + assert len(await store.list(other_ctx)) == 1 + + +async def test_provenance_purge_before_and_tenant( + ctx: RequestContext, other_ctx: RequestContext +) -> None: + store = NoopProvenanceStore() + await store.put(ctx, _provenance(ctx, "old", age_days=200)) + await store.put(ctx, _provenance(ctx, "new", age_days=1)) + await store.put(other_ctx, _provenance(other_ctx, "x", age_days=200)) + + assert await store.purge_before(ctx, _NOW - timedelta(days=90)) == 1 + assert await store.get(ctx, RequestId("new")) is not None + assert await store.get(ctx, RequestId("old")) is None + # Other tenant's old record survives ctx's purge. + assert await store.get(other_ctx, RequestId("x")) is not None + + assert await store.purge_tenant(ctx) == 1 + assert await store.get(ctx, RequestId("new")) is None + + +async def test_purge_default_noop_on_base() -> None: + # The ABC default returns 0 (backward-compatible for non-retention backends). + from rag_core.spi.feedback_store import FeedbackStore + + assert FeedbackStore.purge_tenant.__doc__ is not None # method exists on the ABC diff --git a/tests/logs/test_event_schema.py b/tests/logs/test_event_schema.py index 87f15f5..f7ef0a7 100644 --- a/tests/logs/test_event_schema.py +++ b/tests/logs/test_event_schema.py @@ -13,6 +13,9 @@ EVT_CACHE_HIT, EVT_CACHE_INVALIDATED, EVT_CACHE_MISS, + EVT_COMPLIANCE_DATA_PURGED, + EVT_COMPLIANCE_RESIDENCY_VIOLATION, + EVT_COMPLIANCE_TENANT_ERASED, EVT_DRIFT_DETECTED, EVT_FALLBACK_ENGAGED, EVT_FEEDBACK_RECORD_DEGRADED, @@ -37,6 +40,7 @@ EVT_SSO_AUTHENTICATED, BreakerEvent, CacheEvent, + ComplianceEvent, GuardEvent, IngestEvent, PiiEvent, @@ -79,6 +83,9 @@ EVT_SCIM_USER_PROVISIONED, EVT_SCIM_USER_DEPROVISIONED, EVT_SCIM_GROUP_CHANGED, + EVT_COMPLIANCE_DATA_PURGED, + EVT_COMPLIANCE_TENANT_ERASED, + EVT_COMPLIANCE_RESIDENCY_VIOLATION, ] @@ -423,3 +430,37 @@ def test_carries_no_identifying_attributes(self) -> None: active=False, ) assert check_pii(evt.model_dump_json()) == [] + + +class TestComplianceEvent: + def test_valid_compliance_event(self) -> None: + evt = ComplianceEvent( + event_name=EVT_COMPLIANCE_DATA_PURGED, + tenant_id="acme", + operation="purge", + purged_total=12, + data_classes="feedback,provenance", + ) + assert evt.operation == "purge" + assert evt.purged_total == 12 + + def test_residency_violation_is_pii_free(self) -> None: + for field in ("subject", "email", "document", "query", "user_name"): + assert field not in ComplianceEvent.model_fields + evt = ComplianceEvent( + event_name=EVT_COMPLIANCE_RESIDENCY_VIOLATION, + tenant_id="acme", + operation="residency_violation", + region="us-east-1", + tenant_region="eu-west-1", + ) + assert check_pii(evt.model_dump_json()) == [] + + def test_erasure_event(self) -> None: + evt = ComplianceEvent( + event_name=EVT_COMPLIANCE_TENANT_ERASED, + tenant_id="acme", + operation="erase", + purged_total=5, + ) + assert evt.operation == "erase" diff --git a/uv.lock b/uv.lock index 439abbf..909a4fc 100644 --- a/uv.lock +++ b/uv.lock @@ -26,6 +26,7 @@ members = [ "rag-breaker", "rag-cache", "rag-chunker", + "rag-compliance", "rag-config", "rag-core", "rag-drift", @@ -7319,6 +7320,32 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "rag-compliance" +version = "0.1.0" +source = { editable = "packages/compliance" } +dependencies = [ + { name = "rag-core" }, + { name = "rag-observability" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3" }, + { name = "rag-core", editable = "packages/core" }, + { name = "rag-observability", editable = "packages/observability" }, +] +provides-extras = ["dev"] + [[package]] name = "rag-config" version = "0.3.0" @@ -7507,6 +7534,7 @@ dependencies = [ { name = "rag-breaker" }, { name = "rag-cache" }, { name = "rag-chunker" }, + { name = "rag-compliance" }, { name = "rag-config" }, { name = "rag-core" }, { name = "rag-drift" }, @@ -7551,6 +7579,7 @@ requires-dist = [ { name = "rag-breaker", editable = "packages/breaker" }, { name = "rag-cache", editable = "packages/cache" }, { name = "rag-chunker", editable = "packages/chunker" }, + { name = "rag-compliance", editable = "packages/compliance" }, { name = "rag-config", editable = "packages/config" }, { name = "rag-core", editable = "packages/core" }, { name = "rag-drift", editable = "packages/drift" }, @@ -7920,6 +7949,7 @@ dependencies = [ { name = "rag-backends" }, { name = "rag-breaker" }, { name = "rag-chunker" }, + { name = "rag-compliance" }, { name = "rag-config" }, { name = "rag-core" }, { name = "rag-drift" }, @@ -7952,6 +7982,7 @@ requires-dist = [ { name = "rag-backends", editable = "packages/backends" }, { name = "rag-breaker", editable = "packages/breaker" }, { name = "rag-chunker", editable = "packages/chunker" }, + { name = "rag-compliance", editable = "packages/compliance" }, { name = "rag-config", editable = "packages/config" }, { name = "rag-core", editable = "packages/core" }, { name = "rag-drift", editable = "packages/drift" },