diff --git a/README.md b/README.md index 4f2d8ae..535bfb2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SCOPE.md b/SCOPE.md index 3ea6b2b..2d12235 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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 diff --git a/approval_engine/pep.py b/approval_engine/pep.py index 269476a..5c6ced7 100644 --- a/approval_engine/pep.py +++ b/approval_engine/pep.py @@ -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() diff --git a/docs/caller-authentication.md b/docs/caller-authentication.md index 240c482..f5a5d1b 100644 --- a/docs/caller-authentication.md +++ b/docs/caller-authentication.md @@ -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`. diff --git a/docs/keycape-service-registrations.md b/docs/keycape-service-registrations.md new file mode 100644 index 0000000..f4e2b5b --- /dev/null +++ b/docs/keycape-service-registrations.md @@ -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`. diff --git a/docs/pep-integration.md b/docs/pep-integration.md index 2ef3d7c..1a0f352 100644 --- a/docs/pep-integration.md +++ b/docs/pep-integration.md @@ -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`). diff --git a/tests/test_audit.py b/tests/test_audit.py index cb34aed..a2eba0e 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -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") diff --git a/tests/test_auth.py b/tests/test_auth.py index a8cdf2d..ee786c7 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 63d197a..245441b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_pep.py b/tests/test_pep.py index 6c09816..f496fc6 100644 --- a/tests/test_pep.py +++ b/tests/test_pep.py @@ -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 == [] diff --git a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md index ec8af8c..52ebf5e 100644 --- a/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md +++ b/workplans/APPROVAL-WP-0002-production-readiness-and-consumer-adoption.md @@ -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.