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
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
10
SCOPE.md
10
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
67
docs/keycape-service-registrations.md
Normal file
67
docs/keycape-service-registrations.md
Normal 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`.
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue