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
137 lines
4.6 KiB
Python
137 lines
4.6 KiB
Python
"""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)
|