Adoption asked for by flex-auth (FLEX-WP-0021-T05) and glas-harness (GLAS-WP-0015), plus the first real decision this engine has obtained from the deployed pin -- which found a defect the fixtures could not. ACCESS PATH. require_supported_pdp_address refuses in-cluster Service names and any non-loopback host. This is no longer a unilateral call: the owner path is documented as loopback kubectl port-forward over the authenticated Kubernetes API, which is what authenticates the responder transitively (FLEX-DEC-2026-010). A Service name from a workstation does not fail, it resolves through the DNS search suffix to an unrelated public host, and since decision records carry no signature, a responder knowing the published package and version can return an allow that passes every check we make. Fail-closed protects against a PDP that is absent, not one that lies. The guard runs before the token is read, so a misdirected request cannot leak it; a test pins that ordering. LIVE PROOF. Minted a 10-minute TokenRequest token (audience flex-auth, SA secrets-engine/secrets-engine, mode 0600 outside the worktree, shredded after), forwarded to the named pod, and sent a real CheckRequest for glas-claude-agent-dev-anthropic. Result: allow, catalog_lane_policy_matched, served by v2 (sha256:bd11c5fe...) -- so the redeploy flex-auth flagged as outstanding has landed and the pin no longer serves the tenant-blind v1. Our tenant fix is confirmed against the real service: binding.tenant is tenant:platform. THE DEFECT IT FOUND. The evaluator enriches from its registry before hashing -- subject gains attributes and tenant, resource gains tenant -- so binding.request_digest is over material we never sent and cannot reproduce. validate_decision_envelope rejects every real allow. Every replay test passes because _request_from() rebuilds the request out of the binding, i.e. the enriched form: a self-consistent fake agreeing with itself, which hid this through three rounds of digest work. Third time a real artifact has beaten a fake in this integration. NOT FIXED, DELIBERATELY. Rejecting a valid allow is wrong in the safe direction. Which fields may be enriched is flex-auth's contract to publish; inferring it means accepting a binding that differs from our proposal in a way we decided was benign -- the fail-open shape GH-DEC-2026-008 rejected for vocabularies and FLEX-DEC-2026-007 for digests. Raised with them. Tenant question closed by operator decision 5ed3fb35: tenant:platform exactly, and service_auth.TENANT stays tenant:coulomb because the two identity layers are to remain distinct. Declining to author that mapping was right -- the answer was neither reading offered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4tNMAYcSQmZWUE4wqP4ij Assistant: claude-code Assistant-Model: opus Assistant-Process: 715726@bnt-lap001 Assistant-Session: 80a42b32-cba6-4b23-8be0-68819b1a6092
130 lines
5.5 KiB
Python
130 lines
5.5 KiB
Python
"""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
|