feat: exchange scoped approval service tokens per request
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
3a19069b4b
commit
7688445184
14 changed files with 859 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue