Add companion lanes to catalog-bound exec owners (SECRETS-WP-0011 T01-T03)
A configured exec owner may receive fields from other consenting kv lanes. Each lane is gated, consumed and read through its own AppRole; any refusal starts no child. Companions are part of the owner digest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 226514@bnt-lap001 Assistant-Session: 26ba103d-05fe-45a1-9cd7-9475bf239df6
This commit is contained in:
parent
452203b19b
commit
bc58184d71
10 changed files with 449 additions and 29 deletions
|
|
@ -48,7 +48,7 @@ def test_exec_env_injects_only_selected_declared_field(monkeypatch):
|
|||
|
||||
def fake_spawn(command, env, secret):
|
||||
assert command == ["probe"]
|
||||
assert secret == "test-secret-value"
|
||||
assert secret == ["test-secret-value"]
|
||||
assert env["SELECTED_VALUE"] == "test-secret-value"
|
||||
assert "PRIMARY" not in env
|
||||
return 0
|
||||
|
|
@ -81,7 +81,7 @@ def test_exec_file_injects_path_not_value_and_unlinks(monkeypatch, tmp_path):
|
|||
seen["contents"] = __import__("pathlib").Path(path).read_text()
|
||||
seen["has_value_env"] = "API_TOKEN" in env
|
||||
assert command == ["probe"]
|
||||
assert secret == "test-secret-value"
|
||||
assert secret == ["test-secret-value"]
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("secrets_engine.exec_delivery._fetch_value", fake_fetch)
|
||||
|
|
|
|||
181
tests/test_exec_owner_companions.py
Normal file
181
tests/test_exec_owner_companions.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import copy
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine import cli, exec_delivery
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.errors import CatalogError, DeliveryError
|
||||
from secrets_engine.exec_owner import owner_digest, resolve_companions
|
||||
from tests.test_catalog import VALID
|
||||
from tests.test_exec_owner import bound # noqa: F401 (fixture)
|
||||
from tests.test_lane_state import _cfg
|
||||
|
||||
|
||||
def _companion(primary_id="test-lane", **changes):
|
||||
data = copy.deepcopy(VALID)
|
||||
data.update(id="test-worker", path="test/team/worker", fields=["worker_token"])
|
||||
data["delivery_config"] = {"companion_of": [primary_id]}
|
||||
data.update(changes)
|
||||
return data
|
||||
|
||||
|
||||
def _with_companion(data, env="WORKER_TOKEN", field="worker_token", catalog="test-worker"):
|
||||
data["delivery_config"]["exec_owner"]["companions"] = [
|
||||
{"catalog": catalog, "field": field, "env": env}
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
def _lookup(*entries):
|
||||
table = {e.id: e for e in entries}
|
||||
return lambda cid: table[cid]
|
||||
|
||||
|
||||
def test_companion_value_reaches_bound_child_and_is_redacted(bound, monkeypatch, capsys):
|
||||
data, _, script = bound
|
||||
script.write_text('''test "$API_TOKEN" = synthetic-owner-value || exit 10
|
||||
test "$WORKER_TOKEN" = synthetic-worker-value || exit 11
|
||||
read ignored && exit 15
|
||||
printf '%s %s\\n' "$API_TOKEN" "$WORKER_TOKEN"
|
||||
printf 'companion-child-ok\\n'
|
||||
''')
|
||||
import hashlib
|
||||
data["delivery_config"]["exec_owner"]["files"][str(script)]["sha256"] = hashlib.sha256(script.read_bytes()).hexdigest()
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane = validate_entry(_companion())
|
||||
companions = resolve_companions(entry, _lookup(lane))
|
||||
values = {"test-lane": "synthetic-owner-value", "test-worker": "synthetic-worker-value"}
|
||||
seen = []
|
||||
def fetch(client, e, field, **kwargs):
|
||||
seen.append((e.id, field))
|
||||
return values[e.id]
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", fetch)
|
||||
sessions = {}
|
||||
rc = exec_delivery.exec_with_secret(
|
||||
object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"],
|
||||
mode="exec-env", expected_owner_digest=owner_digest(entry),
|
||||
companions=companions, companion_sessions=sessions,
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0 and "companion-child-ok" in out
|
||||
assert "synthetic-owner-value" not in out and "synthetic-worker-value" not in out
|
||||
assert seen == [("test-lane", "api_token"), ("test-worker", "worker_token")]
|
||||
assert set(sessions) == {"test-worker"}
|
||||
|
||||
|
||||
def test_companion_fetch_failure_starts_no_child(bound, monkeypatch):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane = validate_entry(_companion())
|
||||
def fetch(client, e, field, **kwargs):
|
||||
if e.id == "test-worker":
|
||||
raise DeliveryError("scoped read failed for lane 'test-worker' (denied or absent)")
|
||||
return "synthetic-owner-value"
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", fetch)
|
||||
monkeypatch.setattr(exec_delivery, "_spawn", lambda *a, **k: pytest.fail("must not launch"))
|
||||
with pytest.raises(DeliveryError, match="test-worker"):
|
||||
exec_delivery.exec_with_secret(
|
||||
object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"],
|
||||
mode="exec-env", companions=resolve_companions(entry, _lookup(lane)),
|
||||
)
|
||||
|
||||
|
||||
def test_companions_are_part_of_the_owner_digest(bound):
|
||||
data, _, _ = bound
|
||||
before = owner_digest(validate_entry(copy.deepcopy(data)))
|
||||
assert owner_digest(validate_entry(_with_companion(copy.deepcopy(data)))) != before
|
||||
changed = owner_digest(validate_entry(_with_companion(copy.deepcopy(data), env="OTHER_TOKEN")))
|
||||
assert changed != owner_digest(validate_entry(_with_companion(copy.deepcopy(data))))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["extra_key", "bad_env", "fixed_env", "forbidden_env", "duplicate", "not_list"])
|
||||
def test_invalid_companion_spec_is_a_catalog_error(bound, change):
|
||||
data, _, _ = bound
|
||||
binding = _with_companion(data)["delivery_config"]["exec_owner"]
|
||||
spec = binding["companions"][0]
|
||||
if change == "extra_key": spec["note"] = "x"
|
||||
elif change == "bad_env": spec["env"] = "worker-token"
|
||||
elif change == "fixed_env": spec["env"] = "LANG"
|
||||
elif change == "forbidden_env": spec["env"] = "BAO_TOKEN"
|
||||
elif change == "duplicate": binding["companions"].append(dict(spec))
|
||||
else: binding["companions"] = {"catalog": "test-worker"}
|
||||
with pytest.raises(CatalogError):
|
||||
validate_entry(data)
|
||||
|
||||
|
||||
def test_primary_field_env_cannot_collide_with_companion(bound, monkeypatch):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data, env="API_TOKEN"))
|
||||
monkeypatch.setattr(exec_delivery, "_fetch_value", lambda *a, **k: pytest.fail("must refuse before read"))
|
||||
with pytest.raises(DeliveryError, match="conflicts"):
|
||||
exec_delivery.exec_with_secret(object(), entry, "api_token", data["delivery_config"]["exec_owner"]["command"], mode="exec-env")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["no_consent", "other_primary", "stage", "field", "mode", "own_owner", "self"])
|
||||
def test_companion_resolution_refuses(bound, change):
|
||||
data, _, _ = bound
|
||||
lane_data = _companion()
|
||||
catalog = "test-worker"
|
||||
if change == "no_consent": lane_data["delivery_config"] = {}
|
||||
elif change == "other_primary": lane_data["delivery_config"] = {"companion_of": ["another-lane"]}
|
||||
elif change == "stage": lane_data.update(stage="build", path="build/team/worker")
|
||||
elif change == "field": lane_data["fields"] = ["other"]
|
||||
elif change == "mode": lane_data["delivery_modes"] = ["read-check"]; lane_data["delivery_config"] = {}
|
||||
elif change == "own_owner": lane_data["delivery_config"]["exec_owner"] = {"status": "pending", "owner": "x", "reason": "y"}
|
||||
else: catalog = "test-lane"
|
||||
entry = validate_entry(_with_companion(data, catalog=catalog))
|
||||
lane = validate_entry(lane_data)
|
||||
with pytest.raises(DeliveryError):
|
||||
resolve_companions(entry, _lookup(lane, entry))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [[], "test-lane", ["test-lane", "test-lane"], [""]])
|
||||
def test_companion_of_must_be_a_nonempty_unique_list(value):
|
||||
lane_data = _companion()
|
||||
lane_data["delivery_config"] = {"companion_of": value}
|
||||
with pytest.raises(CatalogError):
|
||||
validate_entry(lane_data)
|
||||
|
||||
|
||||
def test_companion_of_requires_exec_env():
|
||||
lane_data = _companion()
|
||||
lane_data["delivery_modes"] = ["read-check"]
|
||||
with pytest.raises(CatalogError):
|
||||
validate_entry(lane_data)
|
||||
|
||||
|
||||
def test_each_lane_is_gated_separately_before_backend(bound, monkeypatch, tmp_path):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
lane = validate_entry(_companion())
|
||||
table = {entry.id: entry, lane.id: lane}
|
||||
monkeypatch.setattr(cli, "get_entry", lambda _dir, cid: table[cid])
|
||||
gated = []
|
||||
def gate(cfg, e, action, evidence, *, fields=()):
|
||||
gated.append((e.id, action, fields))
|
||||
if e.id == "test-worker":
|
||||
raise DeliveryError("companion lane denied")
|
||||
return SimpleNamespace(status="approved")
|
||||
monkeypatch.setattr(cli, "_require_lane_approval", gate)
|
||||
monkeypatch.setattr(cli, "require_delivery_state", lambda *a: None)
|
||||
monkeypatch.setattr(cli, "_open_backend", lambda *a, **k: pytest.fail("no backend after a lane refusal"))
|
||||
command = data["delivery_config"]["exec_owner"]["command"]
|
||||
with pytest.raises(DeliveryError, match="companion lane denied"):
|
||||
cli.cmd_exec(_cfg(tmp_path), SimpleNamespace(field=None, catalog=entry.id, command=command, mode="exec-env"))
|
||||
assert gated == [("test-lane", "exec", ("api_token",)), ("test-worker", "exec", ("worker_token",))]
|
||||
|
||||
|
||||
def test_unresolvable_companion_refuses_before_any_gate(bound, monkeypatch, tmp_path):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(data))
|
||||
def get(_dir, cid):
|
||||
if cid == entry.id:
|
||||
return entry
|
||||
raise KeyError(cid)
|
||||
monkeypatch.setattr(cli, "get_entry", get)
|
||||
monkeypatch.setattr(cli, "_require_lane_approval", lambda *a, **k: pytest.fail("no gate"))
|
||||
monkeypatch.setattr(cli, "_open_backend", lambda *a, **k: pytest.fail("no backend"))
|
||||
command = data["delivery_config"]["exec_owner"]["command"]
|
||||
with pytest.raises(DeliveryError, match="unavailable"):
|
||||
cli.cmd_exec(_cfg(tmp_path), SimpleNamespace(field=None, catalog=entry.id, command=command, mode="exec-env"))
|
||||
67
tests/test_integration_companions.py
Normal file
67
tests/test_integration_companions.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Two cataloged KV lanes delivered to one bound owner on a throwaway OpenBao.
|
||||
|
||||
Each lane gets its own policy and AppRole, and each value is read through that
|
||||
lane's own AppRole session. The child sees both values; the output does not.
|
||||
"""
|
||||
import copy
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from secrets_engine.apply import apply_plan
|
||||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.exec_delivery import exec_with_secret
|
||||
from secrets_engine.exec_owner import owner_digest, resolve_companions
|
||||
from secrets_engine.plan import build_plan
|
||||
from secrets_engine.provision import provision_from_file
|
||||
from tests.test_exec_owner import bound # noqa: F401 (fixture)
|
||||
from tests.test_exec_owner_companions import _companion, _lookup, _with_companion
|
||||
from tests.test_integration_bao import bao_dev, pytestmark # noqa: F401
|
||||
|
||||
|
||||
def _provision(client, entry, field, value, tmp_path):
|
||||
source = tmp_path / f"{entry.id}.value"
|
||||
source.write_text(value)
|
||||
os.chmod(source, 0o600)
|
||||
apply_plan(client, entry, build_plan(entry, entry.stage, decision_id="test"))
|
||||
provision_from_file(client, entry, field, source)
|
||||
|
||||
|
||||
def test_two_lanes_reach_one_bound_owner(bao_dev, bound, tmp_path, capsys):
|
||||
data, _, script = bound
|
||||
script.write_text('''test "$API_TOKEN" = primary-integration-value || exit 10
|
||||
test "$WORKER_TOKEN" = worker-integration-value || exit 11
|
||||
test -z "$BAO_TOKEN" || exit 12
|
||||
read ignored && exit 15
|
||||
printf '%s %s\\n' "$API_TOKEN" "$WORKER_TOKEN"
|
||||
printf 'two-lane-child-ok\\n'
|
||||
''')
|
||||
data["delivery_config"]["exec_owner"]["files"][str(script)]["sha256"] = hashlib.sha256(
|
||||
script.read_bytes()
|
||||
).hexdigest()
|
||||
entry = validate_entry(_with_companion(copy.deepcopy(data)))
|
||||
lane = validate_entry(_companion())
|
||||
_provision(bao_dev, entry, "api_token", "primary-integration-value", tmp_path)
|
||||
_provision(bao_dev, lane, "worker_token", "worker-integration-value", tmp_path)
|
||||
|
||||
sessions, primary_session = {}, {}
|
||||
rc = exec_with_secret(
|
||||
bao_dev, entry, "api_token", data["delivery_config"]["exec_owner"]["command"],
|
||||
mode="exec-env", session_evidence=primary_session,
|
||||
expected_owner_digest=owner_digest(entry),
|
||||
companions=resolve_companions(entry, _lookup(lane)), companion_sessions=sessions,
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert rc == 0 and "two-lane-child-ok" in out
|
||||
assert "primary-integration-value" not in out and "worker-integration-value" not in out
|
||||
assert primary_session.get("established") and sessions["test-worker"].get("established")
|
||||
|
||||
|
||||
def test_primary_approle_cannot_read_companion_path(bao_dev, bound, tmp_path):
|
||||
data, _, _ = bound
|
||||
entry = validate_entry(_with_companion(copy.deepcopy(data)))
|
||||
lane = validate_entry(_companion())
|
||||
_provision(bao_dev, entry, "api_token", "primary-integration-value", tmp_path)
|
||||
_provision(bao_dev, lane, "worker_token", "worker-integration-value", tmp_path)
|
||||
with bao_dev.approle_session(entry.role_name) as session:
|
||||
proc = session.client._run(["kv", "get", "-format=json", f"{lane.mount}/{lane.path}"])
|
||||
assert proc.returncode != 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue