feat(phase-2): workstream 2 — Python SDK (sentryagent-idp)
Sync (requests) and async (httpx) clients with identical API surface to the Node.js SDK. Delivered: - pyproject.toml — python>=3.9, hatchling build, mypy strict config - types.py — all 14-endpoint request/response dataclasses - errors.py — AgentIdPError with from_api_error, from_oauth2_error, network_error - token_manager.py — thread-safe sync TokenManager, 60s refresh buffer - async_token_manager.py — asyncio-safe AsyncTokenManager (httpx) - _request.py — shared sync/async request helper (DRY) - services/agents.py — AgentRegistryClient + AsyncAgentRegistryClient (5 methods each) - services/credentials.py — CredentialClient + AsyncCredentialClient (4 methods each) - services/token.py — TokenClient + AsyncTokenClient (introspect + revoke) - services/audit.py — AuditClient + AsyncAuditClient (query + get) - client.py — AgentIdPClient + AsyncAgentIdPClient - __init__.py — barrel exports - README.md — installation, quick start, full API reference QA gates: - mypy --strict: 0 errors (12 source files) - pytest: 57/57 passed - Coverage: 90.83% (required >= 80%) - All 14 endpoints covered (sync + async) - AgentIdPError raised on all failure paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BIN
sdk-python/.coverage
Normal file
BIN
sdk-python/.coverage
Normal file
Binary file not shown.
214
sdk-python/README.md
Normal file
214
sdk-python/README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# sentryagent-idp
|
||||
|
||||
Python SDK for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — the open-source Identity Provider for AI agents.
|
||||
|
||||
Handles token acquisition and caching automatically. Covers all 14 AgentIdP API endpoints. Provides both synchronous (`requests`) and asynchronous (`httpx`) clients.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.9 or later
|
||||
- A running AgentIdP server
|
||||
- A registered agent with a valid `client_id` and `client_secret`
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install sentryagent-idp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### Synchronous
|
||||
|
||||
```python
|
||||
from sentryagent_idp import AgentIdPClient
|
||||
|
||||
client = AgentIdPClient(
|
||||
base_url="http://localhost:3000",
|
||||
client_id="your-agent-id", # the agent's agentId (UUID)
|
||||
client_secret="your-client-secret",
|
||||
)
|
||||
|
||||
# List agents — token is acquired and cached automatically
|
||||
result = client.agents.list_agents()
|
||||
print(result.data)
|
||||
```
|
||||
|
||||
### Asynchronous
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from sentryagent_idp import AsyncAgentIdPClient
|
||||
|
||||
async def main() -> None:
|
||||
client = AsyncAgentIdPClient(
|
||||
base_url="http://localhost:3000",
|
||||
client_id="your-agent-id",
|
||||
client_secret="your-client-secret",
|
||||
)
|
||||
result = await client.agents.list_agents()
|
||||
print(result.data)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
```python
|
||||
client = AgentIdPClient(
|
||||
base_url="http://localhost:3000",
|
||||
client_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
client_secret="your-client-secret",
|
||||
# Optional: restrict scopes. Defaults to all four.
|
||||
scopes=["agents:read", "tokens:read"],
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `base_url` | Yes | Base URL of the AgentIdP server |
|
||||
| `client_id` | Yes | The agent's `agentId` (UUID) |
|
||||
| `client_secret` | Yes | The credential secret |
|
||||
| `scopes` | No | OAuth 2.0 scopes to request. Defaults to all four. |
|
||||
|
||||
---
|
||||
|
||||
## Token management
|
||||
|
||||
The SDK fetches and caches access tokens automatically. A new token is requested when the cached token is within 60 seconds of expiry.
|
||||
|
||||
```python
|
||||
# Force a fresh token on the next request (e.g. after rotating credentials)
|
||||
client.clear_token_cache()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent Registry
|
||||
|
||||
```python
|
||||
from sentryagent_idp import RegisterAgentRequest, UpdateAgentRequest
|
||||
|
||||
# Register a new agent
|
||||
agent = client.agents.register_agent(RegisterAgentRequest(
|
||||
email="classifier-v2@myorg.ai",
|
||||
agent_type="classifier",
|
||||
version="2.0.0",
|
||||
capabilities=["text-classification", "sentiment-analysis"],
|
||||
owner="platform-team",
|
||||
deployment_env="production",
|
||||
))
|
||||
print(agent.agent_id) # UUID assigned by AgentIdP
|
||||
|
||||
# List agents
|
||||
result = client.agents.list_agents(status="active", limit=20)
|
||||
|
||||
# Get a single agent
|
||||
agent = client.agents.get_agent("a1b2c3d4-...")
|
||||
|
||||
# Update an agent
|
||||
updated = client.agents.update_agent("a1b2c3d4-...", UpdateAgentRequest(
|
||||
version="2.1.0",
|
||||
capabilities=["text-classification", "sentiment-analysis", "intent-detection"],
|
||||
))
|
||||
|
||||
# Decommission (irreversible)
|
||||
client.agents.decommission_agent("a1b2c3d4-...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
```python
|
||||
# Generate a credential — client_secret shown once, store it securely
|
||||
cred = client.credentials.generate_credential("a1b2c3d4-...")
|
||||
print(cred.client_secret) # only available here
|
||||
|
||||
# List credentials
|
||||
result = client.credentials.list_credentials("a1b2c3d4-...")
|
||||
|
||||
# Rotate — same credential_id, new secret, old secret immediately invalid
|
||||
rotated = client.credentials.rotate_credential("a1b2c3d4-...", "cred-uuid")
|
||||
print(rotated.client_secret) # new secret — store immediately
|
||||
|
||||
# Revoke
|
||||
client.credentials.revoke_credential("a1b2c3d4-...", "cred-uuid")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token operations
|
||||
|
||||
```python
|
||||
# Introspect — check whether a token is active
|
||||
result = client.tokens.introspect_token(some_token)
|
||||
if result.active:
|
||||
print(f"Token valid, expires at {result.exp}")
|
||||
else:
|
||||
print("Token is expired or revoked")
|
||||
|
||||
# Revoke — immediately invalidates the token
|
||||
client.tokens.revoke_token(some_token)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit log
|
||||
|
||||
```python
|
||||
# Query audit events
|
||||
result = client.audit.query_audit_log(
|
||||
agent_id="a1b2c3d4-...",
|
||||
action="token.issued",
|
||||
outcome="success",
|
||||
from_date="2026-03-01T00:00:00Z",
|
||||
to_date="2026-03-31T23:59:59Z",
|
||||
limit=50,
|
||||
)
|
||||
|
||||
# Get a single event
|
||||
event = client.audit.get_audit_event("event-uuid")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
All API errors are raised as `AgentIdPError`:
|
||||
|
||||
```python
|
||||
from sentryagent_idp import AgentIdPClient, AgentIdPError
|
||||
|
||||
try:
|
||||
client.agents.get_agent("non-existent-id")
|
||||
except AgentIdPError as err:
|
||||
print(err.code) # e.g. "AgentNotFoundError"
|
||||
print(err.http_status) # e.g. 404
|
||||
print(str(err)) # human-readable description
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available scopes
|
||||
|
||||
| Scope | What it allows |
|
||||
|-------|----------------|
|
||||
| `agents:read` | Read agent records |
|
||||
| `agents:write` | Create, update, decommission agents |
|
||||
| `tokens:read` | Introspect tokens |
|
||||
| `audit:read` | Query audit logs |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0 — see `LICENSE` in the repository root.
|
||||
61
sdk-python/pyproject.toml
Normal file
61
sdk-python/pyproject.toml
Normal file
@@ -0,0 +1,61 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "sentryagent-idp"
|
||||
version = "1.0.0"
|
||||
description = "Python SDK for the SentryAgent.ai AgentIdP — Identity Provider for AI agents"
|
||||
readme = "README.md"
|
||||
license = { text = "Apache-2.0" }
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["ai", "agents", "identity", "oauth2", "agntcy"]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Topic :: Security",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"requests>=2.28.0",
|
||||
"httpx>=0.25.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"mypy>=1.8.0",
|
||||
"pytest>=7.4.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"respx>=0.20.0",
|
||||
"responses>=0.24.0",
|
||||
"types-requests>=2.31.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/sentryagent_idp"]
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
python_version = "3.9"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_any_generics = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
addopts = "--cov=src/sentryagent_idp --cov-report=term-missing --cov-fail-under=80"
|
||||
82
sdk-python/src/sentryagent_idp/__init__.py
Normal file
82
sdk-python/src/sentryagent_idp/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
SentryAgent.ai AgentIdP Python SDK.
|
||||
|
||||
Provides synchronous and asynchronous clients for the AgentIdP API.
|
||||
|
||||
Example (sync)::
|
||||
|
||||
from sentryagent_idp import AgentIdPClient
|
||||
|
||||
client = AgentIdPClient(
|
||||
base_url="http://localhost:3000",
|
||||
client_id="your-agent-id",
|
||||
client_secret="your-client-secret",
|
||||
)
|
||||
result = client.agents.list_agents()
|
||||
|
||||
Example (async)::
|
||||
|
||||
from sentryagent_idp import AsyncAgentIdPClient
|
||||
|
||||
client = AsyncAgentIdPClient(
|
||||
base_url="http://localhost:3000",
|
||||
client_id="your-agent-id",
|
||||
client_secret="your-client-secret",
|
||||
)
|
||||
result = await client.agents.list_agents()
|
||||
"""
|
||||
|
||||
from .client import AgentIdPClient, AsyncAgentIdPClient
|
||||
from .errors import AgentIdPError
|
||||
from .token_manager import TokenManager
|
||||
from .async_token_manager import AsyncTokenManager
|
||||
from .types import (
|
||||
Agent,
|
||||
AgentStatus,
|
||||
AgentType,
|
||||
AuditAction,
|
||||
AuditEvent,
|
||||
AuditOutcome,
|
||||
Credential,
|
||||
CredentialStatus,
|
||||
CredentialWithSecret,
|
||||
DeploymentEnv,
|
||||
IntrospectResponse,
|
||||
OAuthScope,
|
||||
PaginatedAgents,
|
||||
PaginatedAuditEvents,
|
||||
PaginatedCredentials,
|
||||
RegisterAgentRequest,
|
||||
TokenResponse,
|
||||
UpdateAgentRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Clients
|
||||
"AgentIdPClient",
|
||||
"AsyncAgentIdPClient",
|
||||
# Errors
|
||||
"AgentIdPError",
|
||||
# Token managers (for advanced use)
|
||||
"TokenManager",
|
||||
"AsyncTokenManager",
|
||||
# Types
|
||||
"Agent",
|
||||
"AgentStatus",
|
||||
"AgentType",
|
||||
"AuditAction",
|
||||
"AuditEvent",
|
||||
"AuditOutcome",
|
||||
"Credential",
|
||||
"CredentialStatus",
|
||||
"CredentialWithSecret",
|
||||
"DeploymentEnv",
|
||||
"IntrospectResponse",
|
||||
"OAuthScope",
|
||||
"PaginatedAgents",
|
||||
"PaginatedAuditEvents",
|
||||
"PaginatedCredentials",
|
||||
"RegisterAgentRequest",
|
||||
"TokenResponse",
|
||||
"UpdateAgentRequest",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
sdk-python/src/sentryagent_idp/__pycache__/types.cpython-312.pyc
Normal file
BIN
sdk-python/src/sentryagent_idp/__pycache__/types.cpython-312.pyc
Normal file
Binary file not shown.
127
sdk-python/src/sentryagent_idp/_request.py
Normal file
127
sdk-python/src/sentryagent_idp/_request.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Internal HTTP request helpers shared by all service clients.
|
||||
Not part of the public API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
from .errors import AgentIdPError
|
||||
|
||||
|
||||
def sync_request(
|
||||
method: str,
|
||||
base_url: str,
|
||||
path: str,
|
||||
token: str,
|
||||
body: Optional[Any] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Make a synchronous authenticated JSON request to the AgentIdP API.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PATCH, DELETE).
|
||||
base_url: AgentIdP base URL.
|
||||
path: API path (e.g. ``/api/v1/agents``).
|
||||
token: Bearer access token.
|
||||
body: Optional request body (serialised as JSON).
|
||||
params: Optional query parameters (None values are excluded).
|
||||
|
||||
Returns:
|
||||
Parsed JSON response body, or None for 204 responses.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On any API or network failure.
|
||||
"""
|
||||
url = base_url.rstrip("/") + path
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
filtered_params: Optional[Dict[str, str]] = (
|
||||
{k: str(v) for k, v in params.items() if v is not None}
|
||||
if params
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=body,
|
||||
params=filtered_params,
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise AgentIdPError.network_error(exc) from exc
|
||||
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
|
||||
resp_body = response.json() if response.content else {}
|
||||
if not response.ok:
|
||||
raise AgentIdPError.from_api_error(resp_body, response.status_code)
|
||||
return resp_body
|
||||
|
||||
|
||||
async def async_request(
|
||||
method: str,
|
||||
base_url: str,
|
||||
path: str,
|
||||
token: str,
|
||||
body: Optional[Any] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Make an asynchronous authenticated JSON request to the AgentIdP API.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PATCH, DELETE).
|
||||
base_url: AgentIdP base URL.
|
||||
path: API path.
|
||||
token: Bearer access token.
|
||||
body: Optional request body (serialised as JSON).
|
||||
params: Optional query parameters (None values are excluded).
|
||||
|
||||
Returns:
|
||||
Parsed JSON response body, or None for 204 responses.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On any API or network failure.
|
||||
"""
|
||||
url = base_url.rstrip("/") + path
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
filtered_params: Optional[Dict[str, str]] = (
|
||||
{k: str(v) for k, v in params.items() if v is not None}
|
||||
if params
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=body,
|
||||
params=filtered_params,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise AgentIdPError.network_error(exc) from exc
|
||||
|
||||
if response.status_code == 204:
|
||||
return None
|
||||
|
||||
resp_body = response.json() if response.content else {}
|
||||
if not response.is_success:
|
||||
raise AgentIdPError.from_api_error(resp_body, response.status_code)
|
||||
return resp_body
|
||||
117
sdk-python/src/sentryagent_idp/async_token_manager.py
Normal file
117
sdk-python/src/sentryagent_idp/async_token_manager.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Asynchronous TokenManager — handles OAuth 2.0 token acquisition, caching, and refresh.
|
||||
Uses httpx for async HTTP. Tokens are re-issued automatically when within 60 seconds of expiry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import AgentIdPError
|
||||
from .types import TokenResponse
|
||||
|
||||
#: Seconds before expiry at which a token refresh is triggered.
|
||||
REFRESH_BUFFER_SECONDS = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CachedToken:
|
||||
access_token: str
|
||||
expires_at: float # Unix timestamp (seconds)
|
||||
|
||||
|
||||
class AsyncTokenManager:
|
||||
"""
|
||||
Asyncio-safe asynchronous token manager.
|
||||
|
||||
Acquires and caches OAuth 2.0 access tokens. Automatically refreshes
|
||||
the token when it is within :data:`REFRESH_BUFFER_SECONDS` of expiry.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL (e.g. ``http://localhost:3000``).
|
||||
client_id: The agent's ``agentId`` (UUID).
|
||||
client_secret: The agent's credential secret.
|
||||
scopes: Space-separated OAuth 2.0 scopes to request.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scopes: str,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._scopes = scopes
|
||||
self._cached: Optional[_CachedToken] = None
|
||||
self._lock: Optional[asyncio.Lock] = None
|
||||
|
||||
def _get_lock(self) -> asyncio.Lock:
|
||||
"""Lazily create the asyncio.Lock on first use (supports different event loops)."""
|
||||
if self._lock is None:
|
||||
self._lock = asyncio.Lock()
|
||||
return self._lock
|
||||
|
||||
async def get_token(self) -> str:
|
||||
"""
|
||||
Return a valid access token, refreshing if necessary.
|
||||
|
||||
Returns:
|
||||
A valid JWT access token string.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: If token acquisition fails.
|
||||
"""
|
||||
async with self._get_lock():
|
||||
now = time.time()
|
||||
if (
|
||||
self._cached is not None
|
||||
and self._cached.expires_at - now > REFRESH_BUFFER_SECONDS
|
||||
):
|
||||
return self._cached.access_token
|
||||
|
||||
token_response = await self._issue_token()
|
||||
self._cached = _CachedToken(
|
||||
access_token=token_response.access_token,
|
||||
expires_at=now + token_response.expires_in,
|
||||
)
|
||||
return self._cached.access_token
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the cached token, forcing re-acquisition on the next call."""
|
||||
self._cached = None
|
||||
|
||||
async def _issue_token(self) -> TokenResponse:
|
||||
"""
|
||||
POST /api/v1/token to obtain a new access token.
|
||||
|
||||
Returns:
|
||||
TokenResponse from the API.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On authentication failure or network error.
|
||||
"""
|
||||
url = f"{self._base_url}/api/v1/token"
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
"scope": self._scopes,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, data=data)
|
||||
except httpx.RequestError as exc:
|
||||
raise AgentIdPError.network_error(exc) from exc
|
||||
|
||||
body = response.json()
|
||||
if not response.is_success:
|
||||
raise AgentIdPError.from_oauth2_error(body, response.status_code)
|
||||
return TokenResponse.from_dict(body)
|
||||
128
sdk-python/src/sentryagent_idp/client.py
Normal file
128
sdk-python/src/sentryagent_idp/client.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Top-level client for the SentryAgent.ai AgentIdP API.
|
||||
Provides both synchronous (AgentIdPClient) and asynchronous (AsyncAgentIdPClient) variants.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from .token_manager import TokenManager
|
||||
from .async_token_manager import AsyncTokenManager
|
||||
from .services.agents import AgentRegistryClient, AsyncAgentRegistryClient
|
||||
from .services.credentials import CredentialClient, AsyncCredentialClient
|
||||
from .services.token import TokenClient, AsyncTokenClient
|
||||
from .services.audit import AuditClient, AsyncAuditClient
|
||||
from .types import OAuthScope
|
||||
|
||||
_DEFAULT_SCOPES: List[OAuthScope] = [
|
||||
"agents:read",
|
||||
"agents:write",
|
||||
"tokens:read",
|
||||
"audit:read",
|
||||
]
|
||||
|
||||
|
||||
class AgentIdPClient:
|
||||
"""
|
||||
Synchronous client for the SentryAgent.ai AgentIdP API.
|
||||
|
||||
Composes all service clients under a single entry point. Handles token
|
||||
acquisition and caching automatically via :class:`~.token_manager.TokenManager`.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the AgentIdP server (e.g. ``http://localhost:3000``).
|
||||
client_id: The agent's ``agentId`` (UUID).
|
||||
client_secret: The credential secret.
|
||||
scopes: OAuth 2.0 scopes to request. Defaults to all four scopes.
|
||||
|
||||
Example::
|
||||
|
||||
from sentryagent_idp import AgentIdPClient, RegisterAgentRequest
|
||||
|
||||
client = AgentIdPClient(
|
||||
base_url="http://localhost:3000",
|
||||
client_id="your-agent-id",
|
||||
client_secret="your-client-secret",
|
||||
)
|
||||
agents = client.agents.list_agents()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scopes: Optional[List[OAuthScope]] = None,
|
||||
) -> None:
|
||||
scope_str = " ".join(scopes if scopes is not None else _DEFAULT_SCOPES)
|
||||
self._token_manager = TokenManager(base_url, client_id, client_secret, scope_str)
|
||||
|
||||
get_token = self._token_manager.get_token
|
||||
|
||||
#: Agent Registry operations: register, list, get, update, decommission.
|
||||
self.agents = AgentRegistryClient(base_url, get_token)
|
||||
#: Credential operations: generate, list, rotate, revoke.
|
||||
self.credentials = CredentialClient(base_url, get_token)
|
||||
#: Token operations: introspect, revoke.
|
||||
self.tokens = TokenClient(base_url, get_token)
|
||||
#: Audit log operations: query, get event.
|
||||
self.audit = AuditClient(base_url, get_token)
|
||||
|
||||
def clear_token_cache(self) -> None:
|
||||
"""
|
||||
Clear the cached access token.
|
||||
The next API call will request a new token. Use this after rotating credentials.
|
||||
"""
|
||||
self._token_manager.clear_cache()
|
||||
|
||||
|
||||
class AsyncAgentIdPClient:
|
||||
"""
|
||||
Asynchronous client for the SentryAgent.ai AgentIdP API.
|
||||
|
||||
All methods are coroutines and must be awaited. Token acquisition and caching
|
||||
are handled automatically via :class:`~.async_token_manager.AsyncTokenManager`.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the AgentIdP server.
|
||||
client_id: The agent's ``agentId`` (UUID).
|
||||
client_secret: The credential secret.
|
||||
scopes: OAuth 2.0 scopes to request. Defaults to all four scopes.
|
||||
|
||||
Example::
|
||||
|
||||
from sentryagent_idp import AsyncAgentIdPClient
|
||||
|
||||
client = AsyncAgentIdPClient(
|
||||
base_url="http://localhost:3000",
|
||||
client_id="your-agent-id",
|
||||
client_secret="your-client-secret",
|
||||
)
|
||||
agents = await client.agents.list_agents()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scopes: Optional[List[OAuthScope]] = None,
|
||||
) -> None:
|
||||
scope_str = " ".join(scopes if scopes is not None else _DEFAULT_SCOPES)
|
||||
self._token_manager = AsyncTokenManager(base_url, client_id, client_secret, scope_str)
|
||||
|
||||
get_token = self._token_manager.get_token
|
||||
|
||||
#: Agent Registry operations (async).
|
||||
self.agents = AsyncAgentRegistryClient(base_url, get_token)
|
||||
#: Credential operations (async).
|
||||
self.credentials = AsyncCredentialClient(base_url, get_token)
|
||||
#: Token operations (async).
|
||||
self.tokens = AsyncTokenClient(base_url, get_token)
|
||||
#: Audit log operations (async).
|
||||
self.audit = AsyncAuditClient(base_url, get_token)
|
||||
|
||||
def clear_token_cache(self) -> None:
|
||||
"""Clear the cached access token."""
|
||||
self._token_manager.clear_cache()
|
||||
108
sdk-python/src/sentryagent_idp/errors.py
Normal file
108
sdk-python/src/sentryagent_idp/errors.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Error types for the SentryAgent.ai AgentIdP Python SDK.
|
||||
All API failures are raised as AgentIdPError — never as raw requests/httpx exceptions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class AgentIdPError(Exception):
|
||||
"""
|
||||
Typed exception raised for all AgentIdP API failures.
|
||||
|
||||
Attributes:
|
||||
code: Machine-readable error code from the API (e.g. ``AgentNotFoundError``).
|
||||
http_status: HTTP status code of the failed response.
|
||||
details: Optional structured details from the API error response.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
http_status: int,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
self.details = details
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"AgentIdPError(code={self.code!r}, "
|
||||
f"http_status={self.http_status}, "
|
||||
f"message={str(self)!r})"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_api_error(
|
||||
cls, body: Any, http_status: int
|
||||
) -> "AgentIdPError":
|
||||
"""
|
||||
Create an AgentIdPError from a standard API error response body.
|
||||
|
||||
Args:
|
||||
body: Parsed response body (dict or unknown).
|
||||
http_status: HTTP status code.
|
||||
|
||||
Returns:
|
||||
AgentIdPError instance.
|
||||
"""
|
||||
if isinstance(body, dict) and "code" in body and "message" in body:
|
||||
return cls(
|
||||
code=str(body["code"]),
|
||||
message=str(body["message"]),
|
||||
http_status=http_status,
|
||||
details=body.get("details"),
|
||||
)
|
||||
return cls(
|
||||
code="UNKNOWN_ERROR",
|
||||
message=str(body),
|
||||
http_status=http_status,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_oauth2_error(
|
||||
cls, body: Any, http_status: int
|
||||
) -> "AgentIdPError":
|
||||
"""
|
||||
Create an AgentIdPError from an OAuth 2.0 error response body.
|
||||
|
||||
Args:
|
||||
body: Parsed response body.
|
||||
http_status: HTTP status code.
|
||||
|
||||
Returns:
|
||||
AgentIdPError instance.
|
||||
"""
|
||||
if isinstance(body, dict):
|
||||
return cls(
|
||||
code=str(body.get("error", "unknown_error")),
|
||||
message=str(body.get("error_description", "Token request failed.")),
|
||||
http_status=http_status,
|
||||
)
|
||||
return cls(
|
||||
code="unknown_error",
|
||||
message=str(body),
|
||||
http_status=http_status,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def network_error(cls, cause: Exception) -> "AgentIdPError":
|
||||
"""
|
||||
Create an AgentIdPError for a network-level failure (no HTTP response).
|
||||
|
||||
Args:
|
||||
cause: The underlying exception.
|
||||
|
||||
Returns:
|
||||
AgentIdPError with http_status=0.
|
||||
"""
|
||||
return cls(
|
||||
code="NETWORK_ERROR",
|
||||
message=f"Network error: {cause}",
|
||||
http_status=0,
|
||||
)
|
||||
1
sdk-python/src/sentryagent_idp/services/__init__.py
Normal file
1
sdk-python/src/sentryagent_idp/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
202
sdk-python/src/sentryagent_idp/services/agents.py
Normal file
202
sdk-python/src/sentryagent_idp/services/agents.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Agent Registry service clients — sync and async.
|
||||
Covers all five agent endpoints: register, list, get, update, decommission.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Coroutine, Dict, Optional
|
||||
|
||||
from .._request import sync_request, async_request
|
||||
from ..types import (
|
||||
Agent,
|
||||
AgentStatus,
|
||||
AgentType,
|
||||
PaginatedAgents,
|
||||
RegisterAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
)
|
||||
|
||||
|
||||
class AgentRegistryClient:
|
||||
"""
|
||||
Synchronous client for the Agent Registry service.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL.
|
||||
get_token: Callable that returns a valid Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
get_token: Callable[[], str],
|
||||
) -> None:
|
||||
self._base_url = base_url
|
||||
self._get_token = get_token
|
||||
|
||||
def register_agent(self, request: RegisterAgentRequest) -> Agent:
|
||||
"""
|
||||
Register a new AI agent.
|
||||
|
||||
Args:
|
||||
request: Agent registration parameters.
|
||||
|
||||
Returns:
|
||||
The created Agent record.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
data = sync_request(
|
||||
"POST", self._base_url, "/api/v1/agents",
|
||||
self._get_token(), body=request.to_dict(),
|
||||
)
|
||||
return Agent.from_dict(data)
|
||||
|
||||
def list_agents(
|
||||
self,
|
||||
status: Optional[AgentStatus] = None,
|
||||
agent_type: Optional[AgentType] = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
) -> PaginatedAgents:
|
||||
"""
|
||||
List all registered agents with optional filters.
|
||||
|
||||
Args:
|
||||
status: Filter by lifecycle status.
|
||||
agent_type: Filter by agent type.
|
||||
page: Page number (1-based).
|
||||
limit: Results per page.
|
||||
|
||||
Returns:
|
||||
PaginatedAgents response.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
data = sync_request(
|
||||
"GET", self._base_url, "/api/v1/agents",
|
||||
self._get_token(),
|
||||
params={"status": status, "agentType": agent_type, "page": page, "limit": limit},
|
||||
)
|
||||
return PaginatedAgents.from_dict(data)
|
||||
|
||||
def get_agent(self, agent_id: str) -> Agent:
|
||||
"""
|
||||
Get a single agent by its agentId.
|
||||
|
||||
Args:
|
||||
agent_id: The agent UUID.
|
||||
|
||||
Returns:
|
||||
Agent record.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: If agent not found or network failure.
|
||||
"""
|
||||
data = sync_request(
|
||||
"GET", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||
self._get_token(),
|
||||
)
|
||||
return Agent.from_dict(data)
|
||||
|
||||
def update_agent(self, agent_id: str, request: UpdateAgentRequest) -> Agent:
|
||||
"""
|
||||
Update mutable fields on an existing agent.
|
||||
|
||||
Args:
|
||||
agent_id: The agent UUID.
|
||||
request: Fields to update.
|
||||
|
||||
Returns:
|
||||
Updated Agent record.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
data = sync_request(
|
||||
"PATCH", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||
self._get_token(), body=request.to_dict(),
|
||||
)
|
||||
return Agent.from_dict(data)
|
||||
|
||||
def decommission_agent(self, agent_id: str) -> None:
|
||||
"""
|
||||
Decommission an agent. This is irreversible.
|
||||
|
||||
Args:
|
||||
agent_id: The agent UUID.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
sync_request(
|
||||
"DELETE", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||
self._get_token(),
|
||||
)
|
||||
|
||||
|
||||
class AsyncAgentRegistryClient:
|
||||
"""
|
||||
Asynchronous client for the Agent Registry service.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL.
|
||||
get_token: Async callable that returns a valid Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
get_token: Callable[[], Coroutine[Any, Any, str]],
|
||||
) -> None:
|
||||
self._base_url = base_url
|
||||
self._get_token = get_token
|
||||
|
||||
async def register_agent(self, request: RegisterAgentRequest) -> Agent:
|
||||
"""Register a new AI agent (async)."""
|
||||
data = await async_request(
|
||||
"POST", self._base_url, "/api/v1/agents",
|
||||
await self._get_token(), body=request.to_dict(),
|
||||
)
|
||||
return Agent.from_dict(data)
|
||||
|
||||
async def list_agents(
|
||||
self,
|
||||
status: Optional[AgentStatus] = None,
|
||||
agent_type: Optional[AgentType] = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
) -> PaginatedAgents:
|
||||
"""List all registered agents with optional filters (async)."""
|
||||
data = await async_request(
|
||||
"GET", self._base_url, "/api/v1/agents",
|
||||
await self._get_token(),
|
||||
params={"status": status, "agentType": agent_type, "page": page, "limit": limit},
|
||||
)
|
||||
return PaginatedAgents.from_dict(data)
|
||||
|
||||
async def get_agent(self, agent_id: str) -> Agent:
|
||||
"""Get a single agent by its agentId (async)."""
|
||||
data = await async_request(
|
||||
"GET", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||
await self._get_token(),
|
||||
)
|
||||
return Agent.from_dict(data)
|
||||
|
||||
async def update_agent(self, agent_id: str, request: UpdateAgentRequest) -> Agent:
|
||||
"""Update mutable fields on an existing agent (async)."""
|
||||
data = await async_request(
|
||||
"PATCH", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||
await self._get_token(), body=request.to_dict(),
|
||||
)
|
||||
return Agent.from_dict(data)
|
||||
|
||||
async def decommission_agent(self, agent_id: str) -> None:
|
||||
"""Decommission an agent — irreversible (async)."""
|
||||
await async_request(
|
||||
"DELETE", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||
await self._get_token(),
|
||||
)
|
||||
144
sdk-python/src/sentryagent_idp/services/audit.py
Normal file
144
sdk-python/src/sentryagent_idp/services/audit.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Audit Log service clients — sync and async.
|
||||
Covers query (list) and get-by-id operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Coroutine, Optional
|
||||
|
||||
from .._request import sync_request, async_request
|
||||
from ..types import AuditAction, AuditEvent, AuditOutcome, PaginatedAuditEvents
|
||||
|
||||
|
||||
class AuditClient:
|
||||
"""
|
||||
Synchronous client for the Audit Log service.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL.
|
||||
get_token: Callable that returns a valid Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
get_token: Callable[[], str],
|
||||
) -> None:
|
||||
self._base_url = base_url
|
||||
self._get_token = get_token
|
||||
|
||||
def query_audit_log(
|
||||
self,
|
||||
agent_id: Optional[str] = None,
|
||||
action: Optional[AuditAction] = None,
|
||||
outcome: Optional[AuditOutcome] = None,
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
) -> PaginatedAuditEvents:
|
||||
"""
|
||||
Query audit log events with optional filters. Requires ``audit:read`` scope.
|
||||
Events are retained for 90 days.
|
||||
|
||||
Args:
|
||||
agent_id: Filter by agent UUID.
|
||||
action: Filter by audit action type.
|
||||
outcome: Filter by outcome (success or failure).
|
||||
from_date: ISO 8601 start datetime (inclusive).
|
||||
to_date: ISO 8601 end datetime (inclusive).
|
||||
page: Page number (1-based).
|
||||
limit: Results per page.
|
||||
|
||||
Returns:
|
||||
PaginatedAuditEvents response.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
data = sync_request(
|
||||
"GET", self._base_url, "/api/v1/audit",
|
||||
self._get_token(),
|
||||
params={
|
||||
"agentId": agent_id,
|
||||
"action": action,
|
||||
"outcome": outcome,
|
||||
"fromDate": from_date,
|
||||
"toDate": to_date,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
return PaginatedAuditEvents.from_dict(data)
|
||||
|
||||
def get_audit_event(self, event_id: str) -> AuditEvent:
|
||||
"""
|
||||
Get a single audit event by its eventId. Requires ``audit:read`` scope.
|
||||
|
||||
Args:
|
||||
event_id: The audit event UUID.
|
||||
|
||||
Returns:
|
||||
AuditEvent record.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
data = sync_request(
|
||||
"GET", self._base_url, f"/api/v1/audit/{event_id}",
|
||||
self._get_token(),
|
||||
)
|
||||
return AuditEvent.from_dict(data)
|
||||
|
||||
|
||||
class AsyncAuditClient:
|
||||
"""
|
||||
Asynchronous client for the Audit Log service.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL.
|
||||
get_token: Async callable that returns a valid Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
get_token: Callable[[], Coroutine[Any, Any, str]],
|
||||
) -> None:
|
||||
self._base_url = base_url
|
||||
self._get_token = get_token
|
||||
|
||||
async def query_audit_log(
|
||||
self,
|
||||
agent_id: Optional[str] = None,
|
||||
action: Optional[AuditAction] = None,
|
||||
outcome: Optional[AuditOutcome] = None,
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
) -> PaginatedAuditEvents:
|
||||
"""Query audit log events with optional filters (async)."""
|
||||
data = await async_request(
|
||||
"GET", self._base_url, "/api/v1/audit",
|
||||
await self._get_token(),
|
||||
params={
|
||||
"agentId": agent_id,
|
||||
"action": action,
|
||||
"outcome": outcome,
|
||||
"fromDate": from_date,
|
||||
"toDate": to_date,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
return PaginatedAuditEvents.from_dict(data)
|
||||
|
||||
async def get_audit_event(self, event_id: str) -> AuditEvent:
|
||||
"""Get a single audit event by its eventId (async)."""
|
||||
data = await async_request(
|
||||
"GET", self._base_url, f"/api/v1/audit/{event_id}",
|
||||
await self._get_token(),
|
||||
)
|
||||
return AuditEvent.from_dict(data)
|
||||
209
sdk-python/src/sentryagent_idp/services/credentials.py
Normal file
209
sdk-python/src/sentryagent_idp/services/credentials.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Credential Management service clients — sync and async.
|
||||
Covers generate, list, rotate, and revoke operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Coroutine, Optional
|
||||
|
||||
from .._request import sync_request, async_request
|
||||
from ..types import (
|
||||
Credential,
|
||||
CredentialStatus,
|
||||
CredentialWithSecret,
|
||||
PaginatedCredentials,
|
||||
)
|
||||
|
||||
|
||||
class CredentialClient:
|
||||
"""
|
||||
Synchronous client for the Credential Management service.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL.
|
||||
get_token: Callable that returns a valid Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
get_token: Callable[[], str],
|
||||
) -> None:
|
||||
self._base_url = base_url
|
||||
self._get_token = get_token
|
||||
|
||||
def generate_credential(
|
||||
self,
|
||||
agent_id: str,
|
||||
expires_at: Optional[str] = None,
|
||||
) -> CredentialWithSecret:
|
||||
"""
|
||||
Generate a new credential for an agent.
|
||||
The ``client_secret`` is shown **once** — store it securely immediately.
|
||||
|
||||
Args:
|
||||
agent_id: The agent UUID.
|
||||
expires_at: Optional ISO 8601 expiry date string.
|
||||
|
||||
Returns:
|
||||
CredentialWithSecret including the one-time plain-text secret.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
body = {"expiresAt": expires_at} if expires_at is not None else None
|
||||
data = sync_request(
|
||||
"POST", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
|
||||
self._get_token(), body=body,
|
||||
)
|
||||
return CredentialWithSecret.from_dict(data)
|
||||
|
||||
def list_credentials(
|
||||
self,
|
||||
agent_id: str,
|
||||
status: Optional[CredentialStatus] = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
) -> PaginatedCredentials:
|
||||
"""
|
||||
List credentials for an agent. Secrets are never returned in list responses.
|
||||
|
||||
Args:
|
||||
agent_id: The agent UUID.
|
||||
status: Filter by credential status.
|
||||
page: Page number (1-based).
|
||||
limit: Results per page.
|
||||
|
||||
Returns:
|
||||
PaginatedCredentials response.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
data = sync_request(
|
||||
"GET", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
|
||||
self._get_token(),
|
||||
params={"status": status, "page": page, "limit": limit},
|
||||
)
|
||||
return PaginatedCredentials.from_dict(data)
|
||||
|
||||
def rotate_credential(
|
||||
self, agent_id: str, credential_id: str
|
||||
) -> CredentialWithSecret:
|
||||
"""
|
||||
Rotate a credential. The same ``credential_id`` is retained; a new secret is issued.
|
||||
The old secret is immediately invalidated.
|
||||
The new ``client_secret`` is shown **once** — store it securely immediately.
|
||||
|
||||
Args:
|
||||
agent_id: The agent UUID.
|
||||
credential_id: The credential UUID to rotate.
|
||||
|
||||
Returns:
|
||||
CredentialWithSecret with the new one-time secret.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
data = sync_request(
|
||||
"POST",
|
||||
self._base_url,
|
||||
f"/api/v1/agents/{agent_id}/credentials/{credential_id}/rotate",
|
||||
self._get_token(),
|
||||
)
|
||||
return CredentialWithSecret.from_dict(data)
|
||||
|
||||
def revoke_credential(
|
||||
self, agent_id: str, credential_id: str
|
||||
) -> Credential:
|
||||
"""
|
||||
Revoke a credential permanently.
|
||||
|
||||
Args:
|
||||
agent_id: The agent UUID.
|
||||
credential_id: The credential UUID to revoke.
|
||||
|
||||
Returns:
|
||||
The revoked Credential record.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
data = sync_request(
|
||||
"DELETE",
|
||||
self._base_url,
|
||||
f"/api/v1/agents/{agent_id}/credentials/{credential_id}",
|
||||
self._get_token(),
|
||||
)
|
||||
return Credential.from_dict(data)
|
||||
|
||||
|
||||
class AsyncCredentialClient:
|
||||
"""
|
||||
Asynchronous client for the Credential Management service.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL.
|
||||
get_token: Async callable that returns a valid Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
get_token: Callable[[], Coroutine[Any, Any, str]],
|
||||
) -> None:
|
||||
self._base_url = base_url
|
||||
self._get_token = get_token
|
||||
|
||||
async def generate_credential(
|
||||
self,
|
||||
agent_id: str,
|
||||
expires_at: Optional[str] = None,
|
||||
) -> CredentialWithSecret:
|
||||
"""Generate a new credential for an agent (async)."""
|
||||
body = {"expiresAt": expires_at} if expires_at is not None else None
|
||||
data = await async_request(
|
||||
"POST", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
|
||||
await self._get_token(), body=body,
|
||||
)
|
||||
return CredentialWithSecret.from_dict(data)
|
||||
|
||||
async def list_credentials(
|
||||
self,
|
||||
agent_id: str,
|
||||
status: Optional[CredentialStatus] = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
) -> PaginatedCredentials:
|
||||
"""List credentials for an agent (async)."""
|
||||
data = await async_request(
|
||||
"GET", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
|
||||
await self._get_token(),
|
||||
params={"status": status, "page": page, "limit": limit},
|
||||
)
|
||||
return PaginatedCredentials.from_dict(data)
|
||||
|
||||
async def rotate_credential(
|
||||
self, agent_id: str, credential_id: str
|
||||
) -> CredentialWithSecret:
|
||||
"""Rotate a credential (async)."""
|
||||
data = await async_request(
|
||||
"POST",
|
||||
self._base_url,
|
||||
f"/api/v1/agents/{agent_id}/credentials/{credential_id}/rotate",
|
||||
await self._get_token(),
|
||||
)
|
||||
return CredentialWithSecret.from_dict(data)
|
||||
|
||||
async def revoke_credential(
|
||||
self, agent_id: str, credential_id: str
|
||||
) -> Credential:
|
||||
"""Revoke a credential permanently (async)."""
|
||||
data = await async_request(
|
||||
"DELETE",
|
||||
self._base_url,
|
||||
f"/api/v1/agents/{agent_id}/credentials/{credential_id}",
|
||||
await self._get_token(),
|
||||
)
|
||||
return Credential.from_dict(data)
|
||||
154
sdk-python/src/sentryagent_idp/services/token.py
Normal file
154
sdk-python/src/sentryagent_idp/services/token.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Token service clients (introspect and revoke) — sync and async.
|
||||
Token issuance is handled by TokenManager / AsyncTokenManager.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
from ..errors import AgentIdPError
|
||||
from ..types import IntrospectResponse
|
||||
|
||||
|
||||
class TokenClient:
|
||||
"""
|
||||
Synchronous client for token introspection and revocation.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL.
|
||||
get_token: Callable that returns a valid Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
get_token: Callable[[], str],
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._get_token = get_token
|
||||
|
||||
def introspect_token(self, token_to_check: str) -> IntrospectResponse:
|
||||
"""
|
||||
Check whether a token is currently active.
|
||||
Always returns successfully — check ``response.active`` for validity.
|
||||
|
||||
Args:
|
||||
token_to_check: The JWT string to introspect.
|
||||
|
||||
Returns:
|
||||
IntrospectResponse with ``active`` field set.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
url = f"{self._base_url}/api/v1/token/introspect"
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
data={"token": token_to_check},
|
||||
headers={
|
||||
"Authorization": f"Bearer {self._get_token()}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise AgentIdPError.network_error(exc) from exc
|
||||
|
||||
body = response.json()
|
||||
if not response.ok:
|
||||
raise AgentIdPError.from_api_error(body, response.status_code)
|
||||
return IntrospectResponse.from_dict(body)
|
||||
|
||||
def revoke_token(self, token_to_revoke: str) -> None:
|
||||
"""
|
||||
Revoke a token immediately. Idempotent (RFC 7009).
|
||||
|
||||
Args:
|
||||
token_to_revoke: The JWT string to revoke.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On API or network failure.
|
||||
"""
|
||||
url = f"{self._base_url}/api/v1/token/revoke"
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
data={"token": token_to_revoke},
|
||||
headers={
|
||||
"Authorization": f"Bearer {self._get_token()}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise AgentIdPError.network_error(exc) from exc
|
||||
|
||||
if not response.ok:
|
||||
body = response.json() if response.content else {}
|
||||
raise AgentIdPError.from_api_error(body, response.status_code)
|
||||
|
||||
|
||||
class AsyncTokenClient:
|
||||
"""
|
||||
Asynchronous client for token introspection and revocation.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL.
|
||||
get_token: Async callable that returns a valid Bearer token.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
get_token: Callable[[], Coroutine[Any, Any, str]],
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._get_token = get_token
|
||||
|
||||
async def introspect_token(self, token_to_check: str) -> IntrospectResponse:
|
||||
"""Check whether a token is currently active (async)."""
|
||||
url = f"{self._base_url}/api/v1/token/introspect"
|
||||
token = await self._get_token()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
data={"token": token_to_check},
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise AgentIdPError.network_error(exc) from exc
|
||||
|
||||
body = response.json()
|
||||
if not response.is_success:
|
||||
raise AgentIdPError.from_api_error(body, response.status_code)
|
||||
return IntrospectResponse.from_dict(body)
|
||||
|
||||
async def revoke_token(self, token_to_revoke: str) -> None:
|
||||
"""Revoke a token immediately — idempotent (async)."""
|
||||
url = f"{self._base_url}/api/v1/token/revoke"
|
||||
token = await self._get_token()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
data={"token": token_to_revoke},
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise AgentIdPError.network_error(exc) from exc
|
||||
|
||||
if not response.is_success:
|
||||
body = response.json() if response.content else {}
|
||||
raise AgentIdPError.from_api_error(body, response.status_code)
|
||||
116
sdk-python/src/sentryagent_idp/token_manager.py
Normal file
116
sdk-python/src/sentryagent_idp/token_manager.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Synchronous TokenManager — handles OAuth 2.0 token acquisition, caching, and refresh.
|
||||
Tokens are re-issued automatically when expired or within 60 seconds of expiry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .errors import AgentIdPError
|
||||
from .types import TokenResponse
|
||||
|
||||
#: Seconds before expiry at which a token refresh is triggered.
|
||||
REFRESH_BUFFER_SECONDS = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CachedToken:
|
||||
access_token: str
|
||||
expires_at: float # Unix timestamp (seconds)
|
||||
|
||||
|
||||
class TokenManager:
|
||||
"""
|
||||
Thread-safe synchronous token manager.
|
||||
|
||||
Acquires and caches OAuth 2.0 access tokens. Automatically refreshes
|
||||
the token when it is within :data:`REFRESH_BUFFER_SECONDS` of expiry.
|
||||
|
||||
Args:
|
||||
base_url: AgentIdP server base URL (e.g. ``http://localhost:3000``).
|
||||
client_id: The agent's ``agentId`` (UUID).
|
||||
client_secret: The agent's credential secret.
|
||||
scopes: Space-separated OAuth 2.0 scopes to request.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scopes: str,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._scopes = scopes
|
||||
self._cached: Optional[_CachedToken] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get_token(self) -> str:
|
||||
"""
|
||||
Return a valid access token, refreshing if necessary.
|
||||
|
||||
Returns:
|
||||
A valid JWT access token string.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: If token acquisition fails.
|
||||
"""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
if (
|
||||
self._cached is not None
|
||||
and self._cached.expires_at - now > REFRESH_BUFFER_SECONDS
|
||||
):
|
||||
return self._cached.access_token
|
||||
|
||||
token_response = self._issue_token()
|
||||
self._cached = _CachedToken(
|
||||
access_token=token_response.access_token,
|
||||
expires_at=now + token_response.expires_in,
|
||||
)
|
||||
return self._cached.access_token
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the cached token, forcing re-acquisition on the next call."""
|
||||
with self._lock:
|
||||
self._cached = None
|
||||
|
||||
def _issue_token(self) -> TokenResponse:
|
||||
"""
|
||||
POST /api/v1/token to obtain a new access token.
|
||||
|
||||
Returns:
|
||||
TokenResponse from the API.
|
||||
|
||||
Raises:
|
||||
AgentIdPError: On authentication failure or network error.
|
||||
"""
|
||||
url = f"{self._base_url}/api/v1/token"
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
"scope": self._scopes,
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise AgentIdPError.network_error(exc) from exc
|
||||
|
||||
body = response.json()
|
||||
if not response.ok:
|
||||
raise AgentIdPError.from_oauth2_error(body, response.status_code)
|
||||
return TokenResponse.from_dict(body)
|
||||
323
sdk-python/src/sentryagent_idp/types.py
Normal file
323
sdk-python/src/sentryagent_idp/types.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Type definitions for the SentryAgent.ai AgentIdP Python SDK.
|
||||
All request and response shapes derived from the AgentIdP OpenAPI 3.0 specs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Enums / Literal types
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
AgentType = Literal[
|
||||
"screener",
|
||||
"classifier",
|
||||
"orchestrator",
|
||||
"extractor",
|
||||
"summarizer",
|
||||
"router",
|
||||
"monitor",
|
||||
"custom",
|
||||
]
|
||||
|
||||
AgentStatus = Literal["active", "suspended", "decommissioned"]
|
||||
|
||||
DeploymentEnv = Literal["development", "staging", "production"]
|
||||
|
||||
CredentialStatus = Literal["active", "revoked"]
|
||||
|
||||
OAuthScope = Literal["agents:read", "agents:write", "tokens:read", "audit:read"]
|
||||
|
||||
AuditAction = Literal[
|
||||
"agent.created",
|
||||
"agent.updated",
|
||||
"agent.decommissioned",
|
||||
"agent.suspended",
|
||||
"agent.reactivated",
|
||||
"token.issued",
|
||||
"token.revoked",
|
||||
"token.introspected",
|
||||
"credential.generated",
|
||||
"credential.rotated",
|
||||
"credential.revoked",
|
||||
"auth.failed",
|
||||
]
|
||||
|
||||
AuditOutcome = Literal["success", "failure"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Agent Registry
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Agent:
|
||||
"""A registered AI agent identity."""
|
||||
|
||||
agent_id: str
|
||||
email: str
|
||||
agent_type: AgentType
|
||||
version: str
|
||||
capabilities: List[str]
|
||||
owner: str
|
||||
deployment_env: DeploymentEnv
|
||||
status: AgentStatus
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Agent":
|
||||
"""Deserialise from an API response dict."""
|
||||
return cls(
|
||||
agent_id=data["agentId"],
|
||||
email=data["email"],
|
||||
agent_type=data["agentType"],
|
||||
version=data["version"],
|
||||
capabilities=data["capabilities"],
|
||||
owner=data["owner"],
|
||||
deployment_env=data["deploymentEnv"],
|
||||
status=data["status"],
|
||||
created_at=data["createdAt"],
|
||||
updated_at=data["updatedAt"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegisterAgentRequest:
|
||||
"""Request body for registering a new AI agent."""
|
||||
|
||||
email: str
|
||||
agent_type: AgentType
|
||||
version: str
|
||||
capabilities: List[str]
|
||||
owner: str
|
||||
deployment_env: DeploymentEnv
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialise to API request dict."""
|
||||
return {
|
||||
"email": self.email,
|
||||
"agentType": self.agent_type,
|
||||
"version": self.version,
|
||||
"capabilities": self.capabilities,
|
||||
"owner": self.owner,
|
||||
"deploymentEnv": self.deployment_env,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateAgentRequest:
|
||||
"""Request body for partially updating an agent (all fields optional)."""
|
||||
|
||||
agent_type: Optional[AgentType] = None
|
||||
version: Optional[str] = None
|
||||
capabilities: Optional[List[str]] = None
|
||||
owner: Optional[str] = None
|
||||
deployment_env: Optional[DeploymentEnv] = None
|
||||
status: Optional[AgentStatus] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialise to API request dict, omitting None fields."""
|
||||
out: Dict[str, Any] = {}
|
||||
if self.agent_type is not None:
|
||||
out["agentType"] = self.agent_type
|
||||
if self.version is not None:
|
||||
out["version"] = self.version
|
||||
if self.capabilities is not None:
|
||||
out["capabilities"] = self.capabilities
|
||||
if self.owner is not None:
|
||||
out["owner"] = self.owner
|
||||
if self.deployment_env is not None:
|
||||
out["deploymentEnv"] = self.deployment_env
|
||||
if self.status is not None:
|
||||
out["status"] = self.status
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaginatedAgents:
|
||||
"""Paginated list of agents."""
|
||||
|
||||
data: List[Agent]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedAgents":
|
||||
return cls(
|
||||
data=[Agent.from_dict(a) for a in d["data"]],
|
||||
total=d["total"],
|
||||
page=d["page"],
|
||||
limit=d["limit"],
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Credential Management
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Credential:
|
||||
"""A credential record (client_secret never included)."""
|
||||
|
||||
credential_id: str
|
||||
client_id: str
|
||||
status: CredentialStatus
|
||||
created_at: str
|
||||
expires_at: Optional[str]
|
||||
revoked_at: Optional[str]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "Credential":
|
||||
return cls(
|
||||
credential_id=data["credentialId"],
|
||||
client_id=data["clientId"],
|
||||
status=data["status"],
|
||||
created_at=data["createdAt"],
|
||||
expires_at=data.get("expiresAt"),
|
||||
revoked_at=data.get("revokedAt"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredentialWithSecret(Credential):
|
||||
"""Credential with plain-text secret — returned once only on create/rotate."""
|
||||
|
||||
client_secret: str = field(default="")
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "CredentialWithSecret":
|
||||
base = Credential.from_dict(data)
|
||||
return cls(
|
||||
credential_id=base.credential_id,
|
||||
client_id=base.client_id,
|
||||
status=base.status,
|
||||
created_at=base.created_at,
|
||||
expires_at=base.expires_at,
|
||||
revoked_at=base.revoked_at,
|
||||
client_secret=data["clientSecret"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaginatedCredentials:
|
||||
"""Paginated list of credentials."""
|
||||
|
||||
data: List[Credential]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedCredentials":
|
||||
return cls(
|
||||
data=[Credential.from_dict(c) for c in d["data"]],
|
||||
total=d["total"],
|
||||
page=d["page"],
|
||||
limit=d["limit"],
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# OAuth 2.0 Tokens
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenResponse:
|
||||
"""OAuth 2.0 access token response."""
|
||||
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
scope: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "TokenResponse":
|
||||
return cls(
|
||||
access_token=data["access_token"],
|
||||
token_type=data["token_type"],
|
||||
expires_in=data["expires_in"],
|
||||
scope=data["scope"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntrospectResponse:
|
||||
"""Token introspection response (RFC 7662)."""
|
||||
|
||||
active: bool
|
||||
sub: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
scope: Optional[str] = None
|
||||
token_type: Optional[str] = None
|
||||
iat: Optional[int] = None
|
||||
exp: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "IntrospectResponse":
|
||||
return cls(
|
||||
active=data["active"],
|
||||
sub=data.get("sub"),
|
||||
client_id=data.get("client_id"),
|
||||
scope=data.get("scope"),
|
||||
token_type=data.get("token_type"),
|
||||
iat=data.get("iat"),
|
||||
exp=data.get("exp"),
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Audit Log
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditEvent:
|
||||
"""An immutable audit event record."""
|
||||
|
||||
event_id: str
|
||||
agent_id: str
|
||||
action: AuditAction
|
||||
outcome: AuditOutcome
|
||||
ip_address: str
|
||||
user_agent: str
|
||||
metadata: Dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "AuditEvent":
|
||||
return cls(
|
||||
event_id=data["eventId"],
|
||||
agent_id=data["agentId"],
|
||||
action=data["action"],
|
||||
outcome=data["outcome"],
|
||||
ip_address=data["ipAddress"],
|
||||
user_agent=data["userAgent"],
|
||||
metadata=data.get("metadata", {}),
|
||||
timestamp=data["timestamp"],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaginatedAuditEvents:
|
||||
"""Paginated list of audit events."""
|
||||
|
||||
data: List[AuditEvent]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedAuditEvents":
|
||||
return cls(
|
||||
data=[AuditEvent.from_dict(e) for e in d["data"]],
|
||||
total=d["total"],
|
||||
page=d["page"],
|
||||
limit=d["limit"],
|
||||
)
|
||||
0
sdk-python/tests/__init__.py
Normal file
0
sdk-python/tests/__init__.py
Normal file
BIN
sdk-python/tests/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
sdk-python/tests/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
52
sdk-python/tests/test_errors.py
Normal file
52
sdk-python/tests/test_errors.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Tests for AgentIdPError."""
|
||||
|
||||
from sentryagent_idp.errors import AgentIdPError
|
||||
|
||||
|
||||
def test_basic_construction() -> None:
|
||||
err = AgentIdPError("AgentNotFoundError", "Agent not found.", 404)
|
||||
assert err.code == "AgentNotFoundError"
|
||||
assert err.http_status == 404
|
||||
assert str(err) == "Agent not found."
|
||||
assert err.details is None
|
||||
|
||||
|
||||
def test_from_api_error_valid_body() -> None:
|
||||
body = {"code": "AgentNotFoundError", "message": "Not found.", "details": {"id": "x"}}
|
||||
err = AgentIdPError.from_api_error(body, 404)
|
||||
assert err.code == "AgentNotFoundError"
|
||||
assert err.http_status == 404
|
||||
assert err.details == {"id": "x"}
|
||||
|
||||
|
||||
def test_from_api_error_unknown_body() -> None:
|
||||
err = AgentIdPError.from_api_error("plain string", 500)
|
||||
assert err.code == "UNKNOWN_ERROR"
|
||||
assert err.http_status == 500
|
||||
|
||||
|
||||
def test_from_oauth2_error() -> None:
|
||||
body = {"error": "invalid_client", "error_description": "Bad credentials."}
|
||||
err = AgentIdPError.from_oauth2_error(body, 401)
|
||||
assert err.code == "invalid_client"
|
||||
assert str(err) == "Bad credentials."
|
||||
assert err.http_status == 401
|
||||
|
||||
|
||||
def test_from_oauth2_error_unknown() -> None:
|
||||
err = AgentIdPError.from_oauth2_error("garbage", 400)
|
||||
assert err.code == "unknown_error"
|
||||
|
||||
|
||||
def test_network_error() -> None:
|
||||
cause = ConnectionError("refused")
|
||||
err = AgentIdPError.network_error(cause)
|
||||
assert err.code == "NETWORK_ERROR"
|
||||
assert err.http_status == 0
|
||||
assert "refused" in str(err)
|
||||
|
||||
|
||||
def test_repr() -> None:
|
||||
err = AgentIdPError("CODE", "msg", 400)
|
||||
assert "AgentIdPError" in repr(err)
|
||||
assert "CODE" in repr(err)
|
||||
349
sdk-python/tests/test_services.py
Normal file
349
sdk-python/tests/test_services.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
Tests for all service clients — covers all 14 API endpoints (sync + async).
|
||||
Uses `responses` for sync mocking and `respx` for async mocking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import responses as resp_lib
|
||||
import respx
|
||||
import httpx
|
||||
|
||||
from sentryagent_idp.errors import AgentIdPError
|
||||
from sentryagent_idp.services.agents import AgentRegistryClient, AsyncAgentRegistryClient
|
||||
from sentryagent_idp.services.credentials import CredentialClient, AsyncCredentialClient
|
||||
from sentryagent_idp.services.token import TokenClient, AsyncTokenClient
|
||||
from sentryagent_idp.services.audit import AuditClient, AsyncAuditClient
|
||||
from sentryagent_idp.types import RegisterAgentRequest, UpdateAgentRequest
|
||||
|
||||
BASE = "http://localhost:3000"
|
||||
TOKEN = "test-bearer-token"
|
||||
|
||||
|
||||
def get_token() -> str:
|
||||
return TOKEN
|
||||
|
||||
|
||||
async def async_get_token() -> str:
|
||||
return TOKEN
|
||||
|
||||
|
||||
# ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
AGENT = {
|
||||
"agentId": "uuid-1", "email": "a@b.ai", "agentType": "screener",
|
||||
"version": "1.0.0", "capabilities": ["read"], "owner": "team",
|
||||
"deploymentEnv": "production", "status": "active",
|
||||
"createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
PAGINATED_AGENTS = {"data": [AGENT], "total": 1, "page": 1, "limit": 20}
|
||||
|
||||
CRED = {
|
||||
"credentialId": "cred-1", "clientId": "uuid-1", "status": "active",
|
||||
"createdAt": "2026-01-01T00:00:00Z", "expiresAt": None, "revokedAt": None,
|
||||
}
|
||||
CRED_WITH_SECRET = {**CRED, "clientSecret": "sk_live_abc"}
|
||||
PAGINATED_CREDS = {"data": [CRED], "total": 1, "page": 1, "limit": 20}
|
||||
|
||||
INTROSPECT_ACTIVE = {"active": True, "sub": "uuid-1", "exp": 9999999999}
|
||||
INTROSPECT_INACTIVE = {"active": False}
|
||||
|
||||
AUDIT_EVENT = {
|
||||
"eventId": "ev-1", "agentId": "uuid-1", "action": "token.issued",
|
||||
"outcome": "success", "ipAddress": "1.2.3.4", "userAgent": "curl",
|
||||
"metadata": {}, "timestamp": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
PAGINATED_AUDIT = {"data": [AUDIT_EVENT], "total": 1, "page": 1, "limit": 20}
|
||||
|
||||
|
||||
# ─── Agent Registry — Sync ────────────────────────────────────────────────────
|
||||
|
||||
@resp_lib.activate
|
||||
def test_register_agent() -> None:
|
||||
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/agents", json=AGENT, status=201)
|
||||
client = AgentRegistryClient(BASE, get_token)
|
||||
agent = client.register_agent(RegisterAgentRequest(
|
||||
email="a@b.ai", agent_type="screener", version="1.0.0",
|
||||
capabilities=["read"], owner="team", deployment_env="production",
|
||||
))
|
||||
assert agent.agent_id == "uuid-1"
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_list_agents() -> None:
|
||||
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/agents", json=PAGINATED_AGENTS, status=200)
|
||||
client = AgentRegistryClient(BASE, get_token)
|
||||
result = client.list_agents()
|
||||
assert result.total == 1
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_get_agent() -> None:
|
||||
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/agents/uuid-1", json=AGENT, status=200)
|
||||
client = AgentRegistryClient(BASE, get_token)
|
||||
agent = client.get_agent("uuid-1")
|
||||
assert agent.agent_id == "uuid-1"
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_update_agent() -> None:
|
||||
resp_lib.add(resp_lib.PATCH, f"{BASE}/api/v1/agents/uuid-1", json=AGENT, status=200)
|
||||
client = AgentRegistryClient(BASE, get_token)
|
||||
agent = client.update_agent("uuid-1", UpdateAgentRequest(version="2.0.0"))
|
||||
assert agent.agent_id == "uuid-1"
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_decommission_agent() -> None:
|
||||
resp_lib.add(resp_lib.DELETE, f"{BASE}/api/v1/agents/uuid-1", status=204)
|
||||
client = AgentRegistryClient(BASE, get_token)
|
||||
result = client.decommission_agent("uuid-1")
|
||||
assert result is None
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_agent_not_found_raises() -> None:
|
||||
resp_lib.add(
|
||||
resp_lib.GET, f"{BASE}/api/v1/agents/bad-id",
|
||||
json={"code": "AgentNotFoundError", "message": "Not found."}, status=404,
|
||||
)
|
||||
client = AgentRegistryClient(BASE, get_token)
|
||||
with pytest.raises(AgentIdPError) as exc_info:
|
||||
client.get_agent("bad-id")
|
||||
assert exc_info.value.code == "AgentNotFoundError"
|
||||
assert exc_info.value.http_status == 404
|
||||
|
||||
|
||||
# ─── Credentials — Sync ───────────────────────────────────────────────────────
|
||||
|
||||
@resp_lib.activate
|
||||
def test_generate_credential() -> None:
|
||||
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/agents/uuid-1/credentials", json=CRED_WITH_SECRET, status=201)
|
||||
client = CredentialClient(BASE, get_token)
|
||||
cred = client.generate_credential("uuid-1")
|
||||
assert cred.client_secret == "sk_live_abc"
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_list_credentials() -> None:
|
||||
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/agents/uuid-1/credentials", json=PAGINATED_CREDS, status=200)
|
||||
client = CredentialClient(BASE, get_token)
|
||||
result = client.list_credentials("uuid-1")
|
||||
assert result.total == 1
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_rotate_credential() -> None:
|
||||
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1/rotate", json=CRED_WITH_SECRET, status=200)
|
||||
client = CredentialClient(BASE, get_token)
|
||||
cred = client.rotate_credential("uuid-1", "cred-1")
|
||||
assert cred.client_secret == "sk_live_abc"
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_revoke_credential() -> None:
|
||||
revoked = {**CRED, "status": "revoked", "revokedAt": "2026-01-02T00:00:00Z"}
|
||||
resp_lib.add(resp_lib.DELETE, f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1", json=revoked, status=200)
|
||||
client = CredentialClient(BASE, get_token)
|
||||
cred = client.revoke_credential("uuid-1", "cred-1")
|
||||
assert cred.status == "revoked"
|
||||
|
||||
|
||||
# ─── Token — Sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
@resp_lib.activate
|
||||
def test_introspect_token_active() -> None:
|
||||
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/token/introspect", json=INTROSPECT_ACTIVE, status=200)
|
||||
client = TokenClient(BASE, get_token)
|
||||
result = client.introspect_token("some-token")
|
||||
assert result.active is True
|
||||
assert result.sub == "uuid-1"
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_introspect_token_inactive() -> None:
|
||||
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/token/introspect", json=INTROSPECT_INACTIVE, status=200)
|
||||
client = TokenClient(BASE, get_token)
|
||||
result = client.introspect_token("expired-token")
|
||||
assert result.active is False
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_revoke_token() -> None:
|
||||
resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/token/revoke", json={}, status=200)
|
||||
client = TokenClient(BASE, get_token)
|
||||
result = client.revoke_token("some-token")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ─── Audit — Sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
@resp_lib.activate
|
||||
def test_query_audit_log() -> None:
|
||||
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/audit", json=PAGINATED_AUDIT, status=200)
|
||||
client = AuditClient(BASE, get_token)
|
||||
result = client.query_audit_log(agent_id="uuid-1", action="token.issued")
|
||||
assert result.total == 1
|
||||
assert result.data[0].event_id == "ev-1"
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_get_audit_event() -> None:
|
||||
resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/audit/ev-1", json=AUDIT_EVENT, status=200)
|
||||
client = AuditClient(BASE, get_token)
|
||||
event = client.get_audit_event("ev-1")
|
||||
assert event.event_id == "ev-1"
|
||||
|
||||
|
||||
# ─── Async — all 14 endpoints ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_register_agent() -> None:
|
||||
with respx.mock:
|
||||
respx.post(f"{BASE}/api/v1/agents").mock(return_value=httpx.Response(201, json=AGENT))
|
||||
client = AsyncAgentRegistryClient(BASE, async_get_token)
|
||||
agent = await client.register_agent(RegisterAgentRequest(
|
||||
email="a@b.ai", agent_type="screener", version="1.0.0",
|
||||
capabilities=["read"], owner="team", deployment_env="production",
|
||||
))
|
||||
assert agent.agent_id == "uuid-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_list_agents() -> None:
|
||||
with respx.mock:
|
||||
respx.get(f"{BASE}/api/v1/agents").mock(return_value=httpx.Response(200, json=PAGINATED_AGENTS))
|
||||
client = AsyncAgentRegistryClient(BASE, async_get_token)
|
||||
result = await client.list_agents()
|
||||
assert result.total == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_get_agent() -> None:
|
||||
with respx.mock:
|
||||
respx.get(f"{BASE}/api/v1/agents/uuid-1").mock(return_value=httpx.Response(200, json=AGENT))
|
||||
client = AsyncAgentRegistryClient(BASE, async_get_token)
|
||||
agent = await client.get_agent("uuid-1")
|
||||
assert agent.agent_id == "uuid-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_update_agent() -> None:
|
||||
with respx.mock:
|
||||
respx.patch(f"{BASE}/api/v1/agents/uuid-1").mock(return_value=httpx.Response(200, json=AGENT))
|
||||
client = AsyncAgentRegistryClient(BASE, async_get_token)
|
||||
agent = await client.update_agent("uuid-1", UpdateAgentRequest(version="2.0.0"))
|
||||
assert agent.agent_id == "uuid-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_decommission_agent() -> None:
|
||||
with respx.mock:
|
||||
respx.delete(f"{BASE}/api/v1/agents/uuid-1").mock(return_value=httpx.Response(204))
|
||||
client = AsyncAgentRegistryClient(BASE, async_get_token)
|
||||
result = await client.decommission_agent("uuid-1")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_generate_credential() -> None:
|
||||
with respx.mock:
|
||||
respx.post(f"{BASE}/api/v1/agents/uuid-1/credentials").mock(
|
||||
return_value=httpx.Response(201, json=CRED_WITH_SECRET))
|
||||
client = AsyncCredentialClient(BASE, async_get_token)
|
||||
cred = await client.generate_credential("uuid-1")
|
||||
assert cred.client_secret == "sk_live_abc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_list_credentials() -> None:
|
||||
with respx.mock:
|
||||
respx.get(f"{BASE}/api/v1/agents/uuid-1/credentials").mock(
|
||||
return_value=httpx.Response(200, json=PAGINATED_CREDS))
|
||||
client = AsyncCredentialClient(BASE, async_get_token)
|
||||
result = await client.list_credentials("uuid-1")
|
||||
assert result.total == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_rotate_credential() -> None:
|
||||
with respx.mock:
|
||||
respx.post(f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1/rotate").mock(
|
||||
return_value=httpx.Response(200, json=CRED_WITH_SECRET))
|
||||
client = AsyncCredentialClient(BASE, async_get_token)
|
||||
cred = await client.rotate_credential("uuid-1", "cred-1")
|
||||
assert cred.client_secret == "sk_live_abc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_revoke_credential() -> None:
|
||||
revoked = {**CRED, "status": "revoked", "revokedAt": "2026-01-02T00:00:00Z"}
|
||||
with respx.mock:
|
||||
respx.delete(f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1").mock(
|
||||
return_value=httpx.Response(200, json=revoked))
|
||||
client = AsyncCredentialClient(BASE, async_get_token)
|
||||
cred = await client.revoke_credential("uuid-1", "cred-1")
|
||||
assert cred.status == "revoked"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_introspect_token() -> None:
|
||||
with respx.mock:
|
||||
respx.post(f"{BASE}/api/v1/token/introspect").mock(
|
||||
return_value=httpx.Response(200, json=INTROSPECT_ACTIVE))
|
||||
client = AsyncTokenClient(BASE, async_get_token)
|
||||
result = await client.introspect_token("tok")
|
||||
assert result.active is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_revoke_token() -> None:
|
||||
with respx.mock:
|
||||
respx.post(f"{BASE}/api/v1/token/revoke").mock(return_value=httpx.Response(200, json={}))
|
||||
client = AsyncTokenClient(BASE, async_get_token)
|
||||
await client.revoke_token("tok")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_query_audit_log() -> None:
|
||||
with respx.mock:
|
||||
respx.get(f"{BASE}/api/v1/audit").mock(return_value=httpx.Response(200, json=PAGINATED_AUDIT))
|
||||
client = AsyncAuditClient(BASE, async_get_token)
|
||||
result = await client.query_audit_log()
|
||||
assert result.total == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_get_audit_event() -> None:
|
||||
with respx.mock:
|
||||
respx.get(f"{BASE}/api/v1/audit/ev-1").mock(return_value=httpx.Response(200, json=AUDIT_EVENT))
|
||||
client = AsyncAuditClient(BASE, async_get_token)
|
||||
event = await client.get_audit_event("ev-1")
|
||||
assert event.event_id == "ev-1"
|
||||
|
||||
|
||||
# ─── Error propagation ────────────────────────────────────────────────────────
|
||||
|
||||
@resp_lib.activate
|
||||
def test_api_error_propagated_from_service() -> None:
|
||||
resp_lib.add(
|
||||
resp_lib.GET, f"{BASE}/api/v1/agents/bad",
|
||||
json={"code": "AgentNotFoundError", "message": "Not found."}, status=404,
|
||||
)
|
||||
client = AgentRegistryClient(BASE, get_token)
|
||||
with pytest.raises(AgentIdPError) as exc_info:
|
||||
client.get_agent("bad")
|
||||
assert exc_info.value.http_status == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_api_error_propagated() -> None:
|
||||
with respx.mock:
|
||||
respx.get(f"{BASE}/api/v1/agents/bad").mock(return_value=httpx.Response(
|
||||
404, json={"code": "AgentNotFoundError", "message": "Not found."}
|
||||
))
|
||||
client = AsyncAgentRegistryClient(BASE, async_get_token)
|
||||
with pytest.raises(AgentIdPError) as exc_info:
|
||||
await client.get_agent("bad")
|
||||
assert exc_info.value.http_status == 404
|
||||
112
sdk-python/tests/test_token_manager.py
Normal file
112
sdk-python/tests/test_token_manager.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for TokenManager (sync) and AsyncTokenManager."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
import responses as resp_lib
|
||||
import respx
|
||||
import httpx
|
||||
|
||||
from sentryagent_idp.token_manager import TokenManager, REFRESH_BUFFER_SECONDS
|
||||
from sentryagent_idp.async_token_manager import AsyncTokenManager
|
||||
from sentryagent_idp.errors import AgentIdPError
|
||||
|
||||
BASE_URL = "http://localhost:3000"
|
||||
TOKEN_URL = f"{BASE_URL}/api/v1/token"
|
||||
TOKEN_RESP = {
|
||||
"access_token": "eyJ.abc.def",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "agents:read",
|
||||
}
|
||||
|
||||
|
||||
# ─── Sync TokenManager ────────────────────────────────────────────────────────
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_issues_token() -> None:
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
token = tm.get_token()
|
||||
assert token == "eyJ.abc.def"
|
||||
assert len(resp_lib.calls) == 1
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_caches_token() -> None:
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
tm.get_token()
|
||||
tm.get_token()
|
||||
# Only one HTTP call because second call uses cache
|
||||
assert len(resp_lib.calls) == 1
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_refreshes_near_expiry() -> None:
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json={**TOKEN_RESP, "expires_in": 30}, status=200)
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
tm.get_token()
|
||||
# Simulate cached token being nearly expired
|
||||
assert tm._cached is not None
|
||||
tm._cached.expires_at = time.time() + (REFRESH_BUFFER_SECONDS - 1)
|
||||
tm.get_token()
|
||||
assert len(resp_lib.calls) == 2
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_raises_on_auth_failure() -> None:
|
||||
resp_lib.add(
|
||||
resp_lib.POST, TOKEN_URL,
|
||||
json={"error": "invalid_client", "error_description": "Bad creds."},
|
||||
status=401,
|
||||
)
|
||||
tm = TokenManager(BASE_URL, "client-id", "bad-secret", "agents:read")
|
||||
with pytest.raises(AgentIdPError) as exc_info:
|
||||
tm.get_token()
|
||||
assert exc_info.value.code == "invalid_client"
|
||||
assert exc_info.value.http_status == 401
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_clear_cache() -> None:
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
tm.get_token()
|
||||
tm.clear_cache()
|
||||
tm.get_token()
|
||||
assert len(resp_lib.calls) == 2
|
||||
|
||||
|
||||
# ─── Async TokenManager ───────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_token_manager_issues_token() -> None:
|
||||
with respx.mock:
|
||||
respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
|
||||
tm = AsyncTokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
token = await tm.get_token()
|
||||
assert token == "eyJ.abc.def"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_token_manager_caches_token() -> None:
|
||||
with respx.mock:
|
||||
route = respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
|
||||
tm = AsyncTokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
await tm.get_token()
|
||||
await tm.get_token()
|
||||
assert route.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_token_manager_raises_on_auth_failure() -> None:
|
||||
with respx.mock:
|
||||
respx.post(TOKEN_URL).mock(return_value=httpx.Response(
|
||||
401, json={"error": "invalid_client", "error_description": "Bad creds."}
|
||||
))
|
||||
tm = AsyncTokenManager(BASE_URL, "client-id", "bad-secret", "agents:read")
|
||||
with pytest.raises(AgentIdPError) as exc_info:
|
||||
await tm.get_token()
|
||||
assert exc_info.value.code == "invalid_client"
|
||||
133
sdk-python/tests/test_types.py
Normal file
133
sdk-python/tests/test_types.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Tests for dataclass deserialisation in types.py."""
|
||||
|
||||
from sentryagent_idp.types import (
|
||||
Agent,
|
||||
Credential,
|
||||
CredentialWithSecret,
|
||||
PaginatedAgents,
|
||||
PaginatedCredentials,
|
||||
TokenResponse,
|
||||
IntrospectResponse,
|
||||
AuditEvent,
|
||||
PaginatedAuditEvents,
|
||||
RegisterAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
)
|
||||
|
||||
|
||||
AGENT_DICT = {
|
||||
"agentId": "uuid-1",
|
||||
"email": "a@b.ai",
|
||||
"agentType": "screener",
|
||||
"version": "1.0.0",
|
||||
"capabilities": ["read"],
|
||||
"owner": "team",
|
||||
"deploymentEnv": "production",
|
||||
"status": "active",
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"updatedAt": "2026-01-02T00:00:00Z",
|
||||
}
|
||||
|
||||
CREDENTIAL_DICT = {
|
||||
"credentialId": "cred-1",
|
||||
"clientId": "uuid-1",
|
||||
"status": "active",
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"expiresAt": None,
|
||||
"revokedAt": None,
|
||||
}
|
||||
|
||||
|
||||
def test_agent_from_dict() -> None:
|
||||
agent = Agent.from_dict(AGENT_DICT)
|
||||
assert agent.agent_id == "uuid-1"
|
||||
assert agent.agent_type == "screener"
|
||||
assert agent.capabilities == ["read"]
|
||||
|
||||
|
||||
def test_register_agent_request_to_dict() -> None:
|
||||
req = RegisterAgentRequest(
|
||||
email="a@b.ai",
|
||||
agent_type="classifier",
|
||||
version="1.0.0",
|
||||
capabilities=["read"],
|
||||
owner="team",
|
||||
deployment_env="production",
|
||||
)
|
||||
d = req.to_dict()
|
||||
assert d["agentType"] == "classifier"
|
||||
assert d["deploymentEnv"] == "production"
|
||||
|
||||
|
||||
def test_update_agent_request_omits_none() -> None:
|
||||
req = UpdateAgentRequest(version="2.0.0")
|
||||
d = req.to_dict()
|
||||
assert "version" in d
|
||||
assert "agentType" not in d
|
||||
|
||||
|
||||
def test_paginated_agents_from_dict() -> None:
|
||||
result = PaginatedAgents.from_dict({"data": [AGENT_DICT], "total": 1, "page": 1, "limit": 20})
|
||||
assert result.total == 1
|
||||
assert result.data[0].agent_id == "uuid-1"
|
||||
|
||||
|
||||
def test_credential_from_dict() -> None:
|
||||
cred = Credential.from_dict(CREDENTIAL_DICT)
|
||||
assert cred.credential_id == "cred-1"
|
||||
assert cred.expires_at is None
|
||||
|
||||
|
||||
def test_credential_with_secret_from_dict() -> None:
|
||||
d = {**CREDENTIAL_DICT, "clientSecret": "sk_live_abc"}
|
||||
cred = CredentialWithSecret.from_dict(d)
|
||||
assert cred.client_secret == "sk_live_abc"
|
||||
assert cred.credential_id == "cred-1"
|
||||
|
||||
|
||||
def test_paginated_credentials_from_dict() -> None:
|
||||
result = PaginatedCredentials.from_dict(
|
||||
{"data": [CREDENTIAL_DICT], "total": 1, "page": 1, "limit": 20}
|
||||
)
|
||||
assert result.total == 1
|
||||
|
||||
|
||||
def test_token_response_from_dict() -> None:
|
||||
tr = TokenResponse.from_dict(
|
||||
{"access_token": "tok", "token_type": "Bearer", "expires_in": 3600, "scope": "agents:read"}
|
||||
)
|
||||
assert tr.access_token == "tok"
|
||||
assert tr.expires_in == 3600
|
||||
|
||||
|
||||
def test_introspect_response_active() -> None:
|
||||
ir = IntrospectResponse.from_dict({"active": True, "sub": "uuid-1", "exp": 9999999999})
|
||||
assert ir.active is True
|
||||
assert ir.sub == "uuid-1"
|
||||
|
||||
|
||||
def test_introspect_response_inactive() -> None:
|
||||
ir = IntrospectResponse.from_dict({"active": False})
|
||||
assert ir.active is False
|
||||
assert ir.sub is None
|
||||
|
||||
|
||||
def test_audit_event_from_dict() -> None:
|
||||
ev = AuditEvent.from_dict({
|
||||
"eventId": "ev-1", "agentId": "uuid-1", "action": "token.issued",
|
||||
"outcome": "success", "ipAddress": "1.2.3.4", "userAgent": "curl",
|
||||
"metadata": {}, "timestamp": "2026-01-01T00:00:00Z",
|
||||
})
|
||||
assert ev.event_id == "ev-1"
|
||||
assert ev.action == "token.issued"
|
||||
|
||||
|
||||
def test_paginated_audit_events_from_dict() -> None:
|
||||
ev_dict = {
|
||||
"eventId": "ev-1", "agentId": "uuid-1", "action": "token.issued",
|
||||
"outcome": "success", "ipAddress": "1.2.3.4", "userAgent": "curl",
|
||||
"metadata": {}, "timestamp": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
result = PaginatedAuditEvents.from_dict({"data": [ev_dict], "total": 1, "page": 1, "limit": 20})
|
||||
assert result.total == 1
|
||||
assert result.data[0].event_id == "ev-1"
|
||||
Reference in New Issue
Block a user