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.
439 lines
18 KiB
Python
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)
|