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
91
tests/test_lane_state.py
Normal file
91
tests/test_lane_state.py
Normal 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
|
||||
|
|
@ -173,6 +173,8 @@ def test_classify_does_not_grant_permission():
|
|||
heartbeat = classify("evidence-heartbeat", "prod")
|
||||
session_revoke = classify("session-revoke", "prod")
|
||||
wrap = classify("wrap", "prod")
|
||||
rotate = classify("rotate", "prod")
|
||||
compromise = classify("lifecycle-compromise", "prod")
|
||||
assert prod_provision.kind == "load-bearing"
|
||||
assert test_provision.kind == "attributive"
|
||||
assert destroy.kind == "load-bearing"
|
||||
|
|
@ -180,6 +182,8 @@ def test_classify_does_not_grant_permission():
|
|||
assert heartbeat.kind == "heartbeat"
|
||||
assert session_revoke.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 CLASSIFICATION.exists()
|
||||
|
||||
|
|
|
|||
131
tests/test_rotate.py
Normal file
131
tests/test_rotate.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue