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

@ -19,19 +19,43 @@ does not redefine it.
4. PEP OpenBao call → only after consume succeeds
```
Step 1 is `resolve_consume_binding` (`approval_consume.py`): it reproduces the
exact CheckRequest with `build_action_request`, fetches the durable
`ActionAuthorization` from `GET /v1/approvals/{id}/claim`, and validates it with
`validate_action_authorization` before returning a consume binding. Until
2026-09-06 that function was a `return None` stub and the validator was
unreachable from `src/`; the join now exists.
**Each artifact is validated by the layer that owns its data**
(`GH-DEC-2026-005`). There is no single bundled object:
| Step | Artifact | Owner | Validated by |
| --- | --- | --- | --- |
| 1 | approval-claim | approval-engine | `approval_claim.validate_approval_claim` |
| 2 | DecisionEnvelope | flex-auth | `authorization.validate_decision_envelope` |
The claim carries the *approval fact*: issuer, `valid_now`, consumption state,
binding digest, freshness, `reason_code`. The envelope carries the *decision*:
effect, exact CheckRequest binding, canonical request digest, lifetime, and the
policy package/version pin. Neither republishes the other's data.
`ActionAuthorization` is **deferred and was never ratified**
(`FLEX-DEC-2026-006`). It cannot be served from a step-1 call, and nothing here
validates it. A ratified post-decision form remains a deferred option.
There are **two different digests** over the same proposed action, and they are
never compared to each other:
- `claim.binding.digest` — approval-engine native, `sha256` over canonical JSON
of `{action, actor, principal, purpose, target}`.
- `decision.binding.request_digest` — flex-auth canonical CheckRequest digest.
When the issuer recorded `claim.binding.pdp_digest`, that comparison is
preferred. The CheckRequest mapping onto the claim binding is published in
`approval-engine/docs/approval-claim.md`.
The approval-engine object id is never inferred from a State Hub decision UUID.
It comes from catalog `approval.authorization_id` or a served
`decision.authorization_id`. The Check request `id` is an opaque correlator the
PEP cannot regenerate, so the served one is adopted; every security-relevant
field is still compared exactly and the binding digest is recomputed against the
served request.
It comes from catalog `approval.authorization_id`, and is the value in the
`{id}` path segment echoed back as `approval_id`.
**There is no authority constant.** The engine previously required
`provenance.authority == "state-hub"`, which contradicted its own source: State
Hub is a read model and holds no runtime approval authority, so that check
failed closed against every correctly issued record. The approval fact's
authority is approval-engine (checked as `issuer`); the decision's is flex-auth.
Every live privileged production handler passes `_require_lane_approval`,
which calls `require_production_consume` before `OpenBaoClient.resolve`.
@ -67,13 +91,23 @@ did before. Production additionally needs:
| `SECRETS_ENGINE_APPROVAL_URL` | approval-engine base URL (claim + consume) |
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | mode-0600 credential, outside Git |
| `SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal |
| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin |
| `SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS` | distinct-approver threshold |
| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin (step 2) |
There is deliberately no default policy pin. flex-auth stated that the published
`secrets-engine.lifecycle` / `v1` names are example vocabulary on the envelope,
not a live package, so treating them as a default would pin production to a
package nobody publishes.
The distinct-approver threshold is no longer a consumer-side check. The claim
does not expose approver entries; approval-engine folds that requirement into
`valid_now`, which is true only when enough distinct authenticated approvers
have been recorded and the object is not consumed, superseded, revoked, or
expired.
There is deliberately no default policy pin. The reserved coordinate is
`secrets-engine.catalog-lane.lifecycle` / `v1`, but that is a *reservation, not
a publication* (`FLEX-DEC-2026-005`) and must not be configured until
`FLEX-WP-0021-T02` publishes the package. `docs/gated-actions.md` supplies the
action vocabulary that package is built from.
Step 2 additionally needs the `flex-auth-secrets-engine` Service DNS. No
estate-wide PDP exists by design — flex-auth runs per-consumer cluster-local
pins — and that pin has not been created, so the PDP call is not wired yet.
## What this does not do

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(),
)

View file

@ -1,21 +1,26 @@
"""flex-auth DecisionEnvelope consumer (step 2 of GH-DEC-2026-003).
The ActionAuthorization envelope these tests used to cover is deferred and was
never ratified (FLEX-DEC-2026-006); the approval fact moved to
tests/test_approval_claim.py. The canonical request digest is unchanged and its
contract test below is preserved verbatim -- flex-auth confirmed only the
envelope went away, not the digest join.
"""
import copy
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import pytest
from secrets_engine.authorization import (
build_action_request,
request_digest,
validate_action_authorization,
validate_decision_envelope,
)
from secrets_engine.catalog import validate_entry
from secrets_engine.errors import DecisionError
from tests.test_catalog import VALID
NOW = datetime(2026, 8, 23, 10, 5, tzinfo=timezone.utc)
def _request():
entry = validate_entry(copy.deepcopy(VALID))
return build_action_request(
@ -31,62 +36,44 @@ def _request():
)
def _envelope():
request = _request()
def _envelope(request=None):
"""A flex-auth DecisionEnvelope shaped by schemas/decision_envelope.schema.json."""
request = request or _request()
now = datetime.now(timezone.utc)
return {
"schema_version": "0.1",
"id": "8bfc20be-47a4-4fb0-97a2-bf0a920afad8",
"status": "approved",
"request": request,
"validity": {
"not_before": "2026-08-23T10:00:00Z",
"expires_at": "2026-08-23T10:15:00Z",
},
"approvals": {
"required_count": 2,
"entries": [
{
"subject_id": "user:alice",
"approved_at": "2026-08-23T10:01:00Z",
},
{
"subject_id": "user:bob",
"approved_at": "2026-08-23T10:02:00Z",
},
],
},
"decision": {
"id": "decision:test-lane-deactivate",
"request_id": request["id"],
"effect": "allow",
"resource": copy.deepcopy(request["resource"]),
"id": "decision:test-lane-deactivate",
"contract_version": "flex-auth.decision-record.v1",
"request_id": request["id"],
"effect": "allow",
"subject": copy.deepcopy(request["subject"]),
"resource": copy.deepcopy(request["resource"]),
"binding": {
"subject": copy.deepcopy(request["subject"]),
"binding": {
"subject": copy.deepcopy(request["subject"]),
"action": request["action"],
"resource": copy.deepcopy(request["resource"]),
"context": copy.deepcopy(request["context"]),
"request_digest": request_digest(request),
},
"provenance": {
"evaluator": "flex-auth/local",
"mode": "standalone",
"policy_package": "secrets-engine.lifecycle",
"policy_version": "v1",
},
"action": request["action"],
"resource": copy.deepcopy(request["resource"]),
"context": copy.deepcopy(request["context"]),
"request_digest": request_digest(request),
},
"lifetime": {
"kind": "bounded",
"not_before": (now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
},
"provenance": {
"evaluator": "flex-auth/secrets-engine",
"mode": "cluster-local",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_version": "v1",
},
"provenance": {"authority": "state-hub"},
}
def _validate(envelope, expected=None):
return validate_action_authorization(
return validate_decision_envelope(
envelope,
expected or _request(),
accepted_policy_packages={"secrets-engine.lifecycle"},
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v1"},
minimum_approval_count=2,
now=NOW,
)
@ -115,111 +102,86 @@ def test_digest_matches_flex_auth_contract_example():
"sha256:73d5d7d5b3363f1a1db8f4c0e79c8f33dae5d77ffb97f21e449438bc0defa4c3"
)
def test_valid_exact_action_authorization_passes():
def test_valid_allow_envelope_passes():
result = _validate(_envelope())
assert result.authorization_id == "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
assert result.decision_id == "decision:test-lane-deactivate"
assert result.action == "deactivate"
assert result.subject_id == "user:alice"
def test_state_hub_authority_is_no_longer_required():
"""GH-DEC-2026-005: State Hub holds no runtime approval authority.
A correctly issued record naming any other authority (or none) must pass;
the old AUTHORITY constant failed closed against every real record.
"""
env = _envelope()
env["provenance"]["authority"] = "approval-engine"
assert _validate(env).action == "deactivate"
env["provenance"].pop("authority")
assert _validate(env).action == "deactivate"
@pytest.mark.parametrize(
("mutation", "match"),
[
(lambda doc: doc.update(status="superseded"), "status is not approved"),
(
lambda doc: doc["request"]["resource"].update(id="catalog:wrong"),
"does not exactly match",
),
(
lambda doc: doc["request"].update(action="destroy"),
"does not exactly match",
),
(
lambda doc: doc["request"]["resource"]["attributes"].update(
fields=["other"]
),
"does not exactly match",
),
(
lambda doc: doc["request"]["context"].update(purpose="wrong"),
"does not exactly match",
),
(
lambda doc: doc["decision"].update(effect="deny"),
"effect is not allow",
),
(
lambda doc: doc["decision"]["binding"].update(
request_digest="sha256:" + "0" * 64
),
"digest does not match",
),
(
lambda doc: doc["approvals"]["entries"][1].update(
subject_id="user:alice"
),
"duplicate approver",
),
(
lambda doc: doc["approvals"].update(required_count=1),
"threshold is insufficient",
),
(
lambda doc: doc["decision"].update(request_id="check:wrong"),
"request id does not match",
),
(
lambda doc: doc["provenance"].update(authority="local-fixture"),
"authority is not State Hub",
),
(lambda d: d.update(effect="deny"), "effect is not allow"),
(lambda d: d.update(effect="audit_only"), "effect is not allow"),
(lambda d: d["binding"].update(action="destroy"), "binding does not match"),
(lambda d: d["binding"].update(request_digest="sha256:" + "0" * 64),
"request digest does not match"),
(lambda d: d["provenance"].update(policy_package="other.package"),
"policy package is not accepted"),
(lambda d: d["provenance"].update(policy_version="v2"),
"policy version is not accepted"),
(lambda d: d.update(contract_version="flex-auth.decision-record.v2"),
"contract version"),
(lambda d: d["subject"].update(id="user:mallory"), "subject does not match"),
],
)
def test_invalid_authorizations_fail_closed(mutation, match):
envelope = _envelope()
mutation(envelope)
def test_invalid_envelopes_fail_closed(mutation, match):
env = _envelope()
mutation(env)
with pytest.raises(DecisionError, match=match):
_validate(envelope)
_validate(env)
def test_expired_authorization_fails_closed():
with pytest.raises(DecisionError, match="expired"):
validate_action_authorization(
_envelope(),
_request(),
accepted_policy_packages={"secrets-engine.lifecycle"},
accepted_policy_versions={"v1"},
now=datetime(2026, 8, 23, 10, 15, tzinfo=timezone.utc),
def test_expired_lifetime_fails_closed():
env = _envelope()
past = datetime.now(timezone.utc) - timedelta(minutes=1)
env["lifetime"]["expires_at"] = past.strftime("%Y-%m-%dT%H:%M:%SZ")
with pytest.raises(DecisionError, match="lifetime has expired"):
_validate(env)
def test_lifetime_not_yet_started_fails_closed():
env = _envelope()
future = datetime.now(timezone.utc) + timedelta(minutes=5)
env["lifetime"]["not_before"] = future.strftime("%Y-%m-%dT%H:%M:%SZ")
with pytest.raises(DecisionError, match="has not started"):
_validate(env)
def test_unaccepted_policy_pin_is_required():
with pytest.raises(DecisionError, match="package/version is required"):
validate_decision_envelope(
_envelope(), _request(),
accepted_policy_packages=set(), accepted_policy_versions={"v1"},
)
def test_noncanonical_authorization_uuid_is_rejected():
envelope = _envelope()
envelope["id"] = envelope["id"].replace("-", "")
with pytest.raises(DecisionError, match="canonical UUID"):
_validate(envelope)
def test_approval_timestamp_must_be_inside_current_authorization_window():
envelope = _envelope()
envelope["approvals"]["entries"][1]["approved_at"] = "2026-08-23T10:06:00Z"
with pytest.raises(DecisionError, match="approval time is outside window"):
_validate(envelope)
def test_unsorted_or_duplicate_target_sets_are_rejected():
envelope = _envelope()
envelope["request"]["resource"]["attributes"]["fields"] = [
"second",
"first",
"first",
]
request = _request()
request["resource"]["attributes"]["policy_targets"] = ["b", "a"]
with pytest.raises(DecisionError, match="sorted and unique"):
_validate(envelope, expected=envelope["request"])
_validate(_envelope(), request)
def test_unaccepted_policy_revision_is_rejected():
envelope = _envelope()
envelope["decision"]["provenance"]["policy_version"] = "v2"
with pytest.raises(DecisionError, match="policy version is not accepted"):
_validate(envelope)
def test_approval_fact_is_not_rechecked_here():
"""GH-DEC-2026-005: a PIP must not republish the PDP's decision, and the
decision layer must not restate the approval fact. Consumption, supersession
and approver counts belong to the claim; adding them here would fail closed
against a valid envelope that simply does not carry them."""
env = _envelope()
assert "approvals" not in env and "status" not in env
assert _validate(env).action == "deactivate"

View file

@ -0,0 +1,180 @@
"""approval-engine approval-claim consumer (step 1 of GH-DEC-2026-003).
Covers the published "Required verification" list in
approval-engine/docs/approval-claim.md. The claim is a fact, never a decision.
"""
import copy
from datetime import datetime, timedelta, timezone
import pytest
from secrets_engine.approval_claim import (
binding_from_check_request,
claim_binding_digest,
validate_approval_claim,
)
from secrets_engine.errors import DecisionError
APPROVAL_ID = "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f"
def _binding():
return {
"action": "secrets.kv.destroy",
"target": {"id": "lane-openbao-root", "stage": "prod"},
"actor": "agt-secrets-engine",
"principal": "bernd",
"purpose": "rotate-exposed-key",
}
def _claim(**over):
now = datetime.now(timezone.utc)
binding = dict(_binding())
binding["digest"] = claim_binding_digest(**_binding())
claim = {
"schema_version": "0.1",
"kind": "approval-claim",
"issuer": "approval-engine",
"approval_id": APPROVAL_ID,
"state": "valid",
"valid_now": True,
"consumed": False,
"binding": binding,
"freshness": {
"observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"ttl_seconds": 30,
"not_after": (now + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ"),
},
"validity": {
"not_before": (now - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"expires_at": (now + timedelta(hours=3)).strftime("%Y-%m-%dT%H:%M:%SZ"),
},
"reason_code": "ok",
}
claim.update(over)
return claim
def _validate(claim, **kw):
kw.setdefault("approval_id", APPROVAL_ID)
kw.setdefault("expected_binding_digest", claim_binding_digest(**_binding()))
return validate_approval_claim(claim, **kw)
def test_valid_claim_passes():
assert _validate(_claim())["approval_id"] == APPROVAL_ID
def test_binding_digest_is_canonical_sorted_json():
"""Sorted keys at every level, no insignificant whitespace."""
digest = claim_binding_digest(**_binding())
assert digest.startswith("sha256:") and len(digest) == 71
shuffled = {
"purpose": "rotate-exposed-key",
"actor": "agt-secrets-engine",
"target": {"stage": "prod", "id": "lane-openbao-root"},
"action": "secrets.kv.destroy",
"principal": "bernd",
}
assert claim_binding_digest(**shuffled) == digest
def test_claim_digest_is_not_the_flex_auth_request_digest():
"""The two digest functions are different by contract and must not be mixed."""
from secrets_engine.authorization import request_digest
request = {
"subject": {"id": "agt-secrets-engine"},
"action": "secrets.kv.destroy",
"resource": {"id": "lane-openbao-root", "type": "t", "system": "s"},
"context": {"purpose": "rotate-exposed-key"},
}
assert claim_binding_digest(**binding_from_check_request(request)) != request_digest(
request
)
def test_check_request_maps_onto_claim_binding():
request = {
"subject": {"id": "user:alice", "attributes": {"principal": "bernd"}},
"action": "deactivate",
"resource": {"id": "catalog:x", "type": "secret-catalog-lane"},
"context": {"purpose": "contract-test"},
}
mapped = binding_from_check_request(request)
assert mapped["actor"] == "user:alice"
assert mapped["principal"] == "bernd"
assert mapped["purpose"] == "contract-test"
assert mapped["target"] == request["resource"]
def test_principal_falls_back_to_actor_when_absent():
request = {
"subject": {"id": "user:alice"},
"action": "deactivate",
"resource": {"id": "catalog:x"},
"context": {"purpose": "p"},
}
assert binding_from_check_request(request)["principal"] == "user:alice"
def test_pdp_digest_is_preferred_when_the_issuer_recorded_one():
claim = _claim()
claim["binding"]["pdp_digest"] = "sha256:" + "a" * 64
claim["binding"]["digest"] = "sha256:" + "b" * 64 # native no longer matches
assert _validate(claim, expected_pdp_digest="sha256:" + "a" * 64)
@pytest.mark.parametrize(
("mutation", "match"),
[
(lambda c: c.update(issuer="state-hub"), "issuer is not approval-engine"),
(lambda c: c.update(kind="decision"), "kind is not approval-claim"),
(lambda c: c.update(schema_version="0.2"), "schema version"),
(lambda c: c.update(valid_now=False, reason_code="revoked"), "not valid now"),
(lambda c: c.update(consumed=True), "already consumed"),
(lambda c: c.update(reason_code="superseded"), "reason code is not ok"),
(lambda c: c.update(effect="allow"), "a claim is not a decision"),
(lambda c: c.update(approval_id="00000000-0000-0000-0000-000000000000"),
"different approval object"),
(lambda c: c["binding"].update(digest="sha256:" + "f" * 64),
"binding digest does not match"),
],
)
def test_invalid_claims_fail_closed(mutation, match):
claim = _claim()
mutation(claim)
with pytest.raises(DecisionError, match=match):
_validate(claim)
def test_stale_observation_is_rejected_even_when_still_valid():
"""Stale is not the same as valid_now false; the object may still be good."""
claim = _claim()
past = datetime.now(timezone.utc) - timedelta(seconds=1)
claim["freshness"]["not_after"] = past.strftime("%Y-%m-%dT%H:%M:%SZ")
assert claim["valid_now"] is True
with pytest.raises(DecisionError, match="stale"):
_validate(claim)
def test_expired_validity_window_fails_closed():
claim = _claim()
past = datetime.now(timezone.utc) - timedelta(minutes=1)
claim["validity"]["expires_at"] = past.strftime("%Y-%m-%dT%H:%M:%SZ")
with pytest.raises(DecisionError, match="validity window has expired"):
_validate(claim)
def test_not_yet_valid_fails_closed():
claim = _claim()
future = datetime.now(timezone.utc) + timedelta(minutes=5)
claim["validity"]["not_before"] = future.strftime("%Y-%m-%dT%H:%M:%SZ")
with pytest.raises(DecisionError, match="not yet valid"):
_validate(claim)
def test_comparison_requires_an_expected_digest():
with pytest.raises(DecisionError, match="requires an expected digest"):
validate_approval_claim(_claim(), approval_id=APPROVAL_ID)

View file

@ -13,11 +13,14 @@ from pathlib import Path
import pytest
from secrets_engine.approval_claim import (
binding_from_check_request,
claim_binding_digest,
)
from secrets_engine.approval_consume import resolve_consume_binding
from secrets_engine.authorization import build_action_request, request_digest
from secrets_engine.catalog import validate_entry
from secrets_engine.errors import DecisionError
from tests.test_action_authorization import _envelope
from tests.test_catalog import VALID
AUTH_ID = "8bfc20be-47a4-4fb0-97a2-bf0a920afad8"
@ -51,19 +54,43 @@ def _token(tmp_path):
return f
def _served(**over):
"""A served envelope whose validity window is live now."""
env = copy.deepcopy(_envelope())
def _expected_request(entry, action="deactivate", fields=("api_token",)):
return build_action_request(
entry, action,
subject_id="user:alice", subject_type="Human", purpose="contract-test",
fields=list(fields),
policy_targets=[entry.policy_name], auth_targets=[entry.role_name],
)
def _served(entry=None, action="deactivate", fields=("api_token",), **over):
"""An approval-engine approval-claim bound to the proposed action."""
entry = entry or _entry()
request = _expected_request(entry, action, fields)
binding = binding_from_check_request(request)
now = datetime.now(timezone.utc)
env["validity"] = {
"not_before": (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
claim = {
"schema_version": "0.1",
"kind": "approval-claim",
"issuer": "approval-engine",
"approval_id": AUTH_ID,
"state": "valid",
"valid_now": True,
"consumed": False,
"binding": {**binding, "digest": claim_binding_digest(**binding)},
"freshness": {
"observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"ttl_seconds": 30,
"not_after": (now + timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ"),
},
"validity": {
"not_before": (now - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
},
"reason_code": "ok",
}
approved = (now - timedelta(minutes=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
for approval in env["approvals"]["entries"]:
approval["approved_at"] = approved
env.update(over)
return env
claim.update(over)
return claim
def _opener(envelope, status=200):
@ -99,14 +126,7 @@ def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path):
binding = _resolve(cfg, entry, _served())
assert binding is not None
assert binding.approval_id == AUTH_ID
expected = build_action_request(
entry, "deactivate",
subject_id="user:alice", subject_type="Human", purpose="contract-test",
fields=["api_token"],
policy_targets=[entry.policy_name], auth_targets=[entry.role_name],
request_id="check:test-lane-deactivate",
)
assert binding.request_digest == request_digest(expected)
assert binding.request_digest == request_digest(_expected_request(entry))
def test_missing_subject_raises_instead_of_returning_none(tmp_path):
@ -116,11 +136,12 @@ def test_missing_subject_raises_instead_of_returning_none(tmp_path):
_resolve(cfg, _entry(), _served())
def test_example_policy_names_are_not_an_implicit_pin(tmp_path):
def test_policy_pin_is_not_enforced_on_the_claim_path(tmp_path):
"""flex-auth: the published example vocabulary is not a live pin."""
# The pin is a step-2 (DecisionEnvelope) concern after GH-DEC-2026-005 and
# is asserted in tests/test_action_authorization.py, not on the claim path.
cfg = _Cfg(_token(tmp_path), authorization_policy_package="")
with pytest.raises(DecisionError, match="policy .*pin"):
_resolve(cfg, _entry(), _served())
assert _resolve(cfg, _entry(), _served()) is not None
def test_wrong_field_set_fails_closed(tmp_path):
@ -158,6 +179,6 @@ def test_unreachable_approval_engine_fails_closed(tmp_path):
)
def test_superseded_authorization_fails_closed(tmp_path):
def test_superseded_claim_fails_closed(tmp_path):
with pytest.raises(DecisionError):
_resolve(_Cfg(_token(tmp_path)), _entry(), _served(status="superseded"))
_resolve(_Cfg(_token(tmp_path)), _entry(), _served(valid_now=False, reason_code="superseded"))

View file

@ -311,6 +311,47 @@ artifact and must not be extended until gate-house rules.
pins, and `flex-auth-secrets-engine` has not been created yet, so the
2026-09-06 probe finding was the design working rather than an outage.
Resolved 2026-09-06 by gate-house `GH-DEC-2026-005` (GH-IN-0002), settling
approval-engine's `APPROVAL-IN-0002`. Both changes it assigned to this repo are
implemented.
1. The validator is split by owning layer. `approval_claim.validate_approval_claim`
consumes approval-engine's approval-claim for the approval fact (issuer,
`valid_now`, consumption state, binding digest, freshness, `reason_code`).
`authorization.validate_decision_envelope` consumes the flex-auth
DecisionEnvelope for the decision (effect, exact CheckRequest binding,
canonical request digest, lifetime, policy package/version pin). Neither
republishes the other's data. `ActionAuthorization` is deferred and never
ratified (`FLEX-DEC-2026-006`); nothing validates it any more.
2. `AUTHORITY = "state-hub"` and the `provenance.authority` requirement are
gone. flex-auth traced the constant to their own fixture
(`examples/caring/action_authorization.json`), which contradicted their
ownership section — their bug, fixed at source. State Hub is a read model and
holds no runtime approval authority, so the check failed closed against every
correctly issued record.
Two consequences worth stating rather than burying:
- There are now **two different digests** over the same proposed action, by
contract, never compared to each other: the approval-engine native binding
digest over `{action, actor, principal, purpose, target}`, and the flex-auth
canonical CheckRequest digest. `claim.binding.pdp_digest` is preferred when
the issuer recorded one. The CheckRequest digest itself is unchanged and its
contract test is preserved verbatim — flex-auth confirmed only the envelope
went away, not the digest join.
- The distinct-approver threshold is **no longer a consumer-side check**. The
claim does not expose approver entries; approval-engine folds that requirement
into `valid_now`. We now rely on the issuer for it, which is the correct layer
but is a real reduction in what this engine verifies independently.
Still outstanding, unchanged by the ruling: step 2 needs the
`flex-auth-secrets-engine` Service DNS (not created — per-consumer cluster-local
pins are the design, so the earlier "no reachable PDP" probe was not an outage)
and the published package from `FLEX-WP-0021-T02`. The pin stays unset.
`docs/gated-actions.md` delivered the twelve-action vocabulary that unblocks
`FLEX-WP-0021-T01`. Separately tracked: production requires a KeyCape RS256 JWT,
not the static Bearer token this engine currently sends.
Define and enforce the decision contract needed by production commands. A
resolved approval must bind at least: