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 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 3377672@bnt-lap001 Assistant-Session: 15463ccf-238f-4e13-b163-93aa25c6d166
93 lines
3.4 KiB
Python
Executable file
93 lines
3.4 KiB
Python
Executable file
#!/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())
|