approval-engine/approval_engine/cli.py
tegwick 6d18f62a90 Set the approval store tenant to exact tenant:platform
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management, administration and services tenant, with no alias
to platform or tenant:coulomb and no implicit cross-tenant grant. This closes
the collision recorded in 5c87ba8, where the manifest served --tenant platform
while the requested registrations issued tenant:coulomb.

The store tenant is now exactly tenant:platform in the manifest, the CLI
default, and the Engine default, and the requested client registrations ask for
the same spelling. Exact JWT/store equality is retained: no mapping table, no
normalisation, no prefix handling.

Moving the defaults rather than only the manifest is deliberate. A default of
platform under a sanctioned value of tenant:platform is a trap, because a serve
that omits --tenant would come up healthy and then refuse every authenticated
call -- the exact failure this decision exists to prevent.

That default change broke ten tests whose identity fixtures hard-coded
platform. This is the hazard flex-auth reported as FLEX-DEC-2026-008: fixtures
that all carry one tenant prove nothing about the tenant field. Fixtures are
aligned to the exact spelling, and the field is now varied rather than merely
present. test_near_miss_tenant_spellings_are_forbidden refuses platform,
tenant:coulomb, case variants, whitespace variants and empty against a
tenant:platform store; test_exact_sanctioned_tenant_is_admitted pins the other
half so a reject-everything bug cannot pass it. 111 tests pass.

Also records the credential-independent half of the GLAS-WP-0015 image request:
the image builds non-root uid 10001 off the pinned base, carries schema v3 and
the new tenant default, migrates and verifies a fresh store to schema_version 3
with integrity ok, and refuses production without a persistent database or
authenticated audit delivery. No scan was run -- no scanner is installed here --
and no release digest exists, so T01 and T03 both stay open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM5HnEAhokxdfcPqBNpT7D

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715850@bnt-lap001
Assistant-Session: eb557e93-7cb1-45d0-9e57-7d15b3edc60e
2026-09-06 22:33:50 +02:00

156 lines
5.6 KiB
Python

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="tenant: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 = _parser()
args = parser.parse_args(argv)
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
if __name__ == "__main__":
raise SystemExit(main())