From 815b124ab11e2d6580d4eab587b089f366180755 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 27 Jun 2026 22:57:21 +0200 Subject: [PATCH] Implement credential change request review flow --- Makefile | 16 + ...CCR-2026-0001-whynot-design-npm-token.yaml | 72 +++ docs/credential-change-approval.md | 19 +- schemas/credential-change-request.schema.yaml | 93 ++++ scripts/credential-change.py | 466 ++++++++++++++++++ tests/test_credential_change.py | 80 +++ ...007-credential-change-approval-workflow.md | 40 +- 7 files changed, 772 insertions(+), 14 deletions(-) create mode 100644 credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml create mode 100644 schemas/credential-change-request.schema.yaml create mode 100755 scripts/credential-change.py create mode 100644 tests/test_credential_change.py diff --git a/Makefile b/Makefile index 96747fa..209518c 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ ARGOCD_NAMESPACE ?= argocd ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap ARGOCD_REPOSITORY_SECRET ?= CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml +CREDENTIAL_CHANGE ?= CCR-2026-0001 OPENBAO_TOKEN_GRANT_ARGS ?= OPENBAO_WORKLOAD_KV_ARGS ?= CREDENTIAL_HELPER_GLOBAL_ARGS ?= @@ -190,6 +191,18 @@ openbao-validate-emergency-evidence: ## Validate non-secret OpenBao emergency se credential-grants-validate: ## Validate non-secret credential grant catalog scripts/credential-grants-validate.py $(CREDENTIAL_GRANTS) +credential-change-validate: ## Validate non-secret credential change requests + scripts/credential-change.py validate + +credential-change-render: ## Render a credential change request review summary + scripts/credential-change.py render $(CREDENTIAL_CHANGE) + +credential-change-plan: ## Render a credential change request apply plan for review + scripts/credential-change.py plan $(CREDENTIAL_CHANGE) + +credential-change-apply-plan: ## Render approved-only operator apply plan + scripts/credential-change.py apply-plan $(CREDENTIAL_CHANGE) + openbao-token-grants-dry-run: ## Dry-run OpenBao token roles and issuer policies for credential grants scripts/openbao-apply-token-grants.py --dry-run $(OPENBAO_TOKEN_GRANT_ARGS) @@ -227,6 +240,9 @@ credential-helper-dry-run: ## Dry-run credential request, exec, status, and revo credential-tests: ## Run offline credential broker unit tests python3 -m unittest discover -s tests -p 'test_credential*.py' +credential-change-tests: ## Run credential change request unit tests + python3 -m unittest discover -s tests -p 'test_credential_change.py' + credential-exec-ops-warden-smoke: ## Run ops-warden smoke with an exec-injected warden-sign token KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \ OPENBAO_RELEASE=$(OPENBAO_RELEASE) \ diff --git a/credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml b/credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml new file mode 100644 index 0000000..499e6cf --- /dev/null +++ b/credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml @@ -0,0 +1,72 @@ +id: CCR-2026-0001 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: "whynot-design npm publish token lane" +status: proposed +created: "2026-06-27" +updated: "2026-06-27" +requester: + agent: ops-warden + message_id: "551031d1-335e-4db8-9535-820fea52d0a3" + reason: "Allow ops-warden to proxy caller-scoped access to whynot-design's npm publish token." +review: + required: true + required_approvers: + - platform-operator + comments: [] +target: + domain: financials + tenant: whynot-design + workload: whynot-design + environment: production + purpose: "npm package publishing through ops-warden caller-scoped fetch/exec" +openbao: + mount: platform + kv_path: platform/workloads/whynot-design/whynot-design/npm-publish + fields: + - NPM_AUTH_TOKEN + policy_name: workload-kv-read-whynot-design-npm-publish + policy_file: openbao/policies/workload-kv-read-whynot-design-npm-publish.hcl + auth: + method: oidc + mount: netkingdom + role: whynot-design-workload-kv-read + user_claim: sub + groups_claim: groups + bound_claims: + groups: + - whynot-design + bound_claims_confirmed: false + policies: + - workload-kv-read-whynot-design-npm-publish + ttl: 15m +access_frontdoor: + type: ops-warden + catalog_id: whynot-design-npm-token + selector: "npm auth token" + activation: "draft-until-ccr-verified" +risk: + classification: high + notes: + - "Grants read access to the credential used to publish npm packages." + - "The proposed OIDC bound claim must be confirmed before apply." + - "ops-warden must proxy the read as the caller and must not retain the token value." +verification: + positive: + - "Approved whynot-design identity can fetch field NPM_AUTH_TOKEN through OpenBao or ops-warden." + negative: + - "Non-whynot identity cannot read the path or field." + activation_conditions: + - "Policy applied with platform-admin/operator authority." + - "OIDC role bound to confirmed whynot-design claim or approved service account." + - "Secret value provisioned directly in OpenBao through approved operator custody." + - "Positive and negative verification recorded with non-secret audit ids or timestamps." +lifecycle: + deactivate: "Disable ops-warden catalog entry and remove or detach auth role policy." + rotate: "Replace NPM_AUTH_TOKEN value directly in OpenBao and record non-secret rotation evidence." + compromised: "Immediately deactivate access front door, rotate npm token, record blast-radius notes, and open incident follow-up tasks." +state_hub: + workplan_id: RAILIANCE-WP-0007 + related_workplan_id: RAILIANCE-WP-0006 + ops_warden_reply_message_id: "b175c561-7858-43f5-a309-949b0dede1b4" diff --git a/docs/credential-change-approval.md b/docs/credential-change-approval.md index 6230e68..61799fe 100644 --- a/docs/credential-change-approval.md +++ b/docs/credential-change-approval.md @@ -137,18 +137,21 @@ Version 1 should be boring: - prompt or delegate separately for secret value entry; - record non-secret evidence in State Hub. -The CLI shape can be: +The first implemented CLI slice is: ```bash -scripts/credential-change.py propose workload-kv ... -scripts/credential-change.py render CCR-YYYY-NNNN -scripts/credential-change.py approve CCR-YYYY-NNNN --comment "..." -scripts/credential-change.py deny CCR-YYYY-NNNN --comment "..." -scripts/credential-change.py apply CCR-YYYY-NNNN -scripts/credential-change.py verify CCR-YYYY-NNNN -scripts/credential-change.py deactivate CCR-YYYY-NNNN --reason "..." +make credential-change-validate +make credential-change-render CREDENTIAL_CHANGE=CCR-2026-0001 +make credential-change-plan CREDENTIAL_CHANGE=CCR-2026-0001 +scripts/credential-change.py approve CCR-2026-0001 --reviewer --comment "..." +scripts/credential-change.py deny CCR-2026-0001 --reviewer --comment "..." +scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer --comment "..." +make credential-change-apply-plan CREDENTIAL_CHANGE=CCR-2026-0001 ``` +`apply-plan` is intentionally guarded: it refuses anything not approved and +refuses unconfirmed auth bindings. + The same operations can be exposed through chat by having the agent create the proposal, show the rendered summary, then call the CLI only after the human gives an explicit approval phrase. diff --git a/schemas/credential-change-request.schema.yaml b/schemas/credential-change-request.schema.yaml new file mode 100644 index 0000000..f7dbada --- /dev/null +++ b/schemas/credential-change-request.schema.yaml @@ -0,0 +1,93 @@ +schema_version: 1 +kind: credential-change-request-schema +description: Non-secret schema contract for credential/security change requests. + +required_top_level: + - id + - kind + - schema_version + - request_type + - title + - status + - created + - updated + - requester + - target + - openbao + - access_frontdoor + - risk + - verification + - lifecycle + +allowed_statuses: + - draft + - proposed + - needs_changes + - approved + - denied + - apply_pending + - applied + - verified + - active + - deactivated + - rotated + - compromised + - superseded + - cancelled + +allowed_request_types: + - workload-kv-read + +secret_markers_rejected: + - AGE-SECRET-KEY-1 + - "-----BEGIN PRIVATE KEY-----" + - "-----BEGIN OPENSSH PRIVATE KEY-----" + - OPENBAO_ROOT_TOKEN= + - VAULT_TOKEN= + - BAO_TOKEN= + - hvb. + - hvc. + - hvs. + - npm_ + - ghp_ + - sk- + +workload_kv_read: + required: + openbao: + - mount + - kv_path + - fields + - policy_name + - policy_file + - auth + openbao.auth: + - method + - mount + - role + - bound_claims + - bound_claims_confirmed + - policies + access_frontdoor: + - type + - catalog_id + verification: + - positive + - negative + - activation_conditions + lifecycle: + - deactivate + - rotate + - compromised + +guardrails: + apply_plan_requires_status: + - approved + active_requires_status: + - verified + disallowed_policy_names: + - root + - platform-admin + disallowed_path_fragments: + - "*" + - ".." diff --git a/scripts/credential-change.py b/scripts/credential-change.py new file mode 100755 index 0000000..493da5c --- /dev/null +++ b/scripts/credential-change.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +REPO_DIR = Path(__file__).resolve().parents[1] +DEFAULT_CCR_DIR = REPO_DIR / "credential-change-requests" +ALLOWED_STATUSES = { + "draft", + "proposed", + "needs_changes", + "approved", + "denied", + "apply_pending", + "applied", + "verified", + "active", + "deactivated", + "rotated", + "compromised", + "superseded", + "cancelled", +} +APPLY_ALLOWED_STATUSES = {"approved"} +SECRET_MARKERS = [ + "AGE-SECRET-KEY-1", + "-----BEGIN PRIVATE KEY-----", + "-----BEGIN OPENSSH PRIVATE KEY-----", + "OPENBAO_ROOT_TOKEN=", + "VAULT_TOKEN=", + "BAO_TOKEN=", + "hvb.", + "hvc.", + "hvs.", + "npm_", + "ghp_", + "sk-", +] +DISALLOWED_POLICY_NAMES = {"root", "platform-admin"} +SAFE_ID_RE = re.compile(r"^[A-Z0-9][A-Z0-9_.-]*$") +TTL_RE = re.compile(r"^[1-9][0-9]*[smhd]$") + + +def fail(message: str) -> None: + raise SystemExit(f"ERROR: {message}") + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def resolve_repo_path(path: str | Path) -> Path: + p = Path(path).expanduser() + if p.is_absolute(): + return p + return (REPO_DIR / p).resolve() + + +def load_yaml(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + fail(f"YAML root must be an object: {path}") + return data + + +def dump_yaml(path: Path, data: dict[str, Any]) -> None: + path.write_text( + yaml.safe_dump(data, sort_keys=False, allow_unicode=False), + encoding="utf-8", + ) + + +def ccr_dir() -> Path: + return resolve_repo_path(os.environ.get("CCR_DIR", str(DEFAULT_CCR_DIR))) + + +def resolve_ccr(ref: str) -> Path: + candidate = resolve_repo_path(ref) + if candidate.exists(): + return candidate + matches = sorted(ccr_dir().glob(f"{ref}*.y*ml")) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + fail(f"CCR reference is ambiguous: {ref} -> {[m.name for m in matches]}") + fail(f"CCR not found by path or id prefix: {ref}") + + +def require_object(value: Any, field: str, errors: list[str]) -> dict[str, Any]: + if not isinstance(value, dict): + errors.append(f"{field} must be an object") + return {} + return value + + +def require_list(value: Any, field: str, errors: list[str]) -> list[Any]: + if not isinstance(value, list): + errors.append(f"{field} must be a list") + return [] + return value + + +def require_string(value: Any, field: str, errors: list[str]) -> str: + if not isinstance(value, str) or not value.strip(): + errors.append(f"{field} must be a non-empty string") + return "" + return value.strip() + + +def scan_for_secrets(path: Path, errors: list[str]) -> None: + text = path.read_text(encoding="utf-8") + for marker in SECRET_MARKERS: + if marker in text: + errors.append(f"{path.name} contains rejected secret marker {marker!r}") + + +def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: list[str]) -> None: + target = require_object(ccr.get("target"), "target", errors) + for field in ("domain", "tenant", "workload", "environment", "purpose"): + require_string(target.get(field), f"target.{field}", errors) + + openbao = require_object(ccr.get("openbao"), "openbao", errors) + mount = require_string(openbao.get("mount"), "openbao.mount", errors) + kv_path = require_string(openbao.get("kv_path"), "openbao.kv_path", errors) + policy_name = require_string( + openbao.get("policy_name"), "openbao.policy_name", errors + ) + policy_file = require_string( + openbao.get("policy_file"), "openbao.policy_file", 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") + if mount and kv_path and not kv_path.startswith(f"{mount}/"): + errors.append("openbao.kv_path must start with the declared mount") + if any(fragment in kv_path for fragment in ("*", "..")): + errors.append("openbao.kv_path must not contain '*' or '..'") + if policy_name in DISALLOWED_POLICY_NAMES: + errors.append(f"openbao.policy_name is disallowed: {policy_name}") + if policy_file: + resolved_policy = resolve_repo_path(policy_file) + if not resolved_policy.exists(): + errors.append(f"openbao.policy_file does not exist: {policy_file}") + + auth = require_object(openbao.get("auth"), "openbao.auth", errors) + method = require_string(auth.get("method"), "openbao.auth.method", errors) + if method not in {"oidc", "kubernetes"}: + errors.append("openbao.auth.method must be oidc or kubernetes") + require_string(auth.get("mount"), "openbao.auth.mount", errors) + require_string(auth.get("role"), "openbao.auth.role", errors) + policies = [str(policy) for policy in require_list(auth.get("policies"), "openbao.auth.policies", errors)] + if policies != [policy_name]: + errors.append("openbao.auth.policies must contain exactly openbao.policy_name") + for policy in policies: + if policy in DISALLOWED_POLICY_NAMES: + errors.append(f"openbao.auth.policies contains disallowed policy {policy}") + ttl = auth.get("ttl") + if ttl is not None and (not isinstance(ttl, str) or not TTL_RE.match(ttl)): + errors.append("openbao.auth.ttl must match ") + bound_claims = require_object( + auth.get("bound_claims"), "openbao.auth.bound_claims", errors + ) + if not bound_claims: + errors.append("openbao.auth.bound_claims must not be empty") + if auth.get("bound_claims_confirmed") is not True: + warnings.append("OIDC/Kubernetes bound claim is not confirmed; apply is blocked") + + frontdoor = require_object(ccr.get("access_frontdoor"), "access_frontdoor", errors) + require_string(frontdoor.get("type"), "access_frontdoor.type", errors) + require_string(frontdoor.get("catalog_id"), "access_frontdoor.catalog_id", errors) + + risk = require_object(ccr.get("risk"), "risk", errors) + require_string(risk.get("classification"), "risk.classification", errors) + require_list(risk.get("notes"), "risk.notes", errors) + + verification = require_object(ccr.get("verification"), "verification", errors) + for field in ("positive", "negative", "activation_conditions"): + values = require_list(verification.get(field), f"verification.{field}", errors) + if not values: + errors.append(f"verification.{field} must not be empty") + + lifecycle = require_object(ccr.get("lifecycle"), "lifecycle", errors) + for field in ("deactivate", "rotate", "compromised"): + require_string(lifecycle.get(field), f"lifecycle.{field}", errors) + + +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) + for field in ( + "id", + "kind", + "schema_version", + "request_type", + "title", + "status", + "created", + "updated", + "requester", + ): + if field == "schema_version": + if ccr.get(field) != 1: + errors.append("schema_version must be 1") + elif field == "requester": + require_object(ccr.get(field), field, errors) + else: + require_string(ccr.get(field), field, errors) + if ccr.get("kind") != "credential-change-request": + errors.append("kind must be credential-change-request") + ccr_id = ccr.get("id") + if isinstance(ccr_id, str) and not SAFE_ID_RE.match(ccr_id): + errors.append("id must contain only uppercase letters, digits, dot, dash, or underscore") + status = ccr.get("status") + if isinstance(status, str) and status not in ALLOWED_STATUSES: + errors.append(f"status must be one of {sorted(ALLOWED_STATUSES)}") + request_type = ccr.get("request_type") + if request_type != "workload-kv-read": + errors.append("request_type must be workload-kv-read") + else: + validate_workload_kv_read(ccr, errors, warnings) + return ccr, errors, warnings + + +def render_summary(ccr: dict[str, Any], warnings: list[str]) -> str: + openbao = ccr["openbao"] + auth = openbao["auth"] + frontdoor = ccr["access_frontdoor"] + risk = ccr["risk"] + verification = ccr["verification"] + fields = ", ".join(openbao["fields"]) + claim_bits = ", ".join( + f"{key}={value}" for key, value in auth.get("bound_claims", {}).items() + ) + lines = [ + f"Request: {ccr['title']}", + f"CCR: {ccr['id']} ({ccr['status']})", + f"Type: {ccr['request_type']}", + f"Target: {ccr['target']['tenant']}/{ccr['target']['workload']} ({ccr['target']['environment']})", + "Mount/path/field:", + f" {openbao['kv_path']}", + f" {fields}", + "Policy:", + f" {openbao['policy_name']}", + "Auth binding:", + f" {auth['mount']} {auth['method']} role {auth['role']}", + f" bound claims: {claim_bits}", + f" confirmed: {auth.get('bound_claims_confirmed') is True}", + "Access front door:", + f" {frontdoor['type']} {frontdoor['catalog_id']}", + f"Risk: {risk['classification']}", + ] + for note in risk.get("notes", []): + lines.append(f" - {note}") + lines.append("Checks:") + for check in verification.get("positive", []): + lines.append(f" + {check}") + for check in verification.get("negative", []): + lines.append(f" - {check}") + if warnings: + lines.append("Warnings:") + for warning in warnings: + lines.append(f" ! {warning}") + lines.extend( + [ + "Decision:", + " approve | deny | needs_changes", + "Comment:", + " free text; do not include secret values", + ] + ) + return "\n".join(lines) + + +def generated_policy_hcl(ccr: dict[str, Any]) -> str: + openbao = ccr["openbao"] + mount = openbao["mount"] + suffix = openbao["kv_path"][len(mount) + 1 :] + return ( + f'path "{mount}/data/{suffix}" {{\n' + ' capabilities = ["read"]\n' + "}\n\n" + f'path "{mount}/metadata/{suffix}" {{\n' + ' capabilities = ["read"]\n' + "}\n" + ) + + +def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]: + auth = ccr["openbao"]["auth"] + payload: dict[str, Any] = { + "role_type": "oidc" if auth["method"] == "oidc" else "jwt", + "user_claim": auth.get("user_claim", "sub"), + "policies": ",".join(auth["policies"]), + "ttl": auth.get("ttl", "15m"), + "bound_claims": auth["bound_claims"], + } + if auth.get("groups_claim"): + payload["groups_claim"] = auth["groups_claim"] + return payload + + +def render_plan(ccr: dict[str, Any]) -> str: + openbao = ccr["openbao"] + auth = openbao["auth"] + payload = auth_payload(ccr) + lines = [ + f"CCR {ccr['id']} apply plan", + "", + "1. Write policy HCL:", + f" policy: {openbao['policy_name']}", + f" source: {openbao['policy_file']}", + "", + generated_policy_hcl(ccr).rstrip(), + "", + "2. Create/update auth role payload:", + f" path: auth/{auth['mount']}/role/{auth['role']}", + json.dumps(payload, indent=2, sort_keys=True), + "", + "3. Provision secret value out-of-band:", + f" path: {openbao['kv_path']}", + f" fields: {', '.join(openbao['fields'])}", + "", + "4. Verify positive and negative access without printing secret values.", + ] + return "\n".join(lines) + + +def validate_or_exit(path: Path) -> tuple[dict[str, Any], list[str]]: + ccr, errors, warnings = validate_ccr(path) + for warning in warnings: + print(f"[WARN] {path.name}: {warning}", file=sys.stderr) + if errors: + for error in errors: + print(f"[FAIL] {path.name}: {error}", file=sys.stderr) + raise SystemExit(1) + return ccr, warnings + + +def append_decision(path: Path, status: str, reviewer: str, comment: str) -> None: + ccr, _warnings = validate_or_exit(path) + review = ccr.setdefault("review", {}) + comments = review.setdefault("comments", []) + if not isinstance(comments, list): + fail("review.comments must be a list") + comments.append( + { + "at": utc_now(), + "reviewer": reviewer, + "decision": status, + "comment": comment, + } + ) + ccr["status"] = status + ccr["updated"] = datetime.now(timezone.utc).date().isoformat() + dump_yaml(path, ccr) + + +def command_validate(args: argparse.Namespace) -> int: + refs = args.refs or [str(path) for path in sorted(ccr_dir().glob("*.y*ml"))] + if not refs: + fail(f"no CCR files found in {ccr_dir()}") + ok = True + for ref in refs: + path = resolve_ccr(ref) + _ccr, errors, warnings = validate_ccr(path) + for warning in warnings: + print(f"[WARN] {path.name}: {warning}", file=sys.stderr) + if errors: + ok = False + for error in errors: + print(f"[FAIL] {path.name}: {error}", file=sys.stderr) + else: + print(f"[OK] {path.name}") + return 0 if ok else 1 + + +def command_render(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr, warnings = validate_or_exit(path) + print(render_summary(ccr, warnings)) + return 0 + + +def command_plan(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr, _warnings = validate_or_exit(path) + print(render_plan(ccr)) + return 0 + + +def command_apply_plan(args: argparse.Namespace) -> int: + path = resolve_ccr(args.ref) + ccr, _warnings = validate_or_exit(path) + if ccr.get("status") not in APPLY_ALLOWED_STATUSES: + fail(f"apply-plan requires status approved, got {ccr.get('status')}") + auth = ccr["openbao"]["auth"] + if auth.get("bound_claims_confirmed") is not True: + fail("apply-plan requires openbao.auth.bound_claims_confirmed=true") + print(render_plan(ccr)) + return 0 + + +def command_decision(args: argparse.Namespace, status: str) -> int: + path = resolve_ccr(args.ref) + append_decision(path, status, args.reviewer, args.comment) + print(f"[OK] {path.name} -> {status}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Validate, render, and review non-secret credential change requests." + ) + sub = parser.add_subparsers(dest="command", required=True) + + validate = sub.add_parser("validate", help="Validate CCR files") + validate.add_argument("refs", nargs="*") + validate.set_defaults(func=command_validate) + + render = sub.add_parser("render", help="Render a human review summary") + render.add_argument("ref") + render.set_defaults(func=command_render) + + plan = sub.add_parser("plan", help="Render the generated apply plan for review") + plan.add_argument("ref") + plan.set_defaults(func=command_plan) + + apply_plan = sub.add_parser( + "apply-plan", help="Render an operator apply plan only for approved CCRs" + ) + apply_plan.add_argument("ref") + apply_plan.set_defaults(func=command_apply_plan) + + for name, status in ( + ("approve", "approved"), + ("deny", "denied"), + ("needs-changes", "needs_changes"), + ): + decision = sub.add_parser(name, help=f"Record {status} decision") + decision.add_argument("ref") + decision.add_argument("--reviewer", required=True) + decision.add_argument("--comment", required=True) + decision.set_defaults(func=lambda args, status=status: command_decision(args, status)) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py new file mode 100644 index 0000000..792973f --- /dev/null +++ b/tests/test_credential_change.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import importlib.util +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "credential_change", REPO_DIR / "scripts/credential-change.py" +) +credential_change = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = credential_change +SPEC.loader.exec_module(credential_change) + + +class CredentialChangeTests(unittest.TestCase): + def setUp(self) -> None: + self.sample = ( + REPO_DIR + / "credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml" + ) + + def test_sample_ccr_validates_with_bound_claim_warning(self) -> None: + _ccr, errors, warnings = credential_change.validate_ccr(self.sample) + self.assertEqual(errors, []) + self.assertIn("bound claim is not confirmed", warnings[0]) + + def test_render_summary_contains_review_fields(self) -> None: + ccr, _errors, warnings = credential_change.validate_ccr(self.sample) + rendered = credential_change.render_summary(ccr, warnings) + self.assertIn("whynot-design npm publish token lane", rendered) + self.assertIn("platform/workloads/whynot-design/whynot-design/npm-publish", rendered) + self.assertIn("approve | deny | needs_changes", rendered) + + def test_apply_plan_refuses_unapproved_ccr(self) -> None: + with self.assertRaises(SystemExit): + credential_change.command_apply_plan(type("Args", (), {"ref": str(self.sample)})()) + + def test_approve_records_comment_but_unconfirmed_claim_still_blocks_apply(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + ccr_dir = tmp_path / "ccrs" + ccr_dir.mkdir() + copied = ccr_dir / self.sample.name + shutil.copy2(self.sample, copied) + old_ccr_dir = os.environ.get("CCR_DIR") + os.environ["CCR_DIR"] = str(ccr_dir) + try: + credential_change.append_decision( + copied, "approved", "unit-test", "looks right" + ) + ccr, errors, _warnings = credential_change.validate_ccr(copied) + self.assertEqual(errors, []) + self.assertEqual(ccr["status"], "approved") + self.assertEqual(ccr["review"]["comments"][-1]["comment"], "looks right") + with self.assertRaises(SystemExit): + credential_change.command_apply_plan( + type("Args", (), {"ref": "CCR-2026-0001"})() + ) + finally: + if old_ccr_dir is None: + os.environ.pop("CCR_DIR", None) + else: + os.environ["CCR_DIR"] = old_ccr_dir + + def test_generated_policy_is_narrow(self) -> None: + ccr, _errors, _warnings = credential_change.validate_ccr(self.sample) + policy = credential_change.generated_policy_hcl(ccr) + self.assertIn('path "platform/data/workloads/whynot-design/whynot-design/npm-publish"', policy) + self.assertNotIn("*", policy) + self.assertNotIn("delete", policy) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md index 1ed5082..aa4d94a 100644 --- a/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md +++ b/workplans/RAILIANCE-WP-0007-credential-change-approval-workflow.md @@ -4,7 +4,7 @@ type: workplan title: "Credential Change Proposal Review Workflow" domain: financials repo: railiance-platform -status: ready +status: active owner: codex topic_slug: railiance planning_priority: high @@ -98,7 +98,7 @@ interactive runbook role, and compromise/deactivation path. ```task id: RAILIANCE-WP-0007-T02 -status: todo +status: done priority: high state_hub_task_id: "d50fb9e2-68c2-4a2b-8476-ce646d13e60a" ``` @@ -117,11 +117,17 @@ Acceptance: secrets. - Example CCR fixtures include the whynot-design npm token lane. +**2026-06-27:** Added `schemas/credential-change-request.schema.yaml`, the +`credential-change-requests/` storage directory, and +`credential-change-requests/CCR-2026-0001-whynot-design-npm-token.yaml` as the +first non-secret CCR fixture. The whynot CCR is intentionally `proposed` and +marks the bound claim as unconfirmed, so apply is blocked until review. + ## T03 - Add offline validation and rendering ```task id: RAILIANCE-WP-0007-T03 -status: todo +status: done priority: high state_hub_task_id: "012f05cd-30ce-43dd-802b-4acc938db133" ``` @@ -138,11 +144,17 @@ Acceptance: plan. - A secret-pattern scan rejects likely token values in CCR files. +**2026-06-27:** Added `scripts/credential-change.py validate` and `render`, +plus Make targets `credential-change-validate` and `credential-change-render`. +Validation rejects secret-looking markers and broad/unsafe request shapes; render +produces the chat/State Hub review summary and highlights unconfirmed bound +claims. Unit coverage lives in `tests/test_credential_change.py`. + ## T04 - Generate OpenBao apply plans from approved CCRs ```task id: RAILIANCE-WP-0007-T04 -status: todo +status: progress priority: high state_hub_task_id: "1b2e7752-815c-46f8-a2e2-212e8d04da80" ``` @@ -159,11 +171,18 @@ Acceptance: - The applier uses an approved operator authority path and does not accept raw tokens in argv or logs. +**2026-06-27:** Added `plan` and guarded `apply-plan` rendering for workload KV +CCRs, with Make targets `credential-change-plan` and +`credential-change-apply-plan`. `apply-plan` currently refuses any CCR that is +not `approved` and also refuses unconfirmed bound claims. Remaining T04 work is +to add a richer diff against existing source artifacts and eventually bridge +from reviewed plan to the interactive live applier. + ## T05 - Add chat/CLI approval commands ```task id: RAILIANCE-WP-0007-T05 -status: todo +status: progress priority: high state_hub_task_id: "e6d4d2d1-1881-4db7-92f8-05e3fdb846ae" ``` @@ -180,6 +199,11 @@ Acceptance: - Agents can propose changes and respond to review comments without receiving secret values. +**2026-06-27:** Added file-backed `approve`, `deny`, and `needs-changes` +commands that require reviewer and comment text and append non-secret review +comments to the CCR. Remaining T05 work is State Hub decision-event emission and +tighter chat integration. + ## T06 - Build an interactive runbook for apply and verify ```task @@ -204,7 +228,7 @@ Acceptance: ```task id: RAILIANCE-WP-0007-T07 -status: todo +status: progress priority: high state_hub_task_id: "07a7d8bf-5528-41c8-a791-d6ccd0466a33" ``` @@ -220,6 +244,10 @@ Acceptance: provisioning, verifies access, and notifies ops-warden. - ops-warden activates its catalog entry only after CCR verification. +**2026-06-27:** The whynot-design lane is represented as `CCR-2026-0001` and +can be rendered for review. It remains proposed/unapproved with unconfirmed +bound claims, so live apply and ops-warden activation are correctly blocked. + ## T08 - Add deactivation, rotation, and compromise flows ```task