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).
|
See [INTENT.md](INTENT.md) and [SCOPE.md](SCOPE.md). Declaration: [layer.yaml](layer.yaml).
|
||||||
Claim: [docs/approval-claim.md](docs/approval-claim.md).
|
Claim: [docs/approval-claim.md](docs/approval-claim.md).
|
||||||
Consume: [docs/approval-consumption.md](docs/approval-consumption.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
|
Origin: `flex-auth` `FLEX-DEC-2026-001`, raised while assenting to the security
|
||||||
layer model.
|
layer model.
|
||||||
|
|
||||||
|
|
|
||||||
10
SCOPE.md
10
SCOPE.md
|
|
@ -96,14 +96,18 @@ systems. Consumption follows the assented `GH-DEC-2026-003` contract.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- Status: **first-cut spine**. SQLite-backed object, closed machine, local
|
- Status: **production-packaged spine, not yet live**. Authenticated HTTP
|
||||||
outbox, WSGI introspection API, claim contract. Not a production deploy.
|
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
|
- Layer declaration: INTENT frontmatter + `layer.yaml`. Cadence declared in
|
||||||
`cadence.yaml`. No Tooling contacts.
|
`cadence.yaml`. No Tooling contacts.
|
||||||
- Consumption is public at `POST /v1/approvals/{id}/consume` under
|
- Consumption is public at `POST /v1/approvals/{id}/consume` under
|
||||||
`GH-DEC-2026-003`; the endpoint is a lifecycle mutation, never a decision.
|
`GH-DEC-2026-003`; the endpoint is a lifecycle mutation, never a decision.
|
||||||
- Taxonomy request-claim schema is still unassigned; the local claim yields.
|
- 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
|
## How It Fits
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,37 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from urllib.request import Request, urlopen
|
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):
|
class ApprovalProtocolError(RuntimeError):
|
||||||
pass
|
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:
|
class ApprovalHTTPClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -23,17 +43,23 @@ class ApprovalHTTPClient:
|
||||||
timeout_seconds: float = 3,
|
timeout_seconds: float = 3,
|
||||||
opener: Callable[..., Any] = urlopen,
|
opener: Callable[..., Any] = urlopen,
|
||||||
) -> None:
|
) -> 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.base_url = base_url.rstrip("/")
|
||||||
self.token_file = Path(token_file)
|
self.token_file = Path(token_file)
|
||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
self.opener = opener
|
self.opener = opener
|
||||||
|
|
||||||
def _request(
|
def _token(self) -> str:
|
||||||
self, method: str, path: str, body: dict[str, Any] | None = None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
token = self.token_file.read_text(encoding="utf-8").strip()
|
token = self.token_file.read_text(encoding="utf-8").strip()
|
||||||
if not token or any(ch.isspace() for ch in token):
|
if not token or any(ch.isspace() for ch in token):
|
||||||
raise ApprovalProtocolError("approval credential is unavailable")
|
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")
|
encoded = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
request = Request(
|
request = Request(
|
||||||
self.base_url + path,
|
self.base_url + path,
|
||||||
|
|
@ -42,19 +68,29 @@ class ApprovalHTTPClient:
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = self.opener(request, timeout=self.timeout_seconds)
|
response = self.opener(request, timeout=self.timeout_seconds)
|
||||||
status = int(response.getcode())
|
try:
|
||||||
raw = response.read(256 * 1024 + 1)
|
status = int(response.getcode())
|
||||||
response.close()
|
raw = response.read(_MAX_BODY + 1)
|
||||||
except (HTTPError, URLError, OSError) as exc:
|
finally:
|
||||||
raise ApprovalProtocolError(type(exc).__name__) from exc
|
response.close()
|
||||||
if status != 200 or len(raw) > 256 * 1024:
|
except HTTPError as exc:
|
||||||
raise ApprovalProtocolError(f"approval endpoint returned status {status}")
|
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:
|
try:
|
||||||
result = json.loads(raw)
|
result = json.loads(raw.decode("utf-8"))
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from exc
|
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from exc
|
||||||
if not isinstance(result, dict):
|
if not isinstance(result, dict):
|
||||||
|
|
@ -62,18 +98,32 @@ class ApprovalHTTPClient:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def claim(self, approval_id: str) -> dict[str, Any]:
|
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="")
|
encoded_id = quote(approval_id, safe="")
|
||||||
return self._request("GET", f"/v1/approvals/{encoded_id}/claim")
|
return self._request("GET", f"/v1/approvals/{encoded_id}/claim")
|
||||||
|
|
||||||
def consume(
|
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]:
|
) -> 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="")
|
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",
|
"POST",
|
||||||
f"/v1/approvals/{encoded_id}/consume",
|
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:
|
class ProtectedActionHarness:
|
||||||
|
|
@ -81,7 +131,7 @@ class ProtectedActionHarness:
|
||||||
|
|
||||||
The decision callback owns authorization. This class only enforces that a
|
The decision callback owns authorization. This class only enforces that a
|
||||||
fresh approval claim precedes it and a successful CAS consume precedes the
|
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:
|
def __init__(self, client: ApprovalHTTPClient) -> None:
|
||||||
|
|
@ -106,9 +156,6 @@ class ProtectedActionHarness:
|
||||||
if decision.get("request_digest") != request_digest:
|
if decision.get("request_digest") != request_digest:
|
||||||
raise ApprovalProtocolError("authorization decision digest does not match")
|
raise ApprovalProtocolError("authorization decision digest does not match")
|
||||||
consumed = self.client.consume(approval_id, request_digest, decision_id)
|
consumed = self.client.consume(approval_id, request_digest, decision_id)
|
||||||
if (
|
if "effect" in consumed or "allow" in consumed or "deny" in consumed:
|
||||||
consumed.get("status") != "consumed"
|
raise ApprovalProtocolError("consume response is not a permission")
|
||||||
or consumed.get("request_digest") != request_digest
|
|
||||||
):
|
|
||||||
raise ApprovalProtocolError("approval consumption was not confirmed")
|
|
||||||
return side_effect()
|
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
|
Client credentials belong in OpenBao/operator custody and must not be placed in
|
||||||
manifests, logs, State Hub, or this repository.
|
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
|
the engine's idempotent success. If the callback fails after consume, the
|
||||||
approval stays spent; there is no unconsume.
|
approval stays spent; there is no unconsume.
|
||||||
|
|
||||||
The module rereads the mounted bearer-token file on each HTTP request. Its unit
|
The module rereads the mounted bearer-token file on each HTTP request. Conflict,
|
||||||
harness uses a dry-run callback and demonstrates the ordering, but live closure
|
unavailability, unauthorized, missing, DENY, and digest-mismatch paths all
|
||||||
requires the secrets-engine-owned handler to prove that no OpenBao request is
|
prevent the callback. Same-digest consume retries are idempotent; a later full
|
||||||
made in every failure case.
|
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()
|
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):
|
def test_nonaccepted_audit_status_remains_pending(tmp_path):
|
||||||
token = tmp_path / "token"
|
token = tmp_path / "token"
|
||||||
token.write_text("token")
|
token.write_text("token")
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,9 @@ def test_jwt_authenticator_verifies_signature_issuer_audience_and_claims():
|
||||||
{"iss": "https://wrong.example"},
|
{"iss": "https://wrong.example"},
|
||||||
{"aud": "somewhere-else"},
|
{"aud": "somewhere-else"},
|
||||||
{"exp": 1},
|
{"exp": 1},
|
||||||
|
{"scope": ""},
|
||||||
|
{"principal_type": "unknown"},
|
||||||
|
{"assurance": "not-an-object"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_jwt_authenticator_fails_closed(claims):
|
def test_jwt_authenticator_fails_closed(claims):
|
||||||
|
|
@ -78,6 +81,37 @@ def test_jwt_authenticator_fails_closed(claims):
|
||||||
auth.authenticate("Bearer " + _jwt(private, **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):
|
def test_api_requires_scope_and_binds_create_actor(engine):
|
||||||
identity = Identity(
|
identity = Identity(
|
||||||
subject="service:creator",
|
subject="service:creator",
|
||||||
|
|
@ -165,3 +199,84 @@ def test_wrong_tenant_is_forbidden(engine):
|
||||||
status, body = call(app, "GET", "/v1/cadence", authorization="Bearer wrong")
|
status, body = call(app, "GET", "/v1/cadence", authorization="Bearer wrong")
|
||||||
assert status == 403
|
assert status == 403
|
||||||
assert body["error"] == "forbidden"
|
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():
|
def test_production_refuses_memory_store_before_serving():
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
main(["serve", "--production", "--db", ":memory:"])
|
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 json
|
||||||
|
import threading
|
||||||
|
from io import BytesIO
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from wsgiref.simple_server import make_server
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from approval_engine.api import App, call
|
||||||
|
from approval_engine.auth import Identity, StaticTokenAuthenticator
|
||||||
from approval_engine.pep import (
|
from approval_engine.pep import (
|
||||||
ApprovalHTTPClient,
|
ApprovalHTTPClient,
|
||||||
ApprovalProtocolError,
|
ApprovalProtocolError,
|
||||||
ProtectedActionHarness,
|
ProtectedActionHarness,
|
||||||
)
|
)
|
||||||
|
from approval_engine.store import Engine
|
||||||
|
from tests.conftest import FROZEN, binding, validity
|
||||||
|
|
||||||
|
DIGEST = "sha256:" + "ab" * 32
|
||||||
|
OTHER = "sha256:" + "cd" * 32
|
||||||
|
|
||||||
|
|
||||||
class Client:
|
class Client:
|
||||||
|
|
@ -29,15 +40,13 @@ class Client:
|
||||||
return self.consume_result or {"status": "consumed", "request_digest": digest}
|
return self.consume_result or {"status": "consumed", "request_digest": digest}
|
||||||
|
|
||||||
|
|
||||||
DIGEST = "sha256:" + "ab" * 32
|
|
||||||
|
|
||||||
|
|
||||||
class Response:
|
class Response:
|
||||||
def __init__(self, body):
|
def __init__(self, body, status=200):
|
||||||
self.body = json.dumps(body).encode()
|
self.body = json.dumps(body).encode()
|
||||||
|
self.status = status
|
||||||
|
|
||||||
def getcode(self):
|
def getcode(self):
|
||||||
return 200
|
return self.status
|
||||||
|
|
||||||
def read(self, _size):
|
def read(self, _size):
|
||||||
return self.body
|
return self.body
|
||||||
|
|
@ -46,9 +55,18 @@ class Response:
|
||||||
pass
|
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):
|
def test_http_client_rereads_mounted_token(tmp_path):
|
||||||
token = tmp_path / "token"
|
token = _token(tmp_path, "first")
|
||||||
token.write_text("first")
|
|
||||||
seen = []
|
seen = []
|
||||||
|
|
||||||
def opener(request, timeout):
|
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"]
|
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():
|
def test_side_effect_occurs_only_after_claim_decision_and_consume():
|
||||||
client = Client()
|
client = Client()
|
||||||
order = client.calls
|
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():
|
def test_deny_or_digest_mismatch_prevents_consume_and_side_effect():
|
||||||
for decision in (
|
for decision in (
|
||||||
{"effect": "DENY", "decision_id": "decision:1", "request_digest": DIGEST},
|
{"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()
|
client = Client()
|
||||||
effects = []
|
effects = []
|
||||||
with pytest.raises(ApprovalProtocolError):
|
with pytest.raises(ApprovalProtocolError):
|
||||||
ProtectedActionHarness(client).execute(
|
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 client.calls == ["claim"]
|
||||||
assert effects == []
|
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,
|
Repository implementation complete 2026-09-02: RS256/JWKS verification,
|
||||||
issuer/audience/time/profile validation, exact scopes, store-tenant isolation,
|
issuer/audience/time/profile validation, exact scopes, store-tenant isolation,
|
||||||
verified approver evidence, and a deny-all default are covered by tests. Remains
|
verified approver evidence, and a deny-all default are covered by tests.
|
||||||
`progress` until KeyCape owns and proves the production audience/client/scope
|
|
||||||
registrations.
|
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
|
## 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,
|
Repository implementation complete 2026-09-02: audit-core envelope adaptation,
|
||||||
event-id idempotency, mounted-token reread, accepted/duplicate handling,
|
event-id idempotency, mounted-token reread, accepted/duplicate handling,
|
||||||
retry-attempt/lag metrics, and periodic heartbeats are tested. Waiting on the
|
retry-attempt/lag metrics, and periodic heartbeats are tested.
|
||||||
audit-core sender registration/ingress and receiver-owned reconciliation work.
|
|
||||||
|
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
|
## 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
|
Repository implementation complete 2026-09-02: the HTTP PEP client and
|
||||||
fail-closed sequencing harness prove claim-before-decision and CAS-consume-before
|
fail-closed sequencing harness prove claim-before-decision and CAS-consume-before
|
||||||
callback, including unavailable, DENY, digest mismatch, and conflict paths.
|
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