288 lines
13 KiB
Diff
288 lines
13 KiB
Diff
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.
|