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
|
|
@ -0,0 +1,75 @@
|
||||||
|
id: CCR-2026-0040
|
||||||
|
kind: credential-change-request
|
||||||
|
schema_version: 1
|
||||||
|
request_type: custody-generate-grant
|
||||||
|
title: Broker grant keycape/client-secret-generate (create-only, generate in custody)
|
||||||
|
status: proposed
|
||||||
|
created: '2026-10-09'
|
||||||
|
updated: '2026-10-09'
|
||||||
|
requester:
|
||||||
|
agent: railiance-platform
|
||||||
|
reason: RPF-WP-0054-T04. net-kingdom SecurityCaseType oidc-browser-relying-party step
|
||||||
|
mint-secret (lane keycape-client-secret-generate); ops-warden d9f889b1, ops-mason
|
||||||
|
789feb39. Founder chose the templated create-only grant 2026-10-09 (T02 option A).
|
||||||
|
review:
|
||||||
|
required: true
|
||||||
|
required_approvers:
|
||||||
|
- platform-owner
|
||||||
|
- key-cape-owner
|
||||||
|
comments: []
|
||||||
|
target:
|
||||||
|
domain: infotech
|
||||||
|
tenant: platform
|
||||||
|
workload: keycape
|
||||||
|
environment: production
|
||||||
|
purpose: Let the credential broker create a new KeyCape confidential-client secret at
|
||||||
|
platform/workloads/keycape/<client_id> once, without any value leaving custody.
|
||||||
|
openbao:
|
||||||
|
grant_id: keycape/client-secret-generate
|
||||||
|
kv_prefix: platform/workloads/keycape
|
||||||
|
reserved_entries:
|
||||||
|
- secrets-engine-openbao
|
||||||
|
policy_name: keycape-client-secret-generate
|
||||||
|
policy_file: openbao/policies/keycape-client-secret-generate.hcl
|
||||||
|
issuer_policy: credential-broker-keycape-client-secret-generate-issuer
|
||||||
|
issuer_policy_file: openbao/policies/credential-broker-keycape-client-secret-generate-issuer.hcl
|
||||||
|
token_role: keycape-client-secret-generate
|
||||||
|
token_ttl: 5m
|
||||||
|
founder_confirmation_items:
|
||||||
|
- This apply writes two policies and one token role. It binds the issuer policy to no
|
||||||
|
identity, so nothing can mint the token until a separate review binds it to the
|
||||||
|
caller named in RPF-WP-0054-T01.
|
||||||
|
- Create-only on keycape/+ means a token can create a new client entry once and can
|
||||||
|
never change an existing one. Its widest misuse is an unused entry for a client
|
||||||
|
that does not exist.
|
||||||
|
access_frontdoor:
|
||||||
|
type: none
|
||||||
|
catalog_id: none
|
||||||
|
risk:
|
||||||
|
level: medium
|
||||||
|
notes:
|
||||||
|
- No data read, no update, no metadata write, no list, no delete. Exact denies on
|
||||||
|
reserved entries take precedence over the + glob.
|
||||||
|
- The token lives at most 5m and only inside the broker process.
|
||||||
|
verification:
|
||||||
|
positive:
|
||||||
|
- Readback after apply matches source for both policies and the token role (done by
|
||||||
|
applier-apply; prints nothing).
|
||||||
|
- Disposable probe (T05) creates probe-secret-generate-<date> at version 1.
|
||||||
|
negative:
|
||||||
|
- A second create on the probe path, a data read, update, delete and any path outside
|
||||||
|
keycape/ are denied for the grant token (sys/capabilities-self).
|
||||||
|
- The reserved entry keycape/secrets-engine-openbao reports deny on data and metadata.
|
||||||
|
activation_conditions:
|
||||||
|
- Founder approval (two reviews).
|
||||||
|
- Issuer binding reviewed after RPF-WP-0054-T01; probe passed (T05).
|
||||||
|
evidence: []
|
||||||
|
lifecycle:
|
||||||
|
deactivate: Delete auth/token/roles/keycape-client-secret-generate, then both policies;
|
||||||
|
generated entries stay in custody with their cases.
|
||||||
|
rotate: Not applicable; rotation is a separate lane (RPF-WP-0054-T07).
|
||||||
|
compromised: Delete the token role and revoke outstanding tokens by accessor; review
|
||||||
|
metadata for entries without an accepted case.
|
||||||
|
state_hub:
|
||||||
|
workplan: RPF-WP-0054
|
||||||
|
task: RPF-WP-0054-T04
|
||||||
|
|
@ -104,6 +104,82 @@ grants:
|
||||||
audit: {openbao_audit_required: true, state_hub_metadata_allowed: true, record_secret_values: false}
|
audit: {openbao_audit_required: true, state_hub_metadata_allowed: true, record_secret_values: false}
|
||||||
revocation: {required: true, by_accessor: true, on_exec_exit: true, on_denied_request: false}
|
revocation: {required: true, by_accessor: true, on_exec_exit: true, on_denied_request: false}
|
||||||
|
|
||||||
|
- id: keycape/client-secret-generate
|
||||||
|
title: KeyCape confidential-client secret generation in custody
|
||||||
|
status: pilot
|
||||||
|
grant_class: approval-required
|
||||||
|
credential_type: openbao-token
|
||||||
|
issuer: openbao
|
||||||
|
audience: railiance-platform-credential-broker
|
||||||
|
description: >
|
||||||
|
RPF-WP-0054 / CCR-2026-0040. Short-lived token the broker uses inside its
|
||||||
|
own process to create platform/workloads/keycape/<client_id> once (CAS 0)
|
||||||
|
for an accepted oidc-browser-relying-party case. Create-only: it cannot
|
||||||
|
update, read values, write metadata, list or delete. The generated value
|
||||||
|
never leaves the broker process and the bao child's stdin; the caller gets a
|
||||||
|
receipt only. Founder decision 2026-10-09 (RPF-WP-0054-T02 option A).
|
||||||
|
openbao:
|
||||||
|
namespace: openbao
|
||||||
|
token_role: keycape-client-secret-generate
|
||||||
|
issuer_policy: credential-broker-keycape-client-secret-generate-issuer
|
||||||
|
policies:
|
||||||
|
- keycape-client-secret-generate
|
||||||
|
disallowed_policies:
|
||||||
|
- root
|
||||||
|
- platform-admin
|
||||||
|
mount_paths:
|
||||||
|
- platform/data/workloads/keycape/+
|
||||||
|
- platform/metadata/workloads/keycape/+
|
||||||
|
ttl:
|
||||||
|
default: 5m
|
||||||
|
max: 5m
|
||||||
|
renewable: false
|
||||||
|
requires_human_above: 5m
|
||||||
|
actors:
|
||||||
|
allowed_types:
|
||||||
|
- approved-agent
|
||||||
|
- human-operator
|
||||||
|
required_subject_binding: keycape-or-kubernetes-service-account
|
||||||
|
authorization:
|
||||||
|
flex_auth_required: false
|
||||||
|
flex_auth_mode: optional-preflight
|
||||||
|
approval_required: true
|
||||||
|
purpose_required: true
|
||||||
|
allowed_purpose_examples:
|
||||||
|
- oidc-browser-relying-party-mint-secret
|
||||||
|
delivery:
|
||||||
|
allowed:
|
||||||
|
- exec-env
|
||||||
|
preferred: exec-env
|
||||||
|
denied:
|
||||||
|
- chat
|
||||||
|
- state-hub-body
|
||||||
|
- git
|
||||||
|
- command-line-token-argument
|
||||||
|
- llm-prompt
|
||||||
|
exec_env:
|
||||||
|
variable: VAULT_TOKEN
|
||||||
|
child_only: true
|
||||||
|
redact_logs: true
|
||||||
|
audit:
|
||||||
|
openbao_audit_required: true
|
||||||
|
state_hub_metadata_allowed: true
|
||||||
|
record_secret_values: false
|
||||||
|
metadata_fields:
|
||||||
|
- grant_id
|
||||||
|
- actor
|
||||||
|
- subject
|
||||||
|
- purpose
|
||||||
|
- case_ref
|
||||||
|
- client_id
|
||||||
|
- kv_version
|
||||||
|
- lease_accessor
|
||||||
|
- status
|
||||||
|
revocation:
|
||||||
|
required: true
|
||||||
|
by_accessor: true
|
||||||
|
on_exec_exit: true
|
||||||
|
on_denied_request: false
|
||||||
- id: ops-warden/warden-sign
|
- id: ops-warden/warden-sign
|
||||||
title: Ops Warden OpenBao SSH signing smoke token
|
title: Ops Warden OpenBao SSH signing smoke token
|
||||||
status: pilot
|
status: pilot
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# CCR-2026-0040 (RPF-WP-0054): the credential broker may mint keycape/client-secret-generate
|
||||||
|
# tokens only through this token role. Bind to the reviewed caller identity only.
|
||||||
|
path "auth/token/create/keycape-client-secret-generate" {
|
||||||
|
capabilities = ["create", "update"]
|
||||||
|
}
|
||||||
|
path "auth/token/lookup-accessor" {
|
||||||
|
capabilities = ["update"]
|
||||||
|
}
|
||||||
|
path "auth/token/revoke-accessor" {
|
||||||
|
capabilities = ["update"]
|
||||||
|
}
|
||||||
|
path "auth/token/lookup-self" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "sys/capabilities-self" {
|
||||||
|
capabilities = ["update"]
|
||||||
|
}
|
||||||
17
openbao/policies/keycape-client-secret-generate.hcl
Normal file
17
openbao/policies/keycape-client-secret-generate.hcl
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# CCR-2026-0040 (RPF-WP-0054): grant keycape/client-secret-generate. Create-only: no update,
|
||||||
|
# data read, metadata write, list or delete. Exact deny beats the + glob.
|
||||||
|
path "platform/data/workloads/keycape/+" {
|
||||||
|
capabilities = ["create"]
|
||||||
|
}
|
||||||
|
path "platform/metadata/workloads/keycape/+" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "platform/data/workloads/keycape/secrets-engine-openbao" {
|
||||||
|
capabilities = ["deny"]
|
||||||
|
}
|
||||||
|
path "platform/metadata/workloads/keycape/secrets-engine-openbao" {
|
||||||
|
capabilities = ["deny"]
|
||||||
|
}
|
||||||
|
path "auth/token/revoke-self" {
|
||||||
|
capabilities = ["update"]
|
||||||
|
}
|
||||||
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 = {
|
LANE_MODULES = {
|
||||||
"service-jwt-login": "service_jwt_lane",
|
"service-jwt-login": "service_jwt_lane",
|
||||||
"workload-kv-operator-matrix": "operator_matrix_lane",
|
"workload-kv-operator-matrix": "operator_matrix_lane",
|
||||||
|
"custody-generate-grant": "broker_grant_lane",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
137
tests/test_broker_grant_lane.py
Normal file
137
tests/test_broker_grant_lane.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""RPF-WP-0054-T04: custody-generate-grant lane (CCR-2026-0040)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CCR_PATH = ROOT / "credential-change-requests/CCR-2026-0040-keycape-client-secret-generate-grant.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def load(name: str, rel: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
lane = load("broker_grant_lane", "scripts/broker_grant_lane.py")
|
||||||
|
cc = load("credential_change_cli_grant", "scripts/credential-change.py")
|
||||||
|
|
||||||
|
|
||||||
|
def ccr() -> dict:
|
||||||
|
return yaml.safe_load(CCR_PATH.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
# Stateful fake: policies stored as text; token role key=value args stored the way
|
||||||
|
# OpenBao returns them (lists, booleans, seconds).
|
||||||
|
FAKE_BAO = r'''#!/usr/bin/env python3
|
||||||
|
import json, os, sys
|
||||||
|
path = os.environ["FAKE_BAO_STATE"]
|
||||||
|
state = json.load(open(path))
|
||||||
|
args = [a for a in sys.argv[1:] if a != "-format=json"]
|
||||||
|
if args[:2] == ["policy", "write"]:
|
||||||
|
state["policies"][args[2]] = open(args[3]).read()
|
||||||
|
elif args[:2] == ["policy", "read"]:
|
||||||
|
print(state["policies"][args[2]])
|
||||||
|
elif args[0] == "write":
|
||||||
|
role = {}
|
||||||
|
for kv in args[2:]:
|
||||||
|
k, v = kv.split("=", 1)
|
||||||
|
if k.endswith("_policies"): role[k] = v.split(",")
|
||||||
|
elif v in ("true", "false"): role[k] = v == "true"
|
||||||
|
elif k.endswith("_ttl"): role[k] = int(v[:-1]) * 60
|
||||||
|
else: role[k] = v
|
||||||
|
if state.get("tamper"): role["renewable"] = True
|
||||||
|
state["roles"][args[1]] = role
|
||||||
|
elif args[0] == "read":
|
||||||
|
print(json.dumps({"data": state["roles"][args[1]]}))
|
||||||
|
json.dump(state, open(path, "w"))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def stanzas(text: str) -> dict[str, str]:
|
||||||
|
return dict(re.findall(r'path "([^"]+)"\s*{\s*capabilities = \[([^\]]*)\]', text))
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerGrantLaneTests(unittest.TestCase):
|
||||||
|
def test_ccr_validates_and_waits_for_approval(self):
|
||||||
|
_ccr, errors, _w = cc.validate_ccr(CCR_PATH)
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
blockers = cc.applier_readiness_blockers(_ccr)
|
||||||
|
self.assertTrue(any("got proposed" in b for b in blockers), blockers)
|
||||||
|
|
||||||
|
def test_grant_policy_is_create_only(self):
|
||||||
|
policy = stanzas((ROOT / ccr()["openbao"]["policy_file"]).read_text())
|
||||||
|
self.assertEqual(policy["platform/data/workloads/keycape/+"], '"create"')
|
||||||
|
self.assertEqual(policy["platform/metadata/workloads/keycape/+"], '"read"')
|
||||||
|
for path in ("platform/data/workloads/keycape/secrets-engine-openbao",
|
||||||
|
"platform/metadata/workloads/keycape/secrets-engine-openbao"):
|
||||||
|
self.assertEqual(policy[path], '"deny"')
|
||||||
|
granted = " ".join(c for p, c in policy.items() if p != "auth/token/revoke-self")
|
||||||
|
for word in ("update", "delete", "list", "patch", "sudo"):
|
||||||
|
self.assertNotIn(f'"{word}"', granted)
|
||||||
|
self.assertFalse(any("*" in p for p in policy))
|
||||||
|
|
||||||
|
def test_issuer_policy_mints_only_through_this_role(self):
|
||||||
|
issuer = stanzas((ROOT / ccr()["openbao"]["issuer_policy_file"]).read_text())
|
||||||
|
creates = [p for p in issuer if p.startswith("auth/token/create")]
|
||||||
|
self.assertEqual(creates, ["auth/token/create/keycape-client-secret-generate"])
|
||||||
|
|
||||||
|
def test_validator_rejects_drift(self):
|
||||||
|
cases = [
|
||||||
|
("long ttl", lambda c: c["openbao"].update(token_ttl="1h")),
|
||||||
|
("prefix wildcard", lambda c: c["openbao"].update(kv_prefix="platform/workloads/*")),
|
||||||
|
("unknown grant", lambda c: c["openbao"].update(grant_id="keycape/other")),
|
||||||
|
("reserved glob", lambda c: c["openbao"].update(reserved_entries=["*"])),
|
||||||
|
("policy drift", lambda c: c["openbao"].update(reserved_entries=[])),
|
||||||
|
]
|
||||||
|
for label, mutate in cases:
|
||||||
|
with self.subTest(label):
|
||||||
|
c = ccr()
|
||||||
|
mutate(c)
|
||||||
|
self.assertNotEqual(lane.validate(c, ROOT), [], label)
|
||||||
|
|
||||||
|
def _apply(self, tamper=False):
|
||||||
|
tmp = Path(tempfile.mkdtemp())
|
||||||
|
(tmp / "state.json").write_text(json.dumps({"policies": {}, "roles": {}, "tamper": tamper}))
|
||||||
|
bao = tmp / "bao"
|
||||||
|
bao.write_text(FAKE_BAO)
|
||||||
|
bao.chmod(bao.stat().st_mode | stat.S_IEXEC)
|
||||||
|
os.environ["FAKE_BAO_STATE"] = str(tmp / "state.json")
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
lane.run_apply(ccr(), ROOT, str(bao), quiet=True)
|
||||||
|
except lane.BaoError as exc:
|
||||||
|
error = exc
|
||||||
|
return error, json.loads((tmp / "state.json").read_text())
|
||||||
|
|
||||||
|
def test_apply_writes_three_objects_and_reads_back(self):
|
||||||
|
error, state = self._apply()
|
||||||
|
self.assertIsNone(error)
|
||||||
|
self.assertEqual(sorted(state["policies"]), [
|
||||||
|
"credential-broker-keycape-client-secret-generate-issuer",
|
||||||
|
"keycape-client-secret-generate",
|
||||||
|
])
|
||||||
|
role = state["roles"]["auth/token/roles/keycape-client-secret-generate"]
|
||||||
|
self.assertEqual(role["allowed_policies"], ["keycape-client-secret-generate"])
|
||||||
|
self.assertEqual(role["token_explicit_max_ttl"], 300)
|
||||||
|
|
||||||
|
def test_readback_detects_role_drift(self):
|
||||||
|
error, _state = self._apply(tamper=True)
|
||||||
|
self.assertIsNotNone(error)
|
||||||
|
self.assertIn("renewable", str(error))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -60,12 +60,19 @@ LIVE_BOUNDARY = 'path "platform/data/workloads/forgejo/forgejo-admin" {\n capab
|
||||||
|
|
||||||
|
|
||||||
class OperatorMatrixLaneTests(unittest.TestCase):
|
class OperatorMatrixLaneTests(unittest.TestCase):
|
||||||
def test_ccr_validates_and_waits_for_approval_and_binding(self):
|
def test_ccr_validates_and_records_the_delegated_apply(self):
|
||||||
_ccr, errors, _w = cc.validate_ccr(CCR_PATH)
|
_ccr, errors, _w = cc.validate_ccr(CCR_PATH)
|
||||||
self.assertEqual(errors, [])
|
self.assertEqual(errors, [])
|
||||||
blockers = cc.applier_readiness_blockers(_ccr)
|
self.assertEqual(_ccr["status"], "applied")
|
||||||
self.assertIn("applier requires confirmed OpenBao auth binding", blockers)
|
self.assertEqual(cc.applier_readiness_blockers(_ccr), [])
|
||||||
self.assertTrue(any("got proposed" in b for b in blockers))
|
self.assertTrue(any(e["kind"] == "delegated_metadata_apply" and e["result"] == "passed"
|
||||||
|
for e in _ccr["verification"]["evidence"]))
|
||||||
|
|
||||||
|
def test_unconfirmed_binding_blocks_the_applier(self):
|
||||||
|
c = ccr()
|
||||||
|
c["openbao"]["auth"]["bound_claims_confirmed"] = False
|
||||||
|
self.assertIn("applier requires confirmed OpenBao auth binding",
|
||||||
|
cc.applier_readiness_blockers(c))
|
||||||
|
|
||||||
def test_policy_is_the_exact_matrix_and_nothing_else(self):
|
def test_policy_is_the_exact_matrix_and_nothing_else(self):
|
||||||
policy = (ROOT / ccr()["openbao"]["policy_file"]).read_text()
|
policy = (ROOT / ccr()["openbao"]["policy_file"]).read_text()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue