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:
parent
2bd2d19a98
commit
2370f69927
11 changed files with 588 additions and 46 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue