diff --git a/credential-change-requests/CCR-2026-0051-mas-tem-relay-smtp.yaml b/credential-change-requests/CCR-2026-0051-mas-tem-relay-smtp.yaml index deeead3..e25a49f 100644 --- a/credential-change-requests/CCR-2026-0051-mas-tem-relay-smtp.yaml +++ b/credential-change-requests/CCR-2026-0051-mas-tem-relay-smtp.yaml @@ -99,3 +99,6 @@ lifecycle: metadata version only. compromised: Revoke the SMTP credential at Scaleway, disable the role, rotate the KV value, then re-enable after review. +state_hub: + decision_id: e633607e-ff3e-46ec-bb6f-143e8b44f1af + decision_api_url: http://127.0.0.1:8000/decisions/e633607e-ff3e-46ec-bb6f-143e8b44f1af diff --git a/scripts/ccr_intake.py b/scripts/ccr_intake.py index d4b9734..a9e9082 100644 --- a/scripts/ccr_intake.py +++ b/scripts/ccr_intake.py @@ -29,6 +29,7 @@ CCR_FILE_RE = re.compile(r"^(CCR-\d{4}-\d{4})(-[a-z0-9-]+)?\.ya?ml$") SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") TTL_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400} PLATFORM_OWNER = "railiance-platform-owner" +PLATFORM_TOPIC_ID = "ca369340-a64e-442e-98f1-a4fa7dc74a38" def load_profile(path: Path = DEFAULT_PROFILE) -> tuple[dict[str, Any], str]: @@ -278,6 +279,63 @@ def command_check_branch(args: argparse.Namespace, cc: Any) -> int: return 1 if errors else 0 +def decision_payload(ccr: dict[str, Any], cc: Any) -> dict[str, Any]: + openbao = ccr["openbao"] + auth = openbao["auth"] + review = ccr["review"]["comments"][-1] + description = "\n".join([ + f"Founder decision for {ccr['id']}: {ccr.get('title')}.", + "", + "Non-secret request summary:", + f"- Requester: {(ccr.get('requester') or {}).get('agent')}", + f"- OpenBao path: {openbao.get('kv_path')} fields {openbao.get('fields')}", + f"- Policy: {openbao.get('policy_name')} ({openbao.get('policy_file')})", + f"- Auth: {auth.get('method')} role {auth.get('role')}, SA {bound_list(auth, 'service_account_names')}" + f" in {bound_list(auth, 'service_account_namespaces')}, ttl {auth.get('ttl')}", + f"- Risk: {(ccr.get('risk') or {}).get('classification')}", + f"- Platform review: {review.get('reviewer')}: {review.get('comment')}", + "", + cc.render_decision_templates(ccr), + "", + "Approval permits apply after confirm-binding; it exposes and stores no value.", + ]) + return { + "topic_id": PLATFORM_TOPIC_ID, + "title": f"Review {ccr['id']} {ccr.get('title')}", + "description": description, + "escalation_note": "Founder decision required before OpenBao credential lane apply.", + } + + +def request_decision(path: Path, cc: Any, base_url: str) -> dict[str, Any]: + """Open the founder's State Hub decision for a platform-reviewed CCR (RPF-WP-0061-T04).""" + ccr, _warnings = cc.validate_or_exit(path) + if ccr.get("status") != "proposed": + cc.fail(f"request-decision requires status proposed, got {ccr.get('status')}") + if (ccr.get("state_hub") or {}).get("decision_id"): + cc.fail(f"{ccr['id']} already has State Hub decision {ccr['state_hub']['decision_id']}") + comments = (ccr.get("review") or {}).get("comments") or [] + last = comments[-1] if comments else {} + if last.get("decision") != "approved" or not str(last.get("reviewer", "")).startswith(PLATFORM_OWNER): + cc.fail("request-decision requires a platform-owner approval as the latest review comment") + decision = cc.state_hub_post_json(base_url, "/decisions/", decision_payload(ccr, cc)) + if not decision.get("id") or decision.get("status") not in (None, "open"): + cc.fail(f"State Hub returned an unexpected decision: {decision.get('id')} {decision.get('status')}") + state_hub = ccr.setdefault("state_hub", {}) + state_hub["decision_id"] = decision["id"] + state_hub["decision_api_url"] = f"{base_url.rstrip('/')}/decisions/{decision['id']}" + ccr["updated"] = datetime.now(timezone.utc).date().isoformat() + cc.dump_yaml(path, ccr) + return decision + + +def command_request_decision(args: argparse.Namespace, cc: Any) -> int: + path = cc.resolve_ccr(args.ref) + decision = request_decision(path, cc, args.state_hub_url) + print(f"[OK] {path.name}: State Hub decision {decision['id']} opened; commit the CCR file") + return 0 + + def git(*argv: str) -> None: subprocess.run(["git", "-C", str(REPO_DIR), *argv], check=True) @@ -359,6 +417,13 @@ def add_parsers(sub: Any, cc: Any, state_hub_url: str) -> None: check_branch.add_argument("--main-root", required=True, help="tree of main to compare against") check_branch.set_defaults(func=lambda args: command_check_branch(args, cc)) + decision_p = sub.add_parser( + "request-decision", help="Open the founder State Hub decision for a platform-reviewed proposed CCR" + ) + decision_p.add_argument("ref") + decision_p.add_argument("--state-hub-url", default=state_hub_url) + decision_p.set_defaults(func=lambda args: command_request_decision(args, cc)) + submit_p = sub.add_parser( "submit", help="File a client CCR draft under the next CCR id and run the standard review", diff --git a/tests/test_ccr_intake.py b/tests/test_ccr_intake.py index d276952..c827b0b 100644 --- a/tests/test_ccr_intake.py +++ b/tests/test_ccr_intake.py @@ -247,6 +247,32 @@ class CcrIntakeTests(unittest.TestCase): errors = ccr_intake.branch_gate(main_root, Path(STANDARD).stem, cc) self.assertTrue(any("already exists on main" in e for e in errors), errors) + def test_request_decision_records_id_after_platform_review(self) -> None: + ccr_intake.review_standard(self.path, cc) + with mock.patch.object(cc, "state_hub_post_json", return_value={"id": "dec-1", "status": "open"}) as post: + ccr_intake.request_decision(self.path, cc, "http://hub") + payload = post.call_args.args[2] + self.assertIn("APPROVE: CCR-2026-0051", payload["description"]) + self.assertNotIn("status", payload) + state_hub = cc.load_yaml(self.path)["state_hub"] + self.assertEqual(state_hub["decision_id"], "dec-1") + self.assertEqual(cc.load_yaml(self.path)["status"], "proposed") + with self.assertRaises(SystemExit): + ccr_intake.request_decision(self.path, cc, "http://hub") + + def test_request_decision_requires_platform_approval(self) -> None: + self.edit(lambda d: d["risk"].update(classification="high")) + ccr_intake.review_standard(self.path, cc) + with mock.patch.object(cc, "state_hub_post_json") as post, self.assertRaises(SystemExit): + ccr_intake.request_decision(self.path, cc, "http://hub") + post.assert_not_called() + + def test_request_decision_refuses_pre_resolved_decision(self) -> None: + ccr_intake.review_standard(self.path, cc) + resolved = {"id": "dec-2", "status": "resolved"} + with mock.patch.object(cc, "state_hub_post_json", return_value=resolved), self.assertRaises(SystemExit): + ccr_intake.request_decision(self.path, cc, "http://hub") + if __name__ == "__main__": unittest.main() diff --git a/workplans/RPF-WP-0061-self-service-ccr-intake.md b/workplans/RPF-WP-0061-self-service-ccr-intake.md index 19f37f4..c31e9a1 100644 --- a/workplans/RPF-WP-0061-self-service-ccr-intake.md +++ b/workplans/RPF-WP-0061-self-service-ccr-intake.md @@ -143,11 +143,26 @@ fact. ```task id: RPF-WP-0061-T04 -status: todo +status: progress priority: medium state_hub_task_id: "15ac9eae-9890-5140-b1ca-cd0ad3ca77ac" ``` +2026-10-18: `credential-change.py request-decision ` opens the founder +decision for a `proposed` CCR whose latest review is a platform-owner +approval, refuses a second decision or a decision returned already resolved, +and writes `state_hub.decision_id` into the CCR. The existing `sync-decision` +moves the file. First use: CCR-2026-0051, decision e633607e. The CI gate +passed on main at 3d97117 after adding the jsonschema pin. + +Open, needs a founder choice: the hub does not authenticate decisions. +`decided_by` on resolve is free text, and `POST /decisions/` accepts an +already-resolved decision. With self-service, the requester could resolve its +own CCR decision and run `sync-decision`. This gap existed before (the +platform worker never verified the resolver) but self-service makes it +cheaper to exploit. Until it is closed, `sync-decision` on a self-service CCR +stays a platform/founder step. + ## T05 — Client documentation and first self-service CCR ```task