ops-mason/scripts/custody-inventory.py

113 lines
4.5 KiB
Python
Raw Normal View History

#!/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")
class InventoryError(RuntimeError):
"""Inventory could not be completed; never report this as an empty store."""
def bao(*args: str) -> dict | list:
env = dict(os.environ)
env.setdefault("BAO_ADDR", "http://127.0.0.1:18200")
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()
if not env.get("BAO_TOKEN"):
raise InventoryError("No scoped BAO_TOKEN or ops-mason grant; inventory was not run.")
# 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]]
try:
p = subprocess.run(argv, capture_output=True, text=True, timeout=30, env=env)
except (OSError, subprocess.TimeoutExpired) as exc:
raise InventoryError("OpenBao metadata command unavailable or timed out") from exc
if p.returncode != 0:
raise InventoryError(f"OpenBao metadata request failed for {args[-1]}; inventory incomplete")
try:
return json.loads(p.stdout)
except json.JSONDecodeError as exc:
raise InventoryError("Invalid OpenBao metadata response; inventory incomplete") from exc
def walk(prefix: str) -> list[str]:
keys = bao("kv", "list", prefix)
if not isinstance(keys, list):
raise InventoryError(f"Invalid metadata listing for {prefix}")
out: list[str] = []
for k in keys:
if not isinstance(k, str) or not k.rstrip("/") or "/" in k.rstrip("/") or k.rstrip("/") in {".", ".."}:
raise InventoryError(f"Invalid child in metadata listing for {prefix}")
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)
if not isinstance(meta, dict) or not isinstance(meta.get("data"), dict):
raise InventoryError(f"Invalid metadata response for {path}")
d = meta["data"]
cm = d.get("custom_metadata") or {}
if not isinstance(cm, dict):
raise InventoryError(f"Invalid custom metadata for {path}")
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__":
try:
raise SystemExit(main())
except InventoryError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2)