secrets-engine/tests/test_approval_consume.py

354 lines
11 KiB
Python
Raw Normal View History

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",
feat: implement the PIP claim + validate authorization join resolve_consume_binding was a `return None` stub, so protocol step 1 of docs/approval-consumption.md (GET /v1/approvals/{id}/claim) and the validation join never existed. validate_action_authorization had no caller in src/ at all - it was reachable only from tests. Production fail-closed was correct, but for an undocumented second reason, and WP-0007-T04's "what remains is not local engine work" was wrong. The join now reproduces the exact CheckRequest via build_action_request, fetches the durable ActionAuthorization, and validates request binding, digest, validity, authority, policy pin, and distinct-approver threshold before offering a consume binding. _require_lane_approval threads the exact field set for provision/rotate/verify/exec so the digest covers the real proposed action. Deliberate choices: - The approval-engine object id is never inferred from a State Hub decision UUID; flex-auth stated GET /decisions/{uuid} is not the durable object. - No default policy pin. flex-auth stated secrets-engine.lifecycle/v1 is example vocabulary, not a published package. - A half-configured join raises rather than returning None, so a partial deployment cannot be mistaken for an unconfigured one. Behavior is unchanged today: every new input is absent by default, so production still fails closed and plan/--dry-run still work. 234 tests pass. 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 01:01:55 +02:00
lambda *_args, **_kwargs: _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)
feat: implement the PIP claim + validate authorization join resolve_consume_binding was a `return None` stub, so protocol step 1 of docs/approval-consumption.md (GET /v1/approvals/{id}/claim) and the validation join never existed. validate_action_authorization had no caller in src/ at all - it was reachable only from tests. Production fail-closed was correct, but for an undocumented second reason, and WP-0007-T04's "what remains is not local engine work" was wrong. The join now reproduces the exact CheckRequest via build_action_request, fetches the durable ActionAuthorization, and validates request binding, digest, validity, authority, policy pin, and distinct-approver threshold before offering a consume binding. _require_lane_approval threads the exact field set for provision/rotate/verify/exec so the digest covers the real proposed action. Deliberate choices: - The approval-engine object id is never inferred from a State Hub decision UUID; flex-auth stated GET /decisions/{uuid} is not the durable object. - No default policy pin. flex-auth stated secrets-engine.lifecycle/v1 is example vocabulary, not a published package. - A half-configured join raises rather than returning None, so a partial deployment cannot be mistaken for an unconfigured one. Behavior is unchanged today: every new input is absent by default, so production still fails closed and plan/--dry-run still work. 234 tests pass. 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 01:01:55 +02:00
monkeypatch.setattr(cli, "resolve_consume_binding", lambda *_args, **_kwargs: _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)