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
2026-09-06 08:02:01 +02:00
|
|
|
"""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
|
2026-09-09 08:55:46 +02:00
|
|
|
import re
|
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
2026-09-06 08:02:01 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 08:55:46 +02:00
|
|
|
def _validate_observation(
|
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
2026-09-06 08:02:01 +02:00
|
|
|
claim: object,
|
|
|
|
|
*,
|
|
|
|
|
approval_id: str,
|
|
|
|
|
now: datetime | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
2026-09-09 08:55:46 +02:00
|
|
|
"""Validate issuer, shape, validity and freshness; no action correspondence."""
|
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
2026-09-06 08:02:01 +02:00
|
|
|
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")
|
|
|
|
|
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
|
2026-09-09 08:55:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def observe_pdp_approval_claim(claim: object, *, approval_id: str,
|
|
|
|
|
now: datetime | None = None) -> dict[str, Any]:
|
|
|
|
|
"""Validate a fresh fact for submission to the PDP, not authority to consume.
|
|
|
|
|
|
|
|
|
|
Action correspondence cannot be established until the evaluator returns its
|
|
|
|
|
enriched approval binding. This function deliberately makes no such claim.
|
|
|
|
|
"""
|
|
|
|
|
observed = _validate_observation(claim, approval_id=approval_id, now=now)
|
|
|
|
|
binding = observed["binding"]
|
|
|
|
|
if binding.get("pdp_path") is not True:
|
|
|
|
|
raise DecisionError("approval claim does not declare binding.pdp_path; request an approval bound at issue")
|
|
|
|
|
pdp = binding.get("pdp_digest")
|
|
|
|
|
if not isinstance(pdp, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", pdp):
|
|
|
|
|
raise DecisionError("approval claim records no canonical pdp_digest; no published mapping permits a native fallback")
|
|
|
|
|
return observed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_approval_claim(claim: object, *, approval_id: str,
|
|
|
|
|
expected_binding_digest: str = "", expected_pdp_digest: str = "",
|
|
|
|
|
now: datetime | None = None) -> dict[str, Any]:
|
|
|
|
|
"""Validate the fact and compare an independently supplied binding.
|
|
|
|
|
|
|
|
|
|
A PDP digest supplied here must come from the evaluated decision, never a
|
|
|
|
|
local reconstruction of the unenriched request.
|
|
|
|
|
"""
|
|
|
|
|
observed = _validate_observation(claim, approval_id=approval_id, now=now)
|
|
|
|
|
if expected_pdp_digest:
|
|
|
|
|
observe_pdp_approval_claim(observed, approval_id=approval_id, now=now)
|
|
|
|
|
if observed["binding"]["pdp_digest"] != expected_pdp_digest:
|
|
|
|
|
raise DecisionError("approval claim pdp digest does not match the request")
|
|
|
|
|
elif expected_binding_digest:
|
|
|
|
|
if observed["binding"].get("digest") != expected_binding_digest:
|
|
|
|
|
raise DecisionError("approval claim binding digest does not match the request")
|
|
|
|
|
else:
|
|
|
|
|
raise DecisionError("approval claim comparison requires an expected digest")
|
|
|
|
|
return observed
|