diff --git a/Makefile b/Makefile index 13a35b3..18af1fc 100644 --- a/Makefile +++ b/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) diff --git a/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml b/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml index 9654ec8..707be57 100644 --- a/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml +++ b/credential-change-requests/CCR-2026-0001-whynot-design-npm-publish.yaml @@ -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 diff --git a/docs/credential-change-approval.md b/docs/credential-change-approval.md index 4abc34f..a93beb1 100644 --- a/docs/credential-change-approval.md +++ b/docs/credential-change-approval.md @@ -153,6 +153,7 @@ scripts/credential-change.py confirm-binding CCR-2026-0001 --reviewer --c scripts/credential-change.py approve CCR-2026-0001 --reviewer --comment "..." scripts/credential-change.py deny CCR-2026-0001 --reviewer --comment "..." scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer --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=` to copy the +resolved decision back into the CCR file-backed state. + ## OpenBao Role OpenBao remains authoritative for: diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 90bd4b9..b257466 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -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 diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py index da10295..b4e0197 100644 --- a/tests/test_credential_change.py +++ b/tests/test_credential_change.py @@ -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, []) diff --git a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md index c8e70a9..24e23aa 100644 --- a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md +++ b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md @@ -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