Revise KeyCape secrets-engine-openbao custody: OpenBao-only, operator reader CCR-2026-0034 (source only)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

Founder direction 2026-10-06 and ops-mason plan keycape-secrets-engine-openbao-client-custody
(658cdbc, MASON-WP-0009): the client secret lives only in OpenBao.

- CCR-2026-0033: consumer file and secrets-engine D1-D4 removed; KV metadata declared
  (cas_required, max_versions=2, delete_version_after=0s, custom_metadata); target.domain
  infotech; live order and roll-forward rollback revised; status stays proposed.
- New sibling CCR-2026-0034 + policy: attended OIDC operator reader on auth/netkingdom,
  read data path + revoke-self only, 5m/4 uses, no default policy, bound
  groups=net-kingdom-admins and /assurance/level=aal2 (KeyCape 7d96ee4). Proposed,
  bound claims unconfirmed (founder item for both readers).
- credential-change.py: OIDC roles carry token_max_ttl/explicit_max_ttl/no_default_policy/
  num_uses/type and bound_claims_type (string only); token_revoke_self policy option;
  validated openbao.kv_metadata. Tests added.
- Custody child: consumer write and check-secret-file removed; cas=0 create / --rotate
  cas=<current>; compare-before-write metadata put; refusals unchanged.
- Founder runbook (keycape/founder-runbook.md), window docs, RPF-WP-0035-T09 (waits on A1/A2/A3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 132360@bnt-lap001
Assistant-Session: 769b1afd-4330-4c86-96b2-e510e26db42b
This commit is contained in:
codex 2026-10-06 14:26:42 +02:00
parent 9e23eaa067
commit 4a284f0a3b
11 changed files with 734 additions and 307 deletions

View file

@ -78,6 +78,9 @@ APPLIER_ALLOWED_ENVIRONMENTS = {
}
WORKLOAD_KV_POLICY_PREFIX = "workload-kv-read-"
OIDC_WORKLOAD_ROLE_SUFFIX = "-workload-kv-read"
TOKEN_TYPES = {"service", "batch", "default"}
TOKEN_BOUND_KEYS = ("token_max_ttl", "token_explicit_max_ttl", "token_no_default_policy",
"token_num_uses", "token_type")
KUBERNETES_ROLE_SUFFIXES = ("-workload-kv-read", "-secrets-read")
KUBERNETES_ROLE_PREFIXES = ("external-secrets-",)
EVIDENCE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
@ -248,6 +251,10 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings:
errors.append("openbao.metadata_read must be boolean")
if "token_self_lifecycle" in openbao and not isinstance(openbao["token_self_lifecycle"], bool):
errors.append("openbao.token_self_lifecycle must be boolean")
if "token_revoke_self" in openbao and not isinstance(openbao["token_revoke_self"], bool):
errors.append("openbao.token_revoke_self must be boolean")
if "kv_metadata" in openbao:
validate_kv_metadata(openbao["kv_metadata"], errors)
fields = [str(field) for field in require_list(openbao.get("fields"), "openbao.fields", errors)]
if not fields:
errors.append("openbao.fields must contain at least one field")
@ -276,14 +283,24 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings:
if method in {"oidc", "kubernetes"}:
require_string(auth.get("mount"), "openbao.auth.mount", errors)
require_string(auth.get("role"), "openbao.auth.role", errors)
if method == "kubernetes":
if "audience" in auth:
require_string(auth["audience"], "openbao.auth.audience", errors)
if method == "kubernetes" and "audience" in auth:
require_string(auth["audience"], "openbao.auth.audience", errors)
if method in {"oidc", "kubernetes"}:
for key in ("token_max_ttl", "token_explicit_max_ttl"):
if key in auth and (not isinstance(auth[key], str) or not TTL_RE.match(auth[key])):
errors.append(f"openbao.auth.{key} must be a TTL string")
if "token_no_default_policy" in auth and not isinstance(auth["token_no_default_policy"], bool):
errors.append("openbao.auth.token_no_default_policy must be boolean")
if "token_num_uses" in auth and (
isinstance(auth["token_num_uses"], bool)
or not isinstance(auth["token_num_uses"], int)
or auth["token_num_uses"] < 1
):
errors.append("openbao.auth.token_num_uses must be a positive integer")
if "token_type" in auth and auth["token_type"] not in TOKEN_TYPES:
errors.append(f"openbao.auth.token_type must be one of {sorted(TOKEN_TYPES)}")
if method == "oidc" and "bound_claims_type" in auth and auth["bound_claims_type"] != "string":
errors.append("openbao.auth.bound_claims_type must be 'string'; glob matching is not admitted")
if method == "oidc":
redirect_uris = require_list(
auth.get("allowed_redirect_uris"),
@ -432,6 +449,33 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings:
require_string(lifecycle.get(field), f"lifecycle.{field}", errors)
def validate_kv_metadata(value: Any, errors: list[str]) -> None:
"""Declared KV-v2 metadata, applied by the owning custody child, never by the applier."""
meta = require_object(value, "openbao.kv_metadata", errors)
if not meta:
return
unknown = set(meta) - {"cas_required", "max_versions", "delete_version_after", "custom_metadata"}
if unknown:
errors.append(f"openbao.kv_metadata has unknown keys: {sorted(unknown)}")
if "cas_required" in meta and not isinstance(meta["cas_required"], bool):
errors.append("openbao.kv_metadata.cas_required must be boolean")
max_versions = meta.get("max_versions")
if max_versions is not None and (
isinstance(max_versions, bool) or not isinstance(max_versions, int) or max_versions < 1
):
errors.append("openbao.kv_metadata.max_versions must be a positive integer")
after = meta.get("delete_version_after")
if after is not None and after != "0s" and (not isinstance(after, str) or not TTL_RE.match(after)):
errors.append("openbao.kv_metadata.delete_version_after must be '0s' or a TTL string")
custom = meta.get("custom_metadata")
if custom is not None:
custom = require_object(custom, "openbao.kv_metadata.custom_metadata", errors)
for key, item in custom.items():
if not isinstance(key, str) or not isinstance(item, str) or not key or not item:
errors.append("openbao.kv_metadata.custom_metadata must map non-empty strings to strings")
break
def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
@ -573,6 +617,8 @@ def generated_policy_hcl(ccr: dict[str, Any]) -> str:
if openbao.get("token_self_lifecycle", False):
body += ('\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n'
'\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n')
elif openbao.get("token_revoke_self", False):
body += '\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'
return body
@ -641,7 +687,7 @@ def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]:
"policies": ",".join(auth["policies"]),
"ttl": auth.get("ttl", "15m"),
}
for key in ("audience", "token_max_ttl", "token_explicit_max_ttl", "token_no_default_policy"):
for key in ("audience", *TOKEN_BOUND_KEYS):
if key in auth:
payload[key] = auth[key]
return payload
@ -660,6 +706,9 @@ def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]:
payload["allowed_redirect_uris"] = auth["allowed_redirect_uris"]
if auth.get("oidc_scopes"):
payload["oidc_scopes"] = auth["oidc_scopes"]
for key in ("bound_claims_type", *TOKEN_BOUND_KEYS):
if key in auth:
payload[key] = auth[key]
return payload