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

@ -0,0 +1,172 @@
"""approval-engine approval-claim consumer (GH-DEC-2026-005, step 1).
The claim is a *fact about an approval object*, never a decision. It carries no
`effect`, `allow`, or `deny`, and holding one with ``valid_now: true`` is not
authority to act -- it is one input the decision point weighs.
Contract: ``approval-engine/docs/approval-claim.md`` (schema 0.1). The native
binding digest here is NOT the flex-auth CheckRequest digest: it is taken over
``{action, actor, principal, purpose, target}``. The two are deliberately
different functions and must not be compared to each other.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
from secrets_engine.errors import DecisionError
SCHEMA_VERSION = "0.1"
KIND = "approval-claim"
ISSUER = "approval-engine"
VALID_REASON = "ok"
def claim_binding_digest(
*,
action: str,
actor: str,
principal: str,
purpose: str,
target: dict[str, Any],
) -> str:
"""sha256 over canonical JSON of the approval-engine binding.
Sorted keys at every level, no insignificant whitespace. Wrong action,
target, or scope changes this JSON and therefore the digest, which is what
stops a decision rendered for request R being replayed for request R'.
"""
canonical = json.dumps(
{
"action": action,
"actor": actor,
"principal": principal,
"purpose": purpose,
"target": target,
},
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def binding_from_check_request(request: dict[str, Any]) -> dict[str, Any]:
"""Map a flex-auth CheckRequest onto the claim binding fields.
Mapping is published in ``approval-claim.md``: target is the resource
object, actor is ``subject.id``, principal is ``subject.attributes.principal``
when present and ``subject.id`` otherwise, purpose is ``context.purpose``.
"""
subject = request.get("subject") or {}
if not isinstance(subject, dict):
raise DecisionError("check request subject must be an object")
attributes = subject.get("attributes") or {}
principal = ""
if isinstance(attributes, dict):
principal = str(attributes.get("principal", "") or "")
actor = str(subject.get("id", "") or "")
context = request.get("context") or {}
purpose = ""
if isinstance(context, dict):
purpose = str(context.get("purpose", "") or "")
resource = request.get("resource")
if not isinstance(resource, dict):
raise DecisionError("check request resource must be an object")
return {
"action": str(request.get("action", "") or ""),
"actor": actor,
"principal": principal or actor,
"purpose": purpose,
"target": resource,
}
def _parse_time(value: object, name: str) -> datetime:
if not isinstance(value, str) or not value:
raise DecisionError(f"approval claim {name} must be a timestamp")
text = value.strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(text)
except ValueError as e:
raise DecisionError(f"approval claim {name} is not a valid timestamp") from e
if parsed.tzinfo is None:
raise DecisionError(f"approval claim {name} must carry a timezone")
return parsed.astimezone(timezone.utc)
def validate_approval_claim(
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.
"""
if not isinstance(claim, dict):
raise DecisionError("approval claim must be an object")
if claim.get("schema_version") != SCHEMA_VERSION:
raise DecisionError("unsupported approval claim schema version")
if claim.get("kind") != KIND:
raise DecisionError("approval claim kind is not approval-claim")
if claim.get("issuer") != ISSUER:
raise DecisionError("approval claim issuer is not approval-engine")
for forbidden in ("effect", "decision", "allow", "deny"):
if forbidden in claim:
raise DecisionError(
f"approval claim carries '{forbidden}'; a claim is not a decision"
)
served_id = str(claim.get("approval_id", "") or "")
if not served_id or served_id != approval_id:
raise DecisionError("approval claim is for a different approval object")
if claim.get("valid_now") is not True:
raise DecisionError(
"approval claim is not valid now "
f"(reason_code={claim.get('reason_code', 'unknown')})"
)
if claim.get("consumed") is not False:
raise DecisionError("approval claim is already consumed")
if claim.get("reason_code") != VALID_REASON:
raise DecisionError("approval claim reason code is not ok")
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 when the issuer recorded one at issue time.
if expected_pdp_digest and pdp:
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):
raise DecisionError("approval claim freshness must be an object")
if _parse_time(freshness.get("not_after"), "freshness.not_after") <= current:
raise DecisionError("approval claim observation is stale; re-fetch")
validity = claim.get("validity")
if not isinstance(validity, dict):
raise DecisionError("approval claim validity must be an object")
if _parse_time(validity.get("expires_at"), "validity.expires_at") <= current:
raise DecisionError("approval claim validity window has expired")
if validity.get("not_before") is not None:
if _parse_time(validity.get("not_before"), "validity.not_before") > current:
raise DecisionError("approval claim is not yet valid")
return claim

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

View file

@ -17,12 +17,11 @@ from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import DecisionError
SCHEMA_VERSION = "0.1"
AUTHORITY = "state-hub"
CONTRACT_VERSION = "flex-auth.decision-record.v1"
@dataclass(frozen=True)
class ValidatedActionAuthorization:
authorization_id: str
class ValidatedDecision:
decision_id: str
action: str
subject_id: str
@ -182,91 +181,45 @@ def _require_exact_target_sets(request: dict[str, Any]) -> None:
)
def validate_action_authorization(
def validate_decision_envelope(
envelope: object,
expected_request: object,
*,
accepted_policy_packages: set[str],
accepted_policy_versions: set[str],
minimum_approval_count: int = 1,
now: datetime | None = None,
) -> ValidatedActionAuthorization:
"""Validate exact request binding and dual control; never parse prose."""
if minimum_approval_count < 1:
raise DecisionError("minimum approval count must be positive")
) -> ValidatedDecision:
"""Validate a flex-auth DecisionEnvelope against the proposed action.
This is step 2 of GH-DEC-2026-003. It owns exactly the decision-layer
checks: effect, exact CheckRequest binding, canonical request digest,
lifetime, and the policy package/version pin. The approval fact -- validity,
supersession, consumption, distinct approvers -- belongs to the
approval-engine claim and is NOT re-checked here (GH-DEC-2026-005: a PIP
must not republish the PDP's decision, and neither layer republishes the
other's data).
There is deliberately no authority constant. State Hub is a read model and
holds no runtime approval authority; requiring it fails closed against every
correctly issued record.
"""
if not accepted_policy_packages or not accepted_policy_versions:
raise DecisionError("accepted flex-auth policy package/version is required")
if not isinstance(envelope, dict):
raise DecisionError("action authorization must be an object")
if envelope.get("schema_version") != SCHEMA_VERSION:
raise DecisionError("unsupported action authorization schema version")
authorization_id = _required_text(envelope, "id")
try:
parsed_authorization_id = uuid.UUID(authorization_id)
except ValueError as e:
raise DecisionError("action authorization id must be a canonical UUID") from e
if str(parsed_authorization_id) != authorization_id:
raise DecisionError("action authorization id must be a canonical UUID")
if envelope.get("status") != "approved":
raise DecisionError("action authorization status is not approved")
if envelope.get("superseded_by"):
raise DecisionError("action authorization is superseded")
provenance = _required_dict(envelope, "provenance")
if provenance.get("authority") != AUTHORITY:
raise DecisionError("action authorization authority is not State Hub")
request = canonical_check_request(envelope.get("request"))
expected = canonical_check_request(expected_request)
_require_exact_target_sets(request)
_require_exact_target_sets(expected)
if request != expected:
raise DecisionError("action authorization request does not exactly match action")
validity = _required_dict(envelope, "validity")
expires = _parse_time(validity.get("expires_at"), "expires_at")
not_before = (
_parse_time(validity.get("not_before"), "not_before")
if validity.get("not_before") is not None
else None
)
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
if not_before is not None and current < not_before:
raise DecisionError("action authorization window has not started")
if current >= expires:
raise DecisionError("action authorization has expired")
approvals = _required_dict(envelope, "approvals")
required_count = approvals.get("required_count")
entries = approvals.get("entries")
if not isinstance(required_count, int) or required_count < 1:
raise DecisionError("action authorization approval count is invalid")
if required_count < minimum_approval_count:
raise DecisionError("action authorization approval threshold is insufficient")
if not isinstance(entries, list):
raise DecisionError("action authorization approval entries are invalid")
approvers: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
raise DecisionError("action authorization approval entry is invalid")
subject_id = _required_text(entry, "subject_id")
approved_at = _parse_time(entry.get("approved_at"), "approved_at")
if approved_at > current or approved_at >= expires:
raise DecisionError("action authorization approval time is outside window")
if not_before is not None and approved_at < not_before:
raise DecisionError("action authorization approval time is outside window")
if subject_id in approvers:
raise DecisionError("action authorization contains duplicate approver")
approvers.add(subject_id)
if len(approvers) < required_count:
raise DecisionError("action authorization has insufficient distinct approvals")
decision = _required_dict(envelope, "decision")
if decision.get("effect") != "allow":
raise DecisionError("decision envelope must be an object")
contract = envelope.get("contract_version")
if contract is not None and contract != CONTRACT_VERSION:
raise DecisionError("unsupported decision envelope contract version")
if envelope.get("effect") != "allow":
raise DecisionError("flex-auth decision effect is not allow")
decision_id = _required_text(decision, "id")
if request.get("id") and decision.get("request_id") != request["id"]:
decision_id = _required_text(envelope, "id")
expected = canonical_check_request(expected_request)
_require_exact_target_sets(expected)
if expected.get("id") and envelope.get("request_id") not in (None, expected["id"]):
raise DecisionError("flex-auth decision request id does not match request")
binding = _required_dict(decision, "binding")
binding = _required_dict(envelope, "binding")
bound_request: dict[str, Any] = {}
if binding.get("tenant"):
bound_request["tenant"] = binding["tenant"]
@ -279,36 +232,43 @@ def validate_action_authorization(
}
)
expected_bound: dict[str, Any] = {}
if request.get("tenant"):
expected_bound["tenant"] = request["tenant"]
if expected.get("tenant"):
expected_bound["tenant"] = expected["tenant"]
expected_bound.update(
{
"subject": request["subject"],
"action": request["action"],
"resource": request["resource"],
"context": request.get("context", {}),
"subject": expected["subject"],
"action": expected["action"],
"resource": expected["resource"],
"context": expected.get("context", {}),
}
)
if canonical_check_request(bound_request) != canonical_check_request(
expected_bound
):
if canonical_check_request(bound_request) != canonical_check_request(expected_bound):
raise DecisionError("flex-auth decision binding does not match request")
if binding.get("request_digest") != request_digest(request):
if binding.get("request_digest") != request_digest(expected):
raise DecisionError("flex-auth request digest does not match request")
if _subject_ref(decision.get("subject")) != request["subject"]:
if _subject_ref(envelope.get("subject")) != expected["subject"]:
raise DecisionError("flex-auth decision subject does not match request")
if _resource_ref(decision.get("resource")) != request["resource"]:
if _resource_ref(envelope.get("resource")) != expected["resource"]:
raise DecisionError("flex-auth decision resource does not match request")
decision_provenance = _required_dict(decision, "provenance")
if decision_provenance.get("policy_package") not in accepted_policy_packages:
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
lifetime = _required_dict(envelope, "lifetime")
expires = _parse_time(lifetime.get("expires_at"), "expires_at")
if current >= expires:
raise DecisionError("flex-auth decision lifetime has expired")
if lifetime.get("not_before") is not None:
if current < _parse_time(lifetime.get("not_before"), "not_before"):
raise DecisionError("flex-auth decision lifetime has not started")
provenance = _required_dict(envelope, "provenance")
if provenance.get("policy_package") not in accepted_policy_packages:
raise DecisionError("flex-auth policy package is not accepted")
if decision_provenance.get("policy_version") not in accepted_policy_versions:
if provenance.get("policy_version") not in accepted_policy_versions:
raise DecisionError("flex-auth policy version is not accepted")
return ValidatedActionAuthorization(
authorization_id=authorization_id,
return ValidatedDecision(
decision_id=decision_id,
action=request["action"],
subject_id=request["subject"]["id"],
action=expected["action"],
subject_id=expected["subject"]["id"],
expires_at=expires.isoformat(),
)