RPF-WP-0069-T07: approve sets approved only once every required role approves.
All checks were successful
CCR intake gate / gate (push) Successful in 4m15s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

credential-change.py approve set status approved after the first reviewer; for
role-gated request types validation then refused the second approve before it
was recorded. append_decision now records each approval and keeps the prior
status while the lane validator still reports a missing role approval.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 509845@bnt-lap001
Assistant-Session: ed7a7d95-d8eb-455c-b594-963fb1830f76
This commit is contained in:
codex 2026-11-03 22:41:22 +01:00
parent 371f787493
commit aeb21434bd
5 changed files with 108 additions and 5 deletions

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import argparse
import copy
import difflib
import json
import os
@ -480,9 +481,15 @@ def validate_kv_metadata(value: Any, errors: list[str]) -> None:
def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
scan_for_secrets(path, errors)
ccr = load_yaml(path)
data_errors, warnings = validate_ccr_data(ccr)
return ccr, errors + data_errors, warnings
def validate_ccr_data(ccr: dict[str, Any]) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
for field in (
"id",
"kind",
@ -526,7 +533,7 @@ def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]:
errors.extend(lane_module(ccr).validate(ccr, REPO_DIR))
else:
errors.append("unsupported request_type")
return ccr, errors, warnings
return errors, warnings
def render_summary(ccr: dict[str, Any], warnings: list[str]) -> str:
@ -1857,6 +1864,21 @@ def render_status(payload: dict[str, Any]) -> str:
return "\n".join(lines)
ROLE_APPROVAL_MISSING = "explicit role approval missing: "
def pending_role_approvals(ccr: dict[str, Any]) -> list[str]:
"""Roles the request type still requires before the CCR may be `approved`.
Only lane validators that enforce role approvals report these; for other
request types the list is always empty.
"""
candidate = copy.deepcopy(ccr)
candidate["status"] = "approved"
errors, _warnings = validate_ccr_data(candidate)
return [e[len(ROLE_APPROVAL_MISSING):] for e in errors if e.startswith(ROLE_APPROVAL_MISSING)]
def append_decision(path: Path, status: str, reviewer: str, comment: str) -> dict[str, Any]:
reject_secret_text(comment, "review comment")
ccr, _warnings = validate_or_exit(path)
@ -1872,6 +1894,10 @@ def append_decision(path: Path, status: str, reviewer: str, comment: str) -> dic
"comment": comment,
}
)
# An approval is recorded per reviewer, but the CCR only becomes `approved`
# once every role its request type requires has approved (RPF-WP-0069-T07).
if status == "approved" and pending_role_approvals(ccr):
status = ccr["status"]
ccr["status"] = status
ccr["updated"] = datetime.now(timezone.utc).date().isoformat()
dump_yaml(path, ccr)
@ -2261,7 +2287,12 @@ def command_record_evidence(args: argparse.Namespace) -> int:
def command_decision(args: argparse.Namespace, status: str) -> int:
path = resolve_ccr(args.ref)
ccr = append_decision(path, status, args.reviewer, args.comment)
print(f"[OK] {path.name} -> {status}")
print(f"[OK] {path.name} -> {ccr['status']}")
if status == "approved" and ccr["status"] != "approved":
print(
f"[INFO] {args.reviewer} approval recorded; still awaiting: "
+ ", ".join(pending_role_approvals(ccr))
)
if args.record_state_hub:
openbao = ccr["openbao"]
auth = openbao["auth"]