237 lines
8 KiB
Python
237 lines
8 KiB
Python
|
|
"""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")
|