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
4
SCOPE.md
4
SCOPE.md
|
|
@ -114,6 +114,10 @@ operation or produce OpenBao audit-request correlation.
|
||||||
exact-value redaction.
|
exact-value redaction.
|
||||||
- Undeclared fields and undeclared/unsupported exec modes are rejected before
|
- Undeclared fields and undeclared/unsupported exec modes are rejected before
|
||||||
value fetch.
|
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
|
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
|
at the path crosses that process boundary even though only the selected field is
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
Receipt: `docs/evidence/2026-09-10-exec-owner-approval-exercise.json`.
|
||||||
Native custody, exact operator group/file delivery, real human/audit/service path,
|
Native custody, exact operator group/file delivery, real human/audit/service path,
|
||||||
accepted factory configuration and paid execution remain open.
|
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: <kv-lane-id>, field: <declared-field>, env: <ENV_NAME>}
|
||||||
|
```
|
||||||
|
|
||||||
|
The companion lane must consent in its own catalog entry with
|
||||||
|
`delivery_config.companion_of: [<primary-lane-id>]`. 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).
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,19 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
|
||||||
if "exec-env" not in modes or set(modes) - {"exec-env", "read-check"}:
|
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")
|
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
|
# npm-config delivery must declare WHERE it publishes (registry + scope), so
|
||||||
# the registry is catalog data, never hardcoded in the engine.
|
# the registry is catalog data, never hardcoded in the engine.
|
||||||
if "npm-config" in modes:
|
if "npm-config" in modes:
|
||||||
|
|
|
||||||
|
|
@ -546,15 +546,20 @@ def cmd_wrap(cfg: Config, args) -> int:
|
||||||
|
|
||||||
|
|
||||||
def cmd_exec(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_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)
|
entry = get_entry(cfg.catalog_dir, args.catalog)
|
||||||
field = args.field or (entry.fields[0] if entry.fields else "")
|
field = args.field or (entry.fields[0] if entry.fields else "")
|
||||||
session_detail: dict[str, object] = {}
|
session_detail: dict[str, object] = {}
|
||||||
|
companion_sessions: dict[str, dict[str, object]] = {}
|
||||||
# Refuse a substituted recipient before consuming approval or opening Bao.
|
# Refuse a substituted recipient before consuming approval or opening Bao.
|
||||||
owner_digest = validate_delivery_target(entry, field, args.command, args.mode)
|
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 ""
|
command_name = args.command[0] if args.command else ""
|
||||||
with _privileged_evidence(
|
with ExitStack() as stack:
|
||||||
|
evidence = stack.enter_context(_privileged_evidence(
|
||||||
cfg,
|
cfg,
|
||||||
entry,
|
entry,
|
||||||
"exec",
|
"exec",
|
||||||
|
|
@ -564,14 +569,38 @@ def cmd_exec(cfg: Config, args) -> int:
|
||||||
"field": field,
|
"field": field,
|
||||||
"session": session_detail,
|
"session": session_detail,
|
||||||
"exec_owner_sha256": owner_digest,
|
"exec_owner_sha256": owner_digest,
|
||||||
|
"companions": [lane.id for lane, _, _ in companions],
|
||||||
},
|
},
|
||||||
) as evidence:
|
))
|
||||||
# require approval + readiness before running.
|
# require approval + readiness before running.
|
||||||
decision = _require_lane_approval(
|
decision = _require_lane_approval(
|
||||||
cfg, entry, "exec", evidence, fields=(field,) if field else ()
|
cfg, entry, "exec", evidence, fields=(field,) if field else ()
|
||||||
)
|
)
|
||||||
evidence.mark_approved(decision)
|
evidence.mark_approved(decision)
|
||||||
require_delivery_state(cfg.evidence_dir, entry.id, "exec")
|
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:
|
if not args.command:
|
||||||
from secrets_engine.errors import DeliveryError
|
from secrets_engine.errors import DeliveryError
|
||||||
|
|
||||||
|
|
@ -585,7 +614,11 @@ def cmd_exec(cfg: Config, args) -> int:
|
||||||
mode=args.mode,
|
mode=args.mode,
|
||||||
session_evidence=session_detail,
|
session_evidence=session_detail,
|
||||||
expected_owner_digest=owner_digest,
|
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}")
|
evidence.finish(f"exit-{rc}")
|
||||||
return rc
|
return rc
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,11 +164,11 @@ def _secret_file(value: str) -> Iterator[Path]:
|
||||||
_unlink_secret_file(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)."""
|
"""Stream child output through the redactor (backstop)."""
|
||||||
assert proc.stdout is not None
|
assert proc.stdout is not None
|
||||||
for line in proc.stdout:
|
for line in proc.stdout:
|
||||||
sys.stdout.write(redact_text(line, extra=[secret]))
|
sys.stdout.write(redact_text(line, extra=secrets))
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -182,6 +182,8 @@ def exec_with_secret(
|
||||||
policy_dir=None,
|
policy_dir=None,
|
||||||
session_evidence: dict[str, object] | None = None,
|
session_evidence: dict[str, object] | None = None,
|
||||||
expected_owner_digest: str | None = None,
|
expected_owner_digest: str | None = None,
|
||||||
|
companions: list[tuple[CatalogEntry, str, str]] = (),
|
||||||
|
companion_sessions: dict[str, dict[str, object]] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run `command` with the lane's secret injected for the child only.
|
"""Run `command` with the lane's secret injected for the child only.
|
||||||
|
|
||||||
|
|
@ -225,6 +227,16 @@ def exec_with_secret(
|
||||||
value = _fetch_value(
|
value = _fetch_value(
|
||||||
client, entry, field, session_evidence=session_evidence
|
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
|
# Recheck after retrieval too: a config changed during auth/read cannot be
|
||||||
# launched with a value authorized for the previous recipient.
|
# launched with a value authorized for the previous recipient.
|
||||||
if validate_delivery_target(entry, field, command, mode) != binding_digest:
|
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:
|
with _npm_userconfig(registry, scope, token_env) as npmrc:
|
||||||
child_env["NPM_CONFIG_USERCONFIG"] = str(npmrc)
|
child_env["NPM_CONFIG_USERCONFIG"] = str(npmrc)
|
||||||
child_env[token_env] = value
|
child_env[token_env] = value
|
||||||
rc = _spawn(command, child_env, value)
|
rc = _spawn(command, child_env, [value])
|
||||||
return rc
|
return rc
|
||||||
|
|
||||||
if mode == "exec-env":
|
if mode == "exec-env":
|
||||||
# Inject under a conventional name derived from the field.
|
# Inject under a conventional name derived from the field.
|
||||||
env_name = field.upper()
|
env_name = field.upper()
|
||||||
child_env[env_name] = value
|
child_env[env_name] = value
|
||||||
|
child_env.update(extra)
|
||||||
if binding is not None:
|
if binding is not None:
|
||||||
return _spawn(command, child_env, value, cwd=binding["cwd"])
|
return _spawn(command, child_env, [value, *extra.values()], cwd=binding["cwd"])
|
||||||
return _spawn(command, child_env, value)
|
return _spawn(command, child_env, [value])
|
||||||
|
|
||||||
if mode == "exec-file":
|
if mode == "exec-file":
|
||||||
env_name = f"{field.upper()}_FILE"
|
env_name = f"{field.upper()}_FILE"
|
||||||
with _secret_file(value) as secret_path:
|
with _secret_file(value) as secret_path:
|
||||||
child_env[env_name] = str(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}'")
|
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."""
|
"""Spawn the child, stream redacted output, propagate signals, ensure cleanup."""
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
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_int = signal.signal(signal.SIGINT, _forward)
|
||||||
old_term = signal.signal(signal.SIGTERM, _forward)
|
old_term = signal.signal(signal.SIGTERM, _forward)
|
||||||
try:
|
try:
|
||||||
_stream_redacted(proc, secret)
|
_stream_redacted(proc, secrets)
|
||||||
return proc.wait()
|
return proc.wait()
|
||||||
finally:
|
finally:
|
||||||
signal.signal(signal.SIGINT, old_int)
|
signal.signal(signal.SIGINT, old_int)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ def validate_exec_owner(value: object) -> dict[str, Any]:
|
||||||
raise CatalogError("pending exec_owner requires only status, owner and reason")
|
raise CatalogError("pending exec_owner requires only status, owner and reason")
|
||||||
return value
|
return value
|
||||||
required = {"status", "owner", "command", "cwd", "environment", "files"}
|
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")
|
raise CatalogError("configured exec_owner requires exact command/cwd/environment/files")
|
||||||
command = value["command"]
|
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]):
|
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:]:
|
for arg in command[1:]:
|
||||||
if arg.startswith("/") and arg not in files:
|
if arg.startswith("/") and arg not in files:
|
||||||
raise CatalogError("exec_owner absolute file arguments must have file pins")
|
raise CatalogError("exec_owner absolute file arguments must have file pins")
|
||||||
|
_validate_companions(value.get("companions", []), env)
|
||||||
return value
|
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:
|
def owner_binding(entry) -> dict[str, Any] | None:
|
||||||
config = entry.delivery_config
|
config = entry.delivery_config
|
||||||
if "exec_owner" not in 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")
|
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:
|
if mode not in {"auto", "exec-env"} or "exec-env" not in entry.delivery_modes:
|
||||||
raise DeliveryError("exec owner requires exec-env delivery")
|
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")
|
raise DeliveryError("exec owner field is undeclared or conflicts with fixed environment")
|
||||||
if command != binding["command"]:
|
if command != binding["command"]:
|
||||||
raise DeliveryError("command differs from the catalog-bound exec owner")
|
raise DeliveryError("command differs from the catalog-bound exec owner")
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ def test_exec_env_injects_only_selected_declared_field(monkeypatch):
|
||||||
|
|
||||||
def fake_spawn(command, env, secret):
|
def fake_spawn(command, env, secret):
|
||||||
assert command == ["probe"]
|
assert command == ["probe"]
|
||||||
assert secret == "test-secret-value"
|
assert secret == ["test-secret-value"]
|
||||||
assert env["SELECTED_VALUE"] == "test-secret-value"
|
assert env["SELECTED_VALUE"] == "test-secret-value"
|
||||||
assert "PRIMARY" not in env
|
assert "PRIMARY" not in env
|
||||||
return 0
|
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["contents"] = __import__("pathlib").Path(path).read_text()
|
||||||
seen["has_value_env"] = "API_TOKEN" in env
|
seen["has_value_env"] = "API_TOKEN" in env
|
||||||
assert command == ["probe"]
|
assert command == ["probe"]
|
||||||
assert secret == "test-secret-value"
|
assert secret == ["test-secret-value"]
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr("secrets_engine.exec_delivery._fetch_value", fake_fetch)
|
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
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Multi-lane exec-owner delivery"
|
title: "Multi-lane exec-owner delivery"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: secrets-engine
|
repo: secrets-engine
|
||||||
status: ready
|
status: active
|
||||||
flavor: implementation
|
flavor: implementation
|
||||||
owner: claude-code
|
owner: claude-code
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
|
|
@ -13,6 +13,7 @@ updated: "2026-09-23"
|
||||||
related_workplans:
|
related_workplans:
|
||||||
- SECRETS-WP-0009
|
- SECRETS-WP-0009
|
||||||
- HFACT-WP-0001
|
- HFACT-WP-0001
|
||||||
|
state_hub_workstream_id: "ecb0643d-bad7-5d63-b340-e77ab9579b0a"
|
||||||
---
|
---
|
||||||
|
|
||||||
Demand: SECRETS-WP-0009-T03. A configured `exec_owner` child today receives
|
Demand: SECRETS-WP-0009-T03. A configured `exec_owner` child today receives
|
||||||
|
|
@ -45,8 +46,9 @@ Invariants carried over unchanged:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0011-T01
|
id: SECRETS-WP-0011-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "820514c9-f54d-530e-a45c-9a04486831c2"
|
||||||
```
|
```
|
||||||
|
|
||||||
Extend `exec_owner` with `companions: [{catalog, field, env}]`. Validate at
|
Extend `exec_owner` with `companions: [{catalog, field, env}]`. Validate at
|
||||||
|
|
@ -60,8 +62,9 @@ exec-time delivery section.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0011-T02
|
id: SECRETS-WP-0011-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "33d0fc37-5f23-53a6-9456-f8bd1cc984f9"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the existing approval-claim / CheckRequest / consume chain once per lane,
|
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
|
```task
|
||||||
id: SECRETS-WP-0011-T03
|
id: SECRETS-WP-0011-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "52a0ff40-81d6-589a-b05f-3e5f55f81a81"
|
||||||
```
|
```
|
||||||
|
|
||||||
Unit tests: validation, digest coverage, collision, stage mismatch, partial
|
Unit tests: validation, digest coverage, collision, stage mismatch, partial
|
||||||
|
|
@ -90,6 +94,7 @@ id: SECRETS-WP-0011-T04
|
||||||
status: wait
|
status: wait
|
||||||
priority: high
|
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."
|
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
|
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
|
companion. This coordinates with activity-core's multi-worker request (hub
|
||||||
message `914853d9`): a dedicated metered worker token would use the same lane
|
message `914853d9`): a dedicated metered worker token would use the same lane
|
||||||
shape.
|
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`).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue