secrets-engine/tests/test_integration_authorization.py

237 lines
8 KiB
Python
Raw Normal View History

feat: complete and prove the authorization chain end to end Implements step 2 (access-engine POST /v1/check) and wires the whole GH-DEC-2026-003 sequence together, then proves it against a live throwaway OpenBao rather than only unit-level fakes. - decision_check.check_decision performs the PDP call; an unreachable or non-200 PDP raises, since silence is never permission. - approval_consume.authorize_action coordinates steps 1 and 2 and returns an AuthorizedAction. Both steps build the same CheckRequest via a shared _expected_request, since two descriptions of the action cannot produce corresponding digests. - apply_unreachable_engine_stance takes authorized=. The published map defines fail_closed as no side effect WITHOUT a durable decision record, so holding a validated one means the residue does not apply. Not a bypass: both steps must have succeeded and CAS consume still precedes OpenBao. Unconfigured still returns None and fails closed. The end-to-end test caught one more instance of the cross-vocabulary bug: a leftover comparison of the claim's binding.action against ours. The claim says secrets.kv.destroy where we say destroy, so it would have failed against every real claim. Removed; the tie is pdp_digest. Integration coverage asserts PIP-then-PDP ordering, that consume is the last step before the backend, and that an unreachable PDP, denied decision, invalid claim, missing pdp_digest, consume conflict and action mismatch each stop before OpenBao. 284 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:56:02 +02:00
"""End-to-end proof of the GH-DEC-2026-003 chain against real OpenBao.
Everything before this exercised the join against unit-level fakes. This drives
the actual CLI gate through all four steps in order --
claim -> check -> consume -> OpenBao -- with a live throwaway OpenBao on the
far end, and asserts both that a fully authorized action reaches the backend and
that each failure mode stops before it.
Skipped without the `bao`/`vault` CLI. The stub serves transport and sequencing
only; the wire contracts are pinned against flex-auth's real fixtures in
tests/test_decision_replay.py.
"""
import copy
import os
import shutil
import socket
import subprocess
import time
import pytest
import yaml
from secrets_engine import cli
from secrets_engine.approval_consume import authorize_action
from secrets_engine.catalog import get_entry
from secrets_engine.config import Config
from secrets_engine.errors import DecisionError
from secrets_engine.openbao import OpenBaoClient
from tests.authorization_stub import AuthorizationStub
from tests.test_catalog import VALID
pytestmark = pytest.mark.skipif(
shutil.which("bao") is None and shutil.which("vault") is None,
reason="no OpenBao/Vault CLI on PATH",
)
APPROVAL_ID = "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f"
PACKAGE = "secrets-engine.catalog-lane.lifecycle"
VERSION = "v1"
def _free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
@pytest.fixture()
def bao_dev():
bao = shutil.which("bao") or shutil.which("vault")
port = _free_port()
token = "se-authz-root"
proc = subprocess.Popen(
[bao, "server", "-dev", "-dev-no-store-token", f"-dev-root-token-id={token}",
f"-dev-listen-address=127.0.0.1:{port}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
addr = f"http://127.0.0.1:{port}"
client = OpenBaoClient(addr=addr, token=token, bao_bin=bao)
for _ in range(50):
if client.is_reachable():
break
time.sleep(0.2)
else:
proc.kill()
pytest.fail("dev OpenBao did not become reachable")
try:
yield addr, token, client
finally:
proc.kill()
@pytest.fixture()
def stub():
s = AuthorizationStub(approval_id=APPROVAL_ID, package=PACKAGE, version=VERSION).start()
try:
yield s
finally:
s.stop()
@pytest.fixture()
def lane(tmp_path):
"""A prod lane bound to the approval object, in a throwaway catalog."""
raw = copy.deepcopy(VALID)
raw["stage"] = "prod"
# bootstrap-only skips the *legacy* State Hub lane-approval lookup, which is
# a separate older mechanism with its own repo-rooted fixture directory.
# What is under test here is the GH-DEC-2026-003 chain, which still gates
# fully: the lane is prod, so stance, claim, check and consume all apply.
raw["approval"] = {
"model": "bootstrap-only",
"authorization_id": APPROVAL_ID,
"purpose": "end-to-end authorization proof",
}
catalog_dir = tmp_path / "catalog"
catalog_dir.mkdir()
(catalog_dir / f"{raw['id']}.yaml").write_text(yaml.safe_dump(raw))
return catalog_dir, get_entry(catalog_dir, raw["id"])
def _cfg(tmp_path, catalog_dir, stub, bao_addr):
token_file = tmp_path / "authz.token"
token_file.write_text("stub-credential\n")
os.chmod(token_file, 0o600)
return Config(
catalog_dir=catalog_dir,
policy_dir=tmp_path / "policies",
evidence_dir=tmp_path / "evidence",
hub_url="",
bao_addr=bao_addr,
topic_id="t",
approval_url=stub.url,
approval_token_file=token_file,
authorization_subject_id="secrets-engine",
authorization_subject_type="service",
authorization_policy_package=PACKAGE,
authorization_policy_version=VERSION,
pdp_url=stub.url,
pdp_token_file=token_file,
)
def _prime(stub, cfg, entry, action="apply"):
"""Tell the stub which exact request the engine will propose."""
from secrets_engine.approval_consume import _expected_request
stub.bind_request(_expected_request(cfg, entry, action))
def test_full_authorization_chain_reaches_openbao(bao_dev, stub, lane, tmp_path):
addr, root, client = bao_dev
catalog_dir, entry = lane
cfg = _cfg(tmp_path, catalog_dir, stub, addr)
_prime(stub, cfg, entry)
authorization = authorize_action(cfg, entry, "apply", None)
assert authorization is not None
assert authorization.decision_id == "decision:stub-apply"
assert stub.calls == ["claim", "check"], "PIP then PDP, in that order"
# The gate itself: stance satisfied, consume performed, then the backend.
cli._require_lane_approval(cfg, entry, "apply")
assert stub.consumed, "CAS consume must happen before any OpenBao call"
assert stub.calls[-1] == "consume", "consume is the last step before OpenBao"
# And the backend really is reachable with the authorized identity.
assert client.is_reachable()
def test_unreachable_pdp_stops_before_consume(bao_dev, stub, lane, tmp_path):
addr, _root, _client = bao_dev
catalog_dir, entry = lane
cfg = _cfg(tmp_path, catalog_dir, stub, addr)
_prime(stub, cfg, entry)
cfg = Config(**{**cfg.__dict__, "pdp_url": "http://127.0.0.1:1"})
with pytest.raises(DecisionError, match="unreachable"):
cli._require_lane_approval(cfg, entry, "apply")
assert not stub.consumed, "an unreachable PDP must not consume the approval"
def test_deny_decision_stops_before_consume(bao_dev, stub, lane, tmp_path):
addr, _root, _client = bao_dev
catalog_dir, entry = lane
cfg = _cfg(tmp_path, catalog_dir, stub, addr)
_prime(stub, cfg, entry)
stub.effect = "deny"
with pytest.raises(DecisionError, match="effect is not allow"):
cli._require_lane_approval(cfg, entry, "apply")
assert not stub.consumed
def test_invalid_claim_stops_before_the_pdp(bao_dev, stub, lane, tmp_path):
addr, _root, _client = bao_dev
catalog_dir, entry = lane
cfg = _cfg(tmp_path, catalog_dir, stub, addr)
_prime(stub, cfg, entry)
stub.claim_valid_now = False
stub.claim_reason_code = "revoked"
with pytest.raises(DecisionError, match="not valid now"):
cli._require_lane_approval(cfg, entry, "apply")
assert "check" not in stub.calls, "a bad claim must not reach the PDP"
assert not stub.consumed
def test_missing_pdp_digest_stops_before_the_pdp(bao_dev, stub, lane, tmp_path):
addr, _root, _client = bao_dev
catalog_dir, entry = lane
cfg = _cfg(tmp_path, catalog_dir, stub, addr)
_prime(stub, cfg, entry)
stub.include_pdp_digest = False
with pytest.raises(DecisionError, match="no published mapping"):
cli._require_lane_approval(cfg, entry, "apply")
assert not stub.consumed
def test_consume_conflict_stops_before_openbao(bao_dev, stub, lane, tmp_path):
addr, _root, _client = bao_dev
catalog_dir, entry = lane
cfg = _cfg(tmp_path, catalog_dir, stub, addr)
_prime(stub, cfg, entry)
stub.consume_status = 409
with pytest.raises(DecisionError):
cli._require_lane_approval(cfg, entry, "apply")
assert not stub.consumed
def test_action_mismatch_is_caught_by_the_digest(bao_dev, stub, lane, tmp_path):
"""The claim was primed for apply; proposing destroy must not ride it."""
addr, _root, _client = bao_dev
catalog_dir, entry = lane
cfg = _cfg(tmp_path, catalog_dir, stub, addr)
_prime(stub, cfg, entry, action="apply")
with pytest.raises(DecisionError):
cli._require_lane_approval(cfg, entry, "destroy")
assert not stub.consumed
def test_unconfigured_engine_still_fails_closed(bao_dev, lane, tmp_path):
"""The whole point of the default: no serving path means no production."""
addr, _root, _client = bao_dev
catalog_dir, entry = lane
cfg = Config(
catalog_dir=catalog_dir, policy_dir=tmp_path / "p", evidence_dir=tmp_path / "e",
hub_url="", bao_addr=addr, topic_id="t",
)
with pytest.raises(DecisionError, match="live production remains disabled"):
cli._require_lane_approval(cfg, entry, "apply")