AUDIT-WP-0013-T06: independent full-scope readers may count any source (NK-WP-0035-T04)
GET /v1/reconciliation now admits may_read + tenants ["*"] identities for any source, marked basis: independent_reader. Scoped readers get 403 full_tenant_scope_required; writers keep 403 source_not_allowed. Counts only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 308701@bnt-lap001 Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
This commit is contained in:
parent
64c0fa5285
commit
d46e89ee21
6 changed files with 238 additions and 9 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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": (
|
||||
|
|
|
|||
|
|
@ -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=<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=<source>&tenant=<tenant>&since=<rfc3339>&until=<rfc3339>&stream_instance_id=<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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
144
tests/test_independent_reconciliation.py
Normal file
144
tests/test_independent_reconciliation.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue