secrets-engine/src/secrets_engine/decision_check.py
tegwick 03c0569820
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
feat: adopt the owner-documented PDP access path, and prove it live
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
2026-09-07 00:22:12 +02:00

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