secrets-engine/tests/test_action_authorization.py
tegwick 6e9c15228c
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
fix: exclude correlation fields from the flex-auth request digest
Verified the digest join against flex-auth's T03 replay fixtures and found
request_digest was hashing fields docs/canonical-request-digest.md excludes.
The material is tenant, subject, action, resource, context only: id is
correlation, policy_version lives in provenance, caring_context is hashed
separately. This engine included all three when present.

Because the join adopts the served request id, every real production request
would have carried one, so the computed digest would have matched no issued
decision and failed closed against every correct allow. Same unsatisfiable
shape as the removed AUTHORITY constant.

The old pinned constant was computed with the id inside the material, so it
was wrong and its passing proved nothing. Replaced with fixture-driven tests
over two real envelopes (vendored with provenance) plus a structural test
that correlation fields do not move the digest. Both fixtures are needed:
input_claim_digests.context appears only with a non-empty context.

Also stops computing the native claim digest. The claim's binding.action and
binding.target speak approval-engine's vocabulary while ours speaks the
catalog's, and no mapping is published; flex-auth makes no cross-check and
states the correspondence is ours via pdp_digest. A claim recording no
pdp_digest now fails closed naming the missing mapping rather than comparing
two different languages. That mapping is a prerequisite for destroy.

274 tests pass. Production still fails closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 393550@bnt-lap001
Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 14:17:38 +02:00

194 lines
7.2 KiB
Python

"""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, timedelta, timezone
import pytest
from secrets_engine.authorization import (
build_action_request,
request_digest,
validate_decision_envelope,
)
from secrets_engine.catalog import validate_entry
from secrets_engine.errors import DecisionError
from tests.test_catalog import VALID
def _request():
entry = validate_entry(copy.deepcopy(VALID))
return 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",
)
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 {
"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"]),
"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",
},
}
def _validate(envelope, expected=None):
return validate_decision_envelope(
envelope,
expected or _request(),
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v1"},
)
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"},
"action": "destroy",
"resource": {
"id": "catalog:example-build-test-token",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"attributes": {
"stage": "build",
"fields": ["token"],
"policy_targets": [],
"auth_targets": [],
},
},
"context": {"purpose": "contract-test"},
}
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())
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 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_envelopes_fail_closed(mutation, match):
env = _envelope()
mutation(env)
with pytest.raises(DecisionError, match=match):
_validate(env)
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_unsorted_or_duplicate_target_sets_are_rejected():
request = _request()
request["resource"]["attributes"]["policy_targets"] = ["b", "a"]
with pytest.raises(DecisionError, match="sorted and unique"):
_validate(_envelope(), request)
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"