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

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