diff --git a/TRACKER.md b/TRACKER.md index 7a5ac21..c158bd7 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -14,13 +14,13 @@ | | | |---|---| | **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.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). | +| **Current phase** | Phase 6 — Governance & Tenancy (**6 / 10 steps**) | +| **Overall** | **70 / 84 steps** — Phases 0–5 complete | +| **Next action** | **Step 6.7 — BYOK (Bring Your Own Key)**: KMS integration (AWS KMS / GCP KMS / HashiCorp Vault) + envelope encryption for embeddings; per-tenant keys. New ground (no existing crypto seam beyond the HMAC signers from 5.1 / 6.6b). | **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.6** ✅ Immutable audit log — **6.6a** shared `AuditWriter`/store on `app.state` + `GET /v1/audit` (tenant-scoped, `chain_verified`) + `GET /v1/audit/verify` (whole-log) + `cfg.audit.enabled` ([#153](https://github.com/officialCodeWork/AgentContextOS/pull/153)); **6.6b** `AuditExporter` self-verifying WORM bundle (SHA-256 `content_hash` + HMAC, offline `verify()`), `POST /v1/audit/export` (tenant-scoped) + `ragctl audit`, `cfg.audit.export_secret` ([#154](https://github.com/officialCodeWork/AgentContextOS/pull/154)) - **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) @@ -60,9 +60,9 @@ | 3 | Gateway & Agent Runtime | 11 | **11** | 0 | | 4 | Reliability | 6 | **6** | 0 | | 5 | Eval & Observability | 7 | **7** | 0 | -| 6 | Governance & Tenancy | 10 | **5** | 5 | +| 6 | Governance & Tenancy | 10 | **6** | 4 | | 7 | Pilot, Harden, GA | 10 | 0 | 10 | -| **Total** | | **84** | **69** | **15** | +| **Total** | | **84** | **70** | **14** | --- @@ -654,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 | 🚧 | **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.6 | Immutable audit log | ✅ | **6.6a** [#153](https://github.com/officialCodeWork/AgentContextOS/pull/153) — read API `GET /v1/audit` + `GET /v1/audit/verify` + shared store + `cfg.audit`. **6.6b** [#154](https://github.com/officialCodeWork/AgentContextOS/pull/154) — `AuditExporter` signed WORM bundle + `POST /v1/audit/export` + `ragctl audit` | | 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 | @@ -719,6 +719,14 @@ Step 0.7c shipped the foundation — an append-only `AuditStore` SPI, a `NoopAud - **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) +#### 6.6b — WORM signed export ✅ [#154](https://github.com/officialCodeWork/AgentContextOS/pull/154) + +- The hash chain (6.6a) gives tamper-*evidence* for the live log; 6.6b adds immutability *at rest* via a **self-verifying signed export**. New **`AuditExporter`** (`rag_core.audit_export`, pure stdlib) builds an **`AuditExport`** bundle with **two independent integrity checks**: a **`content_hash`** (SHA-256 over the canonical serialisation of the events — pins the exact set + order, recomputed on verify so any add/remove/edit is caught *even without the secret*) and a **`signature`** (HMAC-SHA256 over `f"{timestamp}.{content_hash}"`, mirroring the Step 5.1 `ProvenanceSigner` scheme). `verify()` returns `{signed, content_ok, verified, reason}` with `reason` ∈ `ok` / `unsigned` / `no_secret` / `content_mismatch` / `signature_mismatch`; `chain_verified` attests the source store's whole-log chain was intact at export time +- **`POST /v1/audit/export`** is **tenant-scoped** (the caller's events only — same isolation as `GET /v1/audit`); `ragctl audit` exports the whole log in-process. Because integrity is `content_hash` + HMAC over *whatever events are included* (not the global chain head), the **same `verify()` works for both** — a tenant subset isn't a contiguous chain. The bundle verifies **offline** (no live store), so it's the artifact an operator archives to immutable storage (S3 Object Lock) +- **Opt-in signing**: new `cfg.audit.export_secret` (env-interpolated, mirrors `provenance.signing_secret`); empty (default) → content-hashed but **unsigned** bundle (`verify` → `unsigned`), set it to enable signature verification. `build_app(audit_export_secret=…)` → `app.state.audit_exporter`; `build_app_from_config` from config +- New `AuditExport` / `AuditExportSignature` / `AuditExportVerification` core types (`dist/schemas` regenerated); `POST /v1/audit/export` → `dist/openapi`; **`ragctl audit`** (seed → export → verify round-trip, `--out` writes the bundle, `--verify FILE` checks one offline). ~23 new tests (exporter unit: content_hash determinism, sign→verify, unsigned, no_secret, wrong-secret, **content tamper → content_mismatch**, **signature tamper → signature_mismatch**, empty; gateway: tenant-scoped + signed + **offline-verifies** + cross-tenant isolation + unsigned + disabled→404 + no-auth→401; ragctl round-trip + file verify). All gates green (ruff, mypy --strict 299 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) +- **Deferred:** a durable live-store `AuditStore` backend (Postgres / append-only ledger) and expanding what gets audited beyond `corpus.route`. + --- ## Phase 7 — Pilot, Harden, GA (Weeks 34–40) ⏳ @@ -876,6 +884,7 @@ Complete log of every PR. Routine Dependabot bumps are grouped; everything else | [#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) | +| [#154](https://github.com/officialCodeWork/AgentContextOS/pull/154) | 2026-06-08 | feat(audit): WORM signed export — AuditExporter + POST /v1/audit/export (Step 6.6b) | | #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 5a10635..9859d67 100644 --- a/apps/gateway/src/rag_gateway/app.py +++ b/apps/gateway/src/rag_gateway/app.py @@ -50,6 +50,7 @@ from rag_chunker import HeadingAwareChunker from rag_core import get_logger from rag_core.audit import AuditWriter +from rag_core.audit_export import AuditExporter from rag_core.errors import ( ACLDeniedError, AuthError, @@ -374,6 +375,7 @@ def build_app( pii_enabled: bool = False, pii_detector: Any | None = None, audit_enabled: bool = True, + audit_export_secret: str = "", enable_cors: bool = True, default_tenant_id: TenantId | None = None, ) -> FastAPI: @@ -475,6 +477,11 @@ def build_app( app.state.audit_writer = audit_writer app.state.audit_store = audit_writer.store app.state.audit_enabled = audit_enabled + # WORM signed export (Step 6.6b) — POST /v1/audit/export returns a + # self-verifying AuditExport bundle, HMAC-signed when a secret is configured + # (empty → unsigned but still content-hashed). The artifact an operator + # archives to immutable storage (S3 Object Lock). + app.state.audit_exporter = AuditExporter(audit_export_secret) # 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. It shares the audit @@ -716,6 +723,7 @@ async def info() -> dict[str, Any]: "GET /v1/status/logs/stream", "GET /v1/audit", "GET /v1/audit/verify", + "POST /v1/audit/export", "WS /v1/status/ws", "GET /v1/connectors/status", "GET /healthz", diff --git a/apps/gateway/src/rag_gateway/audit.py b/apps/gateway/src/rag_gateway/audit.py index 43aa1dc..4608494 100644 --- a/apps/gateway/src/rag_gateway/audit.py +++ b/apps/gateway/src/rag_gateway/audit.py @@ -19,10 +19,11 @@ from __future__ import annotations from fastapi import APIRouter, Request +from rag_core.audit_export import AuditExporter 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 +from rag_core.types import AuditEvent, AuditExport, RequestContext _MAX_LIMIT = 1000 @@ -112,4 +113,27 @@ async def verify_audit(request: Request) -> AuditVerifyResponse: store = _require_store(request) return AuditVerifyResponse(ok=store.verify_chain(), event_count=len(store.events())) + @router.post( + "/v1/audit/export", + response_model=AuditExport, + responses={ + 401: {"model": GatewayError, "description": "Missing or invalid auth"}, + 404: {"model": GatewayError, "description": "Audit read API disabled"}, + }, + summary="Signed WORM export of the tenant's audit log (Step 6.6b)", + ) + async def export_audit(request: Request) -> AuditExport: + """Return a self-verifying export bundle of the calling tenant's events. + + The bundle is content-hashed and (when ``cfg.audit.export_secret`` is set) + HMAC-signed, so a consumer verifies it offline; ``chain_verified`` attests + the source log's whole-log hash chain was intact at export time. It is the + artifact an operator archives to immutable (WORM) storage. + """ + ctx = _require_ctx(request) + store = _require_store(request) + exporter: AuditExporter = request.app.state.audit_exporter + mine = [e for e in store.events() if e.tenant_id == ctx.tenant_id] + return exporter.export(mine, tenant_id=ctx.tenant_id, chain_verified=store.verify_chain()) + return router diff --git a/apps/gateway/src/rag_gateway/wiring.py b/apps/gateway/src/rag_gateway/wiring.py index 0ccb2d6..fee9595 100644 --- a/apps/gateway/src/rag_gateway/wiring.py +++ b/apps/gateway/src/rag_gateway/wiring.py @@ -712,7 +712,9 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI: # Immutable audit-log read API (Step 6.6) — on by default (tenant-scoped, # passive compliance record); flip off to withhold the HTTP read surface. + # ``export_secret`` (6.6b) signs the WORM export bundle when set. audit_enabled = overrides.pop("audit_enabled", cfg.audit.enabled) + audit_export_secret = overrides.pop("audit_export_secret", cfg.audit.export_secret) return build_app( corpus_store=corpus_store, @@ -727,6 +729,7 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI: acl_verify_egress=acl_verify_egress, pii_enabled=pii_enabled, audit_enabled=audit_enabled, + audit_export_secret=audit_export_secret, **overrides, ) diff --git a/apps/gateway/tests/test_audit.py b/apps/gateway/tests/test_audit.py index fe4d84a..45d8658 100644 --- a/apps/gateway/tests/test_audit.py +++ b/apps/gateway/tests/test_audit.py @@ -10,14 +10,16 @@ from __future__ import annotations from fastapi.testclient import TestClient +from rag_core.audit_export import AuditExporter +from rag_core.types import AuditExport 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 _client(*, audit_enabled: bool = True, export_secret: str = "") -> TestClient: + return TestClient(build_app(audit_enabled=audit_enabled, audit_export_secret=export_secret)) def _post_query(client: TestClient, headers: dict[str, str], q: str = "anything") -> None: @@ -128,3 +130,49 @@ def test_default_build_app_enables_audit() -> None: 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 + + +# --------------------------------------------------------------------------- +# POST /v1/audit/export — signed WORM bundle (Step 6.6b) +# --------------------------------------------------------------------------- +def test_export_is_tenant_scoped_and_signed_and_offline_verifies() -> None: + client = _client(export_secret="topsecret") + _post_query(client, _ACME) + r = client.post("/v1/audit/export", headers=_ACME) + assert r.status_code == 200, r.text + body = r.json() + assert body["tenant_id"] == "acme" + assert body["count"] >= 1 + assert body["chain_verified"] is True + assert body["signature"] is not None + assert all(e["tenant_id"] == "acme" for e in body["events"]) + # the bundle verifies offline with the same secret + export = AuditExport.model_validate(body) + assert AuditExporter("topsecret").verify(export).verified is True + # and fails under a wrong secret + assert AuditExporter("wrong").verify(export).reason == "signature_mismatch" + + +def test_export_cross_tenant_isolation() -> None: + client = _client(export_secret="topsecret") + _post_query(client, _ACME) + body = client.post("/v1/audit/export", headers=_GLOBEX).json() + assert body["count"] == 0 + assert body["events"] == [] + + +def test_export_unsigned_without_secret() -> None: + client = _client() # no export secret + _post_query(client, _ACME) + body = client.post("/v1/audit/export", headers=_ACME).json() + assert body["signature"] is None + assert body["content_hash"] # still content-hashed + + +def test_export_disabled_is_404() -> None: + client = _client(audit_enabled=False) + assert client.post("/v1/audit/export", headers=_ACME).status_code == 404 + + +def test_export_no_auth_is_401() -> None: + assert _client().post("/v1/audit/export").status_code == 401 diff --git a/dist/openapi.json b/dist/openapi.json index a711382..5cedfe9 100644 --- a/dist/openapi.json +++ b/dist/openapi.json @@ -329,6 +329,99 @@ "title": "AuditEvent", "type": "object" }, + "AuditExport": { + "description": "A signed, point-in-time WORM export of the audit log (Step 6.6b).\n\nA self-verifying bundle: ``content_hash`` is the SHA-256 over the canonical\nserialisation of ``events`` (pins the exact set + order), and ``signature``\nis the HMAC over that hash (authenticity). An offline verifier recomputes\n``content_hash`` from ``events`` and re-checks the HMAC, so any add / remove /\nedit is detected. ``chain_verified`` attests the *source* store's whole-log\nhash chain was intact at export time. ``tenant_id`` is set for a\ntenant-scoped export (the HTTP path) and ``None`` for a whole-log export.\nThe bundle is the artifact an operator writes to immutable storage\n(e.g. S3 Object Lock).", + "properties": { + "chain_verified": { + "default": true, + "title": "Chain Verified", + "type": "boolean" + }, + "content_hash": { + "default": "", + "title": "Content Hash", + "type": "string" + }, + "count": { + "default": 0, + "title": "Count", + "type": "integer" + }, + "events": { + "items": { + "$ref": "#/components/schemas/AuditEvent" + }, + "title": "Events", + "type": "array" + }, + "exported_at": { + "format": "date-time", + "title": "Exported At", + "type": "string" + }, + "schema_version": { + "default": "audit-export-v1", + "title": "Schema Version", + "type": "string" + }, + "signature": { + "anyOf": [ + { + "$ref": "#/components/schemas/AuditExportSignature" + }, + { + "type": "null" + } + ] + }, + "tenant_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tenant Id" + } + }, + "title": "AuditExport", + "type": "object" + }, + "AuditExportSignature": { + "description": "Detached HMAC signature over an :class:`AuditExport` (Step 6.6b).\n\nMirrors :class:`ProvenanceSignature` — HMAC-SHA256 over\n``f\"{timestamp}.{content_hash}\"`` — so the WORM export reuses one signing\nmodel across the platform. ``timestamp`` (unix seconds) is bound into the\ndigest; ``key_id`` is an opaque label for key-rotation bookkeeping.", + "properties": { + "algorithm": { + "default": "HMAC-SHA256", + "title": "Algorithm", + "type": "string" + }, + "key_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Key Id" + }, + "timestamp": { + "default": 0, + "title": "Timestamp", + "type": "integer" + }, + "value": { + "default": "", + "title": "Value", + "type": "string" + } + }, + "title": "AuditExportSignature", + "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": { @@ -4337,6 +4430,48 @@ ] } }, + "/v1/audit/export": { + "post": { + "description": "Return a self-verifying export bundle of the calling tenant's events.\n\nThe bundle is content-hashed and (when ``cfg.audit.export_secret`` is set)\nHMAC-signed, so a consumer verifies it offline; ``chain_verified`` attests\nthe source log's whole-log hash chain was intact at export time. It is the\nartifact an operator archives to immutable (WORM) storage.", + "operationId": "export_audit_v1_audit_export_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditExport" + } + } + }, + "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": "Signed WORM export of the tenant's audit log (Step 6.6b)", + "tags": [ + "audit" + ] + } + }, "/v1/audit/verify": { "get": { "description": "Verify the whole-log SHA-256 hash chain — True iff no link was altered.", diff --git a/dist/openapi.yaml b/dist/openapi.yaml index 45ae7b1..d19abdf 100644 --- a/dist/openapi.yaml +++ b/dist/openapi.yaml @@ -272,6 +272,96 @@ components: - trace_context title: AuditEvent type: object + AuditExport: + description: 'A signed, point-in-time WORM export of the audit log (Step 6.6b). + + + A self-verifying bundle: ``content_hash`` is the SHA-256 over the canonical + + serialisation of ``events`` (pins the exact set + order), and ``signature`` + + is the HMAC over that hash (authenticity). An offline verifier recomputes + + ``content_hash`` from ``events`` and re-checks the HMAC, so any add / remove + / + + edit is detected. ``chain_verified`` attests the *source* store''s whole-log + + hash chain was intact at export time. ``tenant_id`` is set for a + + tenant-scoped export (the HTTP path) and ``None`` for a whole-log export. + + The bundle is the artifact an operator writes to immutable storage + + (e.g. S3 Object Lock).' + properties: + chain_verified: + default: true + title: Chain Verified + type: boolean + content_hash: + default: '' + title: Content Hash + type: string + count: + default: 0 + title: Count + type: integer + events: + items: + $ref: '#/components/schemas/AuditEvent' + title: Events + type: array + exported_at: + format: date-time + title: Exported At + type: string + schema_version: + default: audit-export-v1 + title: Schema Version + type: string + signature: + anyOf: + - $ref: '#/components/schemas/AuditExportSignature' + - type: 'null' + tenant_id: + anyOf: + - type: string + - type: 'null' + title: Tenant Id + title: AuditExport + type: object + AuditExportSignature: + description: 'Detached HMAC signature over an :class:`AuditExport` (Step 6.6b). + + + Mirrors :class:`ProvenanceSignature` — HMAC-SHA256 over + + ``f"{timestamp}.{content_hash}"`` — so the WORM export reuses one signing + + model across the platform. ``timestamp`` (unix seconds) is bound into the + + digest; ``key_id`` is an opaque label for key-rotation bookkeeping.' + properties: + algorithm: + default: HMAC-SHA256 + title: Algorithm + type: string + key_id: + anyOf: + - type: string + - type: 'null' + title: Key Id + timestamp: + default: 0 + title: Timestamp + type: integer + value: + default: '' + title: Value + type: string + title: AuditExportSignature + type: object AuditListResponse: description: '``GET /v1/audit`` response — the tenant''s audit events (Step 6.6). @@ -3771,6 +3861,42 @@ paths: summary: The tenant's own audit events, newest-first (Step 6.6) tags: - audit + /v1/audit/export: + post: + description: 'Return a self-verifying export bundle of the calling tenant''s + events. + + + The bundle is content-hashed and (when ``cfg.audit.export_secret`` is set) + + HMAC-signed, so a consumer verifies it offline; ``chain_verified`` attests + + the source log''s whole-log hash chain was intact at export time. It is the + + artifact an operator archives to immutable (WORM) storage.' + operationId: export_audit_v1_audit_export_post + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuditExport' + 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: Signed WORM export of the tenant's audit log (Step 6.6b) + tags: + - audit /v1/audit/verify: get: description: Verify the whole-log SHA-256 hash chain — True iff no link was diff --git a/dist/rag.schema.json b/dist/rag.schema.json index 65f1449..353a522 100644 --- a/dist/rag.schema.json +++ b/dist/rag.schema.json @@ -20,12 +20,17 @@ }, "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.", + "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).\n\n``export_secret`` is the HMAC key for the **WORM signed export** (Step 6.6b):\n``POST /v1/audit/export`` returns a self-verifying :class:`AuditExport` bundle\n(content hash + signature) an operator archives to immutable storage. Supports\n``${ENV_VAR}`` interpolation so it stays out of the file; empty (default)\nproduces **unsigned** bundles (still content-hashed) \u2014 set it in production to\nenable signature verification.", "properties": { "enabled": { "default": true, "title": "Enabled", "type": "boolean" + }, + "export_secret": { + "default": "", + "title": "Export Secret", + "type": "string" } }, "title": "AuditConfig", diff --git a/dist/rag.schema.yaml b/dist/rag.schema.yaml index 4c347ab..b171a27 100644 --- a/dist/rag.schema.yaml +++ b/dist/rag.schema.yaml @@ -72,12 +72,30 @@ $defs: expected enterprise default. Set ``false`` to withhold the HTTP read surface - (the endpoints then return 404); the WORM signed export is Step 6.6b.' + (the endpoints then return 404). + + + ``export_secret`` is the HMAC key for the **WORM signed export** (Step 6.6b): + + ``POST /v1/audit/export`` returns a self-verifying :class:`AuditExport` bundle + + (content hash + signature) an operator archives to immutable storage. Supports + + ``${ENV_VAR}`` interpolation so it stays out of the file; empty (default) + + produces **unsigned** bundles (still content-hashed) — set it in production + to + + enable signature verification.' properties: enabled: default: true title: Enabled type: boolean + export_secret: + default: '' + title: Export Secret + type: string title: AuditConfig type: object AuthConfig: diff --git a/docs/README.md b/docs/README.md index c73e05e..644e2c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -105,7 +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 | +| [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, `chain_verified`) + `GET /v1/audit/verify` (whole-log); WORM signed export (6.6b) — `AuditExporter` (content_hash + HMAC), `POST /v1/audit/export`, offline `verify()`, `ragctl audit`; `cfg.audit.enabled` / `export_secret`; 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 | @@ -178,7 +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-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: `AuditExporter` builds a self-verifying `AuditExport` (SHA-256 `content_hash` over the events + HMAC signature, mirroring the ProvenanceSigner scheme), `POST /v1/audit/export` (tenant-scoped) + `ragctl audit` (whole-log), verifiable offline (`{content_ok, verified, reason}`); the artifact for immutable storage (S3 Object Lock) → immutability at rest; unsigned when no `export_secret`. Audit-coverage expansion + a durable live-store 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 index cbff488..bced76f 100644 --- a/docs/adr/ADR-0038-immutable-audit-log.md +++ b/docs/adr/ADR-0038-immutable-audit-log.md @@ -51,13 +51,35 @@ 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. +## Decision (6.6b) + +**6. A self-verifying signed export bundle.** `AuditExporter` (`rag_core.audit_export`) +produces an `AuditExport` with two independent integrity checks: a `content_hash` +(SHA-256 over the canonical serialisation of the events — pins the exact set + +order, recomputed on verify so any add/remove/edit is caught *even without the +secret*) and a `signature` (HMAC-SHA256 over `f"{timestamp}.{content_hash}"` — +authenticity, mirroring the `ProvenanceSigner` scheme from ADR-0026). `verify()` +returns `{signed, content_ok, verified, reason}` with `reason` distinguishing +`content_mismatch` from `signature_mismatch`. `chain_verified` records that the +source store's whole-log chain was intact at export time. + +**7. One uniform integrity model for whole-log and tenant-scoped.** The HTTP +`POST /v1/audit/export` is **tenant-scoped** (the calling tenant's events, so it +preserves the same isolation as `GET /v1/audit`); `ragctl audit` exports the +whole log (operator, in-process). Because integrity is `content_hash` + HMAC over +*whatever events are included* (not the global chain head), the *same* `verify()` +works for both — a tenant subset isn't a contiguous chain, so re-deriving the +global chain wouldn't verify it. + +**8. Unsigned is a valid degraded mode.** An exporter with no secret +(`cfg.audit.export_secret` empty) still produces a content-hashed bundle +(`signature = None`); `verify()` reports `unsigned`. Set the secret +(env-interpolated) to enable signature verification — mirroring how provenance +signing is optional but the record is not. + +The bundle is the artifact an operator writes to immutable storage (S3 Object +Lock / a WORM bucket); the content hash + signature make it tamper-evident at +rest, independent of the live store. ## Consequences @@ -69,8 +91,12 @@ signature + chain make it tamper-evident at rest. - 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. +- In-memory default store is not durable across restarts — the 6.6b WORM export + gives immutability *at rest* for an archived snapshot, but a durable `AuditStore` + backend (Postgres / append-only ledger) for the *live* log is still deferred. +- The export trusts the events the store returns; it doesn't re-derive the global + hash chain inside the bundle (a tenant subset isn't a chain), relying on + `content_hash` + HMAC instead, with `chain_verified` as a source attestation. - 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. diff --git a/docs/architecture/audit-log.md b/docs/architecture/audit-log.md index 514b045..d268ee2 100644 --- a/docs/architecture/audit-log.md +++ b/docs/architecture/audit-log.md @@ -82,10 +82,34 @@ 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. +## WORM signed export (Step 6.6b) + +`AuditExporter` (`rag_core.audit_export`) produces a self-verifying `AuditExport` +bundle and verifies one offline. Its integrity is **two independent checks**: + +- **`content_hash`** — SHA-256 over the canonical serialisation of the included + events. It pins the exact set + order and is recomputed on verify, so any + add/remove/edit is caught *without needing the secret*. +- **`signature`** — HMAC-SHA256 over `f"{timestamp}.{content_hash}"`, the same + scheme as `ProvenanceSigner` (ADR-0026) and the webhook signer. It proves + authenticity (non-forgery). Present only when `cfg.audit.export_secret` is set; + empty secret → a content-hashed but unsigned bundle (`verify` reports + `unsigned`). + +### Why content_hash instead of the chain head + +The live log is one global chain, but the HTTP export is **tenant-scoped** (the +caller's events only — same isolation as `GET /v1/audit`), and a tenant subset +isn't a contiguous chain, so re-deriving the genesis-anchored chain wouldn't +verify it. A digest over *whatever events are in the bundle* + an HMAC gives one +uniform `verify()` that works for both the tenant-scoped HTTP export and the +whole-log `ragctl audit` export. `chain_verified` carries the source log's +whole-log integrity at export time as a separate attestation. + +### What it's for + +The bundle verifies offline (content + signature, no live store needed), so it is +the artifact an operator archives to immutable storage (S3 Object Lock / a WORM +bucket): the storage layer prevents deletion/replacement, and the bundle proves +its own contents weren't altered. `POST /v1/audit/export` returns it over HTTP; +`ragctl audit` drives the seed → export → verify round-trip in-process. diff --git a/docs/reference/audit.md b/docs/reference/audit.md index 356f0e0..9a7133e 100644 --- a/docs/reference/audit.md +++ b/docs/reference/audit.md @@ -69,15 +69,45 @@ 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 ``` +### WORM signed export (Step 6.6b) + +`POST /v1/audit/export` returns a self-verifying **`AuditExport`** bundle of the +**calling tenant's** events — the artifact you archive to immutable (WORM) +storage (e.g. S3 Object Lock). Two independent integrity checks: + +- `content_hash` — SHA-256 over the canonical events (pins the exact set + order; + recomputed on verify, so tampering is caught *even without the secret*). +- `signature` — HMAC-SHA256 over `f"{timestamp}.{content_hash}"`, present when + `cfg.audit.export_secret` is set (else `null` — content-hashed but unsigned). + +`chain_verified` attests the source log's whole-log hash chain was intact at +export time. Verify a bundle **offline** with `AuditExporter`: + +```python +from rag_core.audit_export import AuditExporter +from rag_core.types import AuditExport + +result = AuditExporter("my-secret").verify(AuditExport.model_validate_json(blob)) +# result.reason ∈ {ok, unsigned, no_secret, content_mismatch, signature_mismatch} +``` + +`ragctl audit` drives the whole round-trip in-process (seed → export → verify): + +```bash +ragctl audit --secret demo-secret --out audit.json # export the whole log +ragctl audit --verify audit.json --secret demo-secret # verify a bundle offline +``` + ### Configuration ```yaml audit: - enabled: true # expose GET /v1/audit + /v1/audit/verify (default on) + enabled: true # expose GET /v1/audit + /v1/audit/verify (default on) + export_secret: ${AUDIT_EXPORT_SECRET} # HMAC key for the WORM export (empty = unsigned) ``` Events are always recorded into the store; `enabled` only gates the HTTP read -surface. +surface, and `export_secret` only controls whether export bundles are signed. ## Internals diff --git a/packages/config/src/rag_config/schema.py b/packages/config/src/rag_config/schema.py index d25c6d3..539a04b 100644 --- a/packages/config/src/rag_config/schema.py +++ b/packages/config/src/rag_config/schema.py @@ -595,10 +595,18 @@ class AuditConfig(_StrictBase): 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. + (the endpoints then return 404). + + ``export_secret`` is the HMAC key for the **WORM signed export** (Step 6.6b): + ``POST /v1/audit/export`` returns a self-verifying :class:`AuditExport` bundle + (content hash + signature) an operator archives to immutable storage. Supports + ``${ENV_VAR}`` interpolation so it stays out of the file; empty (default) + produces **unsigned** bundles (still content-hashed) — set it in production to + enable signature verification. """ enabled: bool = True + export_secret: str = "" class QuotaConfig(_StrictBase): diff --git a/packages/core/src/rag_core/audit_export.py b/packages/core/src/rag_core/audit_export.py new file mode 100644 index 0000000..f5e6c5f --- /dev/null +++ b/packages/core/src/rag_core/audit_export.py @@ -0,0 +1,123 @@ +"""AuditExporter — signed WORM export of the audit log (Step 6.6b). + +## Overview + +Produces a self-verifying, point-in-time :class:`~rag_core.types.AuditExport` +bundle of audit events and verifies one offline. The bundle's integrity is two +independent checks: + +* ``content_hash`` — SHA-256 over the canonical serialisation of the events, + pinning the exact set + order; recomputed on verify, so any add / remove / edit + is caught even without the secret. +* ``signature`` — HMAC-SHA256 over ``f"{timestamp}.{content_hash}"`` (authenticity + / non-forgery), mirroring :class:`~rag_provenance.signer.ProvenanceSigner` and + the webhook signer so operators reason about one signing model. + +An exporter built with an empty secret is *disabled* (:attr:`enabled` is False): +it still builds a bundle (content-hashed, unsigned), and :meth:`verify` reports +``no_secret`` for a signed bundle it can't check. The bundle is the artifact an +operator writes to immutable storage (S3 Object Lock); ``chain_verified`` records +that the *source* store's whole-log hash chain was intact at export time. +""" + +from __future__ import annotations + +import hashlib +import hmac +import time +from collections.abc import Sequence + +from rag_core.types import ( + AuditEvent, + AuditExport, + AuditExportSignature, + AuditExportVerification, + TenantId, +) + +_ALGORITHM = "HMAC-SHA256" + +__all__ = ["AuditExporter", "content_hash"] + + +def _canonical_bytes(events: Sequence[AuditEvent]) -> bytes: + # Newline-join each event's deterministic JSON (frozen Pydantic → stable field + # order + ISO datetimes), so re-serialising the same events yields identical + # bytes on verify — the property content_hash + HMAC rely on. + return "\n".join(e.model_dump_json() for e in events).encode("utf-8") + + +def content_hash(events: Sequence[AuditEvent]) -> str: + """SHA-256 hex over the canonical serialisation of *events*.""" + return hashlib.sha256(_canonical_bytes(events)).hexdigest() + + +class AuditExporter: + """Builds and verifies signed WORM audit exports with a shared HMAC secret.""" + + def __init__(self, secret: str = "", *, key_id: str | None = None) -> None: + self._secret = secret + self._key_id = key_id + + @property + def enabled(self) -> bool: + """``True`` when a non-empty secret was supplied (exports are signed).""" + return bool(self._secret) + + def export( + self, + events: Sequence[AuditEvent], + *, + tenant_id: TenantId | None = None, + chain_verified: bool, + timestamp: int | None = None, + ) -> AuditExport: + """Build a (signed, when a secret is set) export bundle over *events*.""" + digest = content_hash(events) + signature = None + if self._secret: + ts = timestamp if timestamp is not None else int(time.time()) + signature = AuditExportSignature( + algorithm=_ALGORITHM, + key_id=self._key_id, + timestamp=ts, + value=self._compute(digest, timestamp=ts), + ) + return AuditExport( + tenant_id=tenant_id, + count=len(events), + content_hash=digest, + chain_verified=chain_verified, + events=list(events), + signature=signature, + ) + + def verify(self, export: AuditExport) -> AuditExportVerification: + """Verify *export*: recompute the content hash, then the HMAC (constant-time).""" + recomputed = content_hash(export.events) + content_ok = hmac.compare_digest(recomputed, export.content_hash) + sig = export.signature + if sig is None: + return AuditExportVerification( + signed=False, content_ok=content_ok, verified=False, reason="unsigned" + ) + if not self._secret: + return AuditExportVerification( + signed=True, content_ok=content_ok, verified=False, reason="no_secret" + ) + expected = self._compute(export.content_hash, timestamp=sig.timestamp) + sig_ok = hmac.compare_digest(expected, sig.value) + verified = content_ok and sig_ok + if verified: + reason = "ok" + elif not content_ok: + reason = "content_mismatch" + else: + reason = "signature_mismatch" + return AuditExportVerification( + signed=True, content_ok=content_ok, verified=verified, reason=reason + ) + + def _compute(self, digest: str, *, timestamp: int) -> str: + signed_string = f"{timestamp}.{digest}".encode() + return hmac.new(self._secret.encode("utf-8"), signed_string, hashlib.sha256).hexdigest() diff --git a/packages/core/src/rag_core/types.py b/packages/core/src/rag_core/types.py index f363414..1de37b5 100644 --- a/packages/core/src/rag_core/types.py +++ b/packages/core/src/rag_core/types.py @@ -1362,6 +1362,61 @@ class AuditEvent(BaseModel): timestamp: datetime = Field(default_factory=_utcnow) +class AuditExportSignature(BaseModel): + """Detached HMAC signature over an :class:`AuditExport` (Step 6.6b). + + Mirrors :class:`ProvenanceSignature` — HMAC-SHA256 over + ``f"{timestamp}.{content_hash}"`` — so the WORM export reuses one signing + model across the platform. ``timestamp`` (unix seconds) is bound into the + digest; ``key_id`` is an opaque label for key-rotation bookkeeping. + """ + + model_config = {"frozen": True} + + algorithm: str = "HMAC-SHA256" + key_id: str | None = None + timestamp: int = 0 + value: str = "" # hex digest + + +class AuditExport(BaseModel): + """A signed, point-in-time WORM export of the audit log (Step 6.6b). + + A self-verifying bundle: ``content_hash`` is the SHA-256 over the canonical + serialisation of ``events`` (pins the exact set + order), and ``signature`` + is the HMAC over that hash (authenticity). An offline verifier recomputes + ``content_hash`` from ``events`` and re-checks the HMAC, so any add / remove / + edit is detected. ``chain_verified`` attests the *source* store's whole-log + hash chain was intact at export time. ``tenant_id`` is set for a + tenant-scoped export (the HTTP path) and ``None`` for a whole-log export. + The bundle is the artifact an operator writes to immutable storage + (e.g. S3 Object Lock). + """ + + model_config = {"frozen": True} + + schema_version: str = "audit-export-v1" + tenant_id: TenantId | None = None + exported_at: datetime = Field(default_factory=_utcnow) + count: int = 0 + content_hash: str = "" # sha256 hex over the canonical events + chain_verified: bool = True + events: list[AuditEvent] = Field(default_factory=list) + signature: AuditExportSignature | None = None + + +class AuditExportVerification(BaseModel): + """Result of verifying an :class:`AuditExport` (Step 6.6b).""" + + model_config = {"frozen": True} + + signed: bool = False # whether a signature was present + content_ok: bool = False # whether content_hash matched the events + verified: bool = False # content_ok AND the signature matched + # "ok" | "unsigned" | "no_secret" | "content_mismatch" | "signature_mismatch" + reason: str = "" + + # --------------------------------------------------------------------------- # Agent-loop validation spike (Step 2.11) — types reused by ``AgentLoopV0`` # in ``rag-retrieval`` but living in ``rag-core`` so downstream packages diff --git a/packages/ragctl/src/ragctl/main.py b/packages/ragctl/src/ragctl/main.py index d5ee401..8937bec 100644 --- a/packages/ragctl/src/ragctl/main.py +++ b/packages/ragctl/src/ragctl/main.py @@ -4806,6 +4806,85 @@ async def _run() -> None: asyncio.run(_run()) +@app.command("audit") +def audit( + verify_file: str | None = typer.Option( + None, "--verify", help="Verify an existing export bundle JSON file instead of exporting." + ), + secret: str = typer.Option( + "demo-secret", "--secret", help="HMAC secret for signing / verifying the bundle." + ), + out: str | None = typer.Option( + None, "--out", help="Write the export bundle JSON to this path (the WORM artifact)." + ), + events: int = typer.Option(3, "--events", "-n", help="How many demo events to seed."), +) -> None: + """Demo the Step 6.6b signed WORM audit export: seed → export → verify. + + With no ``--verify`` it seeds an in-memory hash-chain audit log, exports a + signed :class:`~rag_core.types.AuditExport` bundle, and verifies the + round-trip. ``--out`` writes the bundle JSON (the artifact you'd archive to + immutable storage); ``--verify FILE`` re-loads a bundle and checks it offline. + No external services. + + Example:: + + ragctl audit --out audit.json + ragctl audit --verify audit.json --secret demo-secret + """ + from pathlib import Path + + from rag_core.audit_export import AuditExporter + from rag_core.types import AuditExport + + exporter = AuditExporter(secret) + + if verify_file is not None: + export = AuditExport.model_validate_json(Path(verify_file).read_text(encoding="utf-8")) + result = exporter.verify(export) + typer.echo(f"events: {export.count}") + typer.echo(f"content: {export.content_hash[:16]}…") + typer.echo( + f"verified: {result.verified} " + f"(reason={result.reason}, content_ok={result.content_ok})" + ) + return + + from rag_core.audit import AuditWriter + from rag_core.spi.noop import NoopAuditStore + from rag_core.types import AuditEvent, AuditOutcome, PrincipalId, TenantId, TraceContext + + store = NoopAuditStore() + writer = AuditWriter(store) + tenant = TenantId("acme") + for i in range(max(1, events)): + writer.write( + AuditEvent( + tenant_id=tenant, + principal_id=PrincipalId("alice"), + action="corpus.route", + resource=f"corpus-{i}", + outcome=AuditOutcome.allowed, + trace_context=TraceContext(), + ) + ) + + export = exporter.export(store.events(), chain_verified=store.verify_chain()) + result = exporter.verify(export) + sig = export.signature + typer.echo(f"\naudit export — {export.count} events, chain_verified={export.chain_verified}") + typer.echo("─" * 64) + typer.echo(f" content_hash: {export.content_hash}") + typer.echo( + f" signed: {sig is not None}" + + (f" (alg={sig.algorithm})" if sig is not None else "") + ) + typer.echo(f" verified: {result.verified} (reason={result.reason})") + if out is not None: + Path(out).write_text(export.model_dump_json(indent=2), encoding="utf-8") + typer.echo(f" written: {out}") + + def main() -> None: app() diff --git a/packages/ragctl/tests/test_audit.py b/packages/ragctl/tests/test_audit.py new file mode 100644 index 0000000..7a197c0 --- /dev/null +++ b/packages/ragctl/tests/test_audit.py @@ -0,0 +1,40 @@ +"""Tests for ``ragctl audit`` — Step 6.6b. + +Smoke command seeds an in-memory hash-chain audit log, exports a signed WORM +bundle, verifies the round-trip, and (optionally) writes/reads the bundle JSON. +No infrastructure. +""" + +from __future__ import annotations + +from pathlib import Path + +from ragctl.main import app +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_audit_export_round_trip() -> None: + result = runner.invoke(app, ["audit", "--secret", "topsecret", "--events", "4"]) + assert result.exit_code == 0, result.output + assert "audit export — 4 events" in result.output + assert "signed: True" in result.output + assert "verified: True" in result.output + + +def test_audit_write_and_verify_file(tmp_path: Path) -> None: + bundle = tmp_path / "audit.json" + w = runner.invoke(app, ["audit", "--secret", "s3cret", "--out", str(bundle)]) + assert w.exit_code == 0, w.output + assert bundle.exists() + + v = runner.invoke(app, ["audit", "--verify", str(bundle), "--secret", "s3cret"]) + assert v.exit_code == 0, v.output + assert "verified: True" in v.output + + # wrong secret → not verified + bad = runner.invoke(app, ["audit", "--verify", str(bundle), "--secret", "wrong"]) + assert bad.exit_code == 0, bad.output + assert "verified: False" in bad.output + assert "signature_mismatch" in bad.output diff --git a/tests/audit/test_export.py b/tests/audit/test_export.py new file mode 100644 index 0000000..32121dc --- /dev/null +++ b/tests/audit/test_export.py @@ -0,0 +1,116 @@ +"""Unit tests for AuditExporter — signed WORM export + offline verify (Step 6.6b).""" + +from __future__ import annotations + +import hashlib + +from rag_core.audit_export import AuditExporter, content_hash +from rag_core.types import AuditEvent, AuditOutcome, PrincipalId, TenantId, TraceContext + + +def _event(action: str = "corpus.route", *, tenant: str = "acme") -> AuditEvent: + return AuditEvent( + tenant_id=TenantId(tenant), + principal_id=PrincipalId("alice"), + action=action, + resource="r", + outcome=AuditOutcome.allowed, + trace_context=TraceContext(), + ) + + +_EVENTS = [_event("a"), _event("b"), _event("c")] + + +# --------------------------------------------------------------------------- +# content_hash +# --------------------------------------------------------------------------- +def test_content_hash_is_deterministic() -> None: + assert content_hash(_EVENTS) == content_hash(_EVENTS) + + +def test_content_hash_changes_with_events() -> None: + assert content_hash(_EVENTS) != content_hash(_EVENTS[:2]) + + +def test_empty_content_hash_is_sha256_of_empty() -> None: + assert content_hash([]) == hashlib.sha256(b"").hexdigest() + + +# --------------------------------------------------------------------------- +# sign → verify +# --------------------------------------------------------------------------- +def test_sign_then_verify_ok() -> None: + exporter = AuditExporter("topsecret") + export = exporter.export(_EVENTS, chain_verified=True) + assert export.signature is not None + assert export.count == 3 + assert export.chain_verified is True + result = exporter.verify(export) + assert result.verified is True + assert result.reason == "ok" + assert result.signed is True + assert result.content_ok is True + + +def test_unsigned_export_when_no_secret() -> None: + exporter = AuditExporter("") # disabled + assert exporter.enabled is False + export = exporter.export(_EVENTS, chain_verified=True) + assert export.signature is None + assert export.content_hash # still content-hashed + result = exporter.verify(export) + assert result.signed is False + assert result.reason == "unsigned" + assert result.content_ok is True # the hash still matches the events + + +def test_no_secret_verifier_cannot_check_signed_bundle() -> None: + export = AuditExporter("topsecret").export(_EVENTS, chain_verified=True) + result = AuditExporter("").verify(export) + assert result.signed is True + assert result.verified is False + assert result.reason == "no_secret" + + +def test_wrong_secret_is_signature_mismatch() -> None: + export = AuditExporter("right").export(_EVENTS, chain_verified=True) + result = AuditExporter("wrong").verify(export) + assert result.verified is False + assert result.reason == "signature_mismatch" + assert result.content_ok is True # events untouched; only the key differs + + +def test_content_tampering_is_detected() -> None: + exporter = AuditExporter("topsecret") + export = exporter.export(_EVENTS, chain_verified=True) + # Add an event but keep the original content_hash + signature → mismatch. + tampered = export.model_copy(update={"events": [*export.events, _event("injected")]}) + result = exporter.verify(tampered) + assert result.content_ok is False + assert result.verified is False + assert result.reason == "content_mismatch" + + +def test_signature_tampering_is_detected() -> None: + exporter = AuditExporter("topsecret") + export = exporter.export(_EVENTS, chain_verified=True) + assert export.signature is not None + bad_sig = export.signature.model_copy(update={"value": "00" * 32}) + tampered = export.model_copy(update={"signature": bad_sig}) + result = exporter.verify(tampered) + assert result.content_ok is True + assert result.verified is False + assert result.reason == "signature_mismatch" + + +def test_empty_export_signs_and_verifies() -> None: + exporter = AuditExporter("topsecret") + export = exporter.export([], chain_verified=True) + assert export.count == 0 + assert exporter.verify(export).verified is True + + +def test_tenant_id_is_carried() -> None: + export = AuditExporter("s").export(_EVENTS, tenant_id=TenantId("acme"), chain_verified=True) + assert export.tenant_id == "acme"