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.
This commit is contained in:
commit
be87900794
35 files changed
+5003
No files matched your search
@@ -0,0 +1,256 @@
|
||||
"""Broker core: vault state, ref resolution, confirmations, dispatch.
|
||||
|
||||
This object lives inside the daemon process and is the ONLY place where
|
||||
decrypted credential material exists -- in RAM, under a LOCKED default,
|
||||
auto-expiring after ``unlock_ttl_seconds``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets as _secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import errors, policy as pol
|
||||
from .audit import audit
|
||||
from .backends import make_backend
|
||||
from .config import Config
|
||||
from .items import wipe_item
|
||||
|
||||
|
||||
class VaultState:
|
||||
def __init__(self):
|
||||
self.items: list[dict] = []
|
||||
self.ref_index: dict[str, list[int]] = {}
|
||||
self.unlocked_at: float | None = None
|
||||
self.session_approvals: set = set()
|
||||
self.tickets: dict[str, dict] = {}
|
||||
|
||||
def clear(self):
|
||||
for item in self.items:
|
||||
for k, v in item.items():
|
||||
item[k] = None
|
||||
self.items = []
|
||||
self.ref_index = {}
|
||||
self.unlocked_at = None
|
||||
self.session_approvals = set()
|
||||
self.tickets = {}
|
||||
|
||||
@property
|
||||
def unlocked(self) -> bool:
|
||||
return self.unlocked_at is not None
|
||||
|
||||
|
||||
class BrokerCore:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.backend = make_backend(config)
|
||||
self.state = VaultState()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# -- lifecycle -----------------------------------------------------
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
self._expire_if_needed()
|
||||
return {
|
||||
"locked": not self.state.unlocked,
|
||||
"configured": self._configured(),
|
||||
"refs": len(self.state.ref_index) if self.state.unlocked else None,
|
||||
"backend": self.backend.name,
|
||||
"socket": "unix",
|
||||
}
|
||||
|
||||
def _configured(self) -> bool:
|
||||
return self.backend.configured()
|
||||
|
||||
def _expire_if_needed(self):
|
||||
st = self.state
|
||||
if st.unlocked and (time.time() - st.unlocked_at) > self.config.unlock_ttl_seconds:
|
||||
st.clear()
|
||||
self.backend.lock()
|
||||
audit("auto_lock", result="ok", detail="ttl expired")
|
||||
|
||||
def unlock(self, password: str) -> dict:
|
||||
if not self._configured():
|
||||
raise errors.unconfigured()
|
||||
try:
|
||||
self.backend.unlock(password)
|
||||
finally:
|
||||
password = None
|
||||
items = self.backend.list_metadata()
|
||||
with self._lock:
|
||||
self.state.clear()
|
||||
self.state.items = items
|
||||
for i, item in enumerate(items):
|
||||
p = pol.policy_for(item)
|
||||
if p:
|
||||
self.state.ref_index.setdefault(p.ref, []).append(i)
|
||||
self.state.unlocked_at = time.time()
|
||||
dupes = [r for r, idx in self.state.ref_index.items() if len(idx) > 1]
|
||||
audit("unlock", result="ok", detail=f"items={len(items)} refs={len(self.state.ref_index)}")
|
||||
return {
|
||||
"unlocked": True,
|
||||
"items": len(items),
|
||||
"refs": len(self.state.ref_index),
|
||||
"duplicate_refs": sorted(dupes),
|
||||
}
|
||||
|
||||
def lock(self) -> dict:
|
||||
with self._lock:
|
||||
self.state.clear()
|
||||
self.backend.lock()
|
||||
audit("lock", result="ok")
|
||||
return {"locked": True}
|
||||
|
||||
def logout(self) -> dict:
|
||||
with self._lock:
|
||||
self.state.clear()
|
||||
self.backend.logout()
|
||||
audit("logout", result="ok")
|
||||
return {"locked": True, "logged_out": True}
|
||||
|
||||
# -- refs -----------------------------------------------------------
|
||||
|
||||
def _require_unlocked(self):
|
||||
self._expire_if_needed()
|
||||
if not self.state.unlocked:
|
||||
raise errors.locked()
|
||||
|
||||
def _resolve_index(self, ref: str) -> int:
|
||||
"""Exact-match ref -> item index. 0 -> NOT_FOUND, >1 -> AMBIGUOUS_REF."""
|
||||
idxs = self.state.ref_index.get(ref, [])
|
||||
if not idxs:
|
||||
raise errors.not_found(ref)
|
||||
if len(idxs) > 1:
|
||||
raise errors.ambiguous(ref, len(idxs))
|
||||
return idxs[0]
|
||||
|
||||
def resolve_ref(self, ref: str) -> tuple[dict, pol.CredentialPolicy]:
|
||||
"""Internal resolve: returns (decrypted item, policy). Callers in
|
||||
the process only -- the socket layer restricts who may receive
|
||||
the secret material this yields."""
|
||||
self._require_unlocked()
|
||||
if not isinstance(ref, str) or not ref or len(ref) > 200:
|
||||
raise errors.invalid_argument("bad ref")
|
||||
item = self.state.items[self._resolve_index(ref)]
|
||||
p = pol.policy_for(item)
|
||||
if p is None:
|
||||
raise errors.not_found(ref)
|
||||
return item, p
|
||||
|
||||
def list_refs(self) -> list[dict]:
|
||||
self._require_unlocked()
|
||||
out = []
|
||||
seen = set()
|
||||
for ref, idxs in self.state.ref_index.items():
|
||||
for i in idxs:
|
||||
p = pol.policy_for(self.state.items[i])
|
||||
meta = p.safe_metadata()
|
||||
if len(idxs) > 1:
|
||||
meta["duplicate"] = True
|
||||
if ref not in seen:
|
||||
out.append(meta)
|
||||
seen.add(ref)
|
||||
return sorted(out, key=lambda m: m["ref"])
|
||||
|
||||
def find_for_url(self, url: str) -> list[str]:
|
||||
self._require_unlocked()
|
||||
refs = []
|
||||
for ref in self.state.ref_index:
|
||||
try:
|
||||
item = self.state.items[self.state.ref_index[ref][0]]
|
||||
p = pol.policy_for(item)
|
||||
if p is None:
|
||||
continue
|
||||
pol.require_url_allowed(p, url, require_https=False)
|
||||
refs.append(ref)
|
||||
except errors.BrokerError:
|
||||
continue
|
||||
return sorted(set(refs))
|
||||
|
||||
# -- confirmation ----------------------------------------------------
|
||||
|
||||
def _confirm(self, policy, adapter: str, target: str, ticket=None):
|
||||
level = policy.confirmation
|
||||
if level == "DENY":
|
||||
raise errors.denied(f"credential {policy.ref!r} is DENY for confirmation")
|
||||
if level == "AUTO":
|
||||
return
|
||||
if level == "SESSION" and (policy.ref, adapter) in self.state.session_approvals:
|
||||
return
|
||||
if ticket:
|
||||
tk = self.state.tickets.pop(ticket, None)
|
||||
if (tk and tk["approved"] and time.time() < tk["expires"]
|
||||
and tk["ref"] == policy.ref and tk["adapter"] == adapter):
|
||||
if level == "SESSION":
|
||||
self.state.session_approvals.add((policy.ref, adapter))
|
||||
return
|
||||
raise errors.denied("confirmation ticket invalid or expired")
|
||||
ticket_id = _secrets.token_urlsafe(24)
|
||||
self.state.tickets[ticket_id] = {
|
||||
"ref": policy.ref, "adapter": adapter, "target": target,
|
||||
"approved": False, "expires": time.time() + self.config.confirmation_ttl_seconds,
|
||||
}
|
||||
audit("confirmation_pending", ref=policy.ref, adapter=adapter, target=target)
|
||||
raise errors.confirmation_required(ticket_id)
|
||||
|
||||
def confirm(self, ticket: str) -> dict:
|
||||
"""Approve a pending ticket. Invoked interactively by the human."""
|
||||
tk = self.state.tickets.get(ticket)
|
||||
if not tk or time.time() > tk["expires"]:
|
||||
raise errors.denied("unknown or expired ticket")
|
||||
tk["approved"] = True
|
||||
tk["expires"] = time.time() + self.config.confirmation_ttl_seconds
|
||||
audit("confirmed", ref=tk["ref"], adapter=tk["adapter"], target=tk["target"])
|
||||
return {"approved": True, "ref": tk["ref"], "adapter": tk["adapter"], "target": tk["target"]}
|
||||
|
||||
# -- adapter dispatch --------------------------------------------------
|
||||
|
||||
def run(self, adapter: str, params: dict) -> dict:
|
||||
"""Central guarded dispatch used by every adapter."""
|
||||
self._require_unlocked()
|
||||
ref = params.get("credential_ref") or params.get("ref")
|
||||
ticket = params.get("ticket")
|
||||
meta, p = self.resolve_ref(ref)
|
||||
pol.require_adapter(p, adapter)
|
||||
target = params.get("url") or params.get("host") or params.get("repository") or params.get("profile") or ""
|
||||
from .adapters import registry
|
||||
registry.validate(adapter, meta, p, params, self.config)
|
||||
self._confirm(p, adapter, target, ticket=ticket)
|
||||
item = self.backend.get_item(meta["id"])
|
||||
try:
|
||||
result = registry.run(adapter, item, p, params, self.config)
|
||||
audit(adapter, ref=p.ref, adapter=adapter, target=target, result="ok")
|
||||
return result
|
||||
except errors.BrokerError as e:
|
||||
audit(adapter, ref=p.ref, adapter=adapter, target=target, result="error", error_code=e.code)
|
||||
raise
|
||||
except Exception:
|
||||
audit(adapter, ref=p.ref, adapter=adapter, target=target, result="error", error_code="INTERNAL_ERROR")
|
||||
raise errors.internal("adapter failed")
|
||||
finally:
|
||||
wipe_item(item)
|
||||
|
||||
def resolve_for_plugin(self, ref: str, url: str | None) -> dict:
|
||||
"""Socket-exposed resolve for the agent-browser credential.read
|
||||
plugin. Only usable for refs whose policy allows the browser
|
||||
adapter; origin is validated when a URL is provided."""
|
||||
meta, p = self.resolve_ref(ref)
|
||||
pol.require_adapter(p, "browser")
|
||||
if url:
|
||||
pol.require_url_allowed(p, url)
|
||||
audit("resolve", ref=p.ref, adapter="browser", target=url or p.uris[:1])
|
||||
item = self.backend.get_item(meta["id"])
|
||||
try:
|
||||
secrets = {
|
||||
"username": item.get("username"),
|
||||
"password": item.get("password"),
|
||||
"url": url or (p.uris[0] if p.uris else None),
|
||||
"usernameSelector": p.username_selector,
|
||||
"passwordSelector": p.password_selector,
|
||||
"submitSelector": p.submit_selector,
|
||||
}
|
||||
return {k: v for k, v in secrets.items() if v}
|
||||
finally:
|
||||
wipe_item(item)
|
||||
Reference in new issue
Block a user