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
14
SCOPE.md
14
SCOPE.md
|
|
@ -50,8 +50,12 @@ net-kingdom / info-tech-canon is owner-driven (tracked via coordination messages
|
|||
|
||||
**Policy gate** is shipped on the caller side (WP-0007) with production registry
|
||||
and smoke evidence (WP-0009 archived). flex-auth published the `ssh-certificate`
|
||||
policy package (FLEX-WP-0006). `policy.enabled` remains **false** in production
|
||||
until flex-auth is deployed to a reachable URL (flex-auth FLEX-WP-0007).
|
||||
policy package (FLEX-WP-0006) and now runs the in-cluster pin
|
||||
`flex-auth-ops-warden` (FLEX-WP-0007 finished; FLEX-WP-0016). `policy.enabled`
|
||||
remains **false**: that pin is in `callerAuth.mode: warn` because ops-warden sent
|
||||
no caller identity. WP-0031 ships the calling side (`policy.caller_auth` +
|
||||
`scripts/check_policy_caller_identity.py`); the flip waits on an operator picking
|
||||
a token source and flex-auth then enforcing.
|
||||
|
||||
**ops-bridge cert_command pilot** is shipped to pilot-ready (WP-0016): a read-only
|
||||
readiness gate (`scripts/check_tunnel_cert_readiness.py`) plus an opt-in offline
|
||||
|
|
@ -273,6 +277,7 @@ for the rest.
|
|||
| WP | Status | Focus |
|
||||
| --- | --- | --- |
|
||||
| WP-0027 | `backlog` | Tamper-resistant credential governance + mass rotation/lockdown (Strand B follow-on to WP-0026) |
|
||||
| WP-0031 | `active` | Calling-side identity for flex-auth — code + gate done; flip sequence waits on operator + flex-auth enforce |
|
||||
| WP-0030 | `proposed` | Delegation register — record intended owner + blocker on every interim lane, `warden route gaps`, promotion gate |
|
||||
|
||||
Remaining production distance is also in other repos' lanes (see Known gaps).
|
||||
|
|
@ -281,7 +286,7 @@ Remaining production distance is also in other repos' lanes (see Known gaps).
|
|||
|
||||
| Gap | Owner | Notes |
|
||||
| --- | --- | --- |
|
||||
| flex-auth production runtime + registry deploy | flex-auth | **FLEX-WP-0007** — unblocks `policy.enabled: true` |
|
||||
| `callerAuth.mode: enforce` on the ops-warden pin | flex-auth | **FLEX-WP-0016 T03** — waits on WARDEN-WP-0031 T04, then unblocks `policy.enabled: true` |
|
||||
| ops-bridge `cert_command` on live tunnels | ops-bridge | Playbook + readiness gate shipped (WP-0016); pilot cutover handed off, awaiting ops-bridge |
|
||||
| Principals sync warden ↔ railiance-infra | ops-warden + infra | `scripts/check_principals_drift.py` — operator runs periodically |
|
||||
| NK-WP-0009 joint SSH tutorial | net-kingdom | Parallel coordination track |
|
||||
|
|
@ -347,7 +352,8 @@ Remaining production distance is also in other repos' lanes (see Known gaps).
|
|||
- **Production sign:** verified 2026-06-18 (`history/2026-06-17-openbao-production-verify.md`)
|
||||
- **Access routing:** WP-0010 + WP-0011 shipped (`warden route`, pointer catalog)
|
||||
- **Policy gate:** caller shipped (WP-0007); registry + smoke complete (WP-0009 archived).
|
||||
`policy.enabled: false` until flex-auth reachable (`FLEX-WP-0007`)
|
||||
`policy.enabled: false` — the `flex-auth-ops-warden` pin is in `warn` until
|
||||
ops-warden sends a caller identity (WP-0031)
|
||||
- **Workload posture:** WP-0015 shipped (standard, descriptors, `warden policy`,
|
||||
conformance checker, dev doubles); canon landing owner-driven
|
||||
- **ops-bridge cert_command:** WP-0016 shipped to pilot-ready (readiness gate +
|
||||
|
|
|
|||
|
|
@ -19,9 +19,29 @@ state_dir: ~/.local/state/warden
|
|||
# Registry: registry/flex-auth/production_registry_snapshot.json (build from inventory).
|
||||
# See wiki/PolicyGatedSigning.md (operator checklist) and wiki/playbooks/operator-openbao-token-hygiene.md
|
||||
policy:
|
||||
# Keep false until the flex-auth ops-warden pin runs callerAuth.mode: enforce.
|
||||
# Gate: python scripts/check_policy_caller_identity.py (WARDEN-WP-0031)
|
||||
enabled: false
|
||||
flex_auth_url: http://flex-auth.flex-auth.svc.cluster.local:8080
|
||||
# The in-cluster pin for ops-warden's signing policy (FLEX-WP-0016). A bare
|
||||
# flex-auth.flex-auth.svc Service does not exist. From a workstation, reach it
|
||||
# through a port-forward or tunnel and point this at that local address.
|
||||
flex_auth_url: http://flex-auth-ops-warden.flex-auth.svc.cluster.local:8080
|
||||
fail_closed: true
|
||||
tenant: tenant:platform
|
||||
subject_env: WARDEN_POLICY_SUBJECT
|
||||
system: ops-warden
|
||||
system: ops-warden
|
||||
# How ops-warden proves it is ops-warden. flex-auth TokenReviews this bearer
|
||||
# token and requires the principal system:serviceaccount:ops-warden:ops-warden
|
||||
# for resource.system: ops-warden. Mode none sends no header, which is what
|
||||
# holds the pin in warn.
|
||||
caller_auth:
|
||||
mode: none # none | file | env | command
|
||||
# In-cluster PEP — projected ServiceAccount token, audience-bound:
|
||||
# mode: file
|
||||
# token_path: /var/run/secrets/flex-auth/token
|
||||
# Workstation — mint a short-lived bound token per call:
|
||||
# mode: command
|
||||
# command: kubectl create token ops-warden -n ops-warden
|
||||
# --audience flex-auth --duration 10m
|
||||
token_env: WARDEN_POLICY_CALLER_TOKEN
|
||||
audience: flex-auth
|
||||
189
scripts/check_policy_caller_identity.py
Executable file
189
scripts/check_policy_caller_identity.py
Executable file
|
|
@ -0,0 +1,189 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Readiness gate for the flex-auth ``policy.enabled`` flip (WARDEN-WP-0031).
|
||||
|
||||
flex-auth deployed ``flex-auth-ops-warden`` (FLEX-WP-0016) in ``callerAuth.mode:
|
||||
warn``: it authenticates the caller with a Kubernetes TokenReview and binds
|
||||
``resource.system: ops-warden`` to ``system:serviceaccount:ops-warden:ops-warden``,
|
||||
but a caller that sends no ``Authorization`` header only produces a
|
||||
``caller authentication warning`` and is still served. That pin cannot move to
|
||||
``enforce`` — and therefore ``policy.enabled: true`` cannot be set — until
|
||||
ops-warden's calling side actually presents a token.
|
||||
|
||||
This script asserts the calling side *without* flipping anything:
|
||||
|
||||
* warden.yaml loads and ``policy.caller_auth.mode`` is not ``none``,
|
||||
* a caller token can actually be obtained (file / env / command),
|
||||
* (optional, ``--url``) a live ``/v1/check`` against the warn pin returns a
|
||||
decision **and** the response is reached with the header attached.
|
||||
|
||||
Exit 0 = ready to ask flex-auth to enforce, 1 = not ready, 2 = bad input.
|
||||
The token is never printed, logged, or written anywhere — only its length and a
|
||||
truncated SHA-256 fingerprint, which are safe to paste into a handoff message.
|
||||
|
||||
Usage:
|
||||
python scripts/check_policy_caller_identity.py [--config ~/.config/warden/warden.yaml]
|
||||
python scripts/check_policy_caller_identity.py --url http://127.0.0.1:19090
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
_SRC = Path(__file__).resolve().parent.parent / "src"
|
||||
if _SRC.is_dir() and str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
from warden.caller_identity import ( # noqa: E402
|
||||
CallerIdentityError,
|
||||
resolve_caller_token,
|
||||
)
|
||||
from warden.config import ConfigError, load_config # noqa: E402
|
||||
|
||||
Check = Tuple[str, str, str]
|
||||
|
||||
|
||||
def _fingerprint(token: str) -> str:
|
||||
return "sha256:" + hashlib.sha256(token.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def run_checks(config_path: Optional[Path], url: Optional[str]) -> List[Check]:
|
||||
checks: List[Check] = []
|
||||
try:
|
||||
cfg = load_config(config_path)
|
||||
except ConfigError as e:
|
||||
return [("fail", "warden.yaml", str(e))]
|
||||
|
||||
policy = cfg.policy
|
||||
checks.append(
|
||||
("ok", "warden.yaml", f"loaded; policy.enabled={str(policy.enabled).lower()}")
|
||||
)
|
||||
|
||||
mode = policy.caller_auth.mode
|
||||
if mode == "none":
|
||||
checks.append(
|
||||
(
|
||||
"fail",
|
||||
"caller_auth.mode",
|
||||
"none — no Authorization header is sent; the flex-auth pin stays in warn",
|
||||
)
|
||||
)
|
||||
return checks
|
||||
checks.append(("ok", "caller_auth.mode", mode))
|
||||
|
||||
try:
|
||||
token = resolve_caller_token(policy.caller_auth)
|
||||
except CallerIdentityError as e:
|
||||
checks.append(("fail", "caller token", str(e)))
|
||||
return checks
|
||||
assert token is not None
|
||||
checks.append(
|
||||
("ok", "caller token", f"obtained, {len(token)} chars, {_fingerprint(token)}")
|
||||
)
|
||||
|
||||
target = url or policy.flex_auth_url
|
||||
if url is None and not policy.enabled:
|
||||
checks.append(
|
||||
(
|
||||
"skip",
|
||||
"live /v1/check",
|
||||
f"policy.enabled=false; pass --url to smoke {target} anyway",
|
||||
)
|
||||
)
|
||||
return checks
|
||||
|
||||
import httpx # local import: the offline checks above must not need it
|
||||
|
||||
probe = {
|
||||
"subject": {"id": "readiness", "type": "agt", "tenant": policy.tenant},
|
||||
"action": "sign",
|
||||
"resource": {
|
||||
"id": "ssh-cert:actor/agt-state-hub-bridge",
|
||||
"type": "ssh-certificate",
|
||||
"system": policy.system,
|
||||
"tenant": policy.tenant,
|
||||
},
|
||||
"context": {"readiness_probe": True},
|
||||
}
|
||||
try:
|
||||
response = httpx.post(
|
||||
target.rstrip("/") + "/v1/check",
|
||||
json=probe,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=10.0,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
checks.append(("fail", "live /v1/check", f"unreachable at {target}: {e}"))
|
||||
return checks
|
||||
|
||||
if response.status_code == 401:
|
||||
checks.append(
|
||||
(
|
||||
"fail",
|
||||
"live /v1/check",
|
||||
"401 — the token was sent but flex-auth did not accept it "
|
||||
"(check the TokenReview audience and the ServiceAccount binding)",
|
||||
)
|
||||
)
|
||||
elif response.status_code == 403:
|
||||
checks.append(
|
||||
(
|
||||
"fail",
|
||||
"live /v1/check",
|
||||
f"403 — authenticated, but the principal may not represent "
|
||||
f"system {policy.system!r}",
|
||||
)
|
||||
)
|
||||
elif response.status_code >= 400:
|
||||
checks.append(
|
||||
("fail", "live /v1/check", f"HTTP {response.status_code} from {target}")
|
||||
)
|
||||
else:
|
||||
try:
|
||||
decision = response.json()
|
||||
except ValueError:
|
||||
checks.append(("fail", "live /v1/check", "non-JSON decision"))
|
||||
return checks
|
||||
effect = str(decision.get("effect", "?"))
|
||||
decision_id = decision.get("id") or decision.get("request_id") or "?"
|
||||
checks.append(
|
||||
("ok", "live /v1/check", f"HTTP 200, effect={effect}, decision={decision_id}")
|
||||
)
|
||||
return checks
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", type=Path, default=None, help="path to warden.yaml")
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
default=None,
|
||||
help="flex-auth base URL to smoke (e.g. a port-forward of the warn pin)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
checks = run_checks(args.config, args.url)
|
||||
glyph = {"ok": "✓", "fail": "✗", "skip": "·"}
|
||||
print("flex-auth caller-identity readiness\n")
|
||||
for status, label, detail in checks:
|
||||
print(f" {glyph[status]} {label}: {detail}")
|
||||
|
||||
failed = [c for c in checks if c[0] == "fail"]
|
||||
if failed:
|
||||
print(
|
||||
f"\nNOT READY — {len(failed)} check(s) failed. "
|
||||
"Do not ask flex-auth to enforce, and do not set policy.enabled: true."
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
"\nREADY — the calling side presents an identity. Next: tell flex-auth to set "
|
||||
"callerAuth.mode: enforce on flex-auth-ops-warden, re-run this check, then set "
|
||||
"policy.enabled: true with fail_closed: true."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Tests for warden.policy — flex-auth gate."""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
|
@ -6,7 +7,12 @@ import httpx
|
|||
import pytest
|
||||
|
||||
from warden.ca import CAError
|
||||
from warden.config import PolicyConfig
|
||||
from warden.caller_identity import (
|
||||
CallerIdentityError,
|
||||
caller_auth_headers,
|
||||
resolve_caller_token,
|
||||
)
|
||||
from warden.config import CallerAuthConfig, PolicyConfig
|
||||
from warden.models import ActorType, CertSpec
|
||||
from warden.policy import check_sign_policy, pubkey_fingerprint
|
||||
|
||||
|
|
@ -137,4 +143,123 @@ def test_subject_from_env(tmp_path, monkeypatch):
|
|||
check_sign_policy(cfg, _spec(pubkey))
|
||||
|
||||
body = post.call_args[1]["json"]
|
||||
assert body["subject"]["id"] == "iam:bernd"
|
||||
assert body["subject"]["id"] == "iam:bernd"
|
||||
|
||||
# --- caller identity (FLEX-WP-0016 / WARDEN-WP-0031) -----------------------
|
||||
|
||||
def test_caller_auth_none_sends_no_header():
|
||||
assert caller_auth_headers(CallerAuthConfig()) == {}
|
||||
|
||||
|
||||
def test_caller_auth_file_reads_projected_token(tmp_path):
|
||||
token_file = tmp_path / "token"
|
||||
token_file.write_text("sa-token-value\n")
|
||||
cfg = CallerAuthConfig(mode="file", token_path=token_file)
|
||||
assert caller_auth_headers(cfg) == {"Authorization": "Bearer sa-token-value"}
|
||||
|
||||
|
||||
def test_caller_auth_file_missing_raises(tmp_path):
|
||||
cfg = CallerAuthConfig(mode="file", token_path=tmp_path / "absent")
|
||||
with pytest.raises(CallerIdentityError, match="unreadable"):
|
||||
resolve_caller_token(cfg)
|
||||
|
||||
|
||||
def test_caller_auth_env_mode(monkeypatch):
|
||||
monkeypatch.setenv("WARDEN_POLICY_CALLER_TOKEN", " env-token ")
|
||||
assert resolve_caller_token(CallerAuthConfig(mode="env")) == "env-token"
|
||||
monkeypatch.setenv("WARDEN_POLICY_CALLER_TOKEN", "")
|
||||
with pytest.raises(CallerIdentityError, match="unset or empty"):
|
||||
resolve_caller_token(CallerAuthConfig(mode="env"))
|
||||
|
||||
|
||||
def test_caller_auth_command_mode_uses_stdout(monkeypatch):
|
||||
cfg = CallerAuthConfig(mode="command", command=["kubectl", "create", "token"])
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
assert cmd == cfg.command
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="minted-token\n", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
assert resolve_caller_token(cfg) == "minted-token"
|
||||
|
||||
|
||||
def test_caller_auth_command_failure_message_excludes_token(monkeypatch):
|
||||
cfg = CallerAuthConfig(mode="command", command=["kubectl", "create", "token"])
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="error: forbidden\n")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
with pytest.raises(CallerIdentityError, match="error: forbidden"):
|
||||
resolve_caller_token(cfg)
|
||||
|
||||
|
||||
def test_caller_auth_rejects_whitespace_token(tmp_path):
|
||||
token_file = tmp_path / "token"
|
||||
token_file.write_text("two words")
|
||||
cfg = CallerAuthConfig(mode="file", token_path=token_file)
|
||||
with pytest.raises(CallerIdentityError, match="whitespace"):
|
||||
resolve_caller_token(cfg)
|
||||
|
||||
|
||||
def test_sign_policy_sends_authorization_header(tmp_path, monkeypatch):
|
||||
"""The header flex-auth's ops-warden pin needs to leave warn mode."""
|
||||
from warden import policy as policy_mod
|
||||
|
||||
token_file = tmp_path / "token"
|
||||
token_file.write_text("sa-token-value")
|
||||
pubkey = tmp_path / "id.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA test\n")
|
||||
|
||||
cfg = PolicyConfig(
|
||||
enabled=True,
|
||||
caller_auth=CallerAuthConfig(mode="file", token_path=token_file),
|
||||
)
|
||||
spec = CertSpec(
|
||||
actor_name="agt-state-hub-bridge",
|
||||
actor_type=ActorType.AGT,
|
||||
principals=["agt"],
|
||||
ttl_hours=24,
|
||||
pubkey_path=pubkey,
|
||||
)
|
||||
|
||||
seen = {}
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"effect": "allow", "id": "decision:49350f1064f674d7"}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
seen["headers"] = headers
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr(policy_mod.httpx, "post", fake_post)
|
||||
assert policy_mod.check_sign_policy(cfg, spec) == "decision:49350f1064f674d7"
|
||||
assert seen["headers"] == {"Authorization": "Bearer sa-token-value"}
|
||||
|
||||
|
||||
def test_sign_policy_fail_closed_when_caller_token_unavailable(tmp_path):
|
||||
from warden.ca import CAError
|
||||
from warden import policy as policy_mod
|
||||
|
||||
pubkey = tmp_path / "id.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA test\n")
|
||||
cfg = PolicyConfig(
|
||||
enabled=True,
|
||||
fail_closed=True,
|
||||
caller_auth=CallerAuthConfig(mode="file", token_path=tmp_path / "absent"),
|
||||
)
|
||||
spec = CertSpec(
|
||||
actor_name="agt-state-hub-bridge",
|
||||
actor_type=ActorType.AGT,
|
||||
principals=["agt"],
|
||||
ttl_hours=24,
|
||||
pubkey_path=pubkey,
|
||||
)
|
||||
with pytest.raises(CAError, match="caller identity unavailable"):
|
||||
policy_mod.check_sign_policy(cfg, spec)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ policy:
|
|||
tenant: tenant:platform
|
||||
subject_env: WARDEN_POLICY_SUBJECT
|
||||
system: ops-warden
|
||||
caller_auth: # how ops-warden identifies itself — see "Caller identity"
|
||||
mode: none
|
||||
```
|
||||
|
||||
| Key | Default | Description |
|
||||
|
|
@ -76,6 +78,7 @@ policy:
|
|||
| `tenant` | `tenant:platform` | Tenant sent in subject and resource |
|
||||
| `subject_env` | `WARDEN_POLICY_SUBJECT` | Env var for IAM subject id override |
|
||||
| `system` | `ops-warden` | Resource system identifier |
|
||||
| `caller_auth.mode` | `none` | `none` \| `file` \| `env` \| `command` — source of the `Authorization` bearer token flex-auth TokenReviews ([Caller identity](#caller-identity-warden-wp-0031)) |
|
||||
|
||||
Set `WARDEN_POLICY_SUBJECT` to the caller's IAM profile `sub` when available.
|
||||
If unset, the actor name is used as subject id.
|
||||
|
|
@ -196,6 +199,75 @@ Evidence: `history/2026-06-23-flex-auth-policy-gate-production-smoke.md`.
|
|||
|
||||
---
|
||||
|
||||
## Caller identity (WARDEN-WP-0031)
|
||||
|
||||
flex-auth authenticates the **caller** before it evaluates the request. The
|
||||
in-cluster pin `flex-auth-ops-warden` (FLEX-WP-0016) passes the bearer token to a
|
||||
Kubernetes TokenReview and requires the principal
|
||||
`system:serviceaccount:ops-warden:ops-warden` for any request carrying
|
||||
`resource.system: ops-warden`.
|
||||
|
||||
Until ops-warden sends that header the pin logs
|
||||
|
||||
```
|
||||
caller authentication warning: caller is not authenticated
|
||||
```
|
||||
|
||||
and can only run `callerAuth.mode: warn`. Per ADHOC-2026-08-17-T01,
|
||||
`policy.enabled` must not flip while `/v1/check` still answers unauthenticated
|
||||
callers — so **the missing header is what blocks the flip**, and it is
|
||||
ops-warden's to fix, not flex-auth's.
|
||||
|
||||
### Configure a token source
|
||||
|
||||
```yaml
|
||||
policy:
|
||||
caller_auth:
|
||||
mode: none # none | file | env | command
|
||||
token_path: /var/run/secrets/flex-auth/token # mode: file
|
||||
token_env: WARDEN_POLICY_CALLER_TOKEN # mode: env
|
||||
command: kubectl create token ops-warden -n ops-warden --audience flex-auth --duration 10m
|
||||
audience: flex-auth
|
||||
```
|
||||
|
||||
| Mode | Use it when |
|
||||
| --- | --- |
|
||||
| `none` | Default. No header — pre-FLEX-WP-0016 behaviour, only viable while the pin is in `warn` |
|
||||
| `file` | In-cluster PEP with a projected, audience-bound ServiceAccount token |
|
||||
| `env` | The token is already in the environment (CI, a wrapper) |
|
||||
| `command` | Workstation `warden sign` — mint a short-lived bound token per call |
|
||||
|
||||
A workstation is not a ServiceAccount, which is why `command` exists. The token is
|
||||
read, sent, and dropped: never cached, written, or logged (ADR-0002). If a token
|
||||
is configured but cannot be obtained, the sign is **refused** under
|
||||
`fail_closed` — falling back to an anonymous call would defeat the gate.
|
||||
|
||||
### Readiness gate
|
||||
|
||||
```bash
|
||||
python scripts/check_policy_caller_identity.py # offline
|
||||
python scripts/check_policy_caller_identity.py --url http://127.0.0.1:19090 # port-forward of the warn pin
|
||||
```
|
||||
|
||||
Exit 0 ready / 1 not ready / 2 bad input. It prints the token's length and a
|
||||
truncated fingerprint, never the value, so its output is safe to paste into a
|
||||
handoff message. A live `401` means the token was sent but rejected (audience or
|
||||
binding); `403` means it authenticated but may not represent `system: ops-warden`.
|
||||
|
||||
### Flip sequence
|
||||
|
||||
1. Configure `caller_auth`; `check_policy_caller_identity.py` exits 0 offline.
|
||||
2. Smoke against a port-forward of the warn pin. The evidence is the **absence**
|
||||
of `caller authentication warning` in its log, not the `allow` — warn serves
|
||||
unauthenticated callers too, so an allow alone proves nothing.
|
||||
3. Ask flex-auth to set `callerAuth.mode: enforce` (their FLEX-WP-0016 T03).
|
||||
4. Re-run the gate against the enforcing pin.
|
||||
5. Only then: `policy.enabled: true`, `fail_closed: true`.
|
||||
|
||||
Reversing 3 and 5 401s every `warden sign`.
|
||||
|
||||
---
|
||||
|
||||
## Production rollout
|
||||
|
||||
**Keep `policy.enabled: false` until flex-auth is reachable** at `policy.flex_auth_url`
|
||||
|
|
@ -210,7 +282,9 @@ with `fail_closed: true`, unreachable flex-auth blocks all signs.
|
|||
| 3 | ops-warden | Regenerate registry from inventory: `scripts/build_flex_auth_registry.py` |
|
||||
| 4 | ops-warden | Local smoke: `./scripts/policy_gate_production_smoke.sh` |
|
||||
| 5 | operator | Vault smoke: `make credential-exec-ops-warden-smoke` in `railiance-platform` (or manual `SMOKE_VAULT=1` fallback) |
|
||||
| 6 | operator | Set `policy.flex_auth_url` in `~/.config/warden/warden.yaml` |
|
||||
| 6 | operator | Set `policy.flex_auth_url` in `~/.config/warden/warden.yaml` — the pin is `flex-auth-ops-warden.flex-auth.svc.cluster.local:8080`, reached from a workstation via port-forward or tunnel |
|
||||
| 6a | ops-warden | Configure `policy.caller_auth`; `scripts/check_policy_caller_identity.py` exits 0 (see **Caller identity**) |
|
||||
| 6b | flex-auth | Set `callerAuth.mode: enforce` on the ops-warden pin (FLEX-WP-0016 T03) |
|
||||
| 7 | operator | Set `policy.enabled: true`; keep `fail_closed: true` |
|
||||
| 8 | operator | Allow smoke: `warden sign <actor>` — `signatures.log` has `policy_decision_id` |
|
||||
| 9 | operator | Deny smoke: e.g. `--ttl` above max — CLI shows flex-auth `reason`, no cert |
|
||||
|
|
|
|||
133
workplans/WARDEN-WP-0031-policy-caller-identity.md
Normal file
133
workplans/WARDEN-WP-0031-policy-caller-identity.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
---
|
||||
id: WARDEN-WP-0031
|
||||
type: workplan
|
||||
title: "Calling-side identity for flex-auth, so policy.enabled can flip"
|
||||
domain: infotech
|
||||
repo: ops-warden
|
||||
status: active
|
||||
owner: ops-warden
|
||||
topic_slug: netkingdom
|
||||
planning_priority: P1
|
||||
depends_on_workplans:
|
||||
- WARDEN-WP-0007
|
||||
related_workplans:
|
||||
- WARDEN-WP-0009
|
||||
created: "2026-08-19"
|
||||
updated: "2026-08-19"
|
||||
---
|
||||
|
||||
# WARDEN-WP-0031 — Calling-side identity for flex-auth
|
||||
|
||||
flex-auth shipped `flex-auth-ops-warden` (FLEX-WP-0016 T01/T02): an
|
||||
independently rollable in-cluster pin carrying ops-warden's production registry
|
||||
and policy package, on digest `sha256:138aa347…`, at
|
||||
`flex-auth-ops-warden.flex-auth.svc.cluster.local:8080`.
|
||||
|
||||
It runs `callerAuth.mode: warn`, and it says why in its own logs:
|
||||
|
||||
```
|
||||
caller authentication warning: caller is not authenticated
|
||||
```
|
||||
|
||||
`src/warden/policy.py` posted `/v1/check` with **no `Authorization` header**.
|
||||
flex-auth authenticates the caller with a Kubernetes TokenReview and binds
|
||||
`resource.system: ops-warden` to `system:serviceaccount:ops-warden:ops-warden`;
|
||||
an unauthenticated caller can only be served in `warn`. So the pin cannot
|
||||
enforce, and per ADHOC-2026-08-17-T01 — `policy.enabled` must not flip anywhere
|
||||
while `/v1/check` still answers unauthenticated callers — `policy.enabled` stays
|
||||
false. The gap is ours, not flex-auth's, and this workplan closes it.
|
||||
|
||||
Warn is also not A2 evidence: a request that succeeds because failures are
|
||||
downgraded proves nothing about the enforcing path.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Concern | Owner |
|
||||
| --- | --- |
|
||||
| The pin, its digest, `callerAuth.mode` | flex-auth |
|
||||
| Sending a caller identity on `/v1/check` | **ops-warden** (this workplan) |
|
||||
| Choosing the token source on a given host | ops-warden operator |
|
||||
| `policy.enabled: true` in `warden.yaml` | ops-warden operator |
|
||||
|
||||
## Design note — fail closed on identity too
|
||||
|
||||
When a caller token is configured but cannot be obtained, `check_sign_policy`
|
||||
raises under `fail_closed` rather than retrying unauthenticated. Falling back to
|
||||
an anonymous call is precisely the behaviour that keeps the pin in `warn`; a
|
||||
gate that silently degrades to the ungated path is not a gate (ADR-0004's choke
|
||||
point argument, applied to ops-warden as a caller).
|
||||
|
||||
## Tasks
|
||||
|
||||
```task
|
||||
id: WARDEN-WP-0031-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
**Caller identity on the outbound policy call.** `policy.caller_auth` in
|
||||
`warden.yaml` (`mode: none | file | env | command`, `token_path`, `token_env`,
|
||||
`command`, `audience`); `src/warden/caller_identity.py` resolves the token at
|
||||
call time and never caches, logs, or echoes it (ADR-0002); both
|
||||
`check_sign_policy` and `check_fetch_policy` attach `Authorization: Bearer …`.
|
||||
Whitespace-bearing and empty tokens are rejected before the call, because
|
||||
flex-auth rejects them outright.
|
||||
|
||||
Done 2026-08-19. `mode: none` remains the default, so behaviour is unchanged
|
||||
until an operator opts in. Tests in `tests/test_policy.py`.
|
||||
|
||||
```task
|
||||
id: WARDEN-WP-0031-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
**Readiness gate.** `scripts/check_policy_caller_identity.py` — read-only:
|
||||
config loads, mode is not `none`, a token is actually obtainable, and with
|
||||
`--url` a live `/v1/check` against a port-forward of the warn pin. Prints the
|
||||
token's length and a truncated SHA-256 fingerprint only, so its output is safe
|
||||
to paste into a handoff. Exit 0 ready / 1 not ready / 2 bad input. Distinguishes
|
||||
401 (token not accepted — audience or binding) from 403 (authenticated but not
|
||||
allowed to represent `system: ops-warden`).
|
||||
|
||||
Done 2026-08-19.
|
||||
|
||||
```task
|
||||
id: WARDEN-WP-0031-T03
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
**Docs.** `examples/warden.production.example.yaml` gains the `caller_auth`
|
||||
block with both realistic sources, and its `flex_auth_url` is corrected — the
|
||||
example pointed at `flex-auth.flex-auth.svc.cluster.local`, a Service that does
|
||||
not exist. `wiki/PolicyGatedSigning.md` gains the caller-identity section and
|
||||
the flip sequence.
|
||||
|
||||
Done 2026-08-19.
|
||||
|
||||
```task
|
||||
id: WARDEN-WP-0031-T04
|
||||
status: wait
|
||||
priority: high
|
||||
```
|
||||
|
||||
**Pick the token source and prove it against the warn pin.** Operator work on
|
||||
the real host: `kubectl create token` (workstation) or a projected token
|
||||
(in-cluster PEP), then
|
||||
`python scripts/check_policy_caller_identity.py --url http://127.0.0.1:19090`
|
||||
against a port-forward. Expect `effect=allow` for `agt-state-hub-bridge` while
|
||||
the warn log stops printing `caller authentication warning` — the absence of
|
||||
that line, not the allow, is the evidence.
|
||||
|
||||
```task
|
||||
id: WARDEN-WP-0031-T05
|
||||
status: wait
|
||||
priority: high
|
||||
```
|
||||
|
||||
**Sequence the flip.** Only after T04: tell flex-auth to set
|
||||
`callerAuth.mode: enforce` on `flex-auth-ops-warden` (their FLEX-WP-0016 T03),
|
||||
re-run the gate against the enforcing pin, and only then set
|
||||
`policy.enabled: true` with `fail_closed: true`. Flipping before enforce buys
|
||||
nothing; flipping before T04 401s every `warden sign`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue