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(