fix: exclude correlation fields from the flex-auth request digest
Verified the digest join against flex-auth's T03 replay fixtures and found request_digest was hashing fields docs/canonical-request-digest.md excludes. The material is tenant, subject, action, resource, context only: id is correlation, policy_version lives in provenance, caring_context is hashed separately. This engine included all three when present. Because the join adopts the served request id, every real production request would have carried one, so the computed digest would have matched no issued decision and failed closed against every correct allow. Same unsatisfiable shape as the removed AUTHORITY constant. The old pinned constant was computed with the id inside the material, so it was wrong and its passing proved nothing. Replaced with fixture-driven tests over two real envelopes (vendored with provenance) plus a structural test that correlation fields do not move the digest. Both fixtures are needed: input_claim_digests.context appears only with a non-empty context. Also stops computing the native claim digest. The claim's binding.action and binding.target speak approval-engine's vocabulary while ours speaks the catalog's, and no mapping is published; flex-auth makes no cross-check and states the correspondence is ours via pdp_digest. A claim recording no pdp_digest now fails closed naming the missing mapping rather than comparing two different languages. That mapping is a prerequisite for destroy. 274 tests pass. Production still fails closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD Assistant: claude-code Assistant-Model: opus Assistant-Process: 393550@bnt-lap001 Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
This commit is contained in:
parent
083bee7333
commit
6e9c15228c
11 changed files with 562 additions and 24 deletions
|
|
@ -43,9 +43,28 @@ never compared to each other:
|
|||
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`.
|
||||
`claim.binding.pdp_digest` is the **only** comparison usable today, and its
|
||||
absence fails the action closed. The claim's `binding.action` and
|
||||
`binding.target` speak approval-engine's vocabulary (`secrets.kv.destroy`,
|
||||
`{"id": ..., "stage": ...}`) while ours speaks the catalog's (`destroy`,
|
||||
`catalog:<id>`), and no mapping between them is published. flex-auth makes no
|
||||
cross-check either and states the correspondence is ours. Computing a native
|
||||
digest from our own vocabulary would compare two different languages and never
|
||||
match, so this engine does not compute one. Closing that gap needs a published
|
||||
mapping co-authored by approval-engine and flex-auth; it is a prerequisite for
|
||||
`SECRETS-WP-0007-T04` making `destroy` reachable.
|
||||
|
||||
### What is hashed in the flex-auth digest
|
||||
|
||||
`tenant`, `subject`, `action`, `resource`, `context` — and nothing else.
|
||||
`id` is correlation only, `policy_version` lives in provenance, and
|
||||
`caring_context` is hashed separately. Including any of them yields a digest
|
||||
that matches no issued decision. `digest_material` enforces the exclusion and
|
||||
`tests/test_decision_replay.py` pins it against two real envelopes.
|
||||
|
||||
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, so recompute from the binding tuple.
|
||||
|
||||
The approval-engine object id is never inferred from a State Hub decision UUID.
|
||||
It comes from catalog `approval.authorization_id`, and is the value in the
|
||||
|
|
|
|||
|
|
@ -144,8 +144,18 @@ def validate_approval_claim(
|
|||
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:
|
||||
# Prefer the PDP digest: it is expressed in flex-auth's vocabulary, which is
|
||||
# the one the caller actually proposed. The native digest is over
|
||||
# approval-engine's own vocabulary and is only comparable when the caller
|
||||
# supplies a binding built in that vocabulary (see the note in
|
||||
# resolve_consume_binding about the missing mapping).
|
||||
if expected_pdp_digest:
|
||||
if not pdp:
|
||||
raise DecisionError(
|
||||
"approval claim records no pdp_digest, and no published mapping "
|
||||
"exists between approval-engine and secrets-engine action/target "
|
||||
"vocabularies; the claim cannot be tied to this exact action"
|
||||
)
|
||||
if pdp != expected_pdp_digest:
|
||||
raise DecisionError("approval claim pdp digest does not match the request")
|
||||
elif expected_binding_digest:
|
||||
|
|
|
|||
|
|
@ -18,11 +18,7 @@ from typing import Any, Callable
|
|||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from secrets_engine.approval_claim import (
|
||||
binding_from_check_request,
|
||||
claim_binding_digest,
|
||||
validate_approval_claim,
|
||||
)
|
||||
from secrets_engine.approval_claim import 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
|
||||
|
|
@ -193,10 +189,19 @@ def resolve_consume_binding(
|
|||
policy_targets=policy_targets,
|
||||
auth_targets=auth_targets,
|
||||
)
|
||||
# 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))
|
||||
# Two different digests over the same proposed action, by contract; they are
|
||||
# never compared to each other.
|
||||
#
|
||||
# Only the PDP digest is usable for the action/target correspondence today.
|
||||
# The claim's binding.action and binding.target speak approval-engine's
|
||||
# vocabulary ("secrets.kv.destroy", {"id": ..., "stage": ...}) while ours
|
||||
# speaks the catalog's ("destroy", "catalog:<id>"), and no mapping between
|
||||
# them is published. flex-auth makes no cross-check either and states the
|
||||
# correspondence is ours, via pdp_digest. Computing a native digest from our
|
||||
# own vocabulary would compare two different languages and never match --
|
||||
# the same unsatisfiable-rule defect flex-auth fixed in 68ad039 -- so we do
|
||||
# not compute one, and validate_approval_claim fails closed with a named
|
||||
# reason when the issuer recorded no pdp_digest.
|
||||
pdp_digest = request_digest(expected_request)
|
||||
|
||||
claim = fetch_approval_claim(
|
||||
|
|
@ -208,7 +213,6 @@ def resolve_consume_binding(
|
|||
validate_approval_claim(
|
||||
claim,
|
||||
approval_id=authorization_id,
|
||||
expected_binding_digest=native_digest,
|
||||
expected_pdp_digest=pdp_digest,
|
||||
)
|
||||
binding = claim.get("binding") or {}
|
||||
|
|
|
|||
|
|
@ -156,8 +156,26 @@ def canonical_check_request(request: object) -> dict[str, Any]:
|
|||
return canonical
|
||||
|
||||
|
||||
def request_digest(request: object) -> str:
|
||||
#: Correlation-only or separately-hashed fields, excluded from the digest
|
||||
#: material by docs/canonical-request-digest.md "What is hashed".
|
||||
_UNHASHED_FIELDS = ("id", "policy_version", "caring_context")
|
||||
|
||||
|
||||
def digest_material(request: object) -> dict[str, Any]:
|
||||
"""The exact tuple flex-auth hashes: tenant, subject, action, resource, context.
|
||||
|
||||
``id`` is correlation only, ``policy_version`` is recorded in provenance, and
|
||||
``caring_context`` is hashed separately as
|
||||
``provenance.input_claim_digests.caring_context``. Including any of them
|
||||
produces a digest that matches no real DecisionEnvelope, which fails closed
|
||||
against every correctly issued decision.
|
||||
"""
|
||||
canonical = canonical_check_request(request)
|
||||
return {k: v for k, v in canonical.items() if k not in _UNHASHED_FIELDS}
|
||||
|
||||
|
||||
def request_digest(request: object) -> str:
|
||||
canonical = digest_material(request)
|
||||
encoded = json.dumps(
|
||||
canonical, ensure_ascii=False, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
|
|
|||
17
tests/fixtures/flex-auth-replay/PROVENANCE.md
vendored
Normal file
17
tests/fixtures/flex-auth-replay/PROVENANCE.md
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# flex-auth T03 replay 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.
|
||||
|
||||
Vendored so the digest contract test is hermetic. Regenerate upstream and
|
||||
re-copy if the contract version changes.
|
||||
|
||||
**Pinned here** (stable across runs, per the upstream README):
|
||||
`binding.request_digest`, `provenance.policy_package_digest`,
|
||||
`provenance.registry_snapshot_digest`, and the presence/absence of
|
||||
`provenance.input_claim_digests.context`.
|
||||
|
||||
**Never pin:** `id`, `provenance.decision_time`, `lifetime.not_before`,
|
||||
`lifetime.expires_at` — all move with the clock.
|
||||
121
tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json
vendored
Normal file
121
tests/fixtures/flex-auth-replay/decision_destroy_dual_control.json
vendored
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
"id": "decision:669a12badaa3d82e",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-destroy",
|
||||
"effect": "allow",
|
||||
"reason": "catalog_lane_policy_matched",
|
||||
"matched_policy_version": "v1",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "destroy",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"consumed": false,
|
||||
"issuer": "approval-engine",
|
||||
"kind": "approval-claim",
|
||||
"reason_code": "ok",
|
||||
"schema_version": "0.1",
|
||||
"state": "valid",
|
||||
"valid_now": true
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-06T06:13:43Z",
|
||||
"expires_at": "2026-09-06T06:28:43Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "destroy",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v1",
|
||||
"policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"input_claim_digests": {
|
||||
"context": "sha256:45fa9f41271c684f2c26570be11869a8eda28e7b3b25f11e01f9378b6f89fdc8"
|
||||
},
|
||||
"decision_time": "2026-09-06T06:13:43Z"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
110
tests/fixtures/flex-auth-replay/decision_rotate.json
vendored
Normal file
110
tests/fixtures/flex-auth-replay/decision_rotate.json
vendored
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
{
|
||||
"id": "decision:49309356905a2ad3",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "allow",
|
||||
"reason": "catalog_lane_policy_matched",
|
||||
"matched_policy_version": "v1",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-06T06:13:21Z",
|
||||
"expires_at": "2026-09-06T06:28:21Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v1",
|
||||
"policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-06T06:13:21Z"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -77,7 +77,15 @@ def _validate(envelope, expected=None):
|
|||
)
|
||||
|
||||
|
||||
def test_digest_matches_flex_auth_contract_example():
|
||||
def test_digest_is_stable_and_ignores_correlation_fields():
|
||||
"""The pinned digest contract now lives in tests/test_decision_replay.py.
|
||||
|
||||
That file verifies against two real DecisionEnvelopes issued by the
|
||||
published package. The constant previously pinned here was computed with
|
||||
the request `id` inside the hashed material, which
|
||||
docs/canonical-request-digest.md excludes -- it matched no issued decision.
|
||||
Kept here: the structural property, checked without a hand-maintained pin.
|
||||
"""
|
||||
request = {
|
||||
"id": "check:secrets-engine-destroy-example",
|
||||
"subject": {"id": "user:alice", "type": "Human"},
|
||||
|
|
@ -95,12 +103,11 @@ def test_digest_matches_flex_auth_contract_example():
|
|||
},
|
||||
"context": {"purpose": "contract-test"},
|
||||
}
|
||||
# Generated independently with flex-auth's Go api.CheckRequest and
|
||||
# encoding/json. The action_authorization.json example carried a stale
|
||||
# digest when this consumer contract was implemented.
|
||||
assert request_digest(request) == (
|
||||
"sha256:73d5d7d5b3363f1a1db8f4c0e79c8f33dae5d77ffb97f21e449438bc0defa4c3"
|
||||
)
|
||||
baseline = request_digest(request)
|
||||
assert baseline.startswith("sha256:") and len(baseline) == 71
|
||||
assert request_digest({k: v for k, v in request.items() if k != "id"}) == baseline
|
||||
assert request_digest({**request, "action": "deactivate"}) != baseline
|
||||
|
||||
|
||||
def test_valid_allow_envelope_passes():
|
||||
result = _validate(_envelope())
|
||||
|
|
|
|||
|
|
@ -77,7 +77,14 @@ def _served(entry=None, action="deactivate", fields=("api_token",), **over):
|
|||
"state": "valid",
|
||||
"valid_now": True,
|
||||
"consumed": False,
|
||||
"binding": {**binding, "digest": claim_binding_digest(**binding)},
|
||||
"binding": {
|
||||
**binding,
|
||||
"digest": claim_binding_digest(**binding),
|
||||
# The issuer recorded the PDP digest at issue time. That is the only
|
||||
# comparison usable today: the native digest speaks
|
||||
# approval-engine's vocabulary and no mapping to ours is published.
|
||||
"pdp_digest": request_digest(request),
|
||||
},
|
||||
"freshness": {
|
||||
"observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"ttl_seconds": 30,
|
||||
|
|
@ -182,3 +189,25 @@ def test_unreachable_approval_engine_fails_closed(tmp_path):
|
|||
def test_superseded_claim_fails_closed(tmp_path):
|
||||
with pytest.raises(DecisionError):
|
||||
_resolve(_Cfg(_token(tmp_path)), _entry(), _served(valid_now=False, reason_code="superseded"))
|
||||
|
||||
|
||||
def test_claim_without_pdp_digest_fails_closed_naming_the_missing_mapping(tmp_path):
|
||||
"""No published vocabulary mapping means the claim cannot be tied to this action.
|
||||
|
||||
approval-engine's binding.action/target use their vocabulary
|
||||
("secrets.kv.destroy", {"id":..., "stage":...}); ours uses the catalog's.
|
||||
flex-auth makes no cross-check and states the correspondence is ours via
|
||||
pdp_digest. Without one there is nothing sound to compare, so this must
|
||||
refuse rather than fall back to comparing two different languages.
|
||||
"""
|
||||
claim = _served()
|
||||
claim["binding"].pop("pdp_digest")
|
||||
with pytest.raises(DecisionError, match="no published mapping"):
|
||||
_resolve(_Cfg(_token(tmp_path)), _entry(), claim)
|
||||
|
||||
|
||||
def test_wrong_pdp_digest_fails_closed(tmp_path):
|
||||
claim = _served()
|
||||
claim["binding"]["pdp_digest"] = "sha256:" + "c" * 64
|
||||
with pytest.raises(DecisionError, match="pdp digest does not match"):
|
||||
_resolve(_Cfg(_token(tmp_path)), _entry(), claim)
|
||||
|
|
|
|||
159
tests/test_decision_replay.py
Normal file
159
tests/test_decision_replay.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""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`.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine.authorization import (
|
||||
digest_material,
|
||||
request_digest,
|
||||
validate_decision_envelope,
|
||||
)
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flex-auth-replay"
|
||||
|
||||
PACKAGE_DIGEST = "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c"
|
||||
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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56",
|
||||
"action": "destroy",
|
||||
"context_claim_digest": "sha256:45fa9f41271c684f2c26570be11869a8eda28e7b3b25f11e01f9378b6f89fdc8",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _envelope(name):
|
||||
return json.loads((FIXTURES / CASES[name]["file"]).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 _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", 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))
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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={"v1"},
|
||||
)
|
||||
assert result.action == CASES[name]["action"]
|
||||
assert result.subject_id == "secrets-engine"
|
||||
|
||||
|
||||
@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("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("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_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={"v1"},
|
||||
)
|
||||
|
|
@ -409,6 +409,50 @@ package/version to configure. Next inbound is the `T03` replay fixture — a rea
|
|||
DecisionEnvelope from this package including `provenance.registry_snapshot_digest`
|
||||
— to verify the digest join unchanged.
|
||||
|
||||
Verified 2026-09-06 against flex-auth's `T03` replay fixtures
|
||||
(`FLEX-WP-0021-T03`, commit `9e10d1c`), vendored to
|
||||
`tests/fixtures/flex-auth-replay/`. Running them found a defect of exactly the
|
||||
class we had just been warning about.
|
||||
|
||||
- **`request_digest` was hashing fields the contract excludes.** The material is
|
||||
`tenant`, `subject`, `action`, `resource`, `context` only; `id` is correlation,
|
||||
`policy_version` lives in provenance, and `caring_context` is hashed
|
||||
separately. This engine included all three when present. Because the join
|
||||
adopts the served request id, every real production request would have carried
|
||||
one, so the computed digest would have matched no issued decision and failed
|
||||
closed against every correct allow — the same unsatisfiable-rule shape as the
|
||||
removed `AUTHORITY` constant and as flex-auth's own destroy rule in `68ad039`.
|
||||
Fixed by `digest_material`; both fixtures now reproduce their published
|
||||
digests exactly.
|
||||
- The previously pinned digest constant was computed *with* the id inside the
|
||||
material, so it was wrong and its passing was not evidence. It is replaced by
|
||||
fixture-driven contract tests over two real envelopes plus a structural test
|
||||
that correlation fields do not move the digest. Both fixtures are required:
|
||||
`provenance.input_claim_digests.context` appears only with a non-empty context.
|
||||
- **The native claim digest is unusable and is no longer computed.** The claim's
|
||||
`binding.action`/`binding.target` speak approval-engine's vocabulary while ours
|
||||
speaks the catalog's, and no mapping is published; flex-auth makes no
|
||||
cross-check and states the correspondence is ours via `pdp_digest`. A claim
|
||||
recording no `pdp_digest` now fails closed naming the missing mapping rather
|
||||
than comparing two vocabularies. **A published mapping is a prerequisite for
|
||||
making `destroy` reachable under this task.** flex-auth offered to co-author it
|
||||
with approval-engine.
|
||||
- Narrowing accepted from approval-engine: `entries` is UNIQUE on
|
||||
`(approval_id, subject_id)`, so approver distinctness is a storage invariant,
|
||||
not a count this engine was previously protecting. What we stopped verifying is
|
||||
that approval-engine applies its own invariant correctly — still a real
|
||||
reduction in defence in depth, but narrower than "the threshold is now
|
||||
unverified". They also added `threshold` objects to issuance and use events
|
||||
(`87e55e2`) so the evaluation is reconstructable under §9.6.
|
||||
- flex-auth corrected the `destroy` dual-control rule (`68ad039`) before
|
||||
generating these fixtures: it had required `context.approval.status ==
|
||||
'approved'` and an approver list, neither of which exists in the claim schema.
|
||||
Our acknowledgement of the earlier rule crossed that fix. The corrected rule
|
||||
requires only kind/issuer/`valid_now`, and our claim validation is unchanged.
|
||||
- One global pin confirmed correct: `secrets-engine.catalog-lane.lifecycle` v1 is
|
||||
one package covering all twelve actions. Do not build per-action pin
|
||||
configuration for a distinction that does not exist.
|
||||
|
||||
Define and enforce the decision contract needed by production commands. A
|
||||
resolved approval must bind at least:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue