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

@ -22,6 +22,8 @@ Flexibility here would be a defect. Graded, evidence-based progression belongs t
See [INTENT.md](INTENT.md) and [SCOPE.md](SCOPE.md). Declaration: [layer.yaml](layer.yaml).
Claim: [docs/approval-claim.md](docs/approval-claim.md).
Consume: [docs/approval-consumption.md](docs/approval-consumption.md).
Caller auth: [docs/caller-authentication.md](docs/caller-authentication.md).
PEP sequence: [docs/pep-integration.md](docs/pep-integration.md).
Origin: `flex-auth` `FLEX-DEC-2026-001`, raised while assenting to the security
layer model.

View file

@ -96,14 +96,18 @@ systems. Consumption follows the assented `GH-DEC-2026-003` contract.
## Current State
- Status: **first-cut spine**. SQLite-backed object, closed machine, local
outbox, WSGI introspection API, claim contract. Not a production deploy.
- Status: **production-packaged spine, not yet live**. Authenticated HTTP
mutations, schema-v2 SQLite, outbox drain to audit-core, digest-pin-ready
image and single-writer StatefulSet, and a fail-closed PEP harness exist in
this repository. Live rollout, KeyCape audience/client proof, audit-core
sender registration, and a served secrets-engine consume binding remain
external gates on `APPROVAL-WP-0002`.
- Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in
`cadence.yaml`. No Tooling contacts.
- Consumption is public at `POST /v1/approvals/{id}/consume` under
`GH-DEC-2026-003`; the endpoint is a lifecycle mutation, never a decision.
- Taxonomy request-claim schema is still unassigned; the local claim yields.
- Work: `APPROVAL-WP-0001`. Tests: `make test`.
- Work: `APPROVAL-WP-0002`. Tests: `make test`.
## How It Fits

View file

@ -3,17 +3,37 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
from .binding import DIGEST_RE
_MAX_BODY = 256 * 1024
_ID_RE = re.compile(r"^[A-Za-z0-9:._-]+$")
class ApprovalProtocolError(RuntimeError):
pass
def _status_message(status: int) -> str:
if status == 409:
return "approval consume conflict"
if status == 404:
return "approval not found"
if status in {401, 403}:
return "approval consume unauthorized"
if status == 503:
return "approval-engine unavailable"
if status == 0:
return "approval-engine unreachable"
return f"approval endpoint returned status {status}"
class ApprovalHTTPClient:
def __init__(
self,
@ -23,17 +43,23 @@ class ApprovalHTTPClient:
timeout_seconds: float = 3,
opener: Callable[..., Any] = urlopen,
) -> None:
if not base_url or not base_url.startswith(("http://", "https://")):
raise ApprovalProtocolError("approval-engine URL is missing or invalid")
self.base_url = base_url.rstrip("/")
self.token_file = Path(token_file)
self.timeout_seconds = timeout_seconds
self.opener = opener
def _request(
self, method: str, path: str, body: dict[str, Any] | None = None
) -> dict[str, Any]:
def _token(self) -> str:
token = self.token_file.read_text(encoding="utf-8").strip()
if not token or any(ch.isspace() for ch in token):
raise ApprovalProtocolError("approval credential is unavailable")
return token
def _request(
self, method: str, path: str, body: dict[str, Any] | None = None
) -> dict[str, Any]:
token = self._token()
encoded = None if body is None else json.dumps(body).encode("utf-8")
request = Request(
self.base_url + path,
@ -42,19 +68,29 @@ class ApprovalHTTPClient:
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
response = self.opener(request, timeout=self.timeout_seconds)
status = int(response.getcode())
raw = response.read(256 * 1024 + 1)
response.close()
except (HTTPError, URLError, OSError) as exc:
raise ApprovalProtocolError(type(exc).__name__) from exc
if status != 200 or len(raw) > 256 * 1024:
raise ApprovalProtocolError(f"approval endpoint returned status {status}")
try:
status = int(response.getcode())
raw = response.read(_MAX_BODY + 1)
finally:
response.close()
except HTTPError as exc:
status = int(getattr(exc, "code", 0) or 0)
try:
exc.read(_MAX_BODY)
except Exception:
pass
raise ApprovalProtocolError(_status_message(status)) from None
except (URLError, TimeoutError, OSError):
raise ApprovalProtocolError("approval-engine unreachable") from None
if status != 200 or len(raw) > _MAX_BODY:
raise ApprovalProtocolError(_status_message(status if status != 200 else 502))
try:
result = json.loads(raw)
result = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from exc
if not isinstance(result, dict):
@ -62,18 +98,32 @@ class ApprovalHTTPClient:
return result
def claim(self, approval_id: str) -> dict[str, Any]:
if not _ID_RE.fullmatch(approval_id):
raise ApprovalProtocolError("approval id is invalid")
encoded_id = quote(approval_id, safe="")
return self._request("GET", f"/v1/approvals/{encoded_id}/claim")
def consume(
self, approval_id: str, request_digest: str, decision_id: str
self, approval_id: str, request_digest: str, decision_id: str | None = None
) -> dict[str, Any]:
if not _ID_RE.fullmatch(approval_id):
raise ApprovalProtocolError("approval id is invalid")
if not DIGEST_RE.fullmatch(request_digest):
raise ApprovalProtocolError("approval consume requires the canonical request digest")
encoded_id = quote(approval_id, safe="")
return self._request(
payload: dict[str, str] = {"request_digest": request_digest}
if decision_id:
payload["decision_id"] = decision_id
result = self._request(
"POST",
f"/v1/approvals/{encoded_id}/consume",
{"request_digest": request_digest, "decision_id": decision_id},
payload,
)
if result.get("status") != "consumed":
raise ApprovalProtocolError("approval consumption was not confirmed")
if result.get("request_digest") != request_digest:
raise ApprovalProtocolError("approval consume digest does not match the request")
return result
class ProtectedActionHarness:
@ -81,7 +131,7 @@ class ProtectedActionHarness:
The decision callback owns authorization. This class only enforces that a
fresh approval claim precedes it and a successful CAS consume precedes the
side effect.
side effect. The consume response is mutation evidence, never a permission.
"""
def __init__(self, client: ApprovalHTTPClient) -> None:
@ -106,9 +156,6 @@ class ProtectedActionHarness:
if decision.get("request_digest") != request_digest:
raise ApprovalProtocolError("authorization decision digest does not match")
consumed = self.client.consume(approval_id, request_digest, decision_id)
if (
consumed.get("status") != "consumed"
or consumed.get("request_digest") != request_digest
):
raise ApprovalProtocolError("approval consumption was not confirmed")
if "effect" in consumed or "allow" in consumed or "deny" in consumed:
raise ApprovalProtocolError("consume response is not a permission")
return side_effect()

View file

@ -32,3 +32,8 @@ only verifies and enforces them. Requested registrations are:
Client credentials belong in OpenBao/operator custody and must not be placed in
manifests, logs, State Hub, or this repository.
KeyCape's OpenBao service-auth contract currently emits `aud` as the OAuth
`clientId`. That pattern must not be reused here. Tokens presented to this API
MUST have resource-server audience `approval-engine`. Requested non-secret
client fragments are in `docs/keycape-service-registrations.md`.

View file

@ -0,0 +1,67 @@
# Requested KeyCape registrations
Status: requested by `APPROVAL-WP-0002-T01`. Non-secret. KeyCape owns issuance,
client disablement, and the exact claim contract. This file is a consumer
request, not a live registration.
Tokens presented to approval-engine MUST use resource-server audience
`approval-engine`. Do not reuse the OpenBao service-auth pattern that sets
`aud` to the OAuth `clientId`.
Required claims remain those in `docs/caller-authentication.md`: `iss`, `sub`,
`aud`, `exp`, `iat`, `principal_type`, `tenant`, `roles`, `scope`, `assurance`.
`principal_type` for consume callers must be `service` or `agent`.
## Resource server
| Field | Value |
| --- | --- |
| Audience | `approval-engine` |
| Issuer | the deployed KeyCape issuer (manifest uses `https://auth.netkingdom.local`) |
| JWKS | `GET /jwks` on the KeyCape service |
| Scopes | `approval:create`, `approval:read`, `approval:approve`, `approval:revoke`, `approval:supersede`, `approval:consume`, `approval:observe`, `approval:emit` |
## Clients
Confidential client secrets stay in OpenBao/operator custody. `secretRef`
names below are placeholders for that custody path.
```yaml
clients:
- clientId: secrets-engine-approval
displayName: secrets-engine PEP consume client
audience: approval-engine
allowedScopes: [approval:read, approval:consume]
grantTypes: [client_credentials]
clientType: confidential
secretRef: env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET
serviceSubject: service:secrets-engine
principal_type: service
tenant: tenant:coulomb
roles: [secrets-engine]
tokenLifetime: 15m
- clientId: approval-engine-operator
displayName: approval-engine lifecycle operator
audience: approval-engine
allowedScopes:
- approval:create
- approval:read
- approval:approve
- approval:revoke
- approval:supersede
- approval:observe
- approval:emit
grantTypes: [client_credentials]
clientType: confidential
secretRef: env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET
serviceSubject: service:approval-engine-operator
principal_type: service
tenant: tenant:coulomb
roles: [approval-operator]
tokenLifetime: 15m
```
Human approvers use the existing KeyCape human flow with `approval:approve`
only, still with `aud=approval-engine`. They must not receive
`approval:consume`.

View file

@ -14,7 +14,15 @@ and consume conflicts all prevent the callback. A same-digest retry receives
the engine's idempotent success. If the callback fails after consume, the
approval stays spent; there is no unconsume.
The module rereads the mounted bearer-token file on each HTTP request. Its unit
harness uses a dry-run callback and demonstrates the ordering, but live closure
requires the secrets-engine-owned handler to prove that no OpenBao request is
made in every failure case.
The module rereads the mounted bearer-token file on each HTTP request. Conflict,
unavailability, unauthorized, missing, DENY, and digest-mismatch paths all
prevent the callback. Same-digest consume retries are idempotent; a later full
sequence against a spent approval fails at the claim. The consume response is
rejected if it carries decision-shaped keys (`effect` / `allow` / `deny`).
The live HTTP harness in `tests/test_pep.py` drives this sequence against the
real WSGI surface without performing a protected action. secrets-engine owns
the production OpenBao PEP (`src/secrets_engine/approval_consume.py`); that
handler is implemented and proven in-repo for 409 / unreachable / missing
binding. Live closure still needs this service deployed and a durable consume
binding served (`SECRETS-WP-0007-T04` / `SECRETS-WP-0008-T02`).

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 == []

View file

@ -65,9 +65,15 @@ tokens fail closed without mutation.
Repository implementation complete 2026-09-02: RS256/JWKS verification,
issuer/audience/time/profile validation, exact scopes, store-tenant isolation,
verified approver evidence, and a deny-all default are covered by tests. Remains
`progress` until KeyCape owns and proves the production audience/client/scope
registrations.
verified approver evidence, and a deny-all default are covered by tests.
2026-09-02 follow-up: fail-closed coverage now includes wrong signature, HS256,
empty/invalid profile claims, deny-all mutation refusal, human-principal consume
rejection, and production CLI refusal of static tokens / missing audit
delivery. Requested KeyCape registrations are in
`docs/keycape-service-registrations.md` (`aud` MUST be the resource server
`approval-engine`, not the OAuth client id). Remains `progress` until KeyCape
owns and proves those audience/client/scope registrations.
## Harden durable storage and migrations
@ -138,8 +144,12 @@ on `AUDIT-WP-0009-T04/T06/T09`; do not invent that receiver surface here.
Repository implementation complete 2026-09-02: audit-core envelope adaptation,
event-id idempotency, mounted-token reread, accepted/duplicate handling,
retry-attempt/lag metrics, and periodic heartbeats are tested. Waiting on the
audit-core sender registration/ingress and receiver-owned reconciliation work.
retry-attempt/lag metrics, and periodic heartbeats are tested.
2026-09-02 follow-up: drain tests now cover HTTP 200 duplicate as drained and
urllib `HTTPError` 503 as pending. Waiting on the audit-core sender
registration/ingress and receiver-owned reconciliation work
(`AUDIT-WP-0009-T04/T06/T09`).
## Prove one live PEP consumption path
@ -164,4 +174,14 @@ claim the consumer's side effect.
Repository implementation complete 2026-09-02: the HTTP PEP client and
fail-closed sequencing harness prove claim-before-decision and CAS-consume-before
callback, including unavailable, DENY, digest mismatch, and conflict paths.
Waiting on the secrets-engine-owned handler and live no-OpenBao-on-failure proof.
2026-09-02 follow-up: secrets-engine shipped the PEP consume-before-OpenBao
handler (`src/secrets_engine/approval_consume.py`; inbox `0e04b4e2`). This
engine's client now maps HTTP 409/401/403/404/503 and unreachability, requires
the canonical digest, and refuses a decision-shaped consume payload. The
repeatable harness in `tests/test_pep.py` drives the real HTTP surface: first
consume then same-digest retry, different-digest conflict, spent-on-failure
claim refusal, and unreachable-engine callback suppression. Waiting on live
closure: this service deployed (T03) and a durable consume binding served
(`SECRETS-WP-0007-T04` / `SECRETS-WP-0008-T02`). This repo does not claim the
OpenBao side effect.