Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
242 lines
13 KiB
Python
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}
|