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
|
**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`
|
and smoke evidence (WP-0009 archived). flex-auth published the `ssh-certificate`
|
||||||
policy package (FLEX-WP-0006). `policy.enabled` remains **false** in production
|
policy package (FLEX-WP-0006) and now runs the in-cluster pin
|
||||||
until flex-auth is deployed to a reachable URL (flex-auth FLEX-WP-0007).
|
`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
|
**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
|
readiness gate (`scripts/check_tunnel_cert_readiness.py`) plus an opt-in offline
|
||||||
|
|
@ -273,6 +277,7 @@ for the rest.
|
||||||
| WP | Status | Focus |
|
| WP | Status | Focus |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| WP-0027 | `backlog` | Tamper-resistant credential governance + mass rotation/lockdown (Strand B follow-on to WP-0026) |
|
| 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 |
|
| 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).
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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`)
|
- **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)
|
- **Access routing:** WP-0010 + WP-0011 shipped (`warden route`, pointer catalog)
|
||||||
- **Policy gate:** caller shipped (WP-0007); registry + smoke complete (WP-0009 archived).
|
- **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`,
|
- **Workload posture:** WP-0015 shipped (standard, descriptors, `warden policy`,
|
||||||
conformance checker, dev doubles); canon landing owner-driven
|
conformance checker, dev doubles); canon landing owner-driven
|
||||||
- **ops-bridge cert_command:** WP-0016 shipped to pilot-ready (readiness gate +
|
- **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).
|
# 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
|
# See wiki/PolicyGatedSigning.md (operator checklist) and wiki/playbooks/operator-openbao-token-hygiene.md
|
||||||
policy:
|
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
|
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
|
fail_closed: true
|
||||||
tenant: tenant:platform
|
tenant: tenant:platform
|
||||||
subject_env: WARDEN_POLICY_SUBJECT
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
@ -13,6 +14,35 @@ class ConfigError(Exception):
|
||||||
"""Raised when config is invalid or missing."""
|
"""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
|
@dataclass
|
||||||
class PolicyConfig:
|
class PolicyConfig:
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
|
@ -21,6 +51,7 @@ class PolicyConfig:
|
||||||
tenant: str = "tenant:platform"
|
tenant: str = "tenant:platform"
|
||||||
subject_env: str = "WARDEN_POLICY_SUBJECT"
|
subject_env: str = "WARDEN_POLICY_SUBJECT"
|
||||||
system: str = "ops-warden"
|
system: str = "ops-warden"
|
||||||
|
caller_auth: "CallerAuthConfig" = field(default_factory=lambda: CallerAuthConfig())
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -117,6 +148,33 @@ def load_config(path: Optional[Path] = None) -> WardenConfig:
|
||||||
)
|
)
|
||||||
|
|
||||||
policy_raw = raw.get("policy") or {}
|
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(
|
policy_cfg = PolicyConfig(
|
||||||
enabled=bool(policy_raw.get("enabled", False)),
|
enabled=bool(policy_raw.get("enabled", False)),
|
||||||
flex_auth_url=str(policy_raw.get("flex_auth_url", "http://127.0.0.1:8080")),
|
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")),
|
tenant=str(policy_raw.get("tenant", "tenant:platform")),
|
||||||
subject_env=str(policy_raw.get("subject_env", "WARDEN_POLICY_SUBJECT")),
|
subject_env=str(policy_raw.get("subject_env", "WARDEN_POLICY_SUBJECT")),
|
||||||
system=str(policy_raw.get("system", "ops-warden")),
|
system=str(policy_raw.get("system", "ops-warden")),
|
||||||
|
caller_auth=caller_cfg,
|
||||||
)
|
)
|
||||||
|
|
||||||
return WardenConfig(
|
return WardenConfig(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from pathlib import Path
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from warden.ca import CAError
|
from warden.ca import CAError
|
||||||
|
from warden.caller_identity import CallerIdentityError, caller_auth_headers
|
||||||
from warden.config import PolicyConfig
|
from warden.config import PolicyConfig
|
||||||
from warden.models import CertSpec
|
from warden.models import CertSpec
|
||||||
|
|
||||||
|
|
@ -19,6 +20,21 @@ def pubkey_fingerprint(pubkey_path: Path) -> str:
|
||||||
return f"sha256:{digest}"
|
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:
|
def _subject_id(cfg: PolicyConfig, spec: CertSpec) -> str:
|
||||||
return os.environ.get(cfg.subject_env, "").strip() or spec.actor_name
|
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"
|
url = cfg.flex_auth_url.rstrip("/") + "/v1/check"
|
||||||
|
headers = _caller_headers(cfg)
|
||||||
try:
|
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()
|
response.raise_for_status()
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if cfg.fail_closed:
|
if cfg.fail_closed:
|
||||||
|
|
@ -120,8 +137,9 @@ def check_fetch_policy(
|
||||||
}
|
}
|
||||||
|
|
||||||
url = cfg.flex_auth_url.rstrip("/") + "/v1/check"
|
url = cfg.flex_auth_url.rstrip("/") + "/v1/check"
|
||||||
|
headers = _caller_headers(cfg)
|
||||||
try:
|
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()
|
response.raise_for_status()
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if cfg.fail_closed:
|
if cfg.fail_closed:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Tests for warden.policy — flex-auth gate."""
|
"""Tests for warden.policy — flex-auth gate."""
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
@ -6,7 +7,12 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from warden.ca import CAError
|
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.models import ActorType, CertSpec
|
||||||
from warden.policy import check_sign_policy, pubkey_fingerprint
|
from warden.policy import check_sign_policy, pubkey_fingerprint
|
||||||
|
|
||||||
|
|
@ -138,3 +144,122 @@ def test_subject_from_env(tmp_path, monkeypatch):
|
||||||
|
|
||||||
body = post.call_args[1]["json"]
|
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
|
tenant: tenant:platform
|
||||||
subject_env: WARDEN_POLICY_SUBJECT
|
subject_env: WARDEN_POLICY_SUBJECT
|
||||||
system: ops-warden
|
system: ops-warden
|
||||||
|
caller_auth: # how ops-warden identifies itself — see "Caller identity"
|
||||||
|
mode: none
|
||||||
```
|
```
|
||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|
|
@ -76,6 +78,7 @@ policy:
|
||||||
| `tenant` | `tenant:platform` | Tenant sent in subject and resource |
|
| `tenant` | `tenant:platform` | Tenant sent in subject and resource |
|
||||||
| `subject_env` | `WARDEN_POLICY_SUBJECT` | Env var for IAM subject id override |
|
| `subject_env` | `WARDEN_POLICY_SUBJECT` | Env var for IAM subject id override |
|
||||||
| `system` | `ops-warden` | Resource system identifier |
|
| `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.
|
Set `WARDEN_POLICY_SUBJECT` to the caller's IAM profile `sub` when available.
|
||||||
If unset, the actor name is used as subject id.
|
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
|
## Production rollout
|
||||||
|
|
||||||
**Keep `policy.enabled: false` until flex-auth is reachable** at `policy.flex_auth_url`
|
**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` |
|
| 3 | ops-warden | Regenerate registry from inventory: `scripts/build_flex_auth_registry.py` |
|
||||||
| 4 | ops-warden | Local smoke: `./scripts/policy_gate_production_smoke.sh` |
|
| 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) |
|
| 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` |
|
| 7 | operator | Set `policy.enabled: true`; keep `fail_closed: true` |
|
||||||
| 8 | operator | Allow smoke: `warden sign <actor>` — `signatures.log` has `policy_decision_id` |
|
| 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 |
|
| 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