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