"""access-engine (flex-auth) Check client — step 2 of GH-DEC-2026-003. This engine consumes a decision; it never renders one. The DecisionEnvelope returned here is validated by ``authorization.validate_decision_envelope`` against the exact proposed action before it can satisfy the production stance. No estate-wide PDP exists by design: flex-auth runs per-consumer cluster-local pins, so the address is per-deployment configuration and its absence fails production closed. """ from __future__ import annotations import ipaddress import json from pathlib import Path from urllib.parse import urlsplit from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from secrets_engine.errors import DecisionError from secrets_engine.openbao import read_strict_token_file _MAX_BODY = 512 * 1024 #: Suffixes that name an in-cluster Service and must never be dialled from here. _CLUSTER_SUFFIXES = (".svc.cluster.local", ".svc", ".cluster.local") def _is_loopback(host: str) -> bool: if host in ("localhost", "localhost."): return True try: return ipaddress.ip_address(host.strip("[]")).is_loopback except ValueError: return False def require_supported_pdp_address(base_url: str) -> None: """Refuse address shapes the owner path excludes (FLEX-DEC-2026-010). The supported operator path is a loopback ``kubectl port-forward`` over the authenticated Kubernetes API, which is the only shape that authenticates the *responder*: it resolves no DNS name, targets one named pod, and rides the API server's TLS with our cluster credentials. A cluster Service name must never be dialled from a workstation. It does not merely fail -- it RESOLVES, to the wrong host. This workstation carries ``search ad.binect.de``, a wildcard zone, so every ``*.svc.cluster.local`` name (including services that do not exist) answers with one unrelated public address. Sending there would ship the CheckRequest body and our bearer token to a third party, and because ``flex-auth.decision-record.v1`` carries no signature, a responder that knows the published package id and version can return a well-formed allow that passes every check we make. Fail-closed protects against a PDP that is ABSENT, not one that LIES. Until FLEX-WP-0024 ships detached signatures, the address shape is the only thing standing in for responder authenticity, so it is enforced rather than documented. """ host = (urlsplit(base_url).hostname or "").rstrip(".").lower() if not host: raise DecisionError("access-engine check URL has no host") if any(host.endswith(suffix) for suffix in _CLUSTER_SUFFIXES): raise DecisionError( f"access-engine check URL '{host}' is an in-cluster Service name; " "from a workstation it resolves through the DNS search suffix to an " "unrelated public host. Use the owner-documented loopback " "kubectl port-forward instead (docs/pdp-access-path.md)" ) if not _is_loopback(host): raise DecisionError( f"access-engine check URL host '{host}' is not loopback; the " "supported path is a loopback kubectl port-forward over the " "authenticated Kubernetes API, which is what authenticates the " "responder (FLEX-DEC-2026-010). Set SECRETS_ENGINE_PDP_URL to the " "forwarded 127.0.0.1 port (docs/pdp-access-path.md)" ) def _status_message(status: int) -> str: if status in (401, 403): return "access-engine refused the caller" if status == 404: return "access-engine has no such endpoint" if status == 503: return "access-engine is unavailable" return f"access-engine returned HTTP {status}" def check_decision( *, base_url: str, token_file: Path, request: dict[str, Any], timeout_seconds: float = 3, opener: Callable[..., Any] = urlopen, ) -> dict[str, Any]: """POST /v1/check. Any non-200, non-JSON, or transport failure fails closed. Silence is never permission: an unreachable PDP raises rather than returning a permissive default. """ if not base_url or not base_url.startswith(("http://", "https://")): raise DecisionError("access-engine check URL is missing or invalid") require_supported_pdp_address(base_url) token = read_strict_token_file(Path(token_file), purpose="access-engine credential") encoded = json.dumps(request).encode("utf-8") http_request = Request( base_url.rstrip("/") + "/v1/check", data=encoded, method="POST", ) http_request.add_header("Authorization", f"Bearer {token}") http_request.add_header("Content-Type", "application/json") http_request.add_header("Accept", "application/json") try: with opener(http_request, timeout=timeout_seconds) as response: if getattr(response, "status", 200) != 200: raise DecisionError("access-engine check did not return a decision") payload = json.loads(response.read(_MAX_BODY).decode("utf-8")) except HTTPError as e: raise DecisionError(f"access-engine check refused: {_status_message(e.code)}") from e except URLError as e: raise DecisionError("access-engine is unreachable for check") from e except json.JSONDecodeError as e: raise DecisionError("access-engine check returned a non-JSON body") from e if not isinstance(payload, dict): raise DecisionError("access-engine check returned a non-object body") return payload