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
This commit is contained in:
tegwick 2026-09-02 15:46:06 +02:00
parent 2bd2d19a98
commit 2370f69927
11 changed files with 588 additions and 46 deletions

View file

@ -45,6 +45,41 @@ def test_audit_sender_adapts_envelope_and_rereads_token(tmp_path):
engine.close()
def test_duplicate_audit_status_marks_drained(tmp_path):
token = tmp_path / "token"
token.write_text("token")
engine = Engine(":memory:", clock=lambda: FROZEN)
approve(engine)
sink = AuditCoreSink(
"http://audit-core:8080", token, opener=lambda *_args, **_kwargs: Response(200)
)
result = engine.drain(sink)
assert result == {"delivered": 1, "failed": 0}
assert engine.undrained() == []
engine.close()
def test_httperror_audit_status_remains_pending(tmp_path):
from io import BytesIO
from urllib.error import HTTPError
token = tmp_path / "token"
token.write_text("token")
engine = Engine(":memory:", clock=lambda: FROZEN)
approve(engine)
def opener(request, timeout):
raise HTTPError(
request.full_url, 503, "unavailable", hdrs=None, fp=BytesIO(b"no")
)
sink = AuditCoreSink("http://audit-core:8080", token, opener=opener)
result = engine.drain(sink)
assert result == {"delivered": 0, "failed": 1}
assert engine.undrained()[0]["last_error"] == "AuditDeliveryError"
engine.close()
def test_nonaccepted_audit_status_remains_pending(tmp_path):
token = tmp_path / "token"
token.write_text("token")

View file

@ -64,6 +64,9 @@ def test_jwt_authenticator_verifies_signature_issuer_audience_and_claims():
{"iss": "https://wrong.example"},
{"aud": "somewhere-else"},
{"exp": 1},
{"scope": ""},
{"principal_type": "unknown"},
{"assurance": "not-an-object"},
],
)
def test_jwt_authenticator_fails_closed(claims):
@ -78,6 +81,37 @@ def test_jwt_authenticator_fails_closed(claims):
auth.authenticate("Bearer " + _jwt(private, **claims))
def test_jwt_authenticator_rejects_wrong_signature_and_hs256():
private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
auth = JWTAuthenticator(
issuer="https://keycape.example",
audience="approval-engine",
jwks_url="https://keycape.example/jwks",
jwks_client=_JWKS(private.public_key()),
)
with pytest.raises(Unauthenticated):
auth.authenticate("Bearer " + _jwt(other))
hs = jwt.encode(
{
"iss": "https://keycape.example",
"sub": "service:secrets-engine",
"aud": "approval-engine",
"exp": 2**31 - 1,
"iat": 1,
"tenant": "tenant:coulomb",
"principal_type": "service",
"roles": ["secrets-engine"],
"scope": "approval:read approval:consume",
"assurance": {"level": "aal1"},
},
"not-an-rsa-key",
algorithm="HS256",
)
with pytest.raises(Unauthenticated):
auth.authenticate("Bearer " + hs)
def test_api_requires_scope_and_binds_create_actor(engine):
identity = Identity(
subject="service:creator",
@ -165,3 +199,84 @@ def test_wrong_tenant_is_forbidden(engine):
status, body = call(app, "GET", "/v1/cadence", authorization="Bearer wrong")
assert status == 403
assert body["error"] == "forbidden"
def test_deny_all_default_does_not_mutate(engine):
from approval_engine.api import App
app = App(engine)
status, body = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
authorization="Bearer anything",
)
assert status == 401
assert body["error"] == "unauthenticated"
assert engine.transition_counts()["issuance"] == 0
def test_human_principal_cannot_consume(engine):
from approval_engine.api import App
service = Identity(
subject="agt-secrets-engine",
issuer="test",
audiences=("approval-engine",),
principal_type="service",
tenant="platform",
roles=frozenset(),
scopes=frozenset(
{"approval:create", "approval:approve", "approval:read", "approval:consume"}
),
assurance={"level": "aal1"},
evidence_ref="service",
)
human = Identity(
subject="user:alice",
issuer="test",
audiences=("approval-engine",),
principal_type="human",
tenant="platform",
roles=frozenset(),
scopes=frozenset({"approval:consume"}),
assurance={"level": "aal2"},
evidence_ref="human",
)
app = App(
engine,
StaticTokenAuthenticator({"service": service, "human": human}),
)
_, created = call(
app,
"POST",
"/v1/approvals",
{"binding": binding(), "validity": validity()},
authorization="Bearer service",
)
call(
app,
"POST",
f"/v1/approvals/{created['id']}/entries",
{},
authorization="Bearer service",
)
status, body = call(
app,
"POST",
f"/v1/approvals/{created['id']}/consume",
{"request_digest": "sha256:" + "ab" * 32},
authorization="Bearer human",
)
assert status == 403
assert body["error"] == "forbidden"
status, claim = call(
app,
"GET",
f"/v1/approvals/{created['id']}/claim",
authorization="Bearer service",
)
assert status == 200
assert claim["consumed"] is False
assert claim["valid_now"] is True

View file

@ -20,3 +20,51 @@ def test_migrate_verify_and_backup_commands(tmp_path, capsys):
def test_production_refuses_memory_store_before_serving():
with pytest.raises(SystemExit):
main(["serve", "--production", "--db", ":memory:"])
def test_production_requires_jwt_verifier_not_static_token(tmp_path, capsys):
database = tmp_path / "approval.sqlite"
assert main(["migrate", "--db", str(database)]) == 0
capsys.readouterr()
audit = tmp_path / "audit.token"
audit.write_text("audit")
static = tmp_path / "dev.token"
static.write_text("dev")
with pytest.raises(SystemExit):
main(
[
"serve",
"--production",
"--db",
str(database),
"--audit-url",
"http://audit-core:8080",
"--audit-token-file",
str(audit),
"--dev-token-file",
str(static),
]
)
assert "KeyCape JWT verifier" in capsys.readouterr().err
def test_production_requires_authenticated_audit_delivery(tmp_path, capsys):
database = tmp_path / "approval.sqlite"
assert main(["migrate", "--db", str(database)]) == 0
capsys.readouterr()
with pytest.raises(SystemExit):
main(
[
"serve",
"--production",
"--db",
str(database),
"--jwt-issuer",
"https://keycape.example",
"--jwt-audience",
"approval-engine",
"--jwks-url",
"https://keycape.example/jwks",
]
)
assert "authenticated audit delivery" in capsys.readouterr().err

View file

@ -1,12 +1,23 @@
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:
@ -29,15 +40,13 @@ class Client:
return self.consume_result or {"status": "consumed", "request_digest": digest}
DIGEST = "sha256:" + "ab" * 32
class Response:
def __init__(self, body):
def __init__(self, body, status=200):
self.body = json.dumps(body).encode()
self.status = status
def getcode(self):
return 200
return self.status
def read(self, _size):
return self.body
@ -46,9 +55,18 @@ class Response:
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 = tmp_path / "token"
token.write_text("first")
token = _token(tmp_path, "first")
seen = []
def opener(request, timeout):
@ -62,10 +80,6 @@ def test_http_client_rereads_mounted_token(tmp_path):
assert [item[0] for item in seen] == ["Bearer first", "Bearer second"]
def allow(_claim):
return {"effect": "ALLOW", "decision_id": "decision:1", "request_digest": DIGEST}
def test_side_effect_occurs_only_after_claim_decision_and_consume():
client = Client()
order = client.calls
@ -93,13 +107,190 @@ def test_unavailable_or_conflicting_engine_prevents_side_effect(failure):
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": "sha256:" + "cd" * 32},
{"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")
"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 == []