Send a caller identity to flex-auth so policy.enabled can flip
flex-auth's flex-auth-ops-warden pin (FLEX-WP-0016) TokenReviews the caller and binds resource.system: ops-warden to system:serviceaccount:ops-warden:ops-warden. policy.py posted /v1/check with no Authorization header, so the pin logs "caller authentication warning" and can only run callerAuth.mode: warn — which, under ADHOC-2026-08-17-T01, is exactly what blocks policy.enabled: true. - policy.caller_auth (none | file | env | command) + src/warden/caller_identity.py: token resolved per call, never cached, written, or logged (ADR-0002) - both check_sign_policy and check_fetch_policy attach the bearer header; an unobtainable token fails closed rather than retrying anonymously - scripts/check_policy_caller_identity.py: read-only gate, prints length and a truncated fingerprint only, distinguishes 401 (audience/binding) from 403 - example config: caller_auth block, and flex_auth_url corrected — it pointed at flex-auth.flex-auth.svc, a Service that does not exist - WARDEN-WP-0031, PolicyGatedSigning caller-identity section and flip sequence Default stays mode: none, so behaviour is unchanged until an operator opts in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
35aff380a3
commit
0a331413a2
9 changed files with 728 additions and 12 deletions
92
src/warden/caller_identity.py
Normal file
92
src/warden/caller_identity.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Caller identity for ops-warden's outbound flex-auth policy calls.
|
||||
|
||||
flex-auth's `flex-auth-ops-warden` pin (FLEX-WP-0016) authenticates the *caller*
|
||||
before it evaluates the request: `Authorization: Bearer <token>` is passed to a
|
||||
Kubernetes TokenReview, and `resource.system: ops-warden` is bound to the
|
||||
principal `system:serviceaccount:ops-warden:ops-warden`. Until ops-warden sends
|
||||
that header, the pin logs `caller authentication warning` and can only run in
|
||||
`warn` mode — which is why `policy.enabled` cannot flip.
|
||||
|
||||
This module resolves the token at call time and hands it straight to the request.
|
||||
Nothing is cached to disk, logged, or echoed: ops-warden carries the value, it
|
||||
does not hold it (ADR-0002).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from warden.config import CallerAuthConfig
|
||||
|
||||
|
||||
class CallerIdentityError(Exception):
|
||||
"""Raised when a caller token was configured but could not be obtained."""
|
||||
|
||||
|
||||
def resolve_caller_token(cfg: CallerAuthConfig) -> str | None:
|
||||
"""Return the bearer token for flex-auth, or None when mode is ``none``.
|
||||
|
||||
Raises CallerIdentityError when a token was configured but is unavailable.
|
||||
The token itself never appears in an exception message.
|
||||
"""
|
||||
mode = cfg.mode
|
||||
if mode == "none":
|
||||
return None
|
||||
|
||||
if mode == "file":
|
||||
if cfg.token_path is None:
|
||||
raise CallerIdentityError("caller_auth mode 'file' has no token_path")
|
||||
try:
|
||||
token = cfg.token_path.read_text()
|
||||
except OSError as e:
|
||||
raise CallerIdentityError(
|
||||
f"caller token file unreadable: {cfg.token_path} ({e.strerror})"
|
||||
) from e
|
||||
elif mode == "env":
|
||||
token = os.environ.get(cfg.token_env, "")
|
||||
if not token.strip():
|
||||
raise CallerIdentityError(
|
||||
f"caller token env {cfg.token_env} is unset or empty"
|
||||
)
|
||||
elif mode == "command":
|
||||
if not cfg.command:
|
||||
raise CallerIdentityError("caller_auth mode 'command' has no command")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cfg.command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise CallerIdentityError(
|
||||
f"caller token command not found: {cfg.command[0]}"
|
||||
) from e
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise CallerIdentityError("caller token command timed out") from e
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or "").strip().splitlines()
|
||||
detail = stderr[-1] if stderr else f"exit {result.returncode}"
|
||||
raise CallerIdentityError(f"caller token command failed: {detail}")
|
||||
token = result.stdout
|
||||
else:
|
||||
raise CallerIdentityError(f"unsupported caller_auth mode {mode!r}")
|
||||
|
||||
token = token.strip()
|
||||
if not token:
|
||||
raise CallerIdentityError(f"caller_auth mode {mode!r} produced an empty token")
|
||||
if any(ch.isspace() for ch in token):
|
||||
# flex-auth rejects a bearer token containing whitespace outright.
|
||||
raise CallerIdentityError(
|
||||
f"caller_auth mode {mode!r} produced a token containing whitespace"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def caller_auth_headers(cfg: CallerAuthConfig) -> dict[str, str]:
|
||||
"""Headers to attach to a flex-auth /v1/check call ({} when unauthenticated)."""
|
||||
token = resolve_caller_token(cfg)
|
||||
if token is None:
|
||||
return {}
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
|
@ -2,9 +2,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
|
@ -13,6 +14,35 @@ class ConfigError(Exception):
|
|||
"""Raised when config is invalid or missing."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallerAuthConfig:
|
||||
"""How ops-warden proves *its own* identity to flex-auth (FLEX-WP-0016).
|
||||
|
||||
flex-auth's ops-warden pin authenticates the caller with a Kubernetes
|
||||
TokenReview and binds ``resource.system: ops-warden`` to the principal
|
||||
``system:serviceaccount:ops-warden:ops-warden``. A workstation ``warden
|
||||
sign`` is not a ServiceAccount, so the token has to come from somewhere:
|
||||
|
||||
``none`` send no ``Authorization`` header (pre-FLEX-WP-0016 behaviour;
|
||||
accepted only while that pin runs ``callerAuth.mode: warn``)
|
||||
``file`` read a projected ServiceAccount token from ``token_path``
|
||||
(in-cluster PEP, audience-bound by the projection)
|
||||
``env`` read the token from ``token_env``
|
||||
``command`` run ``command`` and use its stdout, e.g.
|
||||
``kubectl create token ops-warden -n ops-warden
|
||||
--audience flex-auth --duration 10m``
|
||||
|
||||
ops-warden never stores the token: it is read, sent, and dropped
|
||||
(ADR-0002 — transparent conduit, not a broker).
|
||||
"""
|
||||
|
||||
mode: str = "none"
|
||||
token_path: Optional[Path] = None
|
||||
token_env: str = "WARDEN_POLICY_CALLER_TOKEN"
|
||||
command: Optional[List[str]] = None
|
||||
audience: str = "flex-auth"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyConfig:
|
||||
enabled: bool = False
|
||||
|
|
@ -21,6 +51,7 @@ class PolicyConfig:
|
|||
tenant: str = "tenant:platform"
|
||||
subject_env: str = "WARDEN_POLICY_SUBJECT"
|
||||
system: str = "ops-warden"
|
||||
caller_auth: "CallerAuthConfig" = field(default_factory=lambda: CallerAuthConfig())
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -117,6 +148,33 @@ def load_config(path: Optional[Path] = None) -> WardenConfig:
|
|||
)
|
||||
|
||||
policy_raw = raw.get("policy") or {}
|
||||
caller_raw = policy_raw.get("caller_auth") or {}
|
||||
caller_command = caller_raw.get("command")
|
||||
if isinstance(caller_command, str):
|
||||
caller_command = shlex.split(caller_command)
|
||||
elif caller_command is not None:
|
||||
caller_command = [str(part) for part in caller_command]
|
||||
caller_token_path = caller_raw.get("token_path")
|
||||
caller_cfg = CallerAuthConfig(
|
||||
mode=str(caller_raw.get("mode", "none")).strip().lower(),
|
||||
token_path=(
|
||||
Path(os.path.expanduser(str(caller_token_path)))
|
||||
if caller_token_path
|
||||
else None
|
||||
),
|
||||
token_env=str(caller_raw.get("token_env", "WARDEN_POLICY_CALLER_TOKEN")),
|
||||
command=caller_command,
|
||||
audience=str(caller_raw.get("audience", "flex-auth")),
|
||||
)
|
||||
if caller_cfg.mode not in {"none", "file", "env", "command"}:
|
||||
raise ConfigError(
|
||||
f"policy.caller_auth.mode must be none|file|env|command, "
|
||||
f"got {caller_cfg.mode!r}"
|
||||
)
|
||||
if caller_cfg.mode == "file" and caller_cfg.token_path is None:
|
||||
raise ConfigError("policy.caller_auth.token_path is required for mode: file")
|
||||
if caller_cfg.mode == "command" and not caller_cfg.command:
|
||||
raise ConfigError("policy.caller_auth.command is required for mode: command")
|
||||
policy_cfg = PolicyConfig(
|
||||
enabled=bool(policy_raw.get("enabled", False)),
|
||||
flex_auth_url=str(policy_raw.get("flex_auth_url", "http://127.0.0.1:8080")),
|
||||
|
|
@ -124,6 +182,7 @@ def load_config(path: Optional[Path] = None) -> WardenConfig:
|
|||
tenant=str(policy_raw.get("tenant", "tenant:platform")),
|
||||
subject_env=str(policy_raw.get("subject_env", "WARDEN_POLICY_SUBJECT")),
|
||||
system=str(policy_raw.get("system", "ops-warden")),
|
||||
caller_auth=caller_cfg,
|
||||
)
|
||||
|
||||
return WardenConfig(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from pathlib import Path
|
|||
import httpx
|
||||
|
||||
from warden.ca import CAError
|
||||
from warden.caller_identity import CallerIdentityError, caller_auth_headers
|
||||
from warden.config import PolicyConfig
|
||||
from warden.models import CertSpec
|
||||
|
||||
|
|
@ -19,6 +20,21 @@ def pubkey_fingerprint(pubkey_path: Path) -> str:
|
|||
return f"sha256:{digest}"
|
||||
|
||||
|
||||
def _caller_headers(cfg: PolicyConfig) -> dict[str, str]:
|
||||
"""Bearer header identifying ops-warden itself to flex-auth (FLEX-WP-0016).
|
||||
|
||||
When the token cannot be obtained we refuse the call under ``fail_closed``
|
||||
rather than silently falling back to an unauthenticated request — an
|
||||
unauthenticated call is exactly what keeps the flex-auth pin in ``warn``.
|
||||
"""
|
||||
try:
|
||||
return caller_auth_headers(cfg.caller_auth)
|
||||
except CallerIdentityError as e:
|
||||
if cfg.fail_closed:
|
||||
raise CAError(f"flex-auth caller identity unavailable: {e}") from e
|
||||
return {}
|
||||
|
||||
|
||||
def _subject_id(cfg: PolicyConfig, spec: CertSpec) -> str:
|
||||
return os.environ.get(cfg.subject_env, "").strip() or spec.actor_name
|
||||
|
||||
|
|
@ -60,8 +76,9 @@ def check_sign_policy(cfg: PolicyConfig, spec: CertSpec) -> str | None:
|
|||
}
|
||||
|
||||
url = cfg.flex_auth_url.rstrip("/") + "/v1/check"
|
||||
headers = _caller_headers(cfg)
|
||||
try:
|
||||
response = httpx.post(url, json=request, timeout=10.0)
|
||||
response = httpx.post(url, json=request, headers=headers, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if cfg.fail_closed:
|
||||
|
|
@ -120,8 +137,9 @@ def check_fetch_policy(
|
|||
}
|
||||
|
||||
url = cfg.flex_auth_url.rstrip("/") + "/v1/check"
|
||||
headers = _caller_headers(cfg)
|
||||
try:
|
||||
response = httpx.post(url, json=request, timeout=10.0)
|
||||
response = httpx.post(url, json=request, headers=headers, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if cfg.fail_closed:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue