approval-engine/tests/test_pep.py
tegwick 2370f69927 Harden the PEP harness and KeyCape registration request
Close remaining in-repo APPROVAL-WP-0002 gaps: drive GH-DEC-2026-003 against
the real HTTP surface, fail closed on JWT/human-consume/static-token paths,
treat audit 200 duplicates as drained, and ask KeyCape for the production
audience and client grants.

Assistant: grok
Assistant-Session: 01a06253-e557-7971-93d9-4f4c2cfbf455
2026-09-02 15:46:06 +02:00

296 lines
9.3 KiB
Python

import json
import threading
from io import BytesIO
from urllib.error import HTTPError, URLError
from wsgiref.simple_server import make_server
import pytest
from approval_engine.api import App, call
from approval_engine.auth import Identity, StaticTokenAuthenticator
from approval_engine.pep import (
ApprovalHTTPClient,
ApprovalProtocolError,
ProtectedActionHarness,
)
from approval_engine.store import Engine
from tests.conftest import FROZEN, binding, validity
DIGEST = "sha256:" + "ab" * 32
OTHER = "sha256:" + "cd" * 32
class Client:
def __init__(self, claim=None, consume=None, failure=None):
self.claim_result = claim or {"valid_now": True, "consumed": False}
self.consume_result = consume
self.failure = failure
self.calls = []
def claim(self, approval_id):
self.calls.append("claim")
if self.failure == "claim":
raise ApprovalProtocolError("down")
return self.claim_result
def consume(self, approval_id, digest, decision_id):
self.calls.append("consume")
if self.failure == "consume":
raise ApprovalProtocolError("conflict")
return self.consume_result or {"status": "consumed", "request_digest": digest}
class Response:
def __init__(self, body, status=200):
self.body = json.dumps(body).encode()
self.status = status
def getcode(self):
return self.status
def read(self, _size):
return self.body
def close(self):
pass
def _token(tmp_path, value="test-token"):
path = tmp_path / "token"
path.write_text(value)
return path
def allow(_claim):
return {"effect": "ALLOW", "decision_id": "decision:1", "request_digest": DIGEST}
def test_http_client_rereads_mounted_token(tmp_path):
token = _token(tmp_path, "first")
seen = []
def opener(request, timeout):
seen.append((request.get_header("Authorization"), timeout))
return Response({"valid_now": True, "consumed": False})
client = ApprovalHTTPClient("http://approval-engine:8080", token, opener=opener)
client.claim("approval-1")
token.write_text("second")
client.claim("approval-1")
assert [item[0] for item in seen] == ["Bearer first", "Bearer second"]
def test_side_effect_occurs_only_after_claim_decision_and_consume():
client = Client()
order = client.calls
result = ProtectedActionHarness(client).execute(
"approval:1",
DIGEST,
lambda claim: (order.append("decision"), allow(claim))[1],
lambda: (order.append("side-effect"), "dry-run-only")[1],
)
assert result == "dry-run-only"
assert order == ["claim", "decision", "consume", "side-effect"]
@pytest.mark.parametrize("failure", ["claim", "consume"])
def test_unavailable_or_conflicting_engine_prevents_side_effect(failure):
client = Client(failure=failure)
effects = []
with pytest.raises(ApprovalProtocolError):
ProtectedActionHarness(client).execute(
"approval:1", DIGEST, allow, lambda: effects.append("called")
)
assert effects == []
def test_deny_or_digest_mismatch_prevents_consume_and_side_effect():
for decision in (
{"effect": "DENY", "decision_id": "decision:1", "request_digest": DIGEST},
{"effect": "ALLOW", "decision_id": "decision:1", "request_digest": OTHER},
):
client = Client()
effects = []
with pytest.raises(ApprovalProtocolError):
ProtectedActionHarness(client).execute(
"approval:1",
DIGEST,
lambda _claim, value=decision: value,
lambda: effects.append("called"),
)
assert client.calls == ["claim"]
assert effects == []
def test_consume_http_errors_fail_closed(tmp_path):
def opener(request, timeout):
raise HTTPError(
request.full_url,
409,
"conflict",
hdrs=None,
fp=BytesIO(b'{"error":"conflict"}'),
)
client = ApprovalHTTPClient("http://approval.test", _token(tmp_path), opener=opener)
with pytest.raises(ApprovalProtocolError, match="conflict"):
client.consume("approval-1", DIGEST, "decision:1")
def test_unreachable_engine_fails_closed(tmp_path):
def opener(request, timeout):
raise URLError("down")
client = ApprovalHTTPClient("http://approval.test", _token(tmp_path), opener=opener)
with pytest.raises(ApprovalProtocolError, match="unreachable"):
client.claim("approval-1")
def test_consume_rejects_non_canonical_digest_before_http(tmp_path):
client = ApprovalHTTPClient(
"http://approval.test",
_token(tmp_path),
opener=lambda *_args, **_kwargs: pytest.fail("must not HTTP"),
)
with pytest.raises(ApprovalProtocolError, match="canonical request digest"):
client.consume("approval-1", "not-a-digest", "decision:1")
def test_consume_does_not_treat_decision_shaped_payload_as_permission(tmp_path):
def opener(request, timeout):
if request.get_method() == "GET":
return Response({"valid_now": True, "consumed": False})
return Response(
{
"status": "consumed",
"request_digest": DIGEST,
"effect": "ALLOW",
}
)
client = ApprovalHTTPClient("http://approval.test", _token(tmp_path), opener=opener)
effects = []
with pytest.raises(ApprovalProtocolError, match="not a permission"):
ProtectedActionHarness(client).execute(
"approval-1", DIGEST, allow, lambda: effects.append("called")
)
assert effects == []
@pytest.fixture
def live_http(tmp_path):
engine = Engine(tmp_path / "approval.sqlite", clock=lambda: FROZEN)
identity = Identity(
subject="agt-secrets-engine",
issuer="https://keycape.example",
audiences=("approval-engine",),
principal_type="service",
tenant="platform",
roles=frozenset({"secrets-engine"}),
scopes=frozenset(
{
"approval:create",
"approval:read",
"approval:approve",
"approval:revoke",
"approval:supersede",
"approval:consume",
"approval:observe",
"approval:emit",
}
),
assurance={"level": "aal1", "methods": ["test"], "source": "test"},
evidence_ref="test-identity:test-token",
)
app = App(engine, StaticTokenAuthenticator({"test-token": identity}))
server = make_server("127.0.0.1", 0, app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", app
finally:
server.shutdown()
thread.join(timeout=2)
engine.close()
def _approved(app):
_, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
)
aid = created["id"]
call(app, "POST", f"/v1/approvals/{aid}/entries", {})
return aid
def test_live_http_pep_sequence_consumes_before_callback(live_http, tmp_path):
base_url, app = live_http
aid = _approved(app)
client = ApprovalHTTPClient(base_url, _token(tmp_path))
order = []
result = ProtectedActionHarness(client).execute(
aid,
DIGEST,
lambda claim: (
order.append("decision"),
{
"effect": "ALLOW",
"decision_id": "decision:live",
"request_digest": DIGEST,
},
)[1],
lambda: (order.append("side-effect"), "dry-run-only")[1],
)
assert result == "dry-run-only"
assert order == ["decision", "side-effect"]
retry = client.consume(aid, DIGEST, "decision:live")
assert retry["status"] == "consumed"
assert retry["idempotent"] is True
assert retry["request_digest"] == DIGEST
status, claim = call(app, "GET", f"/v1/approvals/{aid}/claim")
assert status == 200
assert claim["consumed"] is True
assert claim["valid_now"] is False
def test_live_http_conflict_and_spent_approval_prevent_side_effect(live_http, tmp_path):
base_url, app = live_http
aid = _approved(app)
client = ApprovalHTTPClient(base_url, _token(tmp_path))
ProtectedActionHarness(client).execute(
aid,
DIGEST,
lambda _claim: {
"effect": "ALLOW",
"decision_id": "decision:live",
"request_digest": DIGEST,
},
lambda: "first",
)
effects = []
with pytest.raises(ApprovalProtocolError, match="conflict"):
client.consume(aid, OTHER, "decision:other")
with pytest.raises(ApprovalProtocolError, match="not valid for use"):
ProtectedActionHarness(client).execute(
aid,
DIGEST,
lambda _claim: pytest.fail("must not decide"),
lambda: effects.append("called"),
)
assert effects == []
def test_live_http_unavailability_prevents_side_effect(tmp_path):
client = ApprovalHTTPClient("http://127.0.0.1:1", _token(tmp_path), timeout_seconds=0.2)
effects = []
with pytest.raises(ApprovalProtocolError, match="unreachable"):
ProtectedActionHarness(client).execute(
"approval-missing",
DIGEST,
allow,
lambda: effects.append("called"),
)
assert effects == []