fix: bind approval consumption to actual Flex Auth submissions
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
89bc31460f
commit
ee4e901611
23 changed files with 612 additions and 1023 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue