diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md index e3c644f..5b71fe4 100644 --- a/docs/approval-consumption.md +++ b/docs/approval-consumption.md @@ -1,8 +1,8 @@ # Approval consumption (PEP) -Status: engine consumer and the PIP claim/validate join are implemented; live -production remains fail-closed until approval-engine and access-engine actually -serve the durable objects. +Status: consumer replay and claim/check/consume join are implemented and exercised +against real local components. Live activation requires current PDP contract +adoption, client-side and audit admission, and the deployed approval service. Normative protocol: `gate-house/docs/contracts/approval-consumption.md` (`GH-DEC-2026-003`). Implementation surface: @@ -24,7 +24,7 @@ does not redefine it. | Step | Artifact | Owner | Validated by | | --- | --- | --- | --- | -| 1 | approval-claim | approval-engine | `approval_claim.validate_approval_claim` | +| 1 | approval-claim | approval-engine | `approval_claim.observe_pdp_approval_claim`, then decision-time correspondence | | 2 | DecisionEnvelope | flex-auth | `authorization.validate_decision_envelope` | The claim carries the *approval fact*: issuer, `valid_now`, consumption state, @@ -36,81 +36,43 @@ policy package/version pin. Neither republishes the other's data. (`FLEX-DEC-2026-006`). It cannot be served from a step-1 call, and nothing here validates it. A ratified post-decision form remains a deferred option. -There are **two different digests** over the same proposed action, and they are -never compared to each other: +### Replay, approval correspondence and consume identity -- `claim.binding.digest` — approval-engine native, `sha256` over canonical JSON - of `{action, actor, principal, purpose, target}`. -- `decision.binding.request_digest` — flex-auth canonical CheckRequest digest. +FLEX-DEC-2026-012 distinguishes three decision bindings: -`claim.binding.pdp_digest` is the **only** comparison usable, 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:`). No mapping between -them exists and none is coming: gate-house rejected one outright in -`GH-DEC-2026-008`, because a translation can be confidently wrong and fails -**open** by silently accepting a claim approved for a different action. An -identity check cannot. So this engine computes no native digest. +| Field | Use | +| --- | --- | +| `submitted_request_digest` | Compare to the canonical request actually sent, including the full `context.approval` claim. Missing or different refuses. | +| `approval_binding_digest` | Compare to the claim's issuer-recorded `binding.pdp_digest`. Both values originate at the evaluator, before and after approval respectively. | +| `request_digest` | Preserve the evaluator's enriched request identity for audit and the consume CAS request, alongside its decision id. | -### The two gates on the pdp path +The consumer first observes issuer, approval id, validity, freshness, unconsumed +state, reason and explicit `pdp_path: true` with a canonical `pdp_digest`. +`resolve_approval_observation` returns an observation, never a consume binding. +It carries that exact claim into `context.approval` for the PDP call. Step 2 +checks allow, submitted replay identity, policy package/version, decision +lifetime and the evaluator-origin approval pair. Claim freshness is rechecked +after the call. Only then does `authorize_action` produce a consume binding. -**1. `binding.pdp_path` must be `true`.** This is approval-engine's declaration -(schema v3) that the approval was requested against a bound CheckRequest. Their -`create()` refuses `pdp_path` true without a `pdp_digest`, so the declaration -*guarantees* the digest. The converse does not hold, and we must not infer it: a -digest recorded for some other reason is no declaration anybody made, and every -approval issued before schema v3 carries `pdp_path` false regardless of any -digest it holds. +Registry facts can override caller attributes. The consumer does not duplicate +that evaluation or reconstruct the enriched request/approval digest. The native +approval digest uses a different vocabulary and is never a fallback. A +claim-free decision cannot satisfy the approval join. Changing or removing a +claim changes the submitted replay identity even when the approval-free digest +stays constant. -The cost is real and is ours to carry — an approval requested without a bound -CheckRequest is not usable here and never becomes usable later. `GH-DEC-2026-008` -holds that correct: an approval granted against an unspecified action does not -become an approval for a specific one because a consumer later found a use for -it. If a real destroy workflow cannot bind at issue, that is the falsifier -gate-house wrote into the reversal, and it should be **raised**, not worked -around. +Canonical hashing covers tenant, subject, action, resource and nonempty context; +request id, policy_version and caring_context are excluded by the producer's +contract. Independent producer input/output fixtures verify this boundary. -**2. The digest identity, against the right comparand.** +CAS retry resends the **same validated enriched request digest**. Fetching a new +claim or changing a request is not the same consume operation. Consumption is +not backend exactly-once evidence: a response-lost OpenBao operation still needs +its own reconciliation before any repeated side effect. -```text -claim.binding.pdp_digest == decision.binding.approval_binding_digest correct -claim.binding.pdp_digest == decision.binding.request_digest can never pass -``` - -`pdp_digest` is recorded at *issue* time, and issue precedes the decision. A -request that carries the claim inside its hashed `context` therefore has a -different `request_digest` by construction — the claim is part of the material -being hashed. This engine found that circularity against the T03 replay fixture; -flex-auth fixed it in `FLEX-DEC-2026-007` by publishing -`binding.approval_binding_digest`, the same canonical digest with -`context.approval` removed, which is stable across attaching the claim. - -`approval_binding_digest` is **not** a replay identity. `request_digest` still -covers the claim and still moves when it changes, because two requests differing -only in which approval was presented must not share a replay identity — one -allows, the other denies `dual_control_required`. Collapsing them would let an -allow obtained with a valid claim be replayed against a request carrying none. - -Our own CheckRequest is claim-free today (`context` is `{"purpose": ...}`), so -flex-auth emits no `approval_binding_digest` for it and the identity holds -transitively: step 1 compares the claim's `pdp_digest` to the canonical digest of -the exact claim-free request we are about to send, and step 2 confirms the -decision's `request_digest` is that same value. `approval_binding_digest(request)` -implements the published exclusion rule so that the check is already correct if -we ever carry the claim in context; when the field is present it is recomputed -and never taken on faith. - -### 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 supported owner loopback forwarding path remains required for PDP transport. +A matching digest does not authenticate a malicious responder. Detached signed +decisions remain FLEX-WP-0024; this correction grants no new network trust. 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/docs/approval-service-auth.md b/docs/approval-service-auth.md index a124ea1..073dcb4 100644 --- a/docs/approval-service-auth.md +++ b/docs/approval-service-auth.md @@ -53,13 +53,14 @@ Run from this repository with Docker available and the owner source checkouts: uv run --with 'PyJWT[crypto]>=2.7,<3' python tools/exercise_approval_identity.py \ --keycape-source /home/worsch/key-cape \ --approval-engine-source /home/worsch/approval-engine \ + --flex-auth-source /home/worsch/flex-auth \ --receipt /tmp/.json ``` The exercise starts the immutable admitted KeyCape image behind local HTTPS, uses synthetic signing keys/client secrets, and serves the real Approval Engine source with its JWT/JWKS verifier and SQLite store. It drives the actual Secrets -Engine production gate, with only the PDP represented by a sequencing double. +Engine production gate through the actual locally compiled Flex Auth evaluator. Operator create/approve, consumer claim/consume, same-digest retry, different digest, spent claim, wrong action, wrong secret and operator-scope denial are checked. All temporary processes and synthetic credentials are removed. @@ -82,6 +83,15 @@ and sender custody remains AUDIT-WP-0009-T09 / APPROVAL-WP-0002-T01. The live Approval Engine deployment and native OpenBao delivery remain open. Synthetic acceptance grants no production access, action approval or model spending. -The newly consumed FLEX-DEC-2026-012 return remains SECRETS-WP-0008-T02: -adopt submitted_request_digest and the evaluator-to-evaluator approval digest -join before live activation. The current PDP double does not validate it. +SECRETS-WP-0008-T02 now supplies the implemented FLEX-DEC-2026-012 replay +and approval join. Verify the deployed PDP implements it before live activation. + + +### 2026-09-09 replay correction accepted locally + +The updated exercise requires `--flex-auth-source` and compiles its actual Go +evaluator. It no longer substitutes a decision double. Thirteen checks cover +signed issuer/claim/consume, registry precedence, changed submission replay, +wrong action, same/different consume digest and actual dual-control policy. +See `docs/evidence/2026-09-09-replay-contract-exercise.json`. Earlier nine-check +PDP-double evidence remains historical. Live custody/deployment gates still apply. diff --git a/docs/evidence/2026-09-09-replay-contract-exercise.json b/docs/evidence/2026-09-09-replay-contract-exercise.json new file mode 100644 index 0000000..660d2b2 --- /dev/null +++ b/docs/evidence/2026-09-09-replay-contract-exercise.json @@ -0,0 +1,47 @@ +{ + "schema_version": 1, + "target": "disposable local processes; synthetic credentials", + "started_at": "2026-09-09T06:38:34.485308+00:00", + "keycape_image": "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611", + "approval_engine_commit": "b46b0f26669dc83c944ee5145426bad03d5ef720", + "flex_auth_commit": "88b354377c8e26b162f1234e673072f1c06dcd89", + "keycape_contract_commit": "0f5535eed95f1223c83a28f5a0bd6fa594cecae8", + "consumer_source_sha256": { + "approval_auth.py": "6f3b033e7928e1c527bc19f5a1e01bf243540769a8646bd8cd47268da25637b5", + "approval_consume.py": "9ccc6997fd4c1c82af8cf3dd440daf7b695d6272bb302eb083b73a2fe3674345", + "approval_claim.py": "54dd469e4e3ac9472c4e21222d5a08f70723a63f01904a35922fcc0a07a41541", + "authorization.py": "75c472f30cce1c3d66be1ded1eb6dcc62684668b0da5bf83d5fcdd37e92d1935", + "config.py": "2f2f1b60664d89bbb806aed0c768378923062077740e1f96e4c359238eb06955", + "service_auth.py": "653723ef5babce2157771416d93cb15cdbb7caca3c45509c3730520c55e4515c", + "cli.py": "a789fcea59e9a03d7ef07bf3ec1baf58234058585149511333c3fb7570f5916a" + }, + "limitations": [ + "standalone Flex Auth source, not deployed pin", + "local Approval Engine source, not deployed image", + "no live custody or client-side read grant", + "no OpenBao effect or model execution" + ], + "checks": { + "operator_issued_and_approved_via_verified_jwt": true, + "wrong_action_refused_before_consume": true, + "producer_origin_join_with_carried_claim": true, + "actual_consumer_claim_check_consume": true, + "same_digest_retry_idempotent": true, + "different_digest_refused": true, + "spent_claim_refused": true, + "operator_consume_scope_denied_by_issuer": true, + "wrong_secret_refused": true, + "no_access_token_file_created": true, + "registry_override_accepts_exact_submission": true, + "same_enriched_result_different_submission_refused": true, + "real_dual_control_denial_then_claim_check_consume": true + }, + "flex_auth_binary_sha256": "c7a1f35aa2cd7cf2733272923ace7fc4b4e55171db4ef8f3d0d6629183dcf1fa", + "producer_input_sha256": { + "policy_package.md": "657fa9312c9dcabee059ec31c89ee13396fe9aa54d852edc9e6559a74bbc139a", + "registry_snapshot.json": "37fba44ec0e0fd9b1e17a05ad1b7e4f9bb6ec9e0fc0bb3133e8af2ac6f832d7c" + }, + "status": "passed", + "cleanup_complete": true, + "finished_at": "2026-09-09T06:38:44.956593+00:00" +} diff --git a/src/secrets_engine/approval_claim.py b/src/secrets_engine/approval_claim.py index 6f86414..f4efe4f 100644 --- a/src/secrets_engine/approval_claim.py +++ b/src/secrets_engine/approval_claim.py @@ -13,6 +13,7 @@ from __future__ import annotations import hashlib import json +import re from datetime import datetime, timezone from typing import Any @@ -97,21 +98,13 @@ def _parse_time(value: object, name: str) -> datetime: return parsed.astimezone(timezone.utc) -def validate_approval_claim( +def _validate_observation( claim: object, *, approval_id: str, - expected_binding_digest: str = "", - expected_pdp_digest: str = "", now: datetime | None = None, ) -> dict[str, Any]: - """Run the published consumer checks. Any failure means do not act. - - Implements ``approval-claim.md`` "Required verification": issuer, valid_now, - not consumed, binding digest match (native or PDP), freshness, reason_code. - The distinct-approver threshold is folded into ``valid_now`` by the issuer; - the claim does not expose approver entries, so it cannot be re-checked here. - """ + """Validate issuer, shape, validity and freshness; no action correspondence.""" if not isinstance(claim, dict): raise DecisionError("approval claim must be an object") if claim.get("schema_version") != SCHEMA_VERSION: @@ -142,42 +135,6 @@ def validate_approval_claim( binding = claim.get("binding") if not isinstance(binding, dict): 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: 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: - # GH-DEC-2026-008 / approval-engine schema v3: pdp_path is the issuer's - # DECLARATION that this approval was requested against a bound - # CheckRequest, and it guarantees pdp_digest is non-null. Path intent is - # never inferred from a pdp_digest that merely happens to be present -- - # a digest recorded for some other reason is not a declaration anybody - # made, and approvals issued before schema v3 carry pdp_path false - # regardless of any digest they hold. - if binding.get("pdp_path") is not True: - raise DecisionError( - "approval claim does not declare binding.pdp_path; it was not " - "issued against a bound CheckRequest and cannot authorize this " - "action (GH-DEC-2026-008). Request an approval bound at issue." - ) - if not pdp: - raise DecisionError( - "approval claim declares pdp_path but 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: - if native != expected_binding_digest: - raise DecisionError("approval claim binding digest does not match the request") - else: - raise DecisionError("approval claim comparison requires an expected digest") - current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) freshness = claim.get("freshness") if not isinstance(freshness, dict): @@ -194,3 +151,41 @@ def validate_approval_claim( if _parse_time(validity.get("not_before"), "validity.not_before") > current: raise DecisionError("approval claim is not yet valid") return claim + + +def observe_pdp_approval_claim(claim: object, *, approval_id: str, + now: datetime | None = None) -> dict[str, Any]: + """Validate a fresh fact for submission to the PDP, not authority to consume. + + Action correspondence cannot be established until the evaluator returns its + enriched approval binding. This function deliberately makes no such claim. + """ + observed = _validate_observation(claim, approval_id=approval_id, now=now) + binding = observed["binding"] + if binding.get("pdp_path") is not True: + raise DecisionError("approval claim does not declare binding.pdp_path; request an approval bound at issue") + pdp = binding.get("pdp_digest") + if not isinstance(pdp, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", pdp): + raise DecisionError("approval claim records no canonical pdp_digest; no published mapping permits a native fallback") + return observed + + +def validate_approval_claim(claim: object, *, approval_id: str, + expected_binding_digest: str = "", expected_pdp_digest: str = "", + now: datetime | None = None) -> dict[str, Any]: + """Validate the fact and compare an independently supplied binding. + + A PDP digest supplied here must come from the evaluated decision, never a + local reconstruction of the unenriched request. + """ + observed = _validate_observation(claim, approval_id=approval_id, now=now) + if expected_pdp_digest: + observe_pdp_approval_claim(observed, approval_id=approval_id, now=now) + if observed["binding"]["pdp_digest"] != expected_pdp_digest: + raise DecisionError("approval claim pdp digest does not match the request") + elif expected_binding_digest: + if observed["binding"].get("digest") != expected_binding_digest: + raise DecisionError("approval claim binding digest does not match the request") + else: + raise DecisionError("approval claim comparison requires an expected digest") + return observed diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index 1538fed..f9b5096 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -12,18 +12,17 @@ from __future__ import annotations import json import re -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen -from secrets_engine.approval_claim import validate_approval_claim +from secrets_engine.approval_claim import observe_pdp_approval_claim, validate_approval_claim from secrets_engine.decision_check import check_decision from secrets_engine.authorization import ( build_action_request, - request_digest, validate_decision_envelope, ) from secrets_engine.errors import DecisionError @@ -43,6 +42,15 @@ class ConsumeBinding: decision_id: str = "" +@dataclass(frozen=True) +class ApprovalObservation: + """Fresh approval input awaiting PDP correspondence; never a consume binding.""" + + approval_id: str + request: dict[str, Any] = field(repr=False) + claim: dict[str, Any] = field(repr=False) + + @dataclass(frozen=True) class AuthorizedAction: """A validated claim and decision for one exact proposed action. @@ -201,7 +209,7 @@ def fetch_approval_claim( return payload -def resolve_consume_binding( +def resolve_approval_observation( cfg: Any, entry: Any, action: str, @@ -211,13 +219,11 @@ def resolve_consume_binding( policy_targets: tuple[str, ...] = (), auth_targets: tuple[str, ...] = (), opener: Callable[..., Any] | None = None, -) -> ConsumeBinding | None: - """Join the proposed action to a served approval-claim (step 1). +) -> ApprovalObservation | None: + """Observe a fresh fact and prepare the claim-bearing request (step 1). - Returns None only when no serving path is configured at all, keeping - production fail-closed exactly as it was before the join existed. Anything - configured-but-wrong raises: a half-configured PEP must not look like an - unconfigured one. + Missing serving coordinates return None, preserving the production refusal. + Correspondence and a consume binding require authorize_action's PDP step. """ base_url = str(getattr(cfg, "approval_url", "") or "") auth_configured = approval_auth_configured(cfg) @@ -230,42 +236,15 @@ def resolve_consume_binding( fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, ) - # 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( base_url=base_url, token_provider=lambda: approval_token(cfg, scope="approval:read"), authorization_id=authorization_id, opener=opener or credential_urlopen, ) - validate_approval_claim( - claim, - approval_id=authorization_id, - expected_pdp_digest=pdp_digest, - ) - # No action comparison here. The claim's binding.action is approval-engine - # vocabulary ("secrets.kv.destroy") and ours is the catalog's ("destroy"); - # comparing them would fail against every real claim, which is the same - # cross-vocabulary mistake the native digest made. The tie to this exact - # action is pdp_digest, checked above. - return ConsumeBinding( - approval_id=authorization_id, - request_digest=pdp_digest, - decision_id="", - ) + observe_pdp_approval_claim(claim, approval_id=authorization_id) + expected_request["context"]["approval"] = claim + return ApprovalObservation(authorization_id, expected_request, claim) def authorize_action( @@ -287,14 +266,14 @@ def authorize_action( configured at all, which leaves production fail-closed. A configured but failing path raises: a partial deployment must not read as an absent one. """ - binding = resolve_consume_binding( + observation = resolve_approval_observation( cfg, entry, action, decision, fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, opener=opener, ) - if binding is None: + if observation is None: return None pdp_url = str(getattr(cfg, "pdp_url", "") or "") @@ -313,10 +292,7 @@ def authorize_action( "not a publication, and must not be used as a default" ) - expected_request = _expected_request( - cfg, entry, action, - fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, - ) + expected_request = observation.request envelope = check_decision( base_url=pdp_url, token_file=Path(pdp_token), @@ -328,17 +304,19 @@ def authorize_action( expected_request, accepted_policy_packages={package}, accepted_policy_versions={version}, - # The claim's pdp_digest, established in step 1. If this request carried - # the claim in context, the decision must name the same claim-free - # envelope in binding.approval_binding_digest (FLEX-DEC-2026-007). - expected_approval_binding_digest=binding.request_digest, + expected_approval_binding_digest=observation.claim["binding"]["pdp_digest"], + ) + # Recheck freshness after the network call before issuing a consume binding. + validate_approval_claim( + observation.claim, approval_id=observation.approval_id, + expected_pdp_digest=envelope["binding"]["approval_binding_digest"], ) if validated.action != action: raise DecisionError("access-engine decision does not bind this action") return AuthorizedAction( binding=ConsumeBinding( - approval_id=binding.approval_id, - request_digest=binding.request_digest, + approval_id=observation.approval_id, + request_digest=validated.request_digest, decision_id=validated.decision_id, ), decision_id=validated.decision_id, diff --git a/src/secrets_engine/authorization.py b/src/secrets_engine/authorization.py index 9313209..273f247 100644 --- a/src/secrets_engine/authorization.py +++ b/src/secrets_engine/authorization.py @@ -1,8 +1,8 @@ """Fail-closed consumer validation for flex-auth action authorizations. -The canonical contract is flex-auth revision c473f19. State Hub does not yet -provide the durable authoritative endpoint, so this module validates supplied -objects but does not resolve or enable production actions by itself. +Replay identity follows FLEX-DEC-2026-012. The authenticated evaluator owns +registry enrichment; the consumer binds its exact submitted request and checks +policy, lifetime and approval correspondence before consumption. """ from __future__ import annotations @@ -50,6 +50,8 @@ class ValidatedDecision: action: str subject_id: str expires_at: str + request_digest: str + submitted_request_digest: str def build_action_request( @@ -272,158 +274,30 @@ def _require_exact_target_sets(request: dict[str, Any]) -> None: def _check_approval_binding_digest( - binding: dict[str, Any], - expected: dict[str, Any], - expected_digest: str, + binding: dict[str, Any], expected: dict[str, Any], expected_digest: str, ) -> None: - """Tie an approval claim to this exact request (FLEX-DEC-2026-007). - - ``binding.approval_binding_digest`` is the canonical request digest computed - with ``context.approval`` removed, and it appears only when the request - carried a claim there. It is the ONLY sound comparand for a claim's - ``pdp_digest``: a digest recorded at issue time can never equal the - ``request_digest`` of the request that carries the claim, because the claim - is part of the hashed material. Comparing against ``request_digest`` fails - closed forever; comparing against nothing fails open. - - It is deliberately NOT a replay identity -- two requests differing only in - which approval was presented share it while their decisions differ -- so it - is checked here in addition to ``request_digest``, never instead of it. - - When our own request is claim-free the field is absent by contract, and the - identity already holds transitively: step 1 compared the claim's pdp_digest - to this same canonical digest of the claim-free request. - """ + """Compare evaluator-origin values; never reproduce registry enrichment.""" + context = expected.get("context", {}) + carried = APPROVAL_CONTEXT_KEY in context present = binding.get("approval_binding_digest") - if present is not None: - if not isinstance(present, str) or not DIGEST_RE.fullmatch(present): - raise DecisionError("flex-auth approval binding digest is malformed") - if present != approval_binding_digest(expected): - raise DecisionError( - "flex-auth approval binding digest does not match this request " - "with the approval claim removed" - ) - if expected_digest: - if present is None: - context = expected.get("context") - if not isinstance(context, dict) or APPROVAL_CONTEXT_KEY not in context: - # Claim-free request: no approval_binding_digest is emitted and - # step 1 already bound the claim to this canonical digest. - return - raise DecisionError( - "request carried an approval claim but the decision records no " - "approval_binding_digest; the claim cannot be tied to it" - ) - if present != expected_digest: - raise DecisionError( - "approval claim pdp digest does not match the decision's " - "approval binding digest" - ) - - -#: Fields the evaluator's documented normalization may add to a ref, per -#: canonical-request-digest.md "Normalization": the request tenant is copied onto -#: subject and resource when they omit it, and a registry hit copies type, -#: tenant, and selected attributes onto the refs the digest sees. -_ENRICHABLE_REF_FIELDS = ("type", "tenant", "attributes") - - -def _require_ref_corresponds(bound: Any, sent: dict[str, Any], tenant: str, name: str) -> None: - """The bound ref must be our ref, plus only documented enrichment.""" - if not isinstance(bound, dict): - raise DecisionError(f"flex-auth decision binding {name} must be an object") - for key, value in sent.items(): - if key == "attributes": - continue - if bound.get(key) != value: - raise DecisionError( - f"flex-auth decision binding {name}.{key} does not match the " - "proposed action" - ) - for key in bound: - if key not in sent and key not in _ENRICHABLE_REF_FIELDS: - raise DecisionError( - f"flex-auth decision binding {name} carries unexpected field " - f"'{key}'; enrichment may add only {_ENRICHABLE_REF_FIELDS}" - ) - # Normalization rule 1: an enriched tenant is the REQUEST's tenant. A ref - # bearing some other tenant would be a cross-tenant binding wearing our - # request's clothes. - bound_tenant = bound.get("tenant") - if bound_tenant is not None and tenant and bound_tenant != tenant: - raise DecisionError( - f"flex-auth decision binding {name}.tenant '{bound_tenant}' is not " - f"the request tenant '{tenant}'" - ) - sent_attributes = sent.get("attributes") - if isinstance(sent_attributes, dict): - bound_attributes = bound.get("attributes") - if not isinstance(bound_attributes, dict): - raise DecisionError( - f"flex-auth decision binding {name}.attributes must be an object" - ) - # Every attribute we proposed must survive unchanged. The registry may - # add its own; it may not restate ours differently. stage, fields, - # policy_targets and auth_targets are security-bearing and are ours. - for key, value in sent_attributes.items(): - if bound_attributes.get(key) != value: - raise DecisionError( - f"flex-auth decision binding {name}.attributes.{key} does " - "not match the proposed action" - ) - - -def _require_binding_corresponds( - binding: dict[str, Any], expected: dict[str, Any] -) -> None: - """Compare structured binding fields to the proposed action. - - This is what canonical-request-digest.md tells a consumer to do, and why it - is not a digest comparison: "A consumer that re-hashes the original - unenriched request will not match a decision that turned on registry - attributes. Compare structured binding fields to the proposed action, and - treat request_digest as the evaluator's statement of what it hashed." - - The evaluator normalizes before hashing -- it copies the request tenant onto - subject and resource, and a registry hit copies type, tenant and selected - attributes onto the refs. Those additions are material we never sent and - cannot reproduce, so byte-equality against our own request is unsatisfiable - against every real decision. - - What must still hold, and is enforced here: everything we DID propose - survives unchanged, enrichment appears only where the contract permits it, - and an enriched tenant is our request's tenant rather than another one. - """ - tenant = str(expected.get("tenant", "") or "") - if tenant and binding.get("tenant") != tenant: - raise DecisionError("flex-auth decision binding tenant does not match request") - if binding.get("action") != expected["action"]: - raise DecisionError("flex-auth decision binding action does not match request") - if binding.get("context", {}) != expected.get("context", {}): - raise DecisionError("flex-auth decision binding context does not match request") - _require_ref_corresponds(binding.get("subject"), expected["subject"], tenant, "subject") - _require_ref_corresponds(binding.get("resource"), expected["resource"], tenant, "resource") - - -def binding_tuple(binding: dict[str, Any]) -> dict[str, Any]: - """The normalized tuple the binding carries, for independent re-hashing. - - canonical-request-digest.md: "To recompute independently, hash the same - normalized tuple the binding carries (tenant, subject, action, resource, - context)." - """ - tuple_: dict[str, Any] = {} - if binding.get("tenant"): - tuple_["tenant"] = binding["tenant"] - tuple_.update( - { - "subject": binding.get("subject"), - "action": binding.get("action"), - "resource": binding.get("resource"), - "context": binding.get("context", {}), - } - ) - return tuple_ + if not carried: + if present is not None or expected_digest: + raise DecisionError("approval binding requires a carried approval claim") + return + if present is None: + raise DecisionError("request carried an approval claim but the decision records no approval_binding_digest") + if not isinstance(present, str) or not DIGEST_RE.fullmatch(present): + raise DecisionError("flex-auth approval binding digest is malformed") + claim = context[APPROVAL_CONTEXT_KEY] + claim_binding = claim.get("binding", {}) if isinstance(claim, dict) else {} + if not isinstance(claim_binding, dict): + raise DecisionError("carried approval claim binding must be an object") + recorded = claim_binding.get("pdp_digest") + if (claim_binding.get("pdp_path") is not True + or not isinstance(recorded, str) or not DIGEST_RE.fullmatch(recorded)): + raise DecisionError("carried approval claim requires a canonical pdp_path binding") + if present != recorded or (expected_digest and present != expected_digest): + raise DecisionError("approval claim pdp digest does not match the decision's approval binding digest") def validate_decision_envelope( @@ -466,25 +340,15 @@ def validate_decision_envelope( raise DecisionError("flex-auth decision request id does not match request") binding = _required_dict(envelope, "binding") - _require_binding_corresponds(binding, expected) - # The digest is the evaluator's statement of what it hashed, so it is - # verified for self-consistency against the tuple the binding carries -- - # never against our unenriched request, which no real decision can match. - bound_tuple = binding_tuple(binding) - if binding.get("request_digest") != request_digest(bound_tuple): - raise DecisionError( - "flex-auth request digest does not match the binding it carries" - ) - _check_approval_binding_digest(binding, bound_tuple, expected_approval_binding_digest) - # The envelope's top-level refs are enriched too, so they get the same - # correspondence rule rather than byte-equality against what we sent. - _tenant = str(expected.get("tenant", "") or "") - _require_ref_corresponds( - _subject_ref(envelope.get("subject")), expected["subject"], _tenant, "subject" - ) - _require_ref_corresponds( - _resource_ref(envelope.get("resource")), expected["resource"], _tenant, "resource" - ) + submitted = binding.get("submitted_request_digest") + if submitted != request_digest(expected): + raise DecisionError("flex-auth submitted request digest is missing or does not match the proposed action") + # Keep the evaluator's enriched digest as audit/consume evidence. It is not + # locally computable, and registry facts may override caller attributes. + evaluated = binding.get("request_digest") + if not isinstance(evaluated, str) or not DIGEST_RE.fullmatch(evaluated): + raise DecisionError("flex-auth evaluated request digest is malformed") + _check_approval_binding_digest(binding, expected, expected_approval_binding_digest) current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) lifetime = _required_dict(envelope, "lifetime") @@ -506,4 +370,6 @@ def validate_decision_envelope( action=expected["action"], subject_id=expected["subject"]["id"], expires_at=expires.isoformat(), + request_digest=evaluated, + submitted_request_digest=submitted, ) diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index 3e71df2..9858a87 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -37,7 +37,6 @@ from secrets_engine.apply import apply_plan from secrets_engine.approval_consume import ( authorize_action, require_production_consume, - resolve_consume_binding, ) from secrets_engine.catalog import get_entry, load_catalog from secrets_engine.config import Config, repo_root diff --git a/tests/authorization_stub.py b/tests/authorization_stub.py index fe5e815..bb3f526 100644 --- a/tests/authorization_stub.py +++ b/tests/authorization_stub.py @@ -15,7 +15,7 @@ from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, HTTPServer from secrets_engine.approval_claim import binding_from_check_request -from secrets_engine.authorization import request_digest +from secrets_engine.authorization import request_digest, approval_binding_digest def _now(): @@ -152,6 +152,9 @@ class AuthorizationStub: if request.get("context") is not None: binding["context"] = request["context"] binding["request_digest"] = request_digest(request) + binding["submitted_request_digest"] = request_digest(request) + if "approval" in request.get("context", {}): + binding["approval_binding_digest"] = approval_binding_digest(request) return { "id": "decision:stub-" + request["action"], "contract_version": "flex-auth.decision-record.v1", diff --git a/tests/fixtures/flex-auth-replay/PROVENANCE.md b/tests/fixtures/flex-auth-replay/PROVENANCE.md index 46e17b5..75dc488 100644 --- a/tests/fixtures/flex-auth-replay/PROVENANCE.md +++ b/tests/fixtures/flex-auth-replay/PROVENANCE.md @@ -1,52 +1,22 @@ -# flex-auth T03 replay fixtures +# Flex Auth replay contract 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. +Copied byte-for-byte on 2026-09-09 from flex-auth revision +`88b354377c8e26b162f1234e673072f1c06dcd89`, examples/secrets-engine/ and its +replay/ directory. Contract: FLEX-DEC-2026-012. These are standalone evaluator +outputs, not deployment evidence. -Vendored so the digest contract test is hermetic. Regenerate upstream and -re-copy if the contract version changes. +Each output is paired with its independently supplied check_request input. +Tests compute submitted_request_digest from that input; they do not reconstruct +it from the enriched response. Existing request_digest, approval_binding_digest, +policy-package and registry-snapshot pins are unchanged. Policy remains v2. +Approval correspondence compares claim.binding.pdp_digest with the evaluator's +approval_binding_digest. Neither is computed from the unenriched request. -Re-copied 2026-09-06 (twice, both upstream regenerations): +Tests inject the actual decision time instead of modifying captured lifetimes. +The older flex-auth-live fixture remains untouched and must fail the new +contract because it has no submitted_request_digest. -1. commit `9f3e7e3` completed the approval-claim on `context.approval`. Because - `context` is hashed material, completing the claim moved the request digest. -2. commit `dd3ce4c` (`FLEX-DEC-2026-007`) published - `binding.approval_binding_digest` and set the embedded claim's - `binding.pdp_digest` to it with `binding.pdp_path` true. The request digest - moved once more with the claim's contents; the approval-binding digest did - **not**, which is the property the fixture now demonstrates rather than - asserts. - -3. commit `d98323b` published **v2**, which adds the `input.tenant` rule v1 - never had, and a third fixture: `decision_wrong_tenant_deny.json`. The - request digests did **not** move — every allow fixture already carried - `tenant: tenant:platform` — but `provenance.policy_version` is now `v2` and - `policy_package_digest` moved to `sha256:bd11c5fe…`. - -`decision_rotate.json` carries no `approval_binding_digest` — the field is -omitted on claim-free decisions rather than duplicated onto them, and a test -pins that omission. - -`decision_wrong_tenant_deny.json` is an `effect: deny` envelope and carries no -`lifetime`, which is legal: the schema requires `lifetime` only for an allow. -It is the wrong-tenant denial evidence `GLAS-WP-0015` asked for. Do not -lifetime-refresh it in tests. - -**v1 must not be pinned.** It had no tenant rule and failed open; flex-auth -superseded rather than amended it so the change is visible in the version -string. See `docs/tenant-alignment.md`. - -**Pinned here** (stable across runs, per the upstream README): -`binding.request_digest`, `binding.approval_binding_digest`, -`provenance.policy_package_digest`, `provenance.registry_snapshot_digest`, and -the presence/absence of `provenance.input_claim_digests.context`. - -`approval_binding_digest` is not only pinned but **rederived** by -`test_pdp_digest_equals_the_published_approval_binding_digest`: our canonical -implementation must reproduce it from the fixture's own request. A pin asserts -the constant; rederiving it proves we hash the same material flex-auth does. - -**Never pin:** `id`, `provenance.decision_time`, `lifetime.not_before`, -`lifetime.expires_at` — all move with the clock. +`tools/exercise_approval_identity.py` additionally compiles the owner evaluator +and runs real KeyCape/Approval Engine/Flex Auth joins with synthetic credentials, +including registry override and destructive-action dual control. Its receipt +pins source, binary and producer inputs; it makes no live admission claim. diff --git a/tests/fixtures/flex-auth-replay/check_request_allow_destroy_dual_control.json b/tests/fixtures/flex-auth-replay/check_request_allow_destroy_dual_control.json new file mode 100644 index 0000000..a64b987 --- /dev/null +++ b/tests/fixtures/flex-auth-replay/check_request_allow_destroy_dual_control.json @@ -0,0 +1,54 @@ +{ + "id": "check:secrets-engine-destroy", + "tenant": "tenant:platform", + "subject": { + "id": "secrets-engine", + "type": "service" + }, + "action": "destroy", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "attributes": { + "stage": "prod", + "fields": [], + "policy_targets": [], + "auth_targets": [] + } + }, + "context": { + "approval": { + "schema_version": "0.1", + "kind": "approval-claim", + "issuer": "approval-engine", + "approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f", + "state": "valid", + "valid_now": true, + "consumed": false, + "binding": { + "action": "secrets.kv.destroy", + "target": { + "id": "lane-openbao-root", + "stage": "prod" + }, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key", + "digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f", + "pdp_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56", + "pdp_path": true + }, + "freshness": { + "observed_at": "2026-09-06T12:00:00+00:00", + "ttl_seconds": 30, + "not_after": "2026-09-06T12:00:30+00:00" + }, + "validity": { + "not_before": "2026-09-06T11:00:00+00:00", + "expires_at": "2026-09-06T15:00:00+00:00" + }, + "reason_code": "ok" + } + } +} diff --git a/tests/fixtures/flex-auth-replay/check_request_allow_rotate.json b/tests/fixtures/flex-auth-replay/check_request_allow_rotate.json new file mode 100644 index 0000000..7b859f0 --- /dev/null +++ b/tests/fixtures/flex-auth-replay/check_request_allow_rotate.json @@ -0,0 +1,23 @@ +{ + "id": "check:secrets-engine-rotate", + "tenant": "tenant:platform", + "subject": { + "id": "secrets-engine", + "type": "service" + }, + "action": "rotate", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "attributes": { + "stage": "prod", + "fields": [ + "password" + ], + "policy_targets": [], + "auth_targets": [] + } + }, + "context": {} +} diff --git a/tests/fixtures/flex-auth-replay/check_request_deny_wrong_tenant.json b/tests/fixtures/flex-auth-replay/check_request_deny_wrong_tenant.json new file mode 100644 index 0000000..dc0dce0 --- /dev/null +++ b/tests/fixtures/flex-auth-replay/check_request_deny_wrong_tenant.json @@ -0,0 +1,23 @@ +{ + "id": "check:secrets-engine-wrong-tenant", + "tenant": "tenant:coulomb", + "subject": { + "id": "secrets-engine", + "type": "service" + }, + "action": "rotate", + "resource": { + "id": "lane:glas-primary", + "type": "secret-catalog-lane", + "system": "secrets-engine", + "attributes": { + "stage": "prod", + "fields": [ + "password" + ], + "policy_targets": [], + "auth_targets": [] + } + }, + "context": {} +} diff --git a/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json b/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json index 9f20bd6..cda690e 100644 --- a/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json +++ b/tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json @@ -100,19 +100,21 @@ } }, "request_digest": "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a", - "approval_binding_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56" + "approval_binding_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56", + "submitted_request_digest": "sha256:c605a9ecd5711d0a1d59e7b29f3a16b53fd09d104dd077766f098e7bc5895435" }, "lifetime": { "kind": "ttl", "ttl": "15m", - "not_before": "2026-09-06T18:35:19Z", - "expires_at": "2026-09-06T18:50:19Z" + "not_before": "2026-09-07T07:10:23Z", + "expires_at": "2026-09-07T07:25:23Z" }, "diagnostics": { "action": "destroy", "matched_relationship": "", "policy_package": "secrets-engine.catalog-lane.lifecycle", "policy_status": "ready", + "registry_overrode": [], "registry_resource": false, "registry_subject": true }, @@ -126,7 +128,7 @@ "input_claim_digests": { "context": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800" }, - "decision_time": "2026-09-06T18:35:19Z" + "decision_time": "2026-09-07T07:10:23Z" }, "caring": { "profile": "caring-0.4.0-rc2", diff --git a/tests/fixtures/flex-auth-replay/decision_rotate.json b/tests/fixtures/flex-auth-replay/decision_rotate.json index 6ac8c8e..483eead 100644 --- a/tests/fixtures/flex-auth-replay/decision_rotate.json +++ b/tests/fixtures/flex-auth-replay/decision_rotate.json @@ -69,19 +69,21 @@ "stage": "prod" } }, - "request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345" + "request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345", + "submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a" }, "lifetime": { "kind": "ttl", "ttl": "15m", - "not_before": "2026-09-06T18:35:18Z", - "expires_at": "2026-09-06T18:50:18Z" + "not_before": "2026-09-07T07:10:23Z", + "expires_at": "2026-09-07T07:25:23Z" }, "diagnostics": { "action": "rotate", "matched_relationship": "", "policy_package": "secrets-engine.catalog-lane.lifecycle", "policy_status": "ready", + "registry_overrode": [], "registry_resource": false, "registry_subject": true }, @@ -92,7 +94,7 @@ "policy_version": "v2", "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", - "decision_time": "2026-09-06T18:35:18Z" + "decision_time": "2026-09-07T07:10:23Z" }, "caring": { "profile": "caring-0.4.0-rc2", diff --git a/tests/fixtures/flex-auth-replay/decision_wrong_tenant_deny.json b/tests/fixtures/flex-auth-replay/decision_wrong_tenant_deny.json index 0f35353..05228c9 100644 --- a/tests/fixtures/flex-auth-replay/decision_wrong_tenant_deny.json +++ b/tests/fixtures/flex-auth-replay/decision_wrong_tenant_deny.json @@ -69,13 +69,15 @@ "stage": "prod" } }, - "request_digest": "sha256:c9c6e6f8713266e9e95ae1443a395a3a1f965ba95469dea747645f0437bb0d20" + "request_digest": "sha256:c9c6e6f8713266e9e95ae1443a395a3a1f965ba95469dea747645f0437bb0d20", + "submitted_request_digest": "sha256:9aab6de9069e1e811a52835ca00bc5e9cb38166444df068eb60a26923b1c9175" }, "diagnostics": { "action": "rotate", "matched_relationship": "", "policy_package": "secrets-engine.catalog-lane.lifecycle", "policy_status": "ready", + "registry_overrode": [], "registry_resource": false, "registry_subject": true }, @@ -86,7 +88,7 @@ "policy_version": "v2", "policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4", "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", - "decision_time": "2026-09-06T18:35:20Z" + "decision_time": "2026-09-07T07:10:24Z" }, "caring": { "profile": "caring-0.4.0-rc2", diff --git a/tests/test_action_authorization.py b/tests/test_action_authorization.py index 3427928..89d4b8e 100644 --- a/tests/test_action_authorization.py +++ b/tests/test_action_authorization.py @@ -54,6 +54,7 @@ def _envelope(request=None): "resource": copy.deepcopy(request["resource"]), "context": copy.deepcopy(request["context"]), "request_digest": request_digest(request), + "submitted_request_digest": request_digest(request), }, "lifetime": { "kind": "bounded", @@ -135,24 +136,14 @@ def test_state_hub_authority_is_no_longer_required(): [ (lambda d: d.update(effect="deny"), "effect is not allow"), (lambda d: d.update(effect="audit_only"), "effect is not allow"), - (lambda d: d["binding"].update(action="destroy"), "binding action does not match"), - (lambda d: d["binding"].update(request_digest="sha256:" + "0" * 64), - "request digest does not match"), + (lambda d: d["binding"].update(submitted_request_digest="sha256:" + "0" * 64), "submitted request digest"), + (lambda d: d["binding"].update(request_digest="malformed"), "evaluated request digest"), (lambda d: d["provenance"].update(policy_package="other.package"), "policy package is not accepted"), (lambda d: d["provenance"].update(policy_version="v1"), "policy version is not accepted"), (lambda d: d.update(contract_version="flex-auth.decision-record.v2"), "contract version"), - (lambda d: d["subject"].update(id="user:mallory"), - "subject.id does not match"), - # Enrichment may add attributes; it may never restate what we sent. - (lambda d: d["binding"]["resource"]["attributes"].update(stage="build"), - "resource.attributes.stage does not match"), - (lambda d: d["binding"]["subject"].update(tenant="tenant:coulomb"), - "subject.tenant 'tenant:coulomb' is not the request tenant"), - (lambda d: d["binding"]["subject"].update(surprise="x"), - "carries unexpected field"), ], ) def test_invalid_envelopes_fail_closed(mutation, match): diff --git a/tests/test_approval_consume.py b/tests/test_approval_consume.py index 7d2bb4d..446b1b8 100644 --- a/tests/test_approval_consume.py +++ b/tests/test_approval_consume.py @@ -13,7 +13,7 @@ from secrets_engine.approval_consume import ( ConsumeBinding, consume_approval, require_production_consume, - resolve_consume_binding, + resolve_approval_observation, ) from secrets_engine.catalog import validate_entry from secrets_engine.config import Config @@ -87,8 +87,8 @@ def _authorized(): ) -def test_resolve_consume_binding_is_unserved(): - assert resolve_consume_binding(object(), object(), "apply", None) is None +def test_resolve_approval_observation_is_unserved(): + assert resolve_approval_observation(object(), object(), "apply", None) is None def test_consume_success_and_same_digest_retry(tmp_path): diff --git a/tests/test_consume_binding_join.py b/tests/test_consume_binding_join.py index 547fd17..722d7f1 100644 --- a/tests/test_consume_binding_join.py +++ b/tests/test_consume_binding_join.py @@ -17,7 +17,8 @@ from secrets_engine.approval_claim import ( binding_from_check_request, claim_binding_digest, ) -from secrets_engine.approval_consume import resolve_consume_binding +from secrets_engine.approval_consume import resolve_approval_observation, authorize_action +from tests.authorization_stub import AuthorizationStub from secrets_engine.authorization import build_action_request, request_digest from secrets_engine.catalog import validate_entry from secrets_engine.errors import DecisionError @@ -34,6 +35,8 @@ class _Cfg: self.authorization_subject_type = "Human" self.authorization_policy_package = "secrets-engine.lifecycle" self.authorization_policy_version = "v1" + self.pdp_url = "http://127.0.0.1:1234" + self.pdp_token_file = token_file self.authorization_min_approvals = 2 for k, v in over.items(): setattr(self, k, v) @@ -113,19 +116,19 @@ def _opener(envelope, status=200): def _resolve(cfg, entry, envelope, action="deactivate"): - return resolve_consume_binding( - cfg, entry, action, None, - fields=("api_token",), - policy_targets=(entry.policy_name,), - auth_targets=(entry.role_name,), - opener=_opener(envelope), - ) + pdp = AuthorizationStub(approval_id=AUTH_ID, package=cfg.authorization_policy_package, version=cfg.authorization_policy_version) + def check(request, timeout=None): + submitted = json.loads(request.data) + return _opener(pdp.decision(submitted))(request) + return authorize_action(cfg, entry, action, None, fields=("api_token",), + policy_targets=(entry.policy_name,), auth_targets=(entry.role_name,), + opener=_opener(envelope), pdp_opener=check) def test_unconfigured_serving_path_stays_fail_closed(tmp_path): """No URL/token/authorization id: None, exactly as before the join existed.""" cfg = _Cfg(None, approval_url="", approval_token_file=None) - assert resolve_consume_binding(cfg, _entry(), "deactivate", None) is None + assert resolve_approval_observation(cfg, _entry(), "deactivate", None) is None def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path): @@ -133,8 +136,8 @@ def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path): cfg = _Cfg(_token(tmp_path)) binding = _resolve(cfg, entry, _served()) assert binding is not None - assert binding.approval_id == AUTH_ID - assert binding.request_digest == request_digest(_expected_request(entry)) + assert binding.binding.approval_id == AUTH_ID + assert binding.binding.request_digest != request_digest(_expected_request(entry)) def test_missing_subject_raises_instead_of_returning_none(tmp_path): @@ -149,21 +152,13 @@ def test_policy_pin_is_not_enforced_on_the_claim_path(tmp_path): # The pin is a step-2 (DecisionEnvelope) concern after GH-DEC-2026-005 and # is asserted in tests/test_action_authorization.py, not on the claim path. cfg = _Cfg(_token(tmp_path), authorization_policy_package="") - assert _resolve(cfg, _entry(), _served()) is not None + assert resolve_approval_observation(cfg, _entry(), "deactivate", None, opener=_opener(_served())) is not None def test_wrong_field_set_fails_closed(tmp_path): - """A different proposed field set must not match the served digest.""" - entry = _entry() - cfg = _Cfg(_token(tmp_path)) - with pytest.raises(DecisionError): - resolve_consume_binding( - cfg, entry, "deactivate", None, - fields=("some_other_field",), - policy_targets=(entry.policy_name,), - auth_targets=(entry.role_name,), - opener=_opener(_served()), - ) + claim = _served(fields=("other_field",)) + with pytest.raises(DecisionError, match="pdp digest does not match"): + _resolve(_Cfg(_token(tmp_path)), _entry(), claim) def test_action_mismatch_fails_closed(tmp_path): @@ -181,7 +176,7 @@ def test_unreachable_approval_engine_fails_closed(tmp_path): raise URLError("no route") with pytest.raises(DecisionError, match="unreachable"): - resolve_consume_binding( + resolve_approval_observation( _Cfg(_token(tmp_path)), _entry(), "deactivate", None, fields=("api_token",), opener=_boom, ) @@ -236,3 +231,24 @@ def test_wrong_pdp_digest_fails_closed(tmp_path): claim["binding"]["pdp_digest"] = "sha256:" + "c" * 64 with pytest.raises(DecisionError, match="pdp digest does not match"): _resolve(_Cfg(_token(tmp_path)), _entry(), claim) + + +def test_observation_is_not_a_consume_binding(tmp_path): + claim = _served() + claim["binding"]["pdp_digest"] = "sha256:" + "e" * 64 + observation = resolve_approval_observation(_Cfg(_token(tmp_path)), _entry(), "deactivate", None, + opener=_opener(claim)) + assert observation.claim == claim + assert observation.request["context"]["approval"] == claim + assert not hasattr(observation, "request_digest") + assert not hasattr(observation, "decision_id") + + +def test_claim_that_expires_during_check_cannot_yield_consume_binding(tmp_path, monkeypatch): + import secrets_engine.approval_consume as consumer + from secrets_engine.approval_claim import validate_approval_claim + def after_check(*args, **kwargs): + return validate_approval_claim(*args, **kwargs, now=datetime.now(timezone.utc) + timedelta(seconds=31)) + monkeypatch.setattr(consumer, "validate_approval_claim", after_check) + with pytest.raises(DecisionError, match="observation is stale"): + _resolve(_Cfg(_token(tmp_path)), _entry(), _served()) diff --git a/tests/test_decision_replay.py b/tests/test_decision_replay.py index e540fe5..47e9b74 100644 --- a/tests/test_decision_replay.py +++ b/tests/test_decision_replay.py @@ -1,438 +1,133 @@ -"""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`. -""" +"""FLEX-DEC-2026-012 against independent producer request/output fixtures.""" +import copy import json -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from pathlib import Path +from types import SimpleNamespace import pytest -from types import SimpleNamespace - -from secrets_engine.authorization import ( - REQUEST_TENANT, - approval_binding_digest, - build_action_request, - digest_material, - digest_material, - request_digest, - validate_decision_envelope, -) -from secrets_engine.decision_check import ( - check_decision, - require_supported_pdp_address, -) +from secrets_engine.authorization import REQUEST_TENANT, build_action_request, request_digest, validate_decision_envelope +from secrets_engine.decision_check import check_decision, require_supported_pdp_address from secrets_engine.errors import DecisionError FIXTURES = Path(__file__).parent / "fixtures" / "flex-auth-replay" - -PACKAGE_DIGEST = "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4" -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:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a", - "action": "destroy", - "context_claim_digest": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800", - }, + "rotate": ("check_request_allow_rotate.json", "decision_rotate.json", "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345"), + "destroy": ("check_request_allow_destroy_dual_control.json", "decision_destroy_dual_control.json", "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a"), + "deny": ("check_request_deny_wrong_tenant.json", "decision_wrong_tenant_deny.json", None), } -def _envelope(name): - return json.loads((FIXTURES / CASES[name]["file"]).read_text()) +def pair(name): + inp, out, _ = CASES[name] + return json.loads((FIXTURES / inp).read_text()), json.loads((FIXTURES / out).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 validate(request, decision, **over): + # Validate at the actual producer decision time; do not rewrite evidence. + now = datetime.fromisoformat(decision["provenance"]["decision_time"].replace("Z", "+00:00")) + return validate_decision_envelope(decision, request, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}, now=now, **over) -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", ["rotate", "destroy"]) +def test_real_producer_request_validates(name): + request, decision = pair(name) + assert request_digest(request) == decision["binding"]["submitted_request_digest"] + assert request_digest(request) != decision["binding"]["request_digest"] + assert decision["binding"]["request_digest"] == CASES[name][2] + assert decision["provenance"]["registry_snapshot_digest"] == "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb" + assert decision["provenance"]["policy_package_digest"] == "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4" + assert validate(request, decision).action == name -@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)) +@pytest.mark.parametrize("name", ["rotate", "destroy"]) +def test_correlation_fields_are_excluded(name): + request, _ = pair(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 + for key, value in (("id", "other"), ("policy_version", "v99"), ("caring_context", {"extra": "context"})): + assert request_digest({**request, key: value}) == 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 +def test_approval_join_is_between_evaluator_origin_values(): + request, decision = pair("destroy") + pdp = request["context"]["approval"]["binding"]["pdp_digest"] + assert pdp == decision["binding"]["approval_binding_digest"] == "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56" + without = copy.deepcopy(request) + del without["context"]["approval"] + assert request_digest(without) != pdp + assert validate(request, decision, expected_approval_binding_digest=pdp).action == "destroy" -@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={"v2"}, - ) - assert result.action == CASES[name]["action"] - assert result.subject_id == "secrets-engine" +@pytest.mark.parametrize("change", ["claim_id", "claim_absent", "purpose", "action", "tenant", "field", "target"]) +def test_allow_cannot_be_replayed_for_changed_submitted_material(change): + request, decision = pair("destroy") + if change == "claim_id": + request["context"]["approval"]["approval_id"] = "other-approval" + elif change == "claim_absent": + del request["context"]["approval"] + elif change == "purpose": + request["context"]["purpose"] = "other-purpose" + elif change in ("action", "tenant"): + request[change] = "other" + elif change == "field": + request["resource"]["attributes"]["fields"] = ["other"] + else: + request["resource"]["id"] = "catalog:other" + with pytest.raises(DecisionError, match="submitted request digest"): + validate(request, decision) -@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("value", [None, "", "sha256:" + "f" * 64]) +def test_missing_or_wrong_submitted_binding_refused(value): + request, decision = pair("rotate") + if value is None: + del decision["binding"]["submitted_request_digest"] + else: + decision["binding"]["submitted_request_digest"] = value + with pytest.raises(DecisionError, match="submitted request digest"): + validate(request, decision) -@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("value", [None, "bad", "sha256:" + "f" * 64]) +def test_approval_binding_missing_malformed_or_wrong_refused(value): + request, decision = pair("destroy") + decision["binding"]["approval_binding_digest"] = value + with pytest.raises(DecisionError, match="approval[_ ]binding_digest|approval binding digest"): + validate(request, decision) -@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_claim_free_shortcut_refused(): + request, decision = pair("rotate") + with pytest.raises(DecisionError, match="carried approval"): + validate(request, decision, expected_approval_binding_digest=decision["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={"v2"}, - ) - - -def test_embedded_claim_uses_approval_engine_vocabulary_not_ours(): - """The published vocabulary gap, asserted rather than described. - - The dual-control request carries a complete approval-claim in - context.approval. Its binding speaks approval-engine's language while the - decision speaks ours, and no mapping between them is published -- which is - why this engine compares pdp_digest and refuses to derive a native digest - from its own vocabulary. - """ - envelope = _envelope("destroy") - claim = envelope["binding"]["context"]["approval"] - assert claim["kind"] == "approval-claim" - assert claim["issuer"] == "approval-engine" - assert claim["valid_now"] is True - assert claim["binding"]["action"] == "secrets.kv.destroy" - assert envelope["binding"]["action"] == "destroy" - assert claim["binding"]["action"] != envelope["binding"]["action"] - assert set(claim["binding"]["target"]) == {"id", "stage"} - assert claim["binding"]["target"]["id"] != envelope["resource"]["id"] - - -APPROVAL_BINDING_DIGEST = ( - "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56" -) - - -def test_embedded_claim_pdp_digest_cannot_equal_the_carrying_request_digest(): - """Carrying the claim inside a hashed context makes the two unequal. - - context is part of the digest material, so embedding an approval-claim - changes the request digest of the very request that carries it. A - pdp_digest recorded at issue time therefore cannot equal the final digest - of the dual-control request. This is why comparing pdp_digest against - request_digest can never pass and would fail destroy closed forever. - """ - envelope = _envelope("destroy") - binding = envelope["binding"] - pdp = binding["context"]["approval"]["binding"]["pdp_digest"] - request = _request_from(envelope) - assert request_digest(request) == binding["request_digest"] - assert pdp != binding["request_digest"] - - -def test_pdp_digest_equals_the_published_approval_binding_digest(): - """FLEX-DEC-2026-007 closed the circularity, and we reproduce the value. - - ``approval_binding_digest`` is the canonical digest with context.approval - removed. Recomputing it here from our own canonical implementation is the - hermetic proof that this engine hashes the same material flex-auth does -- - a pin alone would only assert the constant, not that we can derive it. - """ - envelope = _envelope("destroy") - binding = envelope["binding"] - pdp = binding["context"]["approval"]["binding"]["pdp_digest"] - assert binding["approval_binding_digest"] == APPROVAL_BINDING_DIGEST - assert pdp == APPROVAL_BINDING_DIGEST - - assert approval_binding_digest(_request_from(envelope)) == APPROVAL_BINDING_DIGEST - - -def test_approval_binding_digest_is_not_a_replay_identity(): - """It must not collapse into request_digest, or an allow becomes replayable. - - Two requests differing only in which approval was presented share an - approval_binding_digest while their decisions differ -- one allows, one - denies dual_control_required. The fixture asserts the two digests disagree - on a claim-bearing request so the distinction stays real. - """ - envelope = _envelope("destroy") - binding = envelope["binding"] - assert binding["approval_binding_digest"] != binding["request_digest"] - - -def test_ordinary_decision_carries_no_approval_binding_digest(): - """The field is omitted, not duplicated, on a claim-free decision.""" - binding = _envelope("rotate")["binding"] - assert "approval" not in binding.get("context", {}) - assert "approval_binding_digest" not in binding - - -def test_embedded_claim_declares_the_pdp_path(): - """pdp_path is the issuer's declaration, and it is what our PEP requires. - - Path intent is never inferred from a pdp_digest that happens to be present; - approvals issued before approval-engine schema v3 carry pdp_path false - regardless of any digest they hold. - """ - claim = _envelope("destroy")["binding"]["context"]["approval"] - assert claim["binding"]["pdp_path"] is True - - -def test_decision_validation_ties_the_claim_to_the_approval_binding_digest(): - """Step 2 checks the identity against the real envelope, not a local guess. - - The PEP knows the claim's pdp_digest from step 1. When the request it sent - carried that claim, the decision must name the same claim-free envelope, or - the approval was issued against some other request. - """ - envelope = _refresh_lifetime(_envelope("destroy")) - request = _request_from(envelope) - result = validate_decision_envelope( - envelope, - request, - accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v2"}, - expected_approval_binding_digest=APPROVAL_BINDING_DIGEST, - ) - assert result.action == "destroy" - - with pytest.raises(DecisionError, match="approval binding digest"): - validate_decision_envelope( - envelope, - request, - accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v2"}, - expected_approval_binding_digest="sha256:" + "c" * 64, - ) - - -def test_claim_bearing_request_without_a_binding_digest_fails_closed(): - """A decision that records no binding digest cannot tie the claim to itself. - - Comparing against request_digest instead would be the fail-open direction - the whole field exists to prevent, so the absence is refused outright. - """ - envelope = _refresh_lifetime(_envelope("destroy")) - request = _request_from(envelope) - del envelope["binding"]["approval_binding_digest"] - with pytest.raises(DecisionError, match="records no approval_binding_digest"): - validate_decision_envelope( - envelope, - request, - accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v2"}, - expected_approval_binding_digest=APPROVAL_BINDING_DIGEST, - ) - - -def test_a_forged_binding_digest_is_recomputed_not_trusted(): - """The field is verified against our own canonical digest, never taken on faith.""" - envelope = _refresh_lifetime(_envelope("destroy")) - request = _request_from(envelope) - envelope["binding"]["approval_binding_digest"] = "sha256:" + "d" * 64 - with pytest.raises(DecisionError, match="does not match this request"): - validate_decision_envelope( - envelope, - request, - accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v2"}, - ) - - -# --- tenant scoping (v2) ----------------------------------------------------- -# -# GLAS-WP-0015 asked for the exact CheckRequest tenant and wrong-tenant denial -# evidence. These are that evidence, taken from a real deny envelope rather than -# asserted, plus the pin that stops our request tenant drifting from the package -# it is scoped by. - -WRONG_TENANT_FIXTURE = "decision_wrong_tenant_deny.json" - - -def test_our_request_tenant_is_the_package_known_tenant(): - """The tenant we send must be the one the package allows. - - v2 reads request_tenant := object.get(input, "tenant", "") and allows only - known_tenant := "tenant:platform". Pinning our constant against the real - allow envelopes means a package retenanting shows up here rather than as a - wrong_tenant denial in production. - """ - for name in ("rotate", "destroy"): - assert _envelope(name)["binding"]["tenant"] == REQUEST_TENANT - - -def test_built_request_carries_the_tenant_and_hashes_it(): - """An absent tenant is a denial, not an ignored field, so it must be sent. - - tenant is part of the digest material, so omitting it also produces a digest - that matches no correctly issued decision -- the same class of defect as - hashing excluded fields, arriving from the other direction. - """ - entry = SimpleNamespace(id="glas-primary", stage="prod") - request = build_action_request( - entry, - "rotate", - subject_id="secrets-engine", - subject_type="service", - purpose="rotate-exposed-key", - fields=["password"], - ) - assert request["tenant"] == REQUEST_TENANT - assert "tenant" in digest_material(request) - - untenanted = dict(request) - del untenanted["tenant"] - assert request_digest(untenanted) != request_digest(request) - - -def test_build_action_request_refuses_an_empty_tenant(): - entry = SimpleNamespace(id="glas-primary", stage="prod") - with pytest.raises(DecisionError, match="requires a tenant"): - build_action_request( - entry, - "rotate", - subject_id="secrets-engine", - subject_type="service", - purpose="rotate-exposed-key", - tenant="", - ) - - -def test_wrong_tenant_deny_envelope_fails_closed(): - """Wrong-tenant denial evidence, from a real v2 deny envelope. - - flex-auth sent tenant:coulomb on an otherwise-valid rotate and the package - denied it with matched_rule wrong_tenant, so the tenant alone carried the - denial. Our consumer must refuse it on effect before anything else -- a deny - is not a decision we may act on, whatever else it validates. - """ - # Not lifetime-refreshed: a deny carries no lifetime at all, which is the - # property test_wrong_tenant_deny_carries_no_lifetime pins. - envelope = json.loads((FIXTURES / WRONG_TENANT_FIXTURE).read_text()) - assert envelope["effect"] == "deny" - assert envelope["reason"] == "wrong_tenant" - assert envelope["matched_rule"] == "wrong_tenant" - assert envelope["binding"]["tenant"] == "tenant:coulomb" - assert envelope["binding"]["tenant"] != REQUEST_TENANT - +def test_wrong_tenant_and_superseded_policy_refused(): + request, decision = pair("deny") + assert decision["reason"] == "wrong_tenant" + assert decision.get("lifetime") is None with pytest.raises(DecisionError, match="effect is not allow"): - validate_decision_envelope( - envelope, - _request_from(envelope), - accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v2"}, - ) + validate(request, decision) + request, decision = pair("rotate") + decision["provenance"]["policy_version"] = "v1" + with pytest.raises(DecisionError, match="policy version"): + validate(request, decision) -def test_wrong_tenant_deny_carries_no_lifetime(): - """A deny has no lifetime, which is why effect must be checked first. - - DecisionEnvelope requires lifetime only when effect is allow. A consumer - that validated lifetime before effect would raise a confusing missing-field - error on a perfectly well-formed denial. - """ - envelope = json.loads((FIXTURES / WRONG_TENANT_FIXTURE).read_text()) - assert envelope.get("lifetime") is None +def test_real_expired_allow_refused(): + request, decision = pair("rotate") + with pytest.raises(DecisionError, match="lifetime has expired"): + validate_decision_envelope(decision, request, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}, now=datetime(2100, 1, 1, tzinfo=timezone.utc)) -def test_the_superseded_v1_package_is_not_accepted(): - """v1 had no tenant rule and failed open; pinning it must not be possible. - - A consumer still pinned to v1 would keep getting allows it should never - have had and could not tell from the version string that the rule changed - underneath it, which is exactly why flex-auth superseded v1 rather than - amending it. - """ - envelope = _refresh_lifetime(_envelope("rotate")) - with pytest.raises(DecisionError, match="policy version is not accepted"): - validate_decision_envelope( - envelope, - _request_from(envelope), - accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, - accepted_policy_versions={"v1"}, - ) +def test_request_builder_tenant_boundary(): + entry = SimpleNamespace(id="glas-primary", stage="prod") + args = dict(subject_id="secrets-engine", subject_type="service", purpose="rotation") + request = build_action_request(entry, "rotate", **args) + assert request["tenant"] == REQUEST_TENANT + with pytest.raises(DecisionError, match="requires a tenant"): + build_action_request(entry, "rotate", tenant="", **args) # --- supported PDP address (FLEX-DEC-2026-010) ------------------------------- diff --git a/tests/test_live_decision_enrichment.py b/tests/test_live_decision_enrichment.py index 5d3cfb4..337a7c6 100644 --- a/tests/test_live_decision_enrichment.py +++ b/tests/test_live_decision_enrichment.py @@ -1,181 +1,24 @@ -"""The digest join against a REAL request, not one rebuilt from the binding. - -tests/test_decision_replay.py passes every digest assertion because -``_request_from()`` reconstructs the request out of ``envelope["binding"]`` -- -which is the *enriched* form the evaluator hashed. That is a self-consistent -fake agreeing with itself, and it hid a real defect through three rounds of -digest work: the validator required byte-equality between the binding and our -unenriched request, which no real decision can satisfy. - -The rule was already published in flex-auth's canonical-request-digest.md -("Normalization"): the evaluator copies the request tenant onto subject and -resource, a registry hit copies type/tenant/selected attributes onto the refs, -and a consumer is told to compare structured binding fields to the proposed -action rather than re-hash its own request. This module builds the request the -way the engine actually builds it, compares it to a decision the engine actually -received, and proves the corrected check accepts it. -""" -from __future__ import annotations - +"""Historical live evidence remains immutable and cannot satisfy the new contract.""" import json -from datetime import datetime, timedelta, timezone +from datetime import datetime from pathlib import Path import pytest -from secrets_engine.authorization import ( - binding_tuple, - build_action_request, - canonical_check_request, - request_digest, - validate_decision_envelope, -) +from secrets_engine.authorization import build_action_request, validate_decision_envelope from secrets_engine.catalog import load_catalog from secrets_engine.errors import DecisionError -LIVE = Path(__file__).parent / "fixtures" / "flex-auth-live" / "decision_rotate_glas_live.json" -CATALOG = Path(__file__).resolve().parents[1] / "catalog" -PACKAGE = "secrets-engine.catalog-lane.lifecycle" -VERSION = "v2" - - -def _live(): - return json.loads(LIVE.read_text()) - - -def _our_request(): - entry = load_catalog(CATALOG)["glas-claude-agent-dev-anthropic"] - return build_action_request( - entry, - "rotate", - subject_id="secrets-engine", - subject_type="service", - purpose="live-adoption-proof", - fields=["api_key"], - request_id="check:secrets-engine-adoption-proof", - ) - - -def test_the_live_decision_is_a_real_v2_allow(): - """Provenance of the artifact these assertions rest on.""" - env = _live() - assert env["effect"] == "allow" - assert env["reason"] == "catalog_lane_policy_matched" - provenance = env["provenance"] - assert provenance["policy_package"] == PACKAGE - assert provenance["policy_version"] == VERSION - assert env["binding"]["tenant"] == "tenant:platform" - - -def test_the_evaluator_enriches_subject_and_resource_before_hashing(): - """Names exactly which fields appeared that we never sent. - - If flex-auth publishes an enrichment rule that differs from this, this test - fails and tells us the shape moved -- which is the point. It asserts the - observed gap, not a rule we invented. - """ - sent = _our_request() - bound = _live()["binding"] - - assert "attributes" not in sent["subject"] - assert set(bound["subject"]["attributes"]) == { - "description", "display_name", "groups", "organization_relation", "roles", - } - assert "tenant" not in sent["subject"] - assert bound["subject"]["tenant"] == sent["tenant"] - - assert "tenant" not in sent["resource"] - assert bound["resource"]["tenant"] == sent["tenant"] - - # Everything we DID send survived unchanged. The enrichment is additive, so - # the decision is about the action we proposed -- which is why staying - # fail-closed here costs correctness nothing today. - assert bound["action"] == sent["action"] - assert bound["tenant"] == sent["tenant"] - assert bound["context"] == sent["context"] - assert bound["subject"]["id"] == sent["subject"]["id"] - assert bound["subject"]["type"] == sent["subject"]["type"] - for key in ("id", "type", "system", "attributes"): - assert bound["resource"][key] == sent["resource"][key] - - -def test_our_digest_cannot_match_a_real_binding(): - """The join is unsatisfiable against a real request, not merely mismatched. - - We hash what we sent; the evaluator hashed what it enriched. No amount of - care on our side closes that, because the registry material is not ours. - """ - sent = _our_request() - bound = _live()["binding"] - assert bound["request_digest"] != request_digest(sent) - assert canonical_check_request(bound) != canonical_check_request(sent) - - -def test_the_live_allow_now_validates_under_the_documented_rule(): - """The real decision validates -- this is the fix, proved against the artifact. - - canonical-request-digest.md "Normalization" is the published rule, and it - says a consumer must compare structured binding fields to the proposed - action rather than re-hash its own request. Under that rule this envelope, - which our previous byte-equality check rejected outright, is accepted. - - Only the lifetime is refreshed: the decision was issued on 2026-09-07 with a - bounded lifetime, and pinning a clock-dependent field is what the fixture - provenance forbids. - """ - env = _live() - now = datetime.now(timezone.utc) - env["lifetime"] = { - "kind": "bounded", - "not_before": (now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), - "expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"), - } - result = validate_decision_envelope( - env, - _our_request(), - accepted_policy_packages={PACKAGE}, - accepted_policy_versions={VERSION}, - ) - assert result.action == "rotate" - assert result.subject_id == "secrets-engine" - assert result.decision_id == "decision:0f9c98f14545c42d" - - -def test_the_unrefreshed_live_decision_is_refused_on_lifetime(): - """It is a real expired allow, so it must be refused -- and for that reason. - - Reaching the lifetime check at all is the evidence that every binding check - before it now passes against a real decision. - """ - with pytest.raises(DecisionError, match="lifetime has expired"): - validate_decision_envelope( - _live(), - _our_request(), - accepted_policy_packages={PACKAGE}, - accepted_policy_versions={VERSION}, - ) - - -def test_the_digest_is_verified_against_the_binding_it_carries(): - """Independent recomputation, per the contract's own instruction. - - "To recompute independently, hash the same normalized tuple the binding - carries." So the digest is still checked -- it is simply checked for - self-consistency rather than against material we never sent. - """ - binding = _live()["binding"] - assert request_digest(binding_tuple(binding)) == binding["request_digest"] - - -def test_a_tampered_binding_still_fails_the_digest(): - """Self-consistency is a real check, not a formality.""" - env = _live() - env["binding"]["resource"]["attributes"]["stage"] = "build" - with pytest.raises(DecisionError, match="does not match"): - validate_decision_envelope( - env, - _our_request(), - accepted_policy_packages={PACKAGE}, - accepted_policy_versions={VERSION}, - ) +def test_old_live_pin_without_submitted_binding_refuses_even_during_its_lifetime(): + root = Path(__file__).resolve().parents[1] + decision = json.loads((root / "tests/fixtures/flex-auth-live/decision_rotate_glas_live.json").read_text()) + entry = load_catalog(root / "catalog")["glas-claude-agent-dev-anthropic"] + request = build_action_request(entry, "rotate", subject_id="secrets-engine", subject_type="service", + purpose="live-adoption-proof", fields=["api_key"], request_id="check:secrets-engine-adoption-proof") + assert decision["effect"] == "allow" + assert "submitted_request_digest" not in decision["binding"] + with pytest.raises(DecisionError, match="submitted request digest"): + validate_decision_envelope(decision, request, + accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}, + now=datetime.fromisoformat(decision["provenance"]["decision_time"].replace("Z", "+00:00"))) diff --git a/tools/exercise_approval_identity.py b/tools/exercise_approval_identity.py index afd1514..542f76f 100644 --- a/tools/exercise_approval_identity.py +++ b/tools/exercise_approval_identity.py @@ -2,7 +2,8 @@ """Disposable KeyCape image -> actual Secrets Engine client -> Approval Engine. Synthetic keys/credentials only. No OpenBao, cluster mutation, or model call. -The PDP is a sequencing double; this is not production authorization evidence. +The actual Flex Auth evaluator runs locally with its published policy/registry. +This is component conformance evidence, not production authorization evidence. """ from __future__ import annotations @@ -66,6 +67,7 @@ def exercise(args): from secrets_engine import cli from secrets_engine.approval_auth import approval_token, credential_urlopen from secrets_engine.approval_consume import ConsumeBinding, _expected_request, consume_approval + from secrets_engine.authorization import request_digest, validate_decision_envelope from secrets_engine.catalog import validate_entry from secrets_engine.config import Config from secrets_engine.errors import BackendError, DecisionError @@ -77,17 +79,44 @@ def exercise(args): "started_at": datetime.now(timezone.utc).isoformat(), "keycape_image": KEYCAPE_IMAGE, "approval_engine_commit": commit(args.approval_engine_source), + "flex_auth_commit": commit(args.flex_auth_source), "keycape_contract_commit": commit(args.keycape_source), "consumer_source_sha256": { name: hashlib.sha256((ROOT / "src/secrets_engine" / name).read_bytes()).hexdigest() - for name in ("approval_auth.py", "approval_consume.py", "config.py", "service_auth.py") + for name in ("approval_auth.py", "approval_consume.py", "approval_claim.py", "authorization.py", "config.py", "service_auth.py", "cli.py") }, - "limitations": ["PDP sequencing double", "local Approval Engine source, not deployed image", + "limitations": ["standalone Flex Auth source, not deployed pin", "local Approval Engine source, not deployed image", "no live custody or client-side read grant", "no OpenBao effect or model execution"], "checks": {}, } with tempfile.TemporaryDirectory(prefix="approval-identity-private-") as temporary: root = Path(temporary) + source = args.flex_auth_source.resolve() + run(["go", "build", "-C", str(source), "-o", str(root / "flex-auth"), "./cmd/flex-auth"]) + producer_files = source / "examples/secrets-engine" + receipt["flex_auth_binary_sha256"] = hashlib.sha256((root / "flex-auth").read_bytes()).hexdigest() + receipt["producer_input_sha256"] = { + name: hashlib.sha256((producer_files / name).read_bytes()).hexdigest() + for name in ("policy_package.md", "registry_snapshot.json") + } + + class ProducerPDP(AuthorizationStub): + """Loopback transport only; every decision comes from the Go evaluator.""" + last_decision = None + def decision(self, request): + path = root / "check-request.json" + path.write_text(json.dumps(request)) + path.chmod(0o600) + self.last_decision = json.loads(run([str(root / "flex-auth"), "check", + "-policy", str(producer_files / "policy_package.md"), + "-registry", str(producer_files / "registry_snapshot.json"), "-request", str(path)])) + return self.last_decision + + def bind_request(self, request): + # At issue, record the evaluator's claim-free digest, even if + # the destructive action denies until approval is supplied. + return self.decision(request)["binding"]["request_digest"] + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) private_write(root / "key.pem", key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode()) @@ -189,7 +218,7 @@ def exercise(args): raw["stage"] = "prod" raw["approval"] = {"model": "bootstrap-only", "authorization_id": "synthetic-approval", "purpose": "disposable identity proof"} entry = validate_entry(raw) - pdp = AuthorizationStub(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start() + pdp = ProducerPDP(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start() cfg = Config(catalog_dir=root, policy_dir=root, evidence_dir=root / "evidence", hub_url="", bao_addr="", topic_id="", approval_url=api, approval_client_secret_file=root / "approval.secret", keycape_issuer=issuer, keycape_token_url=issuer + "/token", @@ -215,9 +244,15 @@ def exercise(args): receipt["checks"]["wrong_action_refused_before_consume"] = True cli._require_lane_approval(cfg, entry, "apply") assert engine.claim(obj["id"])["consumed"] - assert pdp.calls == ["check"] + assert pdp.calls == ["check", "check"] + accepted = pdp.last_decision + assert accepted["binding"]["approval_binding_digest"] == digest + assert accepted["binding"]["request_digest"] != digest + assert accepted["binding"]["submitted_request_digest"] != accepted["binding"]["request_digest"] + assert accepted["binding"]["context"]["approval"]["approval_id"] == obj["id"] + receipt["checks"]["producer_origin_join_with_carried_claim"] = True receipt["checks"]["actual_consumer_claim_check_consume"] = True - binding = ConsumeBinding(obj["id"], digest) + binding = ConsumeBinding(obj["id"], accepted["binding"]["request_digest"], accepted["id"]) retry = consume_approval(base_url=api, binding=binding, token_provider=lambda: approval_token(cfg, scope="approval:consume")) assert retry.idempotent receipt["checks"]["same_digest_retry_idempotent"] = True @@ -254,6 +289,44 @@ def exercise(args): receipt["checks"]["wrong_secret_refused"] = True assert not (root / "approval.token").exists() receipt["checks"]["no_access_token_file_created"] = True + + # Independent input -> evaluator -> consumer: registry may + # override a caller's same-key facts without breaking replay. + request = json.loads((producer_files / "check_request_allow_rotate.json").read_text()) + request["subject"]["attributes"] = {"roles": ["UntrustedCallerRole"]} + decision = pdp.decision(request) + assert decision["binding"]["subject"]["attributes"]["roles"] == ["Operator"] + assert decision["binding"]["submitted_request_digest"] == request_digest(request) + validate_decision_envelope(decision, request, + accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}) + receipt["checks"]["registry_override_accepts_exact_submission"] = True + replay = copy.deepcopy(request) + replay["subject"]["attributes"]["roles"] = ["Operator"] + try: + validate_decision_envelope(decision, replay, + accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}) + except DecisionError: + pass + else: + raise AssertionError("changed submission replayed") + receipt["checks"]["same_enriched_result_different_submission_refused"] = True + # Real dual-control policy: empty claim denies; fresh issued + # claim passes. No destructive backend is connected. + raw["approval"]["authorization_id"] = "synthetic-destroy" + destroy_entry = validate_entry(raw) + destroy_request = _expected_request(cfg, destroy_entry, "destroy") + denied = pdp.decision(destroy_request) + assert denied["effect"] == "deny" and denied["reason"] == "dual_control_required" + operator_request("/v1/approvals", { + "id": "synthetic-destroy", "binding": {"action": "secrets.kv.destroy", "target": {"id": destroy_entry.id, "stage": "prod"}, + "actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"}, + "validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()}, + "pdp_digest": denied["binding"]["request_digest"], "pdp_path": True}, "approval:create") + operator_request("/v1/approvals/synthetic-destroy/entries", {}, "approval:approve") + (root / "approval.secret").write_text(values["secrets-engine-approval"]) + cli._require_lane_approval(cfg, destroy_entry, "destroy") + assert engine.claim("synthetic-destroy")["consumed"] + receipt["checks"]["real_dual_control_denial_then_claim_check_consume"] = True finally: if api_server: api_server.shutdown() @@ -275,6 +348,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--keycape-source", required=True, type=Path) parser.add_argument("--approval-engine-source", required=True, type=Path) + parser.add_argument("--flex-auth-source", required=True, type=Path) parser.add_argument("--receipt", required=True, type=Path) args = parser.parse_args() if args.receipt.exists(): diff --git a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md index e993a4b..b7dfe76 100644 --- a/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md +++ b/workplans/SECRETS-WP-0008-layer-model-lifecycle-conformance.md @@ -89,7 +89,7 @@ Acceptance: ```task id: SECRETS-WP-0008-T02 -status: progress +status: wait priority: high state_hub_task_id: "3eb9cff8-1441-5437-9e92-a2b655c82d04" ``` @@ -194,6 +194,30 @@ The current source has not yet implemented the changes in this return; live PDP rollout/adoption evidence remains explicit too. No synthetic fixture or Hub decision substitutes for a runtime allow and consume. +### 2026-09-09 replay consumer implementation and real component acceptance + +The returned correction is implemented. The PEP now checks the exact submitted +request digest including the full carried approval, permits registry-owned +attribute replacement, joins the evaluator-origin approval digests and rechecks +claim freshness after Check. The consume CAS uses the validated evaluator's +request_digest and decision id; an observation alone is not a consume binding. + +334 regression tests pass. The disposable KeyCape + actual Approval Engine + +compiled Flex Auth exercise passes 13 checks, including real dual-control denial +then claim/check/consume, registry override, changed submitted request, wrong +action, consumed claim and same/different CAS digest. Independent producer +inputs replace response-reconstructed replay fixtures; existing digest pins +remain unchanged. Receipt: docs/evidence/2026-09-09-replay-contract-exercise.json. +No OpenBao side effect, live credential read or model request occurred. + +T02 is wait for the live return: deployed PDP contract adoption and the admitted +Approval Engine claim/consume path (APPROVAL-WP-0002-T03 and RPF-WP-0035-T06 / +AUDIT-WP-0009-T09). SECRETS-WP-0009-T03 owns native delivery acceptance. The old +live decision lacks submitted_request_digest and is explicitly refused; its +historical evidence was not rewritten. Local conformance does not close the +remaining live task. This supersedes earlier additive-only and claim-free +transitive correspondence descriptions above. + ## Emit evidence under the §9.6 bound ```task diff --git a/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md b/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md index b546ec3..809202d 100644 --- a/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md +++ b/workplans/SECRETS-WP-0009-glas-claude-native-delivery.md @@ -186,3 +186,13 @@ Owner-return clarification: SECRETS-WP-0008-T02 is now progress for the publishe FLEX-DEC-2026-012 submitted-request replay identity and approval-digest join. The real-component identity exercise used a PDP double and does not close that local implementation. T03 also consumes that correction before live activation. + + +## Replay-contract return — 2026-09-09 + +SECRETS-WP-0008-T02 supplies the implemented FLEX-DEC-2026-012 consumer join. +334 tests and 13 real local KeyCape/Approval Engine/Flex Auth checks pass; +receipt: docs/evidence/2026-09-09-replay-contract-exercise.json. The former PDP +double limitation is resolved for component conformance. T03 remains wait for +current deployed PDP adoption, separately admitted client-side/audit custody, +actual Approval Engine deployment, and the scoped native OpenBao delivery proof.