feat: adopt the owner-documented PDP access path, and prove it live
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

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:
tegwick 2026-09-07 00:22:12 +02:00
parent 7cab4bfcb3
commit 03c0569820
7 changed files with 488 additions and 3 deletions

96
docs/pdp-access-path.md Normal file
View file

@ -0,0 +1,96 @@
# Reaching the access-engine pin
The supported way for this engine to reach `flex-auth-secrets-engine`, and the
reason the address is enforced in code rather than left to configuration.
## Never the Service DNS name
```
SECRETS_ENGINE_PDP_URL=http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080
```
is refused by `decision_check.require_supported_pdp_address`. From a workstation
that name does not fail — it **resolves, to the wrong host**:
```text
$ getent hosts flex-auth-secrets-engine.flex-auth.svc.cluster.local
80.158.43.29 ...svc.cluster.local.ad.binect.de
$ getent hosts this-service-does-not-exist.flex-auth.svc.cluster.local
80.158.43.29 (identical — proves suffix expansion, not a record)
```
`resolv.conf` carries `search ad.binect.de`, a wildcard zone, so every
`*.svc.cluster.local` name answers with one unrelated public address. Sending
there ships the CheckRequest body and our bearer token to a third party.
`flex-auth.decision-record.v1` carries **no signature**, and every digest in it
is either sent by us or published in flex-auth's repo, so a forger reproduces
all three exactly. Digests establish integrity of the binding, never
authenticity of the source. As flex-auth put it in `FLEX-DEC-2026-010`:
> Fail-closed protects against a PDP that is ABSENT, not against one that LIES.
Until detached signatures ship (`FLEX-WP-0024`), the address shape is the only
thing standing in for responder authenticity. That is why it is enforced.
## The supported path
Loopback `kubectl port-forward` to the named pod. It resolves no DNS name,
targets one pod explicitly, and rides the Kubernetes API server's TLS with our
cluster credentials — so it authenticates the responder transitively. Note this
is the exact reverse of the caller direction, where port-forward bypasses the
NetworkPolicy (`FLEX-WP-0023`); the two properties point opposite ways and
summarising either as "the network protects it" gets one backwards.
```bash
# 1. Target the pod by name, not the Service.
POD=$(kubectl -n flex-auth get pods \
-l app.kubernetes.io/name=flex-auth-secrets-engine \
-o jsonpath='{.items[0].metadata.name}')
kubectl -n flex-auth port-forward "pod/$POD" 18080:8080 --address 127.0.0.1 &
# 2. Fresh bounded caller token per session. Audience MUST be flex-auth;
# caller auth is `enforce` as of Helm revision 3.
umask 077
kubectl -n secrets-engine create token secrets-engine \
--audience flex-auth --duration 10m > "$PDP_TOKEN"
chmod 600 "$PDP_TOKEN" # outside any Git worktree
# 3. Point the engine at the forward.
export SECRETS_ENGINE_PDP_URL=http://127.0.0.1:18080
export SECRETS_ENGINE_PDP_TOKEN_FILE="$PDP_TOKEN"
export SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE=secrets-engine.catalog-lane.lifecycle
export SECRETS_ENGINE_AUTHORIZATION_POLICY_VERSION=v2
```
Shred the token file and stop the forward when the session ends. The token is
ten minutes; it is a caller credential, not an authorization.
`ServiceAccount secrets-engine/secrets-engine` has `automountServiceAccountToken:
false` and no role bindings — it exists to be a caller identity and nothing else.
## Pin v2, never v1
v1 shipped and was deployed with no `input.tenant` rule at all; a `rotate` under
`tenant:coulomb` returned **allow**. v2 supersedes rather than amends it so the
change is visible in the version string, and this engine refuses a v1 decision
outright. See `docs/tenant-alignment.md`.
## Known gap: the digest join does not hold against a real request
Proved live on 2026-09-07 and **not yet fixed**; the engine currently rejects
every real allow, which is fail-closed and therefore safe to leave standing
while the rule is published.
The evaluator enriches the request from its registry before hashing — `subject`
gains `attributes` and `tenant`, `resource` gains `tenant` — so
`binding.request_digest` is a digest of material we did not send and cannot
reproduce. `validate_decision_envelope` compares the binding against our
unenriched request and fails with *"decision binding does not match request"*.
Which fields the evaluator may enrich is flex-auth's contract to publish, not
ours to infer. Guessing it would mean accepting a binding that differs from what
we proposed in some way we decided was benign — the same fail-open shape as a
guessed digest exclusion or a guessed vocabulary mapping. Raised with flex-auth;
staying fail-closed and naming the missing rule is the correct posture until
they answer.

View file

@ -112,9 +112,13 @@ alone.
- The owner-reviewed JWT/store/CheckRequest mapping, with a decision reference.
Until it exists, no live client or policy subject changes here.
- A supported owner access path to `flex-auth-secrets-engine` for a workstation
CLI. Service DNS is not workstation connectivity, so the tenant fix above is
necessary but not sufficient for activation (`FLEX-WP-0021-T05`).
- ~~A supported owner access path~~**delivered**. Loopback `kubectl
port-forward` plus a bounded `TokenRequest` token; see
`docs/pdp-access-path.md`. Adopted and enforced in
`decision_check.require_supported_pdp_address`.
- The evaluator's **enrichment rule**: `binding` carries registry-enriched
`subject.attributes`, `subject.tenant` and `resource.tenant` that we did not
send, so the digest join fails against a real request. flex-auth's to publish.
## Hazard: the Service DNS name resolves here, to the wrong host

View file

@ -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(

View file

@ -0,0 +1,31 @@
# Live decision from the deployed pin
Not a vendored example. This is a real `DecisionEnvelope` this engine obtained
on 2026-09-07 from the deployed `flex-auth-secrets-engine` pin, through the
owner-documented access path:
- loopback `kubectl port-forward` to the named pod
`flex-auth-secrets-engine-65c74d4858-qvf2p`, over the authenticated
Kubernetes API (`FLEX-DEC-2026-010`: this is what authenticates the
*responder*);
- a 10-minute `TokenRequest` token for ServiceAccount
`secrets-engine/secrets-engine`, audience `flex-auth`, in a mode-0600 file
outside the worktree, shredded after use;
- request built by `build_action_request` for the real catalog lane
`glas-claude-agent-dev-anthropic`, action `rotate`.
The decision is `allow`, `catalog_lane_policy_matched`, served by
`secrets-engine.catalog-lane.lifecycle` **v2**
(`sha256:bd11c5fe…`) — so the redeploy flex-auth flagged as outstanding has
landed and the pin no longer serves the tenant-blind v1.
It contains no secret material: subject and resource metadata, digests, and
policy provenance only.
**Why it is kept.** It is the artifact that proved the digest join does not hold
against a real request. The evaluator enriches `subject` (attributes, tenant)
and `resource` (tenant) from its registry before hashing, so
`binding.request_digest` cannot equal a digest computed over the unenriched
request we sent. Every replay fixture test passes because `_request_from()`
rebuilds the request *from the binding*, which is the enriched form — a
self-consistent fake agreeing with itself. Only a real request exposed it.

View file

@ -0,0 +1,116 @@
{
"id": "decision:0f9c98f14545c42d",
"contract_version": "flex-auth.decision-record.v1",
"request_id": "check:secrets-engine-adoption-proof",
"effect": "allow",
"reason": "catalog_lane_policy_matched",
"matched_policy_version": "v2",
"matched_rule": "catalog_lane_policy_matched",
"resource": {
"id": "catalog:glas-claude-agent-dev-anthropic",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"tenant": "tenant:platform",
"attributes": {
"auth_targets": [],
"fields": [
"api_key"
],
"policy_targets": [],
"stage": "prod"
}
},
"subject": {
"id": "secrets-engine",
"type": "service",
"tenant": "tenant:platform",
"attributes": {
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
"display_name": "secrets-engine service principal",
"groups": [
"group:secrets-engine-lane-operators"
],
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
]
}
},
"binding": {
"tenant": "tenant:platform",
"subject": {
"id": "secrets-engine",
"type": "service",
"tenant": "tenant:platform",
"attributes": {
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
"display_name": "secrets-engine service principal",
"groups": [
"group:secrets-engine-lane-operators"
],
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
]
}
},
"action": "rotate",
"resource": {
"id": "catalog:glas-claude-agent-dev-anthropic",
"type": "secret-catalog-lane",
"system": "secrets-engine",
"tenant": "tenant:platform",
"attributes": {
"auth_targets": [],
"fields": [
"api_key"
],
"policy_targets": [],
"stage": "prod"
}
},
"context": {
"purpose": "live-adoption-proof"
},
"request_digest": "sha256:c37f2fe78205758b27d081e8fb90a9446aa4bd5a571b337d343621955705013c"
},
"lifetime": {
"kind": "ttl",
"ttl": "15m",
"not_before": "2026-09-06T22:19:09Z",
"expires_at": "2026-09-06T22:34:09Z"
},
"diagnostics": {
"action": "rotate",
"matched_relationship": "",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_status": "ready",
"registry_resource": false,
"registry_subject": true
},
"provenance": {
"evaluator": "flex-auth/local",
"mode": "standalone",
"policy_package": "secrets-engine.catalog-lane.lifecycle",
"policy_version": "v2",
"policy_package_digest": "sha256:bd11c5fe77ce6439c65fea225ad6b71d2110efc5e7b5bc9b499c59cd0a53b8b4",
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
"input_claim_digests": {
"context": "sha256:098626d19cdacbc5abebada472e73b2fa328dfd5f8506cb50e63ac6767f87e3a"
},
"decision_time": "2026-09-06T22:19:09Z"
},
"caring": {
"profile": "caring-0.4.0-rc2",
"conformance_findings": [
{
"code": "CARING-DESCRIPTOR-MISSING",
"severity": "warning",
"message": "no CARING descriptor matched the request",
"fields": [
"caring_context"
]
}
]
}
}

View file

@ -27,6 +27,10 @@ from secrets_engine.authorization import (
request_digest,
validate_decision_envelope,
)
from secrets_engine.decision_check import (
check_decision,
require_supported_pdp_address,
)
from secrets_engine.errors import DecisionError
FIXTURES = Path(__file__).parent / "fixtures" / "flex-auth-replay"
@ -429,3 +433,57 @@ def test_the_superseded_v1_package_is_not_accepted():
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v1"},
)
# --- supported PDP address (FLEX-DEC-2026-010) -------------------------------
@pytest.mark.parametrize(
"url,fragment",
[
# The exact address flex-auth originally handed over. It does not fail
# to resolve from a workstation, it resolves to an unrelated public host.
("http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080",
"in-cluster Service name"),
("http://flex-auth-secrets-engine.flex-auth.svc.cluster.local.:8080",
"in-cluster Service name"),
("http://flex-auth-secrets-engine.flex-auth.svc:8080",
"in-cluster Service name"),
# A public address is refused even though it would "work": nothing
# authenticates the responder, so reaching something is not reaching
# the pin.
("http://80.158.43.29:8080", "is not loopback"),
("https://flex-auth.example.com", "is not loopback"),
],
)
def test_unsupported_pdp_addresses_are_refused(url, fragment):
with pytest.raises(DecisionError, match=fragment):
require_supported_pdp_address(url)
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1:18080",
"http://localhost:18080",
"http://[::1]:18080",
],
)
def test_loopback_forward_addresses_are_accepted(url):
require_supported_pdp_address(url)
def test_the_guard_runs_before_the_token_is_read(tmp_path):
"""A bad address must not cause the bearer token to be read, let alone sent.
The token is the thing a misdirected request would leak, so the address
check has to come first rather than alongside.
"""
missing = tmp_path / "never-read.token"
with pytest.raises(DecisionError, match="in-cluster Service name"):
check_decision(
base_url="http://flex-auth-secrets-engine.flex-auth.svc.cluster.local:8080",
token_file=missing,
request={},
)
assert not missing.exists()

View file

@ -0,0 +1,123 @@
"""The digest join against a REAL request, not one rebuilt from the binding.
tests/test_decision_replay.py passes every digest assertion because
``_request_from()`` reconstructs the request out of ``envelope["binding"]`` --
which is the *enriched* form the evaluator hashed. That is a self-consistent
fake agreeing with itself, and it hid the gap below through three rounds of
digest work.
This module builds the request the way the engine actually builds it and
compares it to a decision the engine actually received, so the gap is pinned
until flex-auth publishes the enrichment rule.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from secrets_engine.authorization import (
build_action_request,
canonical_check_request,
request_digest,
validate_decision_envelope,
)
from secrets_engine.catalog import load_catalog
from secrets_engine.errors import DecisionError
LIVE = Path(__file__).parent / "fixtures" / "flex-auth-live" / "decision_rotate_glas_live.json"
CATALOG = Path(__file__).resolve().parents[1] / "catalog"
PACKAGE = "secrets-engine.catalog-lane.lifecycle"
VERSION = "v2"
def _live():
return json.loads(LIVE.read_text())
def _our_request():
entry = load_catalog(CATALOG)["glas-claude-agent-dev-anthropic"]
return build_action_request(
entry,
"rotate",
subject_id="secrets-engine",
subject_type="service",
purpose="live-adoption-proof",
fields=["api_key"],
request_id="check:secrets-engine-adoption-proof",
)
def test_the_live_decision_is_a_real_v2_allow():
"""Provenance of the artifact these assertions rest on."""
env = _live()
assert env["effect"] == "allow"
assert env["reason"] == "catalog_lane_policy_matched"
provenance = env["provenance"]
assert provenance["policy_package"] == PACKAGE
assert provenance["policy_version"] == VERSION
assert env["binding"]["tenant"] == "tenant:platform"
def test_the_evaluator_enriches_subject_and_resource_before_hashing():
"""Names exactly which fields appeared that we never sent.
If flex-auth publishes an enrichment rule that differs from this, this test
fails and tells us the shape moved -- which is the point. It asserts the
observed gap, not a rule we invented.
"""
sent = _our_request()
bound = _live()["binding"]
assert "attributes" not in sent["subject"]
assert set(bound["subject"]["attributes"]) == {
"description", "display_name", "groups", "organization_relation", "roles",
}
assert "tenant" not in sent["subject"]
assert bound["subject"]["tenant"] == sent["tenant"]
assert "tenant" not in sent["resource"]
assert bound["resource"]["tenant"] == sent["tenant"]
# Everything we DID send survived unchanged. The enrichment is additive, so
# the decision is about the action we proposed -- which is why staying
# fail-closed here costs correctness nothing today.
assert bound["action"] == sent["action"]
assert bound["tenant"] == sent["tenant"]
assert bound["context"] == sent["context"]
assert bound["subject"]["id"] == sent["subject"]["id"]
assert bound["subject"]["type"] == sent["subject"]["type"]
for key in ("id", "type", "system", "attributes"):
assert bound["resource"][key] == sent["resource"][key]
def test_our_digest_cannot_match_a_real_binding():
"""The join is unsatisfiable against a real request, not merely mismatched.
We hash what we sent; the evaluator hashed what it enriched. No amount of
care on our side closes that, because the registry material is not ours.
"""
sent = _our_request()
bound = _live()["binding"]
assert bound["request_digest"] != request_digest(sent)
assert canonical_check_request(bound) != canonical_check_request(sent)
def test_we_currently_reject_every_real_allow_and_that_is_fail_closed():
"""Pins today's behaviour honestly rather than asserting it is correct.
Rejecting a valid allow is wrong, but it is wrong in the safe direction, so
it stays until flex-auth publishes which fields may be enriched. Guessing
the rule would mean accepting a binding that differs from our proposal in
some way we decided was benign -- the fail-open shape GH-DEC-2026-008
rejected for vocabularies and FLEX-DEC-2026-007 rejected for digests.
"""
with pytest.raises(DecisionError, match="binding does not match request"):
validate_decision_envelope(
_live(),
_our_request(),
accepted_policy_packages={PACKAGE},
accepted_policy_versions={VERSION},
)