Stdlib per-tenant service: trusted-header identity (router/relay/worker, constant-time keys, slot pinned to the pod), fail-closed card flags with capability negotiation, tenant-scoped store (atomic writes, revisions, append-only ledgers, content-addressed blobs, manifest), audit outcome for every request, and the /hux/v1 pipeline that maps errors to hux.error.v1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
214 lines
7.8 KiB
Python
214 lines
7.8 KiB
Python
"""Minimal HTTP layer for the per-tenant HUX service.
|
|
|
|
Stdlib only. A ``Router`` maps method + path template to a handler; family
|
|
modules register their routes with it. Every request resolves identity from
|
|
trusted headers, checks the owning card's flag, and writes an audit outcome.
|
|
Errors always leave as ``hux.error.v1``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
from collections.abc import Mapping
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from hux import audit
|
|
from hux.errors import HuxError, Invalid, NotFound, TooLarge
|
|
from hux.flags import Flags, build_from_environ
|
|
from hux.identity import Identity, resolve
|
|
from hux.store import TenantStore
|
|
|
|
MAX_BODY_BYTES = 1024 * 1024
|
|
Handler = Callable[["Request"], "Response"]
|
|
|
|
|
|
@dataclass
|
|
class Request:
|
|
"""Everything a handler needs; no raw socket access."""
|
|
|
|
method: str
|
|
path: str
|
|
params: dict[str, str]
|
|
query: dict[str, str]
|
|
headers: Mapping[str, str]
|
|
body: Any
|
|
identity: Identity
|
|
store: TenantStore
|
|
flags: Flags
|
|
flags_build: dict[str, str] = field(default_factory=dict)
|
|
|
|
def if_match(self) -> int | None:
|
|
"""Parsed If-Match revision, or None when absent."""
|
|
raw = self.header("If-Match")
|
|
if raw == "":
|
|
return None
|
|
if not raw.isdigit():
|
|
raise Invalid("If-Match must be a revision integer")
|
|
return int(raw)
|
|
|
|
def idempotency_key(self) -> str:
|
|
"""Client idempotency key, validated against the contract pattern."""
|
|
raw = self.header("Idempotency-Key")
|
|
if raw and not re.match(r"^[A-Za-z0-9._:-]{8,120}$", raw):
|
|
raise Invalid("malformed Idempotency-Key")
|
|
return raw
|
|
|
|
def header(self, name: str) -> str:
|
|
"""Case-insensitive header lookup."""
|
|
for key, value in self.headers.items():
|
|
if key.lower() == name.lower():
|
|
return value.strip()
|
|
return ""
|
|
|
|
def audit(self, action: str, resource: str, outcome: str = "allow", reason: str = "") -> None:
|
|
"""Write an audit outcome for this request."""
|
|
audit.record(self.store, self.identity, action, resource, outcome, reason)
|
|
|
|
|
|
@dataclass
|
|
class Response:
|
|
"""JSON (or SSE) response."""
|
|
|
|
status: int = 200
|
|
body: Any = None
|
|
headers: dict[str, str] = field(default_factory=dict)
|
|
stream: Callable[[], Any] | None = None
|
|
|
|
|
|
@dataclass
|
|
class Route:
|
|
"""One registered handler."""
|
|
|
|
method: str
|
|
template: str
|
|
card: str
|
|
action: str
|
|
handler: Handler
|
|
pattern: re.Pattern = field(init=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
regex = re.sub(r"\{(\w+)\}", r"(?P<\1>[A-Za-z0-9._:-]+)", self.template)
|
|
self.pattern = re.compile(f"^{regex}$")
|
|
|
|
|
|
class Router:
|
|
"""Route table plus the request pipeline."""
|
|
|
|
def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None:
|
|
self.data_root = Path(data_root)
|
|
self.environ = environ
|
|
self.flags = Flags(environ)
|
|
self.build = build_from_environ(environ)
|
|
self.routes: list[Route] = []
|
|
|
|
def add(self, method: str, template: str, card: str, action: str, handler: Handler) -> None:
|
|
"""Register a handler; ``action`` is the audit action name (family.verb)."""
|
|
self.routes.append(Route(method, template, card, action, handler))
|
|
|
|
def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]:
|
|
"""Return (route, params, path_known)."""
|
|
known = False
|
|
for route in self.routes:
|
|
found = route.pattern.match(path)
|
|
if found:
|
|
known = True
|
|
if route.method == method:
|
|
return route, found.groupdict(), True
|
|
return None, {}, known
|
|
|
|
def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response:
|
|
"""Run the full pipeline and never raise."""
|
|
parts = urlsplit(raw_path)
|
|
query = {k: v[-1] for k, v in parse_qs(parts.query).items()}
|
|
try:
|
|
identity = resolve(headers, self.environ)
|
|
store = TenantStore(self.data_root, identity)
|
|
except HuxError as error:
|
|
return Response(error.status, error.record())
|
|
route, params, known = self.match(method, parts.path)
|
|
if route is None:
|
|
error = Invalid("method not allowed") if known else NotFound("no such route")
|
|
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
|
|
return Response(405 if known else 404, error.record())
|
|
try:
|
|
self.flags.require(route.card)
|
|
payload = self._decode(body)
|
|
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
|
|
response = route.handler(request)
|
|
except HuxError as error:
|
|
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
|
|
audit.record(store, identity, route.action, parts.path, outcome, error.message)
|
|
return Response(error.status, error.record())
|
|
return response
|
|
|
|
@staticmethod
|
|
def _decode(body: bytes) -> Any:
|
|
if not body:
|
|
return None
|
|
if len(body) > MAX_BODY_BYTES:
|
|
raise TooLarge("body exceeds 1 MiB")
|
|
try:
|
|
return json.loads(body)
|
|
except json.JSONDecodeError as error:
|
|
raise Invalid(f"body is not JSON: {error.msg}") from error
|
|
|
|
|
|
def page(items: list[Any], next_cursor: Any = None) -> Response:
|
|
"""Standard list envelope."""
|
|
return Response(200, {"items": items, "next": next_cursor})
|
|
|
|
|
|
def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
|
|
"""Bind a Router to a BaseHTTPRequestHandler subclass."""
|
|
|
|
class HuxHandler(BaseHTTPRequestHandler):
|
|
server_version = "hux-foundation/1.0"
|
|
|
|
def log_message(self, fmt: str, *args: Any) -> None: # noqa: D102 - quiet by design; audit ledger is the log
|
|
return
|
|
|
|
def _run(self) -> None:
|
|
if self.path == "/healthz":
|
|
self._send(Response(200, {"status": "ok", "contract_version": "1.0.0"}))
|
|
return
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
body = self.rfile.read(length) if length else b""
|
|
self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body))
|
|
|
|
def _send(self, response: Response) -> None:
|
|
if response.stream is not None:
|
|
self.send_response(response.status)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
for key, value in response.headers.items():
|
|
self.send_header(key, value)
|
|
self.end_headers()
|
|
for chunk in response.stream():
|
|
self.wfile.write(chunk)
|
|
self.wfile.flush()
|
|
return
|
|
data = json.dumps(response.body, sort_keys=True).encode()
|
|
self.send_response(response.status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
for key, value in response.headers.items():
|
|
self.send_header(key, value)
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
|
|
|
|
return HuxHandler
|
|
|
|
|
|
def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer:
|
|
"""Create (but do not start) the server; callers call serve_forever()."""
|
|
server = ThreadingHTTPServer((host, port), make_handler(router))
|
|
server.daemon_threads = True
|
|
return server
|