fix: bind approval consumption to actual Flex Auth submissions
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 08:55:46 +02:00
parent 89bc31460f
commit ee4e901611
23 changed files with 612 additions and 1023 deletions

View file

@ -15,7 +15,7 @@ from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from secrets_engine.approval_claim import binding_from_check_request
from secrets_engine.authorization import request_digest
from secrets_engine.authorization import request_digest, approval_binding_digest
def _now():
@ -152,6 +152,9 @@ class AuthorizationStub:
if request.get("context") is not None:
binding["context"] = request["context"]
binding["request_digest"] = request_digest(request)
binding["submitted_request_digest"] = request_digest(request)
if "approval" in request.get("context", {}):
binding["approval_binding_digest"] = approval_binding_digest(request)
return {
"id": "decision:stub-" + request["action"],
"contract_version": "flex-auth.decision-record.v1",

View file

@ -1,52 +1,22 @@
# flex-auth T03 replay fixtures
# Flex Auth replay contract fixtures
Copied verbatim from `flex-auth/examples/secrets-engine/replay/` (commit
`9e10d1c`, `FLEX-WP-0021-T03`). Real `DecisionEnvelope`s emitted by the
published `secrets-engine.catalog-lane.lifecycle` v1 package via
`go run ./cmd/flex-auth check`, from `flex-auth/local` in `standalone` mode.
Copied byte-for-byte on 2026-09-09 from flex-auth revision
`88b354377c8e26b162f1234e673072f1c06dcd89`, examples/secrets-engine/ and its
replay/ directory. Contract: FLEX-DEC-2026-012. These are standalone evaluator
outputs, not deployment evidence.
Vendored so the digest contract test is hermetic. Regenerate upstream and
re-copy if the contract version changes.
Each output is paired with its independently supplied check_request input.
Tests compute submitted_request_digest from that input; they do not reconstruct
it from the enriched response. Existing request_digest, approval_binding_digest,
policy-package and registry-snapshot pins are unchanged. Policy remains v2.
Approval correspondence compares claim.binding.pdp_digest with the evaluator's
approval_binding_digest. Neither is computed from the unenriched request.
Re-copied 2026-09-06 (twice, both upstream regenerations):
Tests inject the actual decision time instead of modifying captured lifetimes.
The older flex-auth-live fixture remains untouched and must fail the new
contract because it has no submitted_request_digest.
1. commit `9f3e7e3` completed the approval-claim on `context.approval`. Because
`context` is hashed material, completing the claim moved the request digest.
2. commit `dd3ce4c` (`FLEX-DEC-2026-007`) published
`binding.approval_binding_digest` and set the embedded claim's
`binding.pdp_digest` to it with `binding.pdp_path` true. The request digest
moved once more with the claim's contents; the approval-binding digest did
**not**, which is the property the fixture now demonstrates rather than
asserts.
3. commit `d98323b` published **v2**, which adds the `input.tenant` rule v1
never had, and a third fixture: `decision_wrong_tenant_deny.json`. The
request digests did **not** move — every allow fixture already carried
`tenant: tenant:platform` — but `provenance.policy_version` is now `v2` and
`policy_package_digest` moved to `sha256:bd11c5fe…`.
`decision_rotate.json` carries no `approval_binding_digest` — the field is
omitted on claim-free decisions rather than duplicated onto them, and a test
pins that omission.
`decision_wrong_tenant_deny.json` is an `effect: deny` envelope and carries no
`lifetime`, which is legal: the schema requires `lifetime` only for an allow.
It is the wrong-tenant denial evidence `GLAS-WP-0015` asked for. Do not
lifetime-refresh it in tests.
**v1 must not be pinned.** It had no tenant rule and failed open; flex-auth
superseded rather than amended it so the change is visible in the version
string. See `docs/tenant-alignment.md`.
**Pinned here** (stable across runs, per the upstream README):
`binding.request_digest`, `binding.approval_binding_digest`,
`provenance.policy_package_digest`, `provenance.registry_snapshot_digest`, and
the presence/absence of `provenance.input_claim_digests.context`.
`approval_binding_digest` is not only pinned but **rederived** by
`test_pdp_digest_equals_the_published_approval_binding_digest`: our canonical
implementation must reproduce it from the fixture's own request. A pin asserts
the constant; rederiving it proves we hash the same material flex-auth does.
**Never pin:** `id`, `provenance.decision_time`, `lifetime.not_before`,
`lifetime.expires_at` — all move with the clock.
`tools/exercise_approval_identity.py` additionally compiles the owner evaluator
and runs real KeyCape/Approval Engine/Flex Auth joins with synthetic credentials,
including registry override and destructive-action dual control. Its receipt
pins source, binary and producer inputs; it makes no live admission claim.

View file

@ -0,0 +1,54 @@
{
"id": "check:secrets-engine-destroy",
"tenant": "tenant:platform",
"subject": {
"id": "secrets-engine",
"type": "service"
},
"action": "destroy",
"resource": {
"id": "lane:glas-primary",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"attributes": {
"stage": "prod",
"fields": [],
"policy_targets": [],
"auth_targets": []
}
},
"context": {
"approval": {
"schema_version": "0.1",
"kind": "approval-claim",
"issuer": "approval-engine",
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
"state": "valid",
"valid_now": true,
"consumed": false,
"binding": {
"action": "secrets.kv.destroy",
"target": {
"id": "lane-openbao-root",
"stage": "prod"
},
"actor": "agt-secrets-engine",
"principal": "bernd",
"purpose": "rotate-exposed-key",
"digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
"pdp_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56",
"pdp_path": true
},
"freshness": {
"observed_at": "2026-09-06T12:00:00+00:00",
"ttl_seconds": 30,
"not_after": "2026-09-06T12:00:30+00:00"
},
"validity": {
"not_before": "2026-09-06T11:00:00+00:00",
"expires_at": "2026-09-06T15:00:00+00:00"
},
"reason_code": "ok"
}
}
}

View file

@ -0,0 +1,23 @@
{
"id": "check:secrets-engine-rotate",
"tenant": "tenant:platform",
"subject": {
"id": "secrets-engine",
"type": "service"
},
"action": "rotate",
"resource": {
"id": "lane:glas-primary",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"attributes": {
"stage": "prod",
"fields": [
"password"
],
"policy_targets": [],
"auth_targets": []
}
},
"context": {}
}

View file

@ -0,0 +1,23 @@
{
"id": "check:secrets-engine-wrong-tenant",
"tenant": "tenant:coulomb",
"subject": {
"id": "secrets-engine",
"type": "service"
},
"action": "rotate",
"resource": {
"id": "lane:glas-primary",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"attributes": {
"stage": "prod",
"fields": [
"password"
],
"policy_targets": [],
"auth_targets": []
}
},
"context": {}
}

View file

@ -100,19 +100,21 @@
}
},
"request_digest": "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a",
"approval_binding_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56"
"approval_binding_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56",
"submitted_request_digest": "sha256:c605a9ecd5711d0a1d59e7b29f3a16b53fd09d104dd077766f098e7bc5895435"
},
"lifetime": {
"kind": "ttl",
"ttl": "15m",
"not_before": "2026-09-06T18:35:19Z",
"expires_at": "2026-09-06T18:50:19Z"
"not_before": "2026-09-07T07:10:23Z",
"expires_at": "2026-09-07T07:25:23Z"
},
"diagnostics": {
"action": "destroy",
"matched_relationship": "",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_status": "ready",
"registry_overrode": [],
"registry_resource": false,
"registry_subject": true
},
@ -126,7 +128,7 @@
"input_claim_digests": {
"context": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800"
},
"decision_time": "2026-09-06T18:35:19Z"
"decision_time": "2026-09-07T07:10:23Z"
},
"caring": {
"profile": "caring-0.4.0-rc2",

View file

@ -69,19 +69,21 @@
"stage": "prod"
}
},
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345"
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
"submitted_request_digest": "sha256:41c8fc084e58c46554ccb6afe9943a99906e5986668c923811721f66d9b30a6a"
},
"lifetime": {
"kind": "ttl",
"ttl": "15m",
"not_before": "2026-09-06T18:35:18Z",
"expires_at": "2026-09-06T18:50:18Z"
"not_before": "2026-09-07T07:10:23Z",
"expires_at": "2026-09-07T07:25:23Z"
},
"diagnostics": {
"action": "rotate",
"matched_relationship": "",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_status": "ready",
"registry_overrode": [],
"registry_resource": false,
"registry_subject": true
},
@ -92,7 +94,7 @@
"policy_version": "v2",
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
"decision_time": "2026-09-06T18:35:18Z"
"decision_time": "2026-09-07T07:10:23Z"
},
"caring": {
"profile": "caring-0.4.0-rc2",

View file

@ -69,13 +69,15 @@
"stage": "prod"
}
},
"request_digest": "sha256:c9c6e6f8713266e9e95ae1443a395a3a1f965ba95469dea747645f0437bb0d20"
"request_digest": "sha256:c9c6e6f8713266e9e95ae1443a395a3a1f965ba95469dea747645f0437bb0d20",
"submitted_request_digest": "sha256:9aab6de9069e1e811a52835ca00bc5e9cb38166444df068eb60a26923b1c9175"
},
"diagnostics": {
"action": "rotate",
"matched_relationship": "",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_status": "ready",
"registry_overrode": [],
"registry_resource": false,
"registry_subject": true
},
@ -86,7 +88,7 @@
"policy_version": "v2",
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
"decision_time": "2026-09-06T18:35:20Z"
"decision_time": "2026-09-07T07:10:24Z"
},
"caring": {
"profile": "caring-0.4.0-rc2",

View file

@ -54,6 +54,7 @@ def _envelope(request=None):
"resource": copy.deepcopy(request["resource"]),
"context": copy.deepcopy(request["context"]),
"request_digest": request_digest(request),
"submitted_request_digest": request_digest(request),
},
"lifetime": {
"kind": "bounded",
@ -135,24 +136,14 @@ def test_state_hub_authority_is_no_longer_required():
[
(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 action does not match"),
(lambda d: d["binding"].update(request_digest="sha256:" + "0" * 64),
"request digest does not match"),
(lambda d: d["binding"].update(submitted_request_digest="sha256:" + "0" * 64), "submitted request digest"),
(lambda d: d["binding"].update(request_digest="malformed"), "evaluated request digest"),
(lambda d: d["provenance"].update(policy_package="other.package"),
"policy package is not accepted"),
(lambda d: d["provenance"].update(policy_version="v1"),
"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.id does not match"),
# Enrichment may add attributes; it may never restate what we sent.
(lambda d: d["binding"]["resource"]["attributes"].update(stage="build"),
"resource.attributes.stage does not match"),
(lambda d: d["binding"]["subject"].update(tenant="tenant:coulomb"),
"subject.tenant 'tenant:coulomb' is not the request tenant"),
(lambda d: d["binding"]["subject"].update(surprise="x"),
"carries unexpected field"),
],
)
def test_invalid_envelopes_fail_closed(mutation, match):

View file

@ -13,7 +13,7 @@ from secrets_engine.approval_consume import (
ConsumeBinding,
consume_approval,
require_production_consume,
resolve_consume_binding,
resolve_approval_observation,
)
from secrets_engine.catalog import validate_entry
from secrets_engine.config import Config
@ -87,8 +87,8 @@ def _authorized():
)
def test_resolve_consume_binding_is_unserved():
assert resolve_consume_binding(object(), object(), "apply", None) is None
def test_resolve_approval_observation_is_unserved():
assert resolve_approval_observation(object(), object(), "apply", None) is None
def test_consume_success_and_same_digest_retry(tmp_path):

View file

@ -17,7 +17,8 @@ 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.approval_consume import resolve_approval_observation, authorize_action
from tests.authorization_stub import AuthorizationStub
from secrets_engine.authorization import build_action_request, request_digest
from secrets_engine.catalog import validate_entry
from secrets_engine.errors import DecisionError
@ -34,6 +35,8 @@ class _Cfg:
self.authorization_subject_type = "Human"
self.authorization_policy_package = "secrets-engine.lifecycle"
self.authorization_policy_version = "v1"
self.pdp_url = "http://127.0.0.1:1234"
self.pdp_token_file = token_file
self.authorization_min_approvals = 2
for k, v in over.items():
setattr(self, k, v)
@ -113,19 +116,19 @@ def _opener(envelope, status=200):
def _resolve(cfg, entry, envelope, action="deactivate"):
return resolve_consume_binding(
cfg, entry, action, None,
fields=("api_token",),
policy_targets=(entry.policy_name,),
auth_targets=(entry.role_name,),
opener=_opener(envelope),
)
pdp = AuthorizationStub(approval_id=AUTH_ID, package=cfg.authorization_policy_package, version=cfg.authorization_policy_version)
def check(request, timeout=None):
submitted = json.loads(request.data)
return _opener(pdp.decision(submitted))(request)
return authorize_action(cfg, entry, action, None, fields=("api_token",),
policy_targets=(entry.policy_name,), auth_targets=(entry.role_name,),
opener=_opener(envelope), pdp_opener=check)
def test_unconfigured_serving_path_stays_fail_closed(tmp_path):
"""No URL/token/authorization id: None, exactly as before the join existed."""
cfg = _Cfg(None, approval_url="", approval_token_file=None)
assert resolve_consume_binding(cfg, _entry(), "deactivate", None) is None
assert resolve_approval_observation(cfg, _entry(), "deactivate", None) is None
def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path):
@ -133,8 +136,8 @@ def test_valid_authorization_yields_binding_with_canonical_digest(tmp_path):
cfg = _Cfg(_token(tmp_path))
binding = _resolve(cfg, entry, _served())
assert binding is not None
assert binding.approval_id == AUTH_ID
assert binding.request_digest == request_digest(_expected_request(entry))
assert binding.binding.approval_id == AUTH_ID
assert binding.binding.request_digest != request_digest(_expected_request(entry))
def test_missing_subject_raises_instead_of_returning_none(tmp_path):
@ -149,21 +152,13 @@ def test_policy_pin_is_not_enforced_on_the_claim_path(tmp_path):
# 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="")
assert _resolve(cfg, _entry(), _served()) is not None
assert resolve_approval_observation(cfg, _entry(), "deactivate", None, opener=_opener(_served())) is not None
def test_wrong_field_set_fails_closed(tmp_path):
"""A different proposed field set must not match the served digest."""
entry = _entry()
cfg = _Cfg(_token(tmp_path))
with pytest.raises(DecisionError):
resolve_consume_binding(
cfg, entry, "deactivate", None,
fields=("some_other_field",),
policy_targets=(entry.policy_name,),
auth_targets=(entry.role_name,),
opener=_opener(_served()),
)
claim = _served(fields=("other_field",))
with pytest.raises(DecisionError, match="pdp digest does not match"):
_resolve(_Cfg(_token(tmp_path)), _entry(), claim)
def test_action_mismatch_fails_closed(tmp_path):
@ -181,7 +176,7 @@ def test_unreachable_approval_engine_fails_closed(tmp_path):
raise URLError("no route")
with pytest.raises(DecisionError, match="unreachable"):
resolve_consume_binding(
resolve_approval_observation(
_Cfg(_token(tmp_path)), _entry(), "deactivate", None,
fields=("api_token",), opener=_boom,
)
@ -236,3 +231,24 @@ def test_wrong_pdp_digest_fails_closed(tmp_path):
claim["binding"]["pdp_digest"] = "sha256:" + "c" * 64
with pytest.raises(DecisionError, match="pdp digest does not match"):
_resolve(_Cfg(_token(tmp_path)), _entry(), claim)
def test_observation_is_not_a_consume_binding(tmp_path):
claim = _served()
claim["binding"]["pdp_digest"] = "sha256:" + "e" * 64
observation = resolve_approval_observation(_Cfg(_token(tmp_path)), _entry(), "deactivate", None,
opener=_opener(claim))
assert observation.claim == claim
assert observation.request["context"]["approval"] == claim
assert not hasattr(observation, "request_digest")
assert not hasattr(observation, "decision_id")
def test_claim_that_expires_during_check_cannot_yield_consume_binding(tmp_path, monkeypatch):
import secrets_engine.approval_consume as consumer
from secrets_engine.approval_claim import validate_approval_claim
def after_check(*args, **kwargs):
return validate_approval_claim(*args, **kwargs, now=datetime.now(timezone.utc) + timedelta(seconds=31))
monkeypatch.setattr(consumer, "validate_approval_claim", after_check)
with pytest.raises(DecisionError, match="observation is stale"):
_resolve(_Cfg(_token(tmp_path)), _entry(), _served())

View file

@ -1,438 +1,133 @@
"""Digest join verified against real flex-auth DecisionEnvelopes (FLEX-WP-0021-T03).
These replace a hand-maintained pin that was computed *including* the request
`id`. docs/canonical-request-digest.md excludes `id`, `policy_version` and
`caring_context` from the hashed material, and both real envelopes confirm it:
a digest computed over the old material matches no issued decision, which would
have failed closed against every correct allow.
Both fixtures are required. `provenance.input_claim_digests.context` appears
only when the request carries a non-empty context, so a validator asserting it
is always present passes `destroy` and fails `rotate`.
"""
"""FLEX-DEC-2026-012 against independent producer request/output fixtures."""
import copy
import json
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
from types import SimpleNamespace
from secrets_engine.authorization import (
REQUEST_TENANT,
approval_binding_digest,
build_action_request,
digest_material,
digest_material,
request_digest,
validate_decision_envelope,
)
from secrets_engine.decision_check import (
check_decision,
require_supported_pdp_address,
)
from secrets_engine.authorization import REQUEST_TENANT, build_action_request, request_digest, validate_decision_envelope
from secrets_engine.decision_check import check_decision, require_supported_pdp_address
from secrets_engine.errors import DecisionError
FIXTURES = Path(__file__).parent / "fixtures" / "flex-auth-replay"
PACKAGE_DIGEST = "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4"
SNAPSHOT_DIGEST = "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb"
CASES = {
"rotate": {
"file": "decision_rotate.json",
"digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345",
"action": "rotate",
"context_claim_digest": None,
},
"destroy": {
"file": "decision_destroy_dual_control.json",
"digest": "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a",
"action": "destroy",
"context_claim_digest": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800",
},
"rotate": ("check_request_allow_rotate.json", "decision_rotate.json", "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345"),
"destroy": ("check_request_allow_destroy_dual_control.json", "decision_destroy_dual_control.json", "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a"),
"deny": ("check_request_deny_wrong_tenant.json", "decision_wrong_tenant_deny.json", None),
}
def _envelope(name):
return json.loads((FIXTURES / CASES[name]["file"]).read_text())
def pair(name):
inp, out, _ = CASES[name]
return json.loads((FIXTURES / inp).read_text()), json.loads((FIXTURES / out).read_text())
def _request_from(envelope):
"""Rebuild the normalized tuple the binding carries.
Per the contract, a consumer re-hashing the *original unenriched* request
will not match a decision that turned on registry attributes; the binding is
the evaluator's statement of what it hashed.
"""
binding = envelope["binding"]
request = {"id": envelope["request_id"]}
for key in ("tenant", "subject", "action", "resource"):
if binding.get(key) is not None:
request[key] = binding[key]
if binding.get("context") is not None:
request["context"] = binding["context"]
return request
def validate(request, decision, **over):
# Validate at the actual producer decision time; do not rewrite evidence.
now = datetime.fromisoformat(decision["provenance"]["decision_time"].replace("Z", "+00:00"))
return validate_decision_envelope(decision, request, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}, now=now, **over)
def _refresh_lifetime(envelope):
"""Lifetime moves with the clock and must never be pinned."""
now = datetime.now(timezone.utc)
envelope["lifetime"]["not_before"] = (now - timedelta(minutes=1)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
envelope["lifetime"]["expires_at"] = (now + timedelta(minutes=14)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
return envelope
@pytest.mark.parametrize("name", ["rotate", "destroy"])
def test_real_producer_request_validates(name):
request, decision = pair(name)
assert request_digest(request) == decision["binding"]["submitted_request_digest"]
assert request_digest(request) != decision["binding"]["request_digest"]
assert decision["binding"]["request_digest"] == CASES[name][2]
assert decision["provenance"]["registry_snapshot_digest"] == "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb"
assert decision["provenance"]["policy_package_digest"] == "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4"
assert validate(request, decision).action == name
@pytest.mark.parametrize("name", list(CASES))
def test_request_digest_matches_the_issued_decision(name):
envelope = _envelope(name)
expected = CASES[name]["digest"]
assert envelope["binding"]["request_digest"] == expected, "fixture drifted"
assert request_digest(_request_from(envelope)) == expected
@pytest.mark.parametrize("name", list(CASES))
def test_correlation_fields_are_not_hashed(name):
"""id, policy_version and caring_context must not move the digest."""
request = _request_from(envelope := _envelope(name))
@pytest.mark.parametrize("name", ["rotate", "destroy"])
def test_correlation_fields_are_excluded(name):
request, _ = pair(name)
baseline = request_digest(request)
assert baseline == envelope["binding"]["request_digest"]
for field, value in (
("id", "check:some-other-correlation-id"),
("policy_version", "v99"),
("caring_context", {"anything": "here"}),
):
assert request_digest({**request, field: value}) == baseline, field
stripped = {k: v for k, v in request.items() if k != "id"}
assert request_digest(stripped) == baseline
for key, value in (("id", "other"), ("policy_version", "v99"), ("caring_context", {"extra": "context"})):
assert request_digest({**request, key: value}) == baseline
@pytest.mark.parametrize("name", list(CASES))
def test_digest_material_is_exactly_the_published_tuple(name):
material = digest_material(_request_from(_envelope(name)))
assert set(material) <= {"tenant", "subject", "action", "resource", "context"}
assert "id" not in material
def test_approval_join_is_between_evaluator_origin_values():
request, decision = pair("destroy")
pdp = request["context"]["approval"]["binding"]["pdp_digest"]
assert pdp == decision["binding"]["approval_binding_digest"] == "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56"
without = copy.deepcopy(request)
del without["context"]["approval"]
assert request_digest(without) != pdp
assert validate(request, decision, expected_approval_binding_digest=pdp).action == "destroy"
@pytest.mark.parametrize("name", list(CASES))
def test_real_envelope_validates_against_the_published_package(name):
envelope = _refresh_lifetime(_envelope(name))
result = validate_decision_envelope(
envelope,
_request_from(envelope),
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v2"},
)
assert result.action == CASES[name]["action"]
assert result.subject_id == "secrets-engine"
@pytest.mark.parametrize("change", ["claim_id", "claim_absent", "purpose", "action", "tenant", "field", "target"])
def test_allow_cannot_be_replayed_for_changed_submitted_material(change):
request, decision = pair("destroy")
if change == "claim_id":
request["context"]["approval"]["approval_id"] = "other-approval"
elif change == "claim_absent":
del request["context"]["approval"]
elif change == "purpose":
request["context"]["purpose"] = "other-purpose"
elif change in ("action", "tenant"):
request[change] = "other"
elif change == "field":
request["resource"]["attributes"]["fields"] = ["other"]
else:
request["resource"]["id"] = "catalog:other"
with pytest.raises(DecisionError, match="submitted request digest"):
validate(request, decision)
@pytest.mark.parametrize("name", list(CASES))
def test_provenance_digests_are_pinned(name):
provenance = _envelope(name)["provenance"]
assert provenance["policy_package_digest"] == PACKAGE_DIGEST
assert provenance["registry_snapshot_digest"] == SNAPSHOT_DIGEST
assert provenance["evaluator"] == "flex-auth/local"
assert provenance["mode"] == "standalone"
@pytest.mark.parametrize("value", [None, "", "sha256:" + "f" * 64])
def test_missing_or_wrong_submitted_binding_refused(value):
request, decision = pair("rotate")
if value is None:
del decision["binding"]["submitted_request_digest"]
else:
decision["binding"]["submitted_request_digest"] = value
with pytest.raises(DecisionError, match="submitted request digest"):
validate(request, decision)
@pytest.mark.parametrize("name", list(CASES))
def test_input_claim_digest_is_present_only_with_a_context(name):
"""The reason two fixtures exist: this field is conditional."""
provenance = _envelope(name)["provenance"]
expected = CASES[name]["context_claim_digest"]
actual = (provenance.get("input_claim_digests") or {}).get("context")
assert actual == expected
@pytest.mark.parametrize("value", [None, "bad", "sha256:" + "f" * 64])
def test_approval_binding_missing_malformed_or_wrong_refused(value):
request, decision = pair("destroy")
decision["binding"]["approval_binding_digest"] = value
with pytest.raises(DecisionError, match="approval[_ ]binding_digest|approval binding digest"):
validate(request, decision)
@pytest.mark.parametrize("name", list(CASES))
def test_a_tampered_binding_field_breaks_the_digest(name):
envelope = _envelope(name)
request = _request_from(envelope)
request["action"] = "handoff"
assert request_digest(request) != envelope["binding"]["request_digest"]
def test_claim_free_shortcut_refused():
request, decision = pair("rotate")
with pytest.raises(DecisionError, match="carried approval"):
validate(request, decision, expected_approval_binding_digest=decision["binding"]["request_digest"])
def test_expired_real_envelope_fails_closed():
"""The shipped lifetime is 15m from allow_ttl and has long since passed."""
with pytest.raises(DecisionError, match="lifetime has expired"):
envelope = _envelope("rotate")
validate_decision_envelope(
envelope,
_request_from(envelope),
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v2"},
)
def test_embedded_claim_uses_approval_engine_vocabulary_not_ours():
"""The published vocabulary gap, asserted rather than described.
The dual-control request carries a complete approval-claim in
context.approval. Its binding speaks approval-engine's language while the
decision speaks ours, and no mapping between them is published -- which is
why this engine compares pdp_digest and refuses to derive a native digest
from its own vocabulary.
"""
envelope = _envelope("destroy")
claim = envelope["binding"]["context"]["approval"]
assert claim["kind"] == "approval-claim"
assert claim["issuer"] == "approval-engine"
assert claim["valid_now"] is True
assert claim["binding"]["action"] == "secrets.kv.destroy"
assert envelope["binding"]["action"] == "destroy"
assert claim["binding"]["action"] != envelope["binding"]["action"]
assert set(claim["binding"]["target"]) == {"id", "stage"}
assert claim["binding"]["target"]["id"] != envelope["resource"]["id"]
APPROVAL_BINDING_DIGEST = (
"sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56"
)
def test_embedded_claim_pdp_digest_cannot_equal_the_carrying_request_digest():
"""Carrying the claim inside a hashed context makes the two unequal.
context is part of the digest material, so embedding an approval-claim
changes the request digest of the very request that carries it. A
pdp_digest recorded at issue time therefore cannot equal the final digest
of the dual-control request. This is why comparing pdp_digest against
request_digest can never pass and would fail destroy closed forever.
"""
envelope = _envelope("destroy")
binding = envelope["binding"]
pdp = binding["context"]["approval"]["binding"]["pdp_digest"]
request = _request_from(envelope)
assert request_digest(request) == binding["request_digest"]
assert pdp != binding["request_digest"]
def test_pdp_digest_equals_the_published_approval_binding_digest():
"""FLEX-DEC-2026-007 closed the circularity, and we reproduce the value.
``approval_binding_digest`` is the canonical digest with context.approval
removed. Recomputing it here from our own canonical implementation is the
hermetic proof that this engine hashes the same material flex-auth does --
a pin alone would only assert the constant, not that we can derive it.
"""
envelope = _envelope("destroy")
binding = envelope["binding"]
pdp = binding["context"]["approval"]["binding"]["pdp_digest"]
assert binding["approval_binding_digest"] == APPROVAL_BINDING_DIGEST
assert pdp == APPROVAL_BINDING_DIGEST
assert approval_binding_digest(_request_from(envelope)) == APPROVAL_BINDING_DIGEST
def test_approval_binding_digest_is_not_a_replay_identity():
"""It must not collapse into request_digest, or an allow becomes replayable.
Two requests differing only in which approval was presented share an
approval_binding_digest while their decisions differ -- one allows, one
denies dual_control_required. The fixture asserts the two digests disagree
on a claim-bearing request so the distinction stays real.
"""
envelope = _envelope("destroy")
binding = envelope["binding"]
assert binding["approval_binding_digest"] != binding["request_digest"]
def test_ordinary_decision_carries_no_approval_binding_digest():
"""The field is omitted, not duplicated, on a claim-free decision."""
binding = _envelope("rotate")["binding"]
assert "approval" not in binding.get("context", {})
assert "approval_binding_digest" not in binding
def test_embedded_claim_declares_the_pdp_path():
"""pdp_path is the issuer's declaration, and it is what our PEP requires.
Path intent is never inferred from a pdp_digest that happens to be present;
approvals issued before approval-engine schema v3 carry pdp_path false
regardless of any digest they hold.
"""
claim = _envelope("destroy")["binding"]["context"]["approval"]
assert claim["binding"]["pdp_path"] is True
def test_decision_validation_ties_the_claim_to_the_approval_binding_digest():
"""Step 2 checks the identity against the real envelope, not a local guess.
The PEP knows the claim's pdp_digest from step 1. When the request it sent
carried that claim, the decision must name the same claim-free envelope, or
the approval was issued against some other request.
"""
envelope = _refresh_lifetime(_envelope("destroy"))
request = _request_from(envelope)
result = validate_decision_envelope(
envelope,
request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v2"},
expected_approval_binding_digest=APPROVAL_BINDING_DIGEST,
)
assert result.action == "destroy"
with pytest.raises(DecisionError, match="approval binding digest"):
validate_decision_envelope(
envelope,
request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v2"},
expected_approval_binding_digest="sha256:" + "c" * 64,
)
def test_claim_bearing_request_without_a_binding_digest_fails_closed():
"""A decision that records no binding digest cannot tie the claim to itself.
Comparing against request_digest instead would be the fail-open direction
the whole field exists to prevent, so the absence is refused outright.
"""
envelope = _refresh_lifetime(_envelope("destroy"))
request = _request_from(envelope)
del envelope["binding"]["approval_binding_digest"]
with pytest.raises(DecisionError, match="records no approval_binding_digest"):
validate_decision_envelope(
envelope,
request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v2"},
expected_approval_binding_digest=APPROVAL_BINDING_DIGEST,
)
def test_a_forged_binding_digest_is_recomputed_not_trusted():
"""The field is verified against our own canonical digest, never taken on faith."""
envelope = _refresh_lifetime(_envelope("destroy"))
request = _request_from(envelope)
envelope["binding"]["approval_binding_digest"] = "sha256:" + "d" * 64
with pytest.raises(DecisionError, match="does not match this request"):
validate_decision_envelope(
envelope,
request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v2"},
)
# --- tenant scoping (v2) -----------------------------------------------------
#
# GLAS-WP-0015 asked for the exact CheckRequest tenant and wrong-tenant denial
# evidence. These are that evidence, taken from a real deny envelope rather than
# asserted, plus the pin that stops our request tenant drifting from the package
# it is scoped by.
WRONG_TENANT_FIXTURE = "decision_wrong_tenant_deny.json"
def test_our_request_tenant_is_the_package_known_tenant():
"""The tenant we send must be the one the package allows.
v2 reads request_tenant := object.get(input, "tenant", "") and allows only
known_tenant := "tenant:platform". Pinning our constant against the real
allow envelopes means a package retenanting shows up here rather than as a
wrong_tenant denial in production.
"""
for name in ("rotate", "destroy"):
assert _envelope(name)["binding"]["tenant"] == REQUEST_TENANT
def test_built_request_carries_the_tenant_and_hashes_it():
"""An absent tenant is a denial, not an ignored field, so it must be sent.
tenant is part of the digest material, so omitting it also produces a digest
that matches no correctly issued decision -- the same class of defect as
hashing excluded fields, arriving from the other direction.
"""
entry = SimpleNamespace(id="glas-primary", stage="prod")
request = build_action_request(
entry,
"rotate",
subject_id="secrets-engine",
subject_type="service",
purpose="rotate-exposed-key",
fields=["password"],
)
assert request["tenant"] == REQUEST_TENANT
assert "tenant" in digest_material(request)
untenanted = dict(request)
del untenanted["tenant"]
assert request_digest(untenanted) != request_digest(request)
def test_build_action_request_refuses_an_empty_tenant():
entry = SimpleNamespace(id="glas-primary", stage="prod")
with pytest.raises(DecisionError, match="requires a tenant"):
build_action_request(
entry,
"rotate",
subject_id="secrets-engine",
subject_type="service",
purpose="rotate-exposed-key",
tenant="",
)
def test_wrong_tenant_deny_envelope_fails_closed():
"""Wrong-tenant denial evidence, from a real v2 deny envelope.
flex-auth sent tenant:coulomb on an otherwise-valid rotate and the package
denied it with matched_rule wrong_tenant, so the tenant alone carried the
denial. Our consumer must refuse it on effect before anything else -- a deny
is not a decision we may act on, whatever else it validates.
"""
# Not lifetime-refreshed: a deny carries no lifetime at all, which is the
# property test_wrong_tenant_deny_carries_no_lifetime pins.
envelope = json.loads((FIXTURES / WRONG_TENANT_FIXTURE).read_text())
assert envelope["effect"] == "deny"
assert envelope["reason"] == "wrong_tenant"
assert envelope["matched_rule"] == "wrong_tenant"
assert envelope["binding"]["tenant"] == "tenant:coulomb"
assert envelope["binding"]["tenant"] != REQUEST_TENANT
def test_wrong_tenant_and_superseded_policy_refused():
request, decision = pair("deny")
assert decision["reason"] == "wrong_tenant"
assert decision.get("lifetime") is None
with pytest.raises(DecisionError, match="effect is not allow"):
validate_decision_envelope(
envelope,
_request_from(envelope),
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v2"},
)
validate(request, decision)
request, decision = pair("rotate")
decision["provenance"]["policy_version"] = "v1"
with pytest.raises(DecisionError, match="policy version"):
validate(request, decision)
def test_wrong_tenant_deny_carries_no_lifetime():
"""A deny has no lifetime, which is why effect must be checked first.
DecisionEnvelope requires lifetime only when effect is allow. A consumer
that validated lifetime before effect would raise a confusing missing-field
error on a perfectly well-formed denial.
"""
envelope = json.loads((FIXTURES / WRONG_TENANT_FIXTURE).read_text())
assert envelope.get("lifetime") is None
def test_real_expired_allow_refused():
request, decision = pair("rotate")
with pytest.raises(DecisionError, match="lifetime has expired"):
validate_decision_envelope(decision, request, accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"}, now=datetime(2100, 1, 1, tzinfo=timezone.utc))
def test_the_superseded_v1_package_is_not_accepted():
"""v1 had no tenant rule and failed open; pinning it must not be possible.
A consumer still pinned to v1 would keep getting allows it should never
have had and could not tell from the version string that the rule changed
underneath it, which is exactly why flex-auth superseded v1 rather than
amending it.
"""
envelope = _refresh_lifetime(_envelope("rotate"))
with pytest.raises(DecisionError, match="policy version is not accepted"):
validate_decision_envelope(
envelope,
_request_from(envelope),
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v1"},
)
def test_request_builder_tenant_boundary():
entry = SimpleNamespace(id="glas-primary", stage="prod")
args = dict(subject_id="secrets-engine", subject_type="service", purpose="rotation")
request = build_action_request(entry, "rotate", **args)
assert request["tenant"] == REQUEST_TENANT
with pytest.raises(DecisionError, match="requires a tenant"):
build_action_request(entry, "rotate", tenant="", **args)
# --- supported PDP address (FLEX-DEC-2026-010) -------------------------------

View file

@ -1,181 +1,24 @@
"""The digest join against a REAL request, not one rebuilt from the binding.
tests/test_decision_replay.py passes every digest assertion because
``_request_from()`` reconstructs the request out of ``envelope["binding"]`` --
which is the *enriched* form the evaluator hashed. That is a self-consistent
fake agreeing with itself, and it hid a real defect through three rounds of
digest work: the validator required byte-equality between the binding and our
unenriched request, which no real decision can satisfy.
The rule was already published in flex-auth's canonical-request-digest.md
("Normalization"): the evaluator copies the request tenant onto subject and
resource, a registry hit copies type/tenant/selected attributes onto the refs,
and a consumer is told to compare structured binding fields to the proposed
action rather than re-hash its own request. This module builds the request the
way the engine actually builds it, compares it to a decision the engine actually
received, and proves the corrected check accepts it.
"""
from __future__ import annotations
"""Historical live evidence remains immutable and cannot satisfy the new contract."""
import json
from datetime import datetime, timedelta, timezone
from datetime import datetime
from pathlib import Path
import pytest
from secrets_engine.authorization import (
binding_tuple,
build_action_request,
canonical_check_request,
request_digest,
validate_decision_envelope,
)
from secrets_engine.authorization import build_action_request, validate_decision_envelope
from secrets_engine.catalog import load_catalog
from secrets_engine.errors import DecisionError
LIVE = Path(__file__).parent / "fixtures" / "flex-auth-live" / "decision_rotate_glas_live.json"
CATALOG = Path(__file__).resolve().parents[1] / "catalog"
PACKAGE = "secrets-engine.catalog-lane.lifecycle"
VERSION = "v2"
def _live():
return json.loads(LIVE.read_text())
def _our_request():
entry = load_catalog(CATALOG)["glas-claude-agent-dev-anthropic"]
return build_action_request(
entry,
"rotate",
subject_id="secrets-engine",
subject_type="service",
purpose="live-adoption-proof",
fields=["api_key"],
request_id="check:secrets-engine-adoption-proof",
)
def test_the_live_decision_is_a_real_v2_allow():
"""Provenance of the artifact these assertions rest on."""
env = _live()
assert env["effect"] == "allow"
assert env["reason"] == "catalog_lane_policy_matched"
provenance = env["provenance"]
assert provenance["policy_package"] == PACKAGE
assert provenance["policy_version"] == VERSION
assert env["binding"]["tenant"] == "tenant:platform"
def test_the_evaluator_enriches_subject_and_resource_before_hashing():
"""Names exactly which fields appeared that we never sent.
If flex-auth publishes an enrichment rule that differs from this, this test
fails and tells us the shape moved -- which is the point. It asserts the
observed gap, not a rule we invented.
"""
sent = _our_request()
bound = _live()["binding"]
assert "attributes" not in sent["subject"]
assert set(bound["subject"]["attributes"]) == {
"description", "display_name", "groups", "organization_relation", "roles",
}
assert "tenant" not in sent["subject"]
assert bound["subject"]["tenant"] == sent["tenant"]
assert "tenant" not in sent["resource"]
assert bound["resource"]["tenant"] == sent["tenant"]
# Everything we DID send survived unchanged. The enrichment is additive, so
# the decision is about the action we proposed -- which is why staying
# fail-closed here costs correctness nothing today.
assert bound["action"] == sent["action"]
assert bound["tenant"] == sent["tenant"]
assert bound["context"] == sent["context"]
assert bound["subject"]["id"] == sent["subject"]["id"]
assert bound["subject"]["type"] == sent["subject"]["type"]
for key in ("id", "type", "system", "attributes"):
assert bound["resource"][key] == sent["resource"][key]
def test_our_digest_cannot_match_a_real_binding():
"""The join is unsatisfiable against a real request, not merely mismatched.
We hash what we sent; the evaluator hashed what it enriched. No amount of
care on our side closes that, because the registry material is not ours.
"""
sent = _our_request()
bound = _live()["binding"]
assert bound["request_digest"] != request_digest(sent)
assert canonical_check_request(bound) != canonical_check_request(sent)
def test_the_live_allow_now_validates_under_the_documented_rule():
"""The real decision validates -- this is the fix, proved against the artifact.
canonical-request-digest.md "Normalization" is the published rule, and it
says a consumer must compare structured binding fields to the proposed
action rather than re-hash its own request. Under that rule this envelope,
which our previous byte-equality check rejected outright, is accepted.
Only the lifetime is refreshed: the decision was issued on 2026-09-07 with a
bounded lifetime, and pinning a clock-dependent field is what the fixture
provenance forbids.
"""
env = _live()
now = datetime.now(timezone.utc)
env["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"),
}
result = validate_decision_envelope(
env,
_our_request(),
accepted_policy_packages={PACKAGE},
accepted_policy_versions={VERSION},
)
assert result.action == "rotate"
assert result.subject_id == "secrets-engine"
assert result.decision_id == "decision:0f9c98f14545c42d"
def test_the_unrefreshed_live_decision_is_refused_on_lifetime():
"""It is a real expired allow, so it must be refused -- and for that reason.
Reaching the lifetime check at all is the evidence that every binding check
before it now passes against a real decision.
"""
with pytest.raises(DecisionError, match="lifetime has expired"):
validate_decision_envelope(
_live(),
_our_request(),
accepted_policy_packages={PACKAGE},
accepted_policy_versions={VERSION},
)
def test_the_digest_is_verified_against_the_binding_it_carries():
"""Independent recomputation, per the contract's own instruction.
"To recompute independently, hash the same normalized tuple the binding
carries." So the digest is still checked -- it is simply checked for
self-consistency rather than against material we never sent.
"""
binding = _live()["binding"]
assert request_digest(binding_tuple(binding)) == binding["request_digest"]
def test_a_tampered_binding_still_fails_the_digest():
"""Self-consistency is a real check, not a formality."""
env = _live()
env["binding"]["resource"]["attributes"]["stage"] = "build"
with pytest.raises(DecisionError, match="does not match"):
validate_decision_envelope(
env,
_our_request(),
accepted_policy_packages={PACKAGE},
accepted_policy_versions={VERSION},
)
def test_old_live_pin_without_submitted_binding_refuses_even_during_its_lifetime():
root = Path(__file__).resolve().parents[1]
decision = json.loads((root / "tests/fixtures/flex-auth-live/decision_rotate_glas_live.json").read_text())
entry = load_catalog(root / "catalog")["glas-claude-agent-dev-anthropic"]
request = build_action_request(entry, "rotate", subject_id="secrets-engine", subject_type="service",
purpose="live-adoption-proof", fields=["api_key"], request_id="check:secrets-engine-adoption-proof")
assert decision["effect"] == "allow"
assert "submitted_request_digest" not in decision["binding"]
with pytest.raises(DecisionError, match="submitted request digest"):
validate_decision_envelope(decision, request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"},
now=datetime.fromisoformat(decision["provenance"]["decision_time"].replace("Z", "+00:00")))