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>
This commit is contained in:
@@ -8,7 +8,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from ._errors import raise_for_status
|
||||
from ._models import Account, PGCreds, PublicKey, TokenClaims
|
||||
from ._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
|
||||
|
||||
|
||||
class Client:
|
||||
@@ -76,6 +76,29 @@ class Client:
|
||||
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")
|
||||
@@ -105,6 +128,12 @@ class Client:
|
||||
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")
|
||||
@@ -119,11 +148,45 @@ class Client:
|
||||
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})
|
||||
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 TokenClaims.from_dict(data)
|
||||
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,
|
||||
@@ -131,7 +194,7 @@ class Client:
|
||||
*,
|
||||
password: str | None = None,
|
||||
) -> Account:
|
||||
"""POST /v1/accounts — create a new account."""
|
||||
"""POST /v1/accounts — create a new account (admin)."""
|
||||
payload: dict[str, Any] = {
|
||||
"username": username,
|
||||
"account_type": account_type,
|
||||
@@ -141,14 +204,8 @@ class Client:
|
||||
data = self._request("POST", "/v1/accounts", json=payload)
|
||||
assert data is not None
|
||||
return Account.from_dict(data)
|
||||
def list_accounts(self) -> list[Account]:
|
||||
"""GET /v1/accounts — list all accounts."""
|
||||
data = self._request("GET", "/v1/accounts")
|
||||
assert data is not None
|
||||
accounts_raw = data.get("accounts") or []
|
||||
return [Account.from_dict(a) for a in accounts_raw]
|
||||
def get_account(self, account_id: str) -> Account:
|
||||
"""GET /v1/accounts/{id} — retrieve a single 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)
|
||||
@@ -157,42 +214,40 @@ class Client:
|
||||
account_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
) -> Account:
|
||||
"""PATCH /v1/accounts/{id} — update account fields."""
|
||||
) -> 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
|
||||
data = self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
|
||||
assert data is not None
|
||||
return Account.from_dict(data)
|
||||
self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
|
||||
def delete_account(self, account_id: str) -> None:
|
||||
"""DELETE /v1/accounts/{id} — permanently remove an account."""
|
||||
"""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."""
|
||||
"""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."""
|
||||
"""PUT /v1/accounts/{id}/roles — replace the full role set (admin)."""
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/v1/accounts/{account_id}/roles",
|
||||
json={"roles": roles},
|
||||
)
|
||||
def issue_service_token(self, account_id: str) -> tuple[str, str]:
|
||||
"""POST /v1/accounts/{id}/token — issue a long-lived service token.
|
||||
Returns (token, expires_at).
|
||||
"""
|
||||
data = self._request("POST", f"/v1/accounts/{account_id}/token")
|
||||
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."""
|
||||
self._request("DELETE", f"/v1/token/{jti}")
|
||||
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."""
|
||||
"""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)
|
||||
@@ -205,7 +260,7 @@ class Client:
|
||||
username: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials."""
|
||||
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials (admin)."""
|
||||
payload: dict[str, Any] = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
@@ -214,3 +269,89 @@ class Client:
|
||||
"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}")
|
||||
|
||||
Reference in New Issue
Block a user