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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 327203@bnt-lap001
Assistant-Session: 55028ae3-4914-4bd1-8f5c-815f3527aef0
This commit is contained in:
tegwick 2026-10-09 19:52:33 +02:00
parent a97d1931dd
commit 3e49ed4c0c
11 changed files with 633 additions and 3 deletions

View file

@ -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
'

View file

@ -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

View file

@ -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).

View file

@ -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"
}
}

View file

@ -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"]
}

View file

@ -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"]
}

View file

@ -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_ID>_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.

213
src/ops_mason/case_build.py Normal file
View file

@ -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}

View file

@ -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-<id>)")
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":

107
tests/test_case_build.py Normal file
View file

@ -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"]]) == []

View file

@ -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 <case> --plan <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