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
This commit is contained in:
parent
6e1430073c
commit
8dbf62515a
6 changed files with 367 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
137
src/warden/openbao_caller.py
Normal file
137
src/warden/openbao_caller.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
125
tests/test_openbao_caller.py
Normal file
125
tests/test_openbao_caller.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue