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

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