From 53de40f2a8f6d1cc1a04bd3d444ad680b1556a2c Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 8 Jun 2026 02:58:48 +0530 Subject: [PATCH] feat(gateway): audit read API + chain verification (Step 6.6a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the Step 0.7c hash-chain audit log usable + provably intact. build_app now creates one AuditWriter over a tamper-evident SHA-256 hash-chain store, exposes app.state.audit_store / audit_writer / audit_enabled, and hands the same writer to the corpus router — so the events the writers append land in exactly the store the read API serves (previously the corpus router held a private store the gateway couldn't read). New AuditWriter.store property is the read accessor. GET /v1/audit is tenant-scoped (a principal sees only its own tenant's events, newest-first, with chain_verified inline), bounded by limit and filterable by action / outcome. GET /v1/audit/verify reports whole-log hash-chain integrity {ok, event_count} — content-free, so global verification leaks nothing cross-tenant. The chain is the tamper-evidence mechanism; immutability at rest (the WORM signed export) is Step 6.6b. Read API on by default via cfg.audit.enabled (a passive, tenant-scoped compliance record, unlike the behaviour-changing ACL/PII toggles); off → 404, events still recorded. Adds AuditListResponse / AuditVerifyResponse wire types + AuditError / AuditNotFoundError (→404). First of two slices for Step 6.6. Co-Authored-By: Claude Opus 4.7 --- TRACKER.md | 19 +- apps/gateway/src/rag_gateway/app.py | 18 +- apps/gateway/src/rag_gateway/audit.py | 115 +++++++++ apps/gateway/src/rag_gateway/query.py | 4 + apps/gateway/src/rag_gateway/wiring.py | 5 + apps/gateway/tests/test_audit.py | 130 ++++++++++ dist/openapi.json | 259 ++++++++++++++++++++ dist/openapi.yaml | 201 +++++++++++++++ dist/rag.schema.json | 16 ++ dist/rag.schema.yaml | 32 +++ docs/README.md | 3 + docs/adr/ADR-0038-immutable-audit-log.md | 83 +++++++ docs/architecture/audit-log.md | 91 +++++++ docs/reference/audit.md | 105 ++++++++ packages/config/src/rag_config/schema.py | 19 ++ packages/core/src/rag_core/audit.py | 5 + packages/core/src/rag_core/errors.py | 20 ++ packages/core/src/rag_core/gateway_types.py | 38 +++ 18 files changed, 1160 insertions(+), 3 deletions(-) create mode 100644 apps/gateway/src/rag_gateway/audit.py create mode 100644 apps/gateway/tests/test_audit.py create mode 100644 docs/adr/ADR-0038-immutable-audit-log.md create mode 100644 docs/architecture/audit-log.md create mode 100644 docs/reference/audit.md diff --git a/TRACKER.md b/TRACKER.md index fe0f82a..7a5ac21 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -16,10 +16,11 @@ | **Last updated** | 2026-06-08 | | **Current phase** | Phase 6 — Governance & Tenancy (**5 / 10 steps**) | | **Overall** | **69 / 84 steps** — Phases 0–5 complete | -| **Next action** | **Step 6.6 — Immutable audit log**: hash-chain audit log with tamper-evident verification + WORM export; `GET /v1/audit`. Builds on the Step 0.7c `AuditStore` SPI + `AuditWriter` (SHA-256 hash chain) already in `rag-core`. | +| **Next action** | **Step 6.6b — WORM signed audit export**: an HMAC-signed export bundle over the events + chain head (reusing the `ProvenanceSigner` pattern), `POST /v1/audit/export` + `ragctl audit export/verify` → immutability at rest (write to S3 Object Lock). Completes Step 6.6 (6.6a — the read API + chain verification — shipped). | **Recently shipped** +- **6.6a** 🚧 Audit read API + chain verification — shared `AuditWriter`/store on `app.state`; `GET /v1/audit` (tenant-scoped, newest-first, `chain_verified`) + `GET /v1/audit/verify` (whole-log integrity); `cfg.audit.enabled` (default on); first slice of Step 6.6 (WORM export is 6.6b) — [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153) - **6.5** ✅ PII egress policies — `PiiPolicyEngine` (`rag-pii`) answers `egress_text` over the context (`list[Chunk]`) + agent answer (`str`) the gateway already passes, applying the per-tenant `pii_policy` (allow / redact / mask / block); reuses the Step 1.7 detector + rewriters; opt-in `cfg.pii.enabled`; emits `pii.egress_blocked` — [#152](https://github.com/officialCodeWork/AgentContextOS/pull/152) - **6.4** ✅ ACL egress verifier — `AclEgressVerifier` re-checks returned `ChunkRef`s against the principal's labels at the gateway router boundary (defense-in-depth, same overlap semantics, independent of the PDP); `acl.egress_violation` event; red-team zero-violation-rate gate — [#151](https://github.com/officialCodeWork/AgentContextOS/pull/151) - **6.3** ✅ ACL push-down — opt-in `AclPolicyEngine` And-merges `any_in("acl_labels", principal.acl_labels)` into every `read_chunk` query (overlap, fail-closed); `acl.egress_denied` event — [#150](https://github.com/officialCodeWork/AgentContextOS/pull/150) @@ -653,7 +654,7 @@ | 6.3 | ACL push-down at retrieval | ✅ | [#150](https://github.com/officialCodeWork/AgentContextOS/pull/150) — opt-in `AclPolicyEngine` And-merges `any_in("acl_labels", …)` into every `read_chunk` push-down (overlap, fail-closed); `acl.egress_denied` | | 6.4 | ACL egress verifier | ✅ | [#151](https://github.com/officialCodeWork/AgentContextOS/pull/151) — `AclEgressVerifier` re-checks returned chunks at the gateway router boundary (defense-in-depth above the 6.3 push-down); `acl.egress_violation`; zero-violation-rate red-team gate | | 6.5 | PII policies | ✅ | [#152](https://github.com/officialCodeWork/AgentContextOS/pull/152) — `PiiPolicyEngine` egress_text decorator (allow / redact / mask / block per tenant) over answer + context; reuses Step 1.7 detector; `pii.egress_blocked` | -| 6.6 | Immutable audit log | ⏳ | Hash-chain audit log; WORM export; tamper-evident verification; `GET /v1/audit` | +| 6.6 | Immutable audit log | 🚧 | **6.6a** ✅ [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153) — read API `GET /v1/audit` (tenant-scoped) + `GET /v1/audit/verify` (whole-log chain) + shared store wiring + `cfg.audit`. **6.6b** ⏳ — WORM signed export | | 6.7 | BYOK (Bring Your Own Key) | ⏳ | KMS integration (AWS KMS, GCP KMS, HashiCorp Vault); envelope encryption for embeddings | | 6.8 | SSO / SCIM | ⏳ | OIDC + SAML IdP federation; SCIM 2.0 user provisioning; per-tenant IdP config | | 6.9 | Air-gapped install bundle | ⏳ | Signed tarball with all images + Helm chart; offline bootstrap; cosign verification | @@ -705,6 +706,19 @@ - **Opt-in** via new `cfg.pii.enabled` (default off — it can redact / withhold content); `build_app(pii_enabled=…, pii_detector=…)` wraps the engine after the acl wrap, `build_app_from_config` from config; `app.state.pii_enabled` reports the state. Events are **PII-free**: `pii.egress_blocked` (block, the alertable denial — the reserved event from Step 0.7b now emitted) / `pii.detected` (redact/mask) carry entity *types* + counts + surface, never the matched values - **Scope:** enforces at the existing `egress_text` sites (context for query/OpenAI/gRPC, answer for the agent). **Deferred:** a post-generation answer re-check for the query/OpenAI/gRPC generate paths (they sanitise the *context* pre-LLM) and a PII check on retrieval-only citations — stored chunks are already ingest-sanitised (1.7). `PiiConfig` → `rag.schema` regenerated; ~24 new tests (engine unit: str/chunks × allow/redact/mask/block, min_score/entities, events, delegation; gateway wiring + composition with ACL + behavioral redact/block); all gates green (ruff, mypy --strict 297 files, RAG001, schema-drift, policy-coverage, log-schema/PII gate). [ADR-0037](docs/adr/ADR-0037-pii-egress-policies.md), [architecture/pii.md](docs/architecture/pii.md), [architecture/policy-engine.md](docs/architecture/policy-engine.md), [reference/pii.md](docs/reference/pii.md) +### 6.6 — Immutable audit log 🚧 (sliced 6.6a + 6.6b) + +Step 0.7c shipped the foundation — an append-only `AuditStore` SPI, a `NoopAuditStore` that links events into a **SHA-256 hash chain**, and an `AuditWriter` facade — but the log was **write-only** (only the corpus router wrote `corpus.route`, into a store nothing could read or verify). Step 6.6 makes it usable + provably intact, delivered as two vertical slices. + +#### 6.6a — Audit read API + chain verification ✅ [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153) + +- **One shared audit store.** `build_app` now creates a single `AuditWriter` over a hash-chain store, exposes **`app.state.audit_store` / `audit_writer` / `audit_enabled`**, and hands the *same* writer to the corpus router — so the events the writers append land in exactly the store the read API serves (before this, the corpus router held a private `NoopAuditStore` the gateway couldn't read). New `AuditWriter.store` property is the read accessor; default store is the in-memory `NoopAuditStore` (creds-free chain), production injects a durable one +- **`GET /v1/audit`** — **tenant-scoped** (a principal sees only its own tenant's events, filtered by `ctx.tenant_id` at the boundary — same pattern as `GET /v1/query/{id}/trace`), newest-first, bounded by `limit` (clamped `[1,1000]`), optional `action` / `outcome` filters. The `AuditListResponse` carries **`chain_verified`** so one read both returns the events and attests the log is untampered +- **`GET /v1/audit/verify`** — **whole-log** hash-chain integrity (the chain is one global sequence across tenants), returning `{ok, event_count}` — a boolean + total, no event content, so global verification leaks nothing cross-tenant +- **The hash chain is the tamper-*evidence* mechanism** (no second scheme): `verify_chain()` recomputes every link and fails if any event or stored hash was altered. Immutability *at rest* (preventing deletion/replacement of the store) is the 6.6b WORM export's job +- **Read API on by default**: new `cfg.audit.enabled` (default **true**) — unlike the behaviour-changing ACL / PII toggles, the audit log is a passive, tenant-scoped compliance record, so exposing it out of the box is the expected enterprise default; off → endpoints 404 (`AuditNotFoundError`), events still recorded. New `AuditListResponse` / `AuditVerifyResponse` wire types (`rag_core.gateway_types`); `AuditError` / `AuditNotFoundError` (→ 404); `dist/openapi` + `dist/rag.schema` regenerated +- **Scope:** read + verify only (WORM signed export is 6.6b); today the populated event is `corpus.route` (every query) — expanding audit coverage (ACL / PII / ingest decisions) is a follow-up, the surface + chain are in place for it. ~10 gateway tests (tenant-scoped list, **cross-tenant isolation**, verify ok + **tamper detection**, filters/pagination, disabled→404, no-auth→401, shared-store wiring); all gates green (ruff, mypy --strict 298 files, RAG001, schema/openapi-drift, policy-coverage, log-schema). [ADR-0038](docs/adr/ADR-0038-immutable-audit-log.md), [architecture/audit-log.md](docs/architecture/audit-log.md), [reference/audit.md](docs/reference/audit.md) + --- ## Phase 7 — Pilot, Harden, GA (Weeks 34–40) ⏳ @@ -861,6 +875,7 @@ Complete log of every PR. Routine Dependabot bumps are grouped; everything else | [#150](https://github.com/officialCodeWork/AgentContextOS/pull/150) | 2026-06-08 | feat(policy): ACL push-down at retrieval — AclPolicyEngine (Step 6.3) | | [#151](https://github.com/officialCodeWork/AgentContextOS/pull/151) | 2026-06-08 | feat(policy): ACL egress verifier — defense-in-depth re-check (Step 6.4) | | [#152](https://github.com/officialCodeWork/AgentContextOS/pull/152) | 2026-06-08 | feat(pii): PII egress policies — PiiPolicyEngine egress_text decorator (Step 6.5) | +| [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153) | 2026-06-08 | feat(gateway): audit read API + chain verification (Step 6.6a) | | #78–#80, #116–#118 | Open | Dependabot bumps — awaiting merge | | #81 | Closed | Dependabot bump — superseded | diff --git a/apps/gateway/src/rag_gateway/app.py b/apps/gateway/src/rag_gateway/app.py index 496382e..5a10635 100644 --- a/apps/gateway/src/rag_gateway/app.py +++ b/apps/gateway/src/rag_gateway/app.py @@ -113,6 +113,7 @@ from rag_webhooks import build_default_dispatcher, ingest_completed_event from rag_gateway.agent import build_default_agent_loop, make_agent_router +from rag_gateway.audit import make_audit_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 @@ -372,6 +373,7 @@ def build_app( acl_verify_egress: bool = True, pii_enabled: bool = False, pii_detector: Any | None = None, + audit_enabled: bool = True, enable_cors: bool = True, default_tenant_id: TenantId | None = None, ) -> FastAPI: @@ -464,9 +466,20 @@ def build_app( app.state.llm = llm if llm is not None else NoopLLM() app.state.embedder = embedder or build_default_embedder() app.state.corpus_store = corpus_store or NoopCorpusStore() + # Immutable audit log (Step 6.6) — one AuditWriter over a tamper-evident + # SHA-256 hash-chain store, shared by the writers (the corpus router today) + # and the GET /v1/audit read API. Defaults to an in-memory NoopAuditStore so + # the chain is exercised creds-free; production injects a durable store. + if audit_writer is None: + audit_writer = AuditWriter(NoopAuditStore()) + app.state.audit_writer = audit_writer + app.state.audit_store = audit_writer.store + app.state.audit_enabled = audit_enabled # Corpus router (Step 3.5) sits above the retrieval router; with the # default empty corpus store it degrades to a single unconstrained - # retrieval, preserving the pre-3.5 demo behaviour. + # retrieval, preserving the pre-3.5 demo behaviour. It shares the audit + # writer above so its ``corpus.route`` events land in the same chain the + # read API serves. app.state.corpus_router = corpus_router or build_default_corpus_router( corpus_store=app.state.corpus_store, retrieval_router=app.state.retrieval_router, @@ -701,6 +714,8 @@ async def info() -> dict[str, Any]: "GET /v1/status/drift", "GET /v1/status/logs", "GET /v1/status/logs/stream", + "GET /v1/audit", + "GET /v1/audit/verify", "WS /v1/status/ws", "GET /v1/connectors/status", "GET /healthz", @@ -786,6 +801,7 @@ async def ingest_document( app.include_router(make_webhooks_router()) app.include_router(make_feedback_router()) app.include_router(make_status_router()) + app.include_router(make_audit_router()) return app diff --git a/apps/gateway/src/rag_gateway/audit.py b/apps/gateway/src/rag_gateway/audit.py new file mode 100644 index 0000000..43aa1dc --- /dev/null +++ b/apps/gateway/src/rag_gateway/audit.py @@ -0,0 +1,115 @@ +"""Audit-log read API — ``GET /v1/audit`` + ``/v1/audit/verify`` (Step 6.6). + +Exposes the tamper-evident SHA-256 hash-chain audit store (Step 0.7c) over HTTP: + +* ``GET /v1/audit`` — the **calling tenant's own** audit events, newest-first, + bounded by ``limit`` and optionally filtered by ``action`` / ``outcome``. A + tenant never sees another tenant's events. The response carries + ``chain_verified`` so a single read both returns the events and attests that + the underlying log is untampered. +* ``GET /v1/audit/verify`` — whole-log hash-chain integrity (the chain links all + events into one sequence, so verification is inherently global). + +Both require tenant identity (``Authorization`` / ``X-Tenant-Id`` headers) and +return 404 when the read surface is disabled (``cfg.audit.enabled = false``). +The store is read off ``app.state.audit_store`` — the same instance the writers +(the corpus router today) append to. The signed WORM export is Step 6.6b. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Request +from rag_core.errors import AuditNotFoundError, AuthError +from rag_core.gateway_types import AuditListResponse, AuditVerifyResponse, GatewayError +from rag_core.spi.audit_store import AuditStore +from rag_core.types import AuditEvent, RequestContext + +_MAX_LIMIT = 1000 + +__all__ = ["make_audit_router"] + + +def _require_ctx(request: Request) -> RequestContext: + """Pull the per-request RequestContext off ``request.state.gateway``.""" + gateway = getattr(request.state, "gateway", None) + if gateway is None: + raise AuthError( + "request did not pass through the gateway middleware — server misconfigured" + ) + 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 _require_store(request: Request) -> AuditStore: + """Return the wired audit store, or 404 when the read surface is disabled.""" + state = request.app.state + if not getattr(state, "audit_enabled", False): + raise AuditNotFoundError("the audit-log read API is disabled on this gateway") + store: AuditStore | None = getattr(state, "audit_store", None) + if store is None: + raise AuditNotFoundError("no audit store is wired on this gateway") + return store + + +def make_audit_router() -> APIRouter: + """Build the audit read-API router (Step 6.6).""" + router = APIRouter(tags=["audit"]) + + @router.get( + "/v1/audit", + response_model=AuditListResponse, + responses={ + 401: {"model": GatewayError, "description": "Missing or invalid auth"}, + 404: {"model": GatewayError, "description": "Audit read API disabled"}, + }, + summary="The tenant's own audit events, newest-first (Step 6.6)", + ) + async def list_audit( + request: Request, + limit: int = 100, + action: str | None = None, + outcome: str | None = None, + ) -> AuditListResponse: + """Return the calling tenant's audit events (tamper-evident hash chain).""" + ctx = _require_ctx(request) + store = _require_store(request) + + # Tenant isolation: only the caller's own events ever leave the boundary. + mine = [e for e in store.events() if e.tenant_id == ctx.tenant_id] + total = len(mine) + filtered: list[AuditEvent] = [ + e + for e in mine + if (action is None or e.action == action) + and (outcome is None or e.outcome.value == outcome) + ] + bounded = max(1, min(limit, _MAX_LIMIT)) + newest_first = list(reversed(filtered))[:bounded] + return AuditListResponse( + tenant_id=ctx.tenant_id, + events=newest_first, + returned=len(newest_first), + total=total, + chain_verified=store.verify_chain(), + ) + + @router.get( + "/v1/audit/verify", + response_model=AuditVerifyResponse, + responses={ + 401: {"model": GatewayError, "description": "Missing or invalid auth"}, + 404: {"model": GatewayError, "description": "Audit read API disabled"}, + }, + summary="Whole-log hash-chain integrity check (Step 6.6)", + ) + async def verify_audit(request: Request) -> AuditVerifyResponse: + """Verify the whole-log SHA-256 hash chain — True iff no link was altered.""" + _require_ctx(request) + store = _require_store(request) + return AuditVerifyResponse(ok=store.verify_chain(), event_count=len(store.events())) + + return router diff --git a/apps/gateway/src/rag_gateway/query.py b/apps/gateway/src/rag_gateway/query.py index eb3a643..c026bca 100644 --- a/apps/gateway/src/rag_gateway/query.py +++ b/apps/gateway/src/rag_gateway/query.py @@ -34,6 +34,7 @@ from rag_core import get_logger from rag_core.errors import ( ACLDeniedError, + AuditNotFoundError, AuthError, ProvenanceNotFoundError, RagError, @@ -103,6 +104,9 @@ def _http_status_for(exc: BaseException) -> int: if isinstance(exc, ProvenanceNotFoundError): # No provenance record for this query id (unknown / evicted / other tenant). return 404 + if isinstance(exc, AuditNotFoundError): + # Audit read API disabled / no store wired (Step 6.6). + return 404 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/wiring.py b/apps/gateway/src/rag_gateway/wiring.py index b666463..0ccb2d6 100644 --- a/apps/gateway/src/rag_gateway/wiring.py +++ b/apps/gateway/src/rag_gateway/wiring.py @@ -710,6 +710,10 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI: # Per-tenant PII enforcement at egress (Step 6.5) — opt-in; off by default. pii_enabled = overrides.pop("pii_enabled", cfg.pii.enabled) + # Immutable audit-log read API (Step 6.6) — on by default (tenant-scoped, + # passive compliance record); flip off to withhold the HTTP read surface. + audit_enabled = overrides.pop("audit_enabled", cfg.audit.enabled) + return build_app( corpus_store=corpus_store, retrieval_router=retrieval_router, @@ -722,6 +726,7 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI: acl_enabled=acl_enabled, acl_verify_egress=acl_verify_egress, pii_enabled=pii_enabled, + audit_enabled=audit_enabled, **overrides, ) diff --git a/apps/gateway/tests/test_audit.py b/apps/gateway/tests/test_audit.py new file mode 100644 index 0000000..fe4d84a --- /dev/null +++ b/apps/gateway/tests/test_audit.py @@ -0,0 +1,130 @@ +"""Tests for the audit-log read API — GET /v1/audit + /v1/audit/verify (Step 6.6). + +Drives the default noop gateway: every /v1/query writes a ``corpus.route`` audit +event into the shared tamper-evident store, which the read API then serves. +Covers the tenant-scoped list, cross-tenant isolation, whole-log chain +verification (+ tamper detection), filters/pagination, the disabled surface, and +no-auth. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient +from rag_gateway import build_app + +_ACME = {"X-Tenant-Id": "acme", "X-Principal-Id": "alice"} +_GLOBEX = {"X-Tenant-Id": "globex", "X-Principal-Id": "mallory"} + + +def _client(*, audit_enabled: bool = True) -> TestClient: + return TestClient(build_app(audit_enabled=audit_enabled)) + + +def _post_query(client: TestClient, headers: dict[str, str], q: str = "anything") -> None: + # Vary the query text per call: identical queries hit the retrieval cache and + # short-circuit before the corpus router, so no fresh audit event is written. + r = client.post( + "/v1/query", + json={ + "tenant_id": headers["X-Tenant-Id"], + "principal_id": headers["X-Principal-Id"], + "query": q, + }, + ) + assert r.status_code == 200, r.text + + +def test_query_produces_audit_event() -> None: + client = _client() + _post_query(client, _ACME) + r = client.get("/v1/audit", headers=_ACME) + assert r.status_code == 200, r.text + body = r.json() + assert body["tenant_id"] == "acme" + assert body["returned"] >= 1 + actions = {e["action"] for e in body["events"]} + assert "corpus.route" in actions + assert all(e["tenant_id"] == "acme" for e in body["events"]) + assert body["chain_verified"] is True + + +def test_list_is_newest_first() -> None: + client = _client() + _post_query(client, _ACME, q="first query") + _post_query(client, _ACME, q="second query") + events = client.get("/v1/audit", headers=_ACME).json()["events"] + assert len(events) >= 2 + timestamps = [e["timestamp"] for e in events] + assert timestamps == sorted(timestamps, reverse=True) # descending = newest-first + + +def test_cross_tenant_isolation() -> None: + client = _client() + _post_query(client, _ACME) # only acme has activity + r = client.get("/v1/audit", headers=_GLOBEX) + assert r.status_code == 200 + body = r.json() + # globex must never see acme's events + assert body["returned"] == 0 + assert body["events"] == [] + + +def test_verify_ok() -> None: + client = _client() + _post_query(client, _ACME) + r = client.get("/v1/audit/verify", headers=_ACME) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["event_count"] >= 1 + + +def test_verify_detects_tampering() -> None: + app = build_app(audit_enabled=True) + client = TestClient(app) + _post_query(client, _ACME) + # Simulate tampering: corrupt a stored chain hash directly in the noop store. + app.state.audit_store._hashes[0] = "deadbeef" # noqa: SLF001 — test-only tamper + assert client.get("/v1/audit/verify", headers=_ACME).json()["ok"] is False + # the list response also reflects the broken chain + assert client.get("/v1/audit", headers=_ACME).json()["chain_verified"] is False + + +def test_filter_by_action_and_outcome() -> None: + client = _client() + _post_query(client, _ACME) + assert client.get("/v1/audit?action=corpus.route", headers=_ACME).json()["returned"] >= 1 + assert client.get("/v1/audit?action=nope", headers=_ACME).json()["returned"] == 0 + assert client.get("/v1/audit?outcome=allowed", headers=_ACME).json()["returned"] >= 1 + assert client.get("/v1/audit?outcome=denied", headers=_ACME).json()["returned"] == 0 + + +def test_limit_caps_returned_but_reports_total() -> None: + client = _client() + for i in range(3): + _post_query(client, _ACME, q=f"query {i}") + body = client.get("/v1/audit?limit=1", headers=_ACME).json() + assert body["returned"] == 1 + assert body["total"] >= 3 + + +def test_disabled_is_404() -> None: + client = _client(audit_enabled=False) + _post_query(client, _ACME) # events still recorded, just not served + r = client.get("/v1/audit", headers=_ACME) + assert r.status_code == 404 + assert r.json()["error"]["code"] == "audit_not_found" + assert client.get("/v1/audit/verify", headers=_ACME).status_code == 404 + + +def test_no_auth_is_401() -> None: + client = _client() + assert client.get("/v1/audit").status_code == 401 + assert client.get("/v1/audit/verify").status_code == 401 + + +def test_default_build_app_enables_audit() -> None: + app = build_app() + assert app.state.audit_enabled is True + # the read API + the corpus router share one store + assert app.state.audit_store is app.state.audit_writer.store diff --git a/dist/openapi.json b/dist/openapi.json index d034717..a711382 100644 --- a/dist/openapi.json +++ b/dist/openapi.json @@ -272,6 +272,127 @@ "title": "Answer", "type": "object" }, + "AuditEvent": { + "description": "Immutable record of a security-relevant or compliance-relevant action.\n\nWritten by every pipeline stage that touches tenant data. The hash-chain\nverification (Step 0.7c) links consecutive events to detect tampering.", + "properties": { + "action": { + "title": "Action", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "metadata": { + "additionalProperties": true, + "title": "Metadata", + "type": "object" + }, + "outcome": { + "$ref": "#/components/schemas/AuditOutcome" + }, + "principal_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Principal Id" + }, + "resource": { + "title": "Resource", + "type": "string" + }, + "tenant_id": { + "title": "Tenant Id", + "type": "string" + }, + "timestamp": { + "format": "date-time", + "title": "Timestamp", + "type": "string" + }, + "trace_context": { + "$ref": "#/components/schemas/TraceContext" + } + }, + "required": [ + "tenant_id", + "action", + "resource", + "outcome", + "trace_context" + ], + "title": "AuditEvent", + "type": "object" + }, + "AuditListResponse": { + "description": "``GET /v1/audit`` response — the tenant's audit events (Step 6.6).\n\nReturns the calling tenant's :class:`AuditEvent` records **newest-first**\n(only its own — never another tenant's), bounded by the ``limit`` query\nparam. ``chain_verified`` reports whether the **whole-log** SHA-256 hash\nchain still validates at read time, so a consumer sees in one call both the\nevents and that the underlying log is tamper-free. ``returned`` is the page\nsize; ``total`` is how many events the tenant has before the limit.", + "properties": { + "chain_verified": { + "default": true, + "title": "Chain Verified", + "type": "boolean" + }, + "events": { + "items": { + "$ref": "#/components/schemas/AuditEvent" + }, + "title": "Events", + "type": "array" + }, + "returned": { + "default": 0, + "title": "Returned", + "type": "integer" + }, + "tenant_id": { + "title": "Tenant Id", + "type": "string" + }, + "total": { + "default": 0, + "title": "Total", + "type": "integer" + } + }, + "required": [ + "tenant_id" + ], + "title": "AuditListResponse", + "type": "object" + }, + "AuditOutcome": { + "enum": [ + "allowed", + "denied", + "error" + ], + "title": "AuditOutcome", + "type": "string" + }, + "AuditVerifyResponse": { + "description": "``GET /v1/audit/verify`` response — whole-log hash-chain integrity (Step 6.6).\n\nThe hash chain links **all** events (across tenants) into one tamper-evident\nsequence, so verification is inherently whole-log: ``ok`` is True when every\nlink matches its expected SHA-256, False if any event or stored hash was\naltered. ``event_count`` is the total number of events verified.", + "properties": { + "event_count": { + "default": 0, + "title": "Event Count", + "type": "integer" + }, + "ok": { + "title": "Ok", + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "title": "AuditVerifyResponse", + "type": "object" + }, "BlobRef": { "description": "Reference to a blob stored in the ``Storage`` SPI rather than inline.\n\nUsed by chunks whose text exceeds the inline-storage threshold (see\nADR-0007 tiered storage). Callers must hydrate via ``Storage.get(uri)``\nonly when the text is actually needed.", "properties": { @@ -4120,6 +4241,144 @@ ] } }, + "/v1/audit": { + "get": { + "description": "Return the calling tenant's audit events (tamper-evident hash chain).", + "operationId": "list_audit_v1_audit_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "action", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Action" + } + }, + { + "in": "query", + "name": "outcome", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Outcome" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditListResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayError" + } + } + }, + "description": "Missing or invalid auth" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayError" + } + } + }, + "description": "Audit read API disabled" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "The tenant's own audit events, newest-first (Step 6.6)", + "tags": [ + "audit" + ] + } + }, + "/v1/audit/verify": { + "get": { + "description": "Verify the whole-log SHA-256 hash chain — True iff no link was altered.", + "operationId": "verify_audit_v1_audit_verify_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditVerifyResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayError" + } + } + }, + "description": "Missing or invalid auth" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayError" + } + } + }, + "description": "Audit read API disabled" + } + }, + "summary": "Whole-log hash-chain integrity check (Step 6.6)", + "tags": [ + "audit" + ] + } + }, "/v1/chat/completions": { "post": { "description": "RAG chat completion: retrieve → inject context → generate.\n\nSee ``docs/reference/openai-compat.md`` (``#post-v1chatcompletions``).", diff --git a/dist/openapi.yaml b/dist/openapi.yaml index 6aebdf4..45ae7b1 100644 --- a/dist/openapi.yaml +++ b/dist/openapi.yaml @@ -226,6 +226,124 @@ components: - model title: Answer type: object + AuditEvent: + description: 'Immutable record of a security-relevant or compliance-relevant + action. + + + Written by every pipeline stage that touches tenant data. The hash-chain + + verification (Step 0.7c) links consecutive events to detect tampering.' + properties: + action: + title: Action + type: string + id: + title: Id + type: string + metadata: + additionalProperties: true + title: Metadata + type: object + outcome: + $ref: '#/components/schemas/AuditOutcome' + principal_id: + anyOf: + - type: string + - type: 'null' + title: Principal Id + resource: + title: Resource + type: string + tenant_id: + title: Tenant Id + type: string + timestamp: + format: date-time + title: Timestamp + type: string + trace_context: + $ref: '#/components/schemas/TraceContext' + required: + - tenant_id + - action + - resource + - outcome + - trace_context + title: AuditEvent + type: object + AuditListResponse: + description: '``GET /v1/audit`` response — the tenant''s audit events (Step + 6.6). + + + Returns the calling tenant''s :class:`AuditEvent` records **newest-first** + + (only its own — never another tenant''s), bounded by the ``limit`` query + + param. ``chain_verified`` reports whether the **whole-log** SHA-256 hash + + chain still validates at read time, so a consumer sees in one call both the + + events and that the underlying log is tamper-free. ``returned`` is the page + + size; ``total`` is how many events the tenant has before the limit.' + properties: + chain_verified: + default: true + title: Chain Verified + type: boolean + events: + items: + $ref: '#/components/schemas/AuditEvent' + title: Events + type: array + returned: + default: 0 + title: Returned + type: integer + tenant_id: + title: Tenant Id + type: string + total: + default: 0 + title: Total + type: integer + required: + - tenant_id + title: AuditListResponse + type: object + AuditOutcome: + enum: + - allowed + - denied + - error + title: AuditOutcome + type: string + AuditVerifyResponse: + description: '``GET /v1/audit/verify`` response — whole-log hash-chain integrity + (Step 6.6). + + + The hash chain links **all** events (across tenants) into one tamper-evident + + sequence, so verification is inherently whole-log: ``ok`` is True when every + + link matches its expected SHA-256, False if any event or stored hash was + + altered. ``event_count`` is the total number of events verified.' + properties: + event_count: + default: 0 + title: Event Count + type: integer + ok: + title: Ok + type: boolean + required: + - ok + title: AuditVerifyResponse + type: object BlobRef: description: 'Reference to a blob stored in the ``Storage`` SPI rather than inline. @@ -3597,6 +3715,89 @@ paths: summary: Run an agent loop over a goal, streaming events as SSE tags: - agent + /v1/audit: + get: + description: Return the calling tenant's audit events (tamper-evident hash chain). + operationId: list_audit_v1_audit_get + parameters: + - in: query + name: limit + required: false + schema: + default: 100 + title: Limit + type: integer + - in: query + name: action + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Action + - in: query + name: outcome + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Outcome + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuditListResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayError' + description: Missing or invalid auth + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayError' + description: Audit read API disabled + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: The tenant's own audit events, newest-first (Step 6.6) + tags: + - audit + /v1/audit/verify: + get: + description: Verify the whole-log SHA-256 hash chain — True iff no link was + altered. + operationId: verify_audit_v1_audit_verify_get + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuditVerifyResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayError' + description: Missing or invalid auth + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayError' + description: Audit read API disabled + summary: Whole-log hash-chain integrity check (Step 6.6) + tags: + - audit /v1/chat/completions: post: description: 'RAG chat completion: retrieve → inject context → generate. diff --git a/dist/rag.schema.json b/dist/rag.schema.json index 0eb0527..65f1449 100644 --- a/dist/rag.schema.json +++ b/dist/rag.schema.json @@ -18,6 +18,19 @@ "title": "AclConfig", "type": "object" }, + "AuditConfig": { + "additionalProperties": false, + "description": "Immutable audit-log surface (Step 6.6).\n\nAudit events are always recorded into the tamper-evident SHA-256 hash-chain\nstore (Step 0.7c) regardless of this flag. ``enabled`` controls the **read\nsurface**: ``GET /v1/audit`` (a tenant's own events, newest-first) and\n``GET /v1/audit/verify`` (whole-log chain integrity).\n\n**Enabled by default** \u2014 unlike the behaviour-changing ACL / PII toggles, the\naudit log is a passive compliance record and its read API is tenant-scoped\n(a tenant only ever sees its own events), so exposing it out of the box is the\nexpected enterprise default. Set ``false`` to withhold the HTTP read surface\n(the endpoints then return 404); the WORM signed export is Step 6.6b.", + "properties": { + "enabled": { + "default": true, + "title": "Enabled", + "type": "boolean" + } + }, + "title": "AuditConfig", + "type": "object" + }, "AuthConfig": { "additionalProperties": false, "properties": { @@ -1637,6 +1650,9 @@ "pii": { "$ref": "#/$defs/PiiConfig" }, + "audit": { + "$ref": "#/$defs/AuditConfig" + }, "webhooks": { "$ref": "#/$defs/WebhooksConfig" }, diff --git a/dist/rag.schema.yaml b/dist/rag.schema.yaml index 9eb7af0..4c347ab 100644 --- a/dist/rag.schema.yaml +++ b/dist/rag.schema.yaml @@ -50,6 +50,36 @@ $defs: type: boolean title: AclConfig type: object + AuditConfig: + additionalProperties: false + description: 'Immutable audit-log surface (Step 6.6). + + + Audit events are always recorded into the tamper-evident SHA-256 hash-chain + + store (Step 0.7c) regardless of this flag. ``enabled`` controls the **read + + surface**: ``GET /v1/audit`` (a tenant''s own events, newest-first) and + + ``GET /v1/audit/verify`` (whole-log chain integrity). + + + **Enabled by default** — unlike the behaviour-changing ACL / PII toggles, the + + audit log is a passive compliance record and its read API is tenant-scoped + + (a tenant only ever sees its own events), so exposing it out of the box is the + + expected enterprise default. Set ``false`` to withhold the HTTP read surface + + (the endpoints then return 404); the WORM signed export is Step 6.6b.' + properties: + enabled: + default: true + title: Enabled + type: boolean + title: AuditConfig + type: object AuthConfig: additionalProperties: false properties: @@ -1537,6 +1567,8 @@ properties: $ref: '#/$defs/AclConfig' pii: $ref: '#/$defs/PiiConfig' + audit: + $ref: '#/$defs/AuditConfig' webhooks: $ref: '#/$defs/WebhooksConfig' provenance: diff --git a/docs/README.md b/docs/README.md index b6208a5..c73e05e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ | [request-context.md](architecture/request-context.md) | `RequestContext` — the per-request envelope threaded through every SPI (tenant, principal, namespace, ACLs, PII policy, trace, budget) | | [multi-tenancy.md](architecture/multi-tenancy.md) | Logical multi-tenancy (Step 6.1): make per-tenant `rag.yaml` config drive requests. `TenantResolver` (`rag_config.tenancy`) maps a tenant id → frozen `TenantSettings` (namespace / pii_policy / acl_labels), applied **once** at the gateway boundary (namespace + pii_policy onto the `RequestContext`, acl_labels unioned into the principal); unknown tenants resolve to safe defaults (namespace = id, default PII, no labels — isolated not privileged); `RequestContext.namespace` defaults to `tenant_id` (Pinecone partitions on it; `filter_pushdown` unchanged); resolves+threads only — ACL push-down is 6.3, PII egress 6.5, physical tenancy 6.2; `GET /v1/status/tenant`; inert in `build_app` | | [policy-engine.md](architecture/policy-engine.md) | `PolicyEngine` (PDP) — single decision point for ACL, PII, quotas, redaction; replaces scattered governance checks | +| [audit-log.md](architecture/audit-log.md) | Immutable audit log (Step 6.6): tamper-evidence (SHA-256 hash chain) vs immutability-at-rest (WORM export, 6.6b); one shared `AuditWriter`/store on `app.state`; read-path tenant scoping (tenant-scoped list vs whole-log verify); why the read API defaults on | | [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 | @@ -104,6 +105,7 @@ | [sdks.md](reference/sdks.md) | Official SDKs (Step 3.7) — Python (`agentcontextos`) + TypeScript (`@agentcontextos/sdk`) hand-written clients, generated Go/Java/.NET, identity model, usage per language, the `task openapi:gen` / `sdk:gen` pipeline, extension points | | [admin-ui.md](reference/admin-ui.md) | Admin console (Step 3.10) — Next.js 14 operator GUI (`apps/admin-ui`); 9 pages (dashboard, corpora, connectors, glossary, webhooks, audit, API keys, tenants, config), live-vs-seed hybrid + `NEXT_PUBLIC_GATEWAY_URL`, header identity, running it, internals (shell/primitives/data layer), extension points | | [tenancy.md](reference/tenancy.md) | Logical multi-tenancy (Step 6.1) — per-tenant `rag.yaml` config (`namespace` / `acl_labels` / `pii_policy` / `quota`); `TenantResolver.resolve(id) → TenantSettings`; `RequestContext.namespace`; `GET /v1/status/tenant`; `ragctl tenant list` / `resolve`; config table + scope/boundaries (6.2/6.3/6.5) + extension points; physical tenancy (6.2), ACL push-down (6.3) + egress verifier (6.4 — `cfg.acl.verify_egress`) sections | +| [audit.md](reference/audit.md) | Audit log (Step 6.6) — `AuditEvent` / `AuditStore` (append / events / verify_chain) / `NoopAuditStore` SHA-256 hash chain / `AuditWriter` (+ `.store`); read API `GET /v1/audit` (tenant-scoped, newest-first, `limit`/`action`/`outcome`, `chain_verified`) + `GET /v1/audit/verify` (whole-log); `cfg.audit.enabled`; durable-store 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 | @@ -176,6 +178,7 @@ broken, and what to fix before committing to the next phase. | [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-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) | +| [ADR-0038-immutable-audit-log.md](adr/ADR-0038-immutable-audit-log.md) | Decision (Step 6.6): make the 0.7c hash-chain audit log usable + provably intact, in two slices. 6.6a — the SHA-256 chain is the tamper-*evidence* mechanism (no second scheme); one shared `AuditWriter`/store on `app.state` (corpus router + read API write/read the same chain); `GET /v1/audit` tenant-scoped (a tenant sees only its own events, newest-first, `chain_verified` inline) + `GET /v1/audit/verify` whole-log `{ok,event_count}` (content-free, so global verification leaks nothing cross-tenant); read API on by default (`cfg.audit.enabled=true` — passive compliance record, unlike behaviour-changing ACL/PII). 6.6b — WORM signed export (HMAC over chain head, reusing the ProvenanceSigner pattern) → immutability at rest. Coverage expansion + durable backend deferred | | [ADR-0031-cost-anomaly.md](adr/ADR-0031-cost-anomaly.md) | Decision (Step 5.6c): detect per-tenant spend spikes with a rolling `CostTracker` (not the cumulative quota counter); detect scale-free on the token series (cost = tokens × a constant price) so detection is decoupled from quota pricing and works with quotas off; two gates (ratio + z-score, z relaxed on a flat baseline) → tri-state verdict; put it in `rag-observability` as a `dataclass` (gateway wraps it in a Pydantic `CostStatusResponse`) so there's **no `rag-core` type / `dist/schemas` churn**; feed O(1) from `record_request_usage` before the quota block; pull-based `GET /v1/status/cost` (no per-request span/event); rejected folding into the infra-scoped drift registry, a new package, a `cost.anomaly_detected` push event (deferred), per-model pricing, a time-series DB | | [ADR-0030-drift-monitors.md](adr/ADR-0030-drift-monitors.md) | Decision (Step 5.5): detect retrieval degradation with five drift monitors in a new `rag-drift` package (mirroring rag-feedback); two statistics — PSI (pure, binned, dependency-free) for the distribution monitors + mean-drop for the rate/score monitors — over one scalar-window `DriftMonitor`; infra-scoped registry (like breakers) fed via `observe` from the signals the gateway already computes (query length / retrieval score / HyDE-embedding norm / guard grounded-claim fraction / feedback citation clicks); detection on dashboard-poll `evaluate()` with transition-edge `drift.detected` (structured event + the Step 3.9 webhook, targeting `alert_tenant`); observe-only / inert-by-default / rebaseline; rejected per-tenant monitors, per-dimension embedding PSI, a stats library, a background scheduler, hot-path detection | | [ADR-0029-online-feedback.md](adr/ADR-0029-online-feedback.md) | Decision (Step 5.4): capture online feedback + implicit signals in a new `rag-feedback` package mirroring `rag-provenance` (SPI + types in rag-core; recorder + pure aggregator in the package); one polymorphic `POST /v1/feedback` (a `signal` enum spanning explicit thumbs/rating/comment + implicit citation-click/copy/regenerate/dwell, `kind` inferred); normalise every signal to a `[-1,1]` score so the dashboard has one satisfaction number; **redact-don't-hash** free-text comments via an injected `PIIDetector` (default `NoopPIIDetector` seam, `comment_redacted` flag, PII-free event) — opposite of provenance's hashing; body identity like `/v1/query`; degrade-open + inert-by-default; `GET /v1/status/feedback` dashboard (event-only, no per-call span); admin-UI card deferred to 5.6; rejected separate per-signal endpoints, header-auth, hashing/raw comments, an OTel span per submission, folding into provenance | diff --git a/docs/adr/ADR-0038-immutable-audit-log.md b/docs/adr/ADR-0038-immutable-audit-log.md new file mode 100644 index 0000000..cbff488 --- /dev/null +++ b/docs/adr/ADR-0038-immutable-audit-log.md @@ -0,0 +1,83 @@ +# ADR-0038 — Immutable audit log + +**Status:** Accepted +**Date:** 2026-06-08 +**Step:** 6.6 — Immutable audit log (Phase 6 — Governance & Tenancy) +**Related:** [ADR-0005](ADR-0005-policy-engine.md) (PolicyEngine PDP), [ADR-0026](ADR-0026-per-query-tracing-provenance.md) (provenance signing + read API), [ADR-0033](ADR-0033-logical-multi-tenancy.md) (logical tenancy), [architecture/audit-log.md](../architecture/audit-log.md), [reference/audit.md](../reference/audit.md) + +## Context + +Step 0.7c shipped the audit foundation: an append-only `AuditStore` SPI, a +`NoopAuditStore` that links events into a **SHA-256 hash chain** (each entry hashes +`prev_hash + event_json`, with a genesis sentinel), and an `AuditWriter` facade +that appends + emits a structured log line. The corpus router already writes a +`corpus.route` event per query. But the log was **write-only**: nothing exposed it +for reading, nothing let an operator confirm it hadn't been tampered with, and +there was no durable export. Step 6.6 makes the audit log *usable and provably +intact*. + +This ADR is delivered in two slices: **6.6a** (this) — the read API + chain +verification + wiring; **6.6b** — the WORM signed export. + +## Decision (6.6a) + +**1. The hash chain is the tamper-evidence mechanism (recap).** We do not add a +second integrity scheme. `verify_chain()` recomputes every link and returns False +if any event or stored hash was altered — that is the whole-log integrity primitive +the read API surfaces. (Immutability *at rest* — preventing deletion/replacement +of the store itself — is the WORM export's job, 6.6b.) + +**2. One shared audit store, exposed on `app.state`.** `build_app` now creates a +single `AuditWriter` over a hash-chain store, exposes `app.state.audit_store` / +`audit_writer`, and hands the *same* writer to the corpus router — so events the +writers append land in exactly the store the read API serves. A new +`AuditWriter.store` property is the read accessor. Default is the in-memory +`NoopAuditStore` (creds-free chain); production injects a durable store. + +**3. `GET /v1/audit` is tenant-scoped.** A principal sees **only its own tenant's** +events (filtered by `ctx.tenant_id` at the boundary), newest-first, bounded by +`limit`, optionally filtered by `action` / `outcome`. The response carries +`chain_verified` so one read both returns the events and attests the log is intact. +This mirrors the Step 5.1 `GET /v1/query/{id}/trace` tenant-scoping. + +**4. `GET /v1/audit/verify` is whole-log.** The chain links *all* events (across +tenants) into one sequence, so verification is inherently global — it returns +`{ok, event_count}`. It requires auth but reports only the boolean + a total count +(no tenant content), so it leaks no cross-tenant data. + +**5. Read API on by default.** `cfg.audit.enabled` defaults **true** — unlike the +behaviour-changing ACL / PII toggles, the audit log is a passive compliance record +and the read API is tenant-scoped, so exposing it out of the box is the expected +enterprise default. When false, the endpoints return 404 (`AuditNotFoundError`); +events are still recorded into the store regardless. + +## Decision (6.6b — planned) + +A **WORM signed export**: serialise the events + chain head into a self-verifying +bundle, HMAC-signed (reusing the `ProvenanceSigner` pattern from ADR-0026), via +`POST /v1/audit/export` + `ragctl audit export/verify`. The bundle is the artifact +an operator writes to immutable storage (S3 Object Lock / a WORM bucket); the +signature + chain make it tamper-evident at rest. + +## Consequences + +**Positive** +- The audit log is now queryable (tenant-scoped) and provably intact (chain verify) + over HTTP, reusing the 0.7c hash chain — no new integrity scheme. +- One shared store removes the prior split where the corpus router held a private + `NoopAuditStore` the gateway couldn't read. +- Tenant isolation on the read path matches the rest of Phase 6. + +**Negative / deferred** +- In-memory default store is not durable across restarts — a durable backend + + the WORM export (6.6b) provide persistence/immutability at rest. +- Coverage: today the populated event is `corpus.route` (every query). Expanding + what gets audited (ACL deny / PII block / ingest decisions as audit events) is a + follow-up; the surface + chain are in place for it. +- Whole-log `verify_chain()` is O(n); fine for the in-memory store, a durable + backend should support incremental verification. + +## See also +- [architecture/audit-log.md](../architecture/audit-log.md) — hash-chain design + read-path tenant scoping +- [reference/audit.md](../reference/audit.md) — `AuditStore` / `AuditWriter` + the read API +- [ADR-0026](ADR-0026-per-query-tracing-provenance.md) — the signing pattern reused by the 6.6b export diff --git a/docs/architecture/audit-log.md b/docs/architecture/audit-log.md new file mode 100644 index 0000000..514b045 --- /dev/null +++ b/docs/architecture/audit-log.md @@ -0,0 +1,91 @@ +# Immutable audit log — architecture + +This document covers the design behind the audit log that the +[reference doc](../reference/audit.md) doesn't spell out: why the hash chain is +the integrity mechanism, the difference between tamper-*evidence* and +immutability *at rest*, how the store is shared, and how the read path stays +tenant-isolated. + +## Tamper-evidence vs immutability at rest + +Two distinct properties, addressed separately: + +- **Tamper-evidence** (Step 0.7c + 6.6a) — you can *detect* that the log was + altered. The SHA-256 hash chain provides this: each event's stored hash is + `sha256(prev_hash + event_json)`, so changing any event (or any stored hash) + breaks every subsequent link, and `verify_chain()` catches it. +- **Immutability at rest** (Step 6.6b) — you can't silently *delete or replace* + the log itself. The chain alone doesn't stop someone with store access from + truncating it or rewriting the whole sequence. That's the WORM export's job: a + signed, point-in-time bundle written to write-once storage (S3 Object Lock). + +6.6a ships the first; 6.6b ships the second. Keeping them separate means the live +log is queryable + verifiable now, and the durable export layers on without +changing the read API. + +## Why the hash chain (not signatures per event) + +Per-event signatures would prove *authenticity* but not *ordering / completeness* +— you could drop an event without detection. The chain ties each event to its +predecessor, so the integrity check is about the **sequence**, which is what an +audit trail needs (no gaps, no reordering, no edits). A single +`verify_chain()` over the genesis-anchored chain is the whole-log integrity +primitive; the 6.6b export signs the chain *head*, so signing + chaining compose: +the signature pins a specific intact sequence. + +## One shared store + +Before 6.6a the corpus router constructed its *own* `AuditWriter(NoopAuditStore())` +when none was injected, so events it wrote went into a store the gateway had no +handle on — unreadable. 6.6a fixes the wiring: `build_app` creates one +`AuditWriter`, exposes `app.state.audit_store` / `audit_writer`, and passes the +*same* writer to the corpus router. `AuditWriter.store` is the read accessor. Now +"what's written" and "what the API serves" are the same object by construction. + +``` +build_app + └─ AuditWriter(store) ← created once + ├─ app.state.audit_writer ← writers use this + ├─ app.state.audit_store ← GET /v1/audit reads this (= writer.store) + └─ corpus_router(audit_writer) ← writes corpus.route into the same chain +``` + +## Read-path tenant scoping + +The audit log is one global chain (all tenants interleaved — required for a single +tamper-evident sequence). That creates a tension with multi-tenancy, resolved by +splitting the two reads: + +- **`GET /v1/audit`** is tenant-scoped: the handler filters `store.events()` by + `ctx.tenant_id`, so a principal only ever sees its own tenant's events. This is + the same boundary-filtering pattern as `GET /v1/query/{id}/trace` (Step 5.1). +- **`GET /v1/audit/verify`** is whole-log: it returns `{ok, event_count}` — a + boolean + a total, never event content — so verifying global integrity leaks no + cross-tenant data. + +A tenant cannot verify "just its slice" of the chain (the slice isn't a chain), +which is why verify is global-but-content-free rather than tenant-scoped. + +## Why the read API is on by default + +The other Phase-6 governance toggles (`acl.enabled`, `pii.enabled`) default +**off** because they *change request behaviour* (filter results, redact/withhold +answers). The audit read API does neither — it's a passive, tenant-scoped view of +a record that's always being written. Enterprises expect the audit trail to be +available, so `cfg.audit.enabled` defaults **true**; operators can withhold the +HTTP surface (→ 404) without affecting recording. + +## Policy boundary + +The audit log is *downstream* of the PolicyEngine: governance decisions are made +by the PDP (ACL / PII / quotas), and the audit log *records* what happened. The +read API performs no governed SPI calls (it reads the store + filters by tenant), +so it adds no PolicyEngine coverage-linter entry. + +## WORM export (Step 6.6b — preview) + +The export serialises `{events, chain_head_hash}` into a bundle, HMAC-signs it +(reusing the `ProvenanceSigner` scheme from Step 5.1 / ADR-0026), and exposes it +via `POST /v1/audit/export` + `ragctl audit export/verify`. The bundle verifies +offline (chain + signature), and is the artifact operators write to immutable +storage. Deferred to keep 6.6a a tight, shippable slice. diff --git a/docs/reference/audit.md b/docs/reference/audit.md new file mode 100644 index 0000000..356f0e0 --- /dev/null +++ b/docs/reference/audit.md @@ -0,0 +1,105 @@ +# Audit log — reference + +The audit log is a tamper-evident, append-only record of security- and +compliance-relevant actions. Step 0.7c shipped the store + writer; Step 6.6a adds +the HTTP read API. The signed WORM export is Step 6.6b. + +## Overview + +- **`AuditEvent`** (`rag_core.types`) — an immutable (`frozen`) record: + `id`, `tenant_id`, `principal_id`, `action` (dot-namespaced, e.g. + `corpus.route`), `resource`, `outcome` (`allowed` / `denied` / `error`), + `trace_context`, `metadata`, `timestamp`. +- **`AuditStore`** (`rag_core.spi.audit_store`) — append-only SPI: + `append(event)`, `events()`, `verify_chain()`, `health()`. +- **`NoopAuditStore`** — in-memory reference impl that links events into a + **SHA-256 hash chain**: each entry's hash is `sha256(prev_hash + event_json)` + (genesis sentinel for the first). `verify_chain()` recomputes every link and + returns False if any event or stored hash was altered. +- **`AuditWriter`** (`rag_core.audit`) — facade: `write(event)` appends to the + store **and** emits a structured `audit.event` log line; `.store` exposes the + backing store for the read API. + +## Usage + +### Recording + +```python +from rag_core.audit import AuditWriter +from rag_core.spi.noop import NoopAuditStore +from rag_core.types import AuditEvent, AuditOutcome + +writer = AuditWriter(NoopAuditStore()) +writer.write( + AuditEvent( + tenant_id=ctx.tenant_id, + principal_id=ctx.principal.id, + action="corpus.route", + resource="corpus-a", + outcome=AuditOutcome.allowed, + trace_context=ctx.trace, + ) +) +``` + +### Read API (Step 6.6a) + +`GET /v1/audit` — the **calling tenant's own** events, newest-first. Auth via +`Authorization` / `X-Tenant-Id` headers (a tenant never sees another tenant's +events). + +| Param | Default | Meaning | +|-------|---------|---------| +| `limit` | 100 | page size, clamped to `[1, 1000]` | +| `action` | — | exact-match filter (e.g. `corpus.route`) | +| `outcome` | — | `allowed` / `denied` / `error` | + +Response (`AuditListResponse`): `{ tenant_id, events: [AuditEvent…], returned, +total, chain_verified }`. `chain_verified` is the whole-log integrity result at +read time. + +`GET /v1/audit/verify` — whole-log hash-chain integrity (the chain spans all +tenants, so this is global). Response (`AuditVerifyResponse`): `{ ok, event_count }`. + +Both return **404** when the read API is disabled (`cfg.audit.enabled = false`) +and **401** without tenant credentials. + +```bash +curl -H "X-Tenant-Id: acme" -H "X-Principal-Id: alice" localhost:8000/v1/audit +curl -H "X-Tenant-Id: acme" -H "X-Principal-Id: alice" localhost:8000/v1/audit/verify +``` + +### Configuration + +```yaml +audit: + enabled: true # expose GET /v1/audit + /v1/audit/verify (default on) +``` + +Events are always recorded into the store; `enabled` only gates the HTTP read +surface. + +## Internals + +- **One shared store.** `build_app` creates a single `AuditWriter` over a + hash-chain store, exposes it on `app.state.audit_store` / `audit_writer`, and + hands the same writer to the corpus router — so the events the writers append + are exactly what the read API serves. The default store is `NoopAuditStore` + (in-memory); inject a durable one via `build_app(audit_writer=…)`. +- **Tenant scoping at the boundary.** `GET /v1/audit` filters `store.events()` by + `ctx.tenant_id` in the handler — cross-tenant events never leave. Chain + *verification* is whole-log (the chain is one global sequence). +- **What gets audited today.** Every `/v1/query` writes a `corpus.route` event via + the corpus router. Expanding coverage (ACL / PII / ingest decisions) is a + follow-up; the store + chain + read API are in place for it. + +## Extension points + +Implement a durable `AuditStore` (Postgres, S3, an append-only ledger): + +1. Subclass `rag_core.spi.audit_store.AuditStore`. +2. `append(event)` must persist immutably and extend the hash chain + (`sha256(prev_hash + event.model_dump_json())`). +3. `events()` returns insertion order (oldest-first); `verify_chain()` recomputes + the chain (consider incremental verification for large logs). +4. Inject via `build_app(audit_writer=AuditWriter(MyStore()))`. diff --git a/packages/config/src/rag_config/schema.py b/packages/config/src/rag_config/schema.py index 07fafa1..d25c6d3 100644 --- a/packages/config/src/rag_config/schema.py +++ b/packages/config/src/rag_config/schema.py @@ -583,6 +583,24 @@ class PiiConfig(_StrictBase): enabled: bool = False +class AuditConfig(_StrictBase): + """Immutable audit-log surface (Step 6.6). + + Audit events are always recorded into the tamper-evident SHA-256 hash-chain + store (Step 0.7c) regardless of this flag. ``enabled`` controls the **read + surface**: ``GET /v1/audit`` (a tenant's own events, newest-first) and + ``GET /v1/audit/verify`` (whole-log chain integrity). + + **Enabled by default** — unlike the behaviour-changing ACL / PII toggles, the + audit log is a passive compliance record and its read API is tenant-scoped + (a tenant only ever sees its own events), so exposing it out of the box is the + expected enterprise default. Set ``false`` to withhold the HTTP read surface + (the endpoints then return 404); the WORM signed export is Step 6.6b. + """ + + enabled: bool = True + + class QuotaConfig(_StrictBase): """Per-tenant quota & rate-limit enforcement knobs (Step 4.5). @@ -895,6 +913,7 @@ class RagConfig(_StrictBase): quotas: QuotaConfig = Field(default_factory=QuotaConfig) acl: AclConfig = Field(default_factory=AclConfig) pii: PiiConfig = Field(default_factory=PiiConfig) + audit: AuditConfig = Field(default_factory=AuditConfig) webhooks: WebhooksConfig = Field(default_factory=WebhooksConfig) provenance: ProvenanceConfig = Field(default_factory=ProvenanceConfig) feedback: FeedbackConfig = Field(default_factory=FeedbackConfig) diff --git a/packages/core/src/rag_core/audit.py b/packages/core/src/rag_core/audit.py index bb30b71..7e03505 100644 --- a/packages/core/src/rag_core/audit.py +++ b/packages/core/src/rag_core/audit.py @@ -21,6 +21,11 @@ class AuditWriter: def __init__(self, store: AuditStore) -> None: self._store = store + @property + def store(self) -> AuditStore: + """The backing store — read side for the audit query API (Step 6.6).""" + return self._store + def write(self, event: AuditEvent) -> None: """Persist *event* and emit a structured log entry. diff --git a/packages/core/src/rag_core/errors.py b/packages/core/src/rag_core/errors.py index 91018cd..8c8fea7 100644 --- a/packages/core/src/rag_core/errors.py +++ b/packages/core/src/rag_core/errors.py @@ -271,3 +271,23 @@ class ProvenanceNotFoundError(ProvenanceError): """ code = "provenance_not_found" + + +# --------------------------------------------------------------------------- +# Audit log (Step 6.6) +# --------------------------------------------------------------------------- +class AuditError(RagError): + """The audit-log surface could not satisfy a request (Step 6.6).""" + + code = "audit_error" + + +class AuditNotFoundError(AuditError): + """The audit-log read API is unavailable for this request (Step 6.6). + + Raised by ``GET /v1/audit`` / ``GET /v1/audit/verify`` when the audit API is + disabled (``cfg.audit.enabled = false``) or no audit store is wired. Maps to + HTTP 404 at the gateway. + """ + + code = "audit_not_found" diff --git a/packages/core/src/rag_core/gateway_types.py b/packages/core/src/rag_core/gateway_types.py index 65f52c0..9048a8e 100644 --- a/packages/core/src/rag_core/gateway_types.py +++ b/packages/core/src/rag_core/gateway_types.py @@ -34,6 +34,7 @@ from pydantic import BaseModel, Field from rag_core.types import ( + AuditEvent, Chunk, ChunkRef, Citation, @@ -365,6 +366,41 @@ class QueryTraceResponse(BaseModel): spans: list[SpanRecord] = Field(default_factory=list) +class AuditListResponse(BaseModel): + """``GET /v1/audit`` response — the tenant's audit events (Step 6.6). + + Returns the calling tenant's :class:`AuditEvent` records **newest-first** + (only its own — never another tenant's), bounded by the ``limit`` query + param. ``chain_verified`` reports whether the **whole-log** SHA-256 hash + chain still validates at read time, so a consumer sees in one call both the + events and that the underlying log is tamper-free. ``returned`` is the page + size; ``total`` is how many events the tenant has before the limit. + """ + + model_config = {"frozen": True} + + tenant_id: TenantId + events: list[AuditEvent] = Field(default_factory=list) + returned: int = 0 + total: int = 0 + chain_verified: bool = True + + +class AuditVerifyResponse(BaseModel): + """``GET /v1/audit/verify`` response — whole-log hash-chain integrity (Step 6.6). + + The hash chain links **all** events (across tenants) into one tamper-evident + sequence, so verification is inherently whole-log: ``ok`` is True when every + link matches its expected SHA-256, False if any event or stored hash was + altered. ``event_count`` is the total number of events verified. + """ + + model_config = {"frozen": True} + + ok: bool + event_count: int = 0 + + class FeedbackRequest(BaseModel): """``POST /v1/feedback`` body (Step 5.4). @@ -463,6 +499,8 @@ class GatewayError(BaseModel): __all__ = [ "Answer", + "AuditListResponse", + "AuditVerifyResponse", "Corpus", "CorpusList", "ExperimentAssignment",