From 3e49ed4c0ca34b76a75fcaba6a6b1e23a8e92b94 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 9 Oct 2026 19:52:33 +0200 Subject: [PATCH] MASON-WP-0010-T03: ops-mason build --case; Forgejo case bundle built Builds from an accepted case and its reviewed, undrifted plan into CCR drafts for railiance-platform's applier, policy HCL, catalog proposal, exact rollback and manifest; the local audit log records case, type and acceptance. Drafts pass the platform's own CCR validator. 6 tests; suite 175 passed. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 327203@bnt-lap001 Assistant-Session: 55028ae3-4914-4bd1-8f5c-815f3527aef0 --- .../catalog-proposal.yaml | 33 +++ ...CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-ESO.yaml | 86 +++++++ ...DRAFT-FORGEJO-NETKINGDOM-OIDC-FORGEJO.yaml | 74 ++++++ .../manifest.json | 28 +++ .../workload-kv-read-keycape-forgejo-eso.hcl | 6 + ...rkload-kv-read-keycape-forgejo-forgejo.hcl | 6 + .../rollback.yaml | 36 +++ src/ops_mason/case_build.py | 213 ++++++++++++++++++ src/ops_mason/cli.py | 26 +++ tests/test_case_build.py | 107 +++++++++ ...N-WP-0010-security-case-materialization.md | 21 +- 11 files changed, 633 insertions(+), 3 deletions(-) create mode 100644 builds/case-forgejo-netkingdom-oidc/catalog-proposal.yaml create mode 100644 builds/case-forgejo-netkingdom-oidc/credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-ESO.yaml create mode 100644 builds/case-forgejo-netkingdom-oidc/credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-FORGEJO.yaml create mode 100644 builds/case-forgejo-netkingdom-oidc/manifest.json create mode 100644 builds/case-forgejo-netkingdom-oidc/openbao/policies/workload-kv-read-keycape-forgejo-eso.hcl create mode 100644 builds/case-forgejo-netkingdom-oidc/openbao/policies/workload-kv-read-keycape-forgejo-forgejo.hcl create mode 100644 builds/case-forgejo-netkingdom-oidc/rollback.yaml create mode 100644 src/ops_mason/case_build.py create mode 100644 tests/test_case_build.py diff --git a/builds/case-forgejo-netkingdom-oidc/catalog-proposal.yaml b/builds/case-forgejo-netkingdom-oidc/catalog-proposal.yaml new file mode 100644 index 0000000..3775d7d --- /dev/null +++ b/builds/case-forgejo-netkingdom-oidc/catalog-proposal.yaml @@ -0,0 +1,33 @@ +proposal_for: ops-warden +security_case: + case: forgejo-netkingdom-oidc + type: oidc-browser-relying-party@1.0 + render_digest: sha256:fb8b114d465341a46bb492259cb9c6b77bc0731670edace2e13a1f58758c6e23 + acceptance_record: key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); + type acknowledged via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e + accepted_at: '2026-10-08' + accepted_by: + - owner + - founder +entry: 'id: forgejo-keycape-client + + owner_repo: key-cape + + warden_executes: false + + risk: high + + status: active once verification passes + + workload_ref: applicable, name forgejo, declaration_ref of the consumer''s published declaration or the explicit + unknown form, never inferred + + path_template: platform/workloads/keycape/forgejo + + field: CLIENT_SECRET + + delegation: native, intended_owner key-cape + + rotation: mint-secret then verification; automatable false until a run has been proven + + ' diff --git a/builds/case-forgejo-netkingdom-oidc/credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-ESO.yaml b/builds/case-forgejo-netkingdom-oidc/credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-ESO.yaml new file mode 100644 index 0000000..3def51c --- /dev/null +++ b/builds/case-forgejo-netkingdom-oidc/credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-ESO.yaml @@ -0,0 +1,86 @@ +id: CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-ESO +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: 'forgejo-netkingdom-oidc: keycape-reader-policy (oidc-browser-relying-party@1.0)' +status: proposed +created: '2026-10-09' +updated: '2026-10-09' +requester: + agent: ops-mason + reason: 'MASON-WP-0010-T03 build of SecurityCase forgejo-netkingdom-oidc (oidc-browser-relying-party@1.0), render + sha256:fb8b114d465341a46bb492259cb9c6b77bc0731670edace2e13a1f58758c6e23. Accepted by owner, founder on 2026-10-08: + key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); type acknowledged + via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e. The case acceptance + replaces a fresh executive summary.' +review: + required: true + required_approvers: + - platform-owner + comments: [] +target: + domain: infotech + tenant: platform + workload: keycape + environment: production + purpose: KeyCape reads its client secret only through ESO. No metadata, list, parent or sibling access. +openbao: + mount: platform + kv_path: platform/workloads/keycape/forgejo + fields: + - CLIENT_SECRET + policy_name: workload-kv-read-keycape-forgejo-eso + policy_file: openbao/policies/workload-kv-read-keycape-forgejo-eso.hcl + metadata_read: false + token_self_lifecycle: true + auth: + method: kubernetes + mount: kubernetes + role: external-secrets-keycape-forgejo + audience: openbao + ttl: 15m + token_max_ttl: 15m + token_explicit_max_ttl: 15m + token_no_default_policy: true + policies: + - workload-kv-read-keycape-forgejo-eso + bound_claims: + service_account_names: + - external-secrets + service_account_namespaces: + - external-secrets + bound_claims_confirmed: false +access_frontdoor: + type: external-secrets + catalog_id: forgejo-keycape-client + readiness: pending-review + resolvable: false +risk: + classification: high + notes: + - Rendered from an acknowledged SecurityCaseType; shape reviewed with the type, names checked against live structure. + - Exact read on one KV path; no metadata, list, parent or sibling access. +verification: + positive: + - policy workload-kv-read-keycape-forgejo-eso grants read on platform/data/workloads/keycape/forgejo only (capabilities + check) + negative: + - a default-only identity has no read capability on the path + - sibling GET and parent LIST are denied + activation_conditions: + - case acceptance present + - bound claims confirmed by the platform owner +lifecycle: + deactivate: delete the role, then policy workload-kv-read-keycape-forgejo-eso (case rollback) + rotate: roll-forward through the credential broker (mint-secret); never a read of the old value + compromised: owner incident process; no automatic read, reseed or cleanup +security_case: + case: forgejo-netkingdom-oidc + type: oidc-browser-relying-party@1.0 + render_digest: sha256:fb8b114d465341a46bb492259cb9c6b77bc0731670edace2e13a1f58758c6e23 + acceptance_record: key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); + type acknowledged via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e + accepted_at: '2026-10-08' + accepted_by: + - owner + - founder diff --git a/builds/case-forgejo-netkingdom-oidc/credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-FORGEJO.yaml b/builds/case-forgejo-netkingdom-oidc/credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-FORGEJO.yaml new file mode 100644 index 0000000..7e73672 --- /dev/null +++ b/builds/case-forgejo-netkingdom-oidc/credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-FORGEJO.yaml @@ -0,0 +1,74 @@ +id: CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-FORGEJO +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: 'forgejo-netkingdom-oidc: consumer-reader-policy (oidc-browser-relying-party@1.0)' +status: in_flight +created: '2026-10-09' +updated: '2026-10-09' +requester: + agent: ops-mason + reason: 'MASON-WP-0010-T03 build of SecurityCase forgejo-netkingdom-oidc (oidc-browser-relying-party@1.0), render + sha256:fb8b114d465341a46bb492259cb9c6b77bc0731670edace2e13a1f58758c6e23. Accepted by owner, founder on 2026-10-08: + key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); type acknowledged + via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e. The case acceptance + replaces a fresh executive summary.' +review: + required: true + required_approvers: + - platform-owner + comments: [] +target: + domain: infotech + tenant: platform + workload: keycape + environment: production + purpose: The consuming service reads the same secret. No metadata, list, parent or sibling access. +openbao: + mount: platform + kv_path: platform/workloads/keycape/forgejo + fields: + - CLIENT_SECRET + policy_name: workload-kv-read-keycape-forgejo-forgejo + policy_file: openbao/policies/workload-kv-read-keycape-forgejo-forgejo.hcl + metadata_read: false + token_self_lifecycle: true +access_frontdoor: + type: none + catalog_id: forgejo-keycape-client + readiness: pending-review + resolvable: false +risk: + classification: high + notes: + - Rendered from an acknowledged SecurityCaseType; shape reviewed with the type, names checked against live structure. + - Exact read on one KV path; no metadata, list, parent or sibling access. +verification: + positive: + - policy workload-kv-read-keycape-forgejo-forgejo grants read on platform/data/workloads/keycape/forgejo only + (capabilities check) + negative: + - a default-only identity has no read capability on the path + - sibling GET and parent LIST are denied + activation_conditions: + - case acceptance present + - bound claims confirmed by the platform owner +lifecycle: + deactivate: delete the role, then policy workload-kv-read-keycape-forgejo-forgejo (case rollback) + rotate: roll-forward through the credential broker (mint-secret); never a read of the old value + compromised: owner incident process; no automatic read, reseed or cleanup +security_case: + case: forgejo-netkingdom-oidc + type: oidc-browser-relying-party@1.0 + render_digest: sha256:fb8b114d465341a46bb492259cb9c6b77bc0731670edace2e13a1f58758c6e23 + acceptance_record: key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); + type acknowledged via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e + accepted_at: '2026-10-08' + accepted_by: + - owner + - founder +in_flight: + missing_fields: + - openbao.auth + owner: railiance-apps + blocking_reason: railiance-apps binds this reader to forgejo's own identity in its deployment path (hand-over). diff --git a/builds/case-forgejo-netkingdom-oidc/manifest.json b/builds/case-forgejo-netkingdom-oidc/manifest.json new file mode 100644 index 0000000..4a1cdc5 --- /dev/null +++ b/builds/case-forgejo-netkingdom-oidc/manifest.json @@ -0,0 +1,28 @@ +{ + "applier": "railiance-platform credential-change.py applier-apply (founder), after it files the CCR drafts", + "files": { + "catalog-proposal.yaml": "8bb3faedca22c7fa995e26978a0f040def032882fb9072b3a818cd40d5ff3818", + "credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-ESO.yaml": "50940969383a9c7a6a8a3c2d03a0aa6fbed574a12139072b5388402651550b3e", + "credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-FORGEJO.yaml": "691423c684da31d4611b15adb601f622f89c9cd465b55c637aed0836bb9ac9c8", + "openbao/policies/workload-kv-read-keycape-forgejo-eso.hcl": "92fa2aa74b6d4254b7b15dd7f376566afdafe5bcfbc9546fa9bccdf8d2214ab8", + "openbao/policies/workload-kv-read-keycape-forgejo-forgejo.hcl": "690aa95a2ecd7eb906de87af20e3c0905854e14e864a24f4241216afa4bc9c45", + "rollback.yaml": "b8bc1a3c99507183733a246d415fa5508e0f45590e9517d5a70e48ae73206fc9" + }, + "kv_path": "platform/workloads/keycape/forgejo", + "not_ops_mason": [ + "mint-secret (credential broker lane keycape-client-secret-generate)", + "catalog activation (ops-warden)", + "client registration (net-kingdom, attended)" + ], + "security_case": { + "acceptance_record": "key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); type acknowledged via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e", + "accepted_at": "2026-10-08", + "accepted_by": [ + "owner", + "founder" + ], + "case": "forgejo-netkingdom-oidc", + "render_digest": "sha256:fb8b114d465341a46bb492259cb9c6b77bc0731670edace2e13a1f58758c6e23", + "type": "oidc-browser-relying-party@1.0" + } +} diff --git a/builds/case-forgejo-netkingdom-oidc/openbao/policies/workload-kv-read-keycape-forgejo-eso.hcl b/builds/case-forgejo-netkingdom-oidc/openbao/policies/workload-kv-read-keycape-forgejo-eso.hcl new file mode 100644 index 0000000..2ecbad7 --- /dev/null +++ b/builds/case-forgejo-netkingdom-oidc/openbao/policies/workload-kv-read-keycape-forgejo-eso.hcl @@ -0,0 +1,6 @@ +# workload-kv-read-keycape-forgejo-eso: rendered by ops-mason from SecurityCase forgejo-netkingdom-oidc (oidc-browser-relying-party@1.0, render sha256:fb8b114d465341a46bb492259cb9c6b77bc0731670edace2e13a1f58758c6e23). +# Do not edit: re-run ops-mason build --case. + +path "platform/data/workloads/keycape/forgejo" { + capabilities = ["read"] +} diff --git a/builds/case-forgejo-netkingdom-oidc/openbao/policies/workload-kv-read-keycape-forgejo-forgejo.hcl b/builds/case-forgejo-netkingdom-oidc/openbao/policies/workload-kv-read-keycape-forgejo-forgejo.hcl new file mode 100644 index 0000000..017af60 --- /dev/null +++ b/builds/case-forgejo-netkingdom-oidc/openbao/policies/workload-kv-read-keycape-forgejo-forgejo.hcl @@ -0,0 +1,6 @@ +# workload-kv-read-keycape-forgejo-forgejo: rendered by ops-mason from SecurityCase forgejo-netkingdom-oidc (oidc-browser-relying-party@1.0, render sha256:fb8b114d465341a46bb492259cb9c6b77bc0731670edace2e13a1f58758c6e23). +# Do not edit: re-run ops-mason build --case. + +path "platform/data/workloads/keycape/forgejo" { + capabilities = ["read"] +} diff --git a/builds/case-forgejo-netkingdom-oidc/rollback.yaml b/builds/case-forgejo-netkingdom-oidc/rollback.yaml new file mode 100644 index 0000000..f064e08 --- /dev/null +++ b/builds/case-forgejo-netkingdom-oidc/rollback.yaml @@ -0,0 +1,36 @@ +security_case: + case: forgejo-netkingdom-oidc + type: oidc-browser-relying-party@1.0 + render_digest: sha256:fb8b114d465341a46bb492259cb9c6b77bc0731670edace2e13a1f58758c6e23 + acceptance_record: key-cape docs/forgejo-oidc-client-request.yaml (status accepted-2026-10-08, KEY-WP-0038-T01); + type acknowledged via informed-decision memo nk-wp-0048-t05-oidc-browser-relying-party-r4, hub decision e418263e + accepted_at: '2026-10-08' + accepted_by: + - owner + - founder +removes_exactly: +- kind: catalog_entry + name: forgejo-keycape-client +- kind: openbao_role + name: external-secrets-keycape-forgejo +- kind: openbao_policy + name: workload-kv-read-keycape-forgejo-forgejo +- kind: openbao_policy + name: workload-kv-read-keycape-forgejo-eso +- kind: kv_path + name: platform/workloads/keycape/forgejo +steps: +- remove the OIDC source for forgejo from forgejo (owner railiance-apps) +- keycape-client.py apply --operation remove for forgejo +- remove env KEYCAPE__CLIENT_SECRET from Deployment sso/keycape, after register-client is reversed +- delete ExternalSecret sso/keycape-forgejo-client and its target +- 'covered by client-secret-path: one metadata delete, after deliver-secret is reversed' +- identity-provisioner revoke_service_groups inside the provisioner pod (attended, as ensure-groups) with the case's + memberships; delete_if_empty only if no other case uses forge-admins,forge-coulomb-owners,forge-coulomb-readers +- delete metadata and all versions at platform/workloads/keycape/forgejo, after the ExternalSecret is gone (this + also reverses mint-secret) +- delete Kubernetes-auth role external-secrets-keycape-forgejo +- delete policy workload-kv-read-keycape-forgejo-eso, after eso-role +- delete policy workload-kv-read-keycape-forgejo-forgejo +- remove catalog entry forgejo-keycape-client +note: Undo removes only what this case created; shared groups are revoked only if no other case uses them. diff --git a/src/ops_mason/case_build.py b/src/ops_mason/case_build.py new file mode 100644 index 0000000..74fdab4 --- /dev/null +++ b/src/ops_mason/case_build.py @@ -0,0 +1,213 @@ +"""Build from an accepted SecurityCase (MASON-WP-0010-T03). + +`ops-mason build --case` turns a reviewed, case-derived construction plan into +the inputs the platform's delegated applier executes. ops-mason never writes to +OpenBao: the founder applies through railiance-platform (`credential-change.py +applier-apply`), as for every other lane. What changes for a KnownSecurityCase +is the gate: the case acceptance replaces a fresh executive summary, so the +build needs no separate approval in ops-mason, only + + - an accepted case (validated by net-kingdom's validator), + - a plan rendered from it with status `reviewed` (live survey, no collision), + - and the plan's render digest equal to a fresh render, so nothing drifted. + +The bundle names the case, the type version and the acceptance in every file, +and its rollback lists exactly what this case creates, in reverse order. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +from datetime import date +from pathlib import Path + +import yaml + +from ops_mason import case_plan +from ops_mason.audit import record_build +from ops_mason.plan import ConstructionPlan, PlanError + +RAILIANCE_PLATFORM = Path.home() / "railiance-platform" + + +class CaseBuildRefused(RuntimeError): + """The case cannot be built yet; nothing was written.""" + + +def _plan_front(path: Path) -> dict: + text = Path(path).read_text() + if not text.startswith("---\n"): + raise CaseBuildRefused(f"{path} has no frontmatter") + return yaml.safe_load(text.split("---\n", 2)[1]) or {} + + +def _items(rendered: dict, kind: str) -> list[dict]: + return [i for i in rendered["materialization"] if i["kind"] == kind] + + +def _provenance(case: dict, rendered: dict) -> dict: + acc = case_plan.acceptance_ref(case) + return {"case": case["spec"]["id"], "type": rendered["type"], "render_digest": rendered["digest"], + "acceptance_record": acc["record"], "accepted_at": acc["at"], "accepted_by": acc["by"]} + + +def _ccr(case: dict, rendered: dict, policy: dict, role: dict | None, kv_path: str, prov: dict) -> dict: + """A workload-kv-read CCR draft; railiance-platform assigns the id when it files it.""" + params = case["spec"]["parameters"] + role_body = yaml.safe_load(role["body"]) if role else None + today = date.today().isoformat() + openbao = { + "mount": kv_path.split("/", 1)[0], + "kv_path": kv_path, + "fields": ["CLIENT_SECRET"], + "policy_name": policy["template"], + "policy_file": f"openbao/policies/{policy['template']}.hcl", + "metadata_read": False, + "token_self_lifecycle": True, + } + if role_body: + openbao["auth"] = { + "method": role_body.get("auth", "kubernetes"), + "mount": role_body.get("auth", "kubernetes"), + "role": role["template"], + "audience": "openbao", + "ttl": role_body.get("token_ttl", "15m"), + "token_max_ttl": role_body.get("token_max_ttl", "15m"), + "token_explicit_max_ttl": role_body.get("token_max_ttl", "15m"), + "token_no_default_policy": bool(role_body.get("token_no_default_policy", True)), + "policies": list(role_body.get("token_policies") or [policy["template"]]), + "bound_claims": {"service_account_names": list(role_body.get("bound_service_account_names") or []), + "service_account_namespaces": list(role_body.get("bound_service_account_namespaces") or [])}, + "bound_claims_confirmed": False, + } + reader = "eso" if role else params["consuming_service"] + doc = { + "id": f"CCR-DRAFT-{prov['case']}-{reader}".upper(), + "kind": "credential-change-request", + "schema_version": 1, + "request_type": "workload-kv-read", + "title": f"{prov['case']}: {policy['id']} ({prov['type']})", + "status": "proposed" if role else "in_flight", + "created": today, + "updated": today, + "requester": {"agent": "ops-mason", "reason": ( + f"MASON-WP-0010-T03 build of SecurityCase {prov['case']} ({prov['type']}), render " + f"{prov['render_digest']}. Accepted by {', '.join(prov['accepted_by'])} on {prov['accepted_at']}: " + f"{prov['acceptance_record']}. The case acceptance replaces a fresh executive summary.")}, + "review": {"required": True, "required_approvers": ["platform-owner"], "comments": []}, + "target": {"domain": "infotech", "tenant": "platform", "workload": "keycape", "environment": "production", + "purpose": policy.get("description") or policy["id"]}, + "openbao": openbao, + "access_frontdoor": {"type": "external-secrets" if role else "none", + "catalog_id": f"{params['consuming_service']}-keycape-client", + "readiness": "pending-review", "resolvable": False}, + "risk": {"classification": "high", "notes": [ + "Rendered from an acknowledged SecurityCaseType; shape reviewed with the type, names checked against live structure.", + "Exact read on one KV path; no metadata, list, parent or sibling access."]}, + "verification": { + "positive": [f"policy {policy['template']} grants read on {kv_path.replace('/', '/data/', 1)} only (capabilities check)"], + "negative": ["a default-only identity has no read capability on the path", "sibling GET and parent LIST are denied"], + "activation_conditions": ["case acceptance present", "bound claims confirmed by the platform owner"]}, + "lifecycle": {"deactivate": f"delete the role, then policy {policy['template']} (case rollback)", + "rotate": "roll-forward through the credential broker (mint-secret); never a read of the old value", + "compromised": "owner incident process; no automatic read, reseed or cleanup"}, + "security_case": prov, + } + if not role: + doc["in_flight"] = {"missing_fields": ["openbao.auth"], "owner": params["consumer_owner_repo"], + "blocking_reason": (f"{params['consumer_owner_repo']} binds this reader to " + f"{params['consuming_service']}'s own identity in its deployment path (hand-over).")} + return doc + + +def _policy_hcl(policy: dict, prov: dict) -> str: + head = (f"# {policy['template']}: rendered by ops-mason from SecurityCase {prov['case']} " + f"({prov['type']}, render {prov['render_digest']}).\n# Do not edit: re-run ops-mason build --case.\n\n") + return head + policy["body"].rstrip() + "\n" + + +def validate_with_platform(ccr_paths: list[Path], platform: Path = RAILIANCE_PLATFORM) -> list[str]: + """Run railiance-platform's own CCR validator, read-only; return its FAIL lines. + + A policy file that exists in the bundle but not yet in railiance-platform is + expected (the platform files it with the CCR) and is not counted. + """ + script = platform / "scripts" / "credential-change.py" + if not script.is_file(): + return [f"platform validator not found at {script}"] + # Run in the platform's environment, not ops-mason's virtualenv. + venv = os.environ.get("VIRTUAL_ENV") + env = {k: v for k, v in os.environ.items() if k not in ("VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT", "PYTHONPATH")} + if venv: + env["PATH"] = os.pathsep.join(d for d in env.get("PATH", "").split(os.pathsep) if not d.startswith(venv)) + ccr_paths = [Path(p).resolve() for p in ccr_paths] + r = subprocess.run(["python3", str(script), "validate", *map(str, ccr_paths)], + capture_output=True, text=True, cwd=platform, env=env) + bundle_policies = {f"openbao/policies/{q.name}" for p in ccr_paths + for q in (p.parent.parent / "openbao" / "policies").glob("*.hcl")} + fails = [l for l in (r.stdout + r.stderr).splitlines() if l.startswith("[FAIL]")] + fails = [l for l in fails if not any(l.endswith(f"policy_file does not exist: {b}") for b in bundle_policies)] + if r.returncode != 0 and not fails and "[FAIL]" not in r.stdout + r.stderr: + fails = [f"validator did not run: {(r.stderr or r.stdout).strip().splitlines()[-1:]}"] + return fails + + +def build_from_case(case_path: Path, plan_path: Path, out_dir: Path, *, nk_root: Path = case_plan.DEFAULT_NK_ROOT, + allow_unimplemented: bool = False, audit_log: Path | None = None, + types_root: Path | None = None) -> dict: + case, _type, rendered = case_plan.render(case_path, nk_root, types_root, allow_unimplemented) + acc = case_plan.acceptance_ref(case) + if case["spec"].get("status") != "accepted" or not acc: + raise CaseBuildRefused("case is not accepted: a build needs the case acceptance record") + front = _plan_front(plan_path) + if front.get("status") != "reviewed": + raise CaseBuildRefused(f"plan {plan_path} is {front.get('status')!r}, not 'reviewed' (re-run plan --case live)") + if front.get("render_digest") != rendered["digest"]: + raise CaseBuildRefused("plan render digest differs from a fresh render: the case or type changed since review") + + prov = _provenance(case, rendered) + kv = _items(rendered, "kv_path") + if len(kv) != 1: + raise CaseBuildRefused(f"expected exactly one kv_path item, got {len(kv)}") + kv_path = kv[0]["template"] + roles = {yaml.safe_load(r["body"]).get("token_policies", [None])[0]: r for r in _items(rendered, "openbao_role")} + + out_dir.mkdir(parents=True, exist_ok=False) + files: dict[str, str] = {} + + def write(rel: str, text: str) -> Path: + path = out_dir / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + files[rel] = hashlib.sha256(text.encode()).hexdigest() + return path + + ccrs = [] + for policy in _items(rendered, "openbao_policy"): + write(f"openbao/policies/{policy['template']}.hcl", _policy_hcl(policy, prov)) + doc = _ccr(case, rendered, policy, roles.get(policy["template"]), kv_path, prov) + ccrs.append(write(f"credential-change-requests/{doc['id']}.yaml", yaml.safe_dump(doc, sort_keys=False, width=110))) + for cat in _items(rendered, "catalog_entry"): + write("catalog-proposal.yaml", yaml.safe_dump({"proposal_for": "ops-warden", "security_case": prov, + "entry": cat["body"]}, sort_keys=False, width=110)) + created = [{"kind": i["kind"], "name": i["template"]} for i in rendered["materialization"]] + write("rollback.yaml", yaml.safe_dump({ + "security_case": prov, + "removes_exactly": list(reversed(created)), + "steps": [r["action"] for r in rendered["rollback"]], + "note": "Undo removes only what this case created; shared groups are revoked only if no other case uses them.", + }, sort_keys=False, width=110)) + manifest = {"security_case": prov, "kv_path": kv_path, "files": files, + "applier": "railiance-platform credential-change.py applier-apply (founder), after it files the CCR drafts", + "not_ops_mason": ["mint-secret (credential broker lane keycape-client-secret-generate)", + "catalog activation (ops-warden)", "client registration (net-kingdom, attended)"]} + write("manifest.json", json.dumps(manifest, indent=1, sort_keys=True) + "\n") + + record_build(plan_id=front.get("id", f"case-{prov['case']}"), plan_path=str(plan_path), + approved_by="case acceptance: " + ", ".join(acc["by"]), approved_at=str(acc["at"]), + objects={"event": "case-build-prepared", **prov, "bundle": str(out_dir), + "objects": created}, log_path=audit_log) + return {"bundle": str(out_dir), "ccrs": [str(p) for p in ccrs], **prov} diff --git a/src/ops_mason/cli.py b/src/ops_mason/cli.py index 7ab0d05..9370879 100644 --- a/src/ops_mason/cli.py +++ b/src/ops_mason/cli.py @@ -48,9 +48,33 @@ def _parser() -> argparse.ArgumentParser: plan.add_argument("--netkingdom-root", type=Path, default=None) plan.add_argument("--allow-unimplemented", action="store_true", help="reference case only: render against an acknowledged type") + build = families.add_parser("build", help="build bundle from an accepted SecurityCase and its reviewed plan") + build.add_argument("--case", required=True, type=Path) + build.add_argument("--plan", required=True, type=Path, help="the reviewed plan from plan --case") + build.add_argument("--out", type=Path, help="bundle directory (default: builds/case-)") + build.add_argument("--allow-unimplemented", action="store_true", + help="reference case only: build against an acknowledged type") + build.add_argument("--netkingdom-root", type=Path, default=None) return parser +def _build(args) -> int: + from ops_mason import case_build, case_plan + + nk_root = args.netkingdom_root or case_plan.DEFAULT_NK_ROOT + try: + case_id = (case_plan.yaml.safe_load(args.case.read_text()) or {}).get("spec", {}).get("id", "unknown") + out = args.out or Path("builds") / f"case-{case_id}" + result = case_build.build_from_case(args.case, args.plan, out, nk_root=nk_root, + allow_unimplemented=args.allow_unimplemented) + except (case_plan.CasePlanError, case_build.CaseBuildRefused, FileExistsError, OSError) as exc: + print(json.dumps({"ok": False, "error": str(exc), "issues": getattr(exc, "issues", [])}), file=sys.stderr) + return 2 + fails = case_build.validate_with_platform([Path(p) for p in result["ccrs"]]) + print(json.dumps({"ok": not fails, "platform_validation": fails or "pass", **result}, indent=2)) + return 1 if fails else 0 + + def _plan(args) -> int: from ops_mason import case_plan @@ -74,6 +98,8 @@ def main(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) if args.family == "plan": return _plan(args) + if args.family == "build": + return _build(args) try: bundle = PlaneBundle.load(args.bundle) if args.command == "render": diff --git a/tests/test_case_build.py b/tests/test_case_build.py new file mode 100644 index 0000000..c09dd15 --- /dev/null +++ b/tests/test_case_build.py @@ -0,0 +1,107 @@ +import json +import shutil +from pathlib import Path + +import pytest +import yaml + +from ops_mason import case_build as cb +from ops_mason import case_plan as cp + +NK = cp.DEFAULT_NK_ROOT +TYPE = NK / "security-case-types" / "oidc-browser-relying-party" +CASE = NK / "security-cases" / "forgejo-netkingdom-oidc.yaml" +pytestmark = pytest.mark.skipif(not CASE.is_file(), reason="net-kingdom checkout not present") + + +@pytest.fixture +def types_root(tmp_path): + root = tmp_path / "types" + shutil.copytree(TYPE, root / TYPE.name) + f = root / TYPE.name / "type.yaml" + data = yaml.safe_load(f.read_text()) + data["spec"]["status"] = "implemented" + f.write_text(yaml.safe_dump(data, sort_keys=False)) + return root + + +def _case(tmp_path, **spec): + case = yaml.safe_load(CASE.read_text()) + case["spec"].update(spec) + p = tmp_path / "case.yaml" + p.write_text(yaml.safe_dump(case, sort_keys=False)) + return p + + +def _plan(tmp_path, case, types_root, source="live"): + doc, _, _ = cp.plan_from_case(case, types_root=types_root, structure=cp.Structure(source=source)) + p = tmp_path / "plan.md" + p.write_text(doc) + return p + + +def test_builds_bundle_naming_case_type_and_acceptance(tmp_path, types_root): + case = _case(tmp_path) + out, log = tmp_path / "bundle", tmp_path / "audit.jsonl" + r = cb.build_from_case(case, _plan(tmp_path, case, types_root), out, types_root=types_root, audit_log=log) + assert r["case"] == "forgejo-netkingdom-oidc" and r["type"] == "oidc-browser-relying-party@1.0" + ccrs = {Path(p).name: yaml.safe_load(Path(p).read_text()) for p in r["ccrs"]} + eso = ccrs["CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-ESO.yaml"] + assert eso["status"] == "proposed" and eso["openbao"]["auth"]["role"] == "external-secrets-keycape-forgejo" + assert eso["openbao"]["auth"]["bound_claims_confirmed"] is False + consumer = ccrs["CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-FORGEJO.yaml"] + assert consumer["status"] == "in_flight" and consumer["in_flight"]["missing_fields"] == ["openbao.auth"] + for doc in ccrs.values(): + assert doc["security_case"]["acceptance_record"] and "case acceptance replaces" in doc["requester"]["reason"] + hcl = (out / "openbao/policies/workload-kv-read-keycape-forgejo-eso.hcl").read_text() + assert 'path "platform/data/workloads/keycape/forgejo"' in hcl and '["read"]' in hcl and "forgejo-netkingdom-oidc" in hcl + rollback = yaml.safe_load((out / "rollback.yaml").read_text()) + assert rollback["removes_exactly"][-1] == {"kind": "kv_path", "name": "platform/workloads/keycape/forgejo"} + assert rollback["removes_exactly"][0]["kind"] == "catalog_entry" + manifest = json.loads((out / "manifest.json").read_text()) + assert set(manifest["files"]) >= {"rollback.yaml", "catalog-proposal.yaml"} + record = json.loads(log.read_text().splitlines()[-1]) + assert record["objects"]["event"] == "case-build-prepared" and record["approved_by"].startswith("case acceptance") + + +def test_refuses_case_without_acceptance(tmp_path, types_root): + case = _case(tmp_path, status="proposed") + case.write_text(case.read_text()) # acceptance block remains but status is proposed + with pytest.raises(cb.CaseBuildRefused): + cb.build_from_case(case, _plan(tmp_path, case, types_root), tmp_path / "b", types_root=types_root, + audit_log=tmp_path / "a.jsonl") + + +def test_refuses_plan_that_is_not_reviewed(tmp_path, types_root): + case = _case(tmp_path) + with pytest.raises(cb.CaseBuildRefused, match="not 'reviewed'"): + cb.build_from_case(case, _plan(tmp_path, case, types_root, source="none"), tmp_path / "b", + types_root=types_root, audit_log=tmp_path / "a.jsonl") + assert not (tmp_path / "b").exists() + + +def test_refuses_when_case_changed_since_review(tmp_path, types_root): + case = _case(tmp_path) + plan = _plan(tmp_path, case, types_root) + data = yaml.safe_load(case.read_text()) + data["spec"]["parameters"]["display_name"] = "Forgejo (renamed)" + case.write_text(yaml.safe_dump(data, sort_keys=False)) + with pytest.raises(cb.CaseBuildRefused, match="digest"): + cb.build_from_case(case, plan, tmp_path / "b", types_root=types_root, audit_log=tmp_path / "a.jsonl") + + +def test_refuses_to_overwrite_an_existing_bundle(tmp_path, types_root): + case = _case(tmp_path) + plan = _plan(tmp_path, case, types_root) + (tmp_path / "b").mkdir() + with pytest.raises(FileExistsError): + cb.build_from_case(case, plan, tmp_path / "b", types_root=types_root, audit_log=tmp_path / "a.jsonl") + + +def test_platform_validator_accepts_the_drafts(tmp_path, types_root): + if not (cb.RAILIANCE_PLATFORM / "scripts" / "credential-change.py").is_file(): + pytest.skip("railiance-platform checkout not present") + case = _case(tmp_path) + r = cb.build_from_case(case, _plan(tmp_path, case, types_root), tmp_path / "b", types_root=types_root, + audit_log=tmp_path / "a.jsonl") + assert cb.validate_with_platform([Path(p) for p in r["ccrs"]]) == [] diff --git a/workplans/MASON-WP-0010-security-case-materialization.md b/workplans/MASON-WP-0010-security-case-materialization.md index d5e922a..c8945ea 100644 --- a/workplans/MASON-WP-0010-security-case-materialization.md +++ b/workplans/MASON-WP-0010-security-case-materialization.md @@ -141,10 +141,10 @@ collision, digest `sha256:fb8b114d…`. ```task id: MASON-WP-0010-T03 -status: wait +status: progress depends_on: [MASON-WP-0010-T02] -blocked_on: message-from:net-kingdom -blocking_reason: "Needs the type implemented (net-kingdom) and the Forgejo case accepted; builds that write are founder-applied through the railiance-platform applier, so the build path is designed against that applier, and its acceptance is the Forgejo case materializing live." +blocked_on: message-from:railiance-platform +blocking_reason: "Built 2026-10-09 (builds/case-forgejo-netkingdom-oidc). Acceptance waits on railiance-platform filing the CCR drafts and the founder applying them, plus mint-secret; then the Forgejo case is live." priority: high state_hub_task_id: "56cea6be-c613-586c-80cd-125340b374da" ``` @@ -160,6 +160,21 @@ removes exactly what this case created. Acceptance: the Forgejo reference case (NK-WP-0048-T06) materializes through this path. +2026-10-09: implemented. `ops-mason build --case --plan [--out] +[--allow-unimplemented]` (`src/ops_mason/case_build.py`) refuses unless the +case is accepted, the plan is `reviewed` and its render digest equals a fresh +render. It writes a bundle and never touches OpenBao: one workload-kv-read CCR +draft per policy (ESO policy + Kubernetes role, mirroring CCR-2026-0033; the +consumer policy `in_flight` with `openbao.auth` missing until the consumer owner +binds it), the policy HCL, an ops-warden catalog proposal, `rollback.yaml` +(exactly what the case creates, reverse order) and a manifest with file hashes. +Every file names the case, type version, render digest and acceptance record; +the audit log gets a `case-build-prepared` record with the case acceptance as +approver. The drafts are checked with railiance-platform's own +`credential-change.py validate`. `tests/test_case_build.py` (6); suite 175 +passed. Forgejo bundle built into `builds/case-forgejo-netkingdom-oidc/`, +platform validation pass; handed to railiance-platform (message `2c409503`). + ```task id: MASON-WP-0010-T04 status: wait