"""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}")