RPF-WP-0054-T04: create-only broker grant keycape/client-secret-generate
- New CCR request type custody-generate-grant (scripts/broker_grant_lane.py): grant policy (create on keycape/+ data, read on metadata, deny on reserved entries, revoke-self; no update, data read, metadata write, list or delete), broker issuer policy and a 5m orphan non-renewable token role, with readback. Binds no identity, so the apply is inert until T01. - CCR-2026-0040 (proposed), catalog grant (pilot, approval-required, exec-env only), generated policies, tests. - T03 drops custom_metadata: a metadata write would also allow changing other entries' version settings. - test_operator_matrix_lane follows CCR-2026-0039 now being applied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 469992@bnt-lap001 Assistant-Session: e4b8e420-df0a-441d-83ae-7f19130594d9
This commit is contained in:
parent
60435ab53e
commit
857be4695f
8 changed files with 602 additions and 4 deletions
268
scripts/broker_grant_lane.py
Normal file
268
scripts/broker_grant_lane.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""custody-generate-grant CCRs: a create-only KV grant the credential broker mints.
|
||||
|
||||
RPF-WP-0054-T04. Loaded by scripts/credential-change.py for request_type
|
||||
custody-generate-grant. The lane writes three reviewed objects and nothing else:
|
||||
|
||||
* the grant policy: ``create`` on ``<prefix>/+`` data, ``read`` on ``<prefix>/+``
|
||||
metadata, explicit ``deny`` on reserved entries, ``revoke-self``. It has no
|
||||
``update`` anywhere (an existing entry can never be overwritten), no data
|
||||
read, no metadata write (custom_metadata writes would also let the token
|
||||
change ``delete_version_after``/``max_versions`` of other entries), no list
|
||||
and no delete;
|
||||
* the broker issuer policy: create tokens only through this grant's token role;
|
||||
* the token role: exactly the grant policy, orphan, non-renewable, no default
|
||||
policy, at most 15 minutes.
|
||||
|
||||
Binding the issuer policy to the calling identity is a separate review once the
|
||||
caller is known (RPF-WP-0054-T01); until then nothing can mint the token, so
|
||||
the apply is inert. Nothing here reads, prints or accepts a secret value.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
REQUEST_TYPE = "custody-generate-grant"
|
||||
PREFIX_RE = re.compile(r"^platform/workloads/[a-z0-9-]+$")
|
||||
ENTRY_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
||||
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
||||
CATALOG = "credential-grants/catalog.yaml"
|
||||
MAX_TTL_SECONDS = 15 * 60
|
||||
# Same list scripts/openbao-apply-token-grants.py writes; default is excluded by
|
||||
# token_no_default_policy.
|
||||
DISALLOWED_POLICIES = ["root", "platform-admin"]
|
||||
|
||||
|
||||
def _service_jwt_lane():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"service_jwt_lane", Path(__file__).resolve().parent / "service_jwt_lane.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
_shared = _service_jwt_lane()
|
||||
ttl_seconds = _shared.ttl_seconds
|
||||
normalized = _shared.normalized
|
||||
BaoError = _shared.BaoError
|
||||
_run = _shared._run
|
||||
_read_json = _shared._read_json
|
||||
|
||||
|
||||
def _paths(prefix: str) -> tuple[str, str]:
|
||||
mount, rest = prefix.split("/", 1)
|
||||
return f"{mount}/data/{rest}", f"{mount}/metadata/{rest}"
|
||||
|
||||
|
||||
def generated_policy_hcl(ccr: dict[str, Any]) -> str:
|
||||
openbao = ccr["openbao"]
|
||||
data, meta = _paths(openbao["kv_prefix"])
|
||||
lines = [
|
||||
f"# {ccr['id']} (RPF-WP-0054): grant {openbao['grant_id']}. Create-only: no update,",
|
||||
"# data read, metadata write, list or delete. Exact deny beats the + glob.",
|
||||
f'path "{data}/+" {{', ' capabilities = ["create"]', "}",
|
||||
f'path "{meta}/+" {{', ' capabilities = ["read"]', "}",
|
||||
]
|
||||
for entry in openbao["reserved_entries"]:
|
||||
for base in (data, meta):
|
||||
lines += [f'path "{base}/{entry}" {{', ' capabilities = ["deny"]', "}"]
|
||||
lines += ['path "auth/token/revoke-self" {', ' capabilities = ["update"]', "}"]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def generated_issuer_hcl(ccr: dict[str, Any]) -> str:
|
||||
openbao = ccr["openbao"]
|
||||
lines = [
|
||||
f"# {ccr['id']} (RPF-WP-0054): the credential broker may mint {openbao['grant_id']}",
|
||||
"# tokens only through this token role. Bind to the reviewed caller identity only.",
|
||||
]
|
||||
for path, caps in (
|
||||
(f"auth/token/create/{openbao['token_role']}", '"create", "update"'),
|
||||
("auth/token/lookup-accessor", '"update"'),
|
||||
("auth/token/revoke-accessor", '"update"'),
|
||||
("auth/token/lookup-self", '"read"'),
|
||||
("sys/capabilities-self", '"update"'),
|
||||
):
|
||||
lines += [f'path "{path}" {{', f" capabilities = [{caps}]", "}"]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def role_payload(ccr: dict[str, Any]) -> dict[str, Any]:
|
||||
openbao = ccr["openbao"]
|
||||
return {
|
||||
"allowed_policies": [openbao["policy_name"]],
|
||||
"disallowed_policies": DISALLOWED_POLICIES,
|
||||
"orphan": True,
|
||||
"renewable": False,
|
||||
"token_explicit_max_ttl": openbao["token_ttl"],
|
||||
"token_no_default_policy": True,
|
||||
"token_type": "service",
|
||||
}
|
||||
|
||||
|
||||
def _catalog_grant(repo_dir: Path, grant_id: str) -> dict[str, Any] | None:
|
||||
catalog = yaml.safe_load((repo_dir / CATALOG).read_text(encoding="utf-8")) or {}
|
||||
for grant in catalog.get("grants") or []:
|
||||
if grant.get("id") == grant_id:
|
||||
return grant
|
||||
return None
|
||||
|
||||
|
||||
def _check_file(repo_dir: Path, rel: str, expected: str, field: str, errors: list[str]) -> None:
|
||||
path = repo_dir / rel
|
||||
if not path.exists():
|
||||
errors.append(f"{field} does not exist: {rel}")
|
||||
elif normalized(path.read_text(encoding="utf-8")) != normalized(expected):
|
||||
errors.append(f"{field} does not match the generated policy")
|
||||
|
||||
|
||||
def validate(ccr: dict[str, Any], repo_dir: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
openbao = ccr.get("openbao")
|
||||
if not isinstance(openbao, dict):
|
||||
return ["openbao must be an object"]
|
||||
if (ccr.get("target") or {}).get("environment") != "production":
|
||||
errors.append("target.environment must be production")
|
||||
review = ccr.get("review") or {}
|
||||
if review.get("required") is not True or not review.get("required_approvers"):
|
||||
errors.append("review.required must be true with required_approvers")
|
||||
prefix = str(openbao.get("kv_prefix") or "")
|
||||
if not PREFIX_RE.match(prefix):
|
||||
errors.append("openbao.kv_prefix must be platform/workloads/<owner>")
|
||||
reserved = openbao.get("reserved_entries")
|
||||
if not isinstance(reserved, list) or not all(ENTRY_RE.match(str(e)) for e in reserved):
|
||||
errors.append("openbao.reserved_entries must list exact entry names")
|
||||
for key in ("policy_name", "issuer_policy", "token_role"):
|
||||
if not NAME_RE.match(str(openbao.get(key) or "")):
|
||||
errors.append(f"openbao.{key} contains unsafe characters")
|
||||
seconds = ttl_seconds(openbao.get("token_ttl"))
|
||||
if seconds is None or not 0 < seconds <= MAX_TTL_SECONDS:
|
||||
errors.append("openbao.token_ttl must be set and at most 15m")
|
||||
if errors:
|
||||
return errors
|
||||
|
||||
for key, file_key, expected in (
|
||||
("policy_name", "policy_file", generated_policy_hcl(ccr)),
|
||||
("issuer_policy", "issuer_policy_file", generated_issuer_hcl(ccr)),
|
||||
):
|
||||
rel = str(openbao.get(file_key) or "")
|
||||
if rel != f"openbao/policies/{openbao[key]}.hcl":
|
||||
errors.append(f"openbao.{file_key} must be openbao/policies/<{key}>.hcl")
|
||||
else:
|
||||
_check_file(repo_dir, rel, expected, f"openbao.{file_key}", errors)
|
||||
|
||||
grant = _catalog_grant(repo_dir, str(openbao.get("grant_id") or ""))
|
||||
if grant is None:
|
||||
errors.append(f"openbao.grant_id not in {CATALOG}")
|
||||
else:
|
||||
gb = grant.get("openbao") or {}
|
||||
if gb.get("policies") != [openbao["policy_name"]]:
|
||||
errors.append("catalog grant policies must be exactly [openbao.policy_name]")
|
||||
if gb.get("token_role") != openbao["token_role"] or gb.get("issuer_policy") != openbao["issuer_policy"]:
|
||||
errors.append("catalog grant token_role/issuer_policy must match the CCR")
|
||||
if ttl_seconds((grant.get("ttl") or {}).get("max")) != seconds:
|
||||
errors.append("catalog grant ttl.max must equal openbao.token_ttl")
|
||||
return errors
|
||||
|
||||
|
||||
def readiness_blockers(ccr: dict[str, Any], repo_dir: Path, allowed_statuses: set[str]) -> list[str]:
|
||||
blockers: list[str] = []
|
||||
if ccr.get("status") not in allowed_statuses:
|
||||
blockers.append(
|
||||
"applier requires approved, applied, verified, or active CCR status, "
|
||||
f"got {ccr.get('status')}"
|
||||
)
|
||||
blockers.extend(validate(ccr, repo_dir))
|
||||
return blockers
|
||||
|
||||
|
||||
def dry_run_payload(ccr: dict[str, Any], repo_dir: Path, warnings: list[str]) -> dict[str, Any]:
|
||||
openbao = ccr["openbao"]
|
||||
return {
|
||||
"id": ccr["id"],
|
||||
"title": ccr["title"],
|
||||
"status": ccr["status"],
|
||||
"environment": ccr["target"]["environment"],
|
||||
"warnings": warnings,
|
||||
"source_artifacts": {},
|
||||
"mutations": [
|
||||
{"kind": "policy_write", "openbao_path": f"sys/policies/acl/{openbao['policy_name']}",
|
||||
"policy_name": openbao["policy_name"], "source": openbao["policy_file"],
|
||||
"body": generated_policy_hcl(ccr).rstrip()},
|
||||
{"kind": "policy_write", "openbao_path": f"sys/policies/acl/{openbao['issuer_policy']}",
|
||||
"policy_name": openbao["issuer_policy"], "source": openbao["issuer_policy_file"],
|
||||
"body": generated_issuer_hcl(ccr).rstrip()},
|
||||
{"kind": "token_role_write", "openbao_path": f"auth/token/roles/{openbao['token_role']}",
|
||||
"payload": role_payload(ccr)},
|
||||
{"kind": "readback_compare", "openbao_path": "both policies, token role",
|
||||
"payload": {"prints": "booleans only"}},
|
||||
],
|
||||
"out_of_scope": [
|
||||
"secret value reads or writes",
|
||||
"binding the issuer policy to any identity (separate review after RPF-WP-0054-T01)",
|
||||
"token creation",
|
||||
"changes to any other policy or role",
|
||||
],
|
||||
"required_evidence": [
|
||||
"CCR id and approval reference",
|
||||
"readback equality for both policies and the token role",
|
||||
"disposable probe (RPF-WP-0054-T05): create once, second create and data read denied",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def apply_details(ccr: dict[str, Any], actor: str) -> list[str]:
|
||||
openbao = ccr["openbao"]
|
||||
return [
|
||||
f"Delegated metadata applier ran as {actor} using local bao CLI ambient authority.",
|
||||
f"Policy metadata write: sys/policies/acl/{openbao['policy_name']}",
|
||||
f"Issuer policy metadata write: sys/policies/acl/{openbao['issuer_policy']}",
|
||||
f"Token role metadata write: auth/token/roles/{openbao['token_role']}",
|
||||
"Readback matched source for both policies and the token role.",
|
||||
"Issuer policy is not bound to any identity by this apply.",
|
||||
"No secret values were read, written, printed, or accepted in argv.",
|
||||
]
|
||||
|
||||
|
||||
def readback_mismatches(ccr: dict[str, Any], repo_dir: Path, bao_bin: str) -> list[str]:
|
||||
openbao = ccr["openbao"]
|
||||
mismatches: list[str] = []
|
||||
for name, expected in (
|
||||
(openbao["policy_name"], generated_policy_hcl(ccr)),
|
||||
(openbao["issuer_policy"], generated_issuer_hcl(ccr)),
|
||||
):
|
||||
text = _run(bao_bin, ["policy", "read", name], repo_dir, quiet=True, capture=True)
|
||||
if normalized(text) != normalized(expected):
|
||||
mismatches.append(f"policy {name} differs from source")
|
||||
role = _read_json(bao_bin, ["read", f"auth/token/roles/{openbao['token_role']}"], repo_dir).get("data", {})
|
||||
for key, want in role_payload(ccr).items():
|
||||
got = role.get(key)
|
||||
if key == "token_explicit_max_ttl":
|
||||
same = ttl_seconds(got) == ttl_seconds(want)
|
||||
elif key == "disallowed_policies":
|
||||
same = sorted(got or []) == sorted(want)
|
||||
else:
|
||||
same = got == want
|
||||
if not same:
|
||||
mismatches.append(f"token role {key} differs")
|
||||
return mismatches
|
||||
|
||||
|
||||
def run_apply(ccr: dict[str, Any], repo_dir: Path, bao_bin: str, quiet: bool = False) -> None:
|
||||
openbao = ccr["openbao"]
|
||||
_run(bao_bin, ["policy", "write", openbao["policy_name"], openbao["policy_file"]], repo_dir, quiet)
|
||||
_run(bao_bin, ["policy", "write", openbao["issuer_policy"], openbao["issuer_policy_file"]], repo_dir, quiet)
|
||||
role = role_payload(ccr)
|
||||
args = ["write", f"auth/token/roles/{openbao['token_role']}"]
|
||||
for key, value in role.items():
|
||||
rendered = ",".join(value) if isinstance(value, list) else str(value).lower() if isinstance(value, bool) else value
|
||||
args.append(f"{key}={rendered}")
|
||||
_run(bao_bin, args, repo_dir, quiet)
|
||||
mismatches = readback_mismatches(ccr, repo_dir, bao_bin)
|
||||
if mismatches:
|
||||
raise BaoError("readback mismatch: " + "; ".join(mismatches))
|
||||
|
|
@ -823,6 +823,7 @@ def is_service_jwt(ccr: dict[str, Any]) -> bool:
|
|||
LANE_MODULES = {
|
||||
"service-jwt-login": "service_jwt_lane",
|
||||
"workload-kv-operator-matrix": "operator_matrix_lane",
|
||||
"custody-generate-grant": "broker_grant_lane",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue