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

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