chore(python): audit core package
_client_common.py owns request building and EOR signing for both clients; types follow the Backend contract; the deprecated issue_token alias and token response aliases are removed; crypto, utils, and CLI comments trimmed. Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
1 parent
54e65eb56a
commit
0f72a5e2f1
10 files changed
+414
-706
No files matched your search
@@ -168,7 +168,7 @@ auth = ElydoraClient.login(base_url, email, password)
|
|||||||
me = client.get_me()
|
me = client.get_me()
|
||||||
|
|
||||||
# Issue a new API token (with optional TTL in seconds)
|
# Issue a new API token (with optional TTL in seconds)
|
||||||
token_resp = client.issue_token(ttl_seconds=3600)
|
token_resp = client.issue_api_token(ttl_seconds=3600)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
@@ -320,7 +320,7 @@ from elydora import (
|
|||||||
VerifyOperationResponse, AuditQueryResponse, GetEpochResponse,
|
VerifyOperationResponse, AuditQueryResponse, GetEpochResponse,
|
||||||
ListEpochsResponse, CreateExportResponse, GetExportResponse,
|
ListEpochsResponse, CreateExportResponse, GetExportResponse,
|
||||||
ListExportsResponse, JWKSResponse, AuthRegisterResponse,
|
ListExportsResponse, JWKSResponse, AuthRegisterResponse,
|
||||||
AuthLoginResponse, GetMeResponse, IssueTokenResponse, HealthResponse,
|
AuthLoginResponse, GetMeResponse, IssueApiTokenResponse, HealthResponse,
|
||||||
|
|
||||||
# Request types
|
# Request types
|
||||||
RegisterAgentRequest,
|
RegisterAgentRequest,
|
||||||
|
|||||||
+8
-8
@@ -21,6 +21,10 @@ from .types import (
|
|||||||
AgentKey,
|
AgentKey,
|
||||||
AgentAssignment,
|
AgentAssignment,
|
||||||
AdminEvent,
|
AdminEvent,
|
||||||
|
AuthMeUser,
|
||||||
|
CurrentOrganization,
|
||||||
|
DependencyHealth,
|
||||||
|
ErrorCode,
|
||||||
EpochAnchor,
|
EpochAnchor,
|
||||||
MemberEntry,
|
MemberEntry,
|
||||||
Operation,
|
Operation,
|
||||||
@@ -65,8 +69,6 @@ from .types import (
|
|||||||
GetMeResponse,
|
GetMeResponse,
|
||||||
IssueApiTokenResponse,
|
IssueApiTokenResponse,
|
||||||
RotateApiTokenResponse,
|
RotateApiTokenResponse,
|
||||||
IssueTokenResponse,
|
|
||||||
RotateTokenResponse,
|
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
DeepHealthResponse,
|
DeepHealthResponse,
|
||||||
)
|
)
|
||||||
@@ -87,7 +89,6 @@ __all__ = [
|
|||||||
"base64url_decode",
|
"base64url_decode",
|
||||||
"generate_nonce",
|
"generate_nonce",
|
||||||
"generate_uuidv7",
|
"generate_uuidv7",
|
||||||
# Literal type aliases
|
|
||||||
"AdminAction",
|
"AdminAction",
|
||||||
"AgentStatus",
|
"AgentStatus",
|
||||||
"ExportStatus",
|
"ExportStatus",
|
||||||
@@ -95,13 +96,16 @@ __all__ = [
|
|||||||
"IntegrationType",
|
"IntegrationType",
|
||||||
"KeyStatus",
|
"KeyStatus",
|
||||||
"RbacRole",
|
"RbacRole",
|
||||||
# Entity types
|
|
||||||
"EOR",
|
"EOR",
|
||||||
"EAR",
|
"EAR",
|
||||||
"Agent",
|
"Agent",
|
||||||
"AgentAssignment",
|
"AgentAssignment",
|
||||||
"AgentKey",
|
"AgentKey",
|
||||||
"AdminEvent",
|
"AdminEvent",
|
||||||
|
"AuthMeUser",
|
||||||
|
"CurrentOrganization",
|
||||||
|
"DependencyHealth",
|
||||||
|
"ErrorCode",
|
||||||
"EpochAnchor",
|
"EpochAnchor",
|
||||||
"MemberEntry",
|
"MemberEntry",
|
||||||
"Operation",
|
"Operation",
|
||||||
@@ -111,10 +115,8 @@ __all__ = [
|
|||||||
"Organization",
|
"Organization",
|
||||||
"User",
|
"User",
|
||||||
"WebhookEntry",
|
"WebhookEntry",
|
||||||
# Request types
|
|
||||||
"RegisterAgentRequest",
|
"RegisterAgentRequest",
|
||||||
"UpdateAgentRequest",
|
"UpdateAgentRequest",
|
||||||
# Response types
|
|
||||||
"RegisterAgentResponse",
|
"RegisterAgentResponse",
|
||||||
"UpdateAgentResponse",
|
"UpdateAgentResponse",
|
||||||
"GetAgentResponse",
|
"GetAgentResponse",
|
||||||
@@ -141,8 +143,6 @@ __all__ = [
|
|||||||
"GetMeResponse",
|
"GetMeResponse",
|
||||||
"IssueApiTokenResponse",
|
"IssueApiTokenResponse",
|
||||||
"RotateApiTokenResponse",
|
"RotateApiTokenResponse",
|
||||||
"IssueTokenResponse",
|
|
||||||
"RotateTokenResponse",
|
|
||||||
"HealthResponse",
|
"HealthResponse",
|
||||||
"DeepHealthResponse",
|
"DeepHealthResponse",
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Request and response building shared by the sync and async clients."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
||||||
|
from .errors import ElydoraError
|
||||||
|
from .types import EOR
|
||||||
|
from .utils import generate_nonce, generate_uuidv7
|
||||||
|
|
||||||
|
# 32 zero bytes; equals the Backend GENESIS_CHAIN_HASH.
|
||||||
|
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
DEFAULT_BASE_URL = "https://api.elydora.com"
|
||||||
|
REQUEST_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def build_headers(token: Optional[str]) -> Dict[str, str]:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def error_from_response(status_code: int, body: Any, text: str) -> ElydoraError:
|
||||||
|
error = body.get("error", {}) if isinstance(body, dict) else {}
|
||||||
|
return ElydoraError(
|
||||||
|
code=error.get("code", "INTERNAL_ERROR"),
|
||||||
|
message=error.get("message") or text or "Unknown error",
|
||||||
|
request_id=error.get("request_id", ""),
|
||||||
|
details=error.get("details"),
|
||||||
|
status_code=status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def without_none(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {key: value for key, value in values.items() if value is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def register_body(
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
display_name: Optional[str],
|
||||||
|
org_name: Optional[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return without_none({
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
"display_name": display_name,
|
||||||
|
"org_name": org_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def api_token_body(ttl_seconds: Optional[int]) -> Dict[str, Any]:
|
||||||
|
return without_none({"ttl_seconds": ttl_seconds})
|
||||||
|
|
||||||
|
|
||||||
|
def export_body(
|
||||||
|
start_time: int,
|
||||||
|
end_time: int,
|
||||||
|
format: str,
|
||||||
|
agent_id: Optional[str],
|
||||||
|
operation_type: Optional[str],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return without_none({
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"format": format,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"operation_type": operation_type,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def admin_events_params(limit: Optional[int]) -> Dict[str, str]:
|
||||||
|
return {} if limit is None else {"limit": str(limit)}
|
||||||
|
|
||||||
|
|
||||||
|
def build_eor(
|
||||||
|
*,
|
||||||
|
org_id: str,
|
||||||
|
agent_id: str,
|
||||||
|
private_key: str,
|
||||||
|
kid: str,
|
||||||
|
ttl_ms: int,
|
||||||
|
prev_chain_hash: str,
|
||||||
|
operation_type: str,
|
||||||
|
subject: Dict[str, Any],
|
||||||
|
action: Dict[str, Any],
|
||||||
|
payload: Union[Dict[str, Any], str, None],
|
||||||
|
) -> Tuple[EOR, str]:
|
||||||
|
"""Build and sign one EOR; returns the record and its chain hash."""
|
||||||
|
operation_id = generate_uuidv7()
|
||||||
|
issued_at = int(time.time() * 1000)
|
||||||
|
payload_hash = compute_payload_hash(payload)
|
||||||
|
chain_hash = compute_chain_hash(prev_chain_hash, payload_hash, operation_id, issued_at)
|
||||||
|
eor: Dict[str, Any] = {
|
||||||
|
"op_version": "1.0",
|
||||||
|
"operation_id": operation_id,
|
||||||
|
"org_id": org_id,
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"issued_at": issued_at,
|
||||||
|
"ttl_ms": ttl_ms,
|
||||||
|
"nonce": generate_nonce(),
|
||||||
|
"operation_type": operation_type,
|
||||||
|
"subject": subject,
|
||||||
|
"action": action,
|
||||||
|
"payload": payload,
|
||||||
|
"payload_hash": payload_hash,
|
||||||
|
"prev_chain_hash": prev_chain_hash,
|
||||||
|
"agent_pubkey_kid": kid,
|
||||||
|
"signature": "",
|
||||||
|
}
|
||||||
|
eor["signature"] = sign_eor(eor, private_key)
|
||||||
|
return eor, chain_hash # type: ignore[return-value]
|
||||||
+113
-254
@@ -2,16 +2,26 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Any, Dict, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ._async_http import request_with_retries
|
from ._async_http import request_with_retries
|
||||||
|
from ._client_common import (
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
GENESIS_CHAIN_HASH,
|
||||||
|
REQUEST_TIMEOUT_SECONDS,
|
||||||
|
admin_events_params,
|
||||||
|
api_token_body,
|
||||||
|
build_eor,
|
||||||
|
build_headers,
|
||||||
|
error_from_response,
|
||||||
|
export_body,
|
||||||
|
register_body,
|
||||||
|
without_none,
|
||||||
|
)
|
||||||
from ._retry import require_max_retries
|
from ._retry import require_max_retries
|
||||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
|
||||||
from .errors import ElydoraError
|
|
||||||
from .integration_types import require_integration_type
|
from .integration_types import require_integration_type
|
||||||
from .types import (
|
from .types import (
|
||||||
AuditQueryResponse,
|
AuditQueryResponse,
|
||||||
@@ -21,35 +31,33 @@ from .types import (
|
|||||||
DeepHealthResponse,
|
DeepHealthResponse,
|
||||||
DeleteAgentResponse,
|
DeleteAgentResponse,
|
||||||
EOR,
|
EOR,
|
||||||
|
FreezeAgentResponse,
|
||||||
GetAgentResponse,
|
GetAgentResponse,
|
||||||
GetEpochResponse,
|
GetEpochResponse,
|
||||||
GetExportResponse,
|
GetExportResponse,
|
||||||
GetMeResponse,
|
GetMeResponse,
|
||||||
GetOperationResponse,
|
GetOperationResponse,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
IssueApiTokenResponse,
|
|
||||||
IntegrationType,
|
IntegrationType,
|
||||||
ListAdminEventsResponse,
|
IssueApiTokenResponse,
|
||||||
ListMembersResponse,
|
|
||||||
ListWebhooksResponse,
|
|
||||||
RotateApiTokenResponse,
|
|
||||||
JWKSResponse,
|
JWKSResponse,
|
||||||
|
ListAdminEventsResponse,
|
||||||
ListAgentsResponse,
|
ListAgentsResponse,
|
||||||
ListEpochsResponse,
|
ListEpochsResponse,
|
||||||
ListExportsResponse,
|
ListExportsResponse,
|
||||||
|
ListMembersResponse,
|
||||||
|
ListWebhooksResponse,
|
||||||
RegisterAgentRequest,
|
RegisterAgentRequest,
|
||||||
RegisterAgentResponse,
|
RegisterAgentResponse,
|
||||||
RegisterWebhookResponse,
|
RegisterWebhookResponse,
|
||||||
|
RotateApiTokenResponse,
|
||||||
SubmitOperationResponse,
|
SubmitOperationResponse,
|
||||||
UnfreezeAgentResponse,
|
UnfreezeAgentResponse,
|
||||||
UpdateAgentResponse,
|
UpdateAgentResponse,
|
||||||
VerifyOperationResponse,
|
VerifyOperationResponse,
|
||||||
)
|
)
|
||||||
from .utils import generate_nonce, generate_uuidv7
|
|
||||||
|
|
||||||
# The genesis chain hash: base64url encoding of 32 zero bytes.
|
TIMEOUT = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SECONDS)
|
||||||
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
|
||||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncElydoraClient:
|
class AsyncElydoraClient:
|
||||||
@@ -61,7 +69,7 @@ class AsyncElydoraClient:
|
|||||||
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
||||||
base_url: API base URL.
|
base_url: API base URL.
|
||||||
ttl_ms: Time-to-live for operations in milliseconds.
|
ttl_ms: Time-to-live for operations in milliseconds.
|
||||||
max_retries: Maximum number of retries on transient failures.
|
max_retries: Retries after the initial attempt on transient failures.
|
||||||
token: Optional API token for authenticated endpoints.
|
token: Optional API token for authenticated endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@ class AsyncElydoraClient:
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
private_key: str,
|
private_key: str,
|
||||||
*,
|
*,
|
||||||
base_url: str = "https://api.elydora.com",
|
base_url: str = DEFAULT_BASE_URL,
|
||||||
ttl_ms: int = 30000,
|
ttl_ms: int = 30000,
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
token: Optional[str] = None,
|
token: Optional[str] = None,
|
||||||
@@ -83,17 +91,14 @@ class AsyncElydoraClient:
|
|||||||
self.ttl_ms = ttl_ms
|
self.ttl_ms = ttl_ms
|
||||||
self.max_retries = require_max_retries(max_retries)
|
self.max_retries = require_max_retries(max_retries)
|
||||||
self.token = token
|
self.token = token
|
||||||
|
|
||||||
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
||||||
self._kid = agent_id + '-key-1'
|
self._kid = agent_id + "-key-1"
|
||||||
self._session: Optional[aiohttp.ClientSession] = None
|
self._session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
def set_kid(self, kid: str) -> None:
|
def set_kid(self, kid: str) -> None:
|
||||||
"""Set the key ID used for signing operations."""
|
|
||||||
self._kid = kid
|
self._kid = kid
|
||||||
|
|
||||||
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
||||||
"""Set the previous chain hash for the next operation."""
|
|
||||||
self._prev_chain_hash = prev_chain_hash
|
self._prev_chain_hash = prev_chain_hash
|
||||||
|
|
||||||
async def _get_session(self) -> aiohttp.ClientSession:
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
@@ -102,20 +107,9 @@ class AsyncElydoraClient:
|
|||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the underlying HTTP session."""
|
|
||||||
if self._session and not self._session.closed:
|
if self._session and not self._session.closed:
|
||||||
await self._session.close()
|
await self._session.close()
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Internal HTTP helpers
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
|
||||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
|
||||||
if self.token:
|
|
||||||
headers["Authorization"] = f"Bearer {self.token}"
|
|
||||||
return headers
|
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -123,51 +117,43 @@ class AsyncElydoraClient:
|
|||||||
*,
|
*,
|
||||||
json_body: Any = None,
|
json_body: Any = None,
|
||||||
params: Optional[Dict[str, str]] = None,
|
params: Optional[Dict[str, str]] = None,
|
||||||
headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
url = f"{self.base_url}{path}"
|
|
||||||
hdrs = headers or self._headers()
|
|
||||||
session = await self._get_session()
|
|
||||||
|
|
||||||
return await request_with_retries(
|
return await request_with_retries(
|
||||||
session,
|
await self._get_session(),
|
||||||
method,
|
method,
|
||||||
url,
|
f"{self.base_url}{path}",
|
||||||
path=path,
|
path=path,
|
||||||
max_retries=self.max_retries,
|
max_retries=self.max_retries,
|
||||||
response_handler=self._handle_response,
|
response_handler=self._handle_response,
|
||||||
json_body=json_body,
|
json_body=json_body,
|
||||||
params=params,
|
params=params,
|
||||||
headers=hdrs,
|
headers=build_headers(self.token),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _get_public(self, path: str) -> Any:
|
||||||
|
session = await self._get_session()
|
||||||
|
async with session.get(f"{self.base_url}{path}", timeout=TIMEOUT) as response:
|
||||||
|
return await self._handle_response(response)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _handle_response(resp: aiohttp.ClientResponse) -> Any:
|
async def _handle_response(resp: aiohttp.ClientResponse) -> Any:
|
||||||
if resp.status >= 400:
|
if resp.status >= 400:
|
||||||
try:
|
try:
|
||||||
body = await resp.json()
|
body = await resp.json()
|
||||||
except Exception:
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
text = await resp.text()
|
body = None
|
||||||
raise ElydoraError(
|
raise error_from_response(resp.status, body, await resp.text())
|
||||||
code="INTERNAL_ERROR",
|
|
||||||
message=text or "Unknown error",
|
|
||||||
status_code=resp.status,
|
|
||||||
)
|
|
||||||
err = body.get("error", {})
|
|
||||||
raise ElydoraError(
|
|
||||||
code=err.get("code", "INTERNAL_ERROR"),
|
|
||||||
message=err.get("message", ""),
|
|
||||||
request_id=err.get("request_id", ""),
|
|
||||||
details=err.get("details"),
|
|
||||||
status_code=resp.status,
|
|
||||||
)
|
|
||||||
if resp.status == 204:
|
if resp.status == 204:
|
||||||
return None
|
return None
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
@staticmethod
|
||||||
# Auth (static methods)
|
async def _post_json(url: str, body: Dict[str, Any]) -> Any:
|
||||||
# -----------------------------------------------------------------
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
url, json=body, headers=build_headers(None), timeout=TIMEOUT
|
||||||
|
) as response:
|
||||||
|
return await AsyncElydoraClient._handle_response(response)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def register(
|
async def register(
|
||||||
@@ -177,113 +163,75 @@ class AsyncElydoraClient:
|
|||||||
display_name: Optional[str] = None,
|
display_name: Optional[str] = None,
|
||||||
org_name: Optional[str] = None,
|
org_name: Optional[str] = None,
|
||||||
) -> AuthRegisterResponse:
|
) -> AuthRegisterResponse:
|
||||||
"""Register a new user and organization.
|
"""Deprecated password registration; Console users sign in through Better Auth."""
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
Use Better Auth endpoints directly. See docs.
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"AsyncElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.",
|
"AsyncElydoraClient.register() is deprecated. Use Better Auth endpoints directly.",
|
||||||
DeprecationWarning,
|
DeprecationWarning,
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
||||||
body: Dict[str, Any] = {"email": email, "password": password}
|
return await AsyncElydoraClient._post_json(
|
||||||
if display_name is not None:
|
url, register_body(email, password, display_name, org_name)
|
||||||
body["display_name"] = display_name
|
)
|
||||||
if org_name is not None:
|
|
||||||
body["org_name"] = org_name
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(
|
|
||||||
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
|
|
||||||
) as resp:
|
|
||||||
return await AsyncElydoraClient._handle_response(resp)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
async def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
||||||
"""Authenticate and receive a session token.
|
"""Deprecated password login; Console users sign in through Better Auth."""
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
Use Better Auth endpoints directly. See docs.
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"AsyncElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.",
|
"AsyncElydoraClient.login() is deprecated. Use Better Auth endpoints directly.",
|
||||||
DeprecationWarning,
|
DeprecationWarning,
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
||||||
body = {"email": email, "password": password}
|
return await AsyncElydoraClient._post_json(url, {"email": email, "password": password})
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(
|
|
||||||
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
|
|
||||||
) as resp:
|
|
||||||
return await AsyncElydoraClient._handle_response(resp)
|
|
||||||
|
|
||||||
async def get_me(self) -> GetMeResponse:
|
async def get_me(self) -> GetMeResponse:
|
||||||
"""Get the current user's profile."""
|
|
||||||
return await self._request("GET", "/v1/auth/me")
|
return await self._request("GET", "/v1/auth/me")
|
||||||
|
|
||||||
async def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
async def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
||||||
"""Issue an API token with an optional TTL."""
|
return await self._request(
|
||||||
body: Dict[str, Any] = {}
|
"POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds)
|
||||||
if ttl_seconds is not None:
|
)
|
||||||
body["ttl_seconds"] = ttl_seconds
|
|
||||||
return await self._request("POST", "/v1/auth/token", json_body=body)
|
|
||||||
|
|
||||||
async def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
|
||||||
"""Deprecated: Use issue_api_token() instead."""
|
|
||||||
return await self.issue_api_token(ttl_seconds=ttl_seconds)
|
|
||||||
|
|
||||||
async def rotate_api_token(self) -> RotateApiTokenResponse:
|
async def rotate_api_token(self) -> RotateApiTokenResponse:
|
||||||
"""Rotate the current API token and keep the old token in 24h grace."""
|
|
||||||
return await self._request("POST", "/v1/auth/rotate", json_body={})
|
return await self._request("POST", "/v1/auth/rotate", json_body={})
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Agent management
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
async def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||||
"""Register a new agent with the organization."""
|
|
||||||
require_integration_type(request.get("integration_type"))
|
require_integration_type(request.get("integration_type"))
|
||||||
return await self._request("POST", "/v1/agents/register", json_body=request)
|
return await self._request("POST", "/v1/agents/register", json_body=request)
|
||||||
|
|
||||||
async def get_agent(self, agent_id: str) -> GetAgentResponse:
|
async def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||||
"""Retrieve agent details and keys."""
|
|
||||||
return await self._request("GET", f"/v1/agents/{agent_id}")
|
return await self._request("GET", f"/v1/agents/{agent_id}")
|
||||||
|
|
||||||
async def freeze_agent(self, agent_id: str, reason: str) -> None:
|
|
||||||
"""Freeze an agent."""
|
|
||||||
await self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
|
||||||
|
|
||||||
async def list_agents(self) -> ListAgentsResponse:
|
async def list_agents(self) -> ListAgentsResponse:
|
||||||
"""List all agents for the organization."""
|
|
||||||
return await self._request("GET", "/v1/agents")
|
return await self._request("GET", "/v1/agents")
|
||||||
|
|
||||||
async def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
|
async def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
|
||||||
"""Unfreeze an agent."""
|
return await self._request(
|
||||||
return await self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
"POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason}
|
||||||
|
)
|
||||||
|
|
||||||
async def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
|
async def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
|
||||||
"""Update an agent's integration type."""
|
return await self._request(
|
||||||
|
"POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_agent(
|
||||||
|
self, agent_id: str, integration_type: IntegrationType
|
||||||
|
) -> UpdateAgentResponse:
|
||||||
require_integration_type(integration_type)
|
require_integration_type(integration_type)
|
||||||
return await self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type})
|
return await self._request(
|
||||||
|
"PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}
|
||||||
|
)
|
||||||
|
|
||||||
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||||
"""Delete an agent."""
|
|
||||||
return await self._request("DELETE", f"/v1/agents/{agent_id}")
|
return await self._request("DELETE", f"/v1/agents/{agent_id}")
|
||||||
|
|
||||||
async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||||
"""Revoke an agent's key."""
|
|
||||||
await self._request(
|
await self._request(
|
||||||
"POST",
|
"POST", f"/v1/agents/{agent_id}/revoke", json_body={"kid": kid, "reason": reason}
|
||||||
f"/v1/agents/{agent_id}/revoke",
|
|
||||||
json_body={"kid": kid, "reason": reason},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Operations (CORE)
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def create_operation(
|
def create_operation(
|
||||||
self,
|
self,
|
||||||
operation_type: str,
|
operation_type: str,
|
||||||
@@ -291,58 +239,32 @@ class AsyncElydoraClient:
|
|||||||
action: Dict[str, Any],
|
action: Dict[str, Any],
|
||||||
payload: Union[Dict[str, Any], str, None] = None,
|
payload: Union[Dict[str, Any], str, None] = None,
|
||||||
) -> EOR:
|
) -> EOR:
|
||||||
"""Build and sign an Elydora Operation Record (EOR).
|
"""Build and sign an EOR locally; submit it with submit_operation()."""
|
||||||
|
eor, chain_hash = build_eor(
|
||||||
This is synchronous because it only performs local crypto operations.
|
org_id=self.org_id,
|
||||||
Call submit_operation() with the returned EOR to submit it.
|
agent_id=self.agent_id,
|
||||||
"""
|
private_key=self.private_key,
|
||||||
operation_id = generate_uuidv7()
|
kid=self._kid,
|
||||||
issued_at = int(time.time() * 1000)
|
ttl_ms=self.ttl_ms,
|
||||||
nonce = generate_nonce()
|
prev_chain_hash=self._prev_chain_hash,
|
||||||
payload_hash = compute_payload_hash(payload)
|
operation_type=operation_type,
|
||||||
chain_hash = compute_chain_hash(
|
subject=subject,
|
||||||
self._prev_chain_hash, payload_hash, operation_id, issued_at
|
action=action,
|
||||||
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
eor: Dict[str, Any] = {
|
|
||||||
"op_version": "1.0",
|
|
||||||
"operation_id": operation_id,
|
|
||||||
"org_id": self.org_id,
|
|
||||||
"agent_id": self.agent_id,
|
|
||||||
"issued_at": issued_at,
|
|
||||||
"ttl_ms": self.ttl_ms,
|
|
||||||
"nonce": nonce,
|
|
||||||
"operation_type": operation_type,
|
|
||||||
"subject": subject,
|
|
||||||
"action": action,
|
|
||||||
"payload": payload,
|
|
||||||
"payload_hash": payload_hash,
|
|
||||||
"prev_chain_hash": self._prev_chain_hash,
|
|
||||||
"agent_pubkey_kid": self._kid,
|
|
||||||
"signature": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
eor["signature"] = sign_eor(eor, self.private_key)
|
|
||||||
|
|
||||||
self._prev_chain_hash = chain_hash
|
self._prev_chain_hash = chain_hash
|
||||||
|
return eor
|
||||||
return eor # type: ignore[return-value]
|
|
||||||
|
|
||||||
async def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
async def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
||||||
"""Submit a signed EOR to the server."""
|
|
||||||
return await self._request("POST", "/v1/operations", json_body=eor)
|
return await self._request("POST", "/v1/operations", json_body=eor)
|
||||||
|
|
||||||
async def get_operation(self, operation_id: str) -> GetOperationResponse:
|
async def get_operation(self, operation_id: str) -> GetOperationResponse:
|
||||||
"""Retrieve an operation by ID."""
|
|
||||||
return await self._request("GET", f"/v1/operations/{operation_id}")
|
return await self._request("GET", f"/v1/operations/{operation_id}")
|
||||||
|
|
||||||
async def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
async def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
||||||
"""Verify an operation's integrity."""
|
return await self._request(
|
||||||
return await self._request("POST", f"/v1/operations/{operation_id}/verify", json_body={})
|
"POST", f"/v1/operations/{operation_id}/verify", json_body={}
|
||||||
|
)
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Audit
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def query_audit(
|
async def query_audit(
|
||||||
self,
|
self,
|
||||||
@@ -355,40 +277,23 @@ class AsyncElydoraClient:
|
|||||||
cursor: Optional[str] = None,
|
cursor: Optional[str] = None,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
) -> AuditQueryResponse:
|
) -> AuditQueryResponse:
|
||||||
"""Query the tamper-evident audit log."""
|
body = without_none({
|
||||||
body: Dict[str, Any] = {}
|
"org_id": org_id,
|
||||||
if org_id is not None:
|
"agent_id": agent_id,
|
||||||
body["org_id"] = org_id
|
"operation_type": operation_type,
|
||||||
if agent_id is not None:
|
"start_time": start_time,
|
||||||
body["agent_id"] = agent_id
|
"end_time": end_time,
|
||||||
if operation_type is not None:
|
"cursor": cursor,
|
||||||
body["operation_type"] = operation_type
|
"limit": limit,
|
||||||
if start_time is not None:
|
})
|
||||||
body["start_time"] = start_time
|
|
||||||
if end_time is not None:
|
|
||||||
body["end_time"] = end_time
|
|
||||||
if cursor is not None:
|
|
||||||
body["cursor"] = cursor
|
|
||||||
if limit is not None:
|
|
||||||
body["limit"] = limit
|
|
||||||
return await self._request("POST", "/v1/audit/query", json_body=body)
|
return await self._request("POST", "/v1/audit/query", json_body=body)
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Epochs
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_epochs(self) -> ListEpochsResponse:
|
async def list_epochs(self) -> ListEpochsResponse:
|
||||||
"""List all epochs for the organization."""
|
|
||||||
return await self._request("GET", "/v1/epochs")
|
return await self._request("GET", "/v1/epochs")
|
||||||
|
|
||||||
async def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
async def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
||||||
"""Retrieve an epoch root record."""
|
|
||||||
return await self._request("GET", f"/v1/epochs/{epoch_id}")
|
return await self._request("GET", f"/v1/epochs/{epoch_id}")
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Exports
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def create_export(
|
async def create_export(
|
||||||
self,
|
self,
|
||||||
start_time: int,
|
start_time: int,
|
||||||
@@ -397,97 +302,51 @@ class AsyncElydoraClient:
|
|||||||
agent_id: Optional[str] = None,
|
agent_id: Optional[str] = None,
|
||||||
operation_type: Optional[str] = None,
|
operation_type: Optional[str] = None,
|
||||||
) -> CreateExportResponse:
|
) -> CreateExportResponse:
|
||||||
"""Create a compliance export job."""
|
body = export_body(start_time, end_time, format, agent_id, operation_type)
|
||||||
body: Dict[str, Any] = {
|
|
||||||
"start_time": start_time,
|
|
||||||
"end_time": end_time,
|
|
||||||
"format": format,
|
|
||||||
}
|
|
||||||
if agent_id is not None:
|
|
||||||
body["agent_id"] = agent_id
|
|
||||||
if operation_type is not None:
|
|
||||||
body["operation_type"] = operation_type
|
|
||||||
return await self._request("POST", "/v1/exports", json_body=body)
|
return await self._request("POST", "/v1/exports", json_body=body)
|
||||||
|
|
||||||
async def list_exports(self) -> ListExportsResponse:
|
async def list_exports(self) -> ListExportsResponse:
|
||||||
"""List all exports for the organization."""
|
|
||||||
return await self._request("GET", "/v1/exports")
|
return await self._request("GET", "/v1/exports")
|
||||||
|
|
||||||
async def get_export(self, export_id: str) -> GetExportResponse:
|
async def get_export(self, export_id: str) -> GetExportResponse:
|
||||||
"""Retrieve export status and download URL."""
|
|
||||||
return await self._request("GET", f"/v1/exports/{export_id}")
|
return await self._request("GET", f"/v1/exports/{export_id}")
|
||||||
|
|
||||||
async def download_export(self, export_id: str) -> bytes:
|
async def download_export(self, export_id: str) -> bytes:
|
||||||
"""Download an export file as raw bytes."""
|
|
||||||
url = f"{self.base_url}/v1/exports/{export_id}/download"
|
|
||||||
session = await self._get_session()
|
session = await self._get_session()
|
||||||
async with session.get(
|
async with session.get(
|
||||||
url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)
|
f"{self.base_url}/v1/exports/{export_id}/download",
|
||||||
) as resp:
|
headers=build_headers(self.token),
|
||||||
if resp.status >= 400:
|
timeout=TIMEOUT,
|
||||||
await self._handle_response(resp)
|
) as response:
|
||||||
return await resp.read()
|
if response.status >= 400:
|
||||||
|
await self._handle_response(response)
|
||||||
# -----------------------------------------------------------------
|
return await response.read()
|
||||||
# Webhooks
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_webhooks(self) -> ListWebhooksResponse:
|
async def list_webhooks(self) -> ListWebhooksResponse:
|
||||||
"""List all webhooks for the organization."""
|
|
||||||
return await self._request("GET", "/v1/webhooks")
|
return await self._request("GET", "/v1/webhooks")
|
||||||
|
|
||||||
async def register_webhook(self, endpoint_url: str, events: list, secret: str) -> RegisterWebhookResponse:
|
async def register_webhook(
|
||||||
"""Register a new webhook."""
|
self, endpoint_url: str, events: List[str], secret: str
|
||||||
return await self._request("POST", "/v1/webhooks", json_body={"endpoint_url": endpoint_url, "events": events, "secret": secret})
|
) -> RegisterWebhookResponse:
|
||||||
|
body = {"endpoint_url": endpoint_url, "events": events, "secret": secret}
|
||||||
|
return await self._request("POST", "/v1/webhooks", json_body=body)
|
||||||
|
|
||||||
async def delete_webhook(self, webhook_id: str) -> None:
|
async def delete_webhook(self, webhook_id: str) -> None:
|
||||||
"""Delete a webhook."""
|
|
||||||
await self._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
await self._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Members
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_members(self) -> ListMembersResponse:
|
async def list_members(self) -> ListMembersResponse:
|
||||||
"""List all members in the organization."""
|
|
||||||
return await self._request("GET", "/v1/members")
|
return await self._request("GET", "/v1/members")
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Admin events
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
|
async def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
|
||||||
"""List recent admin events."""
|
return await self._request(
|
||||||
params: Dict[str, str] = {}
|
"GET", "/v1/admin/events", params=admin_events_params(limit)
|
||||||
if limit is not None:
|
)
|
||||||
params["limit"] = str(limit)
|
|
||||||
return await self._request("GET", "/v1/admin/events", params=params)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# JWKS
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def get_jwks(self) -> JWKSResponse:
|
async def get_jwks(self) -> JWKSResponse:
|
||||||
"""Retrieve the platform JWKS (public, no auth required)."""
|
return await self._get_public("/.well-known/elydora/jwks.json")
|
||||||
url = f"{self.base_url}/.well-known/elydora/jwks.json"
|
|
||||||
session = await self._get_session()
|
|
||||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
|
||||||
return await self._handle_response(resp)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Health
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
async def health(self) -> HealthResponse:
|
async def health(self) -> HealthResponse:
|
||||||
"""Check API health (public, no auth required)."""
|
return await self._get_public("/v1/health")
|
||||||
url = f"{self.base_url}/v1/health"
|
|
||||||
session = await self._get_session()
|
|
||||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
|
||||||
return await self._handle_response(resp)
|
|
||||||
|
|
||||||
async def deep_health(self) -> DeepHealthResponse:
|
async def deep_health(self) -> DeepHealthResponse:
|
||||||
"""Check API deep health (public, no auth required)."""
|
return await self._get_public("/v1/health/deep")
|
||||||
url = f"{self.base_url}/v1/health/deep"
|
|
||||||
session = await self._get_session()
|
|
||||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
|
||||||
return await self._handle_response(resp)
|
|
||||||
+5
-24
@@ -1,8 +1,4 @@
|
|||||||
"""Elydora CLI — install, uninstall, and manage agent audit hooks.
|
"""Elydora CLI: install, uninstall, and inspect agent audit hooks."""
|
||||||
|
|
||||||
Entry point: ``elydora`` console script (see pyproject.toml).
|
|
||||||
Uses only stdlib argparse — zero external dependencies for the CLI itself.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -173,17 +169,15 @@ def _discover_installed_agents_or_exit() -> list[_InstalledAgent]:
|
|||||||
|
|
||||||
|
|
||||||
def _get_plugin(agent_name: str) -> AgentPlugin:
|
def _get_plugin(agent_name: str) -> AgentPlugin:
|
||||||
"""Instantiate the plugin for the given agent name."""
|
|
||||||
cls = PLUGIN_MAP.get(agent_name)
|
cls = PLUGIN_MAP.get(agent_name)
|
||||||
if cls is None:
|
if cls is None:
|
||||||
print(f"Error: Unknown agent '{agent_name}'.", file=sys.stderr)
|
_exit_with_error(
|
||||||
print(f"Supported agents: {', '.join(get_agent_names())}", file=sys.stderr)
|
f"Unknown agent {agent_name!r}. Supported agents: {', '.join(get_agent_names())}"
|
||||||
sys.exit(1)
|
)
|
||||||
return cls()
|
return cls()
|
||||||
|
|
||||||
|
|
||||||
def cmd_install(args: argparse.Namespace) -> None:
|
def cmd_install(args: argparse.Namespace) -> None:
|
||||||
"""Handle the 'install' subcommand."""
|
|
||||||
recover_pending_transactions()
|
recover_pending_transactions()
|
||||||
agent_name: str = args.agent
|
agent_name: str = args.agent
|
||||||
agent_dir = _resolve_agent_directory_or_exit(args.agent_id)
|
agent_dir = _resolve_agent_directory_or_exit(args.agent_id)
|
||||||
@@ -197,12 +191,10 @@ def cmd_install(args: argparse.Namespace) -> None:
|
|||||||
except (OSError, RuntimeError, ValueError) as error:
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
_exit_with_error(str(error))
|
_exit_with_error(str(error))
|
||||||
|
|
||||||
# Derive public key to verify the private key is valid
|
|
||||||
try:
|
try:
|
||||||
pub = get_public_key_base64url(secrets.private_key)
|
pub = get_public_key_base64url(secrets.private_key)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"Error: Invalid private key — {exc}", file=sys.stderr)
|
_exit_with_error(f"Invalid private key: {exc}")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Verified key pair (public key: {pub[:16]}...)")
|
print(f"Verified key pair (public key: {pub[:16]}...)")
|
||||||
|
|
||||||
@@ -220,12 +212,10 @@ def cmd_install(args: argparse.Namespace) -> None:
|
|||||||
config["token"] = secrets.token
|
config["token"] = secrets.token
|
||||||
plugin.preflight_install(config)
|
plugin.preflight_install(config)
|
||||||
|
|
||||||
# Transactional adapters create and recover their own runtime directories.
|
|
||||||
if not getattr(plugin, "manages_runtime_directories", False):
|
if not getattr(plugin, "manages_runtime_directories", False):
|
||||||
ensure_private_directory(runtime_root())
|
ensure_private_directory(runtime_root())
|
||||||
ensure_private_directory(agent_dir)
|
ensure_private_directory(agent_dir)
|
||||||
|
|
||||||
# Generate and write the guard script for adapters that use the shared runtime.
|
|
||||||
if not plugin.manages_guard_runtime:
|
if not plugin.manages_guard_runtime:
|
||||||
guard_script = generate_guard_script(agent_name, args.agent_id)
|
guard_script = generate_guard_script(agent_name, args.agent_id)
|
||||||
write_text_atomic(
|
write_text_atomic(
|
||||||
@@ -240,7 +230,6 @@ def cmd_install(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_uninstall(args: argparse.Namespace) -> None:
|
def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||||
"""Handle the 'uninstall' subcommand."""
|
|
||||||
recover_pending_transactions()
|
recover_pending_transactions()
|
||||||
explicit_agent_id = getattr(args, "agent_id", None)
|
explicit_agent_id = getattr(args, "agent_id", None)
|
||||||
elydora_dir = runtime_root()
|
elydora_dir = runtime_root()
|
||||||
@@ -296,7 +285,6 @@ def cmd_uninstall(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_status(args: argparse.Namespace) -> None:
|
def cmd_status(args: argparse.Namespace) -> None:
|
||||||
"""Handle the 'status' subcommand."""
|
|
||||||
recover_pending_transactions()
|
recover_pending_transactions()
|
||||||
print("Elydora Agent Hook Status")
|
print("Elydora Agent Hook Status")
|
||||||
print("=" * 40)
|
print("=" * 40)
|
||||||
@@ -309,7 +297,6 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_agents(args: argparse.Namespace) -> None:
|
def cmd_agents(args: argparse.Namespace) -> None:
|
||||||
"""Handle the 'agents' subcommand."""
|
|
||||||
print("Supported agents:")
|
print("Supported agents:")
|
||||||
for name in get_agent_names():
|
for name in get_agent_names():
|
||||||
info = SUPPORTED_AGENTS[name]
|
info = SUPPORTED_AGENTS[name]
|
||||||
@@ -317,7 +304,6 @@ def cmd_agents(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
"""Build the argparse parser."""
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="elydora",
|
prog="elydora",
|
||||||
description="Elydora — tamper-evident audit trail for AI agents",
|
description="Elydora — tamper-evident audit trail for AI agents",
|
||||||
@@ -328,7 +314,6 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||||
|
|
||||||
# install
|
|
||||||
install_parser = subparsers.add_parser("install", help="Install audit hook for an agent")
|
install_parser = subparsers.add_parser("install", help="Install audit hook for an agent")
|
||||||
install_parser.add_argument("--agent", required=True, help="Agent name (e.g. claudecode, cursor)")
|
install_parser.add_argument("--agent", required=True, help="Agent name (e.g. claudecode, cursor)")
|
||||||
install_parser.add_argument("--org_id", required=True, help="Organization ID")
|
install_parser.add_argument("--org_id", required=True, help="Organization ID")
|
||||||
@@ -346,22 +331,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
install_parser.add_argument("--base_url", default="https://api.elydora.com", help="API base URL")
|
install_parser.add_argument("--base_url", default="https://api.elydora.com", help="API base URL")
|
||||||
|
|
||||||
# uninstall
|
|
||||||
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall audit hook for an agent")
|
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall audit hook for an agent")
|
||||||
uninstall_parser.add_argument("--agent", required=True, help="Agent name")
|
uninstall_parser.add_argument("--agent", required=True, help="Agent name")
|
||||||
uninstall_parser.add_argument("--agent_id", default=None, help="Agent ID (if omitted, scans config files for matching agent name)")
|
uninstall_parser.add_argument("--agent_id", default=None, help="Agent ID (if omitted, scans config files for matching agent name)")
|
||||||
|
|
||||||
# status
|
|
||||||
subparsers.add_parser("status", help="Show installation status of all agents")
|
subparsers.add_parser("status", help="Show installation status of all agents")
|
||||||
|
|
||||||
# agents
|
|
||||||
subparsers.add_parser("agents", help="List supported agents")
|
subparsers.add_parser("agents", help="List supported agents")
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""CLI entry point."""
|
|
||||||
_reject_legacy_secret_arguments(sys.argv[1:])
|
_reject_legacy_secret_arguments(sys.argv[1:])
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|||||||
+95
-239
@@ -2,16 +2,26 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Any, Dict, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from ._client_common import (
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
GENESIS_CHAIN_HASH,
|
||||||
|
REQUEST_TIMEOUT_SECONDS,
|
||||||
|
admin_events_params,
|
||||||
|
api_token_body,
|
||||||
|
build_eor,
|
||||||
|
build_headers,
|
||||||
|
error_from_response,
|
||||||
|
export_body,
|
||||||
|
register_body,
|
||||||
|
without_none,
|
||||||
|
)
|
||||||
from ._retry import require_max_retries
|
from ._retry import require_max_retries
|
||||||
from ._sync_http import request_with_retries
|
from ._sync_http import request_with_retries
|
||||||
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
|
|
||||||
from .errors import ElydoraError
|
|
||||||
from .integration_types import require_integration_type
|
from .integration_types import require_integration_type
|
||||||
from .types import (
|
from .types import (
|
||||||
AuditQueryResponse,
|
AuditQueryResponse,
|
||||||
@@ -21,35 +31,31 @@ from .types import (
|
|||||||
DeepHealthResponse,
|
DeepHealthResponse,
|
||||||
DeleteAgentResponse,
|
DeleteAgentResponse,
|
||||||
EOR,
|
EOR,
|
||||||
|
FreezeAgentResponse,
|
||||||
GetAgentResponse,
|
GetAgentResponse,
|
||||||
GetEpochResponse,
|
GetEpochResponse,
|
||||||
GetExportResponse,
|
GetExportResponse,
|
||||||
GetMeResponse,
|
GetMeResponse,
|
||||||
GetOperationResponse,
|
GetOperationResponse,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
IssueApiTokenResponse,
|
|
||||||
IntegrationType,
|
IntegrationType,
|
||||||
ListAdminEventsResponse,
|
IssueApiTokenResponse,
|
||||||
ListMembersResponse,
|
|
||||||
ListWebhooksResponse,
|
|
||||||
RotateApiTokenResponse,
|
|
||||||
JWKSResponse,
|
JWKSResponse,
|
||||||
|
ListAdminEventsResponse,
|
||||||
ListAgentsResponse,
|
ListAgentsResponse,
|
||||||
ListEpochsResponse,
|
ListEpochsResponse,
|
||||||
ListExportsResponse,
|
ListExportsResponse,
|
||||||
|
ListMembersResponse,
|
||||||
|
ListWebhooksResponse,
|
||||||
RegisterAgentRequest,
|
RegisterAgentRequest,
|
||||||
RegisterAgentResponse,
|
RegisterAgentResponse,
|
||||||
RegisterWebhookResponse,
|
RegisterWebhookResponse,
|
||||||
|
RotateApiTokenResponse,
|
||||||
SubmitOperationResponse,
|
SubmitOperationResponse,
|
||||||
UnfreezeAgentResponse,
|
UnfreezeAgentResponse,
|
||||||
UpdateAgentResponse,
|
UpdateAgentResponse,
|
||||||
VerifyOperationResponse,
|
VerifyOperationResponse,
|
||||||
)
|
)
|
||||||
from .utils import generate_nonce, generate_uuidv7
|
|
||||||
|
|
||||||
# The genesis chain hash: base64url encoding of 32 zero bytes.
|
|
||||||
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
|
|
||||||
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|
||||||
|
|
||||||
|
|
||||||
class ElydoraClient:
|
class ElydoraClient:
|
||||||
@@ -61,7 +67,7 @@ class ElydoraClient:
|
|||||||
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
|
||||||
base_url: API base URL.
|
base_url: API base URL.
|
||||||
ttl_ms: Time-to-live for operations in milliseconds.
|
ttl_ms: Time-to-live for operations in milliseconds.
|
||||||
max_retries: Maximum number of retries on transient failures.
|
max_retries: Retries after the initial attempt on transient failures.
|
||||||
token: Optional API token for authenticated endpoints.
|
token: Optional API token for authenticated endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -71,7 +77,7 @@ class ElydoraClient:
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
private_key: str,
|
private_key: str,
|
||||||
*,
|
*,
|
||||||
base_url: str = "https://api.elydora.com",
|
base_url: str = DEFAULT_BASE_URL,
|
||||||
ttl_ms: int = 30000,
|
ttl_ms: int = 30000,
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
token: Optional[str] = None,
|
token: Optional[str] = None,
|
||||||
@@ -83,29 +89,16 @@ class ElydoraClient:
|
|||||||
self.ttl_ms = ttl_ms
|
self.ttl_ms = ttl_ms
|
||||||
self.max_retries = require_max_retries(max_retries)
|
self.max_retries = require_max_retries(max_retries)
|
||||||
self.token = token
|
self.token = token
|
||||||
|
|
||||||
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
self._prev_chain_hash = GENESIS_CHAIN_HASH
|
||||||
self._kid = agent_id + '-key-1'
|
self._kid = agent_id + "-key-1"
|
||||||
self._session = requests.Session()
|
self._session = requests.Session()
|
||||||
|
|
||||||
def set_kid(self, kid: str) -> None:
|
def set_kid(self, kid: str) -> None:
|
||||||
"""Set the key ID used for signing operations."""
|
|
||||||
self._kid = kid
|
self._kid = kid
|
||||||
|
|
||||||
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
|
||||||
"""Set the previous chain hash for the next operation."""
|
|
||||||
self._prev_chain_hash = prev_chain_hash
|
self._prev_chain_hash = prev_chain_hash
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Internal HTTP helpers
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
|
||||||
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
|
||||||
if self.token:
|
|
||||||
headers["Authorization"] = f"Bearer {self.token}"
|
|
||||||
return headers
|
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -113,49 +106,44 @@ class ElydoraClient:
|
|||||||
*,
|
*,
|
||||||
json_body: Any = None,
|
json_body: Any = None,
|
||||||
params: Optional[Dict[str, str]] = None,
|
params: Optional[Dict[str, str]] = None,
|
||||||
headers: Optional[Dict[str, str]] = None,
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
url = f"{self.base_url}{path}"
|
|
||||||
hdrs = headers or self._headers()
|
|
||||||
|
|
||||||
return request_with_retries(
|
return request_with_retries(
|
||||||
self._session,
|
self._session,
|
||||||
method,
|
method,
|
||||||
url,
|
f"{self.base_url}{path}",
|
||||||
path=path,
|
path=path,
|
||||||
max_retries=self.max_retries,
|
max_retries=self.max_retries,
|
||||||
response_handler=self._handle_response,
|
response_handler=self._handle_response,
|
||||||
json_body=json_body,
|
json_body=json_body,
|
||||||
params=params,
|
params=params,
|
||||||
headers=hdrs,
|
headers=build_headers(self.token),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_public(self, path: str) -> Any:
|
||||||
|
response = self._session.get(f"{self.base_url}{path}", timeout=REQUEST_TIMEOUT_SECONDS)
|
||||||
|
return self._handle_response(response)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_response(resp: requests.Response) -> Any:
|
def _handle_response(resp: requests.Response) -> Any:
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
try:
|
try:
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
except Exception:
|
except ValueError:
|
||||||
raise ElydoraError(
|
body = None
|
||||||
code="INTERNAL_ERROR",
|
raise error_from_response(resp.status_code, body, resp.text)
|
||||||
message=resp.text or "Unknown error",
|
|
||||||
status_code=resp.status_code,
|
|
||||||
)
|
|
||||||
err = body.get("error", {})
|
|
||||||
raise ElydoraError(
|
|
||||||
code=err.get("code", "INTERNAL_ERROR"),
|
|
||||||
message=err.get("message", resp.text),
|
|
||||||
request_id=err.get("request_id", ""),
|
|
||||||
details=err.get("details"),
|
|
||||||
status_code=resp.status_code,
|
|
||||||
)
|
|
||||||
if resp.status_code == 204:
|
if resp.status_code == 204:
|
||||||
return None
|
return None
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
@staticmethod
|
||||||
# Auth (static methods)
|
def _post_json(url: str, body: Dict[str, Any]) -> Any:
|
||||||
# -----------------------------------------------------------------
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
json=body,
|
||||||
|
headers=build_headers(None),
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
return ElydoraClient._handle_response(response)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def register(
|
def register(
|
||||||
@@ -165,107 +153,65 @@ class ElydoraClient:
|
|||||||
display_name: Optional[str] = None,
|
display_name: Optional[str] = None,
|
||||||
org_name: Optional[str] = None,
|
org_name: Optional[str] = None,
|
||||||
) -> AuthRegisterResponse:
|
) -> AuthRegisterResponse:
|
||||||
"""Register a new user and organization.
|
"""Deprecated password registration; Console users sign in through Better Auth."""
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
Use Better Auth endpoints directly. See docs.
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"ElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.",
|
"ElydoraClient.register() is deprecated. Use Better Auth endpoints directly.",
|
||||||
DeprecationWarning,
|
DeprecationWarning,
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
url = f"{base_url.rstrip('/')}/v1/auth/register"
|
||||||
body: Dict[str, Any] = {"email": email, "password": password}
|
return ElydoraClient._post_json(url, register_body(email, password, display_name, org_name))
|
||||||
if display_name is not None:
|
|
||||||
body["display_name"] = display_name
|
|
||||||
if org_name is not None:
|
|
||||||
body["org_name"] = org_name
|
|
||||||
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
|
|
||||||
return ElydoraClient._handle_response(resp)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
|
||||||
"""Authenticate and receive a session token.
|
"""Deprecated password login; Console users sign in through Better Auth."""
|
||||||
|
|
||||||
.. deprecated::
|
|
||||||
Use Better Auth endpoints directly. See docs.
|
|
||||||
"""
|
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"ElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.",
|
"ElydoraClient.login() is deprecated. Use Better Auth endpoints directly.",
|
||||||
DeprecationWarning,
|
DeprecationWarning,
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
url = f"{base_url.rstrip('/')}/v1/auth/login"
|
||||||
body = {"email": email, "password": password}
|
return ElydoraClient._post_json(url, {"email": email, "password": password})
|
||||||
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
|
|
||||||
return ElydoraClient._handle_response(resp)
|
|
||||||
|
|
||||||
def get_me(self) -> GetMeResponse:
|
def get_me(self) -> GetMeResponse:
|
||||||
"""Get the current user's profile."""
|
|
||||||
return self._request("GET", "/v1/auth/me")
|
return self._request("GET", "/v1/auth/me")
|
||||||
|
|
||||||
def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
||||||
"""Issue an API token with an optional TTL."""
|
return self._request("POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds))
|
||||||
body: Dict[str, Any] = {}
|
|
||||||
if ttl_seconds is not None:
|
|
||||||
body["ttl_seconds"] = ttl_seconds
|
|
||||||
return self._request("POST", "/v1/auth/token", json_body=body)
|
|
||||||
|
|
||||||
def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
|
|
||||||
"""Deprecated: Use issue_api_token() instead."""
|
|
||||||
return self.issue_api_token(ttl_seconds=ttl_seconds)
|
|
||||||
|
|
||||||
def rotate_api_token(self) -> RotateApiTokenResponse:
|
def rotate_api_token(self) -> RotateApiTokenResponse:
|
||||||
"""Rotate the current API token and keep the old token in 24h grace."""
|
|
||||||
return self._request("POST", "/v1/auth/rotate", json_body={})
|
return self._request("POST", "/v1/auth/rotate", json_body={})
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Agent management
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
|
||||||
"""Register a new agent with the organization."""
|
|
||||||
require_integration_type(request.get("integration_type"))
|
require_integration_type(request.get("integration_type"))
|
||||||
return self._request("POST", "/v1/agents/register", json_body=request)
|
return self._request("POST", "/v1/agents/register", json_body=request)
|
||||||
|
|
||||||
def get_agent(self, agent_id: str) -> GetAgentResponse:
|
def get_agent(self, agent_id: str) -> GetAgentResponse:
|
||||||
"""Retrieve agent details and keys."""
|
|
||||||
return self._request("GET", f"/v1/agents/{agent_id}")
|
return self._request("GET", f"/v1/agents/{agent_id}")
|
||||||
|
|
||||||
def freeze_agent(self, agent_id: str, reason: str) -> None:
|
|
||||||
"""Freeze an agent."""
|
|
||||||
self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
|
||||||
|
|
||||||
def list_agents(self) -> ListAgentsResponse:
|
def list_agents(self) -> ListAgentsResponse:
|
||||||
"""List all agents for the organization."""
|
|
||||||
return self._request("GET", "/v1/agents")
|
return self._request("GET", "/v1/agents")
|
||||||
|
|
||||||
|
def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
|
||||||
|
return self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
|
||||||
|
|
||||||
def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
|
def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
|
||||||
"""Unfreeze an agent."""
|
|
||||||
return self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
return self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
|
||||||
|
|
||||||
def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
|
def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
|
||||||
"""Update an agent's integration type."""
|
|
||||||
require_integration_type(integration_type)
|
require_integration_type(integration_type)
|
||||||
return self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type})
|
return self._request(
|
||||||
|
"PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}
|
||||||
|
)
|
||||||
|
|
||||||
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
|
||||||
"""Delete an agent."""
|
|
||||||
return self._request("DELETE", f"/v1/agents/{agent_id}")
|
return self._request("DELETE", f"/v1/agents/{agent_id}")
|
||||||
|
|
||||||
def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
|
||||||
"""Revoke an agent's key."""
|
|
||||||
self._request(
|
self._request(
|
||||||
"POST",
|
"POST", f"/v1/agents/{agent_id}/revoke", json_body={"kid": kid, "reason": reason}
|
||||||
f"/v1/agents/{agent_id}/revoke",
|
|
||||||
json_body={"kid": kid, "reason": reason},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Operations (CORE)
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def create_operation(
|
def create_operation(
|
||||||
self,
|
self,
|
||||||
operation_type: str,
|
operation_type: str,
|
||||||
@@ -273,61 +219,31 @@ class ElydoraClient:
|
|||||||
action: Dict[str, Any],
|
action: Dict[str, Any],
|
||||||
payload: Union[Dict[str, Any], str, None] = None,
|
payload: Union[Dict[str, Any], str, None] = None,
|
||||||
) -> EOR:
|
) -> EOR:
|
||||||
"""Build and sign an Elydora Operation Record (EOR).
|
"""Build and sign an EOR locally; submit it with submit_operation()."""
|
||||||
|
eor, chain_hash = build_eor(
|
||||||
This does NOT submit the operation to the server. Call submit_operation()
|
org_id=self.org_id,
|
||||||
with the returned EOR to submit it.
|
agent_id=self.agent_id,
|
||||||
"""
|
private_key=self.private_key,
|
||||||
operation_id = generate_uuidv7()
|
kid=self._kid,
|
||||||
issued_at = int(time.time() * 1000)
|
ttl_ms=self.ttl_ms,
|
||||||
nonce = generate_nonce()
|
prev_chain_hash=self._prev_chain_hash,
|
||||||
payload_hash = compute_payload_hash(payload)
|
operation_type=operation_type,
|
||||||
chain_hash = compute_chain_hash(
|
subject=subject,
|
||||||
self._prev_chain_hash, payload_hash, operation_id, issued_at
|
action=action,
|
||||||
|
payload=payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
eor: Dict[str, Any] = {
|
|
||||||
"op_version": "1.0",
|
|
||||||
"operation_id": operation_id,
|
|
||||||
"org_id": self.org_id,
|
|
||||||
"agent_id": self.agent_id,
|
|
||||||
"issued_at": issued_at,
|
|
||||||
"ttl_ms": self.ttl_ms,
|
|
||||||
"nonce": nonce,
|
|
||||||
"operation_type": operation_type,
|
|
||||||
"subject": subject,
|
|
||||||
"action": action,
|
|
||||||
"payload": payload,
|
|
||||||
"payload_hash": payload_hash,
|
|
||||||
"prev_chain_hash": self._prev_chain_hash,
|
|
||||||
"agent_pubkey_kid": self._kid,
|
|
||||||
"signature": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Sign the EOR (signature field excluded from canonical form)
|
|
||||||
eor["signature"] = sign_eor(eor, self.private_key)
|
|
||||||
|
|
||||||
# Update chain state
|
|
||||||
self._prev_chain_hash = chain_hash
|
self._prev_chain_hash = chain_hash
|
||||||
|
return eor
|
||||||
return eor # type: ignore[return-value]
|
|
||||||
|
|
||||||
def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
|
||||||
"""Submit a signed EOR to the server."""
|
|
||||||
return self._request("POST", "/v1/operations", json_body=eor)
|
return self._request("POST", "/v1/operations", json_body=eor)
|
||||||
|
|
||||||
def get_operation(self, operation_id: str) -> GetOperationResponse:
|
def get_operation(self, operation_id: str) -> GetOperationResponse:
|
||||||
"""Retrieve an operation by ID."""
|
|
||||||
return self._request("GET", f"/v1/operations/{operation_id}")
|
return self._request("GET", f"/v1/operations/{operation_id}")
|
||||||
|
|
||||||
def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
|
||||||
"""Verify an operation's integrity."""
|
|
||||||
return self._request("POST", f"/v1/operations/{operation_id}/verify", json_body={})
|
return self._request("POST", f"/v1/operations/{operation_id}/verify", json_body={})
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Audit
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def query_audit(
|
def query_audit(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -339,40 +255,23 @@ class ElydoraClient:
|
|||||||
cursor: Optional[str] = None,
|
cursor: Optional[str] = None,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
) -> AuditQueryResponse:
|
) -> AuditQueryResponse:
|
||||||
"""Query the tamper-evident audit log."""
|
body = without_none({
|
||||||
body: Dict[str, Any] = {}
|
"org_id": org_id,
|
||||||
if org_id is not None:
|
"agent_id": agent_id,
|
||||||
body["org_id"] = org_id
|
"operation_type": operation_type,
|
||||||
if agent_id is not None:
|
"start_time": start_time,
|
||||||
body["agent_id"] = agent_id
|
"end_time": end_time,
|
||||||
if operation_type is not None:
|
"cursor": cursor,
|
||||||
body["operation_type"] = operation_type
|
"limit": limit,
|
||||||
if start_time is not None:
|
})
|
||||||
body["start_time"] = start_time
|
|
||||||
if end_time is not None:
|
|
||||||
body["end_time"] = end_time
|
|
||||||
if cursor is not None:
|
|
||||||
body["cursor"] = cursor
|
|
||||||
if limit is not None:
|
|
||||||
body["limit"] = limit
|
|
||||||
return self._request("POST", "/v1/audit/query", json_body=body)
|
return self._request("POST", "/v1/audit/query", json_body=body)
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Epochs
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def list_epochs(self) -> ListEpochsResponse:
|
def list_epochs(self) -> ListEpochsResponse:
|
||||||
"""List all epochs for the organization."""
|
|
||||||
return self._request("GET", "/v1/epochs")
|
return self._request("GET", "/v1/epochs")
|
||||||
|
|
||||||
def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
def get_epoch(self, epoch_id: str) -> GetEpochResponse:
|
||||||
"""Retrieve an epoch root record."""
|
|
||||||
return self._request("GET", f"/v1/epochs/{epoch_id}")
|
return self._request("GET", f"/v1/epochs/{epoch_id}")
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Exports
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def create_export(
|
def create_export(
|
||||||
self,
|
self,
|
||||||
start_time: int,
|
start_time: int,
|
||||||
@@ -381,91 +280,48 @@ class ElydoraClient:
|
|||||||
agent_id: Optional[str] = None,
|
agent_id: Optional[str] = None,
|
||||||
operation_type: Optional[str] = None,
|
operation_type: Optional[str] = None,
|
||||||
) -> CreateExportResponse:
|
) -> CreateExportResponse:
|
||||||
"""Create a compliance export job."""
|
body = export_body(start_time, end_time, format, agent_id, operation_type)
|
||||||
body: Dict[str, Any] = {
|
|
||||||
"start_time": start_time,
|
|
||||||
"end_time": end_time,
|
|
||||||
"format": format,
|
|
||||||
}
|
|
||||||
if agent_id is not None:
|
|
||||||
body["agent_id"] = agent_id
|
|
||||||
if operation_type is not None:
|
|
||||||
body["operation_type"] = operation_type
|
|
||||||
return self._request("POST", "/v1/exports", json_body=body)
|
return self._request("POST", "/v1/exports", json_body=body)
|
||||||
|
|
||||||
def list_exports(self) -> ListExportsResponse:
|
def list_exports(self) -> ListExportsResponse:
|
||||||
"""List all exports for the organization."""
|
|
||||||
return self._request("GET", "/v1/exports")
|
return self._request("GET", "/v1/exports")
|
||||||
|
|
||||||
def get_export(self, export_id: str) -> GetExportResponse:
|
def get_export(self, export_id: str) -> GetExportResponse:
|
||||||
"""Retrieve export status and download URL."""
|
|
||||||
return self._request("GET", f"/v1/exports/{export_id}")
|
return self._request("GET", f"/v1/exports/{export_id}")
|
||||||
|
|
||||||
def download_export(self, export_id: str) -> bytes:
|
def download_export(self, export_id: str) -> bytes:
|
||||||
"""Download an export file as raw bytes."""
|
response = self._session.get(
|
||||||
url = f"{self.base_url}/v1/exports/{export_id}/download"
|
f"{self.base_url}/v1/exports/{export_id}/download",
|
||||||
resp = self._session.get(url, headers=self._headers(), timeout=30)
|
headers=build_headers(self.token),
|
||||||
if resp.status_code >= 400:
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
self._handle_response(resp)
|
)
|
||||||
return resp.content
|
if response.status_code >= 400:
|
||||||
|
self._handle_response(response)
|
||||||
# -----------------------------------------------------------------
|
return response.content
|
||||||
# Webhooks
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def list_webhooks(self) -> ListWebhooksResponse:
|
def list_webhooks(self) -> ListWebhooksResponse:
|
||||||
"""List all webhooks for the organization."""
|
|
||||||
return self._request("GET", "/v1/webhooks")
|
return self._request("GET", "/v1/webhooks")
|
||||||
|
|
||||||
def register_webhook(self, endpoint_url: str, events: list, secret: str) -> RegisterWebhookResponse:
|
def register_webhook(
|
||||||
"""Register a new webhook."""
|
self, endpoint_url: str, events: List[str], secret: str
|
||||||
return self._request("POST", "/v1/webhooks", json_body={"endpoint_url": endpoint_url, "events": events, "secret": secret})
|
) -> RegisterWebhookResponse:
|
||||||
|
body = {"endpoint_url": endpoint_url, "events": events, "secret": secret}
|
||||||
|
return self._request("POST", "/v1/webhooks", json_body=body)
|
||||||
|
|
||||||
def delete_webhook(self, webhook_id: str) -> None:
|
def delete_webhook(self, webhook_id: str) -> None:
|
||||||
"""Delete a webhook."""
|
|
||||||
self._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
self._request("DELETE", f"/v1/webhooks/{webhook_id}")
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Members
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def list_members(self) -> ListMembersResponse:
|
def list_members(self) -> ListMembersResponse:
|
||||||
"""List all members in the organization."""
|
|
||||||
return self._request("GET", "/v1/members")
|
return self._request("GET", "/v1/members")
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Admin events
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
|
def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
|
||||||
"""List recent admin events."""
|
return self._request("GET", "/v1/admin/events", params=admin_events_params(limit))
|
||||||
params: Dict[str, str] = {}
|
|
||||||
if limit is not None:
|
|
||||||
params["limit"] = str(limit)
|
|
||||||
return self._request("GET", "/v1/admin/events", params=params)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# JWKS
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_jwks(self) -> JWKSResponse:
|
def get_jwks(self) -> JWKSResponse:
|
||||||
"""Retrieve the platform JWKS (public, no auth required)."""
|
return self._get_public("/.well-known/elydora/jwks.json")
|
||||||
url = f"{self.base_url}/.well-known/elydora/jwks.json"
|
|
||||||
resp = self._session.get(url, timeout=30)
|
|
||||||
return self._handle_response(resp)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# Health
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def health(self) -> HealthResponse:
|
def health(self) -> HealthResponse:
|
||||||
"""Check API health (public, no auth required)."""
|
return self._get_public("/v1/health")
|
||||||
url = f"{self.base_url}/v1/health"
|
|
||||||
resp = self._session.get(url, timeout=30)
|
|
||||||
return self._handle_response(resp)
|
|
||||||
|
|
||||||
def deep_health(self) -> DeepHealthResponse:
|
def deep_health(self) -> DeepHealthResponse:
|
||||||
"""Check API deep health (public, no auth required)."""
|
return self._get_public("/v1/health/deep")
|
||||||
url = f"{self.base_url}/v1/health/deep"
|
|
||||||
resp = self._session.get(url, timeout=30)
|
|
||||||
return self._handle_response(resp)
|
|
||||||
+15
-81
@@ -1,25 +1,18 @@
|
|||||||
"""Cryptographic utilities: Ed25519 signing, SHA-256, chain hash, JCS canonicalization.
|
"""Ed25519 signing, SHA-256, chain hash, and RFC 8785 canonicalization; mirrors the Backend."""
|
||||||
|
|
||||||
Mirrors the backend implementation in ElydoraBackend/src/utils/crypto.ts exactly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from typing import Any, Dict, Optional, Union
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
|
||||||
from .utils import base64url_decode, base64url_encode
|
from .utils import base64url_decode, base64url_encode
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SHA-256
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_base64url(data: Union[str, bytes]) -> str:
|
def sha256_base64url(data: Union[str, bytes]) -> str:
|
||||||
"""Compute SHA-256 of data and return base64url-encoded hash."""
|
"""Compute SHA-256 of data and return base64url-encoded hash."""
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
@@ -28,39 +21,21 @@ def sha256_base64url(data: Union[str, bytes]) -> str:
|
|||||||
return base64url_encode(digest)
|
return base64url_encode(digest)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# JCS Canonicalization (RFC 8785)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _jcs_serialize_number(value: Union[int, float]) -> str:
|
def _jcs_serialize_number(value: Union[int, float]) -> str:
|
||||||
"""Serialize a number per JCS / ES2015 Number serialization rules."""
|
"""ES2015 number serialization: NaN and infinities become null, -0.0 becomes 0."""
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
# bool is a subclass of int in Python; handle before int check
|
|
||||||
return "true" if value else "false"
|
return "true" if value else "false"
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
return str(value)
|
return str(value)
|
||||||
# float
|
|
||||||
if math.isnan(value) or math.isinf(value):
|
if math.isnan(value) or math.isinf(value):
|
||||||
return "null"
|
return "null"
|
||||||
if value == 0.0:
|
if value == 0.0:
|
||||||
# Handle -0.0 -> "0"
|
|
||||||
return "0"
|
return "0"
|
||||||
# Use Python's repr which matches ES2015 for normal floats,
|
|
||||||
# but we need to ensure no trailing zeros beyond what's needed.
|
|
||||||
# json.dumps handles this correctly per the JSON spec.
|
|
||||||
return json.dumps(value)
|
return json.dumps(value)
|
||||||
|
|
||||||
|
|
||||||
def jcs_canonicalize(value: Any) -> str:
|
def jcs_canonicalize(value: Any) -> str:
|
||||||
"""Canonicalize a value according to JCS (RFC 8785).
|
"""Canonicalize a value according to JCS (RFC 8785)."""
|
||||||
|
|
||||||
- Object keys sorted lexicographically
|
|
||||||
- No whitespace
|
|
||||||
- Numbers serialized using ES2015 rules
|
|
||||||
- Strings serialized with minimal JSON escaping
|
|
||||||
- undefined values in objects are omitted
|
|
||||||
"""
|
|
||||||
if value is None:
|
if value is None:
|
||||||
return "null"
|
return "null"
|
||||||
|
|
||||||
@@ -78,60 +53,34 @@ def jcs_canonicalize(value: Any) -> str:
|
|||||||
return "[" + ",".join(elements) + "]"
|
return "[" + ",".join(elements) + "]"
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
keys = sorted(value.keys())
|
pairs = [
|
||||||
pairs = []
|
json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(value[key])
|
||||||
for key in keys:
|
for key in sorted(value.keys())
|
||||||
v = value[key]
|
]
|
||||||
if v is not None or key in value:
|
|
||||||
# Include keys with None values (maps to JSON null),
|
|
||||||
# but skip keys that don't exist (which won't happen with dict iteration)
|
|
||||||
pairs.append(json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(v))
|
|
||||||
return "{" + ",".join(pairs) + "}"
|
return "{" + ",".join(pairs) + "}"
|
||||||
|
|
||||||
return json.dumps(value)
|
return json.dumps(value)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Payload hash
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def compute_payload_hash(payload: Any) -> str:
|
def compute_payload_hash(payload: Any) -> str:
|
||||||
"""Compute SHA-256 hash of JCS-canonicalized payload, base64url encoded."""
|
"""Compute SHA-256 hash of JCS-canonicalized payload, base64url encoded."""
|
||||||
canonical = jcs_canonicalize(payload)
|
canonical = jcs_canonicalize(payload)
|
||||||
return sha256_base64url(canonical)
|
return sha256_base64url(canonical)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Chain hash
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def compute_chain_hash(
|
def compute_chain_hash(
|
||||||
prev_chain_hash: str,
|
prev_chain_hash: str,
|
||||||
payload_hash: str,
|
payload_hash: str,
|
||||||
operation_id: str,
|
operation_id: str,
|
||||||
issued_at: int,
|
issued_at: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Compute chain hash: SHA-256(prev|payload_hash|op_id|issued_at) as base64url.
|
"""SHA-256 of "prev|payload_hash|operation_id|issued_at" as base64url."""
|
||||||
|
|
||||||
Matches the backend formula exactly:
|
|
||||||
chain_hash = SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + str(issued_at))
|
|
||||||
"""
|
|
||||||
input_str = f"{prev_chain_hash}|{payload_hash}|{operation_id}|{issued_at}"
|
input_str = f"{prev_chain_hash}|{payload_hash}|{operation_id}|{issued_at}"
|
||||||
return sha256_base64url(input_str)
|
return sha256_base64url(input_str)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Ed25519 signing
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
|
def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
|
||||||
"""Sign data with Ed25519 private key (32-byte seed, base64url encoded).
|
"""Sign data with a base64url 32-byte Ed25519 seed; returns a base64url signature."""
|
||||||
|
|
||||||
Returns a base64url-encoded 64-byte signature.
|
|
||||||
"""
|
|
||||||
seed = base64url_decode(private_key_base64url)
|
seed = base64url_decode(private_key_base64url)
|
||||||
key = Ed25519PrivateKey.from_private_bytes(seed)
|
key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||||
signature = key.sign(data)
|
signature = key.sign(data)
|
||||||
@@ -139,29 +88,14 @@ def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def get_public_key_base64url(private_key_base64url: str) -> str:
|
def get_public_key_base64url(private_key_base64url: str) -> str:
|
||||||
"""Derive the Ed25519 public key from the private key seed.
|
"""Derive the base64url Ed25519 public key from a base64url seed."""
|
||||||
|
|
||||||
Returns base64url-encoded 32-byte public key.
|
|
||||||
"""
|
|
||||||
seed = base64url_decode(private_key_base64url)
|
seed = base64url_decode(private_key_base64url)
|
||||||
key = Ed25519PrivateKey.from_private_bytes(seed)
|
public_key = Ed25519PrivateKey.from_private_bytes(seed).public_key()
|
||||||
pub = key.public_key()
|
return base64url_encode(public_key.public_bytes(Encoding.Raw, PublicFormat.Raw))
|
||||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
||||||
raw_bytes = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
||||||
return base64url_encode(raw_bytes)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# EOR signing
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def sign_eor(eor_dict: Dict[str, Any], private_key_base64url: str) -> str:
|
def sign_eor(eor_dict: Dict[str, Any], private_key_base64url: str) -> str:
|
||||||
"""Sign an EOR by canonicalizing all fields except 'signature', then signing.
|
"""Sign the JCS form of every EOR field except signature."""
|
||||||
|
|
||||||
Returns the base64url-encoded Ed25519 signature.
|
|
||||||
"""
|
|
||||||
# Build the signable object: all EOR fields except 'signature'
|
|
||||||
signable = {k: v for k, v in eor_dict.items() if k != "signature"}
|
signable = {k: v for k, v in eor_dict.items() if k != "signature"}
|
||||||
canonical = jcs_canonicalize(signable)
|
canonical = jcs_canonicalize(signable)
|
||||||
return sign_ed25519(private_key_base64url, canonical.encode("utf-8"))
|
return sign_ed25519(private_key_base64url, canonical.encode("utf-8"))
|
||||||
+55
-77
@@ -1,7 +1,4 @@
|
|||||||
"""Type definitions matching the Elydora backend API contract.
|
"""TypedDict definitions matching the Elydora Backend API contract."""
|
||||||
|
|
||||||
All types use TypedDict for structural typing, compatible with Python 3.10+.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
@@ -12,10 +9,6 @@ else:
|
|||||||
from typing_extensions import Literal, NotRequired, TypedDict
|
from typing_extensions import Literal, NotRequired, TypedDict
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Enums (as Literal types)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
AgentStatus = Literal["active", "frozen", "revoked"]
|
AgentStatus = Literal["active", "frozen", "revoked"]
|
||||||
KeyStatus = Literal["active", "retired", "revoked"]
|
KeyStatus = Literal["active", "retired", "revoked"]
|
||||||
ExportStatus = Literal["queued", "running", "done", "failed"]
|
ExportStatus = Literal["queued", "running", "done", "failed"]
|
||||||
@@ -53,22 +46,32 @@ INTEGRATION_TYPES: Tuple[IntegrationType, ...] = (
|
|||||||
"enterprise", "gui", "sdk", "other",
|
"enterprise", "gui", "sdk", "other",
|
||||||
)
|
)
|
||||||
AdminAction = Literal[
|
AdminAction = Literal[
|
||||||
|
"agent.register",
|
||||||
|
"agent.update",
|
||||||
"agent.freeze",
|
"agent.freeze",
|
||||||
"agent.unfreeze",
|
"agent.unfreeze",
|
||||||
"agent.revoke_key",
|
"agent.revoke",
|
||||||
"agent.delete",
|
"agent.delete",
|
||||||
|
"key.revoke",
|
||||||
|
"export.create",
|
||||||
|
"org.create",
|
||||||
|
"org.update",
|
||||||
"member.invite",
|
"member.invite",
|
||||||
|
"invitation.cancel",
|
||||||
"member.remove",
|
"member.remove",
|
||||||
"member.role_change",
|
"member.role_change",
|
||||||
|
"agent.assign",
|
||||||
|
"agent.unassign",
|
||||||
"webhook.register",
|
"webhook.register",
|
||||||
"webhook.delete",
|
"webhook.delete",
|
||||||
"org.update",
|
|
||||||
]
|
]
|
||||||
ErrorCode = Literal[
|
ErrorCode = Literal[
|
||||||
"INVALID_SIGNATURE",
|
"INVALID_SIGNATURE",
|
||||||
"UNKNOWN_AGENT",
|
"UNKNOWN_AGENT",
|
||||||
"KEY_REVOKED",
|
"KEY_REVOKED",
|
||||||
|
"KEY_RETIRED",
|
||||||
"AGENT_FROZEN",
|
"AGENT_FROZEN",
|
||||||
|
"AGENT_REVOKED",
|
||||||
"TTL_EXPIRED",
|
"TTL_EXPIRED",
|
||||||
"REPLAY_DETECTED",
|
"REPLAY_DETECTED",
|
||||||
"PREV_HASH_MISMATCH",
|
"PREV_HASH_MISMATCH",
|
||||||
@@ -81,11 +84,6 @@ ErrorCode = Literal[
|
|||||||
"VALIDATION_ERROR",
|
"VALIDATION_ERROR",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Entity types
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class Agent(TypedDict):
|
class Agent(TypedDict):
|
||||||
agent_id: str
|
agent_id: str
|
||||||
org_id: str
|
org_id: str
|
||||||
@@ -175,11 +173,6 @@ class Export(TypedDict):
|
|||||||
completed_at: Optional[int]
|
completed_at: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Protocol types
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class EOR(TypedDict):
|
class EOR(TypedDict):
|
||||||
"""Elydora Operation Record."""
|
"""Elydora Operation Record."""
|
||||||
op_version: Literal["1.0"]
|
op_version: Literal["1.0"]
|
||||||
@@ -215,11 +208,6 @@ class EAR(TypedDict):
|
|||||||
elydora_signature: str
|
elydora_signature: str
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# API request types
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterAgentKeyParam(TypedDict):
|
class RegisterAgentKeyParam(TypedDict):
|
||||||
kid: str
|
kid: str
|
||||||
public_key: str
|
public_key: str
|
||||||
@@ -234,21 +222,6 @@ class RegisterAgentRequest(TypedDict):
|
|||||||
responsible_entity: NotRequired[str]
|
responsible_entity: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
class AuditQueryParams(TypedDict, total=False):
|
|
||||||
org_id: str
|
|
||||||
agent_id: str
|
|
||||||
operation_type: str
|
|
||||||
start_time: int
|
|
||||||
end_time: int
|
|
||||||
cursor: str
|
|
||||||
limit: int
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# API response types
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterAgentResponse(TypedDict):
|
class RegisterAgentResponse(TypedDict):
|
||||||
agent: Agent
|
agent: Agent
|
||||||
keys: List[AgentKey]
|
keys: List[AgentKey]
|
||||||
@@ -266,6 +239,7 @@ class SubmitOperationResponse(TypedDict):
|
|||||||
class GetOperationResponse(TypedDict, total=False):
|
class GetOperationResponse(TypedDict, total=False):
|
||||||
operation: Operation
|
operation: Operation
|
||||||
receipt: Receipt
|
receipt: Receipt
|
||||||
|
payload: Any
|
||||||
|
|
||||||
|
|
||||||
class VerifyChecks(TypedDict, total=False):
|
class VerifyChecks(TypedDict, total=False):
|
||||||
@@ -348,8 +322,27 @@ class DeleteAgentResponse(TypedDict):
|
|||||||
deleted: bool
|
deleted: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMeUser(TypedDict):
|
||||||
|
user_id: str
|
||||||
|
org_id: Optional[str]
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
role: RbacRole
|
||||||
|
status: Literal["active", "suspended"]
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
onboarding_completed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentOrganization(TypedDict):
|
||||||
|
org_id: Optional[str]
|
||||||
|
ba_org_id: Optional[str]
|
||||||
|
role: RbacRole
|
||||||
|
|
||||||
|
|
||||||
class GetMeResponse(TypedDict):
|
class GetMeResponse(TypedDict):
|
||||||
user: User
|
user: AuthMeUser
|
||||||
|
current_organization: CurrentOrganization
|
||||||
|
|
||||||
|
|
||||||
class IssueApiTokenResponse(TypedDict):
|
class IssueApiTokenResponse(TypedDict):
|
||||||
@@ -364,39 +357,30 @@ class RotateApiTokenResponse(TypedDict):
|
|||||||
token_id: str
|
token_id: str
|
||||||
|
|
||||||
|
|
||||||
# Backward compatibility aliases (deprecated)
|
|
||||||
IssueTokenResponse = IssueApiTokenResponse
|
|
||||||
RotateTokenResponse = RotateApiTokenResponse
|
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(TypedDict):
|
class HealthResponse(TypedDict):
|
||||||
status: str
|
status: str
|
||||||
version: str
|
version: str
|
||||||
protocol_version: str
|
protocol_version: str
|
||||||
|
capabilities: Dict[str, str]
|
||||||
timestamp: int
|
timestamp: int
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Additional entity types
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class AdminEvent(TypedDict):
|
class AdminEvent(TypedDict):
|
||||||
event_id: str
|
event_id: str
|
||||||
org_id: str
|
org_id: str
|
||||||
actor_user_id: str
|
actor: str
|
||||||
action: str
|
action: str
|
||||||
target_type: str
|
target_type: str
|
||||||
target_id: str
|
target_id: str
|
||||||
metadata: Optional[Dict[str, Any]]
|
details: Optional[str]
|
||||||
created_at: int
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
class AgentAssignment(TypedDict):
|
class AgentAssignment(TypedDict):
|
||||||
assignment_id: str
|
id: str
|
||||||
agent_id: str
|
agent_id: str
|
||||||
|
user_id: str
|
||||||
org_id: str
|
org_id: str
|
||||||
assigned_to: str
|
|
||||||
assigned_by: str
|
assigned_by: str
|
||||||
created_at: int
|
created_at: int
|
||||||
|
|
||||||
@@ -406,48 +390,36 @@ class WebhookEntry(TypedDict):
|
|||||||
org_id: str
|
org_id: str
|
||||||
endpoint_url: str
|
endpoint_url: str
|
||||||
events: List[str]
|
events: List[str]
|
||||||
|
status: Literal["active", "disabled"]
|
||||||
created_at: int
|
created_at: int
|
||||||
updated_at: int
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
class MemberEntry(TypedDict):
|
class MemberEntry(TypedDict):
|
||||||
|
member_id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
org_id: str
|
role: str
|
||||||
|
joined_at: str
|
||||||
email: str
|
email: str
|
||||||
display_name: str
|
display_name: str
|
||||||
role: RbacRole
|
|
||||||
status: Literal["active", "suspended"]
|
|
||||||
created_at: int
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Additional request types
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateAgentRequest(TypedDict):
|
class UpdateAgentRequest(TypedDict):
|
||||||
integration_type: IntegrationType
|
integration_type: IntegrationType
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Additional response types
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateAgentResponse(TypedDict):
|
class UpdateAgentResponse(TypedDict):
|
||||||
agent: Agent
|
agent: Agent
|
||||||
|
|
||||||
|
|
||||||
class FreezeAgentResponse(TypedDict):
|
class FreezeAgentResponse(TypedDict):
|
||||||
agent_id: str
|
agent: Agent
|
||||||
previous_status: AgentStatus
|
previous_status: AgentStatus
|
||||||
status: AgentStatus
|
|
||||||
|
|
||||||
|
|
||||||
class UnfreezeAgentResponse(TypedDict):
|
class UnfreezeAgentResponse(TypedDict):
|
||||||
agent_id: str
|
agent: Agent
|
||||||
previous_status: AgentStatus
|
previous_status: AgentStatus
|
||||||
status: AgentStatus
|
|
||||||
|
|
||||||
|
|
||||||
class ListWebhooksResponse(TypedDict):
|
class ListWebhooksResponse(TypedDict):
|
||||||
@@ -464,12 +436,18 @@ class ListMembersResponse(TypedDict):
|
|||||||
|
|
||||||
class ListAdminEventsResponse(TypedDict):
|
class ListAdminEventsResponse(TypedDict):
|
||||||
events: List[AdminEvent]
|
events: List[AdminEvent]
|
||||||
cursor: Optional[str]
|
|
||||||
|
|
||||||
|
|
||||||
class DeepHealthResponse(TypedDict, total=False):
|
class DependencyHealth(TypedDict):
|
||||||
status: str
|
status: Literal["ok", "failed"]
|
||||||
|
latency_ms: int
|
||||||
|
contract_phase: NotRequired[Literal["expand", "contract"]]
|
||||||
|
|
||||||
|
|
||||||
|
class DeepHealthResponse(TypedDict):
|
||||||
|
status: Literal["healthy", "degraded"]
|
||||||
version: str
|
version: str
|
||||||
protocol_version: str
|
protocol_version: str
|
||||||
|
capabilities: Dict[str, str]
|
||||||
timestamp: int
|
timestamp: int
|
||||||
checks: Dict[str, Any]
|
dependencies: Dict[str, DependencyHealth]
|
||||||
+4
-19
@@ -28,27 +28,12 @@ def generate_nonce() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def generate_uuidv7() -> str:
|
def generate_uuidv7() -> str:
|
||||||
"""Generate a UUIDv7 (time-ordered, random) as a string.
|
"""UUIDv7 (RFC 9562): 48-bit ms timestamp, version 7, variant 10, 74 random bits."""
|
||||||
|
|
||||||
Layout (RFC 9562):
|
|
||||||
- 48-bit Unix timestamp in milliseconds
|
|
||||||
- 4-bit version (0b0111)
|
|
||||||
- 12-bit random
|
|
||||||
- 2-bit variant (0b10)
|
|
||||||
- 62-bit random
|
|
||||||
"""
|
|
||||||
timestamp_ms = int(time.time() * 1000)
|
timestamp_ms = int(time.time() * 1000)
|
||||||
|
ts_bytes = struct.pack(">Q", timestamp_ms)[2:]
|
||||||
# 48-bit timestamp
|
rand_a = (struct.unpack(">H", os.urandom(2))[0] & 0x0FFF) | 0x7000
|
||||||
ts_bytes = struct.pack(">Q", timestamp_ms)[2:] # last 6 bytes of 8-byte big-endian
|
|
||||||
|
|
||||||
# 2 bytes: version (4 bits = 0x7) + 12 random bits
|
|
||||||
rand_a = struct.unpack(">H", os.urandom(2))[0]
|
|
||||||
rand_a = (rand_a & 0x0FFF) | 0x7000 # set version nibble
|
|
||||||
|
|
||||||
# 8 bytes: variant (2 bits = 0b10) + 62 random bits
|
|
||||||
rand_b = bytearray(os.urandom(8))
|
rand_b = bytearray(os.urandom(8))
|
||||||
rand_b[0] = (rand_b[0] & 0x3F) | 0x80 # set variant bits
|
rand_b[0] = (rand_b[0] & 0x3F) | 0x80
|
||||||
|
|
||||||
uuid_bytes = ts_bytes + struct.pack(">H", rand_a) + bytes(rand_b)
|
uuid_bytes = ts_bytes + struct.pack(">H", rand_a) + bytes(rand_b)
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ def test_sync_client_does_not_replay_non_idempotent_requests(
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ElydoraError) as error:
|
with pytest.raises(ElydoraError) as error:
|
||||||
client.issue_token()
|
client.issue_api_token()
|
||||||
assert error.value.status_code == 503
|
assert error.value.status_code == 503
|
||||||
assert scripted_server.methods == ["POST"]
|
assert scripted_server.methods == ["POST"]
|
||||||
assert delays == []
|
assert delays == []
|
||||||
@@ -241,7 +241,7 @@ async def test_async_client_does_not_replay_non_idempotent_requests(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with pytest.raises(ElydoraError) as error:
|
with pytest.raises(ElydoraError) as error:
|
||||||
await client.issue_token()
|
await client.issue_api_token()
|
||||||
assert error.value.status_code == 503
|
assert error.value.status_code == 503
|
||||||
assert scripted_server.methods == ["POST"]
|
assert scripted_server.methods == ["POST"]
|
||||||
assert delays == []
|
assert delays == []
|
||||||
|
|||||||
Reference in new issue
Block a user