feat(mason): describe stored credentials, and stop the grant rewriting itself
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
This commit is contained in:
parent
7bd230c06a
commit
d020413d7a
2 changed files with 110 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
93
scripts/custody-inventory.py
Executable file
93
scripts/custody-inventory.py
Executable file
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue