diff --git a/.forgejo/workflows/ccr-intake.yaml b/.forgejo/workflows/ccr-intake.yaml new file mode 100644 index 0000000..17b90f2 --- /dev/null +++ b/.forgejo/workflows/ccr-intake.yaml @@ -0,0 +1,43 @@ +# RPF-WP-0061-T03: self-service CCR intake gate. +# ccr/* branches: exactly one new in-profile CCR (+ its new policy file) from a +# listed submitter, nothing else changed. main: post-merge id uniqueness. +# Source comes from the exact-revision archive, as in keycape-factor-acceptance. +name: CCR intake gate +on: + push: + branches: ["ccr/**", main] + workflow_dispatch: +jobs: + gate: + runs-on: ubuntu-latest + container: + image: python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 + steps: + - name: Fetch exact revision and main + run: | + set -eu + python3 - <<'PYCODE' + import os,tarfile,tempfile,urllib.request + from pathlib import Path + base='https://forgejo.coulomb.social/'+os.environ['GITHUB_REPOSITORY']+'/archive/' + for name,ref in (('branch',os.environ['GITHUB_SHA']),('main','main')): + root=Path('/tmp/ccr-intake')/name;root.mkdir(parents=True) + with urllib.request.urlopen(base+ref+'.tar.gz',timeout=30) as response,tempfile.TemporaryFile() as archive: + archive.write(response.read());archive.seek(0) + with tarfile.open(fileobj=archive,mode='r:gz') as tar:tar.extractall(root,filter='data') + PYCODE + pip install --quiet --disable-pip-version-check pyyaml==6.0.2 + - name: Gate ccr branch + if: startsWith(github.ref, 'refs/heads/ccr/') + run: | + set -eu + cd /tmp/ccr-intake/branch/* + main_root=$(echo /tmp/ccr-intake/main/*) + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_ccr_intake.py' + python3 scripts/credential-change.py check-branch --branch "$GITHUB_REF" --main-root "$main_root" + - name: Check CCR ids on main + if: github.ref == 'refs/heads/main' + run: | + set -eu + cd /tmp/ccr-intake/branch/* + python3 scripts/credential-change.py check-ids 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 dc27095..deeead3 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 @@ -17,7 +17,13 @@ review: - founder - railiance-platform-owner - mail-address-semantics-owner - comments: [] + comments: + - at: '2026-10-18T10:36:53+00:00' + reviewer: railiance-platform-owner (policy-as-code ccr-standard-lane v1) + decision: approved + comment: 'Standard lane: passes ccr-standard-lane v1 sha256:5481be201456739b9e01b14b0b11211923d7d838a2e71e2e417af038a05aec95. + Platform-owner source review only; founder approval and confirm-binding still + required.' target: domain: infotech tenant: platform @@ -71,13 +77,13 @@ risk: limited to namespace mas. - New read scope on a supplier path; founder approval required. No other lane reads this path (ops-mason live survey 2026-10-18T10:13:55Z). - - Apply may be run by ops-mason under ops-mason-construct (MASON-IN-0005) once - installed (MASON-WP-0015-T02) and this CCR is approved; that grant covers - workload-kv-read-* policies and external-secrets-* roles only. + - Apply may be run by ops-mason under ops-mason-construct (MASON-IN-0005) once installed + (MASON-WP-0015-T02) and this CCR is approved; that grant covers workload-kv-read-* + policies and external-secrets-* roles only. verification: positive: - - Exact SA/namespace/audience login succeeds; ESO Ready/SecretSynced; metadata - version matches admitted KV version 2 without printing values. + - Exact SA/namespace/audience login succeeds; ESO Ready/SecretSynced; metadata version + matches admitted KV version 2 without printing values. negative: - Wrong SA, namespace or audience login refused; sibling paths under platform/suppliers/scaleway, list, metadata, write and delete denied. diff --git a/scripts/ccr_intake.py b/scripts/ccr_intake.py index 177c9c6..d4b9734 100644 --- a/scripts/ccr_intake.py +++ b/scripts/ccr_intake.py @@ -220,6 +220,64 @@ def command_check_ids(args: argparse.Namespace, cc: Any) -> int: return 1 if duplicates else 0 +def tree_digests(root: Path) -> dict[str, str]: + return { + str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in root.rglob("*") + if path.is_file() and ".git" not in path.relative_to(root).parts + and "__pycache__" not in path.parts + } + + +def branch_gate(main_root: Path, stem: str, cc: Any, profile_path: Path = DEFAULT_PROFILE) -> list[str]: + """CI gate for a ccr/ branch checked out at REPO_DIR (RPF-WP-0061-T03). + + The branch may add exactly one new CCR and its new policy file, nothing + else; the CCR must be an in-profile submission from a listed submitter with + a policy-as-code review against the current profile digest. + """ + errors: list[str] = [] + ccr_rel = f"credential-change-requests/{stem}.yaml" + path = REPO_DIR / ccr_rel + if not CCR_FILE_RE.match(path.name) or not path.exists(): + return [f"branch ccr/{stem} must add {ccr_rel}"] + if (main_root / ccr_rel).exists(): + errors.append(f"{ccr_rel} already exists on main") + ccr, validation_errors, _warnings = cc.validate_ccr(path) + errors.extend(f"validate: {item}" for item in validation_errors) + policy_rel = (ccr.get("openbao") or {}).get("policy_file") + allowed = {ccr_rel} | ({policy_rel} if policy_rel else set()) + branch, main = tree_digests(REPO_DIR), tree_digests(main_root) + changed = {k for k in branch.keys() | main.keys() if branch.get(k) != main.get(k)} + for extra in sorted(changed - allowed): + errors.append(f"ccr branch may not change {extra}") + if policy_rel and policy_rel in main and main[policy_rel] != branch.get(policy_rel): + errors.append(f"{policy_rel} exists on main; a submission may not modify it") + if ccr.get("status") != "proposed": + errors.append(f"status must be proposed, got {ccr.get('status')}") + profile, digest = load_profile(profile_path) + if (ccr.get("requester") or {}).get("agent") not in profile["submitters"]: + errors.append(f"requester is not a listed submitter {profile['submitters']}") + errors.extend(standard_violations(ccr, profile, path.parent, cc)) + comments = (ccr.get("review") or {}).get("comments") or [] + if len(comments) != 1 or comments[0].get("decision") != "approved" or digest not in str( + comments[0].get("comment") + ): + errors.append("review must hold exactly the policy-as-code approval for the current profile") + errors.extend(f"duplicate id: {k} {v}" for k, v in duplicate_ids(path.parent, cc).items()) + return errors + + +def command_check_branch(args: argparse.Namespace, cc: Any) -> int: + stem = args.branch.removeprefix("refs/heads/").removeprefix("ccr/") + errors = branch_gate(Path(args.main_root).resolve(), stem, cc) + for item in errors: + print(f"[FAIL] ccr/{stem}: {item}") + if not errors: + print(f"[OK] ccr/{stem}: one in-profile CCR, nothing else changed") + return 1 if errors else 0 + + def git(*argv: str) -> None: subprocess.run(["git", "-C", str(REPO_DIR), *argv], check=True) @@ -294,6 +352,13 @@ def add_parsers(sub: Any, cc: Any, state_hub_url: str) -> None: 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)) + check_branch = sub.add_parser( + "check-branch", help="CI gate: a ccr/* branch adds exactly one in-profile CCR and its policy" + ) + check_branch.add_argument("--branch", required=True, help="ccr/- or refs/heads/ccr/...") + 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)) + 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 a01d424..d276952 100644 --- a/tests/test_ccr_intake.py +++ b/tests/test_ccr_intake.py @@ -194,6 +194,59 @@ class CcrIntakeTests(unittest.TestCase): with self.assertRaises(SystemExit): ccr_intake.submit(args, cc) + def submitted_branch(self) -> tuple[Path, str]: + main_root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, main_root, True) + shutil.copytree(self.root, main_root, dirs_exist_ok=True) + destination, _violations = ccr_intake.submit(self.submit_args(), cc) + return main_root, destination.stem + + def test_branch_gate_accepts_clean_submission(self) -> None: + main_root, stem = self.submitted_branch() + self.assertEqual(ccr_intake.branch_gate(main_root, stem, cc), []) + + def test_branch_gate_refuses_extra_changes(self) -> None: + main_root, stem = self.submitted_branch() + (self.root / "registry").mkdir() + (self.root / "registry/ccr-standard-profile.yaml").write_text("submitters: [anyone]\n") + errors = ccr_intake.branch_gate(main_root, stem, cc) + self.assertIn("ccr branch may not change registry/ccr-standard-profile.yaml", errors) + + def test_branch_gate_refuses_modified_existing_policy(self) -> None: + main_root, stem = self.submitted_branch() + path = self.ccrs / f"{stem}.yaml" + data = cc.load_yaml(path) + data["openbao"]["policy_file"] = POLICY + cc.dump_yaml(path, data) + (self.root / POLICY).write_text((self.root / POLICY).read_text() + "# widened\n") + errors = ccr_intake.branch_gate(main_root, stem, cc) + self.assertTrue(any("may not modify" in e for e in errors), errors) + + def test_branch_gate_refuses_hand_edited_review(self) -> None: + main_root, stem = self.submitted_branch() + path = self.ccrs / f"{stem}.yaml" + data = cc.load_yaml(path) + data["review"]["comments"].append({"reviewer": "founder", "decision": "approved", "comment": "ok"}) + cc.dump_yaml(path, data) + errors = ccr_intake.branch_gate(main_root, stem, cc) + self.assertTrue(any("policy-as-code approval" in e for e in errors), errors) + + def test_branch_gate_refuses_unlisted_requester(self) -> None: + main_root, stem = self.submitted_branch() + path = self.ccrs / f"{stem}.yaml" + data = cc.load_yaml(path) + data["requester"]["agent"] = "someone-else" + cc.dump_yaml(path, data) + errors = ccr_intake.branch_gate(main_root, stem, cc) + self.assertTrue(any("listed submitter" in e for e in errors), errors) + + def test_branch_gate_refuses_existing_ccr(self) -> None: + main_root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, main_root, True) + shutil.copytree(self.root, main_root, dirs_exist_ok=True) + 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) + 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 0c2e617..19f37f4 100644 --- a/workplans/RPF-WP-0061-self-service-ccr-intake.md +++ b/workplans/RPF-WP-0061-self-service-ccr-intake.md @@ -114,7 +114,7 @@ gaps, core-hub database policy assertion) predate this work. ```task id: RPF-WP-0061-T03 -status: todo +status: progress priority: medium state_hub_task_id: "0fd97924-1bc7-5aa1-a520-c6c5737bf09b" ``` @@ -122,6 +122,23 @@ state_hub_task_id: "0fd97924-1bc7-5aa1-a520-c6c5737bf09b" Forgejo Action running validate, review-standard and the secret scan; confirm which identities may push `ccr/*` (ops-mason first). +2026-10-18: `.forgejo/workflows/ccr-intake.yaml` runs on `ccr/**` and `main` +from exact-revision archives. On `ccr/*` it runs the intake tests and +`credential-change.py check-branch`. That command refuses the branch unless it +adds exactly one new CCR plus its new policy file and changes nothing else +(not the profile, not an existing policy). The CCR must validate, be +`proposed`, come from a listed submitter, re-pass the profile in CI and carry +exactly the policy-as-code approval for the current profile digest. On `main` +it runs `check-ids`. 26 tests. CCR-2026-0051 received the first automated +platform-owner review (still `proposed`). + +Remaining, founder/Forgejo admin: a protected-branch rule on `ccr/*` that +allows pushes only from ops-mason's Forgejo identity. Forgejo cannot require +a check on main without blocking every direct push, so merging is a +convention backed by detection: ops-mason fast-forwards only a green branch, +and the main job plus `check-branch` evidence detect violations after the +fact. + ## T04 — Hub decision on submit and client-run `sync-decision` ```task