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
This commit is contained in:
parent
e8144f315c
commit
f62d3fe789
9 changed files with 675 additions and 44 deletions
236
tests/test_integration_authorization.py
Normal file
236
tests/test_integration_authorization.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue