diff --git a/SCOPE.md b/SCOPE.md index 0311fe4..87a1e45 100644 --- a/SCOPE.md +++ b/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 + diff --git a/examples/warden.production.example.yaml b/examples/warden.production.example.yaml index 3231321..c981def 100644 --- a/examples/warden.production.example.yaml +++ b/examples/warden.production.example.yaml @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/scripts/check_policy_caller_identity.py b/scripts/check_policy_caller_identity.py new file mode 100755 index 0000000..dae5f9c --- /dev/null +++ b/scripts/check_policy_caller_identity.py @@ -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()) diff --git a/src/warden/caller_identity.py b/src/warden/caller_identity.py new file mode 100644 index 0000000..e77c52a --- /dev/null +++ b/src/warden/caller_identity.py @@ -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 ` 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}"} diff --git a/src/warden/config.py b/src/warden/config.py index e42abee..4cf51b6 100644 --- a/src/warden/config.py +++ b/src/warden/config.py @@ -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( diff --git a/src/warden/policy.py b/src/warden/policy.py index 920b302..150dd22 100644 --- a/src/warden/policy.py +++ b/src/warden/policy.py @@ -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: diff --git a/tests/test_policy.py b/tests/test_policy.py index 5b9464f..728782f 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -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" \ No newline at end of file + 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) diff --git a/wiki/PolicyGatedSigning.md b/wiki/PolicyGatedSigning.md index 56291eb..982ddba 100644 --- a/wiki/PolicyGatedSigning.md +++ b/wiki/PolicyGatedSigning.md @@ -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 ` — `signatures.log` has `policy_decision_id` | | 9 | operator | Deny smoke: e.g. `--ttl` above max — CLI shows flex-auth `reason`, no cert | diff --git a/workplans/WARDEN-WP-0031-policy-caller-identity.md b/workplans/WARDEN-WP-0031-policy-caller-identity.md new file mode 100644 index 0000000..798879a --- /dev/null +++ b/workplans/WARDEN-WP-0031-policy-caller-identity.md @@ -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`.