- 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>
358 lines
15 KiB
Python
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}")
|