feat: exchange scoped approval service tokens per request
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 07:06:05 +02:00
parent 3a19069b4b
commit 7688445184
14 changed files with 859 additions and 35 deletions

View file

@ -154,7 +154,8 @@ did before. Production additionally needs:
| Variable | Meaning |
| --- | --- |
| `SECRETS_ENGINE_APPROVAL_URL` | approval-engine base URL (claim + consume) |
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | mode-0600 credential, outside Git |
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | explicit bearer credential outside Git; mutually exclusive with the client-secret provider |
| `SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE` | protected temporary KeyCape approval-client secret; fresh in-memory token per request ([contract](approval-service-auth.md)) |
| `SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal |
| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin (step 2) |
| `SECRETS_ENGINE_PDP_URL` / `_PDP_TOKEN_FILE` | the per-consumer access-engine pin |

View file

@ -0,0 +1,83 @@
# Approval consumer identity
Implemented for SECRETS-WP-0009-T03 on 2026-09-09. KeyCape's live verifier
registration is accepted under KEY-WP-0013-T02 and CCR-2026-0017; this consumer
implementation does not grant retrieval of its confidential client secret.
The CLI exchanges a separately supplied `secrets-engine-approval` client secret
for a fresh access token immediately before each approval HTTP request. Claim
requests ask for `approval:read`; consume requests ask for `approval:consume`.
The token exists only in memory for that request. There is no refresh token,
access-token file, background renewal, retry loop, or fallback identity.
| Input | Value |
| --- | --- |
| `SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE` | Explicit, protected temporary file outside Git, no group/other access |
| `SECRETS_ENGINE_KEYCAPE_ISSUER` | `https://kc.coulomb.social` |
| `SECRETS_ENGINE_KEYCAPE_TOKEN_URL` | `https://kc.coulomb.social/token` |
| `SECRETS_ENGINE_APPROVAL_URL` | Admitted HTTPS endpoint or literal loopback owner tunnel |
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | Unset when using the client-secret provider |
The existing token-file provider remains explicitly selectable by configuring
only `SECRETS_ENGINE_APPROVAL_TOKEN_FILE`. Configuring both providers fails
before either credential is read. An exchange failure never reads that file,
`BAO_TOKEN`, or `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` (the different
OpenBao identity's credential).
The approval profile fixes client `secrets-engine-approval`, audience
`approval-engine`, subject `service:secrets-engine`, tenant `tenant:platform`,
role `secrets-engine`, service principal, empty groups and one requested scope.
It enforces RS256 shape, at most 900 seconds of life, strict expiry and at most
30 seconds of future issue time. The KeyCape assurance shape is
`level: aal1`, `methods: [client_secret]`, `mfa: false`, `source: key-cape`.
The same wire-shape correction applies to the existing OpenBao exchange; its
client, audience, tenant and authorization remain separate.
Claim parsing is a preflight, not signature verification or authorization.
Approval Engine verifies signature, issuer, audience and claims against JWKS.
The existing exact-action claim, access-engine decision and atomic consume gates
still precede a production OpenBao operation.
Credentials are sent through a non-redirecting transport. The KeyCape endpoint
must be the configured HTTPS issuer's `/token`. The new consumer refuses
cluster Service DNS from this workstation and plaintext non-loopback approval
endpoints. A literal loopback address alone cannot identify the responder: the
operator must establish a `kubectl port-forward` for the admitted cluster and
named Approval Engine pod. No tunnel or cluster deployment is created here.
## Reproducible component exercise
Run from this repository with Docker available and the owner source checkouts:
```bash
uv run --with 'PyJWT[crypto]>=2.7,<3' python tools/exercise_approval_identity.py \
--keycape-source /home/worsch/key-cape \
--approval-engine-source /home/worsch/approval-engine \
--receipt /tmp/<new-metadata-receipt>.json
```
The exercise starts the immutable admitted KeyCape image behind local HTTPS,
uses synthetic signing keys/client secrets, and serves the real Approval Engine
source with its JWT/JWKS verifier and SQLite store. It drives the actual Secrets
Engine production gate, with only the PDP represented by a sequencing double.
Operator create/approve, consumer claim/consume, same-digest retry, different
digest, spent claim, wrong action, wrong secret and operator-scope denial are
checked. All temporary processes and synthetic credentials are removed.
This exercise caught the previous fixture-only `aal`/`method` assumption:
the real issuer has always emitted `level`/`methods`. The regression suite now
refuses that obsolete shape. See
[the component receipt](evidence/2026-09-09-approval-identity-exercise.json).
## Live handoff
RPF-WP-0035-T06 owns the separate client-side read admission for the existing
`platform/workloads/secrets-engine/approval-client`, field `CLIENT_SECRET`.
The admission must name the actual consumer/placement, bounded reader, protected
temporary delivery and cleanup, refusal/revocation checks and lifecycle owner.
Do not use the verifier's `sso` Secret or reseed version-1 custody.
Approval Engine's operator is a separate identity and read lane. Audit receiver
and sender custody remains AUDIT-WP-0009-T09 / APPROVAL-WP-0002-T01. The live
Approval Engine deployment and native OpenBao delivery remain open. Synthetic
acceptance grants no production access, action approval or model spending.

View file

@ -39,6 +39,8 @@ secrets-engine --version
| `SECRETS_ENGINE_CATALOG` | `./catalog` | catalog directory |
| `SECRETS_ENGINE_EVIDENCE` | `./.evidence` | local non-secret evidence log |
| `SECRETS_ENGINE_UNSAFE_DEMO` | _(unset)_ | allow a prod-labeled lane only when Hub is disabled and OpenBao is loopback; throwaway demos only |
| `SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE` | _(unset)_ | separate temporary approval-client secret; [per-request exchange](approval-service-auth.md) |
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | _(unset)_ | explicit approval bearer file; conflicts with approval client-secret provider |
| `SECRETS_ENGINE_KEYCAPE_TOKEN_URL` | _(unset)_ | KeyCape token endpoint for `service-jwt` |
| `SECRETS_ENGINE_KEYCAPE_ISSUER` | _(unset)_ | KeyCape issuer; must match the JWT login contract |
| `SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE` | _(unset)_ | mode-0600 out-of-repo client secret |

View file

@ -0,0 +1,34 @@
{
"schema_version": 1,
"target": "disposable local processes; synthetic credentials",
"started_at": "2026-09-09T01:49:09.187005+00:00",
"keycape_image": "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611",
"approval_engine_commit": "b46b0f26669dc83c944ee5145426bad03d5ef720",
"keycape_contract_commit": "0f5535eed95f1223c83a28f5a0bd6fa594cecae8",
"consumer_source_sha256": {
"approval_auth.py": "6f3b033e7928e1c527bc19f5a1e01bf243540769a8646bd8cd47268da25637b5",
"approval_consume.py": "cd79b3e4534a5abf55af2b5ab2939c485a3140a1174f593e0aa7719250051d92",
"config.py": "2f2f1b60664d89bbb806aed0c768378923062077740e1f96e4c359238eb06955",
"service_auth.py": "653723ef5babce2157771416d93cb15cdbb7caca3c45509c3730520c55e4515c"
},
"limitations": [
"PDP sequencing double",
"local Approval Engine source, not deployed image",
"no live custody or client-side read grant",
"no OpenBao effect or model execution"
],
"checks": {
"operator_issued_and_approved_via_verified_jwt": true,
"wrong_action_refused_before_consume": true,
"actual_consumer_claim_check_consume": true,
"same_digest_retry_idempotent": true,
"different_digest_refused": true,
"spent_claim_refused": true,
"operator_consume_scope_denied_by_issuer": true,
"wrong_secret_refused": true,
"no_access_token_file_created": true
},
"status": "passed",
"cleanup_complete": true,
"finished_at": "2026-09-09T01:49:11.402008+00:00"
}

View file

@ -31,3 +31,11 @@ those named providers and `--auth service-jwt` fail-closes. See
Canonical provider contract:
`key-cape/docs/openbao-service-auth-contract.md` (reviewed 2026-08-23).
## Approval identity is separate
[Approval service authentication](approval-service-auth.md) uses its own
client-secret input, resource audience, exact tenant:platform and per-request
read/consume scopes. Never put that credential into the OpenBao provider.
Both providers now validate KeyCapes actual `level`/`methods` assurance shape;
the old `aal`/`method` fixture was incompatible with the issuer.

View file

@ -0,0 +1,113 @@
"""The approval consumer's own KeyCape identity, distinct from OpenBao login.
Access tokens stay in memory for one request. Claim preflight is not signature
verification or an authorization decision; approval-engine verifies the JWT.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, build_opener
from secrets_engine.errors import BackendError, DecisionError
from secrets_engine.openbao import read_strict_token_file
from secrets_engine.service_auth import KeyCapeServiceAuthConfig, KeyCapeServiceAuthProvider
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
# urllib otherwise forwards Basic/Bearer headers on a redirected GET.
return None
def credential_urlopen(request, *, timeout):
return build_opener(_NoRedirect()).open(request, timeout=timeout)
@dataclass(frozen=True)
class KeyCapeApprovalAuthConfig(KeyCapeServiceAuthConfig):
client_id: str = "secrets-engine-approval"
audience: str = "approval-engine"
tenant: str = "tenant:platform"
scope: str = "approval:read"
max_future_iat_seconds: int = field(default=30, init=False)
def __post_init__(self) -> None:
# Deliberately do not relax the separate OpenBao profile's validator.
if (
self.client_id != "secrets-engine-approval"
or self.audience != "approval-engine"
or self.subject != "service:secrets-engine"
or self.tenant != "tenant:platform"
or self.required_role != "secrets-engine"
or self.scope not in {"approval:read", "approval:consume"}
or self.max_future_iat_seconds != 30
):
raise BackendError("KeyCape approval identity does not match accepted contract")
try:
issuer = urlsplit(self.issuer)
valid = (
issuer.scheme == "https" and bool(issuer.hostname)
and issuer.username is None and issuer.password is None
and not issuer.query and not issuer.fragment
and issuer.path in {"", "/"}
and self.token_url == self.issuer.rstrip("/") + "/token"
and issuer.port != 0
)
except ValueError:
valid = False
if not valid:
raise BackendError("KeyCape approval exchange requires the issuer's HTTPS /token")
if self.timeout_seconds <= 0:
raise BackendError("KeyCape timeout must be positive")
def approval_auth_configured(cfg: Any) -> bool:
token_file = getattr(cfg, "approval_token_file", None)
secret_file = getattr(cfg, "approval_client_secret_file", None)
if token_file and secret_file:
raise DecisionError("approval auth requires one provider; token and client-secret files conflict")
return bool(token_file or secret_file)
def require_approval_address(base_url: str) -> None:
"""The first CLI consumer uses TLS or the owner's literal loopback tunnel.
A loopback address alone does not establish the tunnel's target identity;
the owner must bind kubectl port-forward to the admitted cluster and pod.
"""
try:
url = urlsplit(base_url)
host = (url.hostname or "").rstrip(".").lower()
valid = (
bool(host) and url.username is None and url.password is None
and not url.query and not url.fragment and url.path in {"", "/"}
and url.port != 0
and not host.endswith((".svc", ".svc.cluster.local", ".cluster.local"))
and (url.scheme == "https" or (
url.scheme == "http" and host in {"127.0.0.1", "::1"}
))
)
except ValueError:
valid = False
if not valid:
raise DecisionError("approval service auth requires HTTPS or an owner-bound literal loopback tunnel")
def approval_token(cfg: Any, *, scope: str) -> str:
"""Select one explicit provider. Never fall back after exchange failure."""
if not approval_auth_configured(cfg):
raise DecisionError("approval credential is unconfigured")
secret_file = getattr(cfg, "approval_client_secret_file", None)
if secret_file:
require_approval_address(str(getattr(cfg, "approval_url", "") or ""))
config = KeyCapeApprovalAuthConfig(
token_url=str(getattr(cfg, "keycape_token_url", "") or ""),
issuer=str(getattr(cfg, "keycape_issuer", "") or ""),
client_secret_file=Path(secret_file),
scope=scope,
)
return KeyCapeServiceAuthProvider(config, transport=credential_urlopen).exchange().token
return read_strict_token_file(Path(cfg.approval_token_file), purpose="approval credential")

View file

@ -18,6 +18,7 @@ from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from secrets_engine.approval_auth import approval_auth_configured, approval_token, credential_urlopen
from secrets_engine.approval_claim import validate_approval_claim
from secrets_engine.decision_check import check_decision
from secrets_engine.authorization import (
@ -160,10 +161,11 @@ def _expected_request(
def fetch_approval_claim(
*,
base_url: str,
token_file: Path,
token_file: Path | None = None,
token_provider: Callable[[], str] | None = None,
authorization_id: str,
timeout_seconds: float = 3,
opener: Callable[..., Any] = urlopen,
opener: Callable[..., Any] = credential_urlopen,
) -> dict[str, Any]:
"""GET /v1/approvals/{id}/claim (PIP). Any non-200 fails closed.
@ -176,7 +178,7 @@ def fetch_approval_claim(
ident = authorization_id.strip()
if not ident or "/" in ident or any(ch.isspace() for ch in ident):
raise DecisionError("approval claim requires a concrete authorization id")
token = read_strict_token_file(Path(token_file), purpose="approval claim credential")
token = _request_token(token_file, token_provider)
request = Request(
base_url.rstrip("/") + f"/v1/approvals/{ident}/claim",
method="GET",
@ -218,9 +220,9 @@ def resolve_consume_binding(
unconfigured one.
"""
base_url = str(getattr(cfg, "approval_url", "") or "")
token_file = getattr(cfg, "approval_token_file", None)
auth_configured = approval_auth_configured(cfg)
authorization_id = _authorization_id(entry, decision)
if not base_url or not token_file or not authorization_id:
if not base_url or not auth_configured or not authorization_id:
return None
expected_request = _expected_request(
@ -245,9 +247,9 @@ def resolve_consume_binding(
claim = fetch_approval_claim(
base_url=base_url,
token_file=Path(token_file),
token_provider=lambda: approval_token(cfg, scope="approval:read"),
authorization_id=authorization_id,
opener=opener or urlopen,
opener=opener or credential_urlopen,
)
validate_approval_claim(
claim,
@ -347,10 +349,11 @@ def authorize_action(
def consume_approval(
*,
base_url: str,
token_file: Path,
token_file: Path | None = None,
token_provider: Callable[[], str] | None = None,
binding: ConsumeBinding,
timeout_seconds: float = 3,
opener: Callable[..., Any] = urlopen,
opener: Callable[..., Any] = credential_urlopen,
) -> ConsumedApproval:
"""POST /v1/approvals/{id}/consume. Fail closed on anything but confirmed use."""
if not base_url or not base_url.startswith(("http://", "https://")):
@ -361,7 +364,7 @@ def consume_approval(
if not DIGEST_RE.fullmatch(binding.request_digest):
raise DecisionError("approval consume requires the canonical request digest")
token = read_strict_token_file(Path(token_file), purpose="approval consume credential")
token = _request_token(token_file, token_provider)
body: dict[str, str] = {"request_digest": binding.request_digest}
if binding.decision_id:
body["decision_id"] = binding.decision_id
@ -448,22 +451,22 @@ def require_production_consume(
"after an access-engine ALLOW; no durable consume binding is served"
)
base_url = str(getattr(cfg, "approval_url", "") or "")
token_file = getattr(cfg, "approval_token_file", None)
auth_configured = approval_auth_configured(cfg)
if not base_url:
raise DecisionError(
"production OpenBao call requires approval-engine consume; "
"SECRETS_ENGINE_APPROVAL_URL is unset"
)
if not token_file:
if not auth_configured:
raise DecisionError(
"production OpenBao call requires approval-engine consume; "
"SECRETS_ENGINE_APPROVAL_TOKEN_FILE is unset"
"SECRETS_ENGINE_APPROVAL_TOKEN_FILE or _CLIENT_SECRET_FILE is unset"
)
consumed = consume_approval(
base_url=base_url,
token_file=Path(token_file),
token_provider=lambda: approval_token(cfg, scope="approval:consume"),
binding=binding,
opener=opener or urlopen,
opener=opener or credential_urlopen,
)
if evidence is not None and hasattr(evidence, "mark_consumed"):
evidence.mark_consumed(consumed)
@ -482,3 +485,17 @@ def _status_message(status: int) -> str:
if status == 0:
return "approval-engine unreachable; OpenBao must not be called"
return "approval consume failed; OpenBao must not be called"
def _request_token(
token_file: Path | None, token_provider: Callable[[], str] | None,
) -> str:
if (token_file is None) == (token_provider is None):
raise DecisionError("approval request requires exactly one credential provider")
token = (
token_provider() if token_provider is not None
else read_strict_token_file(Path(token_file), purpose="approval credential")
)
if not isinstance(token, str) or not token or any(ch.isspace() for ch in token):
raise DecisionError("approval credential is invalid")
return token

View file

@ -38,6 +38,7 @@ class Config:
topic_id: str
approval_url: str = ""
approval_token_file: Path | None = None
approval_client_secret_file: Path | None = None
keycape_token_url: str = ""
keycape_issuer: str = ""
keycape_client_secret_file: Path | None = None
@ -56,6 +57,7 @@ class Config:
def load(cls) -> "Config":
root = repo_root()
token_file = os.environ.get("SECRETS_ENGINE_APPROVAL_TOKEN_FILE", "")
approval_secret = os.environ.get("SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", "")
keycape_secret = os.environ.get("SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", "")
jwt_login = os.environ.get("SECRETS_ENGINE_OPENBAO_JWT_LOGIN", "")
pdp_token = os.environ.get("SECRETS_ENGINE_PDP_TOKEN_FILE", "")
@ -70,6 +72,7 @@ class Config:
),
approval_url=os.environ.get("SECRETS_ENGINE_APPROVAL_URL", ""),
approval_token_file=Path(token_file) if token_file else None,
approval_client_secret_file=Path(approval_secret) if approval_secret else None,
keycape_token_url=os.environ.get("SECRETS_ENGINE_KEYCAPE_TOKEN_URL", ""),
keycape_issuer=os.environ.get("SECRETS_ENGINE_KEYCAPE_ISSUER", ""),
keycape_client_secret_file=Path(keycape_secret) if keycape_secret else None,

View file

@ -1,12 +1,9 @@
"""Explicit KeyCape service-JWT provider for future OpenBao JWT login.
"""KeyCape service-JWT exchange shared by two separately validated profiles.
This module implements the accepted KeyCape consumer contract without wiring it
into OpenBao yet. The platform owner still needs to publish the exact OpenBao
JWT auth mount and role. Keeping provider selection separate prevents an auth
failure from falling back to bootstrap, operator, or AppRole credentials.
JWT parsing here is a claim preflight, not signature verification. OpenBao must
verify the RS256 signature against the configured issuer before issuing a token.
The OpenBao login profile and approval consumer profile have distinct clients,
audiences, tenants and scopes. Provider selection never falls back to another
identity. Token parsing is a claim preflight, not signature verification: the
receiving OpenBao or approval-engine service verifies RS256 before accepting it.
"""
from __future__ import annotations
@ -46,6 +43,7 @@ class KeyCapeServiceAuthConfig:
required_role: str = ROLE
scope: str = SCOPE
timeout_seconds: float = 10.0
max_future_iat_seconds: int = field(default=60, init=False)
def __post_init__(self) -> None:
if not self.token_url.startswith("https://"):
@ -95,7 +93,7 @@ def preflight_service_jwt(
*,
now: datetime | None = None,
) -> ServiceJWT:
"""Validate non-cryptographic JWT shape/claims before OpenBao login."""
"""Validate non-cryptographic JWT shape/claims before resource-server use."""
parts = token.split(".")
if len(parts) != 3 or not all(parts):
raise BackendError("KeyCape access token is not a compact JWT")
@ -122,8 +120,8 @@ def preflight_service_jwt(
assurance = claims.get("assurance")
if not isinstance(assurance, dict) or (
assurance.get("aal") != "AAL1"
or assurance.get("method") != "client_secret"
assurance.get("level") != "aal1"
or assurance.get("methods") != ["client_secret"]
or assurance.get("mfa") is not False
or assurance.get("source") != "key-cape"
):
@ -139,7 +137,7 @@ def preflight_service_jwt(
):
raise BackendError("KeyCape JWT iat/exp must be integer timestamps")
current = int((now or datetime.now(timezone.utc)).timestamp())
if issued_at > current + 60:
if issued_at > current + config.max_future_iat_seconds:
raise BackendError("KeyCape JWT issue time is in the future")
if expires_at <= current:
raise BackendError("KeyCape JWT has expired")
@ -187,7 +185,9 @@ class KeyCapeServiceAuthProvider:
response = self.transport(request, timeout=self.config.timeout_seconds)
with response:
status = getattr(response, "status", 200)
raw = response.read()
raw = response.read(65537)
if len(raw) > 65536:
raise BackendError("KeyCape token response exceeds size bound")
except HTTPError as e:
raise BackendError(f"KeyCape token exchange failed with HTTP {e.code}") from e
except (URLError, TimeoutError, OSError) as e:

237
tests/test_approval_auth.py Normal file
View file

@ -0,0 +1,237 @@
"""Approval credentials must never select the OpenBao or operator identity."""
import base64
import copy
import json
import threading
from dataclasses import replace
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
from urllib.error import HTTPError
from urllib.parse import parse_qs
from urllib.request import Request
import pytest
from secrets_engine import approval_auth, cli
from secrets_engine.approval_auth import (
KeyCapeApprovalAuthConfig,
approval_token,
credential_urlopen,
require_approval_address,
)
from secrets_engine.catalog import validate_entry
from secrets_engine.config import Config
from secrets_engine.errors import BackendError, DecisionError, ProvisioningError
from secrets_engine.service_auth import KeyCapeServiceAuthConfig, preflight_service_jwt
from tests.authorization_stub import AuthorizationStub
from tests.test_catalog import VALID
from tests.test_service_auth import _jwt
ISSUER = "https://keycape.example.test"
def _cfg(tmp_path, **changes):
secret = tmp_path / "approval-client.secret"
secret.write_text("synthetic-client-secret")
secret.chmod(0o600)
return SimpleNamespace(
**({"approval_client_secret_file": secret, "approval_token_file": None,
"keycape_issuer": ISSUER, "keycape_token_url": ISSUER + "/token",
"approval_url": "http://127.0.0.1:18081"} | changes)
)
def _token(_requested_scope="approval:read", **overrides):
now = int(datetime.now(timezone.utc).timestamp())
return _jwt(**({"iat": now, "exp": now + 900, "aud": "approval-engine",
"tenant": "tenant:platform", "scope": _requested_scope} | overrides))
def _transport(seen, **overrides):
def send(request, *, timeout):
assert request.full_url == ISSUER + "/token"
auth = base64.b64decode(request.get_header("Authorization").split()[1]).decode()
assert auth == "secrets-engine-approval:synthetic-client-secret"
body = parse_qs(request.data.decode())
assert body["grant_type"] == ["client_credentials"]
scope = body["scope"][0]
seen.append(scope)
payload = {"access_token": _token(scope, **overrides), "token_type": "Bearer", "expires_in": 900}
response = BytesIO(json.dumps(payload).encode())
response.status = 200
return response
return send
def test_read_and_consume_each_exchange_their_own_minimal_scope(tmp_path, monkeypatch):
cfg, seen = _cfg(tmp_path), []
monkeypatch.setattr(approval_auth, "credential_urlopen", _transport(seen))
for scope in ("approval:read", "approval:consume", "approval:read"):
result = approval_token(cfg, scope=scope)
claims = json.loads(base64.urlsafe_b64decode(result.split(".")[1] + "=="))
assert claims["scope"] == scope
assert seen == ["approval:read", "approval:consume", "approval:read"]
assert sorted(p.name for p in tmp_path.iterdir()) == ["approval-client.secret"]
@pytest.mark.parametrize("claims", [
{"aud": "secrets-engine-openbao"}, {"tenant": "tenant:coulomb"},
{"sub": "service:approval-engine-operator"}, {"roles": ["secrets-engine", "admin"]},
{"scope": "approval:read approval:consume"}, {"principal_type": "human"},
{"exp": 1}, {"iss": "https://another-issuer.test"},
{"assurance": {"aal": "AAL1", "method": "client_secret", "mfa": False, "source": "key-cape"}},
{"assurance": {"level": "aal1", "methods": ["client_secret", "password"], "mfa": False, "source": "key-cape"}},
])
def test_wrong_returned_identity_is_refused_without_fallback(tmp_path, monkeypatch, claims):
cfg = _cfg(tmp_path)
# An OpenBao identity credential exists, but must never be read here.
cfg.keycape_client_secret_file = Path("/must/not/read/openbao-client")
monkeypatch.setenv("BAO_TOKEN", "synthetic-forbidden-fallback")
monkeypatch.setattr(approval_auth, "credential_urlopen", _transport([], **claims))
with pytest.raises(BackendError):
approval_token(cfg, scope="approval:read")
def test_exchange_failure_is_bounded_and_never_falls_back(tmp_path, monkeypatch):
cfg = _cfg(tmp_path)
seen = []
def refuse(request, *, timeout):
seen.append(request.full_url)
raise HTTPError(request.full_url, 401, "private error", {}, BytesIO(b"secret-like error body"))
monkeypatch.setattr(approval_auth, "credential_urlopen", refuse)
with pytest.raises(BackendError, match="HTTP 401") as error:
approval_token(cfg, scope="approval:consume")
assert "secret-like" not in str(error.value)
assert len(seen) == 1
def test_mixed_providers_refused_before_either_file_read(tmp_path):
cfg = _cfg(tmp_path, approval_token_file=Path("/not/read/token"))
with pytest.raises(DecisionError, match="conflict"):
approval_token(cfg, scope="approval:read")
def test_secret_file_protection_is_required(tmp_path):
cfg = _cfg(tmp_path)
cfg.approval_client_secret_file.chmod(0o644)
with pytest.raises(ProvisioningError, match="accessible"):
approval_token(cfg, scope="approval:read")
@pytest.mark.parametrize("url", [
"http://keycape.example.test/token", "https://other.test/token",
ISSUER + "/token?next=elsewhere", ISSUER + "/token#fragment",
])
def test_exchange_url_must_be_the_issuer_endpoint(url):
with pytest.raises(BackendError, match="HTTPS /token"):
KeyCapeApprovalAuthConfig(token_url=url, issuer=ISSUER, client_secret_file=Path("/unused"))
@pytest.mark.parametrize("url", [
"http://approval.example", "http://localhost:8000", "https://approval-engine.svc.cluster.local",
"https://user:password@approval.test", "http://127.0.0.1:8000?next=other", "https://approval.test/#fragment",
])
def test_unsafe_approval_destinations_refused_before_exchange(url):
with pytest.raises(DecisionError):
require_approval_address(url)
def test_approval_profile_cannot_relax_the_openbao_contract():
with pytest.raises(BackendError):
KeyCapeServiceAuthConfig(token_url=ISSUER + "/token", issuer=ISSUER,
client_secret_file=Path("/unused"), audience="approval-engine")
cfg = KeyCapeApprovalAuthConfig(token_url=ISSUER + "/token", issuer=ISSUER, client_secret_file=Path("/unused"))
for changes in ({"client_id": "approval-engine-operator"}, {"scope": "approval:create"}, {"tenant": "tenant:coulomb"}):
with pytest.raises(BackendError):
replace(cfg, **changes)
now = datetime.now(timezone.utc)
stamp = int(now.timestamp())
preflight_service_jwt(_token(iat=stamp + 30, exp=stamp + 930), cfg, now=now)
for changes in ({"iat": stamp + 31, "exp": stamp + 931}, {"exp": stamp}, {"exp": stamp + 901}):
with pytest.raises(BackendError):
preflight_service_jwt(_token(**changes), cfg, now=now)
@pytest.mark.parametrize("method", ["GET", "POST"])
def test_redirect_cannot_forward_credentials(method):
seen = []
class Redirect(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def do_GET(self):
seen.append(self.path)
if self.path == "/start":
self.send_response(302)
self.send_header("Location", "/sink")
else:
self.send_response(200)
self.end_headers()
do_POST = do_GET
server = ThreadingHTTPServer(("127.0.0.1", 0), Redirect)
thread = threading.Thread(target=server.serve_forever)
thread.start()
try:
req = Request(f"http://127.0.0.1:{server.server_port}/start", method=method,
headers={"Authorization": "Bearer synthetic-token"})
with pytest.raises(HTTPError) as error:
credential_urlopen(req, timeout=2)
assert error.value.code == 302
assert seen == ["/start"]
finally:
server.shutdown()
server.server_close()
thread.join()
def test_environment_selects_separate_approval_secret(monkeypatch):
monkeypatch.setenv("SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE", "/protected/approval.secret")
monkeypatch.setenv("SECRETS_ENGINE_KEYCAPE_CLIENT_SECRET_FILE", "/protected/openbao.secret")
cfg = Config.load()
assert cfg.approval_client_secret_file == Path("/protected/approval.secret")
assert cfg.keycape_client_secret_file == Path("/protected/openbao.secret")
@pytest.mark.parametrize("failure", [None, "exchange", "conflict", "wrong-action"])
def test_cli_gate_exchanges_for_claim_and_consume(tmp_path, monkeypatch, failure):
raw = copy.deepcopy(VALID)
raw["stage"] = "prod"
raw["approval"] = {"model": "bootstrap-only", "authorization_id": "approval-test", "purpose": "synthetic proof"}
entry = validate_entry(raw)
cfg = _cfg(tmp_path)
cfg.authorization_subject_id = "secrets-engine"
cfg.authorization_subject_type = "service"
cfg.authorization_policy_package = "secrets-engine.catalog-lane.lifecycle"
cfg.authorization_policy_version = "v2"
cfg.evidence_dir = tmp_path / "evidence"
cfg.pdp_token_file = tmp_path / "pdp.token"
cfg.pdp_token_file.write_text("synthetic-pdp-token")
cfg.pdp_token_file.chmod(0o600)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
stub = AuthorizationStub(approval_id="approval-test", package=cfg.authorization_policy_package, version="v2").start()
seen = []
transport = _transport(seen)
def exchange(request, *, timeout):
if failure == "exchange":
raise HTTPError(request.full_url, 401, "denied", {}, BytesIO(b""))
return transport(request, timeout=timeout)
monkeypatch.setattr(approval_auth, "credential_urlopen", exchange)
cfg.approval_url = cfg.pdp_url = stub.url
from secrets_engine.approval_consume import _expected_request
stub.bind_request(_expected_request(cfg, entry, "apply"))
if failure == "conflict":
stub.consume_status = 409
try:
if failure:
with pytest.raises((DecisionError, BackendError)):
cli._require_lane_approval(cfg, entry, "destroy" if failure == "wrong-action" else "apply")
assert not stub.consumed
else:
cli._require_lane_approval(cfg, entry, "apply")
assert stub.calls == ["claim", "check", "consume"]
assert seen == ["approval:read", "approval:consume"]
assert stub.consumed
finally:
stub.stop()

View file

@ -304,7 +304,7 @@ def test_consume_conflict_prevents_openbao(tmp_path, monkeypatch):
lambda *_args, **_kwargs: _authorized(),
)
monkeypatch.setattr(
"secrets_engine.approval_consume.urlopen",
"secrets_engine.approval_consume.credential_urlopen",
lambda *_args, **_kwargs: (_ for _ in ()).throw(_http_error(409)),
)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
@ -337,7 +337,7 @@ def test_confirmed_consume_allows_openbao_resolve(tmp_path, monkeypatch):
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.setattr(cli, "apply_unreachable_engine_stance", _allow_prod_stance)
monkeypatch.setattr(cli, "authorize_action", lambda *_args, **_kwargs: _authorized())
monkeypatch.setattr("secrets_engine.approval_consume.urlopen", opener)
monkeypatch.setattr("secrets_engine.approval_consume.credential_urlopen", opener)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
monkeypatch.setattr(
cli.OpenBaoClient,

View file

@ -35,8 +35,8 @@ def _jwt(**overrides):
"groups": [],
"scope": "openbao:login",
"assurance": {
"aal": "AAL1",
"method": "client_secret",
"level": "aal1",
"methods": ["client_secret"],
"mfa": False,
"source": "key-cape",
},
@ -91,7 +91,7 @@ class _Response:
def __exit__(self, *_args):
return False
def read(self):
def read(self, _size=-1):
return json.dumps(self.payload).encode()

View file

@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""Disposable KeyCape image -> actual Secrets Engine client -> Approval Engine.
Synthetic keys/credentials only. No OpenBao, cluster mutation, or model call.
The PDP is a sequencing double; this is not production authorization evidence.
"""
from __future__ import annotations
import argparse
import base64
import copy
import hashlib
import http.server
import ipaddress
import json
import os
from pathlib import Path
import secrets
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
from datetime import datetime, timedelta, timezone
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from unittest.mock import patch
from wsgiref.simple_server import WSGIRequestHandler, make_server
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
import yaml
KEYCAPE_IMAGE = "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611"
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / "src"), str(ROOT)]
def run(args):
result = subprocess.run(args, capture_output=True, timeout=45)
if result.returncode:
raise RuntimeError("contained command failed")
return result.stdout.decode().strip()
def commit(path):
return run(["git", "-C", str(path), "rev-parse", "HEAD"])
def private_write(path, value):
with path.open("x") as out:
os.chmod(path, 0o600)
out.write(value)
def exercise(args):
sys.path.insert(0, str(args.approval_engine_source))
from approval_engine.api import App
from approval_engine.auth import JWTAuthenticator
from approval_engine.store import Engine
from secrets_engine import cli
from secrets_engine.approval_auth import approval_token, credential_urlopen
from secrets_engine.approval_consume import ConsumeBinding, _expected_request, consume_approval
from secrets_engine.catalog import validate_entry
from secrets_engine.config import Config
from secrets_engine.errors import BackendError, DecisionError
from tests.authorization_stub import AuthorizationStub
from tests.test_catalog import VALID
receipt = {
"schema_version": 1, "target": "disposable local processes; synthetic credentials",
"started_at": datetime.now(timezone.utc).isoformat(),
"keycape_image": KEYCAPE_IMAGE,
"approval_engine_commit": commit(args.approval_engine_source),
"keycape_contract_commit": commit(args.keycape_source),
"consumer_source_sha256": {
name: hashlib.sha256((ROOT / "src/secrets_engine" / name).read_bytes()).hexdigest()
for name in ("approval_auth.py", "approval_consume.py", "config.py", "service_auth.py")
},
"limitations": ["PDP sequencing double", "local Approval Engine source, not deployed image",
"no live custody or client-side read grant", "no OpenBao effect or model execution"],
"checks": {},
}
with tempfile.TemporaryDirectory(prefix="approval-identity-private-") as temporary:
root = Path(temporary)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_write(root / "key.pem", key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode())
now = datetime.now(timezone.utc)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "local approval identity exercise")])
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(hours=1))
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), False)
.add_extension(x509.BasicConstraints(ca=True, path_length=None), True)
.sign(key, hashes.SHA256()))
(root / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
keycape_port = sock.getsockname()[1]
class Proxy(http.server.BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def proxy(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0))) if self.command == "POST" else None
req = Request(f"http://127.0.0.1:{keycape_port}" + self.path, data=body,
headers={k: self.headers[k] for k in ("Content-Type", "Authorization") if k in self.headers})
try:
response = urlopen(req, timeout=10)
except HTTPError as error:
response = error
with response:
self.send_response(response.code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(response.read())
do_GET = proxy
do_POST = proxy
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Proxy)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(root / "cert.pem", root / "key.pem")
server.socket = context.wrap_socket(server.socket, server_side=True)
issuer = f"https://127.0.0.1:{server.server_port}"
proxy_thread = threading.Thread(target=server.serve_forever, daemon=True)
proxy_thread.start()
config = yaml.safe_load((args.keycape_source / "config/dev-config.yaml").read_text())
clients = yaml.safe_load((args.keycape_source / "config/service-clients.example.yaml").read_text())["clients"]
clients = [c for c in clients if c["clientId"] in ("secrets-engine-approval", "approval-engine-operator")]
assert len(clients) == 2
config.update(issuer=issuer, clients=clients)
config["authelia"]["issuer"] = "https://synthetic-upstream.invalid"
private_write(root / "config.yaml", yaml.safe_dump(config, sort_keys=False))
values = {c["clientId"]: secrets.token_urlsafe(48) for c in clients}
private_write(root / "env", "KEYCAPE_CONFIG=/etc/keycape/config.yaml\n" + "".join(
c["secretRef"].removeprefix("env:") + "=" + values[c["clientId"]] + "\n" for c in clients))
private_write(root / "approval.secret", values["secrets-engine-approval"])
private_write(root / "pdp.token", "synthetic-pdp-token")
container = "approval-identity-exercise-" + secrets.token_hex(5)
created = False
engine = None
api_server = None
pdp = None
try:
run(["docker", "run", "-d", "--rm", "--name", container, "--user", str(os.getuid()),
"--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges",
"--publish", f"127.0.0.1:{keycape_port}:8080", "--env-file", str(root / "env"),
"--mount", "type=bind,source=" + temporary + ",target=/etc/keycape,readonly", KEYCAPE_IMAGE])
created = True
with patch.dict(os.environ, {"SSL_CERT_FILE": str(root / "cert.pem"), "SECRETS_ENGINE_UNSAFE_DEMO": ""}):
for _ in range(40):
try:
with credential_urlopen(issuer + "/jwks", timeout=1) as response:
if response.status == 200:
break
except Exception:
time.sleep(0.25)
else:
raise RuntimeError("disposable KeyCape did not start")
engine = Engine(root / "approval.sqlite")
app = App(engine, JWTAuthenticator(issuer=issuer, audience="approval-engine", jwks_url=issuer + "/jwks"))
class Quiet(WSGIRequestHandler):
def log_message(self, *_args):
pass
api_server = make_server("127.0.0.1", 0, app, handler_class=Quiet)
threading.Thread(target=api_server.serve_forever, daemon=True).start()
api = f"http://127.0.0.1:{api_server.server_port}"
def operator_token(scope):
credential = base64.b64encode(("approval-engine-operator:" + values["approval-engine-operator"]).encode()).decode()
req = Request(issuer + "/token", data=urlencode({"grant_type": "client_credentials", "scope": scope}).encode(),
headers={"Authorization": "Basic " + credential})
with credential_urlopen(req, timeout=3) as response:
return json.load(response)["access_token"]
def operator_request(path, payload, scope):
req = Request(api + path, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + operator_token(scope)})
with credential_urlopen(req, timeout=3) as response:
return json.load(response)
raw = copy.deepcopy(VALID)
raw["stage"] = "prod"
raw["approval"] = {"model": "bootstrap-only", "authorization_id": "synthetic-approval", "purpose": "disposable identity proof"}
entry = validate_entry(raw)
pdp = AuthorizationStub(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start()
cfg = Config(catalog_dir=root, policy_dir=root, evidence_dir=root / "evidence", hub_url="", bao_addr="", topic_id="",
approval_url=api, approval_client_secret_file=root / "approval.secret",
keycape_issuer=issuer, keycape_token_url=issuer + "/token",
authorization_subject_id="secrets-engine", authorization_subject_type="service",
authorization_policy_package="secrets-engine.catalog-lane.lifecycle", authorization_policy_version="v2",
pdp_url=pdp.url, pdp_token_file=root / "pdp.token")
request = _expected_request(cfg, entry, "apply")
digest = pdp.bind_request(request)
obj = operator_request("/v1/approvals", {
"id": "synthetic-approval",
"binding": {"action": "secrets.apply", "target": {"id": entry.id, "stage": "prod"},
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
"pdp_digest": digest, "pdp_path": True}, "approval:create")
operator_request(f"/v1/approvals/{obj['id']}/entries", {}, "approval:approve")
receipt["checks"]["operator_issued_and_approved_via_verified_jwt"] = True
try:
cli._require_lane_approval(cfg, entry, "destroy")
except DecisionError:
assert not engine.claim(obj["id"])["consumed"]
else:
raise AssertionError("wrong action accepted")
receipt["checks"]["wrong_action_refused_before_consume"] = True
cli._require_lane_approval(cfg, entry, "apply")
assert engine.claim(obj["id"])["consumed"]
assert pdp.calls == ["check"]
receipt["checks"]["actual_consumer_claim_check_consume"] = True
binding = ConsumeBinding(obj["id"], digest)
retry = consume_approval(base_url=api, binding=binding, token_provider=lambda: approval_token(cfg, scope="approval:consume"))
assert retry.idempotent
receipt["checks"]["same_digest_retry_idempotent"] = True
try:
consume_approval(base_url=api, binding=ConsumeBinding(obj["id"], "sha256:" + "f" * 64),
token_provider=lambda: approval_token(cfg, scope="approval:consume"))
except DecisionError:
pass
else:
raise AssertionError("different digest accepted")
receipt["checks"]["different_digest_refused"] = True
try:
cli._require_lane_approval(cfg, entry, "apply")
except DecisionError:
pass
else:
raise AssertionError("spent claim accepted")
receipt["checks"]["spent_claim_refused"] = True
try:
operator_token("approval:consume")
except HTTPError as error:
assert error.code in (400, 401)
assert json.load(error)["error"] == "invalid_profile_usage"
else:
raise AssertionError("operator consume scope accepted")
receipt["checks"]["operator_consume_scope_denied_by_issuer"] = True
(root / "approval.secret").write_text("synthetic-wrong-secret")
try:
approval_token(cfg, scope="approval:read")
except BackendError:
pass
else:
raise AssertionError("wrong secret accepted")
receipt["checks"]["wrong_secret_refused"] = True
assert not (root / "approval.token").exists()
receipt["checks"]["no_access_token_file_created"] = True
finally:
if api_server:
api_server.shutdown()
api_server.server_close()
if pdp:
pdp.stop()
if engine:
engine.close()
if created:
run(["docker", "stop", "--time=5", container])
server.shutdown()
server.server_close()
proxy_thread.join()
receipt.update(status="passed", cleanup_complete=True, finished_at=datetime.now(timezone.utc).isoformat())
private_write(args.receipt, json.dumps(receipt, indent=2) + "\n")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--keycape-source", required=True, type=Path)
parser.add_argument("--approval-engine-source", required=True, type=Path)
parser.add_argument("--receipt", required=True, type=Path)
args = parser.parse_args()
if args.receipt.exists():
raise SystemExit("receipt path must be new")
try:
exercise(args)
except Exception as error:
# Never dump a request, client environment or credential-bearing frame.
print("Synthetic approval identity exercise failed: " + type(error).__name__, file=sys.stderr)
return 1
print("Synthetic approval identity exercise passed; temporary resources removed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -7,7 +7,7 @@ repo: secrets-engine
status: blocked
owner: codex
created: "2026-09-05"
updated: "2026-09-05"
updated: "2026-09-09"
state_hub_workstream_id: "40ccc3b4-d046-5a58-8649-e7935f45c974"
---
@ -148,3 +148,36 @@ and negative access, register delivery-ready evidence, bind owner exec with
service authentication, and return verified pins/evidence to SAND-WP-0015 and
GLAS-WP-0012. Provider scope/budget and expiry remain explicit acceptance inputs.
Keep the catalog route inactive until real verification passes.
### 2026-09-09 consumer identity implementation and component proof
The "one external dependency" statement above concealed another local seam:
claim and consume could only read a manually supplied bearer-token file. The
OpenBao service-JWT provider cannot be reused with approval credentials because
its audience, tenant and scope are different.
Implemented the separate secrets-engine-approval provider and wired it into
both actual claim/consume call sites. It requests only read or consume for the
current request, keeps the token in memory, rejects mixed providers, and never
falls back on exchange failure. The exact service:secrets-engine / approval-engine /
tenant:platform identity and scope/time bounds are preflighted. Credential-bearing
redirects and unsupported approval endpoint shapes are refused.
The pinned real KeyCape image exposed a second defect: the shared consumer
expected assurance.aal/method but the issuer emits assurance.level/methods.
Corrected to the issuer's actual source contract, with regression coverage.
The real Approval Engine JWT verifier and SQLite store then accepted the actual
consumer's claim/check/consume chain in a disposable component exercise. Wrong
action/secret, spent claim, different digest and operator consume scope are
refused; same-digest consume retries are idempotent. Only the PDP was a test
double. The suite passed 350 tests; no production credential was read.
Contract and repeatable exercise: docs/approval-service-auth.md; receipt:
docs/evidence/2026-09-09-approval-identity-exercise.json.
T03 remains wait for RPF-WP-0035-T06's separately admitted client-side reader,
AUDIT-WP-0009-T09 audit custody, APPROVAL-WP-0002 live endpoint/claim/consume,
and the existing exact native OpenBao authority/delivery returns. The synthetic
proof does not grant these. KEY-WP-0013-T02 and verifier CCR-2026-0017/0018
are complete and must not be requested again. No new workplan duplicates T03.