feat: opt-in flex-auth policy gate and OpenBao verify (WP-0007)

Add policy.py client that calls flex-auth /v1/check before sign/issue when
policy.enabled is true. Record policy_decision_id in signatures.log. Default
off preserves existing inventory-only behavior. Document production OpenBao
health probe and update config/wiki references.
This commit is contained in:
2026-06-17 08:37:14 +02:00
parent 1865e0744e
commit 8e9383a33a
11 changed files with 552 additions and 71 deletions

View File

@@ -56,6 +56,8 @@ def _append_signature_log(
"cert_path": str(record.cert_path),
"backend": backend,
}
if spec.policy_decision_id:
entry["policy_decision_id"] = spec.policy_decision_id
state_dir.mkdir(parents=True, exist_ok=True)
with (state_dir / "signatures.log").open("a") as f:
f.write(json.dumps(entry) + "\n")

View File

@@ -12,6 +12,7 @@ from rich.table import Table
from warden.ca import CAError, LocalCA, parse_cert_metadata
from warden.config import ConfigError, WardenConfig, load_config
from warden.policy import check_sign_policy
from warden.inventory import ActorEntry, InventoryError, PrincipalsInventory, load_inventory, save_inventory
from warden.models import ActorType, CertSpec, DEFAULT_TTL_HOURS, validate_actor_name
from warden.scorecard import run_scorecard
@@ -54,6 +55,13 @@ def _get_ca(cfg: WardenConfig):
return LocalCA(cfg.ca_key, cfg.state_dir)
def _apply_policy_gate(cfg: WardenConfig, spec: CertSpec) -> None:
"""Run flex-auth check when policy.enabled; sets spec.policy_decision_id."""
decision_id = check_sign_policy(cfg.policy, spec)
if decision_id:
spec.policy_decision_id = decision_id
# ---------------------------------------------------------------------------
# warden sign
# ---------------------------------------------------------------------------
@@ -91,6 +99,7 @@ def sign(
ca = _get_ca(cfg)
try:
_apply_policy_gate(cfg, spec)
record = ca.sign(spec)
except CAError as e:
err.print(f"[red]Signing failed:[/red] {e}")
@@ -142,6 +151,7 @@ def issue(
identity=actor_name,
)
try:
_apply_policy_gate(cfg, spec)
record = ca.sign(spec)
except CAError as e:
err.print(f"[red]Signing failed:[/red] {e}")

View File

@@ -13,6 +13,16 @@ class ConfigError(Exception):
"""Raised when config is invalid or missing."""
@dataclass
class PolicyConfig:
enabled: bool = False
flex_auth_url: str = "http://127.0.0.1:8080"
fail_closed: bool = True
tenant: str = "tenant:platform"
subject_env: str = "WARDEN_POLICY_SUBJECT"
system: str = "ops-warden"
@dataclass
class VaultConfig:
addr: str
@@ -32,6 +42,7 @@ class WardenConfig:
state_dir: Path = field(
default_factory=lambda: Path.home() / ".local" / "state" / "warden"
)
policy: PolicyConfig = field(default_factory=PolicyConfig)
def _default_config_path() -> Path:
@@ -105,10 +116,21 @@ def load_config(path: Optional[Path] = None) -> WardenConfig:
)
)
policy_raw = raw.get("policy") or {}
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")),
fail_closed=bool(policy_raw.get("fail_closed", True)),
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")),
)
return WardenConfig(
backend=backend,
ca_key=ca_key,
vault=vault_cfg,
inventory_path=inventory_path,
state_dir=state_dir,
policy=policy_cfg,
)

View File

@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import List
from typing import List, Optional
class ActorType(str, Enum):
@@ -52,6 +52,7 @@ class CertSpec:
ttl_hours: int
principals: List[str]
identity: str = "" # defaults to actor_name if empty
policy_decision_id: Optional[str] = None
def __post_init__(self) -> None:
if not self.identity:

93
src/warden/policy.py Normal file
View File

@@ -0,0 +1,93 @@
"""flex-auth policy gate for SSH signing (opt-in via warden.yaml)."""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import httpx
from warden.ca import CAError
from warden.config import PolicyConfig
from warden.models import CertSpec
def pubkey_fingerprint(pubkey_path: Path) -> str:
"""SHA256 fingerprint of normalized pubkey text (for audit context)."""
text = pubkey_path.read_text().strip()
digest = hashlib.sha256(text.encode()).hexdigest()
return f"sha256:{digest}"
def _subject_id(cfg: PolicyConfig, spec: CertSpec) -> str:
return os.environ.get(cfg.subject_env, "").strip() or spec.actor_name
def check_sign_policy(cfg: PolicyConfig, spec: CertSpec) -> str | None:
"""Call flex-auth /v1/check before signing.
Returns decision id when policy is enabled and effect is allow.
Returns None when policy is disabled.
Raises CAError on deny or when fail_closed and flex-auth is unreachable.
"""
if not cfg.enabled:
return None
pubkey_path = Path(os.path.expanduser(str(spec.pubkey_path)))
if not pubkey_path.exists():
raise CAError(f"Public key not found: {pubkey_path}")
request = {
"subject": {
"id": _subject_id(cfg, spec),
"type": spec.actor_type.value,
"tenant": cfg.tenant,
},
"action": "sign",
"resource": {
"id": f"ssh-cert:actor/{spec.actor_name}",
"type": "ssh-certificate",
"system": cfg.system,
"tenant": cfg.tenant,
},
"context": {
"actor_name": spec.actor_name,
"actor_type": spec.actor_type.value,
"principals": spec.principals,
"ttl_hours": spec.ttl_hours,
"pubkey_fingerprint": pubkey_fingerprint(pubkey_path),
},
}
url = cfg.flex_auth_url.rstrip("/") + "/v1/check"
try:
response = httpx.post(url, json=request, timeout=10.0)
response.raise_for_status()
except httpx.HTTPStatusError as e:
if cfg.fail_closed:
raise CAError(
f"flex-auth denied or rejected sign policy check (HTTP {e.response.status_code})"
) from e
return None
except httpx.RequestError as e:
if cfg.fail_closed:
raise CAError(
f"flex-auth unreachable at {cfg.flex_auth_url!r} "
f"(fail_closed=true): {e}"
) from e
return None
try:
decision = response.json()
except ValueError as e:
raise CAError("flex-auth returned non-JSON decision") from e
effect = str(decision.get("effect", "")).lower()
decision_id = decision.get("id") or decision.get("request_id")
if effect != "allow":
reason = decision.get("reason") or "no reason provided"
raise CAError(f"flex-auth denied SSH sign for {spec.actor_name!r}: {reason}")
if not decision_id:
raise CAError("flex-auth allow decision missing id")
return str(decision_id)