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:
parent
a97d1931dd
commit
3e49ed4c0c
11 changed files with 633 additions and 3 deletions
33
builds/case-forgejo-netkingdom-oidc/catalog-proposal.yaml
Normal file
33
builds/case-forgejo-netkingdom-oidc/catalog-proposal.yaml
Normal 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
|
||||
|
||||
'
|
||||
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
28
builds/case-forgejo-netkingdom-oidc/manifest.json
Normal file
28
builds/case-forgejo-netkingdom-oidc/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
36
builds/case-forgejo-netkingdom-oidc/rollback.yaml
Normal file
36
builds/case-forgejo-netkingdom-oidc/rollback.yaml
Normal 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
213
src/ops_mason/case_build.py
Normal 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}
|
||||
|
|
@ -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
107
tests/test_case_build.py
Normal 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"]]) == []
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue