From bc58184d71539cda9d3621dec103e8e071849975 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 23 Sep 2026 17:27:09 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 226514@bnt-lap001 Assistant-Session: 26ba103d-05fe-45a1-9cd7-9475bf239df6 --- SCOPE.md | 4 + docs/exec-owner-binding.md | 33 ++++ src/secrets_engine/catalog.py | 13 ++ src/secrets_engine/cli.py | 59 ++++-- src/secrets_engine/exec_delivery.py | 29 ++- src/secrets_engine/exec_owner.py | 63 +++++- tests/test_exec_delivery.py | 4 +- tests/test_exec_owner_companions.py | 181 ++++++++++++++++++ tests/test_integration_companions.py | 67 +++++++ ...-WP-0011-multi-lane-exec-owner-delivery.md | 25 ++- 10 files changed, 449 insertions(+), 29 deletions(-) create mode 100644 tests/test_exec_owner_companions.py create mode 100644 tests/test_integration_companions.py diff --git a/SCOPE.md b/SCOPE.md index 102211c..f48726e 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -114,6 +114,10 @@ operation or produce OpenBao audit-request correlation. exact-value redaction. - Undeclared fields and undeclared/unsupported exec modes are rejected before value fetch. +- A configured exec owner may also receive companion lanes. Each companion lane + must consent with `companion_of`, is gated through its own approval and + consume, and is read through its own AppRole. A refusal on any lane starts no + child (`docs/exec-owner-binding.md`). The OpenBao KV response is parsed in the parent process, so every field stored at the path crosses that process boundary even though only the selected field is diff --git a/docs/exec-owner-binding.md b/docs/exec-owner-binding.md index 2cfeb3f..60c6c7f 100644 --- a/docs/exec-owner-binding.md +++ b/docs/exec-owner-binding.md @@ -91,3 +91,36 @@ uv run --extra dev --with 'PyJWT[crypto]>=2.7,<3' python tools/exercise_approval Receipt: `docs/evidence/2026-09-10-exec-owner-approval-exercise.json`. Native custody, exact operator group/file delivery, real human/audit/service path, accepted factory configuration and paid execution remain open. + +## Companion lanes (SECRETS-WP-0011) + +A configured owner may list extra lanes to deliver alongside the primary field: + +```yaml +exec_owner: + status: configured + # command, cwd, environment, files as above + companions: + - {catalog: , field: , env: } +``` + +The companion lane must consent in its own catalog entry with +`delivery_config.companion_of: []`. It must be a `kv` lane in +the same stage, declare the field and `exec-env`, and bind no exec owner of its +own. Env names must be unique, must not match the fixed environment or the +primary field's name, and must not use loader or engine credential prefixes. +Pending owners cannot list companions. + +Companions are part of the owner binding, so changing a companion's lane, field +or env name changes the owner digest and invalidates earlier decisions. + +At exec time, companions are resolved before any approval is consumed. Each +lane then gets its own privileged evidence record, stance, approval and +consume for action `exec`. No lane's decision covers another lane. After every +gate passes, each value is read through its own lane's AppRole session. A +failure on any lane starts no child. The binding is checked again after the +reads, and all values are injected together and redacted from the output. + +Proof: `tests/test_exec_owner_companions.py`, plus +`tests/test_integration_companions.py` on a throwaway OpenBao (two lanes, one +owner, and the primary AppRole denied the companion path). diff --git a/src/secrets_engine/catalog.py b/src/secrets_engine/catalog.py index 09fd102..6e00aad 100644 --- a/src/secrets_engine/catalog.py +++ b/src/secrets_engine/catalog.py @@ -385,6 +385,19 @@ def validate_entry(data: dict[str, Any], *, source: str = "") -> Catalog if "exec-env" not in modes or set(modes) - {"exec-env", "read-check"}: raise CatalogError(f"{source}: exec_owner permits only exec-env and read-check") + companion_of = delivery_config.get("companion_of") + if companion_of is not None and ( + not isinstance(companion_of, list) + or not companion_of + or not all(isinstance(x, str) and x.strip() for x in companion_of) + or len(set(companion_of)) != len(companion_of) + or data["kind"] != "kv" + or "exec-env" not in modes + ): + raise CatalogError( + f"{source}: delivery_config.companion_of must list primary lane ids on a kv exec-env lane" + ) + # npm-config delivery must declare WHERE it publishes (registry + scope), so # the registry is catalog data, never hardcoded in the engine. if "npm-config" in modes: diff --git a/src/secrets_engine/cli.py b/src/secrets_engine/cli.py index 6ed4fc5..88c4bc0 100644 --- a/src/secrets_engine/cli.py +++ b/src/secrets_engine/cli.py @@ -546,32 +546,61 @@ def cmd_wrap(cfg: Config, args) -> int: def cmd_exec(cfg: Config, args) -> int: + from contextlib import ExitStack + from secrets_engine.exec_delivery import exec_with_secret - from secrets_engine.exec_owner import validate_delivery_target + from secrets_engine.exec_owner import resolve_companions, validate_delivery_target entry = get_entry(cfg.catalog_dir, args.catalog) field = args.field or (entry.fields[0] if entry.fields else "") session_detail: dict[str, object] = {} + companion_sessions: dict[str, dict[str, object]] = {} # Refuse a substituted recipient before consuming approval or opening Bao. owner_digest = validate_delivery_target(entry, field, args.command, args.mode) + companions = resolve_companions(entry, lambda cid: get_entry(cfg.catalog_dir, cid)) command_name = args.command[0] if args.command else "" - with _privileged_evidence( - cfg, - entry, - "exec", - detail={ - "command": command_name, - "mode": args.mode, - "field": field, - "session": session_detail, - "exec_owner_sha256": owner_digest, - }, - ) as evidence: + with ExitStack() as stack: + evidence = stack.enter_context(_privileged_evidence( + cfg, + entry, + "exec", + detail={ + "command": command_name, + "mode": args.mode, + "field": field, + "session": session_detail, + "exec_owner_sha256": owner_digest, + "companions": [lane.id for lane, _, _ in companions], + }, + )) # require approval + readiness before running. decision = _require_lane_approval( cfg, entry, "exec", evidence, fields=(field,) if field else () ) evidence.mark_approved(decision) require_delivery_state(cfg.evidence_dir, entry.id, "exec") + # Each companion read is its own protected action: its own evidence, + # stance, approval and consume. No lane's decision covers another. + companion_evidence = [] + for lane, lane_field, _env in companions: + lane_evidence = stack.enter_context(_privileged_evidence( + cfg, + lane, + "exec", + detail={ + "command": command_name, + "mode": "exec-env", + "field": lane_field, + "companion_of": entry.id, + "session": companion_sessions.setdefault(lane.id, {}), + "exec_owner_sha256": owner_digest, + }, + )) + lane_decision = _require_lane_approval( + cfg, lane, "exec", lane_evidence, fields=(lane_field,) + ) + lane_evidence.mark_approved(lane_decision) + require_delivery_state(cfg.evidence_dir, lane.id, "exec") + companion_evidence.append(lane_evidence) if not args.command: from secrets_engine.errors import DeliveryError @@ -585,7 +614,11 @@ def cmd_exec(cfg: Config, args) -> int: mode=args.mode, session_evidence=session_detail, expected_owner_digest=owner_digest, + companions=companions, + companion_sessions=companion_sessions, ) + for lane_evidence in companion_evidence: + lane_evidence.finish(f"exit-{rc}") evidence.finish(f"exit-{rc}") return rc diff --git a/src/secrets_engine/exec_delivery.py b/src/secrets_engine/exec_delivery.py index 1dcc444..f86c2b1 100644 --- a/src/secrets_engine/exec_delivery.py +++ b/src/secrets_engine/exec_delivery.py @@ -164,11 +164,11 @@ def _secret_file(value: str) -> Iterator[Path]: _unlink_secret_file(path) -def _stream_redacted(proc: subprocess.Popen, secret: str) -> None: +def _stream_redacted(proc: subprocess.Popen, secrets: list[str]) -> None: """Stream child output through the redactor (backstop).""" assert proc.stdout is not None for line in proc.stdout: - sys.stdout.write(redact_text(line, extra=[secret])) + sys.stdout.write(redact_text(line, extra=secrets)) sys.stdout.flush() @@ -182,6 +182,8 @@ def exec_with_secret( policy_dir=None, session_evidence: dict[str, object] | None = None, expected_owner_digest: str | None = None, + companions: list[tuple[CatalogEntry, str, str]] = (), + companion_sessions: dict[str, dict[str, object]] | None = None, ) -> int: """Run `command` with the lane's secret injected for the child only. @@ -225,6 +227,16 @@ def exec_with_secret( value = _fetch_value( client, entry, field, session_evidence=session_evidence ) + if companions and (binding_digest is None or mode != "exec-env"): + raise DeliveryError("companion delivery requires a configured exec owner and exec-env") + # Every lane is read through its own AppRole session; any failure raises + # before a child exists, and the values already read go out of scope. + extra: dict[str, str] = {} + for lane, lane_field, env_name in companions: + lane_session: dict[str, object] = ( + companion_sessions.setdefault(lane.id, {}) if companion_sessions is not None else {} + ) + extra[env_name] = _fetch_value(client, lane, lane_field, session_evidence=lane_session) # Recheck after retrieval too: a config changed during auth/read cannot be # launched with a value authorized for the previous recipient. if validate_delivery_target(entry, field, command, mode) != binding_digest: @@ -245,27 +257,28 @@ def exec_with_secret( with _npm_userconfig(registry, scope, token_env) as npmrc: child_env["NPM_CONFIG_USERCONFIG"] = str(npmrc) child_env[token_env] = value - rc = _spawn(command, child_env, value) + rc = _spawn(command, child_env, [value]) return rc if mode == "exec-env": # Inject under a conventional name derived from the field. env_name = field.upper() child_env[env_name] = value + child_env.update(extra) if binding is not None: - return _spawn(command, child_env, value, cwd=binding["cwd"]) - return _spawn(command, child_env, value) + return _spawn(command, child_env, [value, *extra.values()], cwd=binding["cwd"]) + return _spawn(command, child_env, [value]) if mode == "exec-file": env_name = f"{field.upper()}_FILE" with _secret_file(value) as secret_path: child_env[env_name] = str(secret_path) - return _spawn(command, child_env, value) + return _spawn(command, child_env, [value]) raise DeliveryError(f"unsupported delivery mode '{mode}'") -def _spawn(command: list[str], env: dict[str, str], secret: str, *, cwd: str | None = None) -> int: +def _spawn(command: list[str], env: dict[str, str], secrets: list[str], *, cwd: str | None = None) -> int: """Spawn the child, stream redacted output, propagate signals, ensure cleanup.""" try: proc = subprocess.Popen( @@ -286,7 +299,7 @@ def _spawn(command: list[str], env: dict[str, str], secret: str, *, cwd: str | N old_int = signal.signal(signal.SIGINT, _forward) old_term = signal.signal(signal.SIGTERM, _forward) try: - _stream_redacted(proc, secret) + _stream_redacted(proc, secrets) return proc.wait() finally: signal.signal(signal.SIGINT, old_int) diff --git a/src/secrets_engine/exec_owner.py b/src/secrets_engine/exec_owner.py index 6e02f0a..19e8139 100644 --- a/src/secrets_engine/exec_owner.py +++ b/src/secrets_engine/exec_owner.py @@ -32,7 +32,7 @@ def validate_exec_owner(value: object) -> dict[str, Any]: raise CatalogError("pending exec_owner requires only status, owner and reason") return value required = {"status", "owner", "command", "cwd", "environment", "files"} - if value.get("status") != "configured" or set(value) != required: + if value.get("status") != "configured" or not required <= set(value) <= required | {"companions"}: raise CatalogError("configured exec_owner requires exact command/cwd/environment/files") command = value["command"] if not isinstance(command, list) or not command or not all(_text(x) for x in command) or not _absolute(command[0]): @@ -60,9 +60,66 @@ def validate_exec_owner(value: object) -> dict[str, Any]: for arg in command[1:]: if arg.startswith("/") and arg not in files: raise CatalogError("exec_owner absolute file arguments must have file pins") + _validate_companions(value.get("companions", []), env) return value +def _validate_companions(companions: object, env: dict[str, str]) -> None: + """Structural check only; cross-lane checks need the catalog (resolve_companions).""" + if not isinstance(companions, list): + raise CatalogError("exec_owner companions must be a list") + names: set[str] = set() + lanes: set[tuple[str, str]] = set() + for spec in companions: + if not isinstance(spec, dict) or set(spec) != {"catalog", "field", "env"} or not all( + _text(spec[k]) for k in ("catalog", "field", "env") + ): + raise CatalogError("exec_owner companion requires exactly catalog, field and env") + name = spec["env"] + if not re.fullmatch(r"[A-Z_][A-Z0-9_]*", name) or name.startswith( + ("LD_", "DYLD_", "PYTHON", "BAO_", "VAULT_", "SECRETS_ENGINE_") + ): + raise CatalogError("exec_owner companion env name is invalid or forbidden") + if name in env or name in names or (spec["catalog"], spec["field"]) in lanes: + raise CatalogError("exec_owner companion env or lane field is duplicated") + names.add(name) + lanes.add((spec["catalog"], spec["field"])) + + +def companion_specs(entry) -> list[dict[str, str]]: + binding = owner_binding(entry) + if binding is None or binding.get("status") != "configured": + return [] + return list(binding.get("companions", [])) + + +def resolve_companions(entry, lookup) -> list[tuple[Any, str, str]]: + """Resolve each companion to (lane, field, env). ``lookup`` maps id -> entry. + + A companion lane must consent by naming this primary in its own + ``delivery_config.companion_of``, so no owner can list another team's lane + by itself. Runs before any approval is consumed or OpenBao is opened. + """ + resolved = [] + for spec in companion_specs(entry): + if spec["catalog"] == entry.id: + raise DeliveryError("exec owner companion must be a different lane") + try: + lane = lookup(spec["catalog"]) + except Exception as exc: + raise DeliveryError(f"exec owner companion lane '{spec['catalog']}' is unavailable") from exc + if lane.kind != "kv" or lane.stage != entry.stage: + raise DeliveryError(f"companion lane '{lane.id}' must be a kv lane in stage '{entry.stage}'") + if spec["field"] not in lane.fields or "exec-env" not in lane.delivery_modes: + raise DeliveryError(f"companion lane '{lane.id}' does not declare exec-env delivery of that field") + if entry.id not in (lane.delivery_config.get("companion_of") or []): + raise DeliveryError(f"companion lane '{lane.id}' does not consent to delivery with '{entry.id}'") + if "exec_owner" in lane.delivery_config: + raise DeliveryError(f"companion lane '{lane.id}' must not bind its own exec owner") + resolved.append((lane, spec["field"], spec["env"])) + return resolved + + def owner_binding(entry) -> dict[str, Any] | None: config = entry.delivery_config if "exec_owner" not in config: @@ -102,7 +159,9 @@ def validate_delivery_target(entry, field: str, command: list[str], mode: str) - raise DeliveryError("exec owner binding is pending; no delivery is admitted") if mode not in {"auto", "exec-env"} or "exec-env" not in entry.delivery_modes: raise DeliveryError("exec owner requires exec-env delivery") - if field not in entry.fields or field.upper() in binding["environment"]: + if field not in entry.fields or field.upper() in binding["environment"] or field.upper() in { + spec["env"] for spec in binding.get("companions", []) + }: raise DeliveryError("exec owner field is undeclared or conflicts with fixed environment") if command != binding["command"]: raise DeliveryError("command differs from the catalog-bound exec owner") diff --git a/tests/test_exec_delivery.py b/tests/test_exec_delivery.py index b14e1f8..b1c46e3 100644 --- a/tests/test_exec_delivery.py +++ b/tests/test_exec_delivery.py @@ -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) diff --git a/tests/test_exec_owner_companions.py b/tests/test_exec_owner_companions.py new file mode 100644 index 0000000..cd2d94d --- /dev/null +++ b/tests/test_exec_owner_companions.py @@ -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")) diff --git a/tests/test_integration_companions.py b/tests/test_integration_companions.py new file mode 100644 index 0000000..a824c89 --- /dev/null +++ b/tests/test_integration_companions.py @@ -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 diff --git a/workplans/SECRETS-WP-0011-multi-lane-exec-owner-delivery.md b/workplans/SECRETS-WP-0011-multi-lane-exec-owner-delivery.md index c32b4f0..6a6dd68 100644 --- a/workplans/SECRETS-WP-0011-multi-lane-exec-owner-delivery.md +++ b/workplans/SECRETS-WP-0011-multi-lane-exec-owner-delivery.md @@ -4,7 +4,7 @@ type: workplan title: "Multi-lane exec-owner delivery" domain: infotech repo: secrets-engine -status: ready +status: active flavor: implementation owner: claude-code topic_slug: netkingdom @@ -13,6 +13,7 @@ updated: "2026-09-23" related_workplans: - SECRETS-WP-0009 - HFACT-WP-0001 +state_hub_workstream_id: "ecb0643d-bad7-5d63-b340-e77ab9579b0a" --- Demand: SECRETS-WP-0009-T03. A configured `exec_owner` child today receives @@ -45,8 +46,9 @@ Invariants carried over unchanged: ```task id: SECRETS-WP-0011-T01 -status: todo +status: done priority: high +state_hub_task_id: "820514c9-f54d-530e-a45c-9a04486831c2" ``` Extend `exec_owner` with `companions: [{catalog, field, env}]`. Validate at @@ -60,8 +62,9 @@ exec-time delivery section. ```task id: SECRETS-WP-0011-T02 -status: todo +status: done priority: high +state_hub_task_id: "33d0fc37-5f23-53a6-9456-f8bd1cc984f9" ``` Run the existing approval-claim / CheckRequest / consume chain once per lane, @@ -74,8 +77,9 @@ session cleanup. Any failure starts no child and reports which lane refused. ```task id: SECRETS-WP-0011-T03 -status: todo +status: done priority: high +state_hub_task_id: "52a0ff40-81d6-589a-b05f-3e5f55f81a81" ``` Unit tests: validation, digest coverage, collision, stage mismatch, partial @@ -90,6 +94,7 @@ id: SECRETS-WP-0011-T04 status: wait priority: high blocking_reason: "Needs activity-core owner assent to source ACTIVITY_CORE_WORKER_TOKEN from OpenBao (ESO) instead of a hand-generated Kubernetes Secret." +state_hub_task_id: "cf465065-de7a-5d9c-bc80-fee16ffef70d" ``` Request activity-core to move the worker token into OpenBao custody, synced to @@ -98,3 +103,15 @@ catalog a read lane for the metered owner and add it as the Glas lane's companion. This coordinates with activity-core's multi-worker request (hub message `914853d9`): a dedicated metered worker token would use the same lane shape. + +## Implementation return — 2026-09-23 + +T01–T03 done. `exec_owner.companions` and `delivery_config.companion_of` are +validated at catalog load. `resolve_companions` runs before any gate, and +`cmd_exec` gates each lane separately (its own evidence, stance, approval and +consume) before opening the backend. `exec_with_secret` reads each lane through +its own AppRole, starts no child if any lane fails, re-checks the binding, and +redacts every value. Suite: 465 passed. Integration on a throwaway OpenBao: 2 +passed (two lanes to one owner; the primary AppRole is denied the companion +path). Contract: `docs/exec-owner-binding.md` § Companion lanes. T04 waits on +activity-core (hub message `6e694682`).