feat(WARDEN-WP-0014): T3 — OpenBao proxy lane (--fetch / --exec)

Adds transparent, policy-gated, audited proxy of a non-SSH credential
through `warden access`, for exec_capable lanes. Three guardrails in code:

- G1 caller identity: runs the owner's tool with the caller's own env;
  warden injects no token of its own (caller_auth_present check).
- G2 transit-only: --fetch inherits stdout (never PIPE) so the value
  never enters warden's memory or any log; --exec injects into the child
  env only. Audit (access-audit.log) is metadata-only.
- G3 policy gate: check_fetch_policy runs before any fetch; with
  policy.enabled=false the proxy refuses unless --no-policy is given.

resolve_fetch_command refuses unresolved <…> placeholders rather than
guess owner-side names. New warden/proxy.py + policy.check_fetch_policy;
tests/test_proxy.py asserts all three guardrails. 168 passed, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 16:26:03 +02:00
parent 830a775bcf
commit 6dfa69e310
5 changed files with 588 additions and 11 deletions

View File

@@ -88,6 +88,64 @@ def check_sign_policy(cfg: PolicyConfig, spec: CertSpec) -> str | None:
reason = decision.get("reason") or "no reason provided"
raise CAError(f"flex-auth denied SSH sign for {spec.actor_name!r}: {reason}")
if not decision_id:
raise CAError("flex-auth allow decision missing id")
return str(decision_id)
def check_fetch_policy(
cfg: PolicyConfig, *, need_id: str, owner_repo: str, domain: str | None
) -> str | None:
"""Call flex-auth /v1/check before proxying a non-SSH credential fetch (WP-0014).
The action is ``read`` on a ``secret`` resource owned by another subsystem —
ops-warden is the conduit, not the owner. Returns the decision id on allow,
None when policy is disabled, and raises CAError on deny (or on an unreachable
flex-auth when fail_closed). No secret value is ever part of this request.
"""
if not cfg.enabled:
return None
subject_id = os.environ.get(cfg.subject_env, "").strip() or "operator"
request = {
"subject": {"id": subject_id, "type": "operator", "tenant": cfg.tenant},
"action": "read",
"resource": {
"id": f"secret:{need_id}" + (f"/{domain}" if domain else ""),
"type": "secret",
"system": owner_repo,
"tenant": cfg.tenant,
},
"context": {"need_id": need_id, "owner_repo": owner_repo, "domain": domain},
}
url = cfg.flex_auth_url.rstrip("/") + "/v1/check"
try:
response = httpx.post(url, json=request, timeout=10.0)
response.raise_for_status()
except httpx.HTTPStatusError as e:
if cfg.fail_closed:
raise CAError(
f"flex-auth denied or rejected fetch policy check (HTTP {e.response.status_code})"
) from e
return None
except httpx.RequestError as e:
if cfg.fail_closed:
raise CAError(
f"flex-auth unreachable at {cfg.flex_auth_url!r} (fail_closed=true): {e}"
) from e
return None
try:
decision = response.json()
except ValueError as e:
raise CAError("flex-auth returned non-JSON decision") from e
effect = str(decision.get("effect", "")).lower()
decision_id = decision.get("id") or decision.get("request_id")
if effect != "allow":
reason = decision.get("reason") or "no reason provided"
raise CAError(f"flex-auth denied secret read for {need_id!r}: {reason}")
if not decision_id:
raise CAError("flex-auth allow decision missing id")
return str(decision_id)