secrets-engine/tests/test_approval_consume.py
tegwick 4b4d556d62
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Implement GH-DEC-2026-003 consume-before-OpenBao PEP gate
Every live privileged production handler CAS-consumes through
approval-engine before OpenBao. Conflict, unavailability, or a missing
binding fail closed. Live production remains disabled until the durable
decision record is served.

Record kings-guard assent on the secret-use evidence contract.

Assistant: grok
Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00
2026-09-02 01:06:50 +02:00

353 lines
11 KiB
Python

import copy
import json
from io import BytesIO
from types import SimpleNamespace
from urllib.error import HTTPError, URLError
from urllib.request import Request
import pytest
from secrets_engine import cli
from secrets_engine.approval_consume import (
ConsumeBinding,
consume_approval,
require_production_consume,
resolve_consume_binding,
)
from secrets_engine.catalog import validate_entry
from secrets_engine.config import Config
from secrets_engine.errors import DecisionError, ProvisioningError
from secrets_engine.pep_stance import StanceApplication
from tests.test_catalog import VALID
DIGEST = "sha256:" + ("ab" * 32)
TOKEN = "test-approval-consume-credential"
def _binding(**overrides):
payload = {
"approval_id": "appr_test-1",
"request_digest": DIGEST,
"decision_id": "decision:test",
}
payload.update(overrides)
return ConsumeBinding(**payload)
def _token_file(tmp_path, value=TOKEN, mode=0o600):
path = tmp_path / "approval.token"
path.write_text(value, encoding="utf-8")
path.chmod(mode)
return path
def _consumed_payload(**overrides):
payload = {
"approval_id": "appr_test-1",
"status": "consumed",
"request_digest": DIGEST,
"decision_id": "decision:test",
"consumed_at": "2026-09-02T12:00:00Z",
"idempotent": False,
}
payload.update(overrides)
return payload
class _Response:
def __init__(self, status, payload):
self._status = status
self._raw = json.dumps(payload).encode("utf-8")
def getcode(self):
return self._status
def read(self, _n=-1):
return self._raw
def close(self):
return None
def _http_error(status):
return HTTPError(
"http://approval.test/v1/approvals/appr_test-1/consume",
status,
"error",
hdrs=None,
fp=BytesIO(b'{"error":"no"}'),
)
def test_resolve_consume_binding_is_unserved():
assert resolve_consume_binding(object(), object(), "apply", None) is None
def test_consume_success_and_same_digest_retry(tmp_path):
seen = []
def opener(request, timeout):
seen.append((request.full_url, request.get_header("Authorization"), timeout))
assert request.get_method() == "POST"
body = json.loads(request.data.decode())
assert body["request_digest"] == DIGEST
assert body["decision_id"] == "decision:test"
return _Response(200, _consumed_payload(idempotent=bool(seen[1:])))
first = consume_approval(
base_url="http://approval.test",
token_file=_token_file(tmp_path),
binding=_binding(),
opener=opener,
)
retry = consume_approval(
base_url="http://approval.test",
token_file=_token_file(tmp_path),
binding=_binding(),
opener=opener,
)
assert first.request_digest == DIGEST
assert first.idempotent is False
assert retry.idempotent is True
assert TOKEN not in repr(first)
assert all(call[1] == f"Bearer {TOKEN}" for call in seen)
@pytest.mark.parametrize(
"status,match",
[
(409, "conflict"),
(404, "not found"),
(401, "unauthorized"),
(403, "unauthorized"),
(503, "unavailable"),
(500, "failed"),
],
)
def test_consume_http_errors_fail_closed(tmp_path, status, match):
def opener(request, timeout):
raise _http_error(status)
with pytest.raises(DecisionError, match=match) as raised:
consume_approval(
base_url="http://approval.test",
token_file=_token_file(tmp_path),
binding=_binding(),
opener=opener,
)
assert "OpenBao must not be called" in str(raised.value)
assert TOKEN not in str(raised.value)
def test_consume_unreachable_fails_closed(tmp_path):
def opener(request, timeout):
raise URLError("down")
with pytest.raises(DecisionError, match="unreachable"):
consume_approval(
base_url="http://approval.test",
token_file=_token_file(tmp_path),
binding=_binding(),
opener=opener,
)
def test_consume_rejects_digest_mismatch_and_non_consumed_status(tmp_path):
def mismatch(request, timeout):
return _Response(200, _consumed_payload(request_digest="sha256:" + ("cd" * 32)))
def not_consumed(request, timeout):
return _Response(200, _consumed_payload(status="approved"))
with pytest.raises(DecisionError, match="digest does not match"):
consume_approval(
base_url="http://approval.test",
token_file=_token_file(tmp_path),
binding=_binding(),
opener=mismatch,
)
with pytest.raises(DecisionError, match="not confirmed"):
consume_approval(
base_url="http://approval.test",
token_file=_token_file(tmp_path),
binding=_binding(),
opener=not_consumed,
)
def test_consume_does_not_treat_effect_as_permission(tmp_path):
def opener(request, timeout):
return _Response(
200,
_consumed_payload(effect="deny", allow=False, decision="no"),
)
consumed = consume_approval(
base_url="http://approval.test",
token_file=_token_file(tmp_path),
binding=_binding(),
opener=opener,
)
assert consumed.request_digest == DIGEST
assert "effect" not in consumed.as_evidence()
def test_token_file_guards(tmp_path):
with pytest.raises(ProvisioningError, match="must be 0600"):
consume_approval(
base_url="http://approval.test",
token_file=_token_file(tmp_path, mode=0o644),
binding=_binding(),
opener=lambda *_args, **_kwargs: pytest.fail("must not HTTP"),
)
with pytest.raises(DecisionError, match="canonical request digest"):
consume_approval(
base_url="http://approval.test",
token_file=_token_file(tmp_path),
binding=_binding(request_digest="not-a-digest"),
opener=lambda *_args, **_kwargs: pytest.fail("must not HTTP"),
)
def test_require_production_consume_skips_non_prod_and_demo(tmp_path, monkeypatch):
test_entry = validate_entry(copy.deepcopy(VALID))
prod_entry = validate_entry(dict(VALID, stage="prod"))
cfg = SimpleNamespace(
hub_url="",
bao_addr="http://127.0.0.1:8200",
approval_url="http://approval.test",
approval_token_file=_token_file(tmp_path),
)
assert require_production_consume(cfg, test_entry, binding=_binding()) is None
monkeypatch.setenv("SECRETS_ENGINE_UNSAFE_DEMO", "1")
assert require_production_consume(cfg, prod_entry, binding=_binding()) is None
def test_require_production_consume_fails_closed_without_binding():
entry = validate_entry(dict(VALID, stage="prod"))
cfg = SimpleNamespace(
hub_url="http://127.0.0.1:8000",
bao_addr="http://127.0.0.1:8200",
approval_url="http://approval.test",
approval_token_file=None,
)
with pytest.raises(DecisionError, match="no durable consume binding"):
require_production_consume(cfg, entry, binding=None)
def _config(tmp_path, **overrides):
values = dict(
catalog_dir=tmp_path,
policy_dir=tmp_path,
evidence_dir=tmp_path / "evidence",
hub_url="",
bao_addr="http://127.0.0.1:8200",
topic_id="test-topic",
approval_url="http://approval.test",
approval_token_file=_token_file(tmp_path),
)
values.update(overrides)
return Config(**values)
def _provision_args(entry):
return SimpleNamespace(
catalog_id=entry.id,
stage=entry.stage,
field="api_token",
generate=False,
from_file="/tmp/test-value-file",
bootstrap_token_file=None,
)
def _allow_prod_stance(_cfg, _entry, action, **_kwargs):
return StanceApplication(
stage="prod",
failure_mode="fail_closed",
action=action,
demo_exception=False,
)
def test_stance_bypass_still_cannot_reach_openbao_without_consume(
tmp_path, monkeypatch
):
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
monkeypatch.setattr(
cli.OpenBaoClient,
"resolve",
lambda *_args, **_kwargs: pytest.fail("backend must not be reached"),
)
with pytest.raises(DecisionError, match="no durable consume binding"):
cli.cmd_provision(_config(tmp_path), _provision_args(entry))
def test_consume_conflict_prevents_openbao(tmp_path, monkeypatch):
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance)
monkeypatch.setattr(
cli,
"resolve_consume_binding",
lambda *_args: _binding(),
)
monkeypatch.setattr(
"secrets_engine.approval_consume.urlopen",
lambda *_args, **_kwargs: (_ for _ in ()).throw(_http_error(409)),
)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
monkeypatch.setattr(
cli.OpenBaoClient,
"resolve",
lambda *_args, **_kwargs: pytest.fail("backend must not be reached"),
)
with pytest.raises(DecisionError, match="conflict"):
cli.cmd_provision(_config(tmp_path), _provision_args(entry))
records = [
json.loads(line)
for line in next((tmp_path / "evidence").glob("evidence-*.jsonl")).read_text().splitlines()
]
assert [record["result"] for record in records] == [
"attempt",
"failed-DecisionError",
]
assert TOKEN not in json.dumps(records)
def test_confirmed_consume_allows_openbao_resolve(tmp_path, monkeypatch):
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
reached = {}
def opener(request, timeout):
assert isinstance(request, Request)
return _Response(200, _consumed_payload())
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance)
monkeypatch.setattr(cli, "resolve_consume_binding", lambda *_args: _binding())
monkeypatch.setattr("secrets_engine.approval_consume.urlopen", opener)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
monkeypatch.setattr(
cli.OpenBaoClient,
"resolve",
lambda *_args, **_kwargs: reached.setdefault("openbao", True) or object(),
)
monkeypatch.setattr(
cli,
"provision_from_file",
lambda *_args, **_kwargs: "api_token",
)
rc = cli.cmd_provision(_config(tmp_path), _provision_args(entry))
assert rc == 0
assert reached.get("openbao") is True
records = [
json.loads(line)
for line in next((tmp_path / "evidence").glob("evidence-*.jsonl")).read_text().splitlines()
]
assert records[-1]["detail"]["approval_consumed"] is True
assert records[-1]["detail"]["approval_id"] == "appr_test-1"
assert TOKEN not in json.dumps(records)