Revise KeyCape secrets-engine-openbao custody: OpenBao-only, operator reader CCR-2026-0034 (source only)
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:
parent
9e23eaa067
commit
4a284f0a3b
11 changed files with 734 additions and 307 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue