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
96
docs/pdp-access-path.md
Normal file
96
docs/pdp-access-path.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
31
tests/fixtures/flex-auth-live/PROVENANCE.md
vendored
Normal file
31
tests/fixtures/flex-auth-live/PROVENANCE.md
vendored
Normal 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.
|
||||
116
tests/fixtures/flex-auth-live/decision_rotate_glas_live.json
vendored
Normal file
116
tests/fixtures/flex-auth-live/decision_rotate_glas_live.json
vendored
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
123
tests/test_live_decision_enrichment.py
Normal file
123
tests/test_live_decision_enrichment.py
Normal 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},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue