Implement credential change request review flow
This commit is contained in:
16
Makefile
16
Makefile
@@ -24,6 +24,7 @@ ARGOCD_NAMESPACE ?= argocd
|
|||||||
ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap
|
ARGOCD_BOOTSTRAP_DIR ?= argocd/bootstrap
|
||||||
ARGOCD_REPOSITORY_SECRET ?=
|
ARGOCD_REPOSITORY_SECRET ?=
|
||||||
CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml
|
CREDENTIAL_GRANTS ?= credential-grants/catalog.yaml
|
||||||
|
CREDENTIAL_CHANGE ?= CCR-2026-0001
|
||||||
OPENBAO_TOKEN_GRANT_ARGS ?=
|
OPENBAO_TOKEN_GRANT_ARGS ?=
|
||||||
OPENBAO_WORKLOAD_KV_ARGS ?=
|
OPENBAO_WORKLOAD_KV_ARGS ?=
|
||||||
CREDENTIAL_HELPER_GLOBAL_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
|
credential-grants-validate: ## Validate non-secret credential grant catalog
|
||||||
scripts/credential-grants-validate.py $(CREDENTIAL_GRANTS)
|
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
|
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)
|
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
|
credential-tests: ## Run offline credential broker unit tests
|
||||||
python3 -m unittest discover -s tests -p 'test_credential*.py'
|
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
|
credential-exec-ops-warden-smoke: ## Run ops-warden smoke with an exec-injected warden-sign token
|
||||||
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
KUBECTL='$(KUBECTL)' OPENBAO_NAMESPACE=$(OPENBAO_NAMESPACE) \
|
||||||
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
OPENBAO_RELEASE=$(OPENBAO_RELEASE) \
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -137,18 +137,21 @@ Version 1 should be boring:
|
|||||||
- prompt or delegate separately for secret value entry;
|
- prompt or delegate separately for secret value entry;
|
||||||
- record non-secret evidence in State Hub.
|
- record non-secret evidence in State Hub.
|
||||||
|
|
||||||
The CLI shape can be:
|
The first implemented CLI slice is:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/credential-change.py propose workload-kv ...
|
make credential-change-validate
|
||||||
scripts/credential-change.py render CCR-YYYY-NNNN
|
make credential-change-render CREDENTIAL_CHANGE=CCR-2026-0001
|
||||||
scripts/credential-change.py approve CCR-YYYY-NNNN --comment "..."
|
make credential-change-plan CREDENTIAL_CHANGE=CCR-2026-0001
|
||||||
scripts/credential-change.py deny CCR-YYYY-NNNN --comment "..."
|
scripts/credential-change.py approve CCR-2026-0001 --reviewer <name> --comment "..."
|
||||||
scripts/credential-change.py apply CCR-YYYY-NNNN
|
scripts/credential-change.py deny CCR-2026-0001 --reviewer <name> --comment "..."
|
||||||
scripts/credential-change.py verify CCR-YYYY-NNNN
|
scripts/credential-change.py needs-changes CCR-2026-0001 --reviewer <name> --comment "..."
|
||||||
scripts/credential-change.py deactivate CCR-YYYY-NNNN --reason "..."
|
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
|
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
|
proposal, show the rendered summary, then call the CLI only after the human
|
||||||
gives an explicit approval phrase.
|
gives an explicit approval phrase.
|
||||||
|
|||||||
93
schemas/credential-change-request.schema.yaml
Normal file
93
schemas/credential-change-request.schema.yaml
Normal file
@@ -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:
|
||||||
|
- "*"
|
||||||
|
- ".."
|
||||||
466
scripts/credential-change.py
Executable file
466
scripts/credential-change.py
Executable file
@@ -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 <positive integer><s|m|h|d>")
|
||||||
|
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())
|
||||||
80
tests/test_credential_change.py
Normal file
80
tests/test_credential_change.py
Normal file
@@ -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()
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Credential Change Proposal Review Workflow"
|
title: "Credential Change Proposal Review Workflow"
|
||||||
domain: financials
|
domain: financials
|
||||||
repo: railiance-platform
|
repo: railiance-platform
|
||||||
status: ready
|
status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: railiance
|
topic_slug: railiance
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
@@ -98,7 +98,7 @@ interactive runbook role, and compromise/deactivation path.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAILIANCE-WP-0007-T02
|
id: RAILIANCE-WP-0007-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d50fb9e2-68c2-4a2b-8476-ce646d13e60a"
|
state_hub_task_id: "d50fb9e2-68c2-4a2b-8476-ce646d13e60a"
|
||||||
```
|
```
|
||||||
@@ -117,11 +117,17 @@ Acceptance:
|
|||||||
secrets.
|
secrets.
|
||||||
- Example CCR fixtures include the whynot-design npm token lane.
|
- 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
|
## T03 - Add offline validation and rendering
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAILIANCE-WP-0007-T03
|
id: RAILIANCE-WP-0007-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "012f05cd-30ce-43dd-802b-4acc938db133"
|
state_hub_task_id: "012f05cd-30ce-43dd-802b-4acc938db133"
|
||||||
```
|
```
|
||||||
@@ -138,11 +144,17 @@ Acceptance:
|
|||||||
plan.
|
plan.
|
||||||
- A secret-pattern scan rejects likely token values in CCR files.
|
- 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
|
## T04 - Generate OpenBao apply plans from approved CCRs
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAILIANCE-WP-0007-T04
|
id: RAILIANCE-WP-0007-T04
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "1b2e7752-815c-46f8-a2e2-212e8d04da80"
|
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
|
- The applier uses an approved operator authority path and does not accept raw
|
||||||
tokens in argv or logs.
|
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
|
## T05 - Add chat/CLI approval commands
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAILIANCE-WP-0007-T05
|
id: RAILIANCE-WP-0007-T05
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "e6d4d2d1-1881-4db7-92f8-05e3fdb846ae"
|
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
|
- Agents can propose changes and respond to review comments without receiving
|
||||||
secret values.
|
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
|
## T06 - Build an interactive runbook for apply and verify
|
||||||
|
|
||||||
```task
|
```task
|
||||||
@@ -204,7 +228,7 @@ Acceptance:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAILIANCE-WP-0007-T07
|
id: RAILIANCE-WP-0007-T07
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "07a7d8bf-5528-41c8-a791-d6ccd0466a33"
|
state_hub_task_id: "07a7d8bf-5528-41c8-a791-d6ccd0466a33"
|
||||||
```
|
```
|
||||||
@@ -220,6 +244,10 @@ Acceptance:
|
|||||||
provisioning, verifies access, and notifies ops-warden.
|
provisioning, verifies access, and notifies ops-warden.
|
||||||
- ops-warden activates its catalog entry only after CCR verification.
|
- 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
|
## T08 - Add deactivation, rotation, and compromise flows
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|||||||
Reference in New Issue
Block a user