Add native rotate and persistent lane overlay states
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

rotate replaces one declared KV field through the merge-safe patch path
and never prints the value. Overlay states active/suspended/deactivated/
compromised live under the evidence directory. compromise/reactivate and
successful suspend/deactivate/revoke update that overlay; exec/wrap/
handoff/provision refuse non-active lanes. Provider-side rotation stays
with rotation.owner. Production remains fail-closed.

Assistant: grok
Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00
This commit is contained in:
tegwick 2026-09-02 13:09:10 +02:00
parent 85d4548035
commit 72d3327c28
12 changed files with 596 additions and 20 deletions

View file

@ -7,6 +7,7 @@ Command surface (FR7):
plan <decision-or-ref> --stage <stage>
apply <decision-or-ref> --stage <stage> [--dry-run] [--auth auto] [--bootstrap-token-file F]
provision <catalog-id> --stage <stage> (--from-file F | --generate) --field NAME
rotate <catalog-id> --stage <stage> --field NAME --from-file F [--dry-run]
verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
wrap <catalog-id> --out F [--ttl 15m]
@ -14,7 +15,8 @@ Command surface (FR7):
route <catalog-id> [--json]
revoke <catalog-id>
session revoke (--accessor-file F | --lease-id-file F) [--stage stage]
lifecycle suspend|deactivate|destroy <catalog-id>
lifecycle suspend|deactivate|destroy|compromise|reactivate <catalog-id>
state show <catalog-id> [--json]
audit <catalog-id> [--json]
secret-use snapshot [--catalog-id ID] [--json]
evidence heartbeat|drain|classify
@ -42,6 +44,13 @@ from secrets_engine.decisions import require_approved, resolve_decision
from secrets_engine.errors import DecisionError, SecretsEngineError
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
from secrets_engine.engine_auth import login_service_jwt, select_engine_auth
from secrets_engine.lane_state import (
load_lane_state,
operation_state,
require_delivery_state,
require_provision_state,
save_lane_state,
)
from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision
from secrets_engine.openbao import OpenBaoClient, accessor_fingerprint, read_strict_token_file
from secrets_engine.plan import build_plan
@ -304,6 +313,7 @@ def cmd_provision(cfg: Config, args) -> int:
)
decision = _require_lane_approval(cfg, entry, "provision", evidence)
evidence.mark_approved(decision)
require_provision_state(cfg.evidence_dir, entry.id)
with _open_backend(cfg, args, evidence) as client:
if args.generate:
f = provision_generated(client, entry, field)
@ -318,6 +328,49 @@ def cmd_provision(cfg: Config, args) -> int:
return 0
def cmd_rotate(cfg: Config, args) -> int:
from secrets_engine.rotate import render_rotate_plan, rotate_from_file
entry = get_entry(cfg.catalog_dir, args.catalog_id)
field = args.field or (entry.fields[0] if entry.fields else "")
if args.dry_run:
print(render_rotate_plan(entry, field))
print("\n(dry-run: no OpenBao mutation performed)")
return 0
with _privileged_evidence(
cfg, entry, "rotate", detail={"field": field}
) as evidence:
if args.stage != entry.stage:
from secrets_engine.errors import ProvisioningError
raise ProvisioningError(
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
)
decision = _require_lane_approval(cfg, entry, "rotate", evidence)
evidence.mark_approved(decision)
with _open_backend(cfg, args, evidence) as client:
f = rotate_from_file(client, entry, field, Path(args.from_file))
record = save_lane_state(
cfg.evidence_dir,
entry.id,
load_lane_state(cfg.evidence_dir, entry.id).state,
operation="rotate",
)
print(
f"rotated lane '{entry.id}' field '{f}' — value not displayed "
f"(state={record.state})"
)
evidence.finish(
"rotated",
detail={
"field": f,
"lane_state": record.state,
"rotation_owner": str((entry.rotation or {}).get("owner") or ""),
},
)
return 0
def cmd_verify(cfg: Config, args) -> int:
entry = get_entry(cfg.catalog_dir, args.catalog_id)
fields = [args.field] if args.field else list(entry.fields)
@ -406,6 +459,7 @@ def cmd_handoff(cfg: Config, args) -> int:
)
decision = _require_lane_approval(cfg, entry, "handoff", evidence)
evidence.mark_approved(decision)
require_delivery_state(cfg.evidence_dir, entry.id, "handoff")
with _open_backend(cfg, args, evidence) as client:
result = write_approle_handoff(
client,
@ -445,6 +499,7 @@ def cmd_wrap(cfg: Config, args) -> int:
) as evidence:
decision = _require_lane_approval(cfg, entry, "wrap", evidence)
evidence.mark_approved(decision)
require_delivery_state(cfg.evidence_dir, entry.id, "wrap")
with _open_backend(cfg, args, evidence) as client:
result = write_wrapped_handoff(
client, entry, out_file=Path(args.out), ttl=args.ttl
@ -487,6 +542,7 @@ def cmd_exec(cfg: Config, args) -> int:
# require approval + readiness before running.
decision = _require_lane_approval(cfg, entry, "exec", evidence)
evidence.mark_approved(decision)
require_delivery_state(cfg.evidence_dir, entry.id, "exec")
if not args.command:
from secrets_engine.errors import DeliveryError
@ -611,16 +667,73 @@ def cmd_revoke(cfg: Config, args) -> int:
result = apply_lifecycle_plan(client, plan)
print(plan.render())
print(result.render())
record = save_lane_state(
cfg.evidence_dir, entry.id, "deactivated", operation="revoke"
)
evidence.finish(
"native-access-deactivated",
detail={
"applied": list(result.applied),
"preserved": list(result.preserved),
"lane_state": record.state,
},
)
return 0
def _cmd_lifecycle_state(cfg: Config, args, entry) -> int:
mapped = operation_state(args.operation)
reason = getattr(args, "reason", "") or ""
current = load_lane_state(cfg.evidence_dir, entry.id)
if args.dry_run:
print(
f"Lane state plan for '{entry.id}': {current.state} -> {mapped} "
f"(operation={args.operation})"
)
print("\n(dry-run: no state file written)")
return 0
with _privileged_evidence(
cfg,
entry,
f"lifecycle-{args.operation}",
detail={"operation": args.operation, "from_state": current.state},
) as evidence:
decision = _require_lane_approval(cfg, entry, args.operation, evidence)
evidence.mark_approved(decision)
record = save_lane_state(
cfg.evidence_dir,
entry.id,
mapped or current.state,
operation=args.operation,
reason=reason,
)
print(f"lane '{entry.id}' state {current.state} -> {record.state}")
evidence.finish(
record.state,
detail={"lane_state": record.state, "from_state": current.state},
)
return 0
def cmd_state_show(cfg: Config, args) -> int:
import json
entry = get_entry(cfg.catalog_dir, args.catalog_id)
record = load_lane_state(cfg.evidence_dir, entry.id)
payload = record.as_dict()
if args.json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print(
f"lane: {payload['catalog_id']} state={payload['state']} "
f"last={payload.get('last_operation') or '-'} "
f"at={payload.get('updated_at') or '-'}"
)
if payload.get("reason"):
print(f"reason: {payload['reason']}")
return 0
def cmd_lifecycle(cfg: Config, args) -> int:
from secrets_engine.lifecycle import (
apply_lifecycle_plan,
@ -629,6 +742,8 @@ def cmd_lifecycle(cfg: Config, args) -> int:
)
entry = get_entry(cfg.catalog_dir, args.catalog_id)
if args.operation in {"compromise", "reactivate"}:
return _cmd_lifecycle_state(cfg, args, entry)
plan = build_lifecycle_plan(entry, args.operation)
if args.dry_run:
print(plan.render())
@ -655,15 +770,21 @@ def cmd_lifecycle(cfg: Config, args) -> int:
evidence.mark_approved(decision)
with _open_backend(cfg, args, evidence) as client:
result = apply_lifecycle_plan(client, plan)
mapped = operation_state(args.operation)
record = None
if mapped:
record = save_lane_state(
cfg.evidence_dir, entry.id, mapped, operation=args.operation
)
print(plan.render())
print(result.render())
evidence.finish(
"applied",
detail={
"applied": list(result.applied),
"preserved": list(result.preserved),
},
)
detail = {
"applied": list(result.applied),
"preserved": list(result.preserved),
}
if record is not None:
detail["lane_state"] = record.state
evidence.finish("applied", detail=detail)
return 0
@ -823,6 +944,15 @@ def build_parser() -> argparse.ArgumentParser:
add_token_arg(pr)
pr.set_defaults(func=cmd_provision)
rot = sub.add_parser("rotate", help="replace a declared KV field without printing it")
rot.add_argument("catalog_id")
rot.add_argument("--stage", required=True, choices=("build", "test", "prod"))
rot.add_argument("--field", default=None)
rot.add_argument("--from-file", required=True, help="mode-0600 file holding the new value")
rot.add_argument("--dry-run", action="store_true")
add_token_arg(rot)
rot.set_defaults(func=cmd_rotate)
ve = sub.add_parser("verify", help="positive/negative verification (no value printed)")
ve.add_argument("catalog_id")
ve.add_argument("--field", default=None)
@ -909,6 +1039,8 @@ def build_parser() -> argparse.ArgumentParser:
("suspend", "stop new native logins; preserve policy and KV custody"),
("deactivate", "remove native AppRole/policy; preserve KV custody"),
("destroy", "irreversibly delete KV metadata after deactivation"),
("compromise", "mark the lane compromised; block delivery until reactivate"),
("reactivate", "clear compromised/suspended/deactivated overlay state"),
):
lp = lcsub.add_parser(operation, help=help_text)
lp.add_argument("catalog_id")
@ -919,9 +1051,22 @@ def build_parser() -> argparse.ArgumentParser:
default="",
help="exact catalog id required for live irreversible deletion",
)
if operation == "compromise":
lp.add_argument(
"--reason",
default="",
help="non-secret operator note, max 200 characters",
)
add_token_arg(lp)
lp.set_defaults(func=cmd_lifecycle)
st = sub.add_parser("state", help="show persistent non-secret lane lifecycle state")
stsub = st.add_subparsers(dest="subcmd", required=True)
stshow = stsub.add_parser("show", help="show overlay state for a catalog lane")
stshow.add_argument("catalog_id")
stshow.add_argument("--json", action="store_true")
stshow.set_defaults(func=cmd_state_show)
su = sub.add_parser("secret-use", help="read-only secret-use evidence surface")
susub = su.add_subparsers(dest="subcmd", required=True)
snap = susub.add_parser(

View file

@ -45,6 +45,9 @@ SHIPPED_RULES = (
"provision",
"session-revoke",
"wrap",
"rotate",
"lifecycle-compromise",
"lifecycle-reactivate",
),
"stages": ("prod",),
},

View file

@ -0,0 +1,137 @@
"""Persistent non-secret lane lifecycle state.
State lives under the evidence directory, never in Git, and never holds a
secret value. It does not recreate OpenBao objects; ``apply`` remains the
metadata path. Delivery commands consult this overlay and fail closed.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import yaml
from secrets_engine.errors import DecisionError, PolicyGuardError
from secrets_engine.redact import looks_secret, redact_text
STATES = ("active", "suspended", "deactivated", "compromised")
DELIVERY_BLOCKED = frozenset({"suspended", "deactivated", "compromised"})
PROVISION_BLOCKED = frozenset({"suspended", "deactivated", "compromised"})
@dataclass(frozen=True)
class LaneState:
catalog_id: str
state: str
updated_at: str = ""
last_operation: str = ""
reason: str = ""
def as_dict(self) -> dict[str, str]:
payload = {
"catalog_id": self.catalog_id,
"state": self.state,
"updated_at": self.updated_at,
"last_operation": self.last_operation,
}
if self.reason:
payload["reason"] = self.reason
return payload
def state_dir(evidence_dir: Path) -> Path:
return Path(evidence_dir) / "lane-state"
def state_path(evidence_dir: Path, catalog_id: str) -> Path:
return state_dir(evidence_dir) / f"{catalog_id}.yaml"
def load_lane_state(evidence_dir: Path, catalog_id: str) -> LaneState:
path = state_path(evidence_dir, catalog_id)
if not path.is_file():
return LaneState(catalog_id=catalog_id, state="active")
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as exc:
raise PolicyGuardError(f"unable to load lane state for '{catalog_id}'") from exc
if not isinstance(data, dict):
raise PolicyGuardError(f"lane state for '{catalog_id}' is invalid")
state = str(data.get("state") or "active")
if state not in STATES:
raise PolicyGuardError(f"lane '{catalog_id}' has unknown state '{state}'")
return LaneState(
catalog_id=str(data.get("catalog_id") or catalog_id),
state=state,
updated_at=str(data.get("updated_at") or ""),
last_operation=str(data.get("last_operation") or ""),
reason=str(data.get("reason") or ""),
)
def save_lane_state(
evidence_dir: Path,
catalog_id: str,
state: str,
*,
operation: str,
reason: str = "",
now: datetime | None = None,
) -> LaneState:
if state not in STATES:
raise PolicyGuardError(f"unknown lane state '{state}'")
cleaned = _clean_reason(reason)
record = LaneState(
catalog_id=catalog_id,
state=state,
updated_at=(now or datetime.now(timezone.utc)).astimezone(timezone.utc).isoformat(),
last_operation=operation,
reason=cleaned,
)
path = state_path(evidence_dir, catalog_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(record.as_dict(), sort_keys=True), encoding="utf-8")
return record
def _clean_reason(reason: str) -> str:
text = (reason or "").strip()
if not text:
return ""
if len(text) > 200:
raise PolicyGuardError("lane-state reason must be at most 200 characters")
if looks_secret(text) or redact_text(text) != text:
raise PolicyGuardError("lane-state reason must not contain secret-like material")
return text
def require_delivery_state(evidence_dir: Path, catalog_id: str, action: str) -> LaneState:
"""Refuse exec/wrap/handoff when the lane is not active."""
current = load_lane_state(evidence_dir, catalog_id)
if current.state in DELIVERY_BLOCKED:
raise DecisionError(
f"lane '{catalog_id}' is {current.state}; "
f"refusing {action} until lifecycle reactivate"
)
return current
def require_provision_state(evidence_dir: Path, catalog_id: str) -> LaneState:
current = load_lane_state(evidence_dir, catalog_id)
if current.state in PROVISION_BLOCKED:
hint = "rotate" if current.state == "compromised" else "lifecycle reactivate"
raise DecisionError(
f"lane '{catalog_id}' is {current.state}; refusing provision; use {hint}"
)
return current
def operation_state(operation: str) -> str | None:
"""Return the state persisted after a successful lifecycle operation."""
return {
"suspend": "suspended",
"deactivate": "deactivated",
"compromise": "compromised",
"reactivate": "active",
"revoke": "deactivated",
}.get(operation)

View file

@ -0,0 +1,43 @@
"""Native KV rotation: replace a declared field without touching siblings.
This updates OpenBao custody only. Catalog ``rotation.owner`` remains the
provider/workload owner; this engine does not roll consumers or revoke a
provider credential. Auth-capability lanes rotate via wrap/handoff, not here.
"""
from __future__ import annotations
from pathlib import Path
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import ProvisioningError
from secrets_engine.openbao import OpenBaoClient
from secrets_engine.provision import provision_from_file
def render_rotate_plan(entry: CatalogEntry, field: str) -> str:
owner = str((entry.rotation or {}).get("owner") or "")
lines = [
f"Rotate plan for lane '{entry.id}'",
f" field: {field}",
f" target: {entry.mount}/{entry.path}",
f" owner: {owner or '-'}",
" siblings: preserved (CAS patch)",
" workload delivery: not mutated",
]
return "\n".join(lines)
def rotate_from_file(
client: OpenBaoClient, entry: CatalogEntry, field: str, file_path: Path
) -> str:
"""Replace one declared KV field. Returns the field name only."""
if not entry.stores_kv_value():
raise ProvisioningError(
f"lane '{entry.id}' is {entry.kind}; rotate native KV via this "
"command, or wrap/handoff for auth-capability material"
)
if field not in entry.fields:
raise ProvisioningError(
f"field '{field}' not declared in lane '{entry.id}' fields {entry.fields}"
)
return provision_from_file(client, entry, field, file_path)