Link CCR approval to State Hub decision
This commit is contained in:
4
Makefile
4
Makefile
@@ -25,6 +25,7 @@ ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap
|
||||
ARGOCD_REPOSITORY_SECRET ?=
|
||||
CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml
|
||||
CREDENTIAL_CHANGE ?= CCR-2026-0001
|
||||
STATE_HUB_URL ?= http://127.0.0.1:8000
|
||||
OPENBAO_TOKEN_GRANT_ARGS ?=
|
||||
OPENBAO_WORKLOAD_KV_ARGS ?=
|
||||
CREDENTIAL_HELPER_GLOBAL_ARGS ?=
|
||||
@@ -206,6 +207,9 @@ credential-change-status: ## Render credential change request readiness status
|
||||
credential-change-status-json: ## Render credential change request readiness status as JSON
|
||||
scripts/credential-change.py status --json $(CREDENTIAL_CHANGE)
|
||||
|
||||
credential-change-sync-decision: ## Sync resolved State Hub decision back into a CCR
|
||||
scripts/credential-change.py sync-decision $(CREDENTIAL_CHANGE) --state-hub-url $(STATE_HUB_URL)
|
||||
|
||||
credential-change-apply-plan: ## Render approved-only operator apply plan
|
||||
scripts/credential-change.py apply-plan $(CREDENTIAL_CHANGE)
|
||||
|
||||
|
||||
@@ -85,3 +85,6 @@ state_hub:
|
||||
related_workplan_id: RAILIANCE-WP-0006
|
||||
ops_warden_reply_message_id: b175c561-7858-43f5-a309-949b0dede1b4
|
||||
ops_warden_batch_message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076
|
||||
decision_id: 250669d0-8475-4527-9624-cd072249f9a9
|
||||
decision_api_url: http://127.0.0.1:8000/decisions/250669d0-8475-4527-9624-cd072249f9a9
|
||||
decision_dashboard_url: http://127.0.0.1:3000/decisions
|
||||
|
||||
@@ -153,6 +153,7 @@ scripts/credential-change.py confirm-binding CCR-2026-0001 --reviewer <name> --c
|
||||
scripts/credential-change.py approve CCR-2026-0001 --reviewer <name> --comment "..."
|
||||
scripts/credential-change.py deny CCR-2026-0001 --reviewer <name> --comment "..."
|
||||
scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer <name> --comment "..."
|
||||
make credential-change-sync-decision CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
make credential-change-apply-plan CREDENTIAL_CHANGE=CCR-2026-0001
|
||||
```
|
||||
|
||||
@@ -177,6 +178,13 @@ State Hub should not hold secret values. It can be the first review UI because
|
||||
it already supports messages, progress, task status, and cross-repo
|
||||
coordination.
|
||||
|
||||
For CCR review, create a pending State Hub decision that links to the CCR and
|
||||
contains only non-secret coordinates. Operators can inspect it in the dashboard
|
||||
at `http://127.0.0.1:3000/decisions` and resolve it with a rationale beginning
|
||||
with `APPROVE:`, `DENY:`, or `NEEDS_CHANGES:`. Then run
|
||||
`make credential-change-sync-decision CREDENTIAL_CHANGE=<CCR>` to copy the
|
||||
resolved decision back into the CCR file-backed state.
|
||||
|
||||
## OpenBao Role
|
||||
|
||||
OpenBao remains authoritative for:
|
||||
|
||||
@@ -6,6 +6,8 @@ import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -439,6 +441,11 @@ def status_payload(ccr: dict[str, Any], warnings: list[str]) -> dict[str, Any]:
|
||||
"resolvable": frontdoor.get("resolvable") is True,
|
||||
"command": frontdoor.get("command"),
|
||||
},
|
||||
"state_hub": {
|
||||
"decision_id": ccr.get("state_hub", {}).get("decision_id"),
|
||||
"decision_api_url": ccr.get("state_hub", {}).get("decision_api_url"),
|
||||
"decision_dashboard_url": ccr.get("state_hub", {}).get("decision_dashboard_url"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -450,6 +457,12 @@ def render_status(payload: dict[str, Any]) -> str:
|
||||
f"Resolvable: {payload['frontdoor_resolvable']}",
|
||||
f"Apply allowed: {payload['apply_allowed']}",
|
||||
]
|
||||
decision = payload.get("state_hub", {}).get("decision_api_url")
|
||||
dashboard = payload.get("state_hub", {}).get("decision_dashboard_url")
|
||||
if decision:
|
||||
lines.append(f"State Hub decision: {decision}")
|
||||
if dashboard:
|
||||
lines.append(f"Decision dashboard: {dashboard}")
|
||||
command = payload["access_frontdoor"].get("command")
|
||||
if command:
|
||||
lines.append(f"Command: {command}")
|
||||
@@ -510,6 +523,80 @@ def confirm_binding(path: Path, reviewer: str, comment: str) -> None:
|
||||
dump_yaml(path, ccr)
|
||||
|
||||
|
||||
STATE_HUB_DECISION_PREFIXES = (
|
||||
("NEEDS_CHANGES", "needs_changes"),
|
||||
("NEEDS CHANGES", "needs_changes"),
|
||||
("REQUEST CHANGES", "needs_changes"),
|
||||
("APPROVE", "approved"),
|
||||
("APPROVED", "approved"),
|
||||
("DENY", "denied"),
|
||||
("DENIED", "denied"),
|
||||
("REJECT", "denied"),
|
||||
("REJECTED", "denied"),
|
||||
)
|
||||
|
||||
|
||||
def state_hub_get_json(base_url: str, path: str) -> dict[str, Any]:
|
||||
url = f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
data = json.load(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
fail(f"State Hub GET {url} failed with HTTP {exc.code}")
|
||||
except OSError as exc:
|
||||
fail(f"State Hub GET {url} failed: {exc}")
|
||||
if not isinstance(data, dict):
|
||||
fail(f"State Hub GET {url} returned non-object JSON")
|
||||
return data
|
||||
|
||||
|
||||
def state_hub_decision_status(ccr: dict[str, Any], base_url: str) -> dict[str, Any]:
|
||||
decision_id = ccr.get("state_hub", {}).get("decision_id")
|
||||
if not decision_id:
|
||||
fail("CCR has no state_hub.decision_id")
|
||||
return state_hub_get_json(base_url, f"/decisions/{decision_id}")
|
||||
|
||||
|
||||
def ccr_status_from_state_hub_rationale(rationale: str) -> str:
|
||||
normalized = rationale.strip().upper().replace("-", "_")
|
||||
for prefix, status in STATE_HUB_DECISION_PREFIXES:
|
||||
if normalized == prefix or normalized.startswith(f"{prefix}:"):
|
||||
return status
|
||||
fail(
|
||||
"resolved State Hub decision rationale must start with "
|
||||
"APPROVE:, DENY:, or NEEDS_CHANGES:"
|
||||
)
|
||||
|
||||
|
||||
def sync_state_hub_decision(path: Path, base_url: str) -> dict[str, Any]:
|
||||
ccr, errors, warnings = validate_ccr(path)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"[FAIL] {path.name}: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
for warning in warnings:
|
||||
print(f"[WARN] {path.name}: {warning}", file=sys.stderr)
|
||||
|
||||
decision = state_hub_decision_status(ccr, base_url)
|
||||
if decision.get("status") != "resolved":
|
||||
return decision
|
||||
|
||||
rationale = str(decision.get("rationale") or "")
|
||||
status = ccr_status_from_state_hub_rationale(rationale)
|
||||
reviewer = str(decision.get("decided_by") or "state-hub")
|
||||
append_decision(
|
||||
path,
|
||||
status,
|
||||
reviewer,
|
||||
f"State Hub decision {decision['id']}: {rationale}",
|
||||
)
|
||||
updated = load_yaml(path)
|
||||
state_hub = updated.setdefault("state_hub", {})
|
||||
state_hub["decision_resolved_at"] = decision.get("decided_at")
|
||||
dump_yaml(path, updated)
|
||||
return decision
|
||||
|
||||
|
||||
def command_validate(args: argparse.Namespace) -> int:
|
||||
refs = args.refs or [str(path) for path in sorted(ccr_dir().glob("*.y*ml"))]
|
||||
if not refs:
|
||||
@@ -584,6 +671,19 @@ def command_confirm_binding(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def command_sync_decision(args: argparse.Namespace) -> int:
|
||||
path = resolve_ccr(args.ref)
|
||||
decision = sync_state_hub_decision(path, args.state_hub_url)
|
||||
if decision.get("status") == "resolved":
|
||||
print(f"[OK] {path.name} <- State Hub decision {decision['id']}")
|
||||
else:
|
||||
print(
|
||||
f"[WAIT] State Hub decision {decision['id']} is {decision.get('status')}; "
|
||||
"resolve it before syncing CCR status"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate, render, and review non-secret credential change requests."
|
||||
@@ -633,6 +733,17 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
binding.add_argument("--comment", required=True)
|
||||
binding.set_defaults(func=command_confirm_binding)
|
||||
|
||||
sync_decision = sub.add_parser(
|
||||
"sync-decision",
|
||||
help="Sync an approved/denied/needs_changes CCR decision from State Hub",
|
||||
)
|
||||
sync_decision.add_argument("ref")
|
||||
sync_decision.add_argument(
|
||||
"--state-hub-url",
|
||||
default=os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000"),
|
||||
)
|
||||
sync_decision.set_defaults(func=command_sync_decision)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
|
||||
@@ -64,8 +64,52 @@ class CredentialChangeTests(unittest.TestCase):
|
||||
self.assertEqual(payload["access_frontdoor"]["catalog_id"], "whynot-design-npm-publish")
|
||||
self.assertEqual(payload["apply_blockers"], ["apply requires status approved, got proposed"])
|
||||
self.assertEqual(payload["warnings"], [])
|
||||
self.assertEqual(
|
||||
payload["state_hub"]["decision_id"],
|
||||
"250669d0-8475-4527-9624-cd072249f9a9",
|
||||
)
|
||||
self.assertIn("front door is marked resolvable=false", payload["frontdoor_blockers"])
|
||||
|
||||
def test_state_hub_rationale_prefix_maps_to_ccr_status(self) -> None:
|
||||
cases = {
|
||||
"APPROVE: scoped path and binding are correct": "approved",
|
||||
"DENY: wrong tenant": "denied",
|
||||
"NEEDS_CHANGES: use a read-only token": "needs_changes",
|
||||
"request changes: clarify service account": "needs_changes",
|
||||
}
|
||||
for rationale, expected in cases.items():
|
||||
with self.subTest(rationale=rationale):
|
||||
self.assertEqual(
|
||||
credential_change.ccr_status_from_state_hub_rationale(rationale),
|
||||
expected,
|
||||
)
|
||||
with self.assertRaises(SystemExit):
|
||||
credential_change.ccr_status_from_state_hub_rationale("looks good")
|
||||
|
||||
def test_sync_state_hub_decision_updates_ccr_status(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
copied = Path(tmp) / self.sample.name
|
||||
shutil.copy2(self.sample, copied)
|
||||
original = credential_change.state_hub_decision_status
|
||||
try:
|
||||
credential_change.state_hub_decision_status = lambda _ccr, _url: {
|
||||
"id": "250669d0-8475-4527-9624-cd072249f9a9",
|
||||
"status": "resolved",
|
||||
"rationale": "APPROVE: scoped path and confirmed binding are acceptable",
|
||||
"decided_by": "unit-test",
|
||||
"decided_at": "2026-06-27T22:00:00Z",
|
||||
}
|
||||
credential_change.sync_state_hub_decision(copied, "http://state-hub.test")
|
||||
finally:
|
||||
credential_change.state_hub_decision_status = original
|
||||
ccr, errors, warnings = credential_change.validate_ccr(copied)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(ccr["status"], "approved")
|
||||
self.assertEqual(ccr["review"]["comments"][-1]["reviewer"], "unit-test")
|
||||
self.assertIn("State Hub decision", ccr["review"]["comments"][-1]["comment"])
|
||||
self.assertEqual(ccr["state_hub"]["decision_resolved_at"], "2026-06-27T22:00:00Z")
|
||||
|
||||
def test_kubernetes_auth_payload_uses_service_account_bounds(self) -> None:
|
||||
ccr, errors, _warnings = credential_change.validate_ccr(self.issue_core)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
@@ -207,7 +207,9 @@ comments to the CCR. Added `confirm-binding` for explicit non-secret auth
|
||||
binding confirmation. Added `status` plus Make targets
|
||||
`credential-change-status` and `credential-change-status-json` so ops-warden can
|
||||
consume `readiness`/`resolvable` without scraping prose. Remaining T05 work is
|
||||
State Hub decision-event emission and tighter chat integration.
|
||||
State Hub decision-event emission and tighter chat integration. Created a
|
||||
State Hub decision for `CCR-2026-0001` and added `sync-decision` so resolved
|
||||
State Hub decisions can update the file-backed CCR status.
|
||||
|
||||
## T06 - Build an interactive runbook for apply and verify
|
||||
|
||||
|
||||
Reference in New Issue
Block a user