secrets-engine/src/secrets_engine/approval_auth.py

114 lines
4.7 KiB
Python
Raw Normal View History

"""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")