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

@ -179,8 +179,11 @@ high-risk lanes.
These operations do not manage external workload delivery. `session revoke` These operations do not manage external workload delivery. `session revoke`
revokes an already-issued token accessor or lease id the operator already revokes an already-issued token accessor or lease id the operator already
holds; evidence is fingerprint-only. There is currently no rotation command, holds; evidence is fingerprint-only. `rotate` replaces one declared KV field
compromised state, or persistent/reversible lane state machine. through the merge-safe write path. Persistent overlay states
(`active`/`suspended`/`deactivated`/`compromised`) live under the evidence
directory; they do not recreate OpenBao objects and do not rotate provider
credentials.
## CLI Surface ## CLI Surface
@ -222,10 +225,8 @@ metadata. `secrets-engine wrap` implements response-wrapped operator handoff.
- A service API, daemon, UI, queue, scheduler, or remote multi-user service. - A service API, daemon, UI, queue, scheduler, or remote multi-user service.
- Platform JWT mount/role materialization for the implemented KeyCape - Platform JWT mount/role materialization for the implemented KeyCape
service-auth / `service-jwt` provider. service-auth / `service-jwt` provider.
- Provider-side rotation or coordinated multi-consumer rollout. - Provider-side / workload consumer rotation. Overlay lane state is local and
- First-class rotate, compromise, reactivate, lease-status, or audit report non-secret only; it is not an OpenBao-side state machine.
commands; lifecycle operations currently execute plans without persistent
lane state.
- Resolution of a durable access-engine decision record / State Hub - Resolution of a durable access-engine decision record / State Hub
ActionAuthorization and wiring its validated approval threshold to each ActionAuthorization and wiring its validated approval threshold to each
production handler. The consumer validator exists; the serving endpoint does production handler. The consumer validator exists; the serving endpoint does

View file

@ -53,6 +53,7 @@ secrets-engine decision inspect <decision-or-ccr-id>
secrets-engine plan <ref> --stage <build|test|prod> secrets-engine plan <ref> --stage <build|test|prod>
secrets-engine apply <ref> --stage <stage> [--dry-run] [--bootstrap-token-file F] secrets-engine apply <ref> --stage <stage> [--dry-run] [--bootstrap-token-file F]
secrets-engine provision <catalog-id> --stage <stage> --field NAME (--from-file F | --generate) secrets-engine provision <catalog-id> --stage <stage> --field NAME (--from-file F | --generate)
secrets-engine rotate <catalog-id> --stage <stage> --field NAME --from-file F [--dry-run]
secrets-engine verify <catalog-id> [--field NAME] [--positive] [--negative] [--negative-token-file F] secrets-engine verify <catalog-id> [--field NAME] [--positive] [--negative] [--negative-token-file F]
secrets-engine handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F secrets-engine handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
secrets-engine wrap <catalog-id> --out F [--ttl 15m] secrets-engine wrap <catalog-id> --out F [--ttl 15m]
@ -64,6 +65,9 @@ secrets-engine session revoke (--accessor-file F | --lease-id-file F) [--stage s
secrets-engine lifecycle suspend <catalog-id> [--dry-run] secrets-engine lifecycle suspend <catalog-id> [--dry-run]
secrets-engine lifecycle deactivate <catalog-id> [--dry-run] secrets-engine lifecycle deactivate <catalog-id> [--dry-run]
secrets-engine lifecycle destroy <catalog-id> [--dry-run] [--confirm-destroy <catalog-id>] secrets-engine lifecycle destroy <catalog-id> [--dry-run] [--confirm-destroy <catalog-id>]
secrets-engine lifecycle compromise <catalog-id> [--reason TEXT] [--dry-run]
secrets-engine lifecycle reactivate <catalog-id> [--dry-run]
secrets-engine state show <catalog-id> [--json]
secrets-engine audit <catalog-id> [--json] secrets-engine audit <catalog-id> [--json]
secrets-engine secret-use snapshot [--catalog-id ID] [--json] secrets-engine secret-use snapshot [--catalog-id ID] [--json]
``` ```
@ -117,6 +121,15 @@ All three preserve externally managed auth and workload delivery. The legacy
`revoke` command is a compatibility alias for safe native deactivation, never `revoke` command is a compatibility alias for safe native deactivation, never
KV destruction. KV destruction.
`rotate` replaces one declared KV field through the same merge-safe patch as
provision. It does not roll ESO/consumers or revoke a provider credential;
that remains `rotation.owner`. Auth-capability lanes use wrap/handoff instead.
`lifecycle compromise` / `reactivate` and successful suspend/deactivate/revoke
write a non-secret overlay under the evidence directory. `state show` reads it.
exec/wrap/handoff/provision refuse non-active overlay states; rotate is still
allowed on compromised lanes. Overlay state does not recreate OpenBao objects.
`exec --mode exec-file` writes the selected field to a mode-0600 temp file and `exec --mode exec-file` writes the selected field to a mode-0600 temp file and
sets `{FIELD}_FILE` to that path for the child only. The file is overwritten sets `{FIELD}_FILE` to that path for the child only. The file is overwritten
and unlinked after the child exits. The value is not copied into the child and unlinked after the child exits. The value is not copied into the child

View file

@ -66,11 +66,16 @@ contents in this repo.
## H4 — Rotation & lifecycle states ## H4 — Rotation & lifecycle states
- Implement `rotate` and persistent `compromised` / `deactivated` lane states - Implemented native `rotate`: merge-safe KV field replace from a mode-0600
with evidence. Explicit suspend/deactivate/destroy plans now exist, and file, sibling preservation, no argv values. Auth-capability lanes stay on
ordinary `revoke` safely aliases native AppRole/policy deactivation, but lane wrap/handoff. Provider/workload consumer rollout remains the catalog
state and coordinated provider/workload rotation remain outstanding. Live `rotation.owner` duty.
destroy remains disabled until exact-action authorization is available. - Implemented persistent overlay states `active` / `suspended` /
`deactivated` / `compromised` under `evidence_dir/lane-state/` (non-secret,
not Git). `lifecycle compromise` / `reactivate` and successful
suspend/deactivate/revoke update the overlay. exec/wrap/handoff/provision
refuse non-active lanes; rotate remains allowed on compromised.
- Live destroy remains disabled until exact-action authorization is available.
## H4a — Known-accessor operator command ## H4a — Known-accessor operator command

View file

@ -45,7 +45,7 @@ rules:
- id: production-control-mutation - id: production-control-mutation
kind: load-bearing kind: load-bearing
actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision, session-revoke, wrap] actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision, session-revoke, wrap, rotate, lifecycle-compromise, lifecycle-reactivate]
stages: [prod] stages: [prod]
emission: local-outbox emission: local-outbox
note: >- note: >-

View file

@ -60,6 +60,9 @@ protected_actions:
- lifecycle-destroy - lifecycle-destroy
- session-revoke - session-revoke
- wrap - wrap
- rotate
- lifecycle-compromise
- lifecycle-reactivate
# §13 proposed capabilities. Owner status is proposed, not assented, until # §13 proposed capabilities. Owner status is proposed, not assented, until
# the surface exists in this repository's own contract. # the surface exists in this repository's own contract.

View file

@ -7,6 +7,7 @@ Command surface (FR7):
plan <decision-or-ref> --stage <stage> plan <decision-or-ref> --stage <stage>
apply <decision-or-ref> --stage <stage> [--dry-run] [--auth auto] [--bootstrap-token-file F] 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 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] verify <catalog-id> [--positive] [--negative] [--field NAME] [--negative-token-file F]
handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F handoff <catalog-id> --stage <stage> --role-id-file F --secret-id-file F
wrap <catalog-id> --out F [--ttl 15m] wrap <catalog-id> --out F [--ttl 15m]
@ -14,7 +15,8 @@ Command surface (FR7):
route <catalog-id> [--json] route <catalog-id> [--json]
revoke <catalog-id> revoke <catalog-id>
session revoke (--accessor-file F | --lease-id-file F) [--stage stage] 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] audit <catalog-id> [--json]
secret-use snapshot [--catalog-id ID] [--json] secret-use snapshot [--catalog-id ID] [--json]
evidence heartbeat|drain|classify 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.errors import DecisionError, SecretsEngineError
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
from secrets_engine.engine_auth import login_service_jwt, select_engine_auth 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.pep_stance import apply_unreachable_engine_stance, with_decision
from secrets_engine.openbao import OpenBaoClient, accessor_fingerprint, read_strict_token_file from secrets_engine.openbao import OpenBaoClient, accessor_fingerprint, read_strict_token_file
from secrets_engine.plan import build_plan 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) decision = _require_lane_approval(cfg, entry, "provision", evidence)
evidence.mark_approved(decision) evidence.mark_approved(decision)
require_provision_state(cfg.evidence_dir, entry.id)
with _open_backend(cfg, args, evidence) as client: with _open_backend(cfg, args, evidence) as client:
if args.generate: if args.generate:
f = provision_generated(client, entry, field) f = provision_generated(client, entry, field)
@ -318,6 +328,49 @@ def cmd_provision(cfg: Config, args) -> int:
return 0 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: def cmd_verify(cfg: Config, args) -> int:
entry = get_entry(cfg.catalog_dir, args.catalog_id) entry = get_entry(cfg.catalog_dir, args.catalog_id)
fields = [args.field] if args.field else list(entry.fields) 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) decision = _require_lane_approval(cfg, entry, "handoff", evidence)
evidence.mark_approved(decision) evidence.mark_approved(decision)
require_delivery_state(cfg.evidence_dir, entry.id, "handoff")
with _open_backend(cfg, args, evidence) as client: with _open_backend(cfg, args, evidence) as client:
result = write_approle_handoff( result = write_approle_handoff(
client, client,
@ -445,6 +499,7 @@ def cmd_wrap(cfg: Config, args) -> int:
) as evidence: ) as evidence:
decision = _require_lane_approval(cfg, entry, "wrap", evidence) decision = _require_lane_approval(cfg, entry, "wrap", evidence)
evidence.mark_approved(decision) evidence.mark_approved(decision)
require_delivery_state(cfg.evidence_dir, entry.id, "wrap")
with _open_backend(cfg, args, evidence) as client: with _open_backend(cfg, args, evidence) as client:
result = write_wrapped_handoff( result = write_wrapped_handoff(
client, entry, out_file=Path(args.out), ttl=args.ttl 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. # require approval + readiness before running.
decision = _require_lane_approval(cfg, entry, "exec", evidence) decision = _require_lane_approval(cfg, entry, "exec", evidence)
evidence.mark_approved(decision) evidence.mark_approved(decision)
require_delivery_state(cfg.evidence_dir, entry.id, "exec")
if not args.command: if not args.command:
from secrets_engine.errors import DeliveryError from secrets_engine.errors import DeliveryError
@ -611,16 +667,73 @@ def cmd_revoke(cfg: Config, args) -> int:
result = apply_lifecycle_plan(client, plan) result = apply_lifecycle_plan(client, plan)
print(plan.render()) print(plan.render())
print(result.render()) print(result.render())
record = save_lane_state(
cfg.evidence_dir, entry.id, "deactivated", operation="revoke"
)
evidence.finish( evidence.finish(
"native-access-deactivated", "native-access-deactivated",
detail={ detail={
"applied": list(result.applied), "applied": list(result.applied),
"preserved": list(result.preserved), "preserved": list(result.preserved),
"lane_state": record.state,
}, },
) )
return 0 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: def cmd_lifecycle(cfg: Config, args) -> int:
from secrets_engine.lifecycle import ( from secrets_engine.lifecycle import (
apply_lifecycle_plan, apply_lifecycle_plan,
@ -629,6 +742,8 @@ def cmd_lifecycle(cfg: Config, args) -> int:
) )
entry = get_entry(cfg.catalog_dir, args.catalog_id) 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) plan = build_lifecycle_plan(entry, args.operation)
if args.dry_run: if args.dry_run:
print(plan.render()) print(plan.render())
@ -655,15 +770,21 @@ def cmd_lifecycle(cfg: Config, args) -> int:
evidence.mark_approved(decision) evidence.mark_approved(decision)
with _open_backend(cfg, args, evidence) as client: with _open_backend(cfg, args, evidence) as client:
result = apply_lifecycle_plan(client, plan) 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(plan.render())
print(result.render()) print(result.render())
evidence.finish( detail = {
"applied", "applied": list(result.applied),
detail={ "preserved": list(result.preserved),
"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 return 0
@ -823,6 +944,15 @@ def build_parser() -> argparse.ArgumentParser:
add_token_arg(pr) add_token_arg(pr)
pr.set_defaults(func=cmd_provision) 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 = sub.add_parser("verify", help="positive/negative verification (no value printed)")
ve.add_argument("catalog_id") ve.add_argument("catalog_id")
ve.add_argument("--field", default=None) 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"), ("suspend", "stop new native logins; preserve policy and KV custody"),
("deactivate", "remove native AppRole/policy; preserve KV custody"), ("deactivate", "remove native AppRole/policy; preserve KV custody"),
("destroy", "irreversibly delete KV metadata after deactivation"), ("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 = lcsub.add_parser(operation, help=help_text)
lp.add_argument("catalog_id") lp.add_argument("catalog_id")
@ -919,9 +1051,22 @@ def build_parser() -> argparse.ArgumentParser:
default="", default="",
help="exact catalog id required for live irreversible deletion", 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) add_token_arg(lp)
lp.set_defaults(func=cmd_lifecycle) 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") su = sub.add_parser("secret-use", help="read-only secret-use evidence surface")
susub = su.add_subparsers(dest="subcmd", required=True) susub = su.add_subparsers(dest="subcmd", required=True)
snap = susub.add_parser( snap = susub.add_parser(

View file

@ -45,6 +45,9 @@ SHIPPED_RULES = (
"provision", "provision",
"session-revoke", "session-revoke",
"wrap", "wrap",
"rotate",
"lifecycle-compromise",
"lifecycle-reactivate",
), ),
"stages": ("prod",), "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)

91
tests/test_lane_state.py Normal file
View file

@ -0,0 +1,91 @@
import copy
from types import SimpleNamespace
import pytest
import yaml
from secrets_engine.catalog import validate_entry
from secrets_engine.config import Config
from secrets_engine.errors import DecisionError, PolicyGuardError
from secrets_engine.lane_state import (
load_lane_state,
require_delivery_state,
require_provision_state,
save_lane_state,
)
from tests.test_catalog import VALID
def _cfg(tmp_path):
return Config(
catalog_dir=tmp_path,
policy_dir=tmp_path,
evidence_dir=tmp_path / "evidence",
hub_url="",
bao_addr="http://127.0.0.1:8200",
topic_id="test-topic",
)
def test_missing_state_is_active(tmp_path):
record = load_lane_state(tmp_path / "evidence", "test-lane")
assert record.state == "active"
assert record.last_operation == ""
def test_compromise_blocks_delivery_and_provision(tmp_path):
evidence = tmp_path / "evidence"
save_lane_state(evidence, "test-lane", "compromised", operation="compromise")
with pytest.raises(DecisionError, match="compromised"):
require_delivery_state(evidence, "test-lane", "exec")
with pytest.raises(DecisionError, match="rotate"):
require_provision_state(evidence, "test-lane")
path = evidence / "lane-state" / "test-lane.yaml"
dumped = path.read_text(encoding="utf-8")
assert yaml.safe_load(dumped)["state"] == "compromised"
assert "npm_" not in dumped
def test_reason_rejects_secret_like_material(tmp_path):
with pytest.raises(PolicyGuardError, match="secret-like"):
save_lane_state(
tmp_path / "evidence",
"test-lane",
"compromised",
operation="compromise",
reason="npm_abcdefghijklmnop",
)
def test_exec_refuses_compromised_lane(tmp_path, monkeypatch):
from secrets_engine import cli
entry = validate_entry(copy.deepcopy(VALID))
save_lane_state(tmp_path / "evidence", entry.id, "compromised", operation="compromise")
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
cli.OpenBaoClient,
"resolve",
lambda *_args, **_kwargs: pytest.fail("backend must not be reached"),
)
args = SimpleNamespace(
catalog=entry.id,
field="api_token",
mode="exec-env",
command=["true"],
bootstrap_token_file=None,
auth="auto",
)
with pytest.raises(DecisionError, match="compromised"):
cli.cmd_exec(_cfg(tmp_path), args)
def test_state_show_defaults_active(tmp_path, monkeypatch):
from secrets_engine import cli
entry = validate_entry(copy.deepcopy(VALID))
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
args = SimpleNamespace(catalog_id=entry.id, json=True)
rc = cli.cmd_state_show(_cfg(tmp_path), args)
assert rc == 0

View file

@ -173,6 +173,8 @@ def test_classify_does_not_grant_permission():
heartbeat = classify("evidence-heartbeat", "prod") heartbeat = classify("evidence-heartbeat", "prod")
session_revoke = classify("session-revoke", "prod") session_revoke = classify("session-revoke", "prod")
wrap = classify("wrap", "prod") wrap = classify("wrap", "prod")
rotate = classify("rotate", "prod")
compromise = classify("lifecycle-compromise", "prod")
assert prod_provision.kind == "load-bearing" assert prod_provision.kind == "load-bearing"
assert test_provision.kind == "attributive" assert test_provision.kind == "attributive"
assert destroy.kind == "load-bearing" assert destroy.kind == "load-bearing"
@ -180,6 +182,8 @@ def test_classify_does_not_grant_permission():
assert heartbeat.kind == "heartbeat" assert heartbeat.kind == "heartbeat"
assert session_revoke.kind == "load-bearing" assert session_revoke.kind == "load-bearing"
assert wrap.kind == "load-bearing" assert wrap.kind == "load-bearing"
assert rotate.kind == "load-bearing"
assert compromise.kind == "load-bearing"
assert prod_provision.completeness_claimed is False assert prod_provision.completeness_claimed is False
assert CLASSIFICATION.exists() assert CLASSIFICATION.exists()

131
tests/test_rotate.py Normal file
View file

@ -0,0 +1,131 @@
import copy
from types import SimpleNamespace
import pytest
from secrets_engine.catalog import validate_entry
from secrets_engine.config import Config
from secrets_engine.errors import DecisionError, ProvisioningError
from secrets_engine.lane_state import load_lane_state, save_lane_state
from secrets_engine.rotate import render_rotate_plan, rotate_from_file
from tests.test_catalog import VALID
class RecordingPatchClient:
def __init__(self):
self.calls = []
def ensure_kv_mount(self, mount):
self.calls.append(("ensure", mount))
def kv_patch_fields(self, mount, path, values):
self.calls.append(("patch", mount, path, list(values)))
return 1
def _cfg(tmp_path):
return Config(
catalog_dir=tmp_path,
policy_dir=tmp_path,
evidence_dir=tmp_path / "evidence",
hub_url="",
bao_addr="http://127.0.0.1:8200",
topic_id="test-topic",
)
def test_rotate_plan_is_non_secret():
entry = validate_entry(copy.deepcopy(VALID))
text = render_rotate_plan(entry, "api_token")
assert "api_token" in text
assert "test/team/thing" in text
assert "SUPER" not in text
def test_rotate_patches_declared_field_only(tmp_path):
entry = validate_entry(copy.deepcopy(VALID))
value = tmp_path / "new.value"
value.write_text("replacement-value", encoding="utf-8")
value.chmod(0o600)
client = RecordingPatchClient()
field = rotate_from_file(client, entry, "api_token", value)
assert field == "api_token"
assert client.calls[-1][0] == "patch"
assert client.calls[-1][3] == ["api_token"]
def test_rotate_rejects_auth_capability():
from tests.test_auth_capability import AUTH
entry = validate_entry(copy.deepcopy(AUTH))
with pytest.raises(ProvisioningError, match="auth-capability"):
rotate_from_file(object(), entry, "api_token", __import__("pathlib").Path("/tmp/x"))
def test_rotate_allowed_when_compromised(tmp_path, monkeypatch):
from secrets_engine import cli
entry = validate_entry(copy.deepcopy(VALID))
save_lane_state(
tmp_path / "evidence", entry.id, "compromised", operation="compromise"
)
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.setattr(cli, "_require_lane_approval", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"secrets_engine.rotate.rotate_from_file",
lambda *_args, **_kwargs: "api_token",
)
class _Client:
pass
monkeypatch.setattr(
cli, "_open_backend", lambda *_a, **_k: __import__("contextlib").nullcontext(_Client())
)
args = SimpleNamespace(
catalog_id=entry.id,
stage=entry.stage,
field="api_token",
from_file="/tmp/new.value",
dry_run=False,
bootstrap_token_file=None,
auth="auto",
)
rc = cli.cmd_rotate(_cfg(tmp_path), args)
assert rc == 0
assert load_lane_state(tmp_path / "evidence", entry.id).state == "compromised"
assert load_lane_state(tmp_path / "evidence", entry.id).last_operation == "rotate"
def test_production_rotate_fails_closed(tmp_path, monkeypatch):
from secrets_engine import cli
data = copy.deepcopy(VALID)
data.update(stage="prod", approval={"model": "decision", "decision_ref": "x"})
entry = validate_entry(data)
monkeypatch.setattr(cli, "get_entry", lambda *_args: entry)
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
monkeypatch.setattr(
cli.OpenBaoClient,
"resolve",
lambda *_args, **_kwargs: pytest.fail("backend must not be reached"),
)
args = SimpleNamespace(
catalog_id=entry.id,
stage="prod",
field="api_token",
from_file="/tmp/new.value",
dry_run=False,
bootstrap_token_file=None,
auth="auto",
)
cfg = Config(
catalog_dir=tmp_path,
policy_dir=tmp_path,
evidence_dir=tmp_path / "evidence",
hub_url="http://127.0.0.1:8000",
bao_addr="http://127.0.0.1:8200",
topic_id="test-topic",
)
with pytest.raises(DecisionError, match="production action 'rotate'"):
cli.cmd_rotate(cfg, args)