Add response-wrapped operator handoff
secrets-engine wrap writes a single-use OpenBao wrap token to a mode-0600 out-of-repo file and never prints it. KV reads and AppRole secret_ids are wrapped with a 15m TTL cap. Unwrapped secret payloads fail closed. Production wrap remains fail-closed. Assistant: grok Assistant-Session: 01a05f07-ae72-7781-9fcb-19efd61add00
This commit is contained in:
parent
2278cefbb3
commit
afd1c8e593
12 changed files with 387 additions and 8 deletions
160
tests/test_wrap.py
Normal file
160
tests/test_wrap.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import copy
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.config import Config
|
||||
from secrets_engine.errors import BackendError, DecisionError, ProvisioningError
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.wrap import normalize_wrap_ttl, write_wrapped_handoff
|
||||
from tests.test_catalog import VALID
|
||||
|
||||
WRAP_TOKEN = "wrap-token-SUPER-SECRET"
|
||||
WRAP_ACCESSOR = "wrap-accessor-value"
|
||||
|
||||
|
||||
class FakeWrapClient:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def wrap_kv_get(self, mount, path, *, ttl):
|
||||
from secrets_engine.openbao import WrappedResponse, accessor_fingerprint
|
||||
|
||||
self.calls.append(("kv", mount, path, ttl))
|
||||
return WrappedResponse(
|
||||
wrap_token=WRAP_TOKEN,
|
||||
accessor_fingerprint=accessor_fingerprint(WRAP_ACCESSOR),
|
||||
ttl=ttl,
|
||||
creation_path=f"{mount}/{path}",
|
||||
)
|
||||
|
||||
def wrap_approle_secret_id(self, role_name, *, ttl):
|
||||
from secrets_engine.openbao import WrappedResponse, accessor_fingerprint
|
||||
|
||||
self.calls.append(("approle", role_name, ttl))
|
||||
return WrappedResponse(
|
||||
wrap_token=WRAP_TOKEN,
|
||||
accessor_fingerprint=accessor_fingerprint(WRAP_ACCESSOR),
|
||||
ttl=ttl,
|
||||
creation_path=f"auth/approle/role/{role_name}/secret-id",
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_wrap_ttl_bounds():
|
||||
assert normalize_wrap_ttl("15m") == "15m"
|
||||
assert normalize_wrap_ttl("60s") == "60s"
|
||||
with pytest.raises(ProvisioningError, match="15m"):
|
||||
normalize_wrap_ttl("16m")
|
||||
with pytest.raises(ProvisioningError, match="like"):
|
||||
normalize_wrap_ttl("15")
|
||||
|
||||
|
||||
def test_wrap_kv_writes_0600_file_and_omits_token_from_result(tmp_path):
|
||||
out = tmp_path / "wrap.token"
|
||||
entry = validate_entry(copy.deepcopy(VALID))
|
||||
result = write_wrapped_handoff(
|
||||
FakeWrapClient(), entry, out_file=out, ttl="15m"
|
||||
)
|
||||
assert out.read_text().strip() == WRAP_TOKEN
|
||||
assert (out.stat().st_mode & 0o077) == 0
|
||||
assert result.wrap_handle
|
||||
assert WRAP_TOKEN not in result.wrap_handle
|
||||
assert WRAP_TOKEN not in result.out_file
|
||||
assert WRAP_ACCESSOR not in result.wrap_handle
|
||||
|
||||
|
||||
def test_wrap_rejects_repo_paths():
|
||||
entry = validate_entry(copy.deepcopy(VALID))
|
||||
with pytest.raises(ProvisioningError, match="Git worktree"):
|
||||
write_wrapped_handoff(
|
||||
FakeWrapClient(),
|
||||
entry,
|
||||
out_file=__import__("pathlib").Path("wrap.token"),
|
||||
ttl="15m",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_wrap_rejects_unwrapped_secret_payload():
|
||||
client = OpenBaoClient(addr="http://example.invalid", token="t", bao_bin="bao")
|
||||
secret_json = json.dumps(
|
||||
{"data": {"data": {"api_token": "npm_SHOULDNEVERPARSE"}}}
|
||||
)
|
||||
with pytest.raises(BackendError, match="was not applied") as raised:
|
||||
client._parse_wrap_response(
|
||||
secret_json, ttl="15m", creation_path="secret/x"
|
||||
)
|
||||
assert "npm_SHOULDNEVERPARSE" not in str(raised.value)
|
||||
|
||||
|
||||
def test_parse_wrap_accepts_wrap_info_only():
|
||||
client = OpenBaoClient(addr="http://example.invalid", token="t", bao_bin="bao")
|
||||
payload = json.dumps(
|
||||
{
|
||||
"wrap_info": {
|
||||
"token": WRAP_TOKEN,
|
||||
"accessor": WRAP_ACCESSOR,
|
||||
"ttl": 900,
|
||||
}
|
||||
}
|
||||
)
|
||||
wrapped = client._parse_wrap_response(
|
||||
payload, ttl="15m", creation_path="secret/x"
|
||||
)
|
||||
assert wrapped.wrap_token == WRAP_TOKEN
|
||||
assert WRAP_ACCESSOR not in wrapped.accessor_fingerprint
|
||||
assert WRAP_TOKEN not in repr(wrapped)
|
||||
|
||||
|
||||
def test_wrap_kv_get_uses_wrap_ttl_flag(monkeypatch):
|
||||
client = OpenBaoClient(addr="http://example.invalid", token="t", bao_bin="bao")
|
||||
seen = {}
|
||||
|
||||
def fake_run(args, stdin=None):
|
||||
seen["args"] = list(args)
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=json.dumps(
|
||||
{"wrap_info": {"token": WRAP_TOKEN, "accessor": WRAP_ACCESSOR}}
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(client, "_run", fake_run)
|
||||
wrapped = client.wrap_kv_get("secret", "test/team/thing", ttl="15m")
|
||||
assert "-wrap-ttl=15m" in seen["args"]
|
||||
assert WRAP_TOKEN not in " ".join(seen["args"])
|
||||
assert wrapped.wrap_token == WRAP_TOKEN
|
||||
|
||||
|
||||
def test_production_wrap_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"),
|
||||
)
|
||||
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",
|
||||
)
|
||||
args = SimpleNamespace(
|
||||
catalog_id=entry.id,
|
||||
out=str(tmp_path / "wrap.token"),
|
||||
ttl="15m",
|
||||
bootstrap_token_file=None,
|
||||
auth="auto",
|
||||
)
|
||||
with pytest.raises(DecisionError, match="production action 'wrap'"):
|
||||
cli.cmd_wrap(cfg, args)
|
||||
Loading…
Add table
Add a link
Reference in a new issue