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
|
|
|
|
|
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 "")
|
fix: exclude correlation fields from the flex-auth request digest
Verified the digest join against flex-auth's T03 replay fixtures and found
request_digest was hashing fields docs/canonical-request-digest.md excludes.
The material is tenant, subject, action, resource, context only: id is
correlation, policy_version lives in provenance, caring_context is hashed
separately. This engine included all three when present.
Because the join adopts the served request id, every real production request
would have carried one, so the computed digest would have matched no issued
decision and failed closed against every correct allow. Same unsatisfiable
shape as the removed AUTHORITY constant.
The old pinned constant was computed with the id inside the material, so it
was wrong and its passing proved nothing. Replaced with fixture-driven tests
over two real envelopes (vendored with provenance) plus a structural test
that correlation fields do not move the digest. Both fixtures are needed:
input_claim_digests.context appears only with a non-empty context.
Also stops computing the native claim digest. The claim's binding.action and
binding.target speak approval-engine's vocabulary while ours speaks the
catalog's, and no mapping is published; flex-auth makes no cross-check and
states the correspondence is ours via pdp_digest. A claim recording no
pdp_digest now fails closed naming the missing mapping rather than comparing
two different languages. That mapping is a prerequisite for destroy.
274 tests pass. Production still fails closed.
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 14:17:38 +02:00
|
|
|
# Prefer the PDP digest: it is expressed in flex-auth's vocabulary, which is
|
|
|
|
|
# the one the caller actually proposed. The native digest is over
|
|
|
|
|
# approval-engine's own vocabulary and is only comparable when the caller
|
|
|
|
|
# supplies a binding built in that vocabulary (see the note in
|
|
|
|
|
# resolve_consume_binding about the missing mapping).
|
|
|
|
|
if expected_pdp_digest:
|
feat: bind the destroy gate to approval_binding_digest and pdp_path
The vocabulary mapping this path was waiting on is not coming: gate-house
rejected it in GH-DEC-2026-008, because a translation can be confidently
wrong and fails open by accepting a claim approved for a different action.
The stronger option arrived instead, and both halves are enforced here.
flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) to
fix the circularity this repo reported: a pdp_digest recorded at issue time
can never equal the request_digest of the request that carries the claim in
its hashed context, so with GH-DEC-2026-008 requiring that equality, destroy
would have failed closed forever on a check no correct record could pass.
- authorization.approval_binding_digest implements the published exclusion
rule, including Go's context,omitempty behaviour when stripping empties
the context; digest_material drops an empty context for the same reason.
- validate_decision_envelope recomputes the field rather than trusting it,
refuses a claim-bearing request whose decision records none, and compares
the claim's digest from step 1 against it -- never against request_digest,
which still covers the claim so it stays a sound replay identity.
- validate_approval_claim requires binding.pdp_path true before using
pdp_digest at all. Path intent is never inferred from a digest that
happens to be present; pre-schema-v3 approvals carry pdp_path false
regardless of any digest they hold.
Replay fixtures re-vendored from dd3ce4c. The destroy pins moved a second
and final time; approval_binding_digest did not, which is the point. The
fixture now demonstrates the property instead of asserting it: we rederive
fa07becf... from its own request through our canonical implementation,
proving we hash the same material flex-auth does rather than pinning a
constant we cannot reproduce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4tNMAYcSQmZWUE4wqP4ij
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715726@bnt-lap001
Assistant-Session: 80a42b32-cba6-4b23-8be0-68819b1a6092
2026-09-06 20:39:59 +02:00
|
|
|
# GH-DEC-2026-008 / approval-engine schema v3: pdp_path is the issuer's
|
|
|
|
|
# DECLARATION that this approval was requested against a bound
|
|
|
|
|
# CheckRequest, and it guarantees pdp_digest is non-null. Path intent is
|
|
|
|
|
# never inferred from a pdp_digest that merely happens to be present --
|
|
|
|
|
# a digest recorded for some other reason is not a declaration anybody
|
|
|
|
|
# made, and approvals issued before schema v3 carry pdp_path false
|
|
|
|
|
# regardless of any digest they hold.
|
|
|
|
|
if binding.get("pdp_path") is not True:
|
|
|
|
|
raise DecisionError(
|
|
|
|
|
"approval claim does not declare binding.pdp_path; it was not "
|
|
|
|
|
"issued against a bound CheckRequest and cannot authorize this "
|
|
|
|
|
"action (GH-DEC-2026-008). Request an approval bound at issue."
|
|
|
|
|
)
|
fix: exclude correlation fields from the flex-auth request digest
Verified the digest join against flex-auth's T03 replay fixtures and found
request_digest was hashing fields docs/canonical-request-digest.md excludes.
The material is tenant, subject, action, resource, context only: id is
correlation, policy_version lives in provenance, caring_context is hashed
separately. This engine included all three when present.
Because the join adopts the served request id, every real production request
would have carried one, so the computed digest would have matched no issued
decision and failed closed against every correct allow. Same unsatisfiable
shape as the removed AUTHORITY constant.
The old pinned constant was computed with the id inside the material, so it
was wrong and its passing proved nothing. Replaced with fixture-driven tests
over two real envelopes (vendored with provenance) plus a structural test
that correlation fields do not move the digest. Both fixtures are needed:
input_claim_digests.context appears only with a non-empty context.
Also stops computing the native claim digest. The claim's binding.action and
binding.target speak approval-engine's vocabulary while ours speaks the
catalog's, and no mapping is published; flex-auth makes no cross-check and
states the correspondence is ours via pdp_digest. A claim recording no
pdp_digest now fails closed naming the missing mapping rather than comparing
two different languages. That mapping is a prerequisite for destroy.
274 tests pass. Production still fails closed.
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 14:17:38 +02:00
|
|
|
if not pdp:
|
|
|
|
|
raise DecisionError(
|
feat: bind the destroy gate to approval_binding_digest and pdp_path
The vocabulary mapping this path was waiting on is not coming: gate-house
rejected it in GH-DEC-2026-008, because a translation can be confidently
wrong and fails open by accepting a claim approved for a different action.
The stronger option arrived instead, and both halves are enforced here.
flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) to
fix the circularity this repo reported: a pdp_digest recorded at issue time
can never equal the request_digest of the request that carries the claim in
its hashed context, so with GH-DEC-2026-008 requiring that equality, destroy
would have failed closed forever on a check no correct record could pass.
- authorization.approval_binding_digest implements the published exclusion
rule, including Go's context,omitempty behaviour when stripping empties
the context; digest_material drops an empty context for the same reason.
- validate_decision_envelope recomputes the field rather than trusting it,
refuses a claim-bearing request whose decision records none, and compares
the claim's digest from step 1 against it -- never against request_digest,
which still covers the claim so it stays a sound replay identity.
- validate_approval_claim requires binding.pdp_path true before using
pdp_digest at all. Path intent is never inferred from a digest that
happens to be present; pre-schema-v3 approvals carry pdp_path false
regardless of any digest they hold.
Replay fixtures re-vendored from dd3ce4c. The destroy pins moved a second
and final time; approval_binding_digest did not, which is the point. The
fixture now demonstrates the property instead of asserting it: we rederive
fa07becf... from its own request through our canonical implementation,
proving we hash the same material flex-auth does rather than pinning a
constant we cannot reproduce.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4tNMAYcSQmZWUE4wqP4ij
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715726@bnt-lap001
Assistant-Session: 80a42b32-cba6-4b23-8be0-68819b1a6092
2026-09-06 20:39:59 +02:00
|
|
|
"approval claim declares pdp_path but records no pdp_digest, and "
|
|
|
|
|
"no published mapping exists between approval-engine and "
|
|
|
|
|
"secrets-engine action/target vocabularies; the claim cannot be "
|
|
|
|
|
"tied to this exact action"
|
fix: exclude correlation fields from the flex-auth request digest
Verified the digest join against flex-auth's T03 replay fixtures and found
request_digest was hashing fields docs/canonical-request-digest.md excludes.
The material is tenant, subject, action, resource, context only: id is
correlation, policy_version lives in provenance, caring_context is hashed
separately. This engine included all three when present.
Because the join adopts the served request id, every real production request
would have carried one, so the computed digest would have matched no issued
decision and failed closed against every correct allow. Same unsatisfiable
shape as the removed AUTHORITY constant.
The old pinned constant was computed with the id inside the material, so it
was wrong and its passing proved nothing. Replaced with fixture-driven tests
over two real envelopes (vendored with provenance) plus a structural test
that correlation fields do not move the digest. Both fixtures are needed:
input_claim_digests.context appears only with a non-empty context.
Also stops computing the native claim digest. The claim's binding.action and
binding.target speak approval-engine's vocabulary while ours speaks the
catalog's, and no mapping is published; flex-auth makes no cross-check and
states the correspondence is ours via pdp_digest. A claim recording no
pdp_digest now fails closed naming the missing mapping rather than comparing
two different languages. That mapping is a prerequisite for destroy.
274 tests pass. Production still fails closed.
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 14:17:38 +02:00
|
|
|
)
|
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 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
|