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
113
src/secrets_engine/approval_auth.py
Normal file
113
src/secrets_engine/approval_auth.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue