Implement approval engine production readiness
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a05e2e-805b-7042-a750-71f473bceea2
This commit is contained in:
parent
ebce5abb27
commit
2bd2d19a98
30 changed files with 1679 additions and 53 deletions
|
|
@ -5,8 +5,10 @@ from .errors import (
|
|||
ApprovalError,
|
||||
Conflict,
|
||||
DuplicateApprover,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
StoreUnavailable,
|
||||
Unauthenticated,
|
||||
Unprocessable,
|
||||
)
|
||||
from .store import Engine
|
||||
|
|
@ -16,8 +18,10 @@ __all__ = [
|
|||
"ApprovalError",
|
||||
"Conflict",
|
||||
"DuplicateApprover",
|
||||
"Forbidden",
|
||||
"NotFound",
|
||||
"StoreUnavailable",
|
||||
"Unauthenticated",
|
||||
"Unprocessable",
|
||||
"binding_digest",
|
||||
"canonical_binding",
|
||||
|
|
|
|||
|
|
@ -5,17 +5,25 @@ from __future__ import annotations
|
|||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
from .errors import ApprovalError
|
||||
from .auth import Authenticator, DenyAllAuthenticator, Identity
|
||||
from .errors import ApprovalError, Forbidden
|
||||
from .store import Engine
|
||||
|
||||
FORBIDDEN_DECISION_KEYS = frozenset({"effect", "decision", "allow", "deny"})
|
||||
|
||||
|
||||
def _read_json(environ: dict[str, Any]) -> dict[str, Any]:
|
||||
length = int(environ.get("CONTENT_LENGTH") or 0)
|
||||
try:
|
||||
length = int(environ.get("CONTENT_LENGTH") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ApprovalError("invalid content length") from exc
|
||||
if length < 0 or length > 256 * 1024:
|
||||
raise ApprovalError("request body is too large")
|
||||
if length == 0:
|
||||
return {}
|
||||
raw = environ["wsgi.input"].read(length)
|
||||
if len(raw) != length:
|
||||
raise ApprovalError("truncated request body")
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
|
|
@ -40,8 +48,24 @@ def _assert_not_decision(payload: Any) -> None:
|
|||
|
||||
|
||||
class App:
|
||||
def __init__(self, engine: Engine) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
authenticator: Authenticator | None = None,
|
||||
*,
|
||||
require_persistent: bool = False,
|
||||
) -> None:
|
||||
self.engine = engine
|
||||
self.authenticator = authenticator or DenyAllAuthenticator()
|
||||
self.require_persistent = require_persistent
|
||||
|
||||
def identity(self, environ: dict[str, Any], scope: str) -> Identity:
|
||||
identity = self.authenticator.authenticate(
|
||||
environ.get("HTTP_AUTHORIZATION")
|
||||
).require(scope)
|
||||
if identity.tenant != self.engine.tenant:
|
||||
raise Forbidden("caller tenant does not match this approval store")
|
||||
return identity
|
||||
|
||||
def __call__(self, environ: dict[str, Any], start_response: Callable) -> list[bytes]:
|
||||
method = environ.get("REQUEST_METHOD", "GET").upper()
|
||||
|
|
@ -64,19 +88,38 @@ class App:
|
|||
return [payload]
|
||||
|
||||
def dispatch(self, method: str, path: str, environ: dict[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
if path in ("/healthz", "/v1/healthz") and method == "GET":
|
||||
return 200, {"status": "ok"}
|
||||
if path in ("/readyz", "/v1/readyz") and method == "GET":
|
||||
storage = self.engine.storage_status()
|
||||
ready = storage["schema_current"] and (
|
||||
storage["persistent"] or not self.require_persistent
|
||||
)
|
||||
self.engine.outbox_stats()
|
||||
return 200, {"status": "ok", "store": "ok"}
|
||||
return (200 if ready else 503), {
|
||||
"status": "ok" if ready else "unavailable",
|
||||
"store": "ok" if ready else "not-production-ready",
|
||||
}
|
||||
if path == "/v1/storage/status" and method == "GET":
|
||||
self.identity(environ, "approval:observe")
|
||||
return 200, self.engine.storage_status(integrity=True)
|
||||
if path == "/v1/cadence" and method == "GET":
|
||||
self.identity(environ, "approval:observe")
|
||||
return 200, self.engine.transition_counts() | {"form": "heartbeat-or-reconciliation"}
|
||||
if path == "/v1/outbox/stats" and method == "GET":
|
||||
self.identity(environ, "approval:observe")
|
||||
return 200, self.engine.outbox_stats()
|
||||
if path == "/v1/heartbeat" and method == "POST":
|
||||
self.identity(environ, "approval:emit")
|
||||
return 200, self.engine.emit_heartbeat()
|
||||
if path == "/v1/approvals" and method == "POST":
|
||||
identity = self.identity(environ, "approval:create")
|
||||
data = _read_json(environ)
|
||||
binding = data.get("binding") or {}
|
||||
if binding.get("actor") != identity.subject:
|
||||
raise Forbidden("binding.actor must match the authenticated subject")
|
||||
obj = self.engine.create(
|
||||
data.get("binding") or {},
|
||||
binding,
|
||||
data.get("validity") or {},
|
||||
int(data.get("required_count") or 1),
|
||||
pdp_digest=data.get("pdp_digest"),
|
||||
|
|
@ -88,24 +131,32 @@ class App:
|
|||
approval_id = parts[2]
|
||||
rest = parts[3:]
|
||||
if not rest and method == "GET":
|
||||
self.identity(environ, "approval:read")
|
||||
return 200, self.engine.get(approval_id).as_dict()
|
||||
if rest == ["claim"] and method == "GET":
|
||||
self.identity(environ, "approval:read")
|
||||
return 200, self.engine.claim(approval_id)
|
||||
if rest == ["entries"] and method == "POST":
|
||||
data = _read_json(environ)
|
||||
identity = self.identity(environ, "approval:approve")
|
||||
_read_json(environ)
|
||||
obj = self.engine.add_entry(
|
||||
approval_id,
|
||||
data.get("subject_id") or "",
|
||||
assurance=data.get("assurance"),
|
||||
evidence_ref=data.get("evidence_ref"),
|
||||
identity.subject,
|
||||
assurance=json.dumps(identity.assurance, sort_keys=True),
|
||||
evidence_ref=identity.evidence_ref,
|
||||
)
|
||||
return 200, obj.as_dict()
|
||||
if rest == ["revoke"] and method == "POST":
|
||||
self.identity(environ, "approval:revoke")
|
||||
return 200, self.engine.revoke(approval_id).as_dict()
|
||||
if rest == ["supersede"] and method == "POST":
|
||||
self.identity(environ, "approval:supersede")
|
||||
data = _read_json(environ)
|
||||
return 200, self.engine.supersede(approval_id, data.get("successor_id"))
|
||||
if rest == ["consume"] and method == "POST":
|
||||
identity = self.identity(environ, "approval:consume")
|
||||
if identity.principal_type not in {"service", "agent"}:
|
||||
raise Forbidden("consume requires a service or agent principal")
|
||||
data = _read_json(environ)
|
||||
return 200, self.engine.consume(
|
||||
approval_id,
|
||||
|
|
@ -117,7 +168,14 @@ class App:
|
|||
return 404, {"error": "not_found", "message": path}
|
||||
|
||||
|
||||
def call(app: App, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
|
||||
def call(
|
||||
app: App,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
*,
|
||||
authorization: str | None = "Bearer test-token",
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
"""In-process WSGI helper for tests."""
|
||||
raw = json.dumps(body or {}).encode("utf-8") if body is not None else b""
|
||||
environ = {
|
||||
|
|
@ -127,6 +185,8 @@ def call(app: App, method: str, path: str, body: dict[str, Any] | None = None) -
|
|||
"CONTENT_LENGTH": str(len(raw)) if body is not None else "0",
|
||||
"QUERY_STRING": "",
|
||||
}
|
||||
if authorization is not None:
|
||||
environ["HTTP_AUTHORIZATION"] = authorization
|
||||
status_headers: list[tuple[str, list]] = []
|
||||
|
||||
def start_response(status: str, headers: list[tuple[str, str]]) -> None:
|
||||
|
|
|
|||
100
approval_engine/audit.py
Normal file
100
approval_engine/audit.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Asynchronous delivery of the transactional outbox to audit-core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .store import Engine
|
||||
|
||||
|
||||
class AuditDeliveryError(RuntimeError):
|
||||
"""Bounded delivery failure; details deliberately exclude response bodies."""
|
||||
|
||||
|
||||
def audit_envelope(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
details = dict(payload.get("details") or {})
|
||||
return {
|
||||
"id": payload["event_id"],
|
||||
"type": payload["action"],
|
||||
"source": payload["source"],
|
||||
"subject": payload["resource"],
|
||||
"tenant": payload["tenant"],
|
||||
"correlation_id": details.get("approval_id") or payload["event_id"],
|
||||
"occurred_at": payload["observed_at"],
|
||||
"data": {
|
||||
"schema_version": payload["schema_version"],
|
||||
"scope": payload["scope"],
|
||||
"actor": payload.get("actor"),
|
||||
"resource": payload["resource"],
|
||||
"outcome": payload["outcome"],
|
||||
"reason": payload.get("reason"),
|
||||
"details": details,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class AuditCoreSink:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token_file: str | Path,
|
||||
*,
|
||||
timeout_seconds: float = 5,
|
||||
opener: Callable[..., Any] = urlopen,
|
||||
) -> None:
|
||||
self.url = base_url.rstrip("/") + "/v1/events"
|
||||
self.token_file = Path(token_file)
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.opener = opener
|
||||
|
||||
def __call__(self, payload: dict[str, Any]) -> None:
|
||||
token = self.token_file.read_text(encoding="utf-8").strip()
|
||||
if not token or any(ch.isspace() for ch in token):
|
||||
raise AuditDeliveryError("audit credential is unavailable")
|
||||
body = json.dumps(audit_envelope(payload), sort_keys=True).encode("utf-8")
|
||||
request = Request(
|
||||
self.url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": payload["event_id"],
|
||||
},
|
||||
)
|
||||
try:
|
||||
response = self.opener(request, timeout=self.timeout_seconds)
|
||||
status = int(response.getcode())
|
||||
response.close()
|
||||
except (HTTPError, URLError, OSError) as exc:
|
||||
raise AuditDeliveryError(type(exc).__name__) from exc
|
||||
if status not in {200, 202}:
|
||||
raise AuditDeliveryError(f"audit ingest returned status {status}")
|
||||
|
||||
|
||||
class OutboxWorker:
|
||||
def __init__(
|
||||
self,
|
||||
engine: Engine,
|
||||
sink: Callable[[dict[str, Any]], None],
|
||||
*,
|
||||
heartbeat_interval_seconds: int = 300,
|
||||
) -> None:
|
||||
self.engine = engine
|
||||
self.sink = sink
|
||||
self.heartbeat_interval_seconds = heartbeat_interval_seconds
|
||||
|
||||
def run_once(self) -> dict[str, int]:
|
||||
if self.engine.heartbeat_due(self.heartbeat_interval_seconds):
|
||||
self.engine.emit_heartbeat()
|
||||
return self.engine.drain(self.sink)
|
||||
|
||||
def run_forever(self, stop: threading.Event, poll_seconds: float = 5) -> None:
|
||||
while not stop.is_set():
|
||||
self.run_once()
|
||||
stop.wait(poll_seconds)
|
||||
169
approval_engine/auth.py
Normal file
169
approval_engine/auth.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Verified caller identity for approval-engine's HTTP boundary.
|
||||
|
||||
The engine enforces scopes issued by the identity owner; it does not decide who
|
||||
ought to hold them. Production uses KeyCape's RS256/JWKS contract. Tests use an
|
||||
explicit static verifier so no unverified claim can accidentally become a
|
||||
production fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
import jwt
|
||||
|
||||
from .errors import Forbidden, Unauthenticated
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
subject: str
|
||||
issuer: str
|
||||
audiences: tuple[str, ...]
|
||||
principal_type: str
|
||||
tenant: str
|
||||
roles: frozenset[str]
|
||||
scopes: frozenset[str]
|
||||
assurance: dict[str, Any]
|
||||
evidence_ref: str
|
||||
|
||||
def require(self, scope: str) -> "Identity":
|
||||
if scope not in self.scopes:
|
||||
raise Forbidden(f"caller lacks required scope {scope!r}")
|
||||
return self
|
||||
|
||||
|
||||
class Authenticator(Protocol):
|
||||
def authenticate(self, authorization: str | None) -> Identity: ...
|
||||
|
||||
|
||||
def _bearer(authorization: str | None) -> str:
|
||||
value = str(authorization or "")
|
||||
if not value.startswith("Bearer "):
|
||||
raise Unauthenticated("bearer token is required")
|
||||
token = value[7:].strip()
|
||||
if not token or any(ch.isspace() for ch in token):
|
||||
raise Unauthenticated("bearer token is invalid")
|
||||
return token
|
||||
|
||||
|
||||
def _texts(value: object, name: str, *, allow_empty: bool = False) -> tuple[str, ...]:
|
||||
if isinstance(value, str):
|
||||
items = tuple(item for item in value.split() if item)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
items = tuple(item for item in value if isinstance(item, str) and item)
|
||||
if len(items) != len(value):
|
||||
raise Unauthenticated(f"token claim {name!r} is invalid")
|
||||
else:
|
||||
raise Unauthenticated(f"token claim {name!r} is invalid")
|
||||
if not items and not allow_empty:
|
||||
raise Unauthenticated(f"token claim {name!r} is empty")
|
||||
return items
|
||||
|
||||
|
||||
def identity_from_claims(claims: Mapping[str, Any], token: str) -> Identity:
|
||||
try:
|
||||
subject = str(claims["sub"])
|
||||
issuer = str(claims["iss"])
|
||||
principal_type = str(claims["principal_type"])
|
||||
tenant = str(claims["tenant"])
|
||||
assurance = claims["assurance"]
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise Unauthenticated("token is missing required identity claims") from exc
|
||||
if not subject or not issuer or principal_type not in {"human", "service", "agent"}:
|
||||
raise Unauthenticated("token identity claims are invalid")
|
||||
if not tenant or not isinstance(assurance, dict):
|
||||
raise Unauthenticated("token tenant or assurance claim is invalid")
|
||||
audiences = _texts(claims.get("aud"), "aud")
|
||||
roles = frozenset(_texts(claims.get("roles"), "roles", allow_empty=True))
|
||||
scopes = frozenset(_texts(claims.get("scope", claims.get("scp")), "scope"))
|
||||
fingerprint = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
return Identity(
|
||||
subject=subject,
|
||||
issuer=issuer,
|
||||
audiences=audiences,
|
||||
principal_type=principal_type,
|
||||
tenant=tenant,
|
||||
roles=roles,
|
||||
scopes=scopes,
|
||||
assurance=dict(assurance),
|
||||
evidence_ref=f"jwt-sha256:{fingerprint}",
|
||||
)
|
||||
|
||||
|
||||
class JWTAuthenticator:
|
||||
"""Verify KeyCape JWT signature, issuer, audience, time, and profile claims."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
issuer: str,
|
||||
audience: str,
|
||||
jwks_url: str,
|
||||
leeway_seconds: int = 30,
|
||||
timeout_seconds: int = 3,
|
||||
jwks_client: Any | None = None,
|
||||
) -> None:
|
||||
if not issuer or not audience or not jwks_url:
|
||||
raise ValueError("issuer, audience, and jwks_url are required")
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.leeway_seconds = leeway_seconds
|
||||
self.jwks_client = jwks_client or jwt.PyJWKClient(
|
||||
jwks_url,
|
||||
cache_keys=True,
|
||||
cache_jwk_set=True,
|
||||
lifespan=300,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
def authenticate(self, authorization: str | None) -> Identity:
|
||||
token = _bearer(authorization)
|
||||
try:
|
||||
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=self.issuer,
|
||||
audience=self.audience,
|
||||
leeway=self.leeway_seconds,
|
||||
options={
|
||||
"require": [
|
||||
"iss",
|
||||
"sub",
|
||||
"aud",
|
||||
"exp",
|
||||
"iat",
|
||||
"tenant",
|
||||
"principal_type",
|
||||
"roles",
|
||||
"scope",
|
||||
"assurance",
|
||||
]
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
raise Unauthenticated("token verification failed") from exc
|
||||
return identity_from_claims(claims, token)
|
||||
|
||||
|
||||
class StaticTokenAuthenticator:
|
||||
"""Explicit test/development verifier; never selected implicitly."""
|
||||
|
||||
def __init__(self, identities: Mapping[str, Identity]) -> None:
|
||||
self.identities = dict(identities)
|
||||
|
||||
def authenticate(self, authorization: str | None) -> Identity:
|
||||
token = _bearer(authorization)
|
||||
identity = self.identities.get(token)
|
||||
if identity is None:
|
||||
raise Unauthenticated("token verification failed")
|
||||
return identity
|
||||
|
||||
|
||||
class DenyAllAuthenticator:
|
||||
def authenticate(self, authorization: str | None) -> Identity:
|
||||
raise Unauthenticated("caller authentication is not configured")
|
||||
|
|
@ -1,26 +1,154 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from wsgiref.simple_server import make_server
|
||||
|
||||
from .api import App
|
||||
from .audit import AuditCoreSink, OutboxWorker
|
||||
from .auth import Identity, JWTAuthenticator, StaticTokenAuthenticator
|
||||
from .store import Engine
|
||||
|
||||
ALL_SCOPES = frozenset(
|
||||
{
|
||||
"approval:observe",
|
||||
"approval:emit",
|
||||
"approval:create",
|
||||
"approval:read",
|
||||
"approval:approve",
|
||||
"approval:revoke",
|
||||
"approval:supersede",
|
||||
"approval:consume",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _common_db(sub: argparse.ArgumentParser) -> None:
|
||||
sub.add_argument("--db", default="approvals.sqlite")
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="approval-engine")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
serve = sub.add_parser("serve", help="serve the authenticated HTTP API")
|
||||
_common_db(serve)
|
||||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8787)
|
||||
serve.add_argument("--production", action="store_true")
|
||||
serve.add_argument("--tenant", default="platform")
|
||||
serve.add_argument("--jwt-issuer")
|
||||
serve.add_argument("--jwt-audience")
|
||||
serve.add_argument("--jwks-url")
|
||||
serve.add_argument("--dev-token-file")
|
||||
serve.add_argument("--audit-url")
|
||||
serve.add_argument("--audit-token-file")
|
||||
serve.add_argument("--outbox-poll-seconds", type=float, default=5)
|
||||
serve.add_argument("--heartbeat-seconds", type=int, default=86400)
|
||||
migrate = sub.add_parser("migrate", help="apply repeatable schema migrations")
|
||||
_common_db(migrate)
|
||||
verify = sub.add_parser("verify", help="verify schema and SQLite integrity")
|
||||
_common_db(verify)
|
||||
backup = sub.add_parser("backup", help="create and verify an online backup")
|
||||
_common_db(backup)
|
||||
backup.add_argument("--output", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def _authenticator(args: argparse.Namespace, parser: argparse.ArgumentParser):
|
||||
jwt_values = (args.jwt_issuer, args.jwt_audience, args.jwks_url)
|
||||
if all(jwt_values):
|
||||
return JWTAuthenticator(
|
||||
issuer=args.jwt_issuer,
|
||||
audience=args.jwt_audience,
|
||||
jwks_url=args.jwks_url,
|
||||
)
|
||||
if any(jwt_values):
|
||||
parser.error("--jwt-issuer, --jwt-audience, and --jwks-url are a set")
|
||||
if args.production:
|
||||
parser.error("production requires KeyCape JWT verifier configuration")
|
||||
if not args.dev_token_file:
|
||||
parser.error("configure JWT verification or explicit --dev-token-file")
|
||||
token = Path(args.dev_token_file).read_text(encoding="utf-8").strip()
|
||||
if not token:
|
||||
parser.error("development token file is empty")
|
||||
identity = Identity(
|
||||
subject="development-agent",
|
||||
issuer="local-development",
|
||||
audiences=("approval-engine",),
|
||||
principal_type="agent",
|
||||
tenant="development",
|
||||
roles=frozenset({"developer"}),
|
||||
scopes=ALL_SCOPES,
|
||||
assurance={"method": "explicit-development-token"},
|
||||
evidence_ref="local-development-token",
|
||||
)
|
||||
return StaticTokenAuthenticator({token: identity})
|
||||
|
||||
|
||||
def _serve(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
|
||||
if args.production and args.db == ":memory:":
|
||||
parser.error("production requires a persistent database")
|
||||
if bool(args.audit_url) != bool(args.audit_token_file):
|
||||
parser.error("--audit-url and --audit-token-file are a set")
|
||||
if args.production and not args.audit_url:
|
||||
parser.error("production requires authenticated audit delivery")
|
||||
authenticator = _authenticator(args, parser)
|
||||
engine = Engine(args.db, auto_migrate=not args.production, tenant=args.tenant)
|
||||
storage = engine.storage_status(integrity=True)
|
||||
if args.production and (not storage["ok"] or not storage["persistent"]):
|
||||
parser.error("production store is not ready; run migrate and verify")
|
||||
app = App(engine, authenticator, require_persistent=args.production)
|
||||
stop = threading.Event()
|
||||
if args.audit_url:
|
||||
sink = AuditCoreSink(args.audit_url, args.audit_token_file)
|
||||
worker = OutboxWorker(
|
||||
engine,
|
||||
sink,
|
||||
heartbeat_interval_seconds=args.heartbeat_seconds,
|
||||
)
|
||||
threading.Thread(
|
||||
target=worker.run_forever,
|
||||
args=(stop, args.outbox_poll_seconds),
|
||||
daemon=True,
|
||||
name="approval-outbox",
|
||||
).start()
|
||||
print(f"approval-engine listening on {args.host}:{args.port} db={args.db}")
|
||||
try:
|
||||
if args.production:
|
||||
from waitress import serve
|
||||
|
||||
serve(app, host=args.host, port=args.port, threads=4)
|
||||
else:
|
||||
make_server(args.host, args.port, app).serve_forever()
|
||||
finally:
|
||||
stop.set()
|
||||
engine.close()
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="approval-engine")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
serve = sub.add_parser("serve", help="serve the introspection API")
|
||||
serve.add_argument("--db", default="approvals.sqlite")
|
||||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8787)
|
||||
parser = _parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.cmd == "serve":
|
||||
engine = Engine(args.db)
|
||||
httpd = make_server(args.host, args.port, App(engine))
|
||||
print(f"approval-engine on http://{args.host}:{args.port} db={args.db}")
|
||||
httpd.serve_forever()
|
||||
if args.cmd == "migrate":
|
||||
engine = Engine(args.db, auto_migrate=True)
|
||||
print(json.dumps(engine.storage_status(integrity=True), sort_keys=True))
|
||||
engine.close()
|
||||
return 0
|
||||
if args.cmd == "verify":
|
||||
engine = Engine(args.db, auto_migrate=False)
|
||||
status = engine.storage_status(integrity=True)
|
||||
print(json.dumps(status, sort_keys=True))
|
||||
engine.close()
|
||||
return 0 if status["ok"] else 1
|
||||
if args.cmd == "backup":
|
||||
engine = Engine(args.db, auto_migrate=False)
|
||||
print(json.dumps(engine.backup(args.output), sort_keys=True))
|
||||
engine.close()
|
||||
return 0
|
||||
if args.cmd == "serve":
|
||||
return _serve(args, parser)
|
||||
return 2
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,16 @@ class ApprovalError(Exception):
|
|||
reason_code = "error"
|
||||
|
||||
|
||||
class Unauthenticated(ApprovalError):
|
||||
http_status = 401
|
||||
reason_code = "unauthenticated"
|
||||
|
||||
|
||||
class Forbidden(ApprovalError):
|
||||
http_status = 403
|
||||
reason_code = "forbidden"
|
||||
|
||||
|
||||
class NotFound(ApprovalError):
|
||||
http_status = 404
|
||||
reason_code = "not_found"
|
||||
|
|
|
|||
114
approval_engine/pep.py
Normal file
114
approval_engine/pep.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Fail-closed PEP client and sequencing harness for GH-DEC-2026-003."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
class ApprovalProtocolError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ApprovalHTTPClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token_file: str | Path,
|
||||
*,
|
||||
timeout_seconds: float = 3,
|
||||
opener: Callable[..., Any] = urlopen,
|
||||
) -> None:
|
||||
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]:
|
||||
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")
|
||||
encoded = None if body is None else json.dumps(body).encode("utf-8")
|
||||
request = Request(
|
||||
self.base_url + path,
|
||||
data=encoded,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "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:
|
||||
result = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ApprovalProtocolError("approval endpoint returned invalid JSON") from exc
|
||||
if not isinstance(result, dict):
|
||||
raise ApprovalProtocolError("approval endpoint returned invalid payload")
|
||||
return result
|
||||
|
||||
def claim(self, approval_id: str) -> dict[str, Any]:
|
||||
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
|
||||
) -> dict[str, Any]:
|
||||
encoded_id = quote(approval_id, safe="")
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/v1/approvals/{encoded_id}/consume",
|
||||
{"request_digest": request_digest, "decision_id": decision_id},
|
||||
)
|
||||
|
||||
|
||||
class ProtectedActionHarness:
|
||||
"""Sequence a supplied PDP decision and dry-run/protected callback.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, client: ApprovalHTTPClient) -> None:
|
||||
self.client = client
|
||||
|
||||
def execute(
|
||||
self,
|
||||
approval_id: str,
|
||||
request_digest: str,
|
||||
decide: Callable[[dict[str, Any]], dict[str, Any]],
|
||||
side_effect: Callable[[], Any],
|
||||
) -> Any:
|
||||
claim = self.client.claim(approval_id)
|
||||
if claim.get("valid_now") is not True or claim.get("consumed") is not False:
|
||||
raise ApprovalProtocolError("approval claim is not valid for use")
|
||||
decision = decide(claim)
|
||||
if decision.get("effect") != "ALLOW":
|
||||
raise ApprovalProtocolError("authorization decision did not allow")
|
||||
decision_id = decision.get("decision_id")
|
||||
if not isinstance(decision_id, str) or not decision_id:
|
||||
raise ApprovalProtocolError("authorization decision lacks decision_id")
|
||||
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")
|
||||
return side_effect()
|
||||
|
|
@ -8,8 +8,10 @@ request digest before the protected side effect.
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -33,6 +35,7 @@ AUDIT_SCHEMA = "audit-core.event.v1alpha1"
|
|||
SOURCE = "approval-engine"
|
||||
SCOPE = "netkingdom-approvals"
|
||||
EVENT_CLASSES = ("issuance", "use", "supersession", "revocation", "heartbeat")
|
||||
LATEST_SCHEMA_VERSION = 2
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS approvals (
|
||||
|
|
@ -70,7 +73,10 @@ CREATE TABLE IF NOT EXISTS outbox (
|
|||
approval_id TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
drained_at TEXT
|
||||
drained_at TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at TEXT,
|
||||
last_error TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
|
@ -157,12 +163,14 @@ class Engine:
|
|||
freshness_ttl: int = DEFAULT_FRESHNESS_TTL,
|
||||
fail_outbox: bool = False,
|
||||
tenant: str = "platform",
|
||||
auto_migrate: bool = True,
|
||||
) -> None:
|
||||
self.path = str(path)
|
||||
self.clock = clock
|
||||
self.freshness_ttl = freshness_ttl
|
||||
self.fail_outbox = fail_outbox
|
||||
self.tenant = tenant
|
||||
self.auto_migrate = auto_migrate
|
||||
self._local = threading.local()
|
||||
self._init_schema()
|
||||
|
||||
|
|
@ -188,13 +196,40 @@ class Engine:
|
|||
def _init_schema(self) -> None:
|
||||
conn = self._conn()
|
||||
try:
|
||||
current = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
||||
if not self.auto_migrate:
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
if current != LATEST_SCHEMA_VERSION:
|
||||
raise StoreUnavailable(
|
||||
f"database schema version {current} is not {LATEST_SCHEMA_VERSION}; "
|
||||
"run approval-engine migrate"
|
||||
)
|
||||
if not {"approvals", "entries", "outbox"} <= tables:
|
||||
raise StoreUnavailable("database schema is incomplete")
|
||||
return
|
||||
conn.executescript(SCHEMA)
|
||||
columns = {
|
||||
approval_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(approvals)").fetchall()
|
||||
}
|
||||
for name in ("consumed_digest", "consumed_decision_id", "consumed_at"):
|
||||
if name not in columns:
|
||||
if name not in approval_columns:
|
||||
conn.execute(f"ALTER TABLE approvals ADD COLUMN {name} TEXT")
|
||||
outbox_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(outbox)").fetchall()
|
||||
}
|
||||
if "attempts" not in outbox_columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE outbox ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
for name in ("last_attempt_at", "last_error"):
|
||||
if name not in outbox_columns:
|
||||
conn.execute(f"ALTER TABLE outbox ADD COLUMN {name} TEXT")
|
||||
conn.execute(f"PRAGMA user_version={LATEST_SCHEMA_VERSION}")
|
||||
conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
|
|
@ -208,6 +243,82 @@ class Engine:
|
|||
def now(self) -> datetime:
|
||||
return utc_now(self.clock)
|
||||
|
||||
def storage_status(self, *, integrity: bool = False) -> dict[str, Any]:
|
||||
conn = self._conn()
|
||||
try:
|
||||
version = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
||||
result: dict[str, Any] = {
|
||||
"schema_version": version,
|
||||
"expected_schema_version": LATEST_SCHEMA_VERSION,
|
||||
"schema_current": version == LATEST_SCHEMA_VERSION,
|
||||
"persistent": self.path != ":memory:",
|
||||
}
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
result["required_tables"] = {
|
||||
name: name in tables for name in ("approvals", "entries", "outbox")
|
||||
}
|
||||
if integrity:
|
||||
check = [
|
||||
row[0] for row in conn.execute("PRAGMA integrity_check").fetchall()
|
||||
]
|
||||
foreign_keys = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
result["integrity"] = check
|
||||
result["foreign_key_violations"] = len(foreign_keys)
|
||||
result["ok"] = (
|
||||
check == ["ok"]
|
||||
and not foreign_keys
|
||||
and result["schema_current"]
|
||||
and all(result["required_tables"].values())
|
||||
)
|
||||
return result
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
|
||||
def backup(self, output: str | Path) -> dict[str, Any]:
|
||||
if self.path == ":memory:":
|
||||
raise Unprocessable("an in-memory database cannot be backed up")
|
||||
target = Path(output)
|
||||
if target.exists():
|
||||
raise Conflict(f"backup target already exists: {target}")
|
||||
if not target.parent.exists():
|
||||
raise Unprocessable(f"backup parent does not exist: {target.parent}")
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
prefix=target.name + ".",
|
||||
suffix=".tmp",
|
||||
dir=target.parent,
|
||||
delete=False,
|
||||
)
|
||||
temporary = Path(handle.name)
|
||||
handle.close()
|
||||
os.chmod(temporary, 0o600)
|
||||
destination: sqlite3.Connection | None = None
|
||||
try:
|
||||
destination = sqlite3.connect(temporary)
|
||||
self._conn().backup(destination)
|
||||
destination.close()
|
||||
destination = None
|
||||
check = sqlite3.connect(temporary)
|
||||
try:
|
||||
integrity = [
|
||||
row[0] for row in check.execute("PRAGMA integrity_check")
|
||||
]
|
||||
finally:
|
||||
check.close()
|
||||
if integrity != ["ok"]:
|
||||
raise StoreUnavailable("backup integrity verification failed")
|
||||
os.replace(temporary, target)
|
||||
except Exception:
|
||||
if destination is not None:
|
||||
destination.close()
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
return {"path": str(target), "mode": "0600", "integrity": "ok"}
|
||||
|
||||
def create(
|
||||
self,
|
||||
binding: dict[str, Any],
|
||||
|
|
@ -728,7 +839,8 @@ class Engine:
|
|||
now = self.now()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, class, created_at, drained_at FROM outbox"
|
||||
"SELECT event_id, class, created_at, drained_at, attempts, last_error "
|
||||
"FROM outbox"
|
||||
).fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
|
|
@ -738,14 +850,32 @@ class Engine:
|
|||
"total": len(rows),
|
||||
"pending": len(pending),
|
||||
"max_age_seconds": max(ages) if ages else 0,
|
||||
"attempts": sum(int(row["attempts"]) for row in rows),
|
||||
"failed_pending": sum(
|
||||
1 for row in pending if row["last_error"] is not None
|
||||
),
|
||||
"counts": self.transition_counts(),
|
||||
}
|
||||
|
||||
def heartbeat_due(self, interval_seconds: int) -> bool:
|
||||
if interval_seconds < 1:
|
||||
raise Unprocessable("heartbeat interval must be positive")
|
||||
try:
|
||||
row = self._conn().execute(
|
||||
"SELECT MAX(created_at) AS created_at FROM outbox WHERE class='heartbeat'"
|
||||
).fetchone()
|
||||
except sqlite3.Error as exc:
|
||||
raise StoreUnavailable(str(exc)) from exc
|
||||
if row["created_at"] is None:
|
||||
return True
|
||||
return (self.now() - parse_iso(row["created_at"])).total_seconds() >= interval_seconds
|
||||
|
||||
def undrained(self) -> list[dict[str, Any]]:
|
||||
conn = self._conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT event_id, class, approval_id, payload_json, created_at "
|
||||
"SELECT event_id, class, approval_id, payload_json, created_at, "
|
||||
"attempts, last_attempt_at, last_error "
|
||||
"FROM outbox WHERE drained_at IS NULL ORDER BY created_at"
|
||||
).fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
|
|
@ -757,6 +887,9 @@ class Engine:
|
|||
"approval_id": r["approval_id"],
|
||||
"payload": json.loads(r["payload_json"]),
|
||||
"created_at": r["created_at"],
|
||||
"attempts": r["attempts"],
|
||||
"last_attempt_at": r["last_attempt_at"],
|
||||
"last_error": r["last_error"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
|
@ -770,15 +903,28 @@ class Engine:
|
|||
failed = 0
|
||||
conn = self._conn()
|
||||
for item in self.undrained():
|
||||
attempted_at = iso(self.now())
|
||||
try:
|
||||
sink(item["payload"])
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE outbox SET attempts=attempts+1, last_attempt_at=?, "
|
||||
"last_error=? WHERE event_id=? AND drained_at IS NULL",
|
||||
(attempted_at, type(exc).__name__, item["event_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.Error as db_exc:
|
||||
conn.rollback()
|
||||
raise StoreUnavailable(str(db_exc)) from db_exc
|
||||
continue
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE outbox SET drained_at=? WHERE event_id=? AND drained_at IS NULL",
|
||||
(iso(self.now()), item["event_id"]),
|
||||
"UPDATE outbox SET drained_at=?, attempts=attempts+1, "
|
||||
"last_attempt_at=?, last_error=NULL "
|
||||
"WHERE event_id=? AND drained_at IS NULL",
|
||||
(iso(self.now()), attempted_at, item["event_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
delivered += 1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue