ops-mason/src/ops_mason/case_build.py
tegwick a41312e5a4 Prepare reviewed Forgejo consumer ESO scope and truthful recovery records
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
2026-10-11 04:09:26 +02:00

242 lines
13 KiB
Python

"""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 admission or prepared source inventory is invalid."""
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,
}
consumer = policy["id"] == "consumer-reader-policy"
reader = "eso" if role and not consumer else params["consuming_service"]
workload = params["consuming_service"] if consumer else "keycape"
deactivate = f"delete the role, then policy {policy['template']} (case rollback)"
if consumer and role:
deactivate = (
"Revert the consumer reference first; remove only independently proved "
"case-created delivery objects, role and policy, preserving preexisting-matched "
"and shared objects. Revoke issued reader tokens through owner custody or "
"prove independently verified expiry before declaring deactivation; role/SA/store "
"deletion alone does not revoke tokens. Recovery uses existing owner admission: "
"no new login and no remint. Preserve authenticated creation/ownership/recovery "
"receipts and stop on changed references, UIDs or ownership."
)
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": workload, "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": deactivate,
"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
# The CCR drafts set token_self_lifecycle: true, so the platform applier generates
# these two stanzas and refuses a policy file without them (CCR-2026-0041 review).
_SELF_LIFECYCLE = """
# ESO validates its own short-lived workload session.
path "auth/token/lookup-self" {
capabilities = ["read"]
}
path "auth/token/revoke-self" {
capabilities = ["update"]
}
"""
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" + _SELF_LIFECYCLE
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:
if rel in files:
raise CaseBuildRefused("duplicate output identity in case bundle")
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}