diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 2ddb409..a7309fa 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -97,6 +97,8 @@ | task | AUDIT-WP-0013-T03 | done | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md | | task | AUDIT-WP-0013-T04 | done | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md | | task | AUDIT-WP-0013-T05 | done | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md | +| task | AUDIT-WP-0013-T06 | done | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md | +| task | AUDIT-WP-0013-T07 | wait | — | workplans/AUDIT-WP-0013-cross-repo-sender-admission.md | | task | AUDIT-WP-0014-T01 | done | — | workplans/AUDIT-WP-0014-execution-attribution-receipt.md | | task | AUDIT-WP-0014-T02 | wait | — | workplans/AUDIT-WP-0014-execution-attribution-receipt.md | | task | AUDIT-WP-0015-T01 | wait | — | workplans/AUDIT-WP-0015-independent-evidence-returns.md | diff --git a/audit_core/ingestion.py b/audit_core/ingestion.py index dfd3ee4..a4e1bac 100644 --- a/audit_core/ingestion.py +++ b/audit_core/ingestion.py @@ -185,7 +185,9 @@ class IngestionApplication: # §9.6 makes that comparison the source's own detection obligation. A # writer with may_read: false must therefore be able to ask, or the # obligation audit-core argued for is undischargeable by every sender - # actually registered. Cross-source counts stay behind may_read. + # actually registered. Cross-source counts stay behind may_read AND + # full tenant scope (AUDIT-WP-0013-T06): an operator who may already + # read every event by id may also count another source's window. if method == "GET" and path == "/v1/reconciliation": return self._reconciliation(start_response, environ, identity) @@ -333,12 +335,26 @@ class IngestionApplication: ) def _reconciliation(self, start_response, environ, identity): - """Per-class counts of one source's own events (AUDIT-WP-0009-T06). + """Per-class counts of one source's events (AUDIT-WP-0009-T06). - Counts, never payloads, and never another source's. The comparison a - source makes against its own state transitions is the §9.6 detection - obligation; audit-core supplies the numerator and says nothing about - what the answer means. + Counts, never payloads. The comparison a source makes against its own + state transitions is the §9.6 detection obligation; audit-core + supplies the numerator and says nothing about what the answer means. + + Two callers are admitted, and only two (AUDIT-WP-0013-T06): + + * the source itself — any identity whose ``sources`` permit it, with or + without ``may_read``; + * an independent reader — ``may_read`` with full tenant scope + (``tenants: ["*"]``). Such an identity can already read every stored + event by id or correlation, so per-class counts tell it strictly + less than it may read. A tenant-scoped reader is refused: counting + another source's whole window enumerates existence beyond what an + id lookup filtered to its tenants discloses. The response carries + ``basis: "independent_reader"`` so a receipt cannot be mistaken for + the source's own reconciliation. + + A writer without ``may_read`` still may not count another source. """ query = parse_qs(environ.get("QUERY_STRING", ""), keep_blank_values=True) source = (query.get("source") or [""])[0] @@ -346,13 +362,19 @@ class IngestionApplication: return self._json( start_response, HTTPStatus.BAD_REQUEST, {"error": "source_required"} ) - if not identity.permits_source(source): + independent = not identity.permits_source(source) + if independent and not identity.may_read: # 403 rather than an empty count. A zero here would read as "we # hold none of yours", which is a materially different and false # answer to a question about completeness. return self._json( start_response, HTTPStatus.FORBIDDEN, {"error": "source_not_allowed"} ) + if independent and not identity.has_full_tenant_scope(): + return self._json( + start_response, HTTPStatus.FORBIDDEN, + {"error": "full_tenant_scope_required"}, + ) window = _window(query) if window is None: return self._json( @@ -402,6 +424,7 @@ class IngestionApplication: "until": until, "counts": counts, **({"stream_instance_id": stream_instance_id} if instances else {}), + **({"basis": "independent_reader"} if independent else {}), # Said in the response because this is the number most likely to # be quoted out of context in someone else's conformance argument. "means": ( diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 552ddfa..9020a54 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -83,8 +83,21 @@ curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \ # Every event sharing a correlation id curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \ 'http://127.0.0.1:8080/v1/events?correlation_id=&limit=100' + +# Independent per-class counts for ANY source's window (AUDIT-WP-0013-T06). +# Needs may_read AND tenants ["*"]; a scoped reader gets 403 +# full_tenant_scope_required, a writer 403 source_not_allowed. +# Window is half-open on accepted_at: since <= accepted_at < until. +curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \ + 'http://127.0.0.1:8080/v1/reconciliation?source=&tenant=&since=&until=&stream_instance_id=' ``` +The independent count response is the own-source shape plus +`basis: "independent_reader"`: `source`, `tenant`, `since`, `until`, +`counts` (`[{class, count}]`), optional `stream_instance_id`, and `means`. +Counts only. Sequence and lag are not aggregated; take them from the +exact-id reads above (`accepted_at`, `observed_at`, `details.data`). + Stored records keep the original `event_id`, `payload_hash`, and `accepted_at`. Redacted fields appear in `details.redaction.paths`; the payload itself is stored already masked. diff --git a/docs/section-4-source-of-evidence.md b/docs/section-4-source-of-evidence.md index 40ae70c..9027a41 100644 --- a/docs/section-4-source-of-evidence.md +++ b/docs/section-4-source-of-evidence.md @@ -87,7 +87,7 @@ needs: | --- | --- | | The published class — load-bearing, and rare or volume | Accepting that classification as supplied and never inferring it | | The cadence, per class | `heartbeat_classes` on the registration; `POST /v1/events` class `audit-core.heartbeat` | -| Reconciliation of its own counts | `GET /v1/reconciliation`, scoped to the caller's own sources, counts and never payloads | +| Reconciliation of its own counts | `GET /v1/reconciliation`, scoped to the caller's own sources, counts and never payloads; an independent full-scope reader (`may_read`, `tenants: ["*"]`) may count any source, marked `basis: independent_reader` (AUDIT-WP-0013-T06) | | — | `GET /v1/stream-findings`, and the `means` field on every finding | `access-engine`'s expectation — heartbeat plus reconciliation rather than rate, diff --git a/tests/test_independent_reconciliation.py b/tests/test_independent_reconciliation.py new file mode 100644 index 0000000..6212d02 --- /dev/null +++ b/tests/test_independent_reconciliation.py @@ -0,0 +1,144 @@ +"""AUDIT-WP-0013-T06 — independent cross-source reconciliation counts. + +NK-WP-0035-T04: an operator reader registered for one source must be able to +count another source's window, without payloads, and only with full tenant +scope. A writer still may not count another source. +""" + +import io +import json + +import pytest + +from audit_core.ingestion import IngestionApplication +from audit_core.senders import SenderIdentity, SenderRegistry +from audit_core.sqlite_backend import SQLiteAuditBackend + + +def invoke(app, path, *, token, method="GET", payload=None, key=None): + raw = json.dumps(payload).encode() if payload is not None else b"" + environ = { + "PATH_INFO": path.split("?")[0], + "QUERY_STRING": path.split("?")[1] if "?" in path else "", + "REQUEST_METHOD": method, + "CONTENT_LENGTH": str(len(raw)), + "wsgi.input": io.BytesIO(raw), + "HTTP_AUTHORIZATION": f"Bearer {token}", + } + if key: + environ["HTTP_IDEMPOTENCY_KEY"] = key + result = {} + out = b"".join(app(environ, lambda status, headers: result.update(status=status))) + return result["status"], (json.loads(out) if out else {}) + + +TENANT = "tenant:friendly:binky" +INSTANCE = "b351fa6b-e0d6-4f88-b7c9-6bacb1a112b7" +WINDOW = "&since=2026-09-01T00:00:00%2B00:00&until=2027-01-01T00:00:00%2B00:00" + +QONTO = SenderIdentity( + name="qonto-assistant", tokens=("qonto",), sources=frozenset({"qonto-assistant"}), + tenants=frozenset({TENANT}), may_write=True, may_read=False, +) +USER_ENGINE = SenderIdentity( + name="user-engine", tokens=("writer",), sources=frozenset({"user-engine"}), + tenants=frozenset({"*"}), may_write=True, may_read=False, +) +OPERATOR = SenderIdentity( + name="operator", tokens=("operator",), sources=frozenset({"user-engine"}), + tenants=frozenset({"*"}), may_write=False, may_read=True, +) +SCOPED_READER = SenderIdentity( + name="scoped-reader", tokens=("scoped",), sources=frozenset({"user-engine"}), + tenants=frozenset({TENANT}), may_write=False, may_read=True, +) + + +@pytest.fixture +def app(tmp_path): + backend = SQLiteAuditBackend(str(tmp_path / "s.db")) + return IngestionApplication( + backend, SenderRegistry([QONTO, USER_ENGINE, OPERATOR, SCOPED_READER]) + ) + + +def emit(app, event_id, action, *, instance=INSTANCE, secret=None): + data = {"stream_instance_id": instance, "sequence": 1} + if secret: + data["binding"] = secret + payload = { + "id": event_id, "type": action, "source": "qonto-assistant", + "subject": "s:1", "tenant": TENANT, "correlation_id": "c-1", + "occurred_at": "2026-10-15T19:40:00+00:00", "data": data, + } + status, _ = invoke(app, "/v1/events", token="qonto", method="POST", + payload=payload, key=event_id) + assert status.startswith("202") + + +def count(app, token, query): + return invoke(app, "/v1/reconciliation?" + query + WINDOW, token=token) + + +def test_full_scope_reader_counts_another_source(app): + emit(app, "h-1", "audit.heartbeat") + emit(app, "h-2", "audit.heartbeat") + emit(app, "d-1", "audit.deny") + emit(app, "x-1", "audit.deny", instance="other-instance") + status, body = count( + app, "operator", + f"source=qonto-assistant&tenant={TENANT}&stream_instance_id={INSTANCE}", + ) + assert status.startswith("200") + assert body["counts"] == [ + {"class": "audit.deny", "count": 1}, + {"class": "audit.heartbeat", "count": 2}, + ] + assert body["basis"] == "independent_reader" + assert body["stream_instance_id"] == INSTANCE + + +def test_full_scope_reader_may_omit_tenant(app): + emit(app, "d-1", "audit.deny") + status, body = count(app, "operator", "source=qonto-assistant") + assert status.startswith("200") + assert body["tenant"] is None + assert body["counts"] == [{"class": "audit.deny", "count": 1}] + + +def test_independent_counts_never_carry_payloads(app): + emit(app, "d-1", "audit.deny", secret="value-not-to-leak") + _, body = count(app, "operator", f"source=qonto-assistant&tenant={TENANT}") + assert set(body) == { + "source", "tenant", "since", "until", "counts", "means", "basis", + } + assert "value-not-to-leak" not in json.dumps(body) + + +def test_tenant_scoped_reader_may_not_count_another_source(app): + emit(app, "d-1", "audit.deny") + status, body = count(app, "scoped", f"source=qonto-assistant&tenant={TENANT}") + assert status.startswith("403") + assert body["error"] == "full_tenant_scope_required" + + +def test_writer_still_may_not_count_another_source(app): + status, body = count(app, "writer", f"source=qonto-assistant&tenant={TENANT}") + assert status.startswith("403") + assert body["error"] == "source_not_allowed" + + +def test_own_source_reconciliation_is_unchanged(app): + emit(app, "d-1", "audit.deny") + status, body = count(app, "qonto", f"source=qonto-assistant&tenant={TENANT}") + assert status.startswith("200") + assert "basis" not in body + assert body["counts"] == [{"class": "audit.deny", "count": 1}] + + +def test_independent_reader_still_needs_a_bounded_window(app): + status, body = invoke( + app, "/v1/reconciliation?source=qonto-assistant", token="operator" + ) + assert status.startswith("400") + assert body["error"] == "since_and_until_required" diff --git a/workplans/AUDIT-WP-0013-cross-repo-sender-admission.md b/workplans/AUDIT-WP-0013-cross-repo-sender-admission.md index 4867973..1167f66 100644 --- a/workplans/AUDIT-WP-0013-cross-repo-sender-admission.md +++ b/workplans/AUDIT-WP-0013-cross-repo-sender-admission.md @@ -8,7 +8,7 @@ status: blocked owner: codex topic_slug: audit-evidence-dependencies created: "2026-10-04" -updated: "2026-10-15" +updated: "2026-10-28" state_hub_workstream_id: "9e400de2-6ba1-520e-9b25-94aa2470e528" --- @@ -154,3 +154,50 @@ capped and cannot serve as complete export. Operational repeated ledger custody, per-pin source cadence/runtime adoption and independent observation still wait. The canonical sibling-owner fixture check passed1, resolving the isolated checkout's expected skip without modifying foreign source. + +## Sanction independent cross-source reconciliation counts + +```task +id: AUDIT-WP-0013-T06 +status: done +priority: high +``` + +NK-WP-0035-T04 (State Hub message 6ed5cafc, receipt +net-kingdom docs/evidence/2026-10-15-cadence-adoption/receiver-readback-20261023.json): +the operator reader is registered for source `user-engine`, so +`GET /v1/reconciliation?source=qonto-assistant` returned 403 `source_not_allowed` +while exact-id reads succeeded 3/3. + +Decided: option (a). `GET /v1/reconciliation` admits a second caller class, an +independent reader holding `may_read: true` and `tenants: ["*"]`, for any +source. Such a reader may already read every event by id or correlation, so +per-class counts disclose strictly less. A tenant-scoped reader gets 403 +`full_tenant_scope_required` (counting a whole window enumerates existence +beyond a tenant-filtered id lookup); a writer without `may_read` still gets 403 +`source_not_allowed`. Own-source behaviour and response shape are unchanged. +Independent answers carry `basis: "independent_reader"` so a receipt cannot be +mistaken for the source's own §9.6 reconciliation. No new role, endpoint or +registry field: the existing operator identity is the sanctioned reader. + +Response: `source`, `tenant` (null if omitted), `since`, `until` (normalized, +half-open on `accepted_at`), `counts` `[{class, count}]`, optional +`stream_instance_id`, `basis`, `means`. Counts only; sequence and lag are not +aggregated and stay with the exact-id reads (`accepted_at`, `observed_at`, +`details.data`). Source: audit_core/ingestion.py `_reconciliation`; tests: +tests/test_independent_reconciliation.py (7). Runbook and section-4 docs updated. + +```task +id: AUDIT-WP-0013-T07 +status: wait +priority: high +blocking_reason: "Founder-run: build/publish an image from the T06 commit, pin its digest in deploy/audit-core.yaml, roll out, then NetKingdom reruns its readback." +blocked_on: founder +``` + +No registry or Secret change is expected: the operator identity already reads +by id across tenants. Value-safe precheck with the operator token: `GET /v1/stats` +returns 200 only for a full-scope reader (403 `full_tenant_scope_required` +otherwise, which would need a registry scope change instead). The deployed +b479801b receiver predates T06 and will keep returning `source_not_allowed` +until the new image runs.