fix(audit): retain authenticated consume identity and exact binding

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
tegwick 2026-10-16 07:51:39 +02:00
parent 9491d1d3a7
commit 477c683991
6 changed files with 221 additions and 0 deletions

View file

@ -176,6 +176,7 @@ class App:
approval_id,
data.get("request_digest"),
decision_id=data.get("decision_id"),
consumer=identity,
)
if "check" in path or path.endswith("/authorize"):
return 404, {"error": "not_found", "message": "no such surface"}

View file

@ -18,6 +18,7 @@ from pathlib import Path
from typing import Any, Callable
from uuid import uuid4
from .auth import Identity
from .binding import binding_digest, canonical_binding, require_digest
from .errors import (
Conflict,
@ -750,6 +751,7 @@ class Engine:
request_digest: str | None,
*,
decision_id: str | None = None,
consumer: Identity | None = None,
) -> dict[str, Any]:
"""Atomically spend an approval for one decision-bound request.
@ -819,6 +821,19 @@ class Engine:
actor=row["actor"],
extra={
"binding_digest": row["binding_digest"],
"binding": json.loads(row["binding_json"]),
"pdp_digest": row["pdp_digest"],
"pdp_path": bool(row["pdp_path"]),
# Only the HTTP boundary supplies an authenticated identity.
# Internal/legacy calls explicitly leave provenance unknown.
"consumer": ({
"subject_id": consumer.subject,
"issuer": consumer.issuer,
"principal_type": consumer.principal_type,
"tenant": consumer.tenant,
"tenant_source": consumer.tenant_source,
"evidence_ref": consumer.evidence_ref,
} if consumer is not None else None),
"request_digest": digest,
**({"decision_id": decision_id} if decision_id else {}),
"threshold": self._threshold_evidence(

View file

@ -130,3 +130,32 @@ where the identities are the point. A consumer that wants the threshold reads
Distinctness itself is a storage invariant rather than a recomputation:
`entries` is UNIQUE on `(approval_id, subject_id)`, so a repeat approver is
refused at insert.
## Exact use provenance (source refinement, 2026-10-16)
New `approval.use` rows additionally retain `details.binding` (the native
five-field act), its existing `binding_digest`, nullable `pdp_digest` and
explicit `pdp_path`. `request_digest` remains the full decision-bound request,
and `decision_id` remains caller-supplied correlation, not a verified PDP verdict.
These distinct digests must never be substituted for each other.
`details.consumer` records the authenticated consume caller's `subject_id`,
`issuer`, `principal_type`, exact `tenant`, nullable verified `tenant_source` and
token-fingerprint `evidence_ref`. It comes solely from the HTTP authenticator;
body fields cannot override it. Top-level `actor` retains its existing meaning:
the actor captured on the approved act. Consumer provenance distinguishes the
service spending the object from that initiating actor. Bearer values, scopes,
roles and assurance are not copied into this record.
In-process calls without an authenticated caller record `consumer: null`.
Existing outbox rows remain unchanged; missing/null consumer evidence cannot be
backfilled as authenticated. Same-digest retries preserve the first use row and
never replace its original consumer or decision correspondence. All fields are
inserted atomically with the consumption CAS, survive restart, and pass unchanged
through the existing asynchronous audit adapter. No schema migration is needed.
This source evidence is not a portable signed receipt, execution grant or
server-identity proof. Consumer confirmation still uses the existing trusted
authenticated read/consume transport and a separate live execution journal.
Audit source admission, deployed issuer/profile, lifecycle readback and actual
served-PEP proof remain APPROVAL-WP-0002-T01/T04/T05 native gates.

View file

@ -0,0 +1,110 @@
"""Source evidence binds authenticated consumer, approved act and PDP digest."""
import json
import pytest
from approval_engine.api import App, call
from approval_engine.audit import audit_envelope
from approval_engine.store import Engine
from tests.conftest import FROZEN, binding, validity
from tests.test_auth import _jwt
from tests.test_tenant_provenance import authenticator
FULL = "sha256:" + "ab" * 32
FREE = "sha256:" + "cd" * 32
def approved(engine):
obj = engine.create(binding(), validity(), pdp_digest=FREE, pdp_path=True,
human_control=True)
engine.add_entry(obj.id, "human:founder", principal_type="human")
return obj
def token(private, **overrides):
claims = dict(sub="service:mediator", tenant="tenant:platform",
principal_type="service", tenant_source="registration",
scope="approval:consume")
claims.update(overrides)
return _jwt(private, **claims)
def consume(app, obj, bearer, **body):
return call(app, "POST", f"/v1/approvals/{obj.id}/consume",
{"request_digest": FULL, "decision_id": "decision:original", **body},
authorization="Bearer " + bearer)
def uses(engine):
return [r for r in engine.undrained() if r["class"] == "use"]
def test_authenticated_consumer_and_exact_binding_survive_restart_and_drain(tmp_path):
private, auth = authenticator()
path = tmp_path / "approval.sqlite"
engine = Engine(path, clock=lambda: FROZEN)
obj = approved(engine)
bearer = token(private)
status, result = consume(App(engine, auth), obj, bearer,
consumer={"subject_id": "spoofed"}, pdp_digest="spoofed")
assert status == 200 and result["idempotent"] is False
event = uses(engine)[0]
details = event["payload"]["details"]
assert event["payload"]["actor"] == binding()["actor"]
assert details["binding"] == binding()
assert details["binding_digest"] == obj.binding_digest
assert details["pdp_digest"] == FREE and details["pdp_path"] is True
assert details["request_digest"] == FULL
assert details["decision_id"] == "decision:original"
assert details["consumer"] == {
"subject_id": "service:mediator", "issuer": "https://keycape.example",
"principal_type": "service", "tenant": "tenant:platform",
"tenant_source": "registration", "evidence_ref": auth.authenticate("Bearer " + bearer).evidence_ref,
}
assert bearer not in json.dumps(event)
assert "consumer" not in json.dumps(engine.claim(obj.id))
engine.close()
engine = Engine(path, clock=lambda: FROZEN, auto_migrate=False)
assert uses(engine) == [event]
# A second authenticated caller cannot rewrite the first consumer evidence.
status, retry = consume(App(engine, auth), obj, token(private, sub="service:retry"),
decision_id="decision:other")
assert status == 200 and retry["idempotent"] is True
assert retry["decision_id"] == "decision:original"
assert uses(engine) == [event]
delivered = []
engine.drain(lambda payload: delivered.append(audit_envelope(payload)))
use = next(e for e in delivered if e["type"] == "approval.use")
assert use["id"] == event["event_id"]
assert use["data"]["details"] == details
engine.close()
@pytest.mark.parametrize("overrides,expected", [
({"tenant": "tenant:foreign"}, 403),
({"principal_type": "human"}, 403),
({"scope": "approval:read"}, 403),
({"tenant_source": "spoofed"}, 401),
])
def test_unadmitted_consumer_cannot_create_evidence(engine, overrides, expected):
private, auth = authenticator()
obj = approved(engine)
assert consume(App(engine, auth), obj, token(private, **overrides))[0] == expected
assert uses(engine) == [] and engine.get(obj.id).status == "approved"
def test_outbox_failure_rolls_back_consumer_and_spend(engine):
private, auth = authenticator()
obj = approved(engine)
engine.fail_outbox = True
assert consume(App(engine, auth), obj, token(private))[0] == 503
assert engine.get(obj.id).status == "approved" and uses(engine) == []
engine.fail_outbox = False
assert consume(App(engine, auth), obj, token(private, sub="service:recovered"))[0] == 200
assert uses(engine)[0]["payload"]["details"]["consumer"]["subject_id"] == "service:recovered"
def test_internal_calls_do_not_invent_authenticated_consumer(engine):
obj = approved(engine)
engine.consume(obj.id, FULL)
assert uses(engine)[0]["payload"]["details"]["consumer"] is None

View file

@ -34,6 +34,15 @@ Current status: blocked after the 2026-10-04 dependency review. T02, T03 and
T06 are complete. T01, T04 and T05 use `wait` for blocked task status; their
remaining owner receipts are specified in `docs/production-dependencies.md`.
2026-10-16 target-architecture demand reconciliation: CUST-WP-0080 explicitly
selects necessary authenticated-consume/provenance/audit and exact served-PEP
interfaces from T01/T04/T05. APPROVAL-WP-0004 implements only the reachable
source use-evidence gap: verified consuming identity and approved act/PDP binding
in the existing transactional outbox. This scoped implementation demand does
not activate unrelated residuals or authorize credentials, migration, rollout,
production approvals or native effects. T01/T04/T05 remain wait for their
existing independently owned native receipts; this workplan remains residual.
Review completed 2026-09-01. The plan is active. Production mode will verify
KeyCape JWT signatures and exact issuer/audience/scopes; local caller-supplied
identity never becomes authenticated evidence. SQLite remains the first

View file

@ -0,0 +1,57 @@
---
id: APPROVAL-WP-0004
type: workplan
title: "Retain exact authenticated consume source provenance"
domain: infotech
repo: approval-engine
status: active
flavor: implementation
owner: codex
topic_slug: netkingdom
created: "2026-10-16"
updated: "2026-10-16"
depends_on: []
---
Explicit NetKingdom target demand under CUST-WP-0080 selects the source evidence
subset of APPROVAL-WP-0002-T01/T04/T05. INTENT basis: authenticated consumable
object, exact binding and atomic local evidence. Existing authoritative CAS,
confirmation transport and audit adapter are reused. No second approval/PDP
service, portable signer, foreign owner review or live action is added.
## Preserve verified consumer and approved operation in the use source row
```task
id: APPROVAL-WP-0004-T01
status: done
priority: high
```
Source implementation supplies the HTTP-verified Identity to consume and writes
its bounded metadata plus native act/PDP bindings into the existing atomic use
outbox. Body-provided provenance is ignored, internal calls retain null, original
idempotent evidence is immutable, and historical rows remain untouched. No DB
schema change, response/claim widening or identity scope change is needed.
Seven new source tests cover signature-verified HTTP consumption, restart,
adapter preservation, spoofed fields, denied identities, retry immutability and
rollback. Source checks do not establish native acceptance.
## Integrate independently reviewed source and synchronize work records
```task
id: APPROVAL-WP-0004-T02
status: todo
priority: high
```
The Custodian coordinator independently reviews the exact patch/manifest, then
integrates and synchronizes the canonical owner record. Temporary-checkout work
is not a canonical synchronized record or formal delegated-owner assent.
Existing native delivery obligations remain live under APPROVAL-WP-0002:
T01 exact issuer/profile and release/migration, T04 admitted lifecycle source
delivery/reconciliation, T05 named served PEP consumption before protected effect.
Platform owns mediator transport/identity/custody, the one-shot journal and native
effect; Secrets owns its actual strict handoff and task-scoped backend. Access
owns the signed decision and exact request/action/representation binding. No
claim that an isolated source test closes any of these gates is made.