diff --git a/approval_engine/api.py b/approval_engine/api.py index 607ff19..440e4ac 100644 --- a/approval_engine/api.py +++ b/approval_engine/api.py @@ -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"} diff --git a/approval_engine/store.py b/approval_engine/store.py index c30decc..eb02a24 100644 --- a/approval_engine/store.py +++ b/approval_engine/store.py @@ -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( diff --git a/docs/outbox-contract.md b/docs/outbox-contract.md index a15bdc8..14295a3 100644 --- a/docs/outbox-contract.md +++ b/docs/outbox-contract.md @@ -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. diff --git a/tests/test_consume_provenance.py b/tests/test_consume_provenance.py new file mode 100644 index 0000000..f2b5d6a --- /dev/null +++ b/tests/test_consume_provenance.py @@ -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 diff --git a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md index e3ff5ca..856ebc7 100644 --- a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -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 diff --git a/workplans/APPROVAL-WP-0004-exact-consume-source-provenance.md b/workplans/APPROVAL-WP-0004-exact-consume-source-provenance.md new file mode 100644 index 0000000..b9bc3fc --- /dev/null +++ b/workplans/APPROVAL-WP-0004-exact-consume-source-provenance.md @@ -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.