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

@@ -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 16.
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)

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)

View File

@@ -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
View 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"

View File

@@ -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`) |
---

View File

@@ -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`

View File

@@ -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