Files
mcias/clients/python/mcias_client/_client.py
Kyle Isom cbcb1a0533 clients: expand Go, Python, Rust client APIs
- Add TOTP enrollment/confirmation/removal to all clients
- Add password change and admin set-password endpoints
- Add account listing, status update, and tag management
- Add audit log listing with filter support
- Add policy rule CRUD operations
- Expand test coverage for all new endpoints across clients
- Fix .gitignore to exclude built binaries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:29:11 -07:00

358 lines
15 KiB
Python

"""Synchronous HTTP client for the MCIAS API."""
from __future__ import annotations
import ssl
from types import TracebackType
from typing import Any
import httpx
from ._errors import raise_for_status
from ._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
class Client:
"""Synchronous MCIAS API client backed by httpx."""
def __init__(
self,
server_url: str,
*,
ca_cert_path: str | None = None,
token: str | None = None,
timeout: float = 30.0,
) -> None:
self._base_url = server_url.rstrip("/")
self.token = token
ssl_context: ssl.SSLContext | bool
if ca_cert_path is not None:
ssl_context = ssl.create_default_context(cafile=ca_cert_path)
else:
ssl_context = True # use default SSL verification
self._http = httpx.Client(
verify=ssl_context,
timeout=timeout,
)
def __enter__(self) -> Client:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self.close()
def close(self) -> None:
"""Close the underlying HTTP client."""
self._http.close()
def _request(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
expected_status: int | None = None,
) -> dict[str, Any] | None:
"""Send an HTTP request and return the parsed JSON body.
Returns None for 204 No Content responses.
Raises the appropriate MciasError subclass on 4xx/5xx.
"""
url = f"{self._base_url}{path}"
headers: dict[str, str] = {}
if self.token is not None:
headers["Authorization"] = f"Bearer {self.token}"
response = self._http.request(method, url, json=json, headers=headers)
status = response.status_code
if expected_status is not None:
success_codes = {expected_status}
else:
success_codes = {200, 201, 204}
if status not in success_codes and status >= 400:
try:
body = response.json()
message = str(body.get("error", response.text))
except Exception:
message = response.text
raise_for_status(status, message)
if status == 204 or not response.content:
return None
return response.json() # type: ignore[no-any-return]
def _request_list(
self,
method: str,
path: str,
*,
json: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Send a request that returns a JSON array at the top level."""
url = f"{self._base_url}{path}"
headers: dict[str, str] = {}
if self.token is not None:
headers["Authorization"] = f"Bearer {self.token}"
response = self._http.request(method, url, json=json, headers=headers)
status = response.status_code
if status >= 400:
try:
body = response.json()
message = str(body.get("error", response.text))
except Exception:
message = response.text
raise_for_status(status, message)
return response.json() # type: ignore[no-any-return]
# ── Public ────────────────────────────────────────────────────────────────
def health(self) -> None:
"""GET /v1/health — liveness check."""
self._request("GET", "/v1/health")
def get_public_key(self) -> PublicKey:
"""GET /v1/keys/public — retrieve the server's Ed25519 public key."""
data = self._request("GET", "/v1/keys/public")
assert data is not None
return PublicKey.from_dict(data)
def login(
self,
username: str,
password: str,
totp_code: str | None = None,
) -> tuple[str, str]:
"""POST /v1/auth/login — authenticate and obtain a JWT.
Returns (token, expires_at). Stores the token on self.token.
"""
payload: dict[str, Any] = {
"username": username,
"password": password,
}
if totp_code is not None:
payload["totp_code"] = totp_code
data = self._request("POST", "/v1/auth/login", json=payload)
assert data is not None
token = str(data["token"])
expires_at = str(data["expires_at"])
self.token = token
return token, expires_at
def validate_token(self, token: str) -> TokenClaims:
"""POST /v1/token/validate — check whether a token is valid."""
data = self._request("POST", "/v1/token/validate", json={"token": token})
assert data is not None
return TokenClaims.from_dict(data)
# ── Authenticated ──────────────────────────────────────────────────────────
def logout(self) -> None:
"""POST /v1/auth/logout — invalidate the current token."""
self._request("POST", "/v1/auth/logout")
self.token = None
def renew_token(self) -> tuple[str, str]:
"""POST /v1/auth/renew — exchange current token for a fresh one.
Returns (token, expires_at). Updates self.token.
"""
data = self._request("POST", "/v1/auth/renew")
assert data is not None
token = str(data["token"])
expires_at = str(data["expires_at"])
self.token = token
return token, expires_at
def enroll_totp(self) -> tuple[str, str]:
"""POST /v1/auth/totp/enroll — begin TOTP enrollment.
Returns (secret, otpauth_uri). The secret is shown only once.
"""
data = self._request("POST", "/v1/auth/totp/enroll")
assert data is not None
return str(data["secret"]), str(data["otpauth_uri"])
def confirm_totp(self, code: str) -> None:
"""POST /v1/auth/totp/confirm — confirm TOTP enrollment with a code."""
self._request("POST", "/v1/auth/totp/confirm", json={"code": code})
def change_password(self, current_password: str, new_password: str) -> None:
"""PUT /v1/auth/password — change own password (self-service)."""
self._request(
"PUT",
"/v1/auth/password",
json={"current_password": current_password, "new_password": new_password},
)
# ── Admin — Auth ──────────────────────────────────────────────────────────
def remove_totp(self, account_id: str) -> None:
"""DELETE /v1/auth/totp — remove TOTP from an account (admin)."""
self._request("DELETE", "/v1/auth/totp", json={"account_id": account_id})
# ── Admin — Tokens ────────────────────────────────────────────────────────
def issue_service_token(self, account_id: str) -> tuple[str, str]:
"""POST /v1/token/issue — issue a long-lived service token (admin).
Returns (token, expires_at).
"""
data = self._request("POST", "/v1/token/issue", json={"account_id": account_id})
assert data is not None
return str(data["token"]), str(data["expires_at"])
def revoke_token(self, jti: str) -> None:
"""DELETE /v1/token/{jti} — revoke a token by JTI (admin)."""
self._request("DELETE", f"/v1/token/{jti}")
# ── Admin — Accounts ──────────────────────────────────────────────────────
def list_accounts(self) -> list[Account]:
"""GET /v1/accounts — list all accounts (admin).
The API returns a JSON array directly (no wrapper object).
"""
items = self._request_list("GET", "/v1/accounts")
return [Account.from_dict(a) for a in items]
def create_account(
self,
username: str,
account_type: str,
*,
password: str | None = None,
) -> Account:
"""POST /v1/accounts — create a new account (admin)."""
payload: dict[str, Any] = {
"username": username,
"account_type": account_type,
}
if password is not None:
payload["password"] = password
data = self._request("POST", "/v1/accounts", json=payload)
assert data is not None
return Account.from_dict(data)
def get_account(self, account_id: str) -> Account:
"""GET /v1/accounts/{id} — retrieve a single account (admin)."""
data = self._request("GET", f"/v1/accounts/{account_id}")
assert data is not None
return Account.from_dict(data)
def update_account(
self,
account_id: str,
*,
status: str | None = None,
) -> None:
"""PATCH /v1/accounts/{id} — update account fields (admin).
Currently only `status` is patchable. Returns None (204 No Content).
"""
payload: dict[str, Any] = {}
if status is not None:
payload["status"] = status
self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
def delete_account(self, account_id: str) -> None:
"""DELETE /v1/accounts/{id} — soft-delete an account (admin)."""
self._request("DELETE", f"/v1/accounts/{account_id}")
def get_roles(self, account_id: str) -> list[str]:
"""GET /v1/accounts/{id}/roles — list roles for an account (admin)."""
data = self._request("GET", f"/v1/accounts/{account_id}/roles")
assert data is not None
roles_raw = data.get("roles") or []
return [str(r) for r in roles_raw]
def set_roles(self, account_id: str, roles: list[str]) -> None:
"""PUT /v1/accounts/{id}/roles — replace the full role set (admin)."""
self._request(
"PUT",
f"/v1/accounts/{account_id}/roles",
json={"roles": roles},
)
def admin_set_password(self, account_id: str, new_password: str) -> None:
"""PUT /v1/accounts/{id}/password — reset a password without the old one (admin)."""
self._request(
"PUT",
f"/v1/accounts/{account_id}/password",
json={"new_password": new_password},
)
# ── Admin — Credentials ───────────────────────────────────────────────────
def get_pg_creds(self, account_id: str) -> PGCreds:
"""GET /v1/accounts/{id}/pgcreds — retrieve Postgres credentials (admin)."""
data = self._request("GET", f"/v1/accounts/{account_id}/pgcreds")
assert data is not None
return PGCreds.from_dict(data)
def set_pg_creds(
self,
account_id: str,
host: str,
port: int,
database: str,
username: str,
password: str,
) -> None:
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials (admin)."""
payload: dict[str, Any] = {
"host": host,
"port": port,
"database": database,
"username": username,
"password": password,
}
self._request("PUT", f"/v1/accounts/{account_id}/pgcreds", json=payload)
# ── Admin — Policy ────────────────────────────────────────────────────────
def get_account_tags(self, account_id: str) -> list[str]:
"""GET /v1/accounts/{id}/tags — get account tags (admin)."""
data = self._request("GET", f"/v1/accounts/{account_id}/tags")
assert data is not None
return [str(t) for t in (data.get("tags") or [])]
def set_account_tags(self, account_id: str, tags: list[str]) -> list[str]:
"""PUT /v1/accounts/{id}/tags — replace the full tag set (admin).
Returns the updated tag list.
"""
data = self._request(
"PUT",
f"/v1/accounts/{account_id}/tags",
json={"tags": tags},
)
assert data is not None
return [str(t) for t in (data.get("tags") or [])]
def list_policy_rules(self) -> list[PolicyRule]:
"""GET /v1/policy/rules — list all operator policy rules (admin)."""
items = self._request_list("GET", "/v1/policy/rules")
return [PolicyRule.from_dict(r) for r in items]
def create_policy_rule(
self,
description: str,
rule: RuleBody,
*,
priority: int | None = None,
not_before: str | None = None,
expires_at: str | None = None,
) -> PolicyRule:
"""POST /v1/policy/rules — create a policy rule (admin)."""
payload: dict[str, Any] = {
"description": description,
"rule": rule.to_dict(),
}
if priority is not None:
payload["priority"] = priority
if not_before is not None:
payload["not_before"] = not_before
if expires_at is not None:
payload["expires_at"] = expires_at
data = self._request("POST", "/v1/policy/rules", json=payload)
assert data is not None
return PolicyRule.from_dict(data)
def get_policy_rule(self, rule_id: int) -> PolicyRule:
"""GET /v1/policy/rules/{id} — get a policy rule (admin)."""
data = self._request("GET", f"/v1/policy/rules/{rule_id}")
assert data is not None
return PolicyRule.from_dict(data)
def update_policy_rule(
self,
rule_id: int,
*,
description: str | None = None,
priority: int | None = None,
enabled: bool | None = None,
rule: RuleBody | None = None,
not_before: str | None = None,
expires_at: str | None = None,
clear_not_before: bool | None = None,
clear_expires_at: bool | None = None,
) -> PolicyRule:
"""PATCH /v1/policy/rules/{id} — update a policy rule (admin)."""
payload: dict[str, Any] = {}
if description is not None:
payload["description"] = description
if priority is not None:
payload["priority"] = priority
if enabled is not None:
payload["enabled"] = enabled
if rule is not None:
payload["rule"] = rule.to_dict()
if not_before is not None:
payload["not_before"] = not_before
if expires_at is not None:
payload["expires_at"] = expires_at
if clear_not_before is not None:
payload["clear_not_before"] = clear_not_before
if clear_expires_at is not None:
payload["clear_expires_at"] = clear_expires_at
data = self._request("PATCH", f"/v1/policy/rules/{rule_id}", json=payload)
assert data is not None
return PolicyRule.from_dict(data)
def delete_policy_rule(self, rule_id: int) -> None:
"""DELETE /v1/policy/rules/{id} — delete a policy rule (admin)."""
self._request("DELETE", f"/v1/policy/rules/{rule_id}")