net-kingdom/bounded-executor/clients.py
tegwick ba9d266b7e
All checks were successful
Build and Publish bounded-executor / build-and-push (push) Successful in 23s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Identity provider journey acceptance / provider (push) Successful in 32s
NK-WP-0056-T13 (part): executor CLI, clients, Containerfile, image workflow
CLI entry point and approval-engine/audit-core clients; events carry
occurred_at (audit-core envelope); hash-locked image with pinned lldap
stage builds locally; image workflow and executor tests in CI; pilot
example pins the executed files. rapp reshaping still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 994874@bnt-lap001
Assistant-Session: 6017c416-d619-47eb-bd1a-f670a02d7d87
2026-10-31 16:51:07 +01:00

79 lines
3.3 KiB
Python

"""Clients for the executor's owners (NK-WP-0056-T13): approval-engine (carrier) and audit-core (sink).
Stdlib HTTP only. Bearer tokens are read from files at call time (projected or ESO-delivered), never logged.
Errors carry status codes only, never response bodies.
"""
from __future__ import annotations
import json
import pathlib
import ssl
import urllib.error
import urllib.parse
import urllib.request
class ClientError(Exception):
pass
class _Http:
def __init__(self, base: str, token_file: str, ca_file: str | None = None, timeout: float = 15):
parsed = urllib.parse.urlsplit(base)
if parsed.scheme not in ("https", "http") or not parsed.netloc or parsed.query or parsed.fragment:
raise ClientError("base url")
self.base = base.rstrip("/")
self.token_file = token_file
context = ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()
self.open = urllib.request.build_opener(urllib.request.ProxyHandler({}),
urllib.request.HTTPSHandler(context=context)).open
self.timeout = timeout
def call(self, method: str, path: str, body: dict | None = None, headers: dict | None = None) -> tuple[int, dict]:
head = {"Authorization": "Bearer " + pathlib.Path(self.token_file).read_text().strip(),
"Accept": "application/json", **(headers or {})}
data = None
if body is not None:
head["Content-Type"] = "application/json"
data = json.dumps(body).encode()
request = urllib.request.Request(self.base + path, method=method, headers=head, data=data)
try:
with self.open(request, timeout=self.timeout) as response:
raw = response.read(1 << 20)
return response.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as error:
error.close()
raise ClientError(f"{method} {error.code}") from None
class ApprovalEngine:
"""GET /v1/approvals/{id} and POST /v1/approvals/{id}/consume (approval-engine api.py)."""
def __init__(self, base: str, token_file: str, ca_file: str | None = None):
self.http = _Http(base, token_file, ca_file)
@staticmethod
def _path(approval_id: str) -> str:
if not approval_id or "/" in approval_id or len(approval_id) > 128:
raise ClientError("approval id")
return "/v1/approvals/" + urllib.parse.quote(approval_id, safe="")
def get(self, approval_id: str) -> dict:
return self.http.call("GET", self._path(approval_id))[1]
def consume(self, approval_id: str, request_digest: str, decision_id: str) -> dict:
return self.http.call("POST", self._path(approval_id) + "/consume",
{"request_digest": request_digest, "decision_id": decision_id})[1]
class AuditCore:
"""POST /v1/events with Idempotency-Key = event id (audit-core docs/event-envelope.md)."""
def __init__(self, base: str, token_file: str, ca_file: str | None = None):
self.http = _Http(base, token_file, ca_file)
def emit(self, event: dict) -> None:
status, _ = self.http.call("POST", "/v1/events", event, {"Idempotency-Key": event["id"]})
if status not in (200, 201):
raise ClientError(f"audit {status}")