From d020413d7ad783d1ffda666d85a34e3ce685db28 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 28 Aug 2026 11:43:08 +0200 Subject: [PATCH] feat(mason): describe stored credentials, and stop the grant rewriting itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit custody-inventory.py walks operators/ and platform/workloads/, prints each path's description, owner, consumers and recovery path, and marks any missing them. Metadata only, never a value, so it runs under ops-mason-build and can be handed to anyone orienting themselves. First run: 21 paths, 17 undescribed. Described the four this session touched, including on_loss — the field whose absence meant the LLDAP predecessor's recovery path had to be worked out from first principles while locked out. ops-mason-build gains create/update on */metadata/*, since a description is documentation rather than a value. delete stays absent: deleting a metadata entry destroys every version of the secret beneath it. It also now denies itself sys/policies/acl/ops-mason-build. Without that the policy was advisory — a token that can write policies can delete its own denials, so the claim that OpenBao enforces "never read a value" was not true as written. An exact path outranks the glob, so changing what ops-mason may do is now an operator act, visible as one in the audit log. Co-Authored-By: Claude Opus 5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 3377672@bnt-lap001 Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166 --- policies/ops-mason-build.hcl | 19 +++++++- scripts/custody-inventory.py | 93 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100755 scripts/custody-inventory.py diff --git a/policies/ops-mason-build.hcl b/policies/ops-mason-build.hcl index 3abe5a3..9c1a55a 100644 --- a/policies/ops-mason-build.hcl +++ b/policies/ops-mason-build.hcl @@ -43,12 +43,15 @@ path "auth/approle/role/*" { # Metadata carries versions, timestamps and custom_metadata — enough to confirm # a path exists and that a paste-once delivery landed. It does not carry the # value. +# create/update so a path can be *described* — custom_metadata is documentation, +# not a value. delete is absent: deleting a metadata entry destroys every +# version of the secret under it, which is a destructive act, not a build one. path "platform/metadata/*" { - capabilities = ["read", "list"] + capabilities = ["create", "read", "update", "list"] } path "operators/metadata/*" { - capabilities = ["read", "list"] + capabilities = ["create", "read", "update", "list"] } # --- verification ---------------------------------------------------------- @@ -74,6 +77,18 @@ path "sys/capabilities-self" { capabilities = ["create", "update"] } +# --- the grant cannot rewrite its own scope -------------------------------- +# Without this, everything below is advisory: a token that can write policies +# can delete its own denials and then read anything. An exact path outranks the +# sys/policies/acl/* glob above, so this stanza wins. +# +# The consequence is deliberate — changing what ops-mason may do is an operator +# act, performed with an operator session, and visible as such in the audit log. +# It cannot be done quietly from inside a build. +path "sys/policies/acl/ops-mason-build" { + capabilities = ["read", "deny"] +} + # --- the line, stated as a denial ------------------------------------------ # Explicit deny outranks any grant, including one added here later by mistake. # If ops-mason needs to prove a credential works, the consumer proves it, or a diff --git a/scripts/custody-inventory.py b/scripts/custody-inventory.py new file mode 100755 index 0000000..614dc2d --- /dev/null +++ b/scripts/custody-inventory.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""custody-inventory — what credentials exist, what each is for, who owns it. + + ./scripts/custody-inventory.py operators/ and platform/workloads/ + ./scripts/custody-inventory.py operators one mount or prefix + ./scripts/custody-inventory.py --undescribed only paths missing metadata + +Reads metadata only — never a value — so it runs under ops-mason-build and can +be handed to anyone orienting themselves without granting them a single secret. + +A path with no description is a finding, not a formatting problem: it is a +credential nobody can identify without reading it, which is how a store turns +back into the drawer of unlabelled keys it was meant to replace. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +REQUIRED = ("description", "owner", "used_by", "rotation", "on_loss") +DEFAULT_ROOTS = ("operators", "platform/workloads") + + +def bao(*args: str) -> dict | list | None: + env = dict(os.environ) + env.setdefault("BAO_ADDR", "https://bao.coulomb.social") + grant = os.path.expanduser("~/.claude-bao-token") + if "BAO_TOKEN" not in env and os.path.exists(grant): + with open(grant) as f: + env["BAO_TOKEN"] = f.read().strip() + # The Vault/OpenBao CLI rejects flags placed after a positional argument, + # so -format=json goes immediately before the path, not at the end. + argv = ["bao", *args[:-1], "-format=json", args[-1]] + p = subprocess.run(argv, capture_output=True, text=True, timeout=30, env=env) + if p.returncode != 0: + return None + try: + return json.loads(p.stdout) + except json.JSONDecodeError: + return None + + +def walk(prefix: str) -> list[str]: + keys = bao("kv", "list", prefix) + if not isinstance(keys, list): + return [] + out: list[str] = [] + for k in keys: + child = f"{prefix.rstrip('/')}/{k.rstrip('/')}" + out.extend(walk(child) if k.endswith("/") else [child]) + return out + + +def main() -> int: + only_undescribed = "--undescribed" in sys.argv + roots = [a for a in sys.argv[1:] if not a.startswith("-")] or list(DEFAULT_ROOTS) + + total = incomplete = 0 + for root in roots: + for path in walk(root): + total += 1 + meta = bao("kv", "metadata", "get", path) or {} + d = meta.get("data", {}) if isinstance(meta, dict) else {} + cm = d.get("custom_metadata") or {} + missing = [k for k in REQUIRED if not cm.get(k)] + if missing: + incomplete += 1 + if only_undescribed and not missing: + continue + + flag = " " if not missing else "! " + print(f"{flag}{path} (v{d.get('current_version', '?')}, " + f"{str(d.get('created_time', ''))[:10]})") + for label, key in (("", "description"), ("owner: ", "owner"), + ("used by: ", "used_by"), ("if lost: ", "on_loss")): + if cm.get(key): + print(f" {label}{cm[key]}") + if missing: + print(f" MISSING: {', '.join(missing)}") + print() + + print("─" * 45) + print(f"{total} credential path(s), {incomplete} missing required metadata") + if incomplete: + print("Paths marked ! need describing — see platform-root-custody.md.") + return 1 if incomplete else 0 + + +if __name__ == "__main__": + raise SystemExit(main())