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

@ -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"))