fix: bind approval consumption to actual Flex Auth submissions
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 08:55:46 +02:00
parent 89bc31460f
commit ee4e901611
23 changed files with 612 additions and 1023 deletions

View file

@ -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:<id>`). 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

View file

@ -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/<new-metadata-receipt>.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.

View file

@ -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"
}

View file

@ -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

View file

@ -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:<id>"), 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,

View file

@ -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,
)

View file

@ -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

View file

@ -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",

View file

@ -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.

View file

@ -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"
}
}
}

View file

@ -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": {}
}

View file

@ -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": {}
}

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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):

View file

@ -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):

View file

@ -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())

View file

@ -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) -------------------------------

View file

@ -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")))

View file

@ -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():

View file

@ -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

View file

@ -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.