From b1421317e7ca25f8611e8ce27edf6fe6111ab3a6 Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 6 Nov 2026 08:55:09 +0100 Subject: [PATCH] CCR-2026-0064 (proposed): read-only window watch for the CCR-2026-0058 write role (net-kingdom NK-WP-0058-T06) New request type auth-role-presence-watch (scripts/auth_role_watch_lane.py, registered in LANE_MODULES): a Kubernetes-auth role for sso/nk-window-watch whose policy is generated from the CCR - read (mode flag) or read and delete (mode close) on exactly one watched role path, plus token self-lifecycle. Validator refuses any widening; apply writes policy and role and reads both back; tests with a fake bao. Drafted by net-kingdom on the founder's instruction for platform-owner review. Mode flag; not approved. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 943911@bnt-lap001 Assistant-Session: fc322940-1c8e-4bed-ab39-86cec99f23a0 --- ...0064-nk-bounded-executor-window-watch.yaml | 96 +++++++++ ...-kingdom-bounded-executor-window-watch.hcl | 14 ++ scripts/auth_role_watch_lane.py | 201 ++++++++++++++++++ scripts/credential-change.py | 1 + tests/test_auth_role_watch_lane.py | 142 +++++++++++++ 5 files changed, 454 insertions(+) create mode 100644 credential-change-requests/CCR-2026-0064-nk-bounded-executor-window-watch.yaml create mode 100644 openbao/policies/net-kingdom-bounded-executor-window-watch.hcl create mode 100644 scripts/auth_role_watch_lane.py create mode 100644 tests/test_auth_role_watch_lane.py diff --git a/credential-change-requests/CCR-2026-0064-nk-bounded-executor-window-watch.yaml b/credential-change-requests/CCR-2026-0064-nk-bounded-executor-window-watch.yaml new file mode 100644 index 0000000..9d14c57 --- /dev/null +++ b/credential-change-requests/CCR-2026-0064-nk-bounded-executor-window-watch.yaml @@ -0,0 +1,96 @@ +id: CCR-2026-0064 +kind: credential-change-request +schema_version: 1 +request_type: auth-role-presence-watch +title: 'nk-window-watch: flag the CCR-2026-0058 write role when it outlives its run (read-only)' +status: proposed +created: '2026-11-06' +updated: '2026-11-06' +requester: + agent: net-kingdom + reason: net-kingdom NK-WP-0058-T06 (contract row O5). On 2026-11-04/05 the window-scoped write role of + CCR-2026-0058 stayed open overnight; T11 confirmed no use. A watcher in sso + (net-kingdom bounded-executor/window_watch.py, rapp-bounded-executor manifests/window-watch.yaml) checks every + 10 minutes whether the role exists while no bounded-executor run Job is active or recent, and flags it. + Drafted by net-kingdom on the founder's instruction (2026-11-06), for platform-owner review. Not approved. +review: + required: true + required_approvers: + - platform-owner + - net-kingdom-owner + comments: [] +target: + domain: infotech + tenant: platform + workload: net-kingdom-bounded-executor-window-watch + rapp: rapp-bounded-executor + environment: production + purpose: Let only the ServiceAccount sso/nk-window-watch read whether the role + net-kingdom-bind-executor-svc-authelia-write exists; no other path, no list, no write, no value +openbao: + policy_name: net-kingdom-bounded-executor-window-watch + policy_file: openbao/policies/net-kingdom-bounded-executor-window-watch.hcl + watch: + mount: kubernetes + role: net-kingdom-bind-executor-svc-authelia-write + mode: flag + auth: + method: kubernetes + mount: kubernetes + role: net-kingdom-bounded-executor-window-watch + audience: openbao + ttl: 15m + token_max_ttl: 15m + token_explicit_max_ttl: 15m + token_no_default_policy: true + policies: + - net-kingdom-bounded-executor-window-watch + bound_claims: + service_account_names: + - nk-window-watch + service_account_namespaces: + - sso + bound_claims_confirmed: false +access_frontdoor: + type: platform-owner + catalog_id: bounded-executor-window-watch + readiness: pending-review + resolvable: false +lifecycle: + role_window: Standing. The watcher logs in once per run (every 10 minutes), reads one role path and revokes its + token at exit. + close_mode: Mode close would add delete on the same single path, so the watcher removes a stale write role + itself. It only ever reduces privilege, but it is an unattended write on an auth role, so it needs its own + founder and platform-owner decision and an amendment of this CCR. + deactivate: Delete the role, then the policy; remove the CronJob (rapp-bounded-executor). + rotate: Nothing to rotate; the login uses the projected ServiceAccount token. + compromised: Delete the role. The reachable information is whether one role exists and its parameters. +delivery: + surface: native-workload-login + namespace: sso + service_account: nk-window-watch + reader: The window watch CronJob (net-kingdom bounded-executor/window_watch.py in the pinned executor image, + deployed by rapp-bounded-executor make deploy-window-watch); it prints one value-free line per run +risk: + classification: low + notes: + - Reachable data is the parameters of one Kubernetes-auth role (bound ServiceAccount, policies, TTLs), no + secret value and no token. + - The role is separate from the CCR-2026-0058/0059/0061 roles; policies are never combined in one role. + - Node root, cluster admins able to mint tokens for sso/nk-window-watch and platform-admin remain inside this + boundary. +verification: + positive: + - Exact policy and role read back (credential-change.py applier-apply readback) + - The watch CronJob's next run reports ok (no write role outside a run), visible as a succeeded Job to + sso/nk-agent-status + negative: + - Reading any other auth role, KV path or sys path is refused + - Writing or deleting the watched role is refused in mode flag + - Wrong ServiceAccount, wrong namespace and wrong audience logins are denied + activation_conditions: + - Explicit platform-owner and net-kingdom-owner approval of this exact request. + - ServiceAccount sso/nk-window-watch exists (rapp-bounded-executor make deploy-window-watch), then + bound_claims_confirmed set to true by the platform-owner with its uid. + - Attended platform-admin (aal2) session or the delegated applier applies policy and role from a reviewed + commit; results recorded value-free. diff --git a/openbao/policies/net-kingdom-bounded-executor-window-watch.hcl b/openbao/policies/net-kingdom-bounded-executor-window-watch.hcl new file mode 100644 index 0000000..80acfb6 --- /dev/null +++ b/openbao/policies/net-kingdom-bounded-executor-window-watch.hcl @@ -0,0 +1,14 @@ +# net-kingdom-bounded-executor-window-watch: CCR-2026-0064 (auth-role-presence-watch, mode flag). +# Platform-generated from the CCR by scripts/auth_role_watch_lane.py; do not edit by hand. + +path "auth/kubernetes/role/net-kingdom-bind-executor-svc-authelia-write" { + capabilities = ["read"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} + +path "auth/token/revoke-self" { + capabilities = ["update"] +} diff --git a/scripts/auth_role_watch_lane.py b/scripts/auth_role_watch_lane.py new file mode 100644 index 0000000..4e557fc --- /dev/null +++ b/scripts/auth_role_watch_lane.py @@ -0,0 +1,201 @@ +"""auth-role-presence-watch CCRs: a workload may watch whether one Kubernetes-auth role exists. + +net-kingdom NK-WP-0058-T06 (contract row O5). Loaded by scripts/credential-change.py for request_type +auth-role-presence-watch. A watcher ServiceAccount logs in with its own Kubernetes-auth role and may read exactly one +other role's path, so it can see that a window-scoped role (CCR-2026-0058) outlived its run. Mode `flag` grants read; +mode `close` adds delete on that same path, which only ever reduces privilege. Nothing else: no KV path, no other +auth path, no list, no sys. Nothing here reads, prints or accepts a secret value. +""" +from __future__ import annotations + +import importlib.util +import json +import re +import tempfile +from pathlib import Path +from typing import Any + +REQUEST_TYPE = "auth-role-presence-watch" +ROLE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +MODES = {"flag": ["read"], "close": ["read", "delete"]} +SELF_GRANTS = [("auth/token/lookup-self", "read"), ("auth/token/revoke-self", "update")] +DURATIONS = ("ttl", "token_max_ttl", "token_explicit_max_ttl") + + +def _load(name: str): + spec = importlib.util.spec_from_file_location(name, Path(__file__).resolve().parent / f"{name}.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_shared = _load("service_jwt_lane") +BaoError = _shared.BaoError +normalized = _shared.normalized +ttl_seconds = _shared.ttl_seconds +_run = _shared._run +_read_json = _shared._read_json + + +def watched_path(ccr: dict[str, Any]) -> str: + watch = ccr["openbao"]["watch"] + return f"auth/{watch['mount']}/role/{watch['role']}" + + +def generated_policy_hcl(ccr: dict[str, Any]) -> str: + openbao = ccr["openbao"] + caps = ", ".join(f'"{c}"' for c in MODES[openbao["watch"]["mode"]]) + lines = [f"# {openbao['policy_name']}: {ccr['id']} ({REQUEST_TYPE}, mode {openbao['watch']['mode']}).", + "# Platform-generated from the CCR by scripts/auth_role_watch_lane.py; do not edit by hand.", "", + f'path "{watched_path(ccr)}" {{', f" capabilities = [{caps}]", "}"] + for path, capability in SELF_GRANTS: + lines += ["", f'path "{path}" {{', f' capabilities = ["{capability}"]', "}"] + return "\n".join(lines) + "\n" + + +def role_payload(ccr: dict[str, Any]) -> dict[str, Any]: + auth = ccr["openbao"]["auth"] + return {"bound_service_account_names": list(auth["bound_claims"]["service_account_names"]), + "bound_service_account_namespaces": list(auth["bound_claims"]["service_account_namespaces"]), + "token_policies": [ccr["openbao"]["policy_name"]], "audience": auth["audience"], + "token_ttl": auth["ttl"], "token_max_ttl": auth["token_max_ttl"], + "token_explicit_max_ttl": auth["token_explicit_max_ttl"], "token_no_default_policy": True} + + +def validate(ccr: dict[str, Any], repo_dir: Path) -> list[str]: + errors: list[str] = [] + openbao = ccr.get("openbao") + if not isinstance(openbao, dict) or not isinstance(openbao.get("auth"), dict) \ + or not isinstance(openbao.get("watch"), dict): + return ["openbao.auth and openbao.watch must be objects"] + auth, watch = openbao["auth"], openbao["watch"] + if (ccr.get("target") or {}).get("environment") != "production": + errors.append("target.environment must be production") + review = ccr.get("review") or {} + if review.get("required") is not True or not review.get("required_approvers"): + errors.append("review.required must be true with required_approvers") + if watch.get("mode") not in MODES: + errors.append(f"openbao.watch.mode must be one of {sorted(MODES)}") + if watch.get("mount") != "kubernetes" or not ROLE_RE.match(str(watch.get("role") or "")): + errors.append("openbao.watch must name one role on the kubernetes auth mount") + if not NAME_RE.match(str(openbao.get("policy_name") or "")): + errors.append("openbao.policy_name contains unsafe characters") + if openbao.get("policy_file") != f"openbao/policies/{openbao.get('policy_name')}.hcl": + errors.append("openbao.policy_file must be openbao/policies/.hcl") + if auth.get("method") != "kubernetes" or auth.get("mount") != "kubernetes": + errors.append("openbao.auth must be the kubernetes method on mount kubernetes") + if not ROLE_RE.match(str(auth.get("role") or "")) or auth.get("role") == watch.get("role"): + errors.append("openbao.auth.role must be safe and differ from the watched role") + if auth.get("audience") != "openbao": + errors.append("openbao.auth.audience must be openbao") + if any(ttl_seconds(auth.get(k)) is None or ttl_seconds(auth.get(k)) > 900 for k in DURATIONS): + errors.append("openbao.auth ttl, token_max_ttl and token_explicit_max_ttl must be at most 15m") + if auth.get("token_no_default_policy") is not True: + errors.append("openbao.auth.token_no_default_policy must be true") + if auth.get("policies") != [openbao.get("policy_name")]: + errors.append("openbao.auth.policies must be exactly [policy_name]") + claims = auth.get("bound_claims") or {} + names, namespaces = claims.get("service_account_names"), claims.get("service_account_namespaces") + if not (isinstance(names, list) and len(names) == 1 and isinstance(namespaces, list) and len(namespaces) == 1 + and set(claims) == {"service_account_names", "service_account_namespaces"}): + errors.append("openbao.auth.bound_claims must name exactly one ServiceAccount in one namespace") + if not isinstance(auth.get("bound_claims_confirmed"), bool): + errors.append("openbao.auth.bound_claims_confirmed must be a boolean") + if errors: + return errors + policy = repo_dir / openbao["policy_file"] + if not policy.exists(): + errors.append(f"openbao.policy_file does not exist: {openbao['policy_file']}") + elif normalized(policy.read_text(encoding="utf-8")) != normalized(generated_policy_hcl(ccr)): + errors.append("openbao.policy_file differs from the policy generated from the CCR") + return errors + + +def readiness_blockers(ccr: dict[str, Any], repo_dir: Path, allowed_statuses: set[str]) -> list[str]: + blockers: list[str] = [] + if ccr.get("status") not in allowed_statuses: + blockers.append( + "applier requires approved, applied, verified, or active CCR status, " + f"got {ccr.get('status')}" + ) + if (ccr.get("openbao") or {}).get("auth", {}).get("bound_claims_confirmed") is not True: + blockers.append("applier requires confirmed OpenBao auth binding") + blockers.extend(validate(ccr, repo_dir)) + return blockers + + +def dry_run_payload(ccr: dict[str, Any], repo_dir: Path, warnings: list[str]) -> dict[str, Any]: + openbao = ccr["openbao"] + auth = openbao["auth"] + return { + "id": ccr["id"], + "title": ccr["title"], + "status": ccr["status"], + "environment": ccr["target"]["environment"], + "warnings": warnings, + "source_artifacts": {"policy_file": openbao["policy_file"]}, + "mutations": [ + {"kind": "policy_write", "openbao_path": f"sys/policies/acl/{openbao['policy_name']}", + "payload": {"source": openbao["policy_file"], "watched_path": watched_path(ccr), + "capabilities": MODES[openbao["watch"]["mode"]]}}, + {"kind": "auth_role_write", "openbao_path": f"auth/{auth['mount']}/role/{auth['role']}", + "payload": role_payload(ccr), "precondition": "auth/kubernetes exists (never enabled here)"}, + {"kind": "readback_compare", "openbao_path": "policy and role", "payload": {"prints": "booleans only"}}, + ], + "out_of_scope": [ + "the watched role itself (CCR-2026-0058 window-open and window-close)", + "secret value reads or writes", + "token creation or login", + ], + "required_evidence": [ + "CCR id and approval reference", + "readback equality for policy and role", + "the watch CronJob's first run in mode " + openbao["watch"]["mode"], + ], + } + + +def apply_details(ccr: dict[str, Any], actor: str) -> list[str]: + openbao = ccr["openbao"] + auth = openbao["auth"] + return [ + f"Delegated metadata applier ran as {actor} using local bao CLI ambient authority.", + f"Policy write: sys/policies/acl/{openbao['policy_name']} ({openbao['watch']['mode']} on {watched_path(ccr)})", + f"Kubernetes auth role write: auth/{auth['mount']}/role/{auth['role']}", + "Readback matched source for policy and role.", + "No secret values were read, written, printed, or accepted in argv.", + ] + + +def readback_mismatches(ccr: dict[str, Any], repo_dir: Path, bao_bin: str) -> list[str]: + openbao = ccr["openbao"] + auth = openbao["auth"] + mismatches: list[str] = [] + live = _run(bao_bin, ["policy", "read", openbao["policy_name"]], repo_dir, quiet=True, capture=True) + if normalized(live) != normalized(generated_policy_hcl(ccr)): + mismatches.append("policy differs") + role = _read_json(bao_bin, ["read", f"auth/{auth['mount']}/role/{auth['role']}"], repo_dir).get("data", {}) + for key, want in role_payload(ccr).items(): + got = role.get(key) + same = ttl_seconds(got) == ttl_seconds(want) if key.startswith("token_") and key.endswith("ttl") else got == want + if not same: + mismatches.append(f"role {key} differs") + return mismatches + + +def run_apply(ccr: dict[str, Any], repo_dir: Path, bao_bin: str, quiet: bool = False) -> None: + openbao = ccr["openbao"] + auth = openbao["auth"] + mounts = _read_json(bao_bin, ["auth", "list"], repo_dir) + mounts = mounts.get("data", mounts) + if (mounts.get("kubernetes/") or {}).get("type") != "kubernetes": + raise BaoError("auth mount kubernetes/ is absent or not kubernetes; this lane never enables it") + _run(bao_bin, ["policy", "write", openbao["policy_name"], openbao["policy_file"]], repo_dir, quiet) + with tempfile.TemporaryDirectory() as tmp: + role_path = Path(tmp) / "role.json" + role_path.write_text(json.dumps(role_payload(ccr), sort_keys=True) + "\n") + _run(bao_bin, ["write", f"auth/{auth['mount']}/role/{auth['role']}", f"@{role_path}"], repo_dir, quiet) + mismatches = readback_mismatches(ccr, repo_dir, bao_bin) + if mismatches: + raise BaoError("readback mismatch: " + "; ".join(mismatches)) diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 8bb2842..7714ce9 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -851,6 +851,7 @@ LANE_MODULES = { "workload-kv-operator-matrix": "operator_matrix_lane", "custody-generate-grant": "broker_grant_lane", "broker-issuer-binding": "issuer_binding_lane", + "auth-role-presence-watch": "auth_role_watch_lane", } diff --git a/tests/test_auth_role_watch_lane.py b/tests/test_auth_role_watch_lane.py new file mode 100644 index 0000000..fb4d385 --- /dev/null +++ b/tests/test_auth_role_watch_lane.py @@ -0,0 +1,142 @@ +"""auth-role-presence-watch lane (CCR-2026-0064, net-kingdom NK-WP-0058-T06).""" +from __future__ import annotations + +import copy +import importlib.util +import json +import os +import stat +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +CCR_PATH = ROOT / "credential-change-requests/CCR-2026-0064-nk-bounded-executor-window-watch.yaml" +WATCHED = "auth/kubernetes/role/net-kingdom-bind-executor-svc-authelia-write" + + +def load(name: str, rel: str): + spec = importlib.util.spec_from_file_location(name, ROOT / rel) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +lane = load("auth_role_watch_lane", "scripts/auth_role_watch_lane.py") +cc = load("credential_change_cli_watch", "scripts/credential-change.py") + + +def ccr() -> dict: + return yaml.safe_load(CCR_PATH.read_text()) + + +FAKE_BAO = r'''#!/usr/bin/env python3 +import json, os, sys +path = os.environ["FAKE_BAO_STATE"] +state = json.load(open(path)) +args = [a for a in sys.argv[1:] if a != "-format=json"] +state.setdefault("calls", []).append(args) +if args[:2] == ["auth", "list"]: + print(json.dumps({k: {"type": v} for k, v in state["mounts"].items()})) +elif args[:2] == ["policy", "write"]: + state["policies"][args[2]] = open(args[3]).read() +elif args[:2] == ["policy", "read"]: + if args[2] not in state["policies"]: + json.dump(state, open(path, "w")); sys.exit(2) + print(state["policies"][args[2]]) +elif args[0] == "write": + role = json.load(open(args[2][1:])) + state["roles"][args[1]] = {k: (900 if v == "15m" else v) for k, v in role.items()} +elif args[0] == "read": + print(json.dumps({"data": state["roles"][args[1]]})) +json.dump(state, open(path, "w")) +''' + + +class AuthRoleWatchLaneTests(unittest.TestCase): + def test_ccr_validates_and_blocks_until_approved_and_bound(self): + _ccr, errors, _w = cc.validate_ccr(CCR_PATH) + self.assertEqual(errors, []) + blockers = cc.applier_readiness_blockers(_ccr) + self.assertIn("applier requires confirmed OpenBao auth binding", blockers) + ready = copy.deepcopy(_ccr) + ready["status"] = "approved" + ready["openbao"]["auth"]["bound_claims_confirmed"] = True + self.assertEqual(cc.applier_readiness_blockers(ready), []) + + def test_flag_policy_is_one_read_and_self_lifecycle(self): + text = (ROOT / ccr()["openbao"]["policy_file"]).read_text() + paths = [line.split('"')[1] for line in text.splitlines() if line.startswith("path ")] + self.assertEqual(paths, [WATCHED, "auth/token/lookup-self", "auth/token/revoke-self"]) + self.assertIn('path "%s" {\n capabilities = ["read"]\n}' % WATCHED, text) + self.assertNotIn("delete", text) + + def test_close_mode_adds_only_delete_on_the_same_path(self): + c = ccr() + c["openbao"]["watch"]["mode"] = "close" + self.assertIn('path "%s" {\n capabilities = ["read", "delete"]\n}' % WATCHED, lane.generated_policy_hcl(c)) + self.assertIn("openbao.policy_file differs from the policy generated from the CCR", lane.validate(c, ROOT)) + + def test_validator_rejects_widening(self): + cases = [ + ("unknown mode", lambda c: c["openbao"]["watch"].update(mode="admin")), + ("other mount", lambda c: c["openbao"]["watch"].update(mount="netkingdom")), + ("watch itself", lambda c: c["openbao"]["auth"].update(role=c["openbao"]["watch"]["role"])), + ("long ttl", lambda c: c["openbao"]["auth"].update(ttl="1h")), + ("default policy", lambda c: c["openbao"]["auth"].update(token_no_default_policy=False)), + ("extra policy", lambda c: c["openbao"]["auth"]["policies"].append("platform-admin")), + ("two accounts", lambda c: c["openbao"]["auth"]["bound_claims"]["service_account_names"].append("x")), + ("extra claim", lambda c: c["openbao"]["auth"]["bound_claims"].update(other=["x"])), + ("other audience", lambda c: c["openbao"]["auth"].update(audience="kubernetes")), + ("staging", lambda c: c["target"].update(environment="staging")), + ] + for label, mutate in cases: + with self.subTest(label): + c = ccr() + mutate(c) + self.assertNotEqual(lane.validate(c, ROOT), [], label) + + def test_edited_policy_file_is_refused(self): + c = ccr() + with tempfile.TemporaryDirectory(dir=ROOT / "openbao/policies") as tmp: + edited = Path(tmp) / "p.hcl" + edited.write_text(lane.generated_policy_hcl(c) + 'path "platform/*" {\n capabilities = ["read"]\n}\n') + c["openbao"]["policy_name"] = "p" + c["openbao"]["policy_file"] = str(edited.relative_to(ROOT)) + self.assertNotEqual(lane.validate(c, ROOT), []) + + def _apply(self, mounts): + tmp = Path(tempfile.mkdtemp()) + (tmp / "state.json").write_text(json.dumps({"mounts": mounts, "policies": {}, "roles": {}})) + bao = tmp / "bao" + bao.write_text(FAKE_BAO) + bao.chmod(bao.stat().st_mode | stat.S_IEXEC) + os.environ["FAKE_BAO_STATE"] = str(tmp / "state.json") + error = None + try: + lane.run_apply(ccr(), ROOT, str(bao), quiet=True) + except lane.BaoError as exc: + error = exc + return error, json.loads((tmp / "state.json").read_text()) + + def test_apply_writes_policy_and_role_and_reads_back(self): + error, state = self._apply({"kubernetes/": "kubernetes"}) + self.assertIsNone(error) + role = state["roles"]["auth/kubernetes/role/net-kingdom-bounded-executor-window-watch"] + self.assertEqual(role["bound_service_account_names"], ["nk-window-watch"]) + self.assertEqual(role["token_policies"], ["net-kingdom-bounded-executor-window-watch"]) + self.assertEqual(set(state["policies"]), {"net-kingdom-bounded-executor-window-watch"}) + self.assertNotIn(WATCHED, state["roles"]) # the watched role is never written here + + def test_apply_refuses_without_the_kubernetes_mount(self): + error, state = self._apply({}) + self.assertIsNotNone(error) + self.assertEqual(state["policies"], {}) + + +if __name__ == "__main__": + unittest.main()