From 7b4b9e386e45a1a45153b7c1a9dd1e56683f9879 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 08:02:01 +0200 Subject: [PATCH] feat: split the validator by owning layer per GH-DEC-2026-005 gate-house resolved APPROVAL-IN-0002. Two changes fell to this repo. 1. Split validate_action_authorization. The claim from approval-engine now carries the approval fact (issuer, valid_now, consumption, binding digest, freshness, reason_code) via approval_claim.validate_approval_claim; the flex-auth DecisionEnvelope carries the decision (effect, binding match, request digest, lifetime, policy pin) via validate_decision_envelope. ActionAuthorization is deferred and never ratified (FLEX-DEC-2026-006) and cannot be served from a step-1 call; nothing validates it now. 2. Dropped AUTHORITY = "state-hub" and the provenance.authority requirement. State Hub is a read model with no runtime approval authority, so the check failed closed against every correctly issued record. flex-auth traced the constant to their own fixture and fixed it at source. Two consequences recorded rather than buried: there are now two distinct digests over the same action (approval-engine native over {action,actor,principal,purpose,target}, and the flex-auth CheckRequest digest) which are never compared to each other; and the distinct-approver threshold is no longer checked here, since the claim exposes no approver entries and approval-engine folds it into valid_now. The canonical request digest is unchanged and its contract test is preserved verbatim. Production still fails closed. 251 tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD Assistant: claude-code Assistant-Model: opus Assistant-Process: 393550@bnt-lap001 Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4 --- docs/approval-consumption.md | 68 +++-- src/secrets_engine/approval_claim.py | 172 +++++++++++++ src/secrets_engine/approval_consume.py | 95 ++++--- src/secrets_engine/authorization.py | 152 ++++------- tests/test_action_authorization.py | 240 ++++++++---------- tests/test_approval_claim.py | 180 +++++++++++++ tests/test_consume_binding_join.py | 71 ++++-- ...-WP-0007-production-lifecycle-hardening.md | 41 +++ 8 files changed, 694 insertions(+), 325 deletions(-) create mode 100644 src/secrets_engine/approval_claim.py create mode 100644 tests/test_approval_claim.py diff --git a/docs/approval-consumption.md b/docs/approval-consumption.md index 9438155..105abf1 100644 --- a/docs/approval-consumption.md +++ b/docs/approval-consumption.md @@ -19,19 +19,43 @@ does not redefine it. 4. PEP OpenBao call → only after consume succeeds ``` -Step 1 is `resolve_consume_binding` (`approval_consume.py`): it reproduces the -exact CheckRequest with `build_action_request`, fetches the durable -`ActionAuthorization` from `GET /v1/approvals/{id}/claim`, and validates it with -`validate_action_authorization` before returning a consume binding. Until -2026-09-06 that function was a `return None` stub and the validator was -unreachable from `src/`; the join now exists. +**Each artifact is validated by the layer that owns its data** +(`GH-DEC-2026-005`). There is no single bundled object: + +| Step | Artifact | Owner | Validated by | +| --- | --- | --- | --- | +| 1 | approval-claim | approval-engine | `approval_claim.validate_approval_claim` | +| 2 | DecisionEnvelope | flex-auth | `authorization.validate_decision_envelope` | + +The claim carries the *approval fact*: issuer, `valid_now`, consumption state, +binding digest, freshness, `reason_code`. The envelope carries the *decision*: +effect, exact CheckRequest binding, canonical request digest, lifetime, and the +policy package/version pin. Neither republishes the other's data. + +`ActionAuthorization` is **deferred and was never ratified** +(`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: + +- `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. + +When the issuer recorded `claim.binding.pdp_digest`, that comparison is +preferred. The CheckRequest mapping onto the claim binding is published in +`approval-engine/docs/approval-claim.md`. The approval-engine object id is never inferred from a State Hub decision UUID. -It comes from catalog `approval.authorization_id` or a served -`decision.authorization_id`. The Check request `id` is an opaque correlator the -PEP cannot regenerate, so the served one is adopted; every security-relevant -field is still compared exactly and the binding digest is recomputed against the -served request. +It comes from catalog `approval.authorization_id`, and is the value in the +`{id}` path segment echoed back as `approval_id`. + +**There is no authority constant.** The engine previously required +`provenance.authority == "state-hub"`, which contradicted its own source: State +Hub is a read model and holds no runtime approval authority, so that check +failed closed against every correctly issued record. The approval fact's +authority is approval-engine (checked as `issuer`); the decision's is flex-auth. Every live privileged production handler passes `_require_lane_approval`, which calls `require_production_consume` before `OpenBaoClient.resolve`. @@ -67,13 +91,23 @@ did before. Production additionally needs: | `SECRETS_ENGINE_APPROVAL_URL` | approval-engine base URL (claim + consume) | | `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | mode-0600 credential, outside Git | | `SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal | -| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin | -| `SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS` | distinct-approver threshold | +| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin (step 2) | -There is deliberately no default policy pin. flex-auth stated that the published -`secrets-engine.lifecycle` / `v1` names are example vocabulary on the envelope, -not a live package, so treating them as a default would pin production to a -package nobody publishes. +The distinct-approver threshold is no longer a consumer-side check. The claim +does not expose approver entries; approval-engine folds that requirement into +`valid_now`, which is true only when enough distinct authenticated approvers +have been recorded and the object is not consumed, superseded, revoked, or +expired. + +There is deliberately no default policy pin. The reserved coordinate is +`secrets-engine.catalog-lane.lifecycle` / `v1`, but that is a *reservation, not +a publication* (`FLEX-DEC-2026-005`) and must not be configured until +`FLEX-WP-0021-T02` publishes the package. `docs/gated-actions.md` supplies the +action vocabulary that package is built from. + +Step 2 additionally needs the `flex-auth-secrets-engine` Service DNS. No +estate-wide PDP exists by design — flex-auth runs per-consumer cluster-local +pins — and that pin has not been created, so the PDP call is not wired yet. ## What this does not do diff --git a/src/secrets_engine/approval_claim.py b/src/secrets_engine/approval_claim.py new file mode 100644 index 0000000..ec21815 --- /dev/null +++ b/src/secrets_engine/approval_claim.py @@ -0,0 +1,172 @@ +"""approval-engine approval-claim consumer (GH-DEC-2026-005, step 1). + +The claim is a *fact about an approval object*, never a decision. It carries no +`effect`, `allow`, or `deny`, and holding one with ``valid_now: true`` is not +authority to act -- it is one input the decision point weighs. + +Contract: ``approval-engine/docs/approval-claim.md`` (schema 0.1). The native +binding digest here is NOT the flex-auth CheckRequest digest: it is taken over +``{action, actor, principal, purpose, target}``. The two are deliberately +different functions and must not be compared to each other. +""" +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any + +from secrets_engine.errors import DecisionError + +SCHEMA_VERSION = "0.1" +KIND = "approval-claim" +ISSUER = "approval-engine" +VALID_REASON = "ok" + + +def claim_binding_digest( + *, + action: str, + actor: str, + principal: str, + purpose: str, + target: dict[str, Any], +) -> str: + """sha256 over canonical JSON of the approval-engine binding. + + Sorted keys at every level, no insignificant whitespace. Wrong action, + target, or scope changes this JSON and therefore the digest, which is what + stops a decision rendered for request R being replayed for request R'. + """ + canonical = json.dumps( + { + "action": action, + "actor": actor, + "principal": principal, + "purpose": purpose, + "target": target, + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def binding_from_check_request(request: dict[str, Any]) -> dict[str, Any]: + """Map a flex-auth CheckRequest onto the claim binding fields. + + Mapping is published in ``approval-claim.md``: target is the resource + object, actor is ``subject.id``, principal is ``subject.attributes.principal`` + when present and ``subject.id`` otherwise, purpose is ``context.purpose``. + """ + subject = request.get("subject") or {} + if not isinstance(subject, dict): + raise DecisionError("check request subject must be an object") + attributes = subject.get("attributes") or {} + principal = "" + if isinstance(attributes, dict): + principal = str(attributes.get("principal", "") or "") + actor = str(subject.get("id", "") or "") + context = request.get("context") or {} + purpose = "" + if isinstance(context, dict): + purpose = str(context.get("purpose", "") or "") + resource = request.get("resource") + if not isinstance(resource, dict): + raise DecisionError("check request resource must be an object") + return { + "action": str(request.get("action", "") or ""), + "actor": actor, + "principal": principal or actor, + "purpose": purpose, + "target": resource, + } + + +def _parse_time(value: object, name: str) -> datetime: + if not isinstance(value, str) or not value: + raise DecisionError(f"approval claim {name} must be a timestamp") + text = value.strip().replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(text) + except ValueError as e: + raise DecisionError(f"approval claim {name} is not a valid timestamp") from e + if parsed.tzinfo is None: + raise DecisionError(f"approval claim {name} must carry a timezone") + return parsed.astimezone(timezone.utc) + + +def validate_approval_claim( + 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. + """ + if not isinstance(claim, dict): + raise DecisionError("approval claim must be an object") + if claim.get("schema_version") != SCHEMA_VERSION: + raise DecisionError("unsupported approval claim schema version") + if claim.get("kind") != KIND: + raise DecisionError("approval claim kind is not approval-claim") + if claim.get("issuer") != ISSUER: + raise DecisionError("approval claim issuer is not approval-engine") + for forbidden in ("effect", "decision", "allow", "deny"): + if forbidden in claim: + raise DecisionError( + f"approval claim carries '{forbidden}'; a claim is not a decision" + ) + + served_id = str(claim.get("approval_id", "") or "") + if not served_id or served_id != approval_id: + raise DecisionError("approval claim is for a different approval object") + if claim.get("valid_now") is not True: + raise DecisionError( + "approval claim is not valid now " + f"(reason_code={claim.get('reason_code', 'unknown')})" + ) + if claim.get("consumed") is not False: + raise DecisionError("approval claim is already consumed") + if claim.get("reason_code") != VALID_REASON: + raise DecisionError("approval claim reason code is not ok") + + 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 when the issuer recorded one at issue time. + if expected_pdp_digest and pdp: + 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): + raise DecisionError("approval claim freshness must be an object") + if _parse_time(freshness.get("not_after"), "freshness.not_after") <= current: + raise DecisionError("approval claim observation is stale; re-fetch") + + validity = claim.get("validity") + if not isinstance(validity, dict): + raise DecisionError("approval claim validity must be an object") + if _parse_time(validity.get("expires_at"), "validity.expires_at") <= current: + raise DecisionError("approval claim validity window has expired") + if validity.get("not_before") is not None: + if _parse_time(validity.get("not_before"), "validity.not_before") > current: + raise DecisionError("approval claim is not yet valid") + return claim diff --git a/src/secrets_engine/approval_consume.py b/src/secrets_engine/approval_consume.py index 36676a1..1723a28 100644 --- a/src/secrets_engine/approval_consume.py +++ b/src/secrets_engine/approval_consume.py @@ -18,11 +18,12 @@ from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen -from secrets_engine.authorization import ( - build_action_request, - request_digest, - validate_action_authorization, +from secrets_engine.approval_claim import ( + binding_from_check_request, + claim_binding_digest, + validate_approval_claim, ) +from secrets_engine.authorization import build_action_request, request_digest from secrets_engine.errors import DecisionError from secrets_engine.openbao import read_strict_token_file from secrets_engine.pep_stance import demo_exception_enabled @@ -97,7 +98,7 @@ def _request_purpose(entry: Any) -> str: return "" -def fetch_action_authorization( +def fetch_approval_claim( *, base_url: str, token_file: Path, @@ -105,7 +106,12 @@ def fetch_action_authorization( timeout_seconds: float = 3, opener: Callable[..., Any] = urlopen, ) -> dict[str, Any]: - """GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed.""" + """GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed. + + The body is approval-engine's approval-claim, not a flex-auth + ActionAuthorization -- that object is deferred and was never ratified + (GH-DEC-2026-005 / FLEX-DEC-2026-006). + """ if not base_url or not base_url.startswith(("http://", "https://")): raise DecisionError("approval-engine claim URL is missing or invalid") ident = authorization_id.strip() @@ -121,7 +127,7 @@ def fetch_action_authorization( try: with opener(request, timeout=timeout_seconds) as response: if getattr(response, "status", 200) != 200: - raise DecisionError("approval claim did not return the authorization") + raise DecisionError("approval claim did not return a claim") payload = json.loads(response.read(_MAX_BODY).decode("utf-8")) except HTTPError as e: raise DecisionError(f"approval claim refused: {_status_message(e.code)}") from e @@ -145,12 +151,18 @@ def resolve_consume_binding( auth_targets: tuple[str, ...] = (), opener: Callable[..., Any] | None = None, ) -> ConsumeBinding | None: - """Join the proposed action to a served ActionAuthorization (PIP + validate). + """Join the proposed action to a served approval-claim (step 1 of GH-DEC-2026-003). - Returns None only when this repo holds no configured serving path, which - keeps production fail-closed exactly as it was before the join existed. - Anything configured-but-wrong raises instead of degrading to None: a - half-configured PEP must not look like an unconfigured one. + 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. + + Step 2 (the flex-auth DecisionEnvelope from POST /v1/check) is validated by + ``authorization.validate_decision_envelope``. No PDP is reachable for this + consumer yet -- flex-auth runs per-consumer cluster-local pins and + ``flex-auth-secrets-engine`` has not been created -- so that call is not + wired here and production stays closed at the stance gate regardless. """ base_url = str(getattr(cfg, "approval_url", "") or "") token_file = getattr(cfg, "approval_token_file", None) @@ -165,36 +177,12 @@ def resolve_consume_binding( "authorization join requires SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID " "and _SUBJECT_TYPE; the PEP must not assert an unnamed subject" ) - package = str(getattr(cfg, "authorization_policy_package", "") or "") - version = str(getattr(cfg, "authorization_policy_version", "") or "") - if not package or not version: - raise DecisionError( - "authorization join requires an explicitly configured policy " - "package/version pin; the published example vocabulary " - "(secrets-engine.lifecycle/v1) is not a live pin" - ) purpose = _request_purpose(entry) if not purpose: raise DecisionError( "authorization join requires a declared approval/consumer purpose" ) - envelope = fetch_action_authorization( - base_url=base_url, - token_file=Path(token_file), - authorization_id=authorization_id, - opener=opener or urlopen, - ) - # The Check request id is an opaque correlator chosen by the requester, so - # the PEP cannot regenerate it and adopts the served one. Every - # security-relevant field (subject, action, resource, context) is still - # compared exactly by validate_action_authorization, and the served - # binding digest is recomputed against the served request, so adopting the - # id cannot let a mismatched request validate. - served_request = envelope.get("request") - served_id = "" - if isinstance(served_request, dict): - served_id = str(served_request.get("id", "") or "") expected_request = build_action_request( entry, action, @@ -204,21 +192,32 @@ def resolve_consume_binding( fields=fields, policy_targets=policy_targets, auth_targets=auth_targets, - request_id=served_id, ) - validated = validate_action_authorization( - envelope, - expected_request, - accepted_policy_packages={package}, - accepted_policy_versions={version}, - minimum_approval_count=int(getattr(cfg, "authorization_min_approvals", 1) or 1), + # Two different digests over the same proposed action, by contract: the + # approval-engine native binding digest, and the flex-auth CheckRequest + # digest. They are not interchangeable and are never compared to each other. + native_digest = claim_binding_digest(**binding_from_check_request(expected_request)) + pdp_digest = request_digest(expected_request) + + claim = fetch_approval_claim( + base_url=base_url, + token_file=Path(token_file), + authorization_id=authorization_id, + opener=opener or urlopen, ) - if validated.action != action: - raise DecisionError("action authorization does not bind this action") + validate_approval_claim( + claim, + approval_id=authorization_id, + expected_binding_digest=native_digest, + expected_pdp_digest=pdp_digest, + ) + binding = claim.get("binding") or {} + if isinstance(binding, dict) and binding.get("action") not in (None, action): + raise DecisionError("approval claim does not bind this action") return ConsumeBinding( - approval_id=validated.authorization_id, - request_digest=request_digest(expected_request), - decision_id=validated.decision_id, + approval_id=authorization_id, + request_digest=pdp_digest, + decision_id="", ) diff --git a/src/secrets_engine/authorization.py b/src/secrets_engine/authorization.py index e3bba9d..ba57509 100644 --- a/src/secrets_engine/authorization.py +++ b/src/secrets_engine/authorization.py @@ -17,12 +17,11 @@ from secrets_engine.catalog import CatalogEntry from secrets_engine.errors import DecisionError SCHEMA_VERSION = "0.1" -AUTHORITY = "state-hub" +CONTRACT_VERSION = "flex-auth.decision-record.v1" @dataclass(frozen=True) -class ValidatedActionAuthorization: - authorization_id: str +class ValidatedDecision: decision_id: str action: str subject_id: str @@ -182,91 +181,45 @@ def _require_exact_target_sets(request: dict[str, Any]) -> None: ) -def validate_action_authorization( +def validate_decision_envelope( envelope: object, expected_request: object, *, accepted_policy_packages: set[str], accepted_policy_versions: set[str], - minimum_approval_count: int = 1, now: datetime | None = None, -) -> ValidatedActionAuthorization: - """Validate exact request binding and dual control; never parse prose.""" - if minimum_approval_count < 1: - raise DecisionError("minimum approval count must be positive") +) -> ValidatedDecision: + """Validate a flex-auth DecisionEnvelope against the proposed action. + + This is step 2 of GH-DEC-2026-003. It owns exactly the decision-layer + checks: effect, exact CheckRequest binding, canonical request digest, + lifetime, and the policy package/version pin. The approval fact -- validity, + supersession, consumption, distinct approvers -- belongs to the + approval-engine claim and is NOT re-checked here (GH-DEC-2026-005: a PIP + must not republish the PDP's decision, and neither layer republishes the + other's data). + + There is deliberately no authority constant. State Hub is a read model and + holds no runtime approval authority; requiring it fails closed against every + correctly issued record. + """ if not accepted_policy_packages or not accepted_policy_versions: raise DecisionError("accepted flex-auth policy package/version is required") if not isinstance(envelope, dict): - raise DecisionError("action authorization must be an object") - if envelope.get("schema_version") != SCHEMA_VERSION: - raise DecisionError("unsupported action authorization schema version") - authorization_id = _required_text(envelope, "id") - try: - parsed_authorization_id = uuid.UUID(authorization_id) - except ValueError as e: - raise DecisionError("action authorization id must be a canonical UUID") from e - if str(parsed_authorization_id) != authorization_id: - raise DecisionError("action authorization id must be a canonical UUID") - if envelope.get("status") != "approved": - raise DecisionError("action authorization status is not approved") - if envelope.get("superseded_by"): - raise DecisionError("action authorization is superseded") - provenance = _required_dict(envelope, "provenance") - if provenance.get("authority") != AUTHORITY: - raise DecisionError("action authorization authority is not State Hub") - - request = canonical_check_request(envelope.get("request")) - expected = canonical_check_request(expected_request) - _require_exact_target_sets(request) - _require_exact_target_sets(expected) - if request != expected: - raise DecisionError("action authorization request does not exactly match action") - - validity = _required_dict(envelope, "validity") - expires = _parse_time(validity.get("expires_at"), "expires_at") - not_before = ( - _parse_time(validity.get("not_before"), "not_before") - if validity.get("not_before") is not None - else None - ) - current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) - if not_before is not None and current < not_before: - raise DecisionError("action authorization window has not started") - if current >= expires: - raise DecisionError("action authorization has expired") - - approvals = _required_dict(envelope, "approvals") - required_count = approvals.get("required_count") - entries = approvals.get("entries") - if not isinstance(required_count, int) or required_count < 1: - raise DecisionError("action authorization approval count is invalid") - if required_count < minimum_approval_count: - raise DecisionError("action authorization approval threshold is insufficient") - if not isinstance(entries, list): - raise DecisionError("action authorization approval entries are invalid") - approvers: set[str] = set() - for entry in entries: - if not isinstance(entry, dict): - raise DecisionError("action authorization approval entry is invalid") - subject_id = _required_text(entry, "subject_id") - approved_at = _parse_time(entry.get("approved_at"), "approved_at") - if approved_at > current or approved_at >= expires: - raise DecisionError("action authorization approval time is outside window") - if not_before is not None and approved_at < not_before: - raise DecisionError("action authorization approval time is outside window") - if subject_id in approvers: - raise DecisionError("action authorization contains duplicate approver") - approvers.add(subject_id) - if len(approvers) < required_count: - raise DecisionError("action authorization has insufficient distinct approvals") - - decision = _required_dict(envelope, "decision") - if decision.get("effect") != "allow": + raise DecisionError("decision envelope must be an object") + contract = envelope.get("contract_version") + if contract is not None and contract != CONTRACT_VERSION: + raise DecisionError("unsupported decision envelope contract version") + if envelope.get("effect") != "allow": raise DecisionError("flex-auth decision effect is not allow") - decision_id = _required_text(decision, "id") - if request.get("id") and decision.get("request_id") != request["id"]: + decision_id = _required_text(envelope, "id") + + expected = canonical_check_request(expected_request) + _require_exact_target_sets(expected) + if expected.get("id") and envelope.get("request_id") not in (None, expected["id"]): raise DecisionError("flex-auth decision request id does not match request") - binding = _required_dict(decision, "binding") + + binding = _required_dict(envelope, "binding") bound_request: dict[str, Any] = {} if binding.get("tenant"): bound_request["tenant"] = binding["tenant"] @@ -279,36 +232,43 @@ def validate_action_authorization( } ) expected_bound: dict[str, Any] = {} - if request.get("tenant"): - expected_bound["tenant"] = request["tenant"] + if expected.get("tenant"): + expected_bound["tenant"] = expected["tenant"] expected_bound.update( { - "subject": request["subject"], - "action": request["action"], - "resource": request["resource"], - "context": request.get("context", {}), + "subject": expected["subject"], + "action": expected["action"], + "resource": expected["resource"], + "context": expected.get("context", {}), } ) - if canonical_check_request(bound_request) != canonical_check_request( - expected_bound - ): + if canonical_check_request(bound_request) != canonical_check_request(expected_bound): raise DecisionError("flex-auth decision binding does not match request") - if binding.get("request_digest") != request_digest(request): + if binding.get("request_digest") != request_digest(expected): raise DecisionError("flex-auth request digest does not match request") - if _subject_ref(decision.get("subject")) != request["subject"]: + if _subject_ref(envelope.get("subject")) != expected["subject"]: raise DecisionError("flex-auth decision subject does not match request") - if _resource_ref(decision.get("resource")) != request["resource"]: + if _resource_ref(envelope.get("resource")) != expected["resource"]: raise DecisionError("flex-auth decision resource does not match request") - decision_provenance = _required_dict(decision, "provenance") - if decision_provenance.get("policy_package") not in accepted_policy_packages: + + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + lifetime = _required_dict(envelope, "lifetime") + expires = _parse_time(lifetime.get("expires_at"), "expires_at") + if current >= expires: + raise DecisionError("flex-auth decision lifetime has expired") + if lifetime.get("not_before") is not None: + if current < _parse_time(lifetime.get("not_before"), "not_before"): + raise DecisionError("flex-auth decision lifetime has not started") + + provenance = _required_dict(envelope, "provenance") + if provenance.get("policy_package") not in accepted_policy_packages: raise DecisionError("flex-auth policy package is not accepted") - if decision_provenance.get("policy_version") not in accepted_policy_versions: + if provenance.get("policy_version") not in accepted_policy_versions: raise DecisionError("flex-auth policy version is not accepted") - return ValidatedActionAuthorization( - authorization_id=authorization_id, + return ValidatedDecision( decision_id=decision_id, - action=request["action"], - subject_id=request["subject"]["id"], + action=expected["action"], + subject_id=expected["subject"]["id"], expires_at=expires.isoformat(), ) diff --git a/tests/test_action_authorization.py b/tests/test_action_authorization.py index e69b821..bb4dc02 100644 --- a/tests/test_action_authorization.py +++ b/tests/test_action_authorization.py @@ -1,21 +1,26 @@ +"""flex-auth DecisionEnvelope consumer (step 2 of GH-DEC-2026-003). + +The ActionAuthorization envelope these tests used to cover is deferred and was +never ratified (FLEX-DEC-2026-006); the approval fact moved to +tests/test_approval_claim.py. The canonical request digest is unchanged and its +contract test below is preserved verbatim -- flex-auth confirmed only the +envelope went away, not the digest join. +""" import copy -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import pytest from secrets_engine.authorization import ( build_action_request, request_digest, - validate_action_authorization, + validate_decision_envelope, ) from secrets_engine.catalog import validate_entry from secrets_engine.errors import DecisionError from tests.test_catalog import VALID -NOW = datetime(2026, 8, 23, 10, 5, tzinfo=timezone.utc) - - def _request(): entry = validate_entry(copy.deepcopy(VALID)) return build_action_request( @@ -31,62 +36,44 @@ def _request(): ) -def _envelope(): - request = _request() +def _envelope(request=None): + """A flex-auth DecisionEnvelope shaped by schemas/decision_envelope.schema.json.""" + request = request or _request() + now = datetime.now(timezone.utc) return { - "schema_version": "0.1", - "id": "8bfc20be-47a4-4fb0-97a2-bf0a920afad8", - "status": "approved", - "request": request, - "validity": { - "not_before": "2026-08-23T10:00:00Z", - "expires_at": "2026-08-23T10:15:00Z", - }, - "approvals": { - "required_count": 2, - "entries": [ - { - "subject_id": "user:alice", - "approved_at": "2026-08-23T10:01:00Z", - }, - { - "subject_id": "user:bob", - "approved_at": "2026-08-23T10:02:00Z", - }, - ], - }, - "decision": { - "id": "decision:test-lane-deactivate", - "request_id": request["id"], - "effect": "allow", - "resource": copy.deepcopy(request["resource"]), + "id": "decision:test-lane-deactivate", + "contract_version": "flex-auth.decision-record.v1", + "request_id": request["id"], + "effect": "allow", + "subject": copy.deepcopy(request["subject"]), + "resource": copy.deepcopy(request["resource"]), + "binding": { "subject": copy.deepcopy(request["subject"]), - "binding": { - "subject": copy.deepcopy(request["subject"]), - "action": request["action"], - "resource": copy.deepcopy(request["resource"]), - "context": copy.deepcopy(request["context"]), - "request_digest": request_digest(request), - }, - "provenance": { - "evaluator": "flex-auth/local", - "mode": "standalone", - "policy_package": "secrets-engine.lifecycle", - "policy_version": "v1", - }, + "action": request["action"], + "resource": copy.deepcopy(request["resource"]), + "context": copy.deepcopy(request["context"]), + "request_digest": request_digest(request), + }, + "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"), + }, + "provenance": { + "evaluator": "flex-auth/secrets-engine", + "mode": "cluster-local", + "policy_package": "secrets-engine.catalog-lane.lifecycle", + "policy_version": "v1", }, - "provenance": {"authority": "state-hub"}, } def _validate(envelope, expected=None): - return validate_action_authorization( + return validate_decision_envelope( envelope, expected or _request(), - accepted_policy_packages={"secrets-engine.lifecycle"}, + accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v1"}, - minimum_approval_count=2, - now=NOW, ) @@ -115,111 +102,86 @@ def test_digest_matches_flex_auth_contract_example(): "sha256:73d5d7d5b3363f1a1db8f4c0e79c8f33dae5d77ffb97f21e449438bc0defa4c3" ) - -def test_valid_exact_action_authorization_passes(): +def test_valid_allow_envelope_passes(): result = _validate(_envelope()) - assert result.authorization_id == "8bfc20be-47a4-4fb0-97a2-bf0a920afad8" + assert result.decision_id == "decision:test-lane-deactivate" assert result.action == "deactivate" assert result.subject_id == "user:alice" +def test_state_hub_authority_is_no_longer_required(): + """GH-DEC-2026-005: State Hub holds no runtime approval authority. + + A correctly issued record naming any other authority (or none) must pass; + the old AUTHORITY constant failed closed against every real record. + """ + env = _envelope() + env["provenance"]["authority"] = "approval-engine" + assert _validate(env).action == "deactivate" + env["provenance"].pop("authority") + assert _validate(env).action == "deactivate" + + @pytest.mark.parametrize( ("mutation", "match"), [ - (lambda doc: doc.update(status="superseded"), "status is not approved"), - ( - lambda doc: doc["request"]["resource"].update(id="catalog:wrong"), - "does not exactly match", - ), - ( - lambda doc: doc["request"].update(action="destroy"), - "does not exactly match", - ), - ( - lambda doc: doc["request"]["resource"]["attributes"].update( - fields=["other"] - ), - "does not exactly match", - ), - ( - lambda doc: doc["request"]["context"].update(purpose="wrong"), - "does not exactly match", - ), - ( - lambda doc: doc["decision"].update(effect="deny"), - "effect is not allow", - ), - ( - lambda doc: doc["decision"]["binding"].update( - request_digest="sha256:" + "0" * 64 - ), - "digest does not match", - ), - ( - lambda doc: doc["approvals"]["entries"][1].update( - subject_id="user:alice" - ), - "duplicate approver", - ), - ( - lambda doc: doc["approvals"].update(required_count=1), - "threshold is insufficient", - ), - ( - lambda doc: doc["decision"].update(request_id="check:wrong"), - "request id does not match", - ), - ( - lambda doc: doc["provenance"].update(authority="local-fixture"), - "authority is not State Hub", - ), + (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 does not match"), + (lambda d: d["binding"].update(request_digest="sha256:" + "0" * 64), + "request digest does not match"), + (lambda d: d["provenance"].update(policy_package="other.package"), + "policy package is not accepted"), + (lambda d: d["provenance"].update(policy_version="v2"), + "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 does not match"), ], ) -def test_invalid_authorizations_fail_closed(mutation, match): - envelope = _envelope() - mutation(envelope) +def test_invalid_envelopes_fail_closed(mutation, match): + env = _envelope() + mutation(env) with pytest.raises(DecisionError, match=match): - _validate(envelope) + _validate(env) -def test_expired_authorization_fails_closed(): - with pytest.raises(DecisionError, match="expired"): - validate_action_authorization( - _envelope(), - _request(), - accepted_policy_packages={"secrets-engine.lifecycle"}, - accepted_policy_versions={"v1"}, - now=datetime(2026, 8, 23, 10, 15, tzinfo=timezone.utc), +def test_expired_lifetime_fails_closed(): + env = _envelope() + past = datetime.now(timezone.utc) - timedelta(minutes=1) + env["lifetime"]["expires_at"] = past.strftime("%Y-%m-%dT%H:%M:%SZ") + with pytest.raises(DecisionError, match="lifetime has expired"): + _validate(env) + + +def test_lifetime_not_yet_started_fails_closed(): + env = _envelope() + future = datetime.now(timezone.utc) + timedelta(minutes=5) + env["lifetime"]["not_before"] = future.strftime("%Y-%m-%dT%H:%M:%SZ") + with pytest.raises(DecisionError, match="has not started"): + _validate(env) + + +def test_unaccepted_policy_pin_is_required(): + with pytest.raises(DecisionError, match="package/version is required"): + validate_decision_envelope( + _envelope(), _request(), + accepted_policy_packages=set(), accepted_policy_versions={"v1"}, ) -def test_noncanonical_authorization_uuid_is_rejected(): - envelope = _envelope() - envelope["id"] = envelope["id"].replace("-", "") - with pytest.raises(DecisionError, match="canonical UUID"): - _validate(envelope) - - -def test_approval_timestamp_must_be_inside_current_authorization_window(): - envelope = _envelope() - envelope["approvals"]["entries"][1]["approved_at"] = "2026-08-23T10:06:00Z" - with pytest.raises(DecisionError, match="approval time is outside window"): - _validate(envelope) - - def test_unsorted_or_duplicate_target_sets_are_rejected(): - envelope = _envelope() - envelope["request"]["resource"]["attributes"]["fields"] = [ - "second", - "first", - "first", - ] + request = _request() + request["resource"]["attributes"]["policy_targets"] = ["b", "a"] with pytest.raises(DecisionError, match="sorted and unique"): - _validate(envelope, expected=envelope["request"]) + _validate(_envelope(), request) -def test_unaccepted_policy_revision_is_rejected(): - envelope = _envelope() - envelope["decision"]["provenance"]["policy_version"] = "v2" - with pytest.raises(DecisionError, match="policy version is not accepted"): - _validate(envelope) +def test_approval_fact_is_not_rechecked_here(): + """GH-DEC-2026-005: a PIP must not republish the PDP's decision, and the + decision layer must not restate the approval fact. Consumption, supersession + and approver counts belong to the claim; adding them here would fail closed + against a valid envelope that simply does not carry them.""" + env = _envelope() + assert "approvals" not in env and "status" not in env + assert _validate(env).action == "deactivate" diff --git a/tests/test_approval_claim.py b/tests/test_approval_claim.py new file mode 100644 index 0000000..5d68f24 --- /dev/null +++ b/tests/test_approval_claim.py @@ -0,0 +1,180 @@ +"""approval-engine approval-claim consumer (step 1 of GH-DEC-2026-003). + +Covers the published "Required verification" list in +approval-engine/docs/approval-claim.md. The claim is a fact, never a decision. +""" +import copy +from datetime import datetime, timedelta, timezone + +import pytest + +from secrets_engine.approval_claim import ( + binding_from_check_request, + claim_binding_digest, + validate_approval_claim, +) +from secrets_engine.errors import DecisionError + +APPROVAL_ID = "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f" + + +def _binding(): + return { + "action": "secrets.kv.destroy", + "target": {"id": "lane-openbao-root", "stage": "prod"}, + "actor": "agt-secrets-engine", + "principal": "bernd", + "purpose": "rotate-exposed-key", + } + + +def _claim(**over): + now = datetime.now(timezone.utc) + binding = dict(_binding()) + binding["digest"] = claim_binding_digest(**_binding()) + claim = { + "schema_version": "0.1", + "kind": "approval-claim", + "issuer": "approval-engine", + "approval_id": APPROVAL_ID, + "state": "valid", + "valid_now": True, + "consumed": False, + "binding": binding, + "freshness": { + "observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + "ttl_seconds": 30, + "not_after": (now + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + "validity": { + "not_before": (now - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "expires_at": (now + timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + "reason_code": "ok", + } + claim.update(over) + return claim + + +def _validate(claim, **kw): + kw.setdefault("approval_id", APPROVAL_ID) + kw.setdefault("expected_binding_digest", claim_binding_digest(**_binding())) + return validate_approval_claim(claim, **kw) + + +def test_valid_claim_passes(): + assert _validate(_claim())["approval_id"] == APPROVAL_ID + + +def test_binding_digest_is_canonical_sorted_json(): + """Sorted keys at every level, no insignificant whitespace.""" + digest = claim_binding_digest(**_binding()) + assert digest.startswith("sha256:") and len(digest) == 71 + shuffled = { + "purpose": "rotate-exposed-key", + "actor": "agt-secrets-engine", + "target": {"stage": "prod", "id": "lane-openbao-root"}, + "action": "secrets.kv.destroy", + "principal": "bernd", + } + assert claim_binding_digest(**shuffled) == digest + + +def test_claim_digest_is_not_the_flex_auth_request_digest(): + """The two digest functions are different by contract and must not be mixed.""" + from secrets_engine.authorization import request_digest + + request = { + "subject": {"id": "agt-secrets-engine"}, + "action": "secrets.kv.destroy", + "resource": {"id": "lane-openbao-root", "type": "t", "system": "s"}, + "context": {"purpose": "rotate-exposed-key"}, + } + assert claim_binding_digest(**binding_from_check_request(request)) != request_digest( + request + ) + + +def test_check_request_maps_onto_claim_binding(): + request = { + "subject": {"id": "user:alice", "attributes": {"principal": "bernd"}}, + "action": "deactivate", + "resource": {"id": "catalog:x", "type": "secret-catalog-lane"}, + "context": {"purpose": "contract-test"}, + } + mapped = binding_from_check_request(request) + assert mapped["actor"] == "user:alice" + assert mapped["principal"] == "bernd" + assert mapped["purpose"] == "contract-test" + assert mapped["target"] == request["resource"] + + +def test_principal_falls_back_to_actor_when_absent(): + request = { + "subject": {"id": "user:alice"}, + "action": "deactivate", + "resource": {"id": "catalog:x"}, + "context": {"purpose": "p"}, + } + assert binding_from_check_request(request)["principal"] == "user:alice" + + +def test_pdp_digest_is_preferred_when_the_issuer_recorded_one(): + claim = _claim() + claim["binding"]["pdp_digest"] = "sha256:" + "a" * 64 + claim["binding"]["digest"] = "sha256:" + "b" * 64 # native no longer matches + assert _validate(claim, expected_pdp_digest="sha256:" + "a" * 64) + + +@pytest.mark.parametrize( + ("mutation", "match"), + [ + (lambda c: c.update(issuer="state-hub"), "issuer is not approval-engine"), + (lambda c: c.update(kind="decision"), "kind is not approval-claim"), + (lambda c: c.update(schema_version="0.2"), "schema version"), + (lambda c: c.update(valid_now=False, reason_code="revoked"), "not valid now"), + (lambda c: c.update(consumed=True), "already consumed"), + (lambda c: c.update(reason_code="superseded"), "reason code is not ok"), + (lambda c: c.update(effect="allow"), "a claim is not a decision"), + (lambda c: c.update(approval_id="00000000-0000-0000-0000-000000000000"), + "different approval object"), + (lambda c: c["binding"].update(digest="sha256:" + "f" * 64), + "binding digest does not match"), + ], +) +def test_invalid_claims_fail_closed(mutation, match): + claim = _claim() + mutation(claim) + with pytest.raises(DecisionError, match=match): + _validate(claim) + + +def test_stale_observation_is_rejected_even_when_still_valid(): + """Stale is not the same as valid_now false; the object may still be good.""" + claim = _claim() + past = datetime.now(timezone.utc) - timedelta(seconds=1) + claim["freshness"]["not_after"] = past.strftime("%Y-%m-%dT%H:%M:%SZ") + assert claim["valid_now"] is True + with pytest.raises(DecisionError, match="stale"): + _validate(claim) + + +def test_expired_validity_window_fails_closed(): + claim = _claim() + past = datetime.now(timezone.utc) - timedelta(minutes=1) + claim["validity"]["expires_at"] = past.strftime("%Y-%m-%dT%H:%M:%SZ") + with pytest.raises(DecisionError, match="validity window has expired"): + _validate(claim) + + +def test_not_yet_valid_fails_closed(): + claim = _claim() + future = datetime.now(timezone.utc) + timedelta(minutes=5) + claim["validity"]["not_before"] = future.strftime("%Y-%m-%dT%H:%M:%SZ") + with pytest.raises(DecisionError, match="not yet valid"): + _validate(claim) + + +def test_comparison_requires_an_expected_digest(): + with pytest.raises(DecisionError, match="requires an expected digest"): + validate_approval_claim(_claim(), approval_id=APPROVAL_ID) diff --git a/tests/test_consume_binding_join.py b/tests/test_consume_binding_join.py index 600cf2e..35bf6ad 100644 --- a/tests/test_consume_binding_join.py +++ b/tests/test_consume_binding_join.py @@ -13,11 +13,14 @@ from pathlib import Path import pytest +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.authorization import build_action_request, request_digest from secrets_engine.catalog import validate_entry from secrets_engine.errors import DecisionError -from tests.test_action_authorization import _envelope from tests.test_catalog import VALID AUTH_ID = "8bfc20be-47a4-4fb0-97a2-bf0a920afad8" @@ -51,19 +54,43 @@ def _token(tmp_path): return f -def _served(**over): - """A served envelope whose validity window is live now.""" - env = copy.deepcopy(_envelope()) +def _expected_request(entry, action="deactivate", fields=("api_token",)): + return build_action_request( + entry, action, + subject_id="user:alice", subject_type="Human", purpose="contract-test", + fields=list(fields), + policy_targets=[entry.policy_name], auth_targets=[entry.role_name], + ) + + +def _served(entry=None, action="deactivate", fields=("api_token",), **over): + """An approval-engine approval-claim bound to the proposed action.""" + entry = entry or _entry() + request = _expected_request(entry, action, fields) + binding = binding_from_check_request(request) now = datetime.now(timezone.utc) - env["validity"] = { - "not_before": (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), - "expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"), + claim = { + "schema_version": "0.1", + "kind": "approval-claim", + "issuer": "approval-engine", + "approval_id": AUTH_ID, + "state": "valid", + "valid_now": True, + "consumed": False, + "binding": {**binding, "digest": claim_binding_digest(**binding)}, + "freshness": { + "observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), + "ttl_seconds": 30, + "not_after": (now + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + "validity": { + "not_before": (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"), + "expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + "reason_code": "ok", } - approved = (now - timedelta(minutes=4)).strftime("%Y-%m-%dT%H:%M:%SZ") - for approval in env["approvals"]["entries"]: - approval["approved_at"] = approved - env.update(over) - return env + claim.update(over) + return claim def _opener(envelope, status=200): @@ -99,14 +126,7 @@ def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path): binding = _resolve(cfg, entry, _served()) assert binding is not None assert binding.approval_id == AUTH_ID - expected = build_action_request( - entry, "deactivate", - subject_id="user:alice", subject_type="Human", purpose="contract-test", - fields=["api_token"], - policy_targets=[entry.policy_name], auth_targets=[entry.role_name], - request_id="check:test-lane-deactivate", - ) - assert binding.request_digest == request_digest(expected) + assert binding.request_digest == request_digest(_expected_request(entry)) def test_missing_subject_raises_instead_of_returning_none(tmp_path): @@ -116,11 +136,12 @@ def test_missing_subject_raises_instead_of_returning_none(tmp_path): _resolve(cfg, _entry(), _served()) -def test_example_policy_names_are_not_an_implicit_pin(tmp_path): +def test_policy_pin_is_not_enforced_on_the_claim_path(tmp_path): """flex-auth: the published example vocabulary is not a live pin.""" + # 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="") - with pytest.raises(DecisionError, match="policy .*pin"): - _resolve(cfg, _entry(), _served()) + assert _resolve(cfg, _entry(), _served()) is not None def test_wrong_field_set_fails_closed(tmp_path): @@ -158,6 +179,6 @@ def test_unreachable_approval_engine_fails_closed(tmp_path): ) -def test_superseded_authorization_fails_closed(tmp_path): +def test_superseded_claim_fails_closed(tmp_path): with pytest.raises(DecisionError): - _resolve(_Cfg(_token(tmp_path)), _entry(), _served(status="superseded")) + _resolve(_Cfg(_token(tmp_path)), _entry(), _served(valid_now=False, reason_code="superseded")) diff --git a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md index b1ded12..4b00e4f 100644 --- a/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md +++ b/workplans/SECRETS-WP-0007-production-lifecycle-hardening.md @@ -311,6 +311,47 @@ artifact and must not be extended until gate-house rules. pins, and `flex-auth-secrets-engine` has not been created yet, so the 2026-09-06 probe finding was the design working rather than an outage. +Resolved 2026-09-06 by gate-house `GH-DEC-2026-005` (GH-IN-0002), settling +approval-engine's `APPROVAL-IN-0002`. Both changes it assigned to this repo are +implemented. + +1. The validator is split by owning layer. `approval_claim.validate_approval_claim` + consumes approval-engine's approval-claim for the approval fact (issuer, + `valid_now`, consumption state, binding digest, freshness, `reason_code`). + `authorization.validate_decision_envelope` consumes the flex-auth + DecisionEnvelope for the decision (effect, exact CheckRequest binding, + canonical request digest, lifetime, policy package/version pin). Neither + republishes the other's data. `ActionAuthorization` is deferred and never + ratified (`FLEX-DEC-2026-006`); nothing validates it any more. +2. `AUTHORITY = "state-hub"` and the `provenance.authority` requirement are + gone. flex-auth traced the constant to their own fixture + (`examples/caring/action_authorization.json`), which contradicted their + ownership section — their bug, fixed at source. State Hub is a read model and + holds no runtime approval authority, so the check failed closed against every + correctly issued record. + +Two consequences worth stating rather than burying: + +- There are now **two different digests** over the same proposed action, by + contract, never compared to each other: the approval-engine native binding + digest over `{action, actor, principal, purpose, target}`, and the flex-auth + canonical CheckRequest digest. `claim.binding.pdp_digest` is preferred when + the issuer recorded one. The CheckRequest digest itself is unchanged and its + contract test is preserved verbatim — flex-auth confirmed only the envelope + went away, not the digest join. +- The distinct-approver threshold is **no longer a consumer-side check**. The + claim does not expose approver entries; approval-engine folds that requirement + into `valid_now`. We now rely on the issuer for it, which is the correct layer + but is a real reduction in what this engine verifies independently. + +Still outstanding, unchanged by the ruling: step 2 needs the +`flex-auth-secrets-engine` Service DNS (not created — per-consumer cluster-local +pins are the design, so the earlier "no reachable PDP" probe was not an outage) +and the published package from `FLEX-WP-0021-T02`. The pin stays unset. +`docs/gated-actions.md` delivered the twelve-action vocabulary that unblocks +`FLEX-WP-0021-T01`. Separately tracked: production requires a KeyCape RS256 JWT, +not the static Bearer token this engine currently sends. + Define and enforce the decision contract needed by production commands. A resolved approval must bind at least: