85 lines
3.1 KiB
Python
85 lines
3.1 KiB
Python
|
|
"""HashiCorp Vault KV v2 client using stdlib urllib."""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
import urllib.error
|
||
|
|
import urllib.request
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class VaultClient:
|
||
|
|
"""Simple Vault KV v2 client using stdlib."""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
addr: str | None = None,
|
||
|
|
token: str | None = None,
|
||
|
|
mount: str = "secret",
|
||
|
|
):
|
||
|
|
self.addr = (addr or os.environ.get("VAULT_ADDR", "")).rstrip("/")
|
||
|
|
self.token = token or os.environ.get("VAULT_TOKEN", "")
|
||
|
|
self.mount = mount
|
||
|
|
|
||
|
|
if not self.addr:
|
||
|
|
raise ValueError("Vault address not configured (set VAULT_ADDR)")
|
||
|
|
|
||
|
|
# Auto-detect Kubernetes SA token
|
||
|
|
if not self.token:
|
||
|
|
sa_token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||
|
|
if os.path.exists(sa_token_path):
|
||
|
|
self._login_kubernetes(sa_token_path)
|
||
|
|
|
||
|
|
def _login_kubernetes(self, sa_token_path: str) -> None:
|
||
|
|
"""Authenticate with Vault using Kubernetes service account."""
|
||
|
|
with open(sa_token_path) as f:
|
||
|
|
jwt = f.read().strip()
|
||
|
|
role = os.environ.get("VAULT_ROLE", "claude-memory")
|
||
|
|
resp = self._request("POST", "/v1/auth/kubernetes/login", {"jwt": jwt, "role": role})
|
||
|
|
self.token = resp.get("auth", {}).get("client_token", "")
|
||
|
|
|
||
|
|
def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
|
|
"""Make HTTP request to Vault."""
|
||
|
|
url = f"{self.addr}{path}"
|
||
|
|
data = json.dumps(body).encode() if body else None
|
||
|
|
req = urllib.request.Request(
|
||
|
|
url, data=data, method=method,
|
||
|
|
headers={"X-Vault-Token": self.token, "Content-Type": "application/json"},
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||
|
|
return json.loads(resp.read().decode())
|
||
|
|
except urllib.error.HTTPError as e:
|
||
|
|
if e.code == 404:
|
||
|
|
return {}
|
||
|
|
error_body = e.read().decode() if e.fp else str(e)
|
||
|
|
raise RuntimeError(f"Vault error {e.code}: {error_body}") from e
|
||
|
|
|
||
|
|
def read(self, path: str) -> dict[str, Any] | None:
|
||
|
|
"""Read a secret from KV v2."""
|
||
|
|
resp = self._request("GET", f"/v1/{self.mount}/data/{path}")
|
||
|
|
data = resp.get("data", {})
|
||
|
|
return data.get("data") if data else None
|
||
|
|
|
||
|
|
def write(self, path: str, data: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
"""Write a secret to KV v2."""
|
||
|
|
return self._request("POST", f"/v1/{self.mount}/data/{path}", {"data": data})
|
||
|
|
|
||
|
|
def delete(self, path: str) -> bool:
|
||
|
|
"""Delete a secret from KV v2."""
|
||
|
|
try:
|
||
|
|
self._request("DELETE", f"/v1/{self.mount}/data/{path}")
|
||
|
|
return True
|
||
|
|
except RuntimeError:
|
||
|
|
return False
|
||
|
|
|
||
|
|
def list_secrets(self, path: str) -> list[str]:
|
||
|
|
"""List secrets at a path."""
|
||
|
|
try:
|
||
|
|
resp = self._request("LIST", f"/v1/{self.mount}/metadata/{path}")
|
||
|
|
return resp.get("data", {}).get("keys", [])
|
||
|
|
except RuntimeError:
|
||
|
|
return []
|