diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md index 105abf1..6b1e8d5 100644 --- a/docs/approval-consumption.md +++ b/docs/approval-consumption.md @@ -43,9 +43,28 @@ never compared to each other: of `{action, actor, principal, purpose, target}`. - `decision.binding.request_digest` — flex-auth canonical CheckRequest digest. -When the issuer recorded `claim.binding.pdp_digest`, that comparison is -preferred. The CheckRequest mapping onto the claim binding is published in -`approval-engine/docs/approval-claim.md`. +`claim.binding.pdp_digest` is the **only** comparison usable today, and its +absence fails the action closed. The claim's `binding.action` and +`binding.target` speak approval-engine's vocabulary (`secrets.kv.destroy`, +`{"id": ..., "stage": ...}`) while ours speaks the catalog's (`destroy`, +`catalog:`), and no mapping between them is published. flex-auth makes no +cross-check either and states the correspondence is ours. Computing a native +digest from our own vocabulary would compare two different languages and never +match, so this engine does not compute one. Closing that gap needs a published +mapping co-authored by approval-engine and flex-auth; it is a prerequisite for +`SECRETS-WP-0007-T04` making `destroy` reachable. + +### What is hashed in the flex-auth digest + +`tenant`, `subject`, `action`, `resource`, `context` — and nothing else. +`id` is correlation only, `policy_version` lives in provenance, and +`caring_context` is hashed separately. Including any of them yields a digest +that matches no issued decision. `digest_material` enforces the exclusion and +`tests/test_decision_replay.py` pins it against two real envelopes. + +A consumer re-hashing the *original unenriched* request will not match a +decision that turned on registry attributes: the binding is the evaluator's +statement of what it hashed, so recompute from the binding tuple. The approval-engine object id is never inferred from a State Hub decision UUID. It comes from catalog `approval.authorization_id`, and is the value in the diff --git a/src/secrets_engine/approval_claim.py b/src/secrets_engine/approval_claim.py index ec21815..6c762f7 100644 --- a/src/secrets_engine/approval_claim.py +++ b/src/secrets_engine/approval_claim.py @@ -144,8 +144,18 @@ def validate_approval_claim( raise DecisionError("approval claim binding must be an object") native = str(binding.get("digest", "") or "") pdp = str(binding.get("pdp_digest", "") or "") - # Prefer the PDP digest when the issuer recorded one at issue time. - if expected_pdp_digest and pdp: + # Prefer the PDP digest: it is expressed in flex-auth's vocabulary, which is + # the one the caller actually proposed. The native digest is over + # approval-engine's own vocabulary and is only comparable when the caller + # supplies a binding built in that vocabulary (see the note in + # resolve_consume_binding about the missing mapping). + if expected_pdp_digest: + if not pdp: + raise DecisionError( + "approval claim records no pdp_digest, and no published mapping " + "exists between approval-engine and secrets-engine action/target " + "vocabularies; the claim cannot be tied to this exact action" + ) if pdp != expected_pdp_digest: raise DecisionError("approval claim pdp digest does not match the request") elif expected_binding_digest: diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index 1723a28..eac821f 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -18,11 +18,7 @@ from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen -from secrets_engine.approval_claim import ( - binding_from_check_request, - claim_binding_digest, - validate_approval_claim, -) +from secrets_engine.approval_claim import validate_approval_claim from secrets_engine.authorization import build_action_request, request_digest from secrets_engine.errors import DecisionError from secrets_engine.openbao import read_strict_token_file @@ -193,10 +189,19 @@ def resolve_consume_binding( policy_targets=policy_targets, auth_targets=auth_targets, ) - # Two different digests over the same proposed action, by contract: the - # approval-engine native binding digest, and the flex-auth CheckRequest - # digest. They are not interchangeable and are never compared to each other. - native_digest = claim_binding_digest(**binding_from_check_request(expected_request)) + # Two different digests over the same proposed action, by contract; they are + # never compared to each other. + # + # Only the PDP digest is usable for the action/target correspondence today. + # The claim's binding.action and binding.target speak approval-engine's + # vocabulary ("secrets.kv.destroy", {"id": ..., "stage": ...}) while ours + # speaks the catalog's ("destroy", "catalog:"), and no mapping between + # them is published. flex-auth makes no cross-check either and states the + # correspondence is ours, via pdp_digest. Computing a native digest from our + # own vocabulary would compare two different languages and never match -- + # the same unsatisfiable-rule defect flex-auth fixed in 68ad039 -- so we do + # not compute one, and validate_approval_claim fails closed with a named + # reason when the issuer recorded no pdp_digest. pdp_digest = request_digest(expected_request) claim = fetch_approval_claim( @@ -208,7 +213,6 @@ def resolve_consume_binding( validate_approval_claim( claim, approval_id=authorization_id, - expected_binding_digest=native_digest, expected_pdp_digest=pdp_digest, ) binding = claim.get("binding") or {} diff --git a/src/secrets_engine/authorization.py b/src/secrets_engine/authorization.py index ba57509..270ba3b 100644 --- a/src/secrets_engine/authorization.py +++ b/src/secrets_engine/authorization.py @@ -156,8 +156,26 @@ def canonical_check_request(request: object) -> dict[str, Any]: return canonical -def request_digest(request: object) -> str: +#: Correlation-only or separately-hashed fields, excluded from the digest +#: material by docs/canonical-request-digest.md "What is hashed". +_UNHASHED_FIELDS = ("id", "policy_version", "caring_context") + + +def digest_material(request: object) -> dict[str, Any]: + """The exact tuple flex-auth hashes: tenant, subject, action, resource, context. + + ``id`` is correlation only, ``policy_version`` is recorded in provenance, and + ``caring_context`` is hashed separately as + ``provenance.input_claim_digests.caring_context``. Including any of them + produces a digest that matches no real DecisionEnvelope, which fails closed + against every correctly issued decision. + """ canonical = canonical_check_request(request) + return {k: v for k, v in canonical.items() if k not in _UNHASHED_FIELDS} + + +def request_digest(request: object) -> str: + canonical = digest_material(request) encoded = json.dumps( canonical, ensure_ascii=False, separators=(",", ":") ).encode("utf-8") diff --git a/tests/fixtures/flex-auth-replay/PROVENANCE.md b/tests/fixtures/flex-auth-replay/PROVENANCE.md new file mode 100644 index 0000000..7e90d4d --- /dev/null +++ b/tests/fixtures/flex-auth-replay/PROVENANCE.md @@ -0,0 +1,17 @@ +# flex-auth T03 replay fixtures + +Copied verbatim from `flex-auth/examples/secrets-engine/replay/` (commit +`9e10d1c`, `FLEX-WP-0021-T03`). Real `DecisionEnvelope`s emitted by the +published `secrets-engine.catalog-lane.lifecycle` v1 package via +`go run ./cmd/flex-auth check`, from `flex-auth/local` in `standalone` mode. + +Vendored so the digest contract test is hermetic. Regenerate upstream and +re-copy if the contract version changes. + +**Pinned here** (stable across runs, per the upstream README): +`binding.request_digest`, `provenance.policy_package_digest`, +`provenance.registry_snapshot_digest`, and the presence/absence of +`provenance.input_claim_digests.context`. + +**Never pin:** `id`, `provenance.decision_time`, `lifetime.not_before`, +`lifetime.expires_at` — all move with the clock. diff --git a/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json b/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json new file mode 100644 index 0000000..c4e8ef4 --- /dev/null +++ b/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json @@ -0,0 +1,121 @@ +{ + "id": "decision:669a12badaa3d82e", + "contract_version": "flex-auth.decision-record.v1", + "request_id": "check:secrets-engine-destroy", + "effect": "allow", + "reason": "catalog_lane_policy_matched", + "matched_policy_version": "v1", + "matched_rule": "catalog_lane_policy_matched", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [], + "policy_targets": [], + "stage": "prod" + } + }, + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "binding": { + "tenant": "tenant:platform", + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "action": "destroy", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [], + "policy_targets": [], + "stage": "prod" + } + }, + "context": { + "approval": { + "approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f", + "consumed": false, + "issuer": "approval-engine", + "kind": "approval-claim", + "reason_code": "ok", + "schema_version": "0.1", + "state": "valid", + "valid_now": true + } + }, + "request_digest": "sha256:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56" + }, + "lifetime": { + "kind": "ttl", + "ttl": "15m", + "not_before": "2026-09-06T06:13:43Z", + "expires_at": "2026-09-06T06:28:43Z" + }, + "diagnostics": { + "action": "destroy", + "matched_relationship": "", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_status": "ready", + "registry_resource": false, + "registry_subject": true + }, + "provenance": { + "evaluator": "flex-auth/local", + "mode": "standalone", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_version": "v1", + "policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c", + "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", + "input_claim_digests": { + "context": "sha256:45fa9f41271c684f2c26570be11869a8eda28e7b3b25f11e01f9378b6f89fdc8" + }, + "decision_time": "2026-09-06T06:13:43Z" + }, + "caring": { + "profile": "caring-0.4.0-rc2", + "conformance_findings": [ + { + "code": "CARING-DESCRIPTOR-MISSING", + "severity": "warning", + "message": "no CARING descriptor matched the request", + "fields": [ + "caring_context" + ] + } + ] + } +} diff --git a/tests/fixtures/flex-auth-replay/decision_rotate.json b/tests/fixtures/flex-auth-replay/decision_rotate.json new file mode 100644 index 0000000..e5d04d4 --- /dev/null +++ b/tests/fixtures/flex-auth-replay/decision_rotate.json @@ -0,0 +1,110 @@ +{ + "id": "decision:49309356905a2ad3", + "contract_version": "flex-auth.decision-record.v1", + "request_id": "check:secrets-engine-rotate", + "effect": "allow", + "reason": "catalog_lane_policy_matched", + "matched_policy_version": "v1", + "matched_rule": "catalog_lane_policy_matched", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [ + "password" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "binding": { + "tenant": "tenant:platform", + "subject": { + "id": "secrets-engine", + "type": "service", + "tenant": "tenant:platform", + "attributes": { + "description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.", + "display_name": "secrets-engine service principal", + "groups": [ + "group:secrets-engine-lane-operators" + ], + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ] + } + }, + "action": "rotate", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "tenant": "tenant:platform", + "attributes": { + "auth_targets": [], + "fields": [ + "password" + ], + "policy_targets": [], + "stage": "prod" + } + }, + "request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345" + }, + "lifetime": { + "kind": "ttl", + "ttl": "15m", + "not_before": "2026-09-06T06:13:21Z", + "expires_at": "2026-09-06T06:28:21Z" + }, + "diagnostics": { + "action": "rotate", + "matched_relationship": "", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_status": "ready", + "registry_resource": false, + "registry_subject": true + }, + "provenance": { + "evaluator": "flex-auth/local", + "mode": "standalone", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_version": "v1", + "policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c", + "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", + "decision_time": "2026-09-06T06:13:21Z" + }, + "caring": { + "profile": "caring-0.4.0-rc2", + "conformance_findings": [ + { + "code": "CARING-DESCRIPTOR-MISSING", + "severity": "warning", + "message": "no CARING descriptor matched the request", + "fields": [ + "caring_context" + ] + } + ] + } +} diff --git a/tests/test_action_authorization.py b/tests/test_action_authorization.py index bb4dc02..5f06695 100644 --- a/tests/test_action_authorization.py +++ b/tests/test_action_authorization.py @@ -77,7 +77,15 @@ def _validate(envelope, expected=None): ) -def test_digest_matches_flex_auth_contract_example(): +def test_digest_is_stable_and_ignores_correlation_fields(): + """The pinned digest contract now lives in tests/test_decision_replay.py. + + That file verifies against two real DecisionEnvelopes issued by the + published package. The constant previously pinned here was computed with + the request `id` inside the hashed material, which + docs/canonical-request-digest.md excludes -- it matched no issued decision. + Kept here: the structural property, checked without a hand-maintained pin. + """ request = { "id": "check:secrets-engine-destroy-example", "subject": {"id": "user:alice", "type": "Human"}, @@ -95,12 +103,11 @@ def test_digest_matches_flex_auth_contract_example(): }, "context": {"purpose": "contract-test"}, } - # Generated independently with flex-auth's Go api.CheckRequest and - # encoding/json. The action_authorization.json example carried a stale - # digest when this consumer contract was implemented. - assert request_digest(request) == ( - "sha256:73d5d7d5b3363f1a1db8f4c0e79c8f33dae5d77ffb97f21e449438bc0defa4c3" - ) + baseline = request_digest(request) + assert baseline.startswith("sha256:") and len(baseline) == 71 + assert request_digest({k: v for k, v in request.items() if k != "id"}) == baseline + assert request_digest({**request, "action": "deactivate"}) != baseline + def test_valid_allow_envelope_passes(): result = _validate(_envelope()) diff --git a/tests/test_consume_binding_join.py b/tests/test_consume_binding_join.py index 35bf6ad..a1cd9a2 100644 --- a/tests/test_consume_binding_join.py +++ b/tests/test_consume_binding_join.py @@ -77,7 +77,14 @@ def _served(entry=None, action="deactivate", fields=("api_token",), **over): "state": "valid", "valid_now": True, "consumed": False, - "binding": {**binding, "digest": claim_binding_digest(**binding)}, + "binding": { + **binding, + "digest": claim_binding_digest(**binding), + # The issuer recorded the PDP digest at issue time. That is the only + # comparison usable today: the native digest speaks + # approval-engine's vocabulary and no mapping to ours is published. + "pdp_digest": request_digest(request), + }, "freshness": { "observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "ttl_seconds": 30, @@ -182,3 +189,25 @@ def test_unreachable_approval_engine_fails_closed(tmp_path): def test_superseded_claim_fails_closed(tmp_path): with pytest.raises(DecisionError): _resolve(_Cfg(_token(tmp_path)), _entry(), _served(valid_now=False, reason_code="superseded")) + + +def test_claim_without_pdp_digest_fails_closed_naming_the_missing_mapping(tmp_path): + """No published vocabulary mapping means the claim cannot be tied to this action. + + approval-engine's binding.action/target use their vocabulary + ("secrets.kv.destroy", {"id":..., "stage":...}); ours uses the catalog's. + flex-auth makes no cross-check and states the correspondence is ours via + pdp_digest. Without one there is nothing sound to compare, so this must + refuse rather than fall back to comparing two different languages. + """ + claim = _served() + claim["binding"].pop("pdp_digest") + with pytest.raises(DecisionError, match="no published mapping"): + _resolve(_Cfg(_token(tmp_path)), _entry(), claim) + + +def test_wrong_pdp_digest_fails_closed(tmp_path): + claim = _served() + claim["binding"]["pdp_digest"] = "sha256:" + "c" * 64 + with pytest.raises(DecisionError, match="pdp digest does not match"): + _resolve(_Cfg(_token(tmp_path)), _entry(), claim) diff --git a/tests/test_decision_replay.py b/tests/test_decision_replay.py new file mode 100644 index 0000000..a912e56 --- /dev/null +++ b/tests/test_decision_replay.py @@ -0,0 +1,159 @@ +"""Digest join verified against real flex-auth DecisionEnvelopes (FLEX-WP-0021-T03). + +These replace a hand-maintained pin that was computed *including* the request +`id`. docs/canonical-request-digest.md excludes `id`, `policy_version` and +`caring_context` from the hashed material, and both real envelopes confirm it: +a digest computed over the old material matches no issued decision, which would +have failed closed against every correct allow. + +Both fixtures are required. `provenance.input_claim_digests.context` appears +only when the request carries a non-empty context, so a validator asserting it +is always present passes `destroy` and fails `rotate`. +""" +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from secrets_engine.authorization import ( + digest_material, + request_digest, + validate_decision_envelope, +) +from secrets_engine.errors import DecisionError + +FIXTURES = Path(__file__).parent / "fixtures" / "flex-auth-replay" + +PACKAGE_DIGEST = "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c" +SNAPSHOT_DIGEST = "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb" + +CASES = { + "rotate": { + "file": "decision_rotate.json", + "digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345", + "action": "rotate", + "context_claim_digest": None, + }, + "destroy": { + "file": "decision_destroy_dual_control.json", + "digest": "sha256:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56", + "action": "destroy", + "context_claim_digest": "sha256:45fa9f41271c684f2c26570be11869a8eda28e7b3b25f11e01f9378b6f89fdc8", + }, +} + + +def _envelope(name): + return json.loads((FIXTURES / CASES[name]["file"]).read_text()) + + +def _request_from(envelope): + """Rebuild the normalized tuple the binding carries. + + Per the contract, a consumer re-hashing the *original unenriched* request + will not match a decision that turned on registry attributes; the binding is + the evaluator's statement of what it hashed. + """ + binding = envelope["binding"] + request = {"id": envelope["request_id"]} + for key in ("tenant", "subject", "action", "resource"): + if binding.get(key) is not None: + request[key] = binding[key] + if binding.get("context") is not None: + request["context"] = binding["context"] + return request + + +def _refresh_lifetime(envelope): + """Lifetime moves with the clock and must never be pinned.""" + now = datetime.now(timezone.utc) + envelope["lifetime"]["not_before"] = (now - timedelta(minutes=1)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + envelope["lifetime"]["expires_at"] = (now + timedelta(minutes=14)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + return envelope + + +@pytest.mark.parametrize("name", list(CASES)) +def test_request_digest_matches_the_issued_decision(name): + envelope = _envelope(name) + expected = CASES[name]["digest"] + assert envelope["binding"]["request_digest"] == expected, "fixture drifted" + assert request_digest(_request_from(envelope)) == expected + + +@pytest.mark.parametrize("name", list(CASES)) +def test_correlation_fields_are_not_hashed(name): + """id, policy_version and caring_context must not move the digest.""" + request = _request_from(envelope := _envelope(name)) + baseline = request_digest(request) + assert baseline == envelope["binding"]["request_digest"] + for field, value in ( + ("id", "check:some-other-correlation-id"), + ("policy_version", "v99"), + ("caring_context", {"anything": "here"}), + ): + assert request_digest({**request, field: value}) == baseline, field + stripped = {k: v for k, v in request.items() if k != "id"} + assert request_digest(stripped) == baseline + + +@pytest.mark.parametrize("name", list(CASES)) +def test_digest_material_is_exactly_the_published_tuple(name): + material = digest_material(_request_from(_envelope(name))) + assert set(material) <= {"tenant", "subject", "action", "resource", "context"} + assert "id" not in material + + +@pytest.mark.parametrize("name", list(CASES)) +def test_real_envelope_validates_against_the_published_package(name): + envelope = _refresh_lifetime(_envelope(name)) + result = validate_decision_envelope( + envelope, + _request_from(envelope), + accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, + accepted_policy_versions={"v1"}, + ) + assert result.action == CASES[name]["action"] + assert result.subject_id == "secrets-engine" + + +@pytest.mark.parametrize("name", list(CASES)) +def test_provenance_digests_are_pinned(name): + provenance = _envelope(name)["provenance"] + assert provenance["policy_package_digest"] == PACKAGE_DIGEST + assert provenance["registry_snapshot_digest"] == SNAPSHOT_DIGEST + assert provenance["evaluator"] == "flex-auth/local" + assert provenance["mode"] == "standalone" + + +@pytest.mark.parametrize("name", list(CASES)) +def test_input_claim_digest_is_present_only_with_a_context(name): + """The reason two fixtures exist: this field is conditional.""" + provenance = _envelope(name)["provenance"] + expected = CASES[name]["context_claim_digest"] + actual = (provenance.get("input_claim_digests") or {}).get("context") + assert actual == expected + + +@pytest.mark.parametrize("name", list(CASES)) +def test_a_tampered_binding_field_breaks_the_digest(name): + envelope = _envelope(name) + request = _request_from(envelope) + request["action"] = "handoff" + assert request_digest(request) != envelope["binding"]["request_digest"] + + +def test_expired_real_envelope_fails_closed(): + """The shipped lifetime is 15m from allow_ttl and has long since passed.""" + with pytest.raises(DecisionError, match="lifetime has expired"): + envelope = _envelope("rotate") + validate_decision_envelope( + envelope, + _request_from(envelope), + accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, + accepted_policy_versions={"v1"}, + ) diff --git a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md index ea0e2ee..5ea11af 100644 --- a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md +++ b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md @@ -409,6 +409,50 @@ package/version to configure. Next inbound is the `T03` replay fixture — a rea DecisionEnvelope from this package including `provenance.registry_snapshot_digest` — to verify the digest join unchanged. +Verified 2026-09-06 against flex-auth's `T03` replay fixtures +(`FLEX-WP-0021-T03`, commit `9e10d1c`), vendored to +`tests/fixtures/flex-auth-replay/`. Running them found a defect of exactly the +class we had just been warning about. + +- **`request_digest` was hashing fields the contract excludes.** The material is + `tenant`, `subject`, `action`, `resource`, `context` only; `id` is correlation, + `policy_version` lives in provenance, and `caring_context` is hashed + separately. This engine included all three when present. Because the join + adopts the served request id, every real production request would have carried + one, so the computed digest would have matched no issued decision and failed + closed against every correct allow — the same unsatisfiable-rule shape as the + removed `AUTHORITY` constant and as flex-auth's own destroy rule in `68ad039`. + Fixed by `digest_material`; both fixtures now reproduce their published + digests exactly. +- The previously pinned digest constant was computed *with* the id inside the + material, so it was wrong and its passing was not evidence. It is replaced by + fixture-driven contract tests over two real envelopes plus a structural test + that correlation fields do not move the digest. Both fixtures are required: + `provenance.input_claim_digests.context` appears only with a non-empty context. +- **The native claim digest is unusable and is no longer computed.** The claim's + `binding.action`/`binding.target` speak approval-engine's vocabulary while ours + speaks the catalog's, and no mapping is published; flex-auth makes no + cross-check and states the correspondence is ours via `pdp_digest`. A claim + recording no `pdp_digest` now fails closed naming the missing mapping rather + than comparing two vocabularies. **A published mapping is a prerequisite for + making `destroy` reachable under this task.** flex-auth offered to co-author it + with approval-engine. +- Narrowing accepted from approval-engine: `entries` is UNIQUE on + `(approval_id, subject_id)`, so approver distinctness is a storage invariant, + not a count this engine was previously protecting. What we stopped verifying is + that approval-engine applies its own invariant correctly — still a real + reduction in defence in depth, but narrower than "the threshold is now + unverified". They also added `threshold` objects to issuance and use events + (`87e55e2`) so the evaluation is reconstructable under §9.6. +- flex-auth corrected the `destroy` dual-control rule (`68ad039`) before + generating these fixtures: it had required `context.approval.status == + 'approved'` and an approver list, neither of which exists in the claim schema. + Our acknowledgement of the earlier rule crossed that fix. The corrected rule + requires only kind/issuer/`valid_now`, and our claim validation is unchanged. +- One global pin confirmed correct: `secrets-engine.catalog-lane.lifecycle` v1 is + one package covering all twelve actions. Do not build per-action pin + configuration for a distinction that does not exist. + Define and enforce the decision contract needed by production commands. A resolved approval must bind at least: