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
296 lines
9.3 KiB
Python
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 == []
|