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
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue