diff --git a/INTENT.md b/INTENT.md index 1ea5148..e21af03 100644 --- a/INTENT.md +++ b/INTENT.md @@ -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" diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 953070e..35dfb57 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/intakes/intakes.md b/intakes/intakes.md index 5c7aebf..4d8a527 100644 --- a/intakes/intakes.md +++ b/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" ``` diff --git a/src/ops_mason/ccr_gate.py b/src/ops_mason/ccr_gate.py new file mode 100644 index 0000000..6930311 --- /dev/null +++ b/src/ops_mason/ccr_gate.py @@ -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"(? 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 diff --git a/tests/test_ccr_gate.py b/tests/test_ccr_gate.py new file mode 100644 index 0000000..f7cf412 --- /dev/null +++ b/tests/test_ccr_gate.py @@ -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 diff --git a/workplans/MASON-WP-0015-construct-grant.md b/workplans/MASON-WP-0015-construct-grant.md index 69ff0d8..5a62ea5 100644 --- a/workplans/MASON-WP-0015-construct-grant.md +++ b/workplans/MASON-WP-0015-construct-grant.md @@ -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`.