Link CCR approval to State Hub decision

This commit is contained in:
tegwick 2026-06-28 00:00:02 +02:00
parent 52687d8b3e
commit 3706ff703e
6 changed files with 173 additions and 1 deletions

View file

@ -25,6 +25,7 @@ ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap
ARGOCD_REPOSITORY_SECRET ?= ARGOCD_REPOSITORY_SECRET ?=
CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml
CREDENTIAL_CHANGE ?= CCR-2026-0001 CREDENTIAL_CHANGE ?= CCR-2026-0001
STATE_HUB_URL ?= http://127.0.0.1:8000
OPENBAO_TOKEN_GRANT_ARGS ?= OPENBAO_TOKEN_GRANT_ARGS ?=
OPENBAO_WORKLOAD_KV_ARGS ?= OPENBAO_WORKLOAD_KV_ARGS ?=
CREDENTIAL_HELPER_GLOBAL_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 credential-change-status-json: ## Render credential change request readiness status as JSON
scripts/credential-change.py status --json $(CREDENTIAL_CHANGE) 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 credential-change-apply-plan: ## Render approved-only operator apply plan
scripts/credential-change.py apply-plan $(CREDENTIAL_CHANGE) scripts/credential-change.py apply-plan $(CREDENTIAL_CHANGE)

View file

@ -85,3 +85,6 @@ state_hub:
related_workplan_id: RAILIANCE-WP-0006 related_workplan_id: RAILIANCE-WP-0006
ops_warden_reply_message_id: b175c561-7858-43f5-a309-949b0dede1b4 ops_warden_reply_message_id: b175c561-7858-43f5-a309-949b0dede1b4
ops_warden_batch_message_id: fe5b1696-8956-4bd5-9d6f-dbde1901a076 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

View file

@ -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 approve CCR-2026-0001 --reviewer <name> --comment "..."
scripts/credential-change.py deny 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 "..." 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 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 it already supports messages, progress, task status, and cross-repo
coordination. 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 Role
OpenBao remains authoritative for: OpenBao remains authoritative for:

View file

@ -6,6 +6,8 @@ import json
import os import os
import re import re
import sys import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any 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, "resolvable": frontdoor.get("resolvable") is True,
"command": frontdoor.get("command"), "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"Resolvable: {payload['frontdoor_resolvable']}",
f"Apply allowed: {payload['apply_allowed']}", 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") command = payload["access_frontdoor"].get("command")
if command: if command:
lines.append(f"Command: {command}") lines.append(f"Command: {command}")
@ -510,6 +523,80 @@ def confirm_binding(path: Path, reviewer: str, comment: str) -> None:
dump_yaml(path, ccr) 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: def command_validate(args: argparse.Namespace) -> int:
refs = args.refs or [str(path) for path in sorted(ccr_dir().glob("*.y*ml"))] refs = args.refs or [str(path) for path in sorted(ccr_dir().glob("*.y*ml"))]
if not refs: if not refs:
@ -584,6 +671,19 @@ def command_confirm_binding(args: argparse.Namespace) -> int:
return 0 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: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Validate, render, and review non-secret credential change requests." 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.add_argument("--comment", required=True)
binding.set_defaults(func=command_confirm_binding) 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 return parser

View file

@ -64,8 +64,52 @@ class CredentialChangeTests(unittest.TestCase):
self.assertEqual(payload["access_frontdoor"]["catalog_id"], "whynot-design-npm-publish") 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["apply_blockers"], ["apply requires status approved, got proposed"])
self.assertEqual(payload["warnings"], []) 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"]) 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: def test_kubernetes_auth_payload_uses_service_account_bounds(self) -> None:
ccr, errors, _warnings = credential_change.validate_ccr(self.issue_core) ccr, errors, _warnings = credential_change.validate_ccr(self.issue_core)
self.assertEqual(errors, []) self.assertEqual(errors, [])

View file

@ -207,7 +207,9 @@ comments to the CCR. Added `confirm-binding` for explicit non-secret auth
binding confirmation. Added `status` plus Make targets binding confirmation. Added `status` plus Make targets
`credential-change-status` and `credential-change-status-json` so ops-warden can `credential-change-status` and `credential-change-status-json` so ops-warden can
consume `readiness`/`resolvable` without scraping prose. Remaining T05 work is 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 ## T06 - Build an interactive runbook for apply and verify