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 <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:
parent
dbd3694f71
commit
7b4b9e386e
8 changed files with 694 additions and 325 deletions
|
|
@ -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="",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue