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:
parent
4e46aa3b4f
commit
9b705ec62c
6 changed files with 385 additions and 14 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
278
src/ops_mason/ccr_gate.py
Normal 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
72
tests/test_ccr_gate.py
Normal 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
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue