diff --git a/history/2026-06-17-openbao-production-verify.md b/history/2026-06-17-openbao-production-verify.md new file mode 100644 index 0000000..6768ecc --- /dev/null +++ b/history/2026-06-17-openbao-production-verify.md @@ -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/` 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 --pubkey ` 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) \ No newline at end of file diff --git a/src/warden/ca.py b/src/warden/ca.py index 4cdc307..9be3dfd 100644 --- a/src/warden/ca.py +++ b/src/warden/ca.py @@ -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") diff --git a/src/warden/cli.py b/src/warden/cli.py index 702e21e..c7698c2 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -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}") diff --git a/src/warden/config.py b/src/warden/config.py index 8704fb7..e42abee 100644 --- a/src/warden/config.py +++ b/src/warden/config.py @@ -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, ) diff --git a/src/warden/models.py b/src/warden/models.py index 766060d..689c36b 100644 --- a/src/warden/models.py +++ b/src/warden/models.py @@ -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: diff --git a/src/warden/policy.py b/src/warden/policy.py new file mode 100644 index 0000000..d800f08 --- /dev/null +++ b/src/warden/policy.py @@ -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) \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index 63055be..dec6b4d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 0000000..5b9464f --- /dev/null +++ b/tests/test_policy.py @@ -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" \ No newline at end of file diff --git a/wiki/OpsWardenConfig.md b/wiki/OpsWardenConfig.md index 3536038..40e37b7 100644 --- a/wiki/OpsWardenConfig.md +++ b/wiki/OpsWardenConfig.md @@ -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`) | --- diff --git a/wiki/PolicyGatedSigning.md b/wiki/PolicyGatedSigning.md index 22f7f37..cbfa1e2 100644 --- a/wiki/PolicyGatedSigning.md +++ b/wiki/PolicyGatedSigning.md @@ -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 --pubkey @@ -29,71 +18,82 @@ warden sign --pubkey 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: - 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/` | +| `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/` | +| `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` \ No newline at end of file diff --git a/workplans/WARDEN-WP-0007-policy-gate-and-production-verify.md b/workplans/WARDEN-WP-0007-policy-gate-and-production-verify.md new file mode 100644 index 0000000..9a49acc --- /dev/null +++ b/workplans/WARDEN-WP-0007-policy-gate-and-production-verify.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 \ No newline at end of file