feat: split the validator by owning layer per GH-DEC-2026-005
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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 <noreply@anthropic.com>
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
This commit is contained in:
tegwick 2026-09-06 08:02:01 +02:00
parent dbd3694f71
commit 7b4b9e386e
8 changed files with 694 additions and 325 deletions

View file

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