generated from coulomb/repo-seed
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:
75
history/2026-06-17-openbao-production-verify.md
Normal file
75
history/2026-06-17-openbao-production-verify.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# OpenBao Production Verification — 2026-06-17
|
||||
|
||||
**Workplan:** WARDEN-WP-0007-T01
|
||||
**Endpoint:** `https://bao.coulomb.social`
|
||||
**Operator:** codex (automated probe, no secrets recorded)
|
||||
|
||||
---
|
||||
|
||||
## Health probe
|
||||
|
||||
```bash
|
||||
curl -s "https://bao.coulomb.social/v1/sys/health" | python3 -m json.tool
|
||||
```
|
||||
|
||||
**Result (2026-06-17):**
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `initialized` | `true` |
|
||||
| `sealed` | `false` |
|
||||
| `standby` | `false` |
|
||||
| `version` | `2.5.4` |
|
||||
| `cluster_name` | `vault-cluster-ebe7da39` |
|
||||
| `replication_performance_mode` | `primary` |
|
||||
|
||||
OpenBao is **reachable, initialized, and unsealed**. Suitable as the production
|
||||
platform secrets endpoint for ops-warden `backend: vault`.
|
||||
|
||||
---
|
||||
|
||||
## Authenticated API (blocked without token)
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" "https://bao.coulomb.social/v1/sys/mounts"
|
||||
```
|
||||
|
||||
**Result:** HTTP `403` (expected without `X-Vault-Token`).
|
||||
|
||||
Full SSH engine verification (`bao secrets list`, role TTL alignment, live
|
||||
`warden sign`) requires a **scoped operator token** with permission to:
|
||||
|
||||
1. List mounts and confirm `ssh/` engine is enabled
|
||||
2. Read `ssh/roles/{adm,agt,atm}-role` TTL limits
|
||||
3. Call `POST /v1/ssh/sign/<role>` for each actor type
|
||||
|
||||
See `wiki/OpenBaoSshEngineChecklist.md` for the step-by-step checklist.
|
||||
|
||||
---
|
||||
|
||||
## Blockers for end-to-end `warden sign`
|
||||
|
||||
| Blocker | Owner | Notes |
|
||||
| --- | --- | --- |
|
||||
| No `~/.config/warden/warden.yaml` on dev workstation | Operator | Point `vault.addr` at `https://bao.coulomb.social` |
|
||||
| No scoped `VAULT_TOKEN` in session | Operator | OIDC login via KeyCape / `bao login` |
|
||||
| SSH engine roles may not be provisioned | `railiance-platform` | Run checklist in `wiki/OpenBaoSshEngineChecklist.md` |
|
||||
| flex-auth policy package for `ssh-certificate` | `flex-auth` | Out of scope for WP-0007; gate is opt-in |
|
||||
|
||||
---
|
||||
|
||||
## Recommended next operator steps
|
||||
|
||||
1. Create production `warden.yaml` with `backend: vault` and `vault.addr`.
|
||||
2. Export short-lived `VAULT_TOKEN` after OIDC login.
|
||||
3. Run `wiki/OpenBaoSshEngineChecklist.md` items 1–6.
|
||||
4. Test: `warden sign <actor> --pubkey <path>` against a known inventory actor.
|
||||
5. Enable `policy.enabled: true` only after flex-auth `ssh-certificate` policies exist.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `wiki/OpsWardenConfig.md` — production config examples
|
||||
- `wiki/OpenBaoSshEngineChecklist.md` — SSH engine validation
|
||||
- `wiki/PolicyGatedSigning.md` — opt-in flex-auth gate (implemented WP-0007)
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
93
src/warden/policy.py
Normal 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)
|
||||
@@ -82,3 +82,35 @@ def test_default_vault_token_env(tmp_path):
|
||||
})
|
||||
cfg = load_config(cfg_path)
|
||||
assert cfg.vault.token_env == "VAULT_TOKEN"
|
||||
|
||||
|
||||
def test_policy_defaults_disabled(tmp_path):
|
||||
cfg_path = tmp_path / "warden.yaml"
|
||||
write_yaml(cfg_path, {"backend": "local", "ca_key": str(tmp_path / "ca")})
|
||||
cfg = load_config(cfg_path)
|
||||
assert cfg.policy.enabled is False
|
||||
assert cfg.policy.flex_auth_url == "http://127.0.0.1:8080"
|
||||
assert cfg.policy.fail_closed is True
|
||||
|
||||
|
||||
def test_policy_block_parsed(tmp_path):
|
||||
cfg_path = tmp_path / "warden.yaml"
|
||||
write_yaml(cfg_path, {
|
||||
"backend": "local",
|
||||
"ca_key": str(tmp_path / "ca"),
|
||||
"policy": {
|
||||
"enabled": True,
|
||||
"flex_auth_url": "http://flex-auth:8080",
|
||||
"fail_closed": False,
|
||||
"tenant": "tenant:coulomb",
|
||||
"subject_env": "MY_SUBJECT",
|
||||
"system": "warden-test",
|
||||
},
|
||||
})
|
||||
cfg = load_config(cfg_path)
|
||||
assert cfg.policy.enabled is True
|
||||
assert cfg.policy.flex_auth_url == "http://flex-auth:8080"
|
||||
assert cfg.policy.fail_closed is False
|
||||
assert cfg.policy.tenant == "tenant:coulomb"
|
||||
assert cfg.policy.subject_env == "MY_SUBJECT"
|
||||
assert cfg.policy.system == "warden-test"
|
||||
|
||||
140
tests/test_policy.py
Normal file
140
tests/test_policy.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Tests for warden.policy — flex-auth gate."""
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from warden.ca import CAError
|
||||
from warden.config import PolicyConfig
|
||||
from warden.models import ActorType, CertSpec
|
||||
from warden.policy import check_sign_policy, pubkey_fingerprint
|
||||
|
||||
|
||||
def _spec(pubkey_path: Path) -> CertSpec:
|
||||
return CertSpec(
|
||||
actor_name="agt-state-hub-bridge",
|
||||
actor_type=ActorType.AGT,
|
||||
pubkey_path=pubkey_path,
|
||||
ttl_hours=24,
|
||||
principals=["agt-task-bridge"],
|
||||
)
|
||||
|
||||
|
||||
def test_pubkey_fingerprint(tmp_path):
|
||||
pubkey = tmp_path / "key.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA test\n")
|
||||
fp = pubkey_fingerprint(pubkey)
|
||||
assert fp.startswith("sha256:")
|
||||
assert len(fp) == 7 + 64
|
||||
|
||||
|
||||
def test_disabled_returns_none(tmp_path):
|
||||
pubkey = tmp_path / "key.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA\n")
|
||||
cfg = PolicyConfig(enabled=False)
|
||||
assert check_sign_policy(cfg, _spec(pubkey)) is None
|
||||
|
||||
|
||||
def test_allow_returns_decision_id(tmp_path):
|
||||
pubkey = tmp_path / "key.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA\n")
|
||||
cfg = PolicyConfig(enabled=True, flex_auth_url="http://flex-auth.test")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"effect": "allow", "id": "dec-123"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("warden.policy.httpx.post", return_value=mock_response) as post:
|
||||
result = check_sign_policy(cfg, _spec(pubkey))
|
||||
|
||||
assert result == "dec-123"
|
||||
post.assert_called_once()
|
||||
call_kwargs = post.call_args
|
||||
assert call_kwargs[0][0] == "http://flex-auth.test/v1/check"
|
||||
body = call_kwargs[1]["json"]
|
||||
assert body["action"] == "sign"
|
||||
assert body["resource"]["type"] == "ssh-certificate"
|
||||
assert body["context"]["actor_name"] == "agt-state-hub-bridge"
|
||||
|
||||
|
||||
def test_deny_raises_ca_error(tmp_path):
|
||||
pubkey = tmp_path / "key.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA\n")
|
||||
cfg = PolicyConfig(enabled=True)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"effect": "deny",
|
||||
"reason": "actor not authorized",
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("warden.policy.httpx.post", return_value=mock_response):
|
||||
with pytest.raises(CAError, match="denied SSH sign"):
|
||||
check_sign_policy(cfg, _spec(pubkey))
|
||||
|
||||
|
||||
def test_unreachable_fail_closed_raises(tmp_path):
|
||||
pubkey = tmp_path / "key.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA\n")
|
||||
cfg = PolicyConfig(enabled=True, fail_closed=True)
|
||||
|
||||
with patch(
|
||||
"warden.policy.httpx.post",
|
||||
side_effect=httpx.ConnectError("connection refused"),
|
||||
):
|
||||
with pytest.raises(CAError, match="unreachable"):
|
||||
check_sign_policy(cfg, _spec(pubkey))
|
||||
|
||||
|
||||
def test_unreachable_fail_open_returns_none(tmp_path):
|
||||
pubkey = tmp_path / "key.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA\n")
|
||||
cfg = PolicyConfig(enabled=True, fail_closed=False)
|
||||
|
||||
with patch(
|
||||
"warden.policy.httpx.post",
|
||||
side_effect=httpx.ConnectError("connection refused"),
|
||||
):
|
||||
assert check_sign_policy(cfg, _spec(pubkey)) is None
|
||||
|
||||
|
||||
def test_http_error_fail_closed_raises(tmp_path):
|
||||
pubkey = tmp_path / "key.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA\n")
|
||||
cfg = PolicyConfig(enabled=True, fail_closed=True)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 403
|
||||
error = httpx.HTTPStatusError(
|
||||
"forbidden", request=MagicMock(), response=mock_response
|
||||
)
|
||||
|
||||
with patch("warden.policy.httpx.post", side_effect=error):
|
||||
with pytest.raises(CAError, match="HTTP 403"):
|
||||
check_sign_policy(cfg, _spec(pubkey))
|
||||
|
||||
|
||||
def test_missing_pubkey_raises(tmp_path):
|
||||
cfg = PolicyConfig(enabled=True)
|
||||
spec = _spec(tmp_path / "missing.pub")
|
||||
with pytest.raises(CAError, match="Public key not found"):
|
||||
check_sign_policy(cfg, spec)
|
||||
|
||||
|
||||
def test_subject_from_env(tmp_path, monkeypatch):
|
||||
pubkey = tmp_path / "key.pub"
|
||||
pubkey.write_text("ssh-ed25519 AAAA\n")
|
||||
cfg = PolicyConfig(enabled=True, subject_env="WARDEN_POLICY_SUBJECT")
|
||||
monkeypatch.setenv("WARDEN_POLICY_SUBJECT", "iam:bernd")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"effect": "allow", "id": "dec-456"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("warden.policy.httpx.post", return_value=mock_response) as post:
|
||||
check_sign_policy(cfg, _spec(pubkey))
|
||||
|
||||
body = post.call_args[1]["json"]
|
||||
assert body["subject"]["id"] == "iam:bernd"
|
||||
@@ -35,6 +35,12 @@ ca_key: ~/.ssh/ops-ca-user
|
||||
|
||||
inventory_path: ~/.config/warden/inventory.yaml
|
||||
state_dir: ~/.local/state/warden
|
||||
|
||||
# Optional flex-auth gate (default off — see wiki/PolicyGatedSigning.md)
|
||||
policy:
|
||||
enabled: false
|
||||
flex_auth_url: http://127.0.0.1:8080
|
||||
fail_closed: true
|
||||
```
|
||||
|
||||
### Bootstrapping the local CA key
|
||||
@@ -78,6 +84,12 @@ vault:
|
||||
|
||||
inventory_path: ~/.config/warden/inventory.yaml
|
||||
state_dir: ~/.local/state/warden
|
||||
|
||||
# Enable after flex-auth ssh-certificate policies are deployed:
|
||||
# policy:
|
||||
# enabled: true
|
||||
# flex_auth_url: http://flex-auth.flex-auth.svc.cluster.local:8080
|
||||
# fail_closed: true
|
||||
```
|
||||
|
||||
### Example — in-cluster caller (pod or trusted host)
|
||||
@@ -212,12 +224,33 @@ hosts:
|
||||
|
||||
---
|
||||
|
||||
## Policy gate (flex-auth, opt-in)
|
||||
|
||||
When `policy.enabled: true`, `warden sign` and `warden issue` call flex-auth
|
||||
`POST /v1/check` before signing. Deny or unreachable (with `fail_closed: true`)
|
||||
blocks issuance. Allowed decisions store `policy_decision_id` in `signatures.log`.
|
||||
|
||||
```yaml
|
||||
policy:
|
||||
enabled: false # default — no behavior change
|
||||
flex_auth_url: http://127.0.0.1:8080
|
||||
fail_closed: true # deny when flex-auth unreachable
|
||||
tenant: tenant:platform
|
||||
subject_env: WARDEN_POLICY_SUBJECT
|
||||
system: ops-warden
|
||||
```
|
||||
|
||||
Full request shape and rollout notes: `wiki/PolicyGatedSigning.md`.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `WARDEN_CONFIG` | `~/.config/warden/warden.yaml` | Config file path |
|
||||
| `VAULT_TOKEN` | — | API token for `backend: vault` (OpenBao or Vault; name configurable via `vault.token_env`) |
|
||||
| `WARDEN_POLICY_SUBJECT` | — | IAM subject id for flex-auth checks (when `policy.enabled`) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
# Policy-Gated SSH Signing (design)
|
||||
# Policy-Gated SSH Signing
|
||||
|
||||
Date: 2026-06-17
|
||||
Status: **design only** — not implemented in WARDEN-WP-0006
|
||||
Status: **implemented (opt-in)** — WARDEN-WP-0007
|
||||
|
||||
Today `warden sign` authorizes via **inventory allow-list** and TTL policy only.
|
||||
This document proposes flex-auth integration so SSH issuance matches the
|
||||
NetKingdom authorization path before OpenBao/SSH engine signing.
|
||||
By default `warden sign` authorizes via **inventory allow-list** and TTL policy
|
||||
only. When `policy.enabled: true` in `warden.yaml`, ops-warden calls flex-auth
|
||||
before signing and records the decision id in `signatures.log`.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Inventory-only gating is sufficient for early ops but weak for:
|
||||
|
||||
- many agents and automations across tenants
|
||||
- temporary elevation without inventory edits
|
||||
- unified audit with flex-auth decision envelopes
|
||||
- aligning SSH issuance with IAM Profile claims
|
||||
|
||||
---
|
||||
|
||||
## Target flow (v2)
|
||||
## Flow
|
||||
|
||||
```text
|
||||
warden sign <actor> --pubkey <path>
|
||||
@@ -29,71 +18,82 @@ warden sign <actor> --pubkey <path>
|
||||
Load actor from inventory (type, principals, ttl)
|
||||
|
|
||||
v
|
||||
Obtain identity claims (optional v2.1)
|
||||
OIDC token / env-injected JWT from key-cape session
|
||||
policy.enabled?
|
||||
no -> skip
|
||||
yes -> flex-auth POST /v1/check
|
||||
|
|
||||
v
|
||||
flex-auth Evaluate
|
||||
resource: ssh-certificate / actor:<name>
|
||||
action: sign
|
||||
context: tenant, principal list, pubkey fingerprint, requestor
|
||||
|
|
||||
+-- DENY -> CAError with flex-auth explanation
|
||||
+-- DENY / unreachable (fail_closed) -> CAError
|
||||
|
|
||||
v ALLOW
|
||||
CABackend.sign() (local or OpenBao SSH engine)
|
||||
|
|
||||
v
|
||||
Append signatures.log (+ optional flex-auth audit correlation id)
|
||||
Append signatures.log (+ policy_decision_id when set)
|
||||
```
|
||||
|
||||
The same gate runs for `warden issue` (local backend only).
|
||||
|
||||
---
|
||||
|
||||
## flex-auth request shape (proposed)
|
||||
## flex-auth request shape
|
||||
|
||||
| Field | Source |
|
||||
| --- | --- |
|
||||
| `subject` | IAM Profile `sub` or service identity |
|
||||
| `tenant` | `tenant:platform` or `tenant:coulomb` |
|
||||
| `resource` | `ssh-cert:actor/<actor-name>` |
|
||||
| `subject.id` | `WARDEN_POLICY_SUBJECT` env var, or actor name |
|
||||
| `subject.type` | Actor type (`adm` / `agt` / `atm`) |
|
||||
| `tenant` | `policy.tenant` (default `tenant:platform`) |
|
||||
| `resource.id` | `ssh-cert:actor/<actor-name>` |
|
||||
| `resource.type` | `ssh-certificate` |
|
||||
| `action` | `sign` |
|
||||
| `context.principals` | From inventory |
|
||||
| `context.actor_type` | adm \| agt \| atm |
|
||||
| `context.pubkey_fingerprint` | SHA256 of pubkey |
|
||||
| `context.pubkey_fingerprint` | SHA256 of pubkey text |
|
||||
| `context.ttl_hours` | Requested TTL |
|
||||
|
||||
Decision envelope should return `allow` \| `deny` and `audit_correlation_id`
|
||||
stored in `signatures.log`.
|
||||
flex-auth must return `effect: allow` and an `id` (or `request_id`) on allow.
|
||||
Deny responses include a `reason` surfaced in the CLI error.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
# warden.yaml — policy gate (opt-in, default off)
|
||||
policy:
|
||||
enabled: false
|
||||
flex_auth_url: http://127.0.0.1:8080
|
||||
fail_closed: true
|
||||
tenant: tenant:platform
|
||||
subject_env: WARDEN_POLICY_SUBJECT
|
||||
system: ops-warden
|
||||
```
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `false` | When `true`, call flex-auth before every sign/issue |
|
||||
| `flex_auth_url` | `http://127.0.0.1:8080` | flex-auth base URL |
|
||||
| `fail_closed` | `true` | Deny sign when flex-auth is unreachable or returns HTTP error |
|
||||
| `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 |
|
||||
|
||||
Set `WARDEN_POLICY_SUBJECT` to the caller's IAM profile `sub` when available.
|
||||
If unset, the actor name is used as subject id.
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
| Version | Gate | Notes |
|
||||
| Version | Gate | Status |
|
||||
| --- | --- | --- |
|
||||
| **v1 (today)** | Inventory + TTL max | Shipped |
|
||||
| **v2** | flex-auth required for `backend: vault` production | Config flag |
|
||||
| **v2.1** | Identity claims required for `adm` signs | OIDC from key-cape |
|
||||
| **v3** | Tenant-scoped policies per `tenant:*` | NK recursive rule |
|
||||
| **v1** | Inventory + TTL max | Shipped |
|
||||
| **v2** | flex-auth opt-in via `policy.enabled` | Shipped (WP-0007) |
|
||||
| **v2.1** | Identity claims required for `adm` signs | Planned |
|
||||
| **v3** | Tenant-scoped policies per `tenant:*` | Planned |
|
||||
|
||||
---
|
||||
|
||||
## Configuration sketch (future)
|
||||
|
||||
```yaml
|
||||
# warden.yaml — not implemented
|
||||
policy:
|
||||
enabled: true
|
||||
flex_auth_url: http://flex-auth.flex-auth.svc.cluster.local:8080
|
||||
require_identity_for_adm: true
|
||||
fail_closed: true
|
||||
```
|
||||
|
||||
`fail_closed: true` — if flex-auth unreachable, deny sign (no silent bypass).
|
||||
|
||||
---
|
||||
|
||||
## What stays in inventory (v2)
|
||||
## What stays in inventory
|
||||
|
||||
- Actor registration (name, type, default principals, default TTL)
|
||||
- Host reference documentation
|
||||
@@ -104,26 +104,18 @@ defines **what the actor is allowed to request**.
|
||||
|
||||
---
|
||||
|
||||
## Non-goals (this design)
|
||||
## Production rollout
|
||||
|
||||
- flex-auth implementation changes in WP-0006
|
||||
- Replacing OpenBao SSH engine with flex-auth
|
||||
- Storing flex-auth policies in ops-warden repo
|
||||
|
||||
---
|
||||
|
||||
## Implementation follow-up
|
||||
|
||||
Promote to **WARDEN-WP-0007** (proposed) after:
|
||||
|
||||
1. flex-auth resource type for `ssh-certificate` agreed
|
||||
2. NK platform policy for platform vs tenant sign paths
|
||||
3. Operator approval for `fail_closed` production behavior
|
||||
1. Deploy flex-auth policies for resource type `ssh-certificate`.
|
||||
2. Enable `policy.enabled: true` in production `warden.yaml`.
|
||||
3. Keep `fail_closed: true` unless an explicit break-glass procedure exists.
|
||||
4. Verify `signatures.log` entries include `policy_decision_id`.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `flex-auth/INTENT.md`
|
||||
- `wiki/OpsWardenConfig.md` — full config reference
|
||||
- `wiki/CredentialRouting.md`
|
||||
- `flex-auth/INTENT.md`
|
||||
- `net-kingdom/docs/platform-identity-security-architecture.md`
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: WARDEN-WP-0007
|
||||
type: workplan
|
||||
title: "Policy Gate and Production OpenBao Verification"
|
||||
domain: custodian
|
||||
repo: ops-warden
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
planning_priority: high
|
||||
planning_order: 7
|
||||
created: "2026-06-17"
|
||||
updated: "2026-06-17"
|
||||
---
|
||||
|
||||
# WARDEN-WP-0007 — Policy Gate and Production OpenBao Verification
|
||||
|
||||
**Scope:** Record production OpenBao reachability evidence; implement opt-in
|
||||
flex-auth policy gate before `warden sign` / `warden issue` per
|
||||
`wiki/PolicyGatedSigning.md`.
|
||||
|
||||
**Out of scope:** flex-auth policy package authoring, OpenBao SSH engine mount
|
||||
on Railiance (operator), identity claim requirement (v2.1).
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T1 — Production OpenBao verification evidence
|
||||
|
||||
```task
|
||||
id: WARDEN-WP-0007-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
- [x] Probe `https://bao.coulomb.social/v1/sys/health`
|
||||
- [x] Document results in `history/2026-06-17-openbao-production-verify.md`
|
||||
- [x] Note blockers for full `warden sign` (scoped token, SSH engine roles)
|
||||
|
||||
### T2 — Policy config and flex-auth client
|
||||
|
||||
```task
|
||||
id: WARDEN-WP-0007-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
- [x] `PolicyConfig` in `config.py` (`policy.enabled`, `flex_auth_url`, `fail_closed`)
|
||||
- [x] `policy.py` — POST `/v1/check`, pubkey fingerprint, CAError on deny
|
||||
|
||||
### T3 — Wire policy gate into sign/issue
|
||||
|
||||
```task
|
||||
id: WARDEN-WP-0007-T03
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
- [x] Call policy check before `ca.sign()` when enabled
|
||||
- [x] Store `policy_decision_id` in `signatures.log`
|
||||
|
||||
### T4 — Tests and docs
|
||||
|
||||
```task
|
||||
id: WARDEN-WP-0007-T04
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
- [x] `tests/test_policy.py`
|
||||
- [x] Update `wiki/OpsWardenConfig.md`, `wiki/PolicyGatedSigning.md`
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Production health evidence recorded (non-secret)
|
||||
- [x] `policy.enabled: false` default — no behavior change
|
||||
- [x] `policy.enabled: true` calls flex-auth; deny blocks sign
|
||||
- [x] All unit tests pass
|
||||
Reference in New Issue
Block a user