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
This commit is contained in:
parent
7cab4bfcb3
commit
03c0569820
7 changed files with 488 additions and 3 deletions
|
|
@ -10,8 +10,10 @@ 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
|
||||
|
|
@ -21,6 +23,60 @@ 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):
|
||||
|
|
@ -47,6 +103,7 @@ def check_decision(
|
|||
"""
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue