From 8dbf62515a4e6b18d0b67beaa475a4747bf75cc7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 14 Oct 2026 18:21:02 +0200 Subject: [PATCH] Show the OpenBao identity warden access evaluates reuse-surface-hub-write-token does not select a caller. The data plane uses the ambient token, which can be the coding-agent role denied by agent-high-risk-boundary while an attended platform-admin lookup still shows read. Advisory and proxy output now report that entity, role, and policies without the token. CCR-2026-0005 still has no OIDC binding for workload-kv-read-reuse-surface-runtime. Assistant: grok Assistant-Session: 01a13b2d-0bc1-7a91-ab1d-325bcfb09bf0 --- registry/routing/catalog.yaml | 8 +- src/warden/cli.py | 58 ++++++++ src/warden/openbao_caller.py | 137 ++++++++++++++++++ tests/test_access.py | 25 ++++ tests/test_openbao_caller.py | 125 ++++++++++++++++ .../reuse-surface-hub-write-token.md | 15 ++ 6 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 src/warden/openbao_caller.py create mode 100644 tests/test_openbao_caller.py diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 8c0781b..b8c2e12 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -663,7 +663,13 @@ entries: # reuse/reuse-surface-runtime SecretSynced to reuse-surface-env on Railiance01; # positive + negative access verified. Production consumer is ESO; warden access # proxies reads as the caller and never holds the value. - auth_method: "caller's own OpenBao token (operator OIDC via key-cape, or a token carrying workload-kv-read-reuse-surface-runtime)" + # Data-plane identity is whichever token the OpenBao CLI resolves + # (BAO_TOKEN, then VAULT_TOKEN, then ~/.vault-token). That is not the + # flex-auth caller. CCR-2026-0005 does not bind an OIDC role to + # workload-kv-read-reuse-surface-runtime; the only live binding is the + # ESO Kubernetes role reuse-surface-runtime-eso. platform-admin must not + # receive the scoped policy. See the playbook. + auth_method: "ambient OpenBao token (BAO_TOKEN, then VAULT_TOKEN, then ~/.vault-token); no OIDC role binds workload-kv-read-reuse-surface-runtime" path_template: "platform/workloads/reuse/reuse-surface/runtime-secrets" fetch_command: "bao kv get -field=REUSE_SURFACE_TOKEN platform/workloads/reuse/reuse-surface/runtime-secrets" policy_ref: "flex-auth check secret.read:reuse" diff --git a/src/warden/cli.py b/src/warden/cli.py index 09eca15..d6c13d5 100644 --- a/src/warden/cli.py +++ b/src/warden/cli.py @@ -1172,6 +1172,41 @@ def route_find( # warden access — operator front door (advisory; proxy lands in T3) # --------------------------------------------------------------------------- +def _openbao_caller_view(entry) -> Optional[dict]: + """Sanitized data-plane identity. Absent for lanes that do not use it.""" + if entry.warden_executes or entry.lane == "login" or not entry.exec_capable: + return None + from warden.openbao_caller import caller_boundary_note, describe_ambient_openbao_caller + + described = describe_ambient_openbao_caller() + note = caller_boundary_note(described, high_risk=bool(entry.is_high_risk)) + if note: + described = {**described, "boundary_note": note} + return described + + +def _print_openbao_caller(entry) -> None: + described = _openbao_caller_view(entry) + if described is None: + return + if described.get("error"): + console.print( + f" openbao : [yellow]{described['source']}[/yellow] — {described['error']}" + ) + return + policies = ", ".join(described.get("policies") or []) or "(none)" + role = described.get("oidc_role") or "(none)" + console.print( + f" openbao : {described['source']} entity {described.get('entity_id') or '(none)'}" + ) + console.print(f" role : {role} policies: {policies}") + if described.get("boundary_note"): + console.print(f" caller : [yellow]{described['boundary_note']}[/yellow]") + console.print( + " gate id : [dim]flex-auth uses policy.caller_auth, not this OpenBao entity[/dim]" + ) + + def _access_json(entry, expanded, gate: str, domain: Optional[str]) -> dict: """Stable, secret-free JSON shape for agentic operators. WP-0014 T2.""" payload = _entry_summary(entry) @@ -1184,6 +1219,9 @@ def _access_json(entry, expanded, gate: str, domain: Optional[str]) -> dict: "policy_ref": expanded.policy_ref, "exec_capable": expanded.exec_capable, } + caller = _openbao_caller_view(entry) + if caller is not None: + payload["openbao_caller"] = caller if entry.warden_executes: payload["next_action"] = "ops-warden issues this directly — see cert_command" payload["cert_command"] = entry.cert_command @@ -1372,6 +1410,25 @@ def _access_proxy( ) raise typer.Exit(6) + # After the refusals above, so a denied gate does not look like a fetch. + # The lookup uses the OpenBao CLI and must not run on those paths. + if not is_login: + caller = _openbao_caller_view(entry) + if caller is not None: + if caller.get("error"): + err.print( + f"[yellow]OpenBao caller:[/yellow] {caller['source']} — {caller['error']}" + ) + else: + err.print( + "[dim]OpenBao caller[/dim] " + f"{caller['source']} entity {caller.get('entity_id') or '(none)'} " + f"role {caller.get('oidc_role') or '(none)'} " + f"policies {', '.join(caller.get('policies') or []) or '(none)'}" + ) + if caller.get("boundary_note"): + err.print(f"[yellow]{caller['boundary_note']}[/yellow]") + action = "login" if is_login else ("exec" if do_exec else "fetch") err.print( f"[dim]proxy {action}: {entry.id} → {entry.owner_repo} " @@ -1566,6 +1623,7 @@ def access( if expanded.auth_method: console.print(f" auth : {expanded.auth_method}") + _print_openbao_caller(entry) if expanded.path_template: console.print(f" path : {expanded.path_template}") if expanded.fetch_command and entry.lane != "login": diff --git a/src/warden/openbao_caller.py b/src/warden/openbao_caller.py new file mode 100644 index 0000000..548c151 --- /dev/null +++ b/src/warden/openbao_caller.py @@ -0,0 +1,137 @@ +"""Sanitized view of the OpenBao identity a proxied fetch will use. + +``warden access`` does not select a caller. The data-plane command inherits the +process environment, and the OpenBao CLI then resolves a token in this order: +``BAO_TOKEN``, ``VAULT_TOKEN``, then ``~/.vault-token``. That identity is +independent of the flex-auth caller (``policy.caller_auth``). + +The lookup response contains the token itself. This module keeps only the +fields an operator needs to see which principal will be evaluated. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Mapping, Optional, Sequence + +_TOKEN_KEYS = ("BAO_TOKEN", "VAULT_TOKEN") +_DROPPED = {"id", "accessor", "client_token", "token", "wrap_info"} + + +def ambient_token_source(env: Optional[Mapping[str, str]] = None) -> str: + """Which credential the OpenBao CLI will use. Never reads the value.""" + current = os.environ if env is None else env + if current.get("BAO_TOKEN", "").strip(): + return "BAO_TOKEN" + if current.get("VAULT_TOKEN", "").strip(): + return "VAULT_TOKEN" + if (Path.home() / ".vault-token").is_file(): + return "vault-token-file" + return "none" + + +def describe_ambient_openbao_caller( + env: Optional[Mapping[str, str]] = None, + *, + command: Sequence[str] = ("bao", "token", "lookup", "-format=json"), + timeout: float = 10.0, +) -> dict: + """Return entity, policies, and auth path for the ambient token. + + ``id`` and ``accessor`` from the lookup payload are dropped. On failure the + result carries ``error`` and no raw CLI output. + """ + current = os.environ if env is None else env + source = ambient_token_source(current) + described = { + "source": source, + "entity_id": None, + "display_name": None, + "auth_path": None, + "policies": [], + "oidc_role": None, + "ttl": None, + "error": None, + } + if source == "none": + described["error"] = "no ambient OpenBao token" + return described + + try: + completed = subprocess.run( # noqa: S603 — fixed argv, caller environment + list(command), + capture_output=True, + text=True, + timeout=timeout, + env=dict(current), + check=False, + ) + except FileNotFoundError: + described["error"] = "bao client not found" + return described + except subprocess.TimeoutExpired: + described["error"] = "token lookup timed out" + return described + except OSError: + described["error"] = "token lookup could not start" + return described + + if completed.returncode != 0: + described["error"] = "token lookup failed" + return described + + try: + payload = json.loads(completed.stdout or "") + except json.JSONDecodeError: + described["error"] = "token lookup returned non-JSON" + return described + + data = payload.get("data") if isinstance(payload, dict) else None + if not isinstance(data, dict): + described["error"] = "token lookup returned no data" + return described + + meta = data.get("meta") if isinstance(data.get("meta"), dict) else {} + role = meta.get("role") + policies = data.get("policies") + described.update( + { + "entity_id": _text(data.get("entity_id")), + "display_name": _text(data.get("display_name")), + "auth_path": _text(data.get("path")), + "policies": [str(item) for item in policies] if isinstance(policies, list) else [], + "oidc_role": role if isinstance(role, str) and role else None, + "ttl": data.get("ttl") if isinstance(data.get("ttl"), int) else None, + "error": None, + } + ) + # Guard the contract even if a future field is copied by mistake. + for key in _DROPPED: + described.pop(key, None) + return described + + +def caller_boundary_note(described: Mapping[str, object], *, high_risk: bool) -> Optional[str]: + """One line when this token cannot be the lane's scoped reader.""" + policies = described.get("policies") + names = set(policies) if isinstance(policies, list) else set() + if high_risk and "agent-high-risk-boundary" in names: + return ( + "this token carries agent-high-risk-boundary, which denies data read " + "on high-risk lanes; a platform-admin capabilities check is a different caller" + ) + if "platform-admin" in names: + return ( + "this token is the broad platform-admin role; do not attach the lane's " + "scoped read policy to this entity" + ) + return None + + +def _text(value: object) -> Optional[str]: + if isinstance(value, str) and value.strip(): + return value + return None diff --git a/tests/test_access.py b/tests/test_access.py index 5e10a7d..c4286aa 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -93,6 +93,31 @@ def test_operator_subject_does_not_override_legacy_agent_marker(): # --- CLI ------------------------------------------------------------------- +def test_access_advisory_shows_ambient_openbao_caller(monkeypatch): + monkeypatch.setenv("WARDEN_ROUTING_CATALOG", str(_repo_catalog())) + + def described(env=None, **kwargs): + return { + "source": "BAO_TOKEN", + "entity_id": "4a1f663c-0621-0483-edd0-f852274f4b6b", + "display_name": "platform-root", + "auth_path": "auth/netkingdom/oidc/callback", + "policies": ["agent-high-risk-boundary"], + "oidc_role": "coding-agent", + "ttl": 900, + "error": None, + } + + monkeypatch.setattr("warden.openbao_caller.describe_ambient_openbao_caller", described) + r = runner.invoke(app, ["access", "reuse-surface hub write token", "--json"]) + assert r.exit_code == 0 + payload = json.loads(r.stdout) + caller = payload["openbao_caller"] + assert caller["entity_id"] == "4a1f663c-0621-0483-edd0-f852274f4b6b" + assert "agent-high-risk-boundary" in caller["boundary_note"] + assert "id" not in caller + + def test_access_advisory_output(monkeypatch): monkeypatch.setenv("WARDEN_ROUTING_CATALOG", str(_repo_catalog())) r = runner.invoke(app, ["access", "npm token", "--domain", "coulomb_social"]) diff --git a/tests/test_openbao_caller.py b/tests/test_openbao_caller.py new file mode 100644 index 0000000..6533380 --- /dev/null +++ b/tests/test_openbao_caller.py @@ -0,0 +1,125 @@ +"""Sanitized ambient OpenBao caller identity.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from warden.openbao_caller import ( + ambient_token_source, + caller_boundary_note, + describe_ambient_openbao_caller, +) + + +def test_source_prefers_bao_token_over_vault_and_file(monkeypatch, tmp_path): + monkeypatch.setenv("BAO_TOKEN", "hvs.bao") + monkeypatch.setenv("VAULT_TOKEN", "hvs.vault") + monkeypatch.setattr(Path, "home", lambda: tmp_path) + (tmp_path / ".vault-token").write_text("file-token") + assert ambient_token_source() == "BAO_TOKEN" + + +def test_source_uses_vault_token_file_when_env_absent(monkeypatch, tmp_path): + monkeypatch.delenv("BAO_TOKEN", raising=False) + monkeypatch.delenv("VAULT_TOKEN", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + (tmp_path / ".vault-token").write_text("file-token") + assert ambient_token_source() == "vault-token-file" + + +def test_lookup_drops_token_fields(monkeypatch): + secret = "hvs.this-must-not-appear" + monkeypatch.setenv("BAO_TOKEN", secret) + payload = { + "data": { + "id": secret, + "accessor": "also-secret", + "entity_id": "4a1f663c-0621-0483-edd0-f852274f4b6b", + "display_name": "netkingdom-uid=platform-root", + "path": "auth/netkingdom/oidc/callback", + "policies": ["default", "operator-custody", "platform-admin"], + "meta": {"role": "platform-admin", "email": "hidden@example.invalid"}, + "ttl": 3001, + } + } + + def runner(argv, **kwargs): + class Completed: + returncode = 0 + stdout = json.dumps(payload) + stderr = "" + + assert kwargs["env"]["BAO_TOKEN"] == secret + return Completed() + + monkeypatch.setattr("warden.openbao_caller.subprocess.run", runner) + described = describe_ambient_openbao_caller() + encoded = json.dumps(described) + assert secret not in encoded + assert "accessor" not in described + assert "email" not in encoded + assert described["entity_id"].startswith("4a1f663c-") + assert described["oidc_role"] == "platform-admin" + assert described["policies"] == ["default", "operator-custody", "platform-admin"] + assert described["source"] == "BAO_TOKEN" + assert described["error"] is None + + +def test_lookup_failure_has_no_raw_output(monkeypatch): + monkeypatch.setenv("VAULT_TOKEN", "hvs.caller") + monkeypatch.delenv("BAO_TOKEN", raising=False) + + def runner(argv, **kwargs): + class Completed: + returncode = 2 + stdout = "" + stderr = "Error: permission denied hvs.caller" + + return Completed() + + monkeypatch.setattr("warden.openbao_caller.subprocess.run", runner) + described = describe_ambient_openbao_caller() + assert described["error"] == "token lookup failed" + assert described["entity_id"] is None + assert "hvs.caller" not in json.dumps(described) + + +def test_missing_client(monkeypatch): + monkeypatch.setenv("BAO_TOKEN", "hvs.bao") + + def runner(argv, **kwargs): + raise FileNotFoundError(argv[0]) + + monkeypatch.setattr("warden.openbao_caller.subprocess.run", runner) + assert describe_ambient_openbao_caller()["error"] == "bao client not found" + + +def test_no_token(monkeypatch, tmp_path): + monkeypatch.delenv("BAO_TOKEN", raising=False) + monkeypatch.delenv("VAULT_TOKEN", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + described = describe_ambient_openbao_caller() + assert described["source"] == "none" + assert described["error"] == "no ambient OpenBao token" + + +@pytest.mark.parametrize( + ("policies", "high_risk", "fragment"), + [ + ( + ["agent-high-risk-boundary", "workload-kv-read-issue-core-runtime"], + True, + "agent-high-risk-boundary", + ), + (["default", "platform-admin"], True, "platform-admin"), + (["workload-kv-read-reuse-surface-runtime"], True, None), + ], +) +def test_boundary_note(policies, high_risk, fragment): + note = caller_boundary_note({"policies": policies}, high_risk=high_risk) + if fragment is None: + assert note is None + else: + assert fragment in note diff --git a/wiki/playbooks/reuse-surface-hub-write-token.md b/wiki/playbooks/reuse-surface-hub-write-token.md index 8907aa0..64cd555 100644 --- a/wiki/playbooks/reuse-surface-hub-write-token.md +++ b/wiki/playbooks/reuse-surface-hub-write-token.md @@ -28,6 +28,21 @@ ops-warden does not vend this token — custody belongs to `railiance-platform` `SecretSynced`, positive hub + webhook verification and negative default-policy denial recorded in CCR-2026-0005. +### Which identity this route evaluates + +`warden access` does not pick a principal. Two identities are involved, and they are not the same: + +| Plane | Identity | +| --- | --- | +| OpenBao data read | The ambient token: `BAO_TOKEN`, else `VAULT_TOKEN`, else `~/.vault-token`. `warden access` prints the entity, OIDC role, and policies before a proxy. | +| flex-auth gate | `policy.caller_auth` in `warden.yaml`. On this workstation that mints a short-lived token for ServiceAccount `ops-warden/ops-warden`. An allow here does not mean OpenBao allowed the read. | + +A capabilities check on one shell and a proxy in another can disagree. The OpenBao CLI in the proxy uses that process's ambient token. An attended `platform-admin` login (entity `4a1f663c-0621-0483-edd0-f852274f4b6b`, policies `default`, `operator-custody`, `platform-admin`) can show `read` on both exact paths while `auth/netkingdom/role/coding-agent` is denied: that role's `agent-high-risk-boundary` policy denies data read on this path. The 2026-10-14 hub registration attempt was that shape. PDP decision `f730e3da14beebdd` allowed the fetch; OpenBao then returned HTTP 403 and the helper did not run. + +Do not attach `workload-kv-read-reuse-surface-runtime` to the platform-admin entity. CCR-2026-0005 does not name that caller. The only live binding is Kubernetes role `auth/kubernetes/role/reuse-surface-runtime-eso` (ServiceAccount `reuse/reuse-surface-eso`, audience `openbao`) with policy `workload-kv-read-reuse-surface-runtime-eso`: data read plus token self lookup and revoke. The caller-facing policy file allows read of the exact data and metadata paths and is not attached to any `auth/netkingdom` role. + +A dedicated caller is a new `auth/netkingdom` OIDC role in railiance-platform, same shape as the other `workload-kv-read` roles: `token_policies` only `workload-kv-read-reuse-surface-runtime`, `token_no_default_policy` true, and a sibling path still denied. Platform-operator and reuse-surface-owner approve the named principal first. ops-warden does not apply that binding. + ### Break-glass (cluster read) If OpenBao is unreachable, operators with Railiance01 kubeconfig may read the