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

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