MASON-WP-0015: gate CCR evidence and qualify the kube wait

T07 generates a CCR policy through the platform generator and refuses
record-evidence unless the built policy matches the CCR file. T06 stays
wait on the open founder decision after a fresh can-i of yes, yes, yes.

Assistant: grok
Assistant-Session: 01a16071-5425-78d3-80b4-20fe2ee30f0a
This commit is contained in:
tegwick 2026-10-22 01:59:49 +02:00
parent 4e46aa3b4f
commit 9b705ec62c
6 changed files with 385 additions and 14 deletions

View file

@ -164,7 +164,7 @@ tooling_contacts:
until: "2026-12-21"
rule: "Direct ADMINISTER under activation=APPROVED, each change recorded as production-tier, until ArgoCD onboarding."
relies_on_limits: "One expected namespace per plan; Pod and Secret kinds refused; no data or stringData. Widening them is a new decision."
identity_gap: "2026-10-18 (MASON-IN-0006): the kube identity applied with is system:admin (system:masters). Refusing Pod and Secret kinds is a code limit in kubernetes_plane, not an RBAC limit; the identity can mint any ServiceAccount token."
identity_gap: "2026-10-22 (MASON-IN-0006, still open): context default, name-only kubectl auth can-i --all-namespaces at 2026-10-21T23:55:17Z returned yes for create serviceaccounts/token, yes for create pods, and yes for impersonate serviceaccounts. The kube identity applied with is still system:admin (system:masters). Refusing Pod and Secret kinds is a code limit in kubernetes_plane, not an RBAC limit; the identity can mint any ServiceAccount token. Options (a), (b), and (c) are unchanged and unchosen. T06 waits on founder decision 30d29060-8d14-4a8e-9cde-34aa826eb8d1."
enforcement: "src/ops_mason/readiness.py: inspect_readiness and resolve_tier; kubernetes_plane.apply refuses before Kubernetes writes. Source/history verification, explicit whitehat placement, dated policy-nexus transition, and recorded BREAK_GLASS."
- id: bao-session-grant
shape: "5.2"

View file

@ -101,7 +101,7 @@
| task | MASON-WP-0015-T04 | done | — | workplans/MASON-WP-0015-construct-grant.md |
| task | MASON-WP-0015-T05 | done | — | workplans/MASON-WP-0015-construct-grant.md |
| task | MASON-WP-0015-T06 | wait | — | workplans/MASON-WP-0015-construct-grant.md |
| task | MASON-WP-0015-T07 | wait | — | workplans/MASON-WP-0015-construct-grant.md |
| task | MASON-WP-0015-T07 | done | — | workplans/MASON-WP-0015-construct-grant.md |
| intake | MASON-IN-0001 | closed | green | intakes/intakes.md |
| intake | MASON-IN-0002 | closed | — | intakes/intakes.md |
| intake | MASON-IN-0003 | open | — | intakes/intakes.md |

View file

@ -254,6 +254,11 @@ description: >-
The only control against Secret reads is the guard-secret-reads transcript
hook, which is a convention, not access control. Checked by capability
queries only; not exercised.
Fresh check 2026-10-22 (captured 2026-10-21T23:55:17Z), context default,
name-only, all namespaces: create serviceaccounts/token yes, create pods
yes, impersonate serviceaccounts yes. Still cluster-admin. Options (a),
(b), and (c) below are unchanged and unchosen. MASON-WP-0015-T06 waits on
founder decision 30d29060-8d14-4a8e-9cde-34aa826eb8d1.
Side note: ~/.kube/config-hosteurope points at 127.0.0.1:16443, where nothing
is listening, but the railiance-platform runbooks still reference it.
options: >-
@ -269,7 +274,7 @@ options: >-
date.
Cluster RBAC and the kubeconfig distribution belong to railiance-platform
(and railiance-infra for node credentials); this is a founder decision.
updated: '2026-10-18T00:00:00Z'
updated: '2026-10-22T00:00:00Z'
state_hub_intake_id: "01a14ea8-3bd8-7da1-b51e-849067a39aff"
```

278
src/ops_mason/ccr_gate.py Normal file
View file

@ -0,0 +1,278 @@
"""Client gate for a CCR drafted from a plan (MASON-WP-0015-T07).
The policy text comes from railiance-platform's generator
(``credential-change.py render-policy``). Plan approval is the single hub
decision already stored on the draft. This module does not run
``sync-decision``; that stays a platform or founder step.
``record_evidence`` refuses to call ``record-evidence`` unless the built
policy text matches the CCR policy file after a trailing newline is
normalised.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Any, Sequence
import yaml
PLATFORM = Path.home() / "railiance-platform"
_UUID = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
_COMMIT = re.compile(r"^[0-9a-f]{7,40}$")
# Same markers the platform scanner rejects. Values never belong in a draft.
_SECRET_MARKERS = (
"AGE-SECRET-KEY-1",
"-----BEGIN PRIVATE KEY-----",
"-----BEGIN OPENSSH PRIVATE KEY-----",
"OPENBAO_ROOT_TOKEN=",
"VAULT_TOKEN=",
"BAO_TOKEN=",
"hvb.",
"hvc.",
"hvs.",
"npm_",
"ghp_",
"sk-",
)
class EvidenceRefused(RuntimeError):
"""``record-evidence`` was not run."""
def _platform_env() -> dict[str, str]:
venv = os.environ.get("VIRTUAL_ENV")
env = {
key: value
for key, value in os.environ.items()
if key not in {"VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT", "PYTHONPATH"}
}
if venv:
env["PATH"] = os.pathsep.join(
part for part in env.get("PATH", "").split(os.pathsep) if not part.startswith(venv)
)
return env
def _reject_secrets(text: str) -> None:
for marker in _SECRET_MARKERS:
if marker == "sk-":
if re.search(r"(?<![A-Za-z])sk-[A-Za-z0-9]", text):
raise EvidenceRefused("refusing secret-bearing text; record-evidence not run")
continue
if marker in text:
raise EvidenceRefused("refusing secret-bearing text; record-evidence not run")
def _generator(platform: Path | None) -> Path:
root = platform or PLATFORM
script = root / "scripts" / "credential-change.py"
if not script.is_file():
raise EvidenceRefused(f"platform generator not found at {script}")
return script
def generate_policy(kv_path: str, *, platform: Path | None = None) -> str:
"""Return the standard-lane policy HCL from the platform generator.
This prints a draft. It does not submit a CCR.
"""
script = _generator(platform)
completed = subprocess.run(
[sys.executable, str(script), "render-policy", "--kv-path", kv_path],
cwd=script.parents[1],
env=_platform_env(),
text=True,
capture_output=True,
check=False,
)
if completed.returncode != 0:
raise EvidenceRefused("platform generator refused the KV path; record-evidence not run")
policy = completed.stdout
if not policy.strip():
raise EvidenceRefused("platform generator returned an empty policy")
_reject_secrets(policy)
return policy
def _decision_id(ccr: dict[str, Any]) -> str:
state = ccr.get("state_hub")
if not isinstance(state, dict):
raise EvidenceRefused("plan approval must be the single hub decision")
decision_id = state.get("decision_id")
if not isinstance(decision_id, str) or _UUID.fullmatch(decision_id) is None:
raise EvidenceRefused("plan approval must be the single hub decision")
extra = [key for key in state if key not in {"decision_id", "decision_api_url"}]
if extra:
raise EvidenceRefused("plan approval must be the single hub decision")
return decision_id
def draft_from_plan(
*,
kv_path: str,
plan_ref: dict[str, str],
decision_id: str,
out_dir: Path,
platform: Path | None = None,
) -> dict[str, Any]:
"""Write a non-secret CCR draft and the generated policy. Does not submit."""
if _UUID.fullmatch(decision_id) is None:
raise EvidenceRefused("plan approval must be the single hub decision")
for key in ("repo", "path", "commit"):
value = plan_ref.get(key)
if not isinstance(value, str) or not value:
raise EvidenceRefused("plan_ref needs repo, path, and commit")
if _COMMIT.fullmatch(plan_ref["commit"]) is None:
raise EvidenceRefused("plan_ref commit must be a hex git commit")
policy = generate_policy(kv_path, platform=platform)
out_dir.mkdir(parents=True, exist_ok=True)
policy_path = out_dir / "policy.hcl"
policy_path.write_text(policy if policy.endswith("\n") else policy + "\n", encoding="utf-8")
slug = kv_path.rstrip("/").rsplit("/", 1)[-1]
policy_name = f"workload-kv-read-{slug}"
draft = {
"id": "CCR-DRAFT",
"kind": "credential-change-request",
"schema_version": 1,
"request_type": "workload-kv-read",
"title": f"Draft reader for {kv_path}",
"status": "draft",
"created": "2026-10-22",
"updated": "2026-10-22",
"requester": {"agent": "ops-mason"},
"plan_ref": {key: plan_ref[key] for key in ("repo", "path", "commit")},
"state_hub": {"decision_id": decision_id},
"target": {
"domain": "infotech",
"tenant": "railiance",
"workload": slug,
"environment": "build",
"purpose": "standard-lane KV read drafted from the construction plan",
},
"openbao": {
"mount": kv_path.split("/", 1)[0],
"kv_path": kv_path,
"policy_name": policy_name,
"policy_file": str(policy_path),
"metadata_read": False,
"token_self_lifecycle": True,
"fields": ["FIELD"],
"auth": {
"method": "kubernetes",
"mount": "kubernetes",
"role": f"external-secrets-{slug}",
"audience": "openbao",
"ttl": "15m",
"policies": [policy_name],
"bound_claims": {
"service_account_names": [slug],
"service_account_namespaces": ["workloads"],
},
"bound_claims_confirmed": False,
},
},
"access_frontdoor": {
"type": "ops-warden",
"catalog_id": f"draft-{slug}",
"readiness": "pending-review",
"resolvable": False,
},
"risk": {
"classification": "low",
"notes": ["Draft only. No value is stored in this file."],
},
"verification": {
"positive": ["Authorized caller can read the path without printing a value."],
"negative": ["Unauthorized caller cannot read the path."],
"activation_conditions": ["Founder resolves the single hub decision."],
},
"lifecycle": {
"deactivate": "Delete the Kubernetes auth role and the workload policy.",
"rotate": "Replace the value out of band and record the rotation.",
"compromised": "Revoke the role and treat the path as exposed.",
},
}
text = yaml.safe_dump(draft, sort_keys=False)
_reject_secrets(text)
draft_path = out_dir / "ccr-draft.yaml"
draft_path.write_text(text, encoding="utf-8")
return {"draft": draft_path, "policy_file": policy_path, "built_policy": policy}
def _policy_text(ccr: dict[str, Any], draft_path: Path) -> str:
policy_file = (ccr.get("openbao") or {}).get("policy_file")
if not isinstance(policy_file, str) or not policy_file:
raise EvidenceRefused("CCR draft has no policy file")
path = Path(policy_file)
if not path.is_absolute():
path = draft_path.parent / path
return path.read_text(encoding="utf-8")
def record_evidence(
draft_path: Path,
*,
built_policy: str,
actor: str,
kind: str,
result: str,
detail: Sequence[str],
platform: Path | None = None,
) -> subprocess.CompletedProcess[str]:
"""Append evidence only when the built policy matches the CCR policy file.
A trailing newline is ignored. Any other difference refuses the call.
The command is ``record-evidence``. ``sync-decision`` is never invoked.
"""
draft_path = Path(draft_path)
loaded = yaml.safe_load(draft_path.read_text(encoding="utf-8"))
if not isinstance(loaded, dict):
raise EvidenceRefused("CCR draft is not a document")
_decision_id(loaded)
file_text = _policy_text(loaded, draft_path)
rendered_detail = "\n".join(detail)
_reject_secrets(built_policy)
_reject_secrets(file_text)
_reject_secrets(rendered_detail)
_reject_secrets(actor)
if built_policy.rstrip("\n") != file_text.rstrip("\n"):
raise EvidenceRefused(
"built policy does not match the CCR policy file; record-evidence refused"
)
script = _generator(platform)
argv = [
sys.executable,
str(script),
"record-evidence",
str(draft_path),
"--actor",
actor,
"--kind",
kind,
"--result",
result,
]
for line in detail:
argv.extend(["--detail", line])
if "sync-decision" in argv:
raise EvidenceRefused("sync-decision stays a platform or founder step")
completed = subprocess.run(
argv,
cwd=script.parents[1],
env=_platform_env(),
text=True,
capture_output=True,
check=False,
)
if completed.returncode != 0:
raise EvidenceRefused("record-evidence failed; no evidence was accepted")
return completed

72
tests/test_ccr_gate.py Normal file
View file

@ -0,0 +1,72 @@
"""The CCR record-evidence gate matches a policy and refuses a different one."""
from pathlib import Path
import pytest
import yaml
from ops_mason.ccr_gate import EvidenceRefused, draft_from_plan, record_evidence
DECISION_ID = "00000000-0000-4000-8000-000000000001"
KV_PATH = "platform/workloads/fixture/reader"
def _draft(tmp_path: Path) -> dict:
return draft_from_plan(
kv_path=KV_PATH,
plan_ref={
"repo": "ops-mason",
"path": "plans/fixture.md",
"commit": "0123456789abcdef",
},
decision_id=DECISION_ID,
out_dir=tmp_path,
)
def _evidence(draft: Path) -> list:
document = yaml.safe_load(draft.read_text(encoding="utf-8"))
return (document.get("verification") or {}).get("evidence") or []
def test_record_evidence_accepts_a_trailing_newline_and_refuses_a_different_policy(tmp_path):
drafted = _draft(tmp_path)
policy_path = Path(drafted["policy_file"])
# The platform generator's text, plus one trailing newline, still matches.
policy_path.write_text(policy_path.read_text(encoding="utf-8") + "\n", encoding="utf-8")
completed = record_evidence(
drafted["draft"],
built_policy=drafted["built_policy"],
actor="ops-mason",
kind="metadata_apply",
result="passed",
detail=["built policy matched the CCR policy file"],
)
assert completed.returncode == 0
assert "record-evidence" in completed.args
assert "sync-decision" not in completed.args
recorded = _evidence(drafted["draft"])
assert len(recorded) == 1
assert recorded[0]["kind"] == "metadata_apply"
assert recorded[0]["result"] == "passed"
assert "matched the CCR policy file" in recorded[0]["details"][0]
policy_path.write_text(
policy_path.read_text(encoding="utf-8").replace('["read"]', '["create"]', 1),
encoding="utf-8",
)
with pytest.raises(EvidenceRefused, match="record-evidence refused"):
record_evidence(
drafted["draft"],
built_policy=drafted["built_policy"],
actor="ops-mason",
kind="metadata_apply",
result="passed",
detail=["built policy matched the CCR policy file"],
)
assert len(_evidence(drafted["draft"])) == 1
stored = drafted["draft"].read_text(encoding="utf-8")
assert "sync-decision" not in stored
assert DECISION_ID in stored

View file

@ -8,7 +8,7 @@ status: active
owner: claude
topic_slug: netkingdom-security-architecture
created: "2026-10-18"
updated: "2026-10-18"
updated: "2026-10-22"
flavor: implementation
depends_on: []
state_hub_workstream_id: "384b3efc-f9a5-5ea9-b224-0ce900e79804"
@ -115,6 +115,9 @@ only. They edit their own files.
id: MASON-WP-0015-T06
status: wait
priority: medium
needs_human: true
decision_id: "30d29060-8d14-4a8e-9cde-34aa826eb8d1"
intervention_note: "Founder chooses MASON-IN-0006 option (a), (b), or (c). ops-mason did not choose and did not change cluster RBAC."
state_hub_task_id: "feb17422-9490-51f4-a62d-55647a5f6b05"
```
@ -127,24 +130,37 @@ the Pod and Secret kinds. Record the result in INTENT.md's
Result 2026-10-18: **open.** The kube identity is system:admin
(system:masters). It can create serviceaccounts/token, pods and impersonate,
cluster-wide. Raised as MASON-IN-0006; this waits on the founder's decision
there.
cluster-wide. Raised as MASON-IN-0006.
Result 2026-10-22, context `default`, captured 2026-10-21T23:55:17Z,
`--all-namespaces`, name-only: create serviceaccounts/token **yes**, create
pods **yes**, impersonate serviceaccounts **yes**. The identity is still
cluster-admin. MASON-IN-0006 stays the open residual and still names options
(a), (b), and (c). This task does not choose one and does not change cluster
RBAC. It waits on founder decision
`30d29060-8d14-4a8e-9cde-34aa826eb8d1` (open, pending).
## Submit CCRs from approved plans (RPF-WP-0061)
```task
id: MASON-WP-0015-T07
status: wait
status: done
priority: medium
state_hub_task_id: "ebc420c6-be1a-5abb-b766-fda0bdf2680b"
```
railiance-platform RPF-WP-0061 makes ops-mason the only CCR submitter
(`credential-change.py submit`). ops-mason's side:
- generate the CCR draft and policy file from the plan, using platform's
generator once it is exposed;
- take plan approval from the single hub decision after `sync-decision`;
- compare the built policy with the CCR policy file, then `record-evidence`.
(`credential-change.py submit`). RPF-WP-0061-T03 and T04 are done, so this
no longer waits on them. RPF-WP-0061-T05 still waits for a first live
self-service submission; none was filed here because no approved plan is
waiting.
Comment sent 2026-10-18, as a reply to `f49ddd0d`. This waits on
RPF-WP-0061-T03 and T04.
2026-10-22: ops-mason generates the CCR draft and its policy from the plan
through the platform generator (`credential-change.py render-policy`,
`src/ops_mason/ccr_gate.py` `draft_from_plan`). Plan approval is the single
hub decision recorded on the draft (`state_hub.decision_id`). ops-mason did
not run `sync-decision`; that stays a platform or founder step. Before
`record-evidence`, `record_evidence` compares the built policy text with the
CCR policy file and refuses the call unless they match, with a trailing
newline normalised. The draft, the policy, and the evidence contain no
secret value. Tests: `tests/test_ccr_gate.py` and `tests/test_construct_policy.py`.