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:
@@ -24,19 +24,19 @@
|
|||||||
|
|
||||||
## Workstream 2: Python SDK
|
## Workstream 2: Python SDK
|
||||||
|
|
||||||
- [ ] 2.1 Create `sdk-python/` with `pyproject.toml` — name: sentryagent-idp, python>=3.9
|
- [x] 2.1 Create `sdk-python/` with `pyproject.toml` — name: sentryagent-idp, python>=3.9
|
||||||
- [ ] 2.2 Write `sdk-python/src/sentryagent_idp/types.py` — all request/response dataclasses
|
- [x] 2.2 Write `sdk-python/src/sentryagent_idp/types.py` — all request/response dataclasses
|
||||||
- [ ] 2.3 Write `sdk-python/src/sentryagent_idp/errors.py` — AgentIdPError exception
|
- [x] 2.3 Write `sdk-python/src/sentryagent_idp/errors.py` — AgentIdPError exception
|
||||||
- [ ] 2.4 Write `sdk-python/src/sentryagent_idp/token_manager.py` — sync TokenManager
|
- [x] 2.4 Write `sdk-python/src/sentryagent_idp/token_manager.py` — sync TokenManager
|
||||||
- [ ] 2.5 Write `sdk-python/src/sentryagent_idp/async_token_manager.py` — async TokenManager (httpx)
|
- [x] 2.5 Write `sdk-python/src/sentryagent_idp/async_token_manager.py` — async TokenManager (httpx)
|
||||||
- [ ] 2.6 Write `sdk-python/src/sentryagent_idp/services/agents.py` — AgentRegistryClient (sync + async)
|
- [x] 2.6 Write `sdk-python/src/sentryagent_idp/services/agents.py` — AgentRegistryClient (sync + async)
|
||||||
- [ ] 2.7 Write `sdk-python/src/sentryagent_idp/services/credentials.py` — CredentialClient (sync + async)
|
- [x] 2.7 Write `sdk-python/src/sentryagent_idp/services/credentials.py` — CredentialClient (sync + async)
|
||||||
- [ ] 2.8 Write `sdk-python/src/sentryagent_idp/services/token.py` — TokenClient (sync + async)
|
- [x] 2.8 Write `sdk-python/src/sentryagent_idp/services/token.py` — TokenClient (sync + async)
|
||||||
- [ ] 2.9 Write `sdk-python/src/sentryagent_idp/services/audit.py` — AuditClient (sync + async)
|
- [x] 2.9 Write `sdk-python/src/sentryagent_idp/services/audit.py` — AuditClient (sync + async)
|
||||||
- [ ] 2.10 Write `sdk-python/src/sentryagent_idp/client.py` — AgentIdPClient (sync) + AsyncAgentIdPClient
|
- [x] 2.10 Write `sdk-python/src/sentryagent_idp/client.py` — AgentIdPClient (sync) + AsyncAgentIdPClient
|
||||||
- [ ] 2.11 Write `sdk-python/src/sentryagent_idp/__init__.py` — barrel exports
|
- [x] 2.11 Write `sdk-python/src/sentryagent_idp/__init__.py` — barrel exports
|
||||||
- [ ] 2.12 Write `sdk-python/README.md`
|
- [x] 2.12 Write `sdk-python/README.md`
|
||||||
- [ ] 2.13 QA: `mypy --strict` clean, all 14 endpoints, AgentIdPError on all failure paths, pytest >80%
|
- [x] 2.13 QA: `mypy --strict` clean, all 14 endpoints, AgentIdPError on all failure paths, pytest >80%
|
||||||
|
|
||||||
## Workstream 3: Go SDK
|
## Workstream 3: Go SDK
|
||||||
|
|
||||||
|
|||||||
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