Files
agent-credential-broker/tests/test_openbao_backend.py
T
me be87900794 agent-credential-broker: fork of vaultwarden-agent-broker + OpenBao backend
Mechanical rename (package agent_credential_broker; entry points
agent-broker{,-mcp,-plugin}; env AGENT_BROKER_*; paths agent-broker;
agent-browser credential provider 'broker'; MCP server name agent-broker).

New backend 'openbao': stdlib-only HTTP client for OpenBao/Vault KV v2
(approle login or token file/env; metadata-only ref index; lazy re-login
on 401/403; token renewal). bitwarden-cli backend kept as transition.
2026-10-14 13:27:33 -06:00

439 lines
18 KiB
Python

"""OpenBaoBackend tests -- OFFLINE against a fake OpenBao HTTP server.
Mirror of test_cli_backend.py for the ``openbao`` backend. The fake
(tests/fake_bao.py) records every request path: these tests assert that
unlock + ref-index construction NEVER touch ``/data/*`` (the metadata
API is the only index source -- zero plaintext transit), that items
resolve to the same normalized shape as the bw backend, and that auth
failures map to UNLOCK_FAILED / BROKER_LOCKED.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import fake_bao
from agent_credential_broker.config import Config
from agent_credential_broker.core import BrokerCore
from agent_credential_broker.errors import BrokerError
from agent_credential_broker.server import _Server, call
ROLE_ID = fake_bao.ROLE_ID
SECRET_ID = fake_bao.SECRET_ID
PW = fake_bao.PW
TOKEN = fake_bao.TOKEN
def _write(path: Path, text: str, mode=0o600):
path.write_text(text)
os.chmod(path, mode)
def _cfg_dict(base: Path, port: int, **extra) -> dict:
cfg = {
"vault_backend": "openbao",
"openbao_addr": f"http://127.0.0.1:{port}",
"openbao_mount": "secret",
"openbao_prefix": "agent",
"unlock_ttl_seconds": 3600,
"git_workdir_roots": [str(Path.home())],
}
cfg.update(extra)
return cfg
class BaoHarness(unittest.TestCase):
"""Daemon + socket + OpenBaoBackend against the fake server.
AUTH_MODE selects the credential material provisioned in setUpClass:
"approle" (role_id+secret_id files) or "token_file" (0600 token).
"""
AUTH_MODE = "approle"
@classmethod
def setUpClass(cls):
# hermetic: a stray real token env var must not leak into fixtures
os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None)
cls.tmp = tempfile.TemporaryDirectory()
base = Path(cls.tmp.name)
os.environ["XDG_RUNTIME_DIR"] = str(base / "rt")
os.environ["XDG_STATE_HOME"] = str(base / "state")
(base / "rt").mkdir()
cred = base / "cred"
cred.mkdir()
extra = {}
if cls.AUTH_MODE == "approle":
_write(cred / "role-id.txt", ROLE_ID)
_write(cred / "secret-id.txt", SECRET_ID)
extra["openbao_role_id_file"] = str(cred / "role-id.txt")
extra["openbao_secret_id_file"] = str(cred / "secret-id.txt")
extra["openbao_token_file"] = str(cred / "absent-token.txt")
elif cls.AUTH_MODE == "token_file":
_write(cred / "bao-token.txt", fake_bao.TOKEN_VALUE + "-seed")
extra["openbao_token_file"] = str(cred / "bao-token.txt")
extra["openbao_role_id_file"] = str(cred / "absent-role.txt")
extra["openbao_secret_id_file"] = str(cred / "absent-secret.txt")
# the seed token must be a live token on the fake server
cls._seed_tokens = [fake_bao.TOKEN_VALUE + "-seed"]
cls.bao, cls.bao_thread, cls.port = fake_bao.start_server()
for tok in getattr(cls, "_seed_tokens", []):
cls.bao.tokens.add(tok)
cls.cfg_path = base / "broker-config.json"
cls.cfg_path.write_text(json.dumps(_cfg_dict(base, cls.port, **extra)))
os.environ["AGENT_BROKER_CONFIG"] = str(cls.cfg_path)
os.environ["AGENT_BROKER_SOCKET"] = str(
base / "rt" / "agent-broker" / "broker.sock")
os.environ["AGENT_BROKER_PROFILES_DIR"] = str(
Path(__file__).resolve().parents[1] / "profiles")
cls.socket_path = Path(os.environ["AGENT_BROKER_SOCKET"])
cls.socket_path.parent.mkdir(parents=True, exist_ok=True)
cls.core = BrokerCore(Config.load())
cls.server = _Server(str(cls.socket_path), cls.core)
os.chmod(cls.socket_path, 0o600)
threading.Thread(target=cls.server.serve_forever,
kwargs={"poll_interval": 0.1}, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.bao.shutdown()
cls.tmp.cleanup()
os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None)
def call(self, method, params=None):
return call(method, params or {})
def data_calls(self, since=0):
return [p for m, p in self.bao.calls[since:] if f"/data/" in p]
class TestApproleUnlock(BaoHarness):
"""unlock via AppRole: role_id+secret_id files -> auth/approle/login."""
def test_unlock_status_refs(self):
st = self.call("status")
self.assertTrue(st["locked"])
self.assertEqual(st["backend"], "openbao")
res = self.call("unlock", {"password": "ignored-by-design"})
self.assertTrue(res["unlocked"])
self.assertEqual(res["items"], 4) # incl. nested + ref-less item
refs = {r["ref"]: r for r in self.call("list_refs")}
self.assertEqual(
set(refs), {"gitea.admin", "test.service", "infra.router"})
blob = json.dumps(refs)
self.assertNotIn(PW, blob)
self.assertNotIn(TOKEN, blob)
self.call("lock")
def test_index_never_touches_data(self):
"""unlock + list_refs must use ONLY metadata endpoints."""
n0 = len(self.bao.calls)
self.call("unlock", {"password": "x"})
self.call("list_refs")
self.assertEqual(self.data_calls(since=n0), [])
self.assertTrue(
any("/metadata/agent" in p for m, p in self.bao.calls[n0:]))
self.call("lock")
def test_resolve_fetches_item_on_demand(self):
n0 = len(self.bao.calls)
self.call("unlock", {"password": "x"})
self.assertEqual(self.data_calls(since=n0), [])
cred = self.call("resolve", {"ref": "gitea.admin",
"url": "https://git2.paginica.com/login"})
self.assertEqual(cred["password"], PW)
self.assertEqual(cred["username"], "gitea-admin")
# exactly one data read, for the requested path only
self.assertEqual(self.data_calls(since=n0),
["/v1/secret/data/agent/gitea.admin"])
self.call("lock")
def test_nested_path_id(self):
self.call("unlock", {"password": "x"})
meta = self.core.backend.get_item("infra/router")
self.assertEqual(meta["id"], "infra/router")
self.assertEqual(meta["username"], "root")
self.assertEqual(meta["fields"]["agent.ref"], "infra.router")
self.call("lock")
def test_lock_revokes_token(self):
self.call("unlock", {"password": "x"})
tok = self.core.backend._token
self.call("lock")
self.assertTrue(self.call("status")["locked"])
self.assertIn(("POST", "/v1/auth/token/revoke-self"), self.bao.calls)
self.assertNotIn(tok, self.bao.tokens) # revoked server-side
self.assertIsNone(self.core.backend._token)
def test_logout_revokes(self):
self.call("unlock", {"password": "x"})
self.call("logout")
self.assertIn(("POST", "/v1/auth/token/revoke-self"), self.bao.calls)
self.assertTrue(self.call("status")["locked"])
def test_wrong_secret_id_unlock_failed(self):
self.call("lock") # clean slate in case a prior test left state
# rotate the expected secret on the fake server: next login fails
self.bao.secret_id = "other-secret"
try:
with self.assertRaises(BrokerError) as e:
self.call("unlock", {"password": "x"})
self.assertEqual(e.exception.code, "UNLOCK_FAILED")
finally:
self.bao.secret_id = SECRET_ID
self.assertTrue(self.call("status")["locked"])
def test_403_mid_session_relog_then_locked(self):
"""A 403 on data/* triggers ONE transparent approle re-login;
a second 403 drops the token -> BROKER_LOCKED."""
n0 = len(self.bao.calls)
self.call("unlock", {"password": "x"})
logins = sum(1 for m, p in self.bao.calls[n0:]
if "approle/login" in p)
self.assertEqual(logins, 1)
self.bao.forbidden.add("agent/gitea.admin")
try:
with self.assertRaises(BrokerError) as e:
self.call("resolve", {"ref": "gitea.admin",
"url": "https://git2.paginica.com"})
self.assertEqual(e.exception.code, "BROKER_LOCKED")
logins = sum(1 for m, p in self.bao.calls[n0:]
if "approle/login" in p)
self.assertEqual(logins, 2) # exactly one re-login attempt
self.assertIsNone(self.core.backend._token)
finally:
self.bao.forbidden.discard("agent/gitea.admin")
self.call("lock")
class TestTokenFileUnlock(BaoHarness):
AUTH_MODE = "token_file"
SEED = fake_bao.TOKEN_VALUE + "-seed"
def setUp(self):
# lock() revokes the shared fixture token server-side; re-seed it
# so each test starts from a live token again.
self.bao.tokens.add(self.SEED)
def test_unlock_via_token_file(self):
res = self.call("unlock", {"password": "still-ignored"})
self.assertTrue(res["unlocked"])
# no approle login happened -- lookup-self verified the file token
self.assertFalse(any("approle/login" in p for m, p in self.bao.calls))
self.assertIn(("GET", "/v1/auth/token/lookup-self"), self.bao.calls)
self.call("lock")
def test_403_locks_without_relogin(self):
"""No approle creds -> a 403 cannot re-login; straight to locked."""
self.call("unlock", {"password": "x"})
self.bao.forbidden.add("agent/test.service")
try:
with self.assertRaises(BrokerError) as e:
self.core.backend.get_item("test.service")
self.assertEqual(e.exception.code, "BROKER_LOCKED")
self.assertFalse(any("approle/login" in p
for m, p in self.bao.calls))
finally:
self.bao.forbidden.discard("agent/test.service")
self.call("lock")
def test_renewable_token_renews_near_expiry(self):
self.bao.lookup_ttl = 30 # < _RENEW_MARGIN_S -> renew on next call
self.bao.lookup_renewable = True
try:
self.call("unlock", {"password": "x"})
# renewal already happened inside unlock's metadata prefetch
self.assertIn(("POST", "/v1/auth/token/renew-self"),
self.bao.calls)
self.call("lock")
finally:
self.bao.lookup_ttl = 3600
class TestBackendUnit(unittest.TestCase):
"""Direct OpenBaoBackend use (no socket) for shape/error details."""
def setUp(self):
os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None)
self.tmp = tempfile.TemporaryDirectory()
base = Path(self.tmp.name)
os.environ["XDG_RUNTIME_DIR"] = str(base / "rt")
os.environ["XDG_STATE_HOME"] = str(base / "state")
(base / "rt").mkdir()
cred = base / "cred"
cred.mkdir()
_write(cred / "role-id.txt", ROLE_ID)
_write(cred / "secret-id.txt", SECRET_ID)
self.bao, self._t, self.port = fake_bao.start_server()
cfgd = _cfg_dict(base, self.port,
openbao_role_id_file=str(cred / "role-id.txt"),
openbao_secret_id_file=str(cred / "secret-id.txt"),
openbao_token_file=str(cred / "absent-token.txt"))
self.cfgd = cfgd
from agent_credential_broker.backends.openbao import OpenBaoBackend
self.backend = OpenBaoBackend(Config(cfgd))
def tearDown(self):
self.bao.shutdown()
self.tmp.cleanup()
os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None)
def test_get_item_full_shape(self):
self.backend.unlock("ignored")
item = self.backend.get_item("gitea.admin")
self.assertEqual(
set(item),
{"id", "type", "name", "username", "password", "totp",
"uris", "fields", "notes"})
self.assertEqual(item["id"], "gitea.admin")
self.assertEqual(item["name"], "gitea admin") # from label
self.assertEqual(item["username"], "gitea-admin")
self.assertEqual(item["password"], PW)
self.assertEqual(item["notes"], "bot account")
self.assertEqual(item["uris"], ["https://git2.paginica.com"])
f = item["fields"]
self.assertEqual(f["agent.ref"], "gitea.admin")
self.assertEqual(f["agent.confirmation"], "AUTO")
self.assertNotIn("label", f) # reserved metadata keys mapped away
self.assertNotIn("uris", f)
def test_get_item_csv_uris_and_no_label(self):
self.backend.unlock("x")
item = self.backend.get_item("test.service")
self.assertEqual(item["name"], "api token")
self.assertEqual(item["uris"],
["https://service.example", "https://alt.example"])
self.assertIsNone(item["username"])
self.backend.lock()
def test_get_item_not_found(self):
self.backend.unlock("x")
with self.assertRaises(BrokerError) as e:
self.backend.get_item("no-such-item")
self.assertEqual(e.exception.code, "NOT_FOUND")
def test_get_item_path_traversal_rejected(self):
self.backend.unlock("x")
for bad in ("../sys", "/sys", ".."):
with self.assertRaises(BrokerError) as e:
self.backend.get_item(bad)
self.assertEqual(e.exception.code, "NOT_FOUND")
def test_metadata_projection_agent_only(self):
self.backend.unlock("x")
meta = {m["id"]: m for m in self.backend.list_metadata()}
g = meta["gitea.admin"]
self.assertEqual(g["name"], "gitea admin")
self.assertEqual(g["uris"], ["https://git2.paginica.com"])
self.assertTrue(all(k.startswith("agent.") for k in g["fields"]))
for m in meta.values():
self.assertIsNone(m["password"])
self.assertIsNone(m["username"])
self.assertIsNone(m["notes"])
def test_empty_prefix_unlocks_with_zero_items(self):
cfg = dict(self.cfgd)
cfg["openbao_prefix"] = "empty-prefix"
from agent_credential_broker.backends.openbao import OpenBaoBackend
b = OpenBaoBackend(Config(cfg))
b.unlock("x") # LIST 404 -> empty index, not an error
self.assertEqual(b.list_metadata(), [])
def test_expired_token_locks(self):
self.backend.unlock("x")
self.backend._token_expires_at = time.time() - 1
with self.assertRaises(BrokerError) as e:
self.backend.get_item("gitea.admin")
self.assertEqual(e.exception.code, "BROKER_LOCKED")
self.assertIsNone(self.backend._token)
def test_unlock_via_token_env(self):
tok = fake_bao.TOKEN_VALUE + "-env"
self.bao.tokens.add(tok)
os.environ["AGENT_BROKER_OPENBAO_TOKEN"] = tok
cfg = dict(self.cfgd)
cfg["openbao_role_id_file"] = str(Path(self.tmp.name) / "none")
cfg["openbao_secret_id_file"] = str(Path(self.tmp.name) / "none2")
cfg["openbao_token_file"] = str(Path(self.tmp.name) / "none3")
from agent_credential_broker.backends.openbao import OpenBaoBackend
b = OpenBaoBackend(Config(cfg))
self.assertTrue(b.configured())
b.unlock("x")
self.assertEqual(b._token, tok)
b.lock()
def test_unlock_via_custom_token_env(self):
tok = fake_bao.TOKEN_VALUE + "-custom"
self.bao.tokens.add(tok)
os.environ["MY_BAO_TOKEN"] = tok
try:
cfg = dict(self.cfgd)
cfg["openbao_token_env"] = "MY_BAO_TOKEN"
cfg["openbao_token_file"] = str(Path(self.tmp.name) / "none3")
cfg["openbao_role_id_file"] = str(Path(self.tmp.name) / "none")
cfg["openbao_secret_id_file"] = str(Path(self.tmp.name) / "n2")
from agent_credential_broker.backends.openbao import OpenBaoBackend
b = OpenBaoBackend(Config(cfg))
b.unlock("x")
self.assertEqual(b._token, tok)
b.lock()
finally:
os.environ.pop("MY_BAO_TOKEN", None)
def test_unconfigured(self):
cfg = dict(self.cfgd)
for k in ("openbao_role_id_file", "openbao_secret_id_file",
"openbao_token_file"):
cfg[k] = str(Path(self.tmp.name) / ("absent-" + k))
from agent_credential_broker.backends.openbao import OpenBaoBackend
b = OpenBaoBackend(Config(cfg))
self.assertFalse(b.configured())
with self.assertRaises(BrokerError) as e:
b.unlock("x")
self.assertEqual(e.exception.code, "UNCONFIGURED")
def test_bad_perms_credential_file(self):
cred = Path(self.tmp.name) / "cred"
_write(cred / "secret-id.txt", SECRET_ID, mode=0o644)
with self.assertRaises(BrokerError) as e:
self.backend.unlock("x")
self.assertEqual(e.exception.code, "UNCONFIGURED")
os.chmod(cred / "secret-id.txt", 0o600)
def test_server_down_backend_error(self):
self.bao.shutdown()
with self.assertRaises(BrokerError) as e:
self.backend.unlock("x")
self.assertEqual(e.exception.code, "VAULT_BACKEND_ERROR")
self.assertNotIn(SECRET_ID, e.exception.message)
def test_error_never_echoes_secret_id(self):
self.bao.secret_id = "rotated" # force 400 on login
try:
with self.assertRaises(BrokerError) as e:
self.backend.unlock("x")
self.assertEqual(e.exception.code, "UNLOCK_FAILED")
self.assertNotIn(SECRET_ID, e.exception.message)
self.assertNotIn(ROLE_ID, e.exception.message)
finally:
self.bao.secret_id = SECRET_ID
if __name__ == "__main__":
unittest.main(verbosity=2)