diff --git a/openspec/changes/phase-2-production-ready/tasks.md b/openspec/changes/phase-2-production-ready/tasks.md index 0ac6433..9f9513e 100644 --- a/openspec/changes/phase-2-production-ready/tasks.md +++ b/openspec/changes/phase-2-production-ready/tasks.md @@ -24,19 +24,19 @@ ## Workstream 2: Python SDK -- [ ] 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 -- [ ] 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 -- [ ] 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) -- [ ] 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) -- [ ] 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 -- [ ] 2.11 Write `sdk-python/src/sentryagent_idp/__init__.py` — barrel exports -- [ ] 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.1 Create `sdk-python/` with `pyproject.toml` — name: sentryagent-idp, python>=3.9 +- [x] 2.2 Write `sdk-python/src/sentryagent_idp/types.py` — all request/response dataclasses +- [x] 2.3 Write `sdk-python/src/sentryagent_idp/errors.py` — AgentIdPError exception +- [x] 2.4 Write `sdk-python/src/sentryagent_idp/token_manager.py` — sync TokenManager +- [x] 2.5 Write `sdk-python/src/sentryagent_idp/async_token_manager.py` — async TokenManager (httpx) +- [x] 2.6 Write `sdk-python/src/sentryagent_idp/services/agents.py` — AgentRegistryClient (sync + async) +- [x] 2.7 Write `sdk-python/src/sentryagent_idp/services/credentials.py` — CredentialClient (sync + async) +- [x] 2.8 Write `sdk-python/src/sentryagent_idp/services/token.py` — TokenClient (sync + async) +- [x] 2.9 Write `sdk-python/src/sentryagent_idp/services/audit.py` — AuditClient (sync + async) +- [x] 2.10 Write `sdk-python/src/sentryagent_idp/client.py` — AgentIdPClient (sync) + AsyncAgentIdPClient +- [x] 2.11 Write `sdk-python/src/sentryagent_idp/__init__.py` — barrel exports +- [x] 2.12 Write `sdk-python/README.md` +- [x] 2.13 QA: `mypy --strict` clean, all 14 endpoints, AgentIdPError on all failure paths, pytest >80% ## Workstream 3: Go SDK diff --git a/sdk-python/.coverage b/sdk-python/.coverage new file mode 100644 index 0000000..14fabff Binary files /dev/null and b/sdk-python/.coverage differ diff --git a/sdk-python/README.md b/sdk-python/README.md new file mode 100644 index 0000000..fe1ebda --- /dev/null +++ b/sdk-python/README.md @@ -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. diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml new file mode 100644 index 0000000..e33e351 --- /dev/null +++ b/sdk-python/pyproject.toml @@ -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" diff --git a/sdk-python/src/sentryagent_idp/__init__.py b/sdk-python/src/sentryagent_idp/__init__.py new file mode 100644 index 0000000..36d5648 --- /dev/null +++ b/sdk-python/src/sentryagent_idp/__init__.py @@ -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", +] diff --git a/sdk-python/src/sentryagent_idp/__pycache__/__init__.cpython-312.pyc b/sdk-python/src/sentryagent_idp/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2affff3 Binary files /dev/null and b/sdk-python/src/sentryagent_idp/__pycache__/__init__.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/__pycache__/_request.cpython-312.pyc b/sdk-python/src/sentryagent_idp/__pycache__/_request.cpython-312.pyc new file mode 100644 index 0000000..a125f2c Binary files /dev/null and b/sdk-python/src/sentryagent_idp/__pycache__/_request.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/__pycache__/async_token_manager.cpython-312.pyc b/sdk-python/src/sentryagent_idp/__pycache__/async_token_manager.cpython-312.pyc new file mode 100644 index 0000000..890eef8 Binary files /dev/null and b/sdk-python/src/sentryagent_idp/__pycache__/async_token_manager.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/__pycache__/client.cpython-312.pyc b/sdk-python/src/sentryagent_idp/__pycache__/client.cpython-312.pyc new file mode 100644 index 0000000..9619ee3 Binary files /dev/null and b/sdk-python/src/sentryagent_idp/__pycache__/client.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/__pycache__/errors.cpython-312.pyc b/sdk-python/src/sentryagent_idp/__pycache__/errors.cpython-312.pyc new file mode 100644 index 0000000..f3ea3ad Binary files /dev/null and b/sdk-python/src/sentryagent_idp/__pycache__/errors.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/__pycache__/token_manager.cpython-312.pyc b/sdk-python/src/sentryagent_idp/__pycache__/token_manager.cpython-312.pyc new file mode 100644 index 0000000..aa7ee43 Binary files /dev/null and b/sdk-python/src/sentryagent_idp/__pycache__/token_manager.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/__pycache__/types.cpython-312.pyc b/sdk-python/src/sentryagent_idp/__pycache__/types.cpython-312.pyc new file mode 100644 index 0000000..ca4230e Binary files /dev/null and b/sdk-python/src/sentryagent_idp/__pycache__/types.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/_request.py b/sdk-python/src/sentryagent_idp/_request.py new file mode 100644 index 0000000..2fcb3f4 --- /dev/null +++ b/sdk-python/src/sentryagent_idp/_request.py @@ -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 diff --git a/sdk-python/src/sentryagent_idp/async_token_manager.py b/sdk-python/src/sentryagent_idp/async_token_manager.py new file mode 100644 index 0000000..0173531 --- /dev/null +++ b/sdk-python/src/sentryagent_idp/async_token_manager.py @@ -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) diff --git a/sdk-python/src/sentryagent_idp/client.py b/sdk-python/src/sentryagent_idp/client.py new file mode 100644 index 0000000..7f1390d --- /dev/null +++ b/sdk-python/src/sentryagent_idp/client.py @@ -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() diff --git a/sdk-python/src/sentryagent_idp/errors.py b/sdk-python/src/sentryagent_idp/errors.py new file mode 100644 index 0000000..8cee647 --- /dev/null +++ b/sdk-python/src/sentryagent_idp/errors.py @@ -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, + ) diff --git a/sdk-python/src/sentryagent_idp/services/__init__.py b/sdk-python/src/sentryagent_idp/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/sdk-python/src/sentryagent_idp/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/sdk-python/src/sentryagent_idp/services/__pycache__/__init__.cpython-312.pyc b/sdk-python/src/sentryagent_idp/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..3673052 Binary files /dev/null and b/sdk-python/src/sentryagent_idp/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/services/__pycache__/agents.cpython-312.pyc b/sdk-python/src/sentryagent_idp/services/__pycache__/agents.cpython-312.pyc new file mode 100644 index 0000000..e124b29 Binary files /dev/null and b/sdk-python/src/sentryagent_idp/services/__pycache__/agents.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/services/__pycache__/audit.cpython-312.pyc b/sdk-python/src/sentryagent_idp/services/__pycache__/audit.cpython-312.pyc new file mode 100644 index 0000000..d0213f5 Binary files /dev/null and b/sdk-python/src/sentryagent_idp/services/__pycache__/audit.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/services/__pycache__/credentials.cpython-312.pyc b/sdk-python/src/sentryagent_idp/services/__pycache__/credentials.cpython-312.pyc new file mode 100644 index 0000000..9b177b9 Binary files /dev/null and b/sdk-python/src/sentryagent_idp/services/__pycache__/credentials.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/services/__pycache__/token.cpython-312.pyc b/sdk-python/src/sentryagent_idp/services/__pycache__/token.cpython-312.pyc new file mode 100644 index 0000000..86f95f0 Binary files /dev/null and b/sdk-python/src/sentryagent_idp/services/__pycache__/token.cpython-312.pyc differ diff --git a/sdk-python/src/sentryagent_idp/services/agents.py b/sdk-python/src/sentryagent_idp/services/agents.py new file mode 100644 index 0000000..c2ccaf8 --- /dev/null +++ b/sdk-python/src/sentryagent_idp/services/agents.py @@ -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(), + ) diff --git a/sdk-python/src/sentryagent_idp/services/audit.py b/sdk-python/src/sentryagent_idp/services/audit.py new file mode 100644 index 0000000..f3dd753 --- /dev/null +++ b/sdk-python/src/sentryagent_idp/services/audit.py @@ -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) diff --git a/sdk-python/src/sentryagent_idp/services/credentials.py b/sdk-python/src/sentryagent_idp/services/credentials.py new file mode 100644 index 0000000..2fde78c --- /dev/null +++ b/sdk-python/src/sentryagent_idp/services/credentials.py @@ -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) diff --git a/sdk-python/src/sentryagent_idp/services/token.py b/sdk-python/src/sentryagent_idp/services/token.py new file mode 100644 index 0000000..9469dca --- /dev/null +++ b/sdk-python/src/sentryagent_idp/services/token.py @@ -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) diff --git a/sdk-python/src/sentryagent_idp/token_manager.py b/sdk-python/src/sentryagent_idp/token_manager.py new file mode 100644 index 0000000..3bc7720 --- /dev/null +++ b/sdk-python/src/sentryagent_idp/token_manager.py @@ -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) diff --git a/sdk-python/src/sentryagent_idp/types.py b/sdk-python/src/sentryagent_idp/types.py new file mode 100644 index 0000000..116f055 --- /dev/null +++ b/sdk-python/src/sentryagent_idp/types.py @@ -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"], + ) diff --git a/sdk-python/tests/__init__.py b/sdk-python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sdk-python/tests/__pycache__/__init__.cpython-312.pyc b/sdk-python/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..58555cb Binary files /dev/null and b/sdk-python/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/sdk-python/tests/__pycache__/test_errors.cpython-312-pytest-9.0.2.pyc b/sdk-python/tests/__pycache__/test_errors.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..ee53a00 Binary files /dev/null and b/sdk-python/tests/__pycache__/test_errors.cpython-312-pytest-9.0.2.pyc differ diff --git a/sdk-python/tests/__pycache__/test_services.cpython-312-pytest-9.0.2.pyc b/sdk-python/tests/__pycache__/test_services.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..4772579 Binary files /dev/null and b/sdk-python/tests/__pycache__/test_services.cpython-312-pytest-9.0.2.pyc differ diff --git a/sdk-python/tests/__pycache__/test_token_manager.cpython-312-pytest-9.0.2.pyc b/sdk-python/tests/__pycache__/test_token_manager.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..b815660 Binary files /dev/null and b/sdk-python/tests/__pycache__/test_token_manager.cpython-312-pytest-9.0.2.pyc differ diff --git a/sdk-python/tests/__pycache__/test_types.cpython-312-pytest-9.0.2.pyc b/sdk-python/tests/__pycache__/test_types.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..4844d15 Binary files /dev/null and b/sdk-python/tests/__pycache__/test_types.cpython-312-pytest-9.0.2.pyc differ diff --git a/sdk-python/tests/test_errors.py b/sdk-python/tests/test_errors.py new file mode 100644 index 0000000..944b766 --- /dev/null +++ b/sdk-python/tests/test_errors.py @@ -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) diff --git a/sdk-python/tests/test_services.py b/sdk-python/tests/test_services.py new file mode 100644 index 0000000..7200dc1 --- /dev/null +++ b/sdk-python/tests/test_services.py @@ -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 diff --git a/sdk-python/tests/test_token_manager.py b/sdk-python/tests/test_token_manager.py new file mode 100644 index 0000000..1845506 --- /dev/null +++ b/sdk-python/tests/test_token_manager.py @@ -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" diff --git a/sdk-python/tests/test_types.py b/sdk-python/tests/test_types.py new file mode 100644 index 0000000..ce66ea7 --- /dev/null +++ b/sdk-python/tests/test_types.py @@ -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"