Add native rotate and persistent lane overlay states
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:
parent
85d4548035
commit
72d3327c28
12 changed files with 596 additions and 20 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue