From b6148da443a6817b0df9fb1a4384adb92989da34 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 18 Oct 2026 12:29:47 +0200 Subject: [PATCH] RPF-WP-0061-T01/T02: standard lane profile and self-service CCR submit/review-standard Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 306568@bnt-lap001 Assistant-Session: 8bdf8150-ea56-41e7-9132-13f04f6b0b91 --- registry/ccr-standard-profile.yaml | 76 +++++ scripts/ccr_intake.py | 310 ++++++++++++++++++ scripts/credential-change.py | 3 + tests/test_ccr_intake.py | 199 +++++++++++ .../RPF-WP-0061-self-service-ccr-intake.md | 41 ++- 5 files changed, 619 insertions(+), 10 deletions(-) create mode 100644 registry/ccr-standard-profile.yaml create mode 100644 scripts/ccr_intake.py create mode 100644 tests/test_ccr_intake.py diff --git a/registry/ccr-standard-profile.yaml b/registry/ccr-standard-profile.yaml new file mode 100644 index 0000000..7b9edd7 --- /dev/null +++ b/registry/ccr-standard-profile.yaml @@ -0,0 +1,76 @@ +# Standard lane profile for self-service CCR intake (RPF-WP-0061-T01). +# +# A CCR that satisfies every rule below receives the railiance-platform-owner +# review from `credential-change.py review-standard` without a platform worker +# session. Anything else is routed to the railiance-platform inbox. Changes to +# this file are platform-owner review changes: the profile digest is recorded +# in every review comment it produces. +# +# The profile never grants approval. Founder approval, confirm-binding against +# native state and the applier boundary are unchanged. +profile: ccr-standard-lane +version: 1 + +# Identities allowed to file CCRs with `credential-change.py submit`. +submitters: +- ops-mason + +request_type: workload-kv-read +mount: platform +kv_path_patterns: +- '^platform/workloads/[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*(/[a-z0-9][a-z0-9-]*)?$' +- '^platform/suppliers/[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*$' +policy_name_prefix: workload-kv-read- +# The policy file must equal the generated HCL: data read, plus +# lookup-self/revoke-self when token_self_lifecycle is set. +metadata_read: false +token_capabilities_self: false + +auth: + method: kubernetes + mount: kubernetes + role_prefix: external-secrets- + audience: openbao + max_ttl_seconds: 900 + token_no_default_policy: true + service_accounts: 1 + namespaces: 1 + forbidden_namespaces: + - argocd + - cert-manager + - default + - external-secrets + - kube-public + - kube-system + - openbao + +allowed_risk_classifications: +- low +- medium + +# A second reader of an existing path is a platform judgement. +reader_conflict_statuses: +- draft +- in_flight +- proposed +- needs_changes +- approved +- apply_pending +- applied +- verified +- active +- rotated + +# Calibration against all 51 CCRs on main at 2026-10-18 (version 1, status +# ignored): only CCR-2026-0051 passes. First failing rule for the others: +# metadata_read set ............ 0001-0004, 0006, 0010-0014, 0017-0020, +# 0023-0027, 0029-0031, 0035, 0044-0046 +# non-standard role/policy name 0015, 0016, 0032, 0036, 0037, 0048, 0049 +# audience not openbao ......... 0021, 0022, 0047 +# central ESO SA namespace ..... 0033, 0041 (external-secrets/external-secrets) +# not kubernetes / not platform 0007-0009, 0034, 0042 +# policy file drift ............ 0005 +# risk high .................... 0050 +# other request types .......... 0028, 0038-0040, 0043 +# The profile is deliberately stricter than past practice: older shapes need a +# platform review instead of silently passing. diff --git a/scripts/ccr_intake.py b/scripts/ccr_intake.py new file mode 100644 index 0000000..177c9c6 --- /dev/null +++ b/scripts/ccr_intake.py @@ -0,0 +1,310 @@ +"""Self-service CCR intake: submit, standard-lane review and id checks. + +RPF-WP-0061-T02. Lets a client file a CCR and obtain the platform-owner review +for in-profile lanes without a railiance-platform worker session. The rules +live in registry/ccr-standard-profile.yaml; this module only evaluates them. +Nothing here touches OpenBao or grants approval: the CCR stays `proposed` +until the founder decision is synced. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import subprocess +import urllib.request +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +REPO_DIR = Path(__file__).resolve().parents[1] +DEFAULT_PROFILE = REPO_DIR / "registry/ccr-standard-profile.yaml" +CCR_ID_RE = re.compile(r"^CCR-(\d{4})-(\d{4})$") +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" + + +def load_profile(path: Path = DEFAULT_PROFILE) -> tuple[dict[str, Any], str]: + raw = path.read_bytes() + return yaml.safe_load(raw), "sha256:" + hashlib.sha256(raw).hexdigest() + + +def ttl_seconds(value: Any) -> int | None: + if not isinstance(value, str) or not re.match(r"^[1-9][0-9]*[smhd]$", value): + return None + return int(value[:-1]) * TTL_UNITS[value[-1]] + + +def bound_list(auth: dict[str, Any], key: str) -> list[Any]: + claims = auth.get("bound_claims") or {} + return list(claims.get(key) or auth.get(f"bound_{key}") or []) + + +def ccr_files(directory: Path) -> list[Path]: + return sorted(p for p in directory.glob("CCR-*.y*ml") if CCR_FILE_RE.match(p.name)) + + +def reader_conflicts( + ccr: dict[str, Any], directory: Path, profile: dict[str, Any], cc: Any +) -> list[str]: + kv_path = (ccr.get("openbao") or {}).get("kv_path") + statuses = set(profile["reader_conflict_statuses"]) + conflicts = [] + for path in ccr_files(directory): + other = cc.load_yaml(path) + if other.get("id") == ccr.get("id"): + continue + if (other.get("openbao") or {}).get("kv_path") == kv_path and other.get("status") in statuses: + conflicts.append(f"{other.get('id')} ({other.get('status')})") + return conflicts + + +def standard_violations( + ccr: dict[str, Any], profile: dict[str, Any], directory: Path, cc: Any +) -> list[str]: + """Return every reason the CCR falls outside the standard lane profile.""" + v: list[str] = [] + if ccr.get("request_type") != profile["request_type"]: + return [f"request_type must be {profile['request_type']}"] + openbao = ccr.get("openbao") or {} + auth = openbao.get("auth") or {} + want = profile["auth"] + kv_path = openbao.get("kv_path") or "" + if openbao.get("mount") != profile["mount"]: + v.append(f"mount must be {profile['mount']}") + if not any(re.match(p, kv_path) for p in profile["kv_path_patterns"]): + v.append(f"kv_path {kv_path!r} is outside the standard workload/supplier prefixes") + if openbao.get("metadata_read", True) is not profile["metadata_read"]: + v.append("metadata_read must be false") + if bool(openbao.get("token_capabilities_self", False)) is not profile["token_capabilities_self"]: + v.append("token_capabilities_self is not standard") + policy_name = openbao.get("policy_name") or "" + if not policy_name.startswith(profile["policy_name_prefix"]): + v.append(f"policy_name must start with {profile['policy_name_prefix']}") + policy_file = openbao.get("policy_file") + if not policy_file: + v.append("policy_file is required") + else: + policy_path = REPO_DIR / policy_file + if not policy_path.exists(): + v.append(f"policy_file {policy_file} does not exist") + elif cc.normalized_policy_body(policy_path.read_text()) != cc.normalized_policy_body( + cc.generated_policy_hcl(ccr) + ): + v.append("policy_file differs from the generated policy") + if auth.get("method") != want["method"] or auth.get("mount") != want["mount"]: + v.append(f"auth must be {want['method']} on mount {want['mount']}") + if not str(auth.get("role") or "").startswith(want["role_prefix"]): + v.append(f"auth role must start with {want['role_prefix']}") + if auth.get("audience") != want["audience"]: + v.append(f"audience must be {want['audience']}") + for key in ("ttl", "token_max_ttl", "token_explicit_max_ttl"): + seconds = ttl_seconds(auth.get(key)) + if seconds is None or seconds > want["max_ttl_seconds"]: + v.append(f"{key} must be set and at most {want['max_ttl_seconds']}s") + if auth.get("token_no_default_policy") is not want["token_no_default_policy"]: + v.append("token_no_default_policy must be true") + if auth.get("policies") != [policy_name]: + v.append("auth policies must be exactly [policy_name]") + accounts = bound_list(auth, "service_account_names") + namespaces = bound_list(auth, "service_account_namespaces") + if len(accounts) != want["service_accounts"] or "*" in accounts: + v.append(f"exactly {want['service_accounts']} exact service account required") + if len(namespaces) != want["namespaces"] or "*" in namespaces: + v.append(f"exactly {want['namespaces']} exact namespace required") + forbidden = sorted(set(namespaces) & set(want["forbidden_namespaces"])) + if forbidden: + v.append(f"namespace {forbidden} is reserved for platform components") + risk = (ccr.get("risk") or {}).get("classification") + if risk not in profile["allowed_risk_classifications"]: + v.append(f"risk classification {risk!r} needs a platform review") + conflicts = reader_conflicts(ccr, directory, profile, cc) + if conflicts: + v.append(f"kv_path already has a reader: {', '.join(conflicts)}") + return v + + +def duplicate_ids(directory: Path, cc: Any) -> dict[str, list[str]]: + seen: dict[str, list[str]] = defaultdict(list) + for path in ccr_files(directory): + seen[str(cc.load_yaml(path).get("id"))].append(path.name) + prefix = CCR_FILE_RE.match(path.name).group(1) + if prefix != cc.load_yaml(path).get("id"): + seen[f"{prefix} (file name vs id)"].append(path.name) + return {k: v for k, v in seen.items() if len(v) > 1 or "file name" in k} + + +def next_ccr_id(directory: Path, year: int) -> str: + numbers = [0] + for path in ccr_files(directory): + match = CCR_ID_RE.match(CCR_FILE_RE.match(path.name).group(1)) + if match and int(match.group(1)) == year: + numbers.append(int(match.group(2))) + return f"CCR-{year}-{max(numbers) + 1:04d}" + + +def record_review(path: Path, ccr: dict[str, Any], violations: list[str], profile: dict[str, Any], digest: str, cc: Any) -> None: + label = f"{profile['profile']} v{profile['version']} {digest}" + if violations: + decision = "needs_platform_review" + comment = f"Outside {label}: " + "; ".join(violations) + else: + decision = "approved" + comment = (f"Standard lane: passes {label}. Platform-owner source review only; " + "founder approval and confirm-binding still required.") + comments = ccr.setdefault("review", {}).setdefault("comments", []) + comments.append({ + "at": cc.utc_now(), + "reviewer": f"{PLATFORM_OWNER} (policy-as-code {profile['profile']} v{profile['version']})", + "decision": decision, + "comment": comment, + }) + ccr["updated"] = datetime.now(timezone.utc).date().isoformat() + cc.dump_yaml(path, ccr) + + +def notify_platform(ccr: dict[str, Any], violations: list[str], base_url: str) -> None: + payload = { + "from_agent": (ccr.get("requester") or {}).get("agent", "ccr-intake"), + "to_agent": "railiance-platform", + "subject": f"{ccr['id']} needs platform review (outside standard lane profile)", + "body": f"{ccr['id']} {ccr.get('title')}: " + "; ".join(violations), + } + request = urllib.request.Request( + f"{base_url.rstrip('/')}/messages/", data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(request, timeout=15): + pass + + +def review_standard(path: Path, cc: Any, profile_path: Path = DEFAULT_PROFILE, + write: bool = True) -> list[str]: + ccr, _warnings = cc.validate_or_exit(path) + if ccr.get("status") != "proposed": + cc.fail(f"review-standard requires status proposed, got {ccr.get('status')}") + profile, digest = load_profile(profile_path) + violations = standard_violations(ccr, profile, path.parent, cc) + if write: + record_review(path, ccr, violations, profile, digest, cc) + return violations + + +def command_review_standard(args: argparse.Namespace, cc: Any) -> int: + path = cc.resolve_ccr(args.ref) + violations = review_standard(path, cc, Path(args.profile), write=not args.check) + if not violations: + print(f"[OK] {path.name}: standard lane; platform-owner review recorded" + if not args.check else f"[OK] {path.name}: standard lane") + return 0 + for item in violations: + print(f"[NEEDS-PLATFORM] {path.name}: {item}") + if args.notify and not args.check: + notify_platform(cc.load_yaml(path), violations, args.state_hub_url) + print("[OK] railiance-platform notified") + return 2 + + +def command_check_ids(args: argparse.Namespace, cc: Any) -> int: + duplicates = duplicate_ids(cc.ccr_dir(), cc) + for key, names in duplicates.items(): + print(f"[ERROR] {key}: {names}") + if not duplicates: + print("[OK] CCR ids unique and matching file names") + return 1 if duplicates else 0 + + +def git(*argv: str) -> None: + subprocess.run(["git", "-C", str(REPO_DIR), *argv], check=True) + + +def submit(args: argparse.Namespace, cc: Any) -> tuple[Path, list[str]]: + profile, _digest = load_profile(Path(args.profile)) + if args.requester not in profile["submitters"]: + cc.fail(f"requester {args.requester!r} may not self-submit; allowed: {profile['submitters']}") + if not SLUG_RE.match(args.slug): + cc.fail("slug must be lowercase letters, digits and dashes") + source = cc.resolve_repo_path(args.ccr) + errors: list[str] = [] + cc.scan_for_secrets(source, errors) + if errors: + cc.fail("; ".join(errors)) + ccr = cc.load_yaml(source) + directory = cc.ccr_dir() + today = datetime.now(timezone.utc).date() + ccr_id = next_ccr_id(directory, today.year) + ccr.update(id=ccr_id, status="proposed", created=today.isoformat(), updated=today.isoformat()) + ccr.setdefault("requester", {})["agent"] = args.requester + ccr.setdefault("review", {})["comments"] = [] + openbao = ccr.get("openbao") or {} + if args.policy: + policy_name = openbao.get("policy_name") or cc.fail("openbao.policy_name is required") + target = REPO_DIR / "openbao/policies" / f"{policy_name}.hcl" + policy_text = cc.resolve_repo_path(args.policy).read_text() + cc.reject_secret_text(policy_text, "policy file") + if target.exists() and target.read_text() != policy_text: + cc.fail(f"{target.relative_to(REPO_DIR)} exists with different content") + target.write_text(policy_text) + openbao["policy_file"] = str(target.relative_to(REPO_DIR)) + destination = directory / f"{ccr_id}-{args.slug}.yaml" + cc.dump_yaml(destination, ccr) + violations = review_standard(destination, cc, Path(args.profile)) + return destination, violations + + +def command_submit(args: argparse.Namespace, cc: Any) -> int: + destination, violations = submit(args, cc) + ccr = cc.load_yaml(destination) + print(f"[OK] filed {ccr['id']} -> {destination.relative_to(REPO_DIR)}") + for item in violations: + print(f"[NEEDS-PLATFORM] {item}") + if args.commit or args.push: + branch = f"ccr/{destination.stem}" + git("switch", "-c", branch) + paths = [str(destination.relative_to(REPO_DIR))] + if ccr["openbao"].get("policy_file"): + paths.append(ccr["openbao"]["policy_file"]) + git("add", *paths) + git("commit", "-m", f"File {ccr['id']}: {ccr.get('title')}", "--", *paths) + if args.push: + git("push", "-u", "origin", branch) + if violations and args.notify: + notify_platform(ccr, violations, args.state_hub_url) + return 0 + + +def add_parsers(sub: Any, cc: Any, state_hub_url: str) -> None: + review = sub.add_parser( + "review-standard", + help="Check a proposed CCR against the standard lane profile; record the platform-owner review", + ) + review.add_argument("ref") + review.add_argument("--profile", default=str(DEFAULT_PROFILE)) + review.add_argument("--check", action="store_true", help="report only; do not write the review") + review.add_argument("--notify", action="store_true", help="message railiance-platform when out of profile") + review.add_argument("--state-hub-url", default=state_hub_url) + review.set_defaults(func=lambda args: command_review_standard(args, cc)) + + check_ids = sub.add_parser("check-ids", help="Fail on duplicate CCR ids or id/file name mismatch") + check_ids.set_defaults(func=lambda args: command_check_ids(args, cc)) + + submit_p = sub.add_parser( + "submit", + help="File a client CCR draft under the next CCR id and run the standard review", + ) + submit_p.add_argument("--ccr", required=True, help="client CCR draft (non-secret YAML)") + submit_p.add_argument("--policy", help="client policy HCL; copied to openbao/policies/.hcl") + submit_p.add_argument("--slug", required=True) + submit_p.add_argument("--requester", required=True) + submit_p.add_argument("--profile", default=str(DEFAULT_PROFILE)) + submit_p.add_argument("--commit", action="store_true", help="commit on a new ccr/- branch") + submit_p.add_argument("--push", action="store_true", help="commit and push the ccr/* branch") + submit_p.add_argument("--notify", action="store_true") + submit_p.add_argument("--state-hub-url", default=state_hub_url) + submit_p.set_defaults(func=lambda args: command_submit(args, cc)) diff --git a/scripts/credential-change.py b/scripts/credential-change.py index e75baa6..290a299 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -2384,6 +2384,9 @@ def build_parser() -> argparse.ArgumentParser: scaffold.add_argument("--catalog-manifest") scaffold.set_defaults(func=lambda args: command_scaffold(args, generated_policy_hcl, auth_payload)) + from ccr_intake import add_parsers as add_intake_parsers + add_intake_parsers(sub, sys.modules[__name__], os.environ.get("STATE_HUB_URL", "http://127.0.0.1:8000")) + from provisioning_window import command_window window = sub.add_parser("window-checklist", help="Render an offline attended-window preparation checklist; never execute") window.add_argument("--window-file", required=True) diff --git a/tests/test_ccr_intake.py b/tests/test_ccr_intake.py new file mode 100644 index 0000000..a01d424 --- /dev/null +++ b/tests/test_ccr_intake.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import argparse +import importlib.util +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_DIR / "scripts")) +SPEC = importlib.util.spec_from_file_location( + "credential_change", REPO_DIR / "scripts/credential-change.py" +) +credential_change = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = credential_change +SPEC.loader.exec_module(credential_change) + +import ccr_intake # noqa: E402 + +cc = credential_change +STANDARD = "CCR-2026-0051-mas-tem-relay-smtp.yaml" +POLICY = "openbao/policies/workload-kv-read-mas-tem-relay.hcl" + + +class CcrIntakeTests(unittest.TestCase): + """Run against a scratch repo root so nothing is written into the real tree.""" + + def setUp(self) -> None: + self.root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.root, True) + self.ccrs = self.root / "credential-change-requests" + self.ccrs.mkdir() + (self.root / "openbao/policies").mkdir(parents=True) + shutil.copy2(REPO_DIR / "credential-change-requests" / STANDARD, self.ccrs / STANDARD) + shutil.copy2(REPO_DIR / POLICY, self.root / POLICY) + for target in (cc, ccr_intake): + patcher = mock.patch.object(target, "REPO_DIR", self.root) + patcher.start() + self.addCleanup(patcher.stop) + env = mock.patch.dict(os.environ, {"CCR_DIR": str(self.ccrs)}) + env.start() + self.addCleanup(env.stop) + self.profile, _digest = ccr_intake.load_profile() + self.path = self.ccrs / STANDARD + + def edit(self, change) -> None: + data = cc.load_yaml(self.path) + change(data) + cc.dump_yaml(self.path, data) + + def violations(self) -> list[str]: + return ccr_intake.standard_violations(cc.load_yaml(self.path), self.profile, self.ccrs, cc) + + def assert_violation(self, change, fragment: str) -> None: + self.edit(change) + found = self.violations() + self.assertTrue(any(fragment in item for item in found), found) + + def test_standard_lane_passes_and_records_review_without_approval(self) -> None: + self.assertEqual(ccr_intake.review_standard(self.path, cc), []) + data = cc.load_yaml(self.path) + self.assertEqual(data["status"], "proposed") + comment = data["review"]["comments"][-1] + self.assertEqual(comment["decision"], "approved") + self.assertIn("ccr-standard-lane v1 sha256:", comment["comment"]) + self.assertTrue(comment["reviewer"].startswith("railiance-platform-owner")) + self.assertIn("apply requires status approved, got proposed", cc.apply_blockers(data)) + + def test_check_mode_does_not_write(self) -> None: + before = self.path.read_text() + ccr_intake.review_standard(self.path, cc, write=False) + self.assertEqual(self.path.read_text(), before) + + def test_out_of_profile_records_needs_platform_review(self) -> None: + self.edit(lambda d: d["risk"].update(classification="high")) + self.assertTrue(ccr_intake.review_standard(self.path, cc)) + comment = cc.load_yaml(self.path)["review"]["comments"][-1] + self.assertEqual(comment["decision"], "needs_platform_review") + self.assertEqual(cc.load_yaml(self.path)["status"], "proposed") + + def test_requires_proposed_status(self) -> None: + self.edit(lambda d: d.update(status="approved")) + with self.assertRaises(SystemExit): + ccr_intake.review_standard(self.path, cc) + + def test_metadata_read_rejected(self) -> None: + self.assert_violation(lambda d: d["openbao"].update(metadata_read=True), "metadata_read") + + def test_kv_path_outside_prefixes_rejected(self) -> None: + self.assert_violation( + lambda d: d["openbao"].update(kv_path="platform/operators/founder/x"), "outside the standard" + ) + + def test_supplier_path_needs_exactly_two_segments(self) -> None: + self.assert_violation( + lambda d: d["openbao"].update(kv_path="platform/suppliers/scaleway/a/b"), "outside the standard" + ) + + def test_policy_file_drift_rejected(self) -> None: + (self.root / POLICY).write_text('path "platform/data/suppliers/*" {\n capabilities = ["read"]\n}\n') + self.assertTrue(any("differs" in v for v in self.violations())) + + def test_long_ttl_rejected(self) -> None: + self.assert_violation(lambda d: d["openbao"]["auth"].update(token_max_ttl="1h"), "token_max_ttl") + + def test_default_policy_rejected(self) -> None: + self.assert_violation( + lambda d: d["openbao"]["auth"].update(token_no_default_policy=False), "token_no_default_policy" + ) + + def test_role_prefix_and_audience_enforced(self) -> None: + self.assert_violation(lambda d: d["openbao"]["auth"].update(role="mas-eso"), "role must start") + self.assert_violation(lambda d: d["openbao"]["auth"].update(audience="vault"), "audience") + + def test_two_service_accounts_rejected(self) -> None: + self.assert_violation( + lambda d: d["openbao"]["auth"]["bound_claims"]["service_account_names"].append("other"), + "service account", + ) + + def test_reserved_namespace_rejected(self) -> None: + self.assert_violation( + lambda d: d["openbao"]["auth"]["bound_claims"].update(service_account_namespaces=["external-secrets"]), + "reserved", + ) + + def test_second_reader_of_path_rejected(self) -> None: + other = cc.load_yaml(self.path) + other.update(id="CCR-2026-0052", status="active") + cc.dump_yaml(self.ccrs / "CCR-2026-0052-other.yaml", other) + self.assertTrue(any("already has a reader: CCR-2026-0052" in v for v in self.violations())) + + def test_cancelled_reader_is_not_a_conflict(self) -> None: + other = cc.load_yaml(self.path) + other.update(id="CCR-2026-0052", status="cancelled") + cc.dump_yaml(self.ccrs / "CCR-2026-0052-other.yaml", other) + self.assertEqual(self.violations(), []) + + def test_next_id_and_duplicate_detection(self) -> None: + self.assertEqual(ccr_intake.next_ccr_id(self.ccrs, 2026), "CCR-2026-0052") + self.assertEqual(ccr_intake.next_ccr_id(self.ccrs, 2027), "CCR-2027-0001") + self.assertEqual(ccr_intake.duplicate_ids(self.ccrs, cc), {}) + shutil.copy2(self.path, self.ccrs / "CCR-2026-0051-race.yaml") + self.assertIn("CCR-2026-0051", ccr_intake.duplicate_ids(self.ccrs, cc)) + + def submit_args(self, **overrides) -> argparse.Namespace: + draft_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, draft_dir, True) + draft = cc.load_yaml(self.path) + draft.update(id="CCR-DRAFT-MAS", status="draft") + draft["openbao"].update( + kv_path="platform/suppliers/scaleway/other-relay", + policy_name="workload-kv-read-other-relay", + policy_file="openbao/policies/workload-kv-read-other-relay.hcl", + ) + draft["openbao"]["auth"]["policies"] = ["workload-kv-read-other-relay"] + draft["review"]["comments"] = [{"reviewer": "someone", "decision": "approved", "comment": "x"}] + cc.dump_yaml(draft_dir / "draft.yaml", draft) + (draft_dir / "policy.hcl").write_text( + (self.root / POLICY).read_text().replace("mas-tem-relay", "other-relay") + ) + values = dict(ccr=str(draft_dir / "draft.yaml"), policy=str(draft_dir / "policy.hcl"), + slug="other-relay", requester="ops-mason", profile=str(ccr_intake.DEFAULT_PROFILE)) + values.update(overrides) + return argparse.Namespace(**values) + + def test_submit_allocates_id_copies_policy_and_reviews(self) -> None: + destination, violations = ccr_intake.submit(self.submit_args(), cc) + self.assertEqual(violations, []) + self.assertEqual(destination.name, "CCR-2026-0052-other-relay.yaml") + data = cc.load_yaml(destination) + self.assertEqual((data["id"], data["status"]), ("CCR-2026-0052", "proposed")) + self.assertEqual(len(data["review"]["comments"]), 1) + self.assertTrue(data["review"]["comments"][0]["reviewer"].startswith("railiance-platform-owner")) + self.assertTrue((self.root / "openbao/policies/workload-kv-read-other-relay.hcl").exists()) + + def test_submit_refuses_unlisted_requester(self) -> None: + with self.assertRaises(SystemExit): + ccr_intake.submit(self.submit_args(requester="random-agent"), cc) + + def test_submit_refuses_policy_overwrite(self) -> None: + (self.root / "openbao/policies/workload-kv-read-other-relay.hcl").write_text("different\n") + with self.assertRaises(SystemExit): + ccr_intake.submit(self.submit_args(), cc) + + def test_submit_refuses_secret_markers(self) -> None: + args = self.submit_args() + Path(args.ccr).write_text(Path(args.ccr).read_text() + "# hvs.notarealtoken\n") + with self.assertRaises(SystemExit): + ccr_intake.submit(args, cc) + + +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 969acc5..0c2e617 100644 --- a/workplans/RPF-WP-0061-self-service-ccr-intake.md +++ b/workplans/RPF-WP-0061-self-service-ccr-intake.md @@ -4,13 +4,14 @@ type: workplan title: "Self-service CCR intake: clients file and progress CCRs without a platform worker" domain: financials repo: railiance-platform -status: proposed +status: active owner: codex created: "2026-10-18" updated: "2026-10-18" flavor: implementation origin: cross-repo origin_ref: ops-mason daa119e6 / 049afa1f; MASON-IN-0005 +state_hub_workstream_id: "bad0cc9b-e8ad-572b-8d0d-fb3adb208b4a" --- Demand: every credential change request today waits on a railiance-platform @@ -39,11 +40,14 @@ Invariants (unchanged by this plan): ## Design 1. **Submit.** The client runs, in a railiance-platform checkout, - `scripts/credential-change.py submit --bundle ` (or `make ccr-submit`). - It copies the CCR and policy file, allocates the next free `CCR-YYYY-NNNN` - from the files on the current `main`, sets `status: proposed`, validates, - commits on branch `ccr/-` and pushes. A push or merge race simply - re-allocates; git is the allocator, so no central counter or agent is needed. + `scripts/credential-change.py submit --ccr --policy --slug + --requester --push`. It copies the CCR and policy file, + allocates the next free `CCR-YYYY-NNNN` from the files in the checkout, + sets `status: proposed`, clears client-written review comments, validates, + commits on branch `ccr/-` and pushes. Only profile `submitters` + may submit (ops-mason only, founder decision 2026-10-18). A race produces + two files with the same id; `check-ids` in CI refuses the second, which + re-submits. Git is the allocator, so no central counter or agent is needed. 2. **Platform review as policy-as-code.** `credential-change.py review-standard ` checks the standard lane profile (`registry/ccr-standard-profile.yaml`): `workload-kv-read`; kubernetes auth; @@ -53,8 +57,10 @@ Invariants (unchanged by this plan): max ttl ≤ 15m; `token_no_default_policy: true`; one SA in one namespace; no other CCR in `proposed|approved|active` reading the same path. A pass appends the `railiance-platform-owner` review comment with the profile - version and digest. A fail writes the reasons and marks the CCR - `needs_platform_review`, which sends one hub message to `railiance-platform`. + version and digest. A fail appends a `needs_platform_review` review + comment with the reasons and, with `--notify`, sends one hub message to + `railiance-platform`. Either way the CCR status stays `proposed`; the + automated comment never sets `approved`. 3. **CI gate.** A Forgejo Action on `ccr/*` branches runs `validate`, `review-standard` and the secret scan; only a green branch fast-forwards to `main`. The client merges its own green branch. @@ -72,32 +78,45 @@ Invariants (unchanged by this plan): ```task id: RPF-WP-0061-T01 -status: todo +status: done priority: high +state_hub_task_id: "c18752a6-6126-5410-bbb6-1799ccd63558" ``` Write `registry/ccr-standard-profile.yaml` from the existing approved ESO lanes (CCR-2026-0033, -0035, -0041, -0050, -0051 shapes). Record which past CCRs would and would not have passed it. +Done 2026-10-18: `registry/ccr-standard-profile.yaml` v1. Calibrated on all 51 +CCRs: only CCR-2026-0051 passes; the profile comment lists the first failing +rule for every other CCR. Stricter than past practice by design. + ## T02 — `submit` and `review-standard` subcommands ```task id: RPF-WP-0061-T02 -status: todo +status: done priority: high +state_hub_task_id: "5dd5305f-e1e4-52f5-88aa-f0090e072d0f" ``` Add both subcommands to `scripts/credential-change.py` with tests covering id allocation, duplicate-path refusal, policy-file mismatch and every profile rule. `review-standard` never touches OpenBao. +Done 2026-10-18: `scripts/ccr_intake.py` adds `submit`, `review-standard` +(`--check`, `--notify`) and `check-ids` to `credential-change.py`; +`tests/test_ccr_intake.py` has 20 tests against a scratch repo root. Two +existing failures in `tests/test_credential_change.py` (CCR-2026-0035 schema +gaps, core-hub database policy assertion) predate this work. + ## T03 — CI gate on `ccr/*` branches ```task id: RPF-WP-0061-T03 status: todo priority: medium +state_hub_task_id: "0fd97924-1bc7-5aa1-a520-c6c5737bf09b" ``` Forgejo Action running validate, review-standard and the secret scan; confirm @@ -109,6 +128,7 @@ which identities may push `ccr/*` (ops-mason first). id: RPF-WP-0061-T04 status: todo priority: medium +state_hub_task_id: "15ac9eae-9890-5140-b1ca-cd0ad3ca77ac" ``` ## T05 — Client documentation and first self-service CCR @@ -117,6 +137,7 @@ priority: medium id: RPF-WP-0061-T05 status: todo priority: medium +state_hub_task_id: "27797fa5-3d47-5037-a98c-2d269198d1c4" ``` Document the flow in `docs/credential-change-approval.md` and notify