mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
kms.hanzo.ai runs luxfi/kms, which has never served an /api/* route. This
SDK was sending Infisical's: /api/v3/secrets/raw for every secret operation,
/api/v3/auth/login for user auth, /api/v1/auth/kubernetes-auth/login for k8s.
Only one call in the whole package — /v1/kms/auth/login — was ever real.
It stayed invisible because older luxfi/kms builds embedded a console SPA
behind a root catch-all that answered every unmatched path with 200
text/html. A wrong URL came back as a JSON decode error, which reads like a
parsing bug in the client rather than "this endpoint does not exist." That
catch-all is gone (cmd/kms/main.go ends in notFoundJSON), so those paths now
return honest JSON 404s and the SDK fails outright.
The route table is not the only thing that was wrong. The data model was
Infisical's too — project_id, workspace, secret version, secret comment,
shared-vs-personal type — and luxfi/kms has none of those concepts. It keys
one value by (org, path, name, env) in ZapDB at kms/secrets/{path}/{env}/{name}
and a write upserts it in place. Half-migrating would have left response
models that cannot validate what the server sends, so the vocabulary moves
with the paths:
list_secrets(path, env) -> names GET /v1/kms/orgs/{org}/secrets
get_secret(path, name, env) GET /v1/kms/orgs/{org}/secrets/{path}/{name}
put_secret(path, name, value, env) POST /v1/kms/orgs/{org}/secrets (create AND replace)
delete_secret(path, name, env) DELETE .../secrets/{path}/{name}
health() GET /v1/kms/healthz
Two server behaviors now have one home, hanzo_kms/routes.py, shared by the
sync and async clients so they stay mirror images:
- The server splits the trailing path at its LAST slash into (path, name),
so each segment is escaped individually. Escaping the joined string
encodes the separators away and the server reads one long name. A name
containing "/" is rejected outright: it would be written under one key
and read back under another, so the write looks like it succeeded and
the read never finds it.
- There is no versioned read. get_secret(version=N) raises
VersionUnsupportedError rather than quietly returning the current value.
org is new and required — it scopes both the URL and the JWT owner claim.
Constructor field, HANZO_KMS_ORG, defaults to "hanzo".
Auth collapses to what the server actually offers: client credentials
exchanged at /v1/kms/auth/login, or a pre-issued IAM bearer token. The AWS,
Azure, GCP, Kubernetes and SRP methods were Infisical's and none were served.
That also fixes a divergence where the async client silently ignored
HANZO_KMS_TOKEN — env parsing is now one function.
Callers migrated with it: hanzo-cli's `hanzo kms`, the hanzo-tools-kms MCP
tool, and hanzo-tools-auth's session client, all of which were building the
removed auth models and calling the removed create/update pair. Each now
constructs KMSClient() and lets it read the environment.
Tests: pkg/hanzo-kms/tests pins the wire shape — no request URL may contain
"/api/", per-segment escaping survives the server's last-slash split, sync
and async emit byte-identical request lines, and a decoder rejects the
200-HTML/JSON-404 shapes instead of reading them as empty results. Wired into
hanzo-packages-ci without `|| true`, so a regression turns CI red instead of
waiting for a decode error in production.
hanzo-kms 1.1.0 -> 1.1.1.
Co-authored-by: Hanzo Dev <[email protected]>
209 lines
6.8 KiB
Python
209 lines
6.8 KiB
Python
"""Hanzo KMS client — asynchronous.
|
|
|
|
Mirror image of :class:`hanzo_kms.client.KMSClient`: same methods, same
|
|
arguments, same shared pure functions from :mod:`hanzo_kms.routes` — only the
|
|
I/O differs.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from . import routes
|
|
from .models import ClientSettings, TokenResponse, settings_from_env
|
|
|
|
|
|
class AsyncKMSClient:
|
|
"""Async Hanzo KMS client for secret management.
|
|
|
|
Example:
|
|
async with AsyncKMSClient() as client:
|
|
names = await client.list_secrets("providers/lux", env="prod")
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
settings: Optional[ClientSettings] = None,
|
|
*,
|
|
debug: bool = False,
|
|
):
|
|
"""Initialize the client.
|
|
|
|
Args:
|
|
settings: Configuration; read from the environment when omitted.
|
|
debug: Enable debug logging.
|
|
"""
|
|
self.settings = settings or settings_from_env()
|
|
self.debug = debug
|
|
self._access_token = ""
|
|
self._token_expires_at = 0.0
|
|
self._http_client: Optional[httpx.AsyncClient] = None
|
|
|
|
@property
|
|
def http(self) -> httpx.AsyncClient:
|
|
"""Get or create the async HTTP client."""
|
|
if self._http_client is None:
|
|
self._http_client = httpx.AsyncClient(
|
|
base_url=self.settings.site_url.rstrip("/"),
|
|
timeout=30.0,
|
|
headers={
|
|
"User-Agent": self.settings.user_agent,
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
return self._http_client
|
|
|
|
# =========================================================================
|
|
# Auth
|
|
# =========================================================================
|
|
|
|
async def _token(self) -> str:
|
|
"""Return a valid bearer token, logging in when needed."""
|
|
if self.settings.access_token:
|
|
return self.settings.access_token
|
|
|
|
if self._access_token and time.time() < self._token_expires_at - 60:
|
|
return self._access_token
|
|
|
|
if not (self.settings.client_id and self.settings.client_secret):
|
|
raise ValueError(
|
|
"no KMS credentials: set access_token, or client_id and client_secret "
|
|
"(HANZO_KMS_TOKEN, or HANZO_KMS_CLIENT_ID and HANZO_KMS_CLIENT_SECRET)"
|
|
)
|
|
|
|
response = await self.http.post(
|
|
routes.LOGIN,
|
|
json={
|
|
"clientId": self.settings.client_id,
|
|
"clientSecret": self.settings.client_secret,
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
token = TokenResponse.model_validate(response.json())
|
|
self._access_token = token.access_token
|
|
self._token_expires_at = time.time() + token.expires_in
|
|
return self._access_token
|
|
|
|
async def _headers(self) -> dict[str, str]:
|
|
"""Bearer header. The org is carried by the URL and the JWT `owner`
|
|
claim — the server reads no org header."""
|
|
return {"Authorization": f"Bearer {await self._token()}"}
|
|
|
|
# =========================================================================
|
|
# Secrets
|
|
# =========================================================================
|
|
|
|
async def list_secrets(
|
|
self, path: str = "", env: str = routes.DEFAULT_ENV
|
|
) -> list[str]:
|
|
"""List the secret names stored at ``path``.
|
|
|
|
Names only: the server's list route returns ``{"names": [...]}`` with
|
|
no values, so reading a value takes a :meth:`get_secret` per name.
|
|
"""
|
|
response = await self.http.get(
|
|
routes.secrets_url(self.settings.org),
|
|
params=routes.list_params(path, env),
|
|
headers=await self._headers(),
|
|
)
|
|
response.raise_for_status()
|
|
return routes.names_of(response.json())
|
|
|
|
async def get_secret(
|
|
self,
|
|
path: str,
|
|
name: str,
|
|
env: str = routes.DEFAULT_ENV,
|
|
version: Optional[int] = None,
|
|
) -> str:
|
|
"""Read the value of one secret.
|
|
|
|
Args:
|
|
path: Secret path, e.g. ``"providers/lux"``.
|
|
name: Secret name, e.g. ``"deploy-mnemonic"``. May not contain ``/``.
|
|
env: Environment bucket.
|
|
version: Must be None — see :class:`~hanzo_kms.routes.VersionUnsupportedError`.
|
|
"""
|
|
routes.check_version(version)
|
|
response = await self.http.get(
|
|
routes.secret_url(self.settings.org, path, name),
|
|
params=routes.env_params(env),
|
|
headers=await self._headers(),
|
|
)
|
|
response.raise_for_status()
|
|
return routes.value_of(response.json())
|
|
|
|
async def put_secret(
|
|
self,
|
|
path: str,
|
|
name: str,
|
|
value: str,
|
|
env: str = routes.DEFAULT_ENV,
|
|
) -> None:
|
|
"""Create or replace a secret.
|
|
|
|
One upsert, not a create/update pair: luxfi/kms holds exactly one
|
|
value per (path, name, env) and a write replaces it in place.
|
|
"""
|
|
response = await self.http.post(
|
|
routes.secrets_url(self.settings.org),
|
|
json=routes.upsert_body(path, name, value, env),
|
|
headers=await self._headers(),
|
|
)
|
|
response.raise_for_status()
|
|
|
|
async def delete_secret(
|
|
self,
|
|
path: str,
|
|
name: str,
|
|
env: str = routes.DEFAULT_ENV,
|
|
) -> None:
|
|
"""Delete a secret."""
|
|
response = await self.http.delete(
|
|
routes.secret_url(self.settings.org, path, name),
|
|
params=routes.env_params(env),
|
|
headers=await self._headers(),
|
|
)
|
|
response.raise_for_status()
|
|
|
|
async def health(self) -> dict[str, Any]:
|
|
"""Probe the server: ``{"service": "kms", "status": "ok"}``. No auth."""
|
|
response = await self.http.get(routes.HEALTH)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
async def inject_env(
|
|
self,
|
|
path: str = "",
|
|
env: str = routes.DEFAULT_ENV,
|
|
overwrite: bool = False,
|
|
) -> int:
|
|
"""Load every secret at ``path`` into ``os.environ``, keyed by name.
|
|
|
|
Costs one list request plus one read per secret — the list route
|
|
returns names, not values.
|
|
|
|
Returns:
|
|
The number of variables set.
|
|
"""
|
|
count = 0
|
|
for name in await self.list_secrets(path, env):
|
|
if overwrite or name not in os.environ:
|
|
os.environ[name] = await self.get_secret(path, name, env)
|
|
count += 1
|
|
return count
|
|
|
|
async def close(self) -> None:
|
|
"""Close the HTTP client."""
|
|
if self._http_client:
|
|
await self._http_client.aclose()
|
|
self._http_client = None
|
|
|
|
async def __aenter__(self) -> "AsyncKMSClient":
|
|
return self
|
|
|
|
async def __aexit__(self, *args: Any) -> None:
|
|
await self.close()
|