From c93562e685206a06855a3752d7b88dad8959c748 Mon Sep 17 00:00:00 2001 From: "SentryAgent.ai Developer" Date: Sat, 28 Mar 2026 15:11:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(phase-2):=20workstream=202=20=E2=80=94=20P?= =?UTF-8?q?ython=20SDK=20(sentryagent-idp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../changes/phase-2-production-ready/tasks.md | 26 +- sdk-python/.coverage | Bin 0 -> 53248 bytes sdk-python/README.md | 214 +++++++++++ sdk-python/pyproject.toml | 61 +++ sdk-python/src/sentryagent_idp/__init__.py | 82 ++++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1729 bytes .../__pycache__/_request.cpython-312.pyc | Bin 0 -> 4659 bytes .../async_token_manager.cpython-312.pyc | Bin 0 -> 5689 bytes .../__pycache__/client.cpython-312.pyc | Bin 0 -> 5147 bytes .../__pycache__/errors.cpython-312.pyc | Bin 0 -> 4025 bytes .../__pycache__/token_manager.cpython-312.pyc | Bin 0 -> 4877 bytes .../__pycache__/types.cpython-312.pyc | Bin 0 -> 10952 bytes sdk-python/src/sentryagent_idp/_request.py | 127 +++++++ .../sentryagent_idp/async_token_manager.py | 117 ++++++ sdk-python/src/sentryagent_idp/client.py | 128 +++++++ sdk-python/src/sentryagent_idp/errors.py | 108 ++++++ .../src/sentryagent_idp/services/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 194 bytes .../__pycache__/agents.cpython-312.pyc | Bin 0 -> 8198 bytes .../__pycache__/audit.cpython-312.pyc | Bin 0 -> 5224 bytes .../__pycache__/credentials.cpython-312.pyc | Bin 0 -> 8076 bytes .../__pycache__/token.cpython-312.pyc | Bin 0 -> 7140 bytes .../src/sentryagent_idp/services/agents.py | 202 ++++++++++ .../src/sentryagent_idp/services/audit.py | 144 ++++++++ .../sentryagent_idp/services/credentials.py | 209 +++++++++++ .../src/sentryagent_idp/services/token.py | 154 ++++++++ .../src/sentryagent_idp/token_manager.py | 116 ++++++ sdk-python/src/sentryagent_idp/types.py | 323 ++++++++++++++++ sdk-python/tests/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 171 bytes .../test_errors.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 13639 bytes ...test_services.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 41572 bytes ...token_manager.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 15192 bytes .../test_types.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 18487 bytes sdk-python/tests/test_errors.py | 52 +++ sdk-python/tests/test_services.py | 349 ++++++++++++++++++ sdk-python/tests/test_token_manager.py | 112 ++++++ sdk-python/tests/test_types.py | 133 +++++++ 38 files changed, 2645 insertions(+), 13 deletions(-) create mode 100644 sdk-python/.coverage create mode 100644 sdk-python/README.md create mode 100644 sdk-python/pyproject.toml create mode 100644 sdk-python/src/sentryagent_idp/__init__.py create mode 100644 sdk-python/src/sentryagent_idp/__pycache__/__init__.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/__pycache__/_request.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/__pycache__/async_token_manager.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/__pycache__/client.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/__pycache__/errors.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/__pycache__/token_manager.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/__pycache__/types.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/_request.py create mode 100644 sdk-python/src/sentryagent_idp/async_token_manager.py create mode 100644 sdk-python/src/sentryagent_idp/client.py create mode 100644 sdk-python/src/sentryagent_idp/errors.py create mode 100644 sdk-python/src/sentryagent_idp/services/__init__.py create mode 100644 sdk-python/src/sentryagent_idp/services/__pycache__/__init__.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/services/__pycache__/agents.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/services/__pycache__/audit.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/services/__pycache__/credentials.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/services/__pycache__/token.cpython-312.pyc create mode 100644 sdk-python/src/sentryagent_idp/services/agents.py create mode 100644 sdk-python/src/sentryagent_idp/services/audit.py create mode 100644 sdk-python/src/sentryagent_idp/services/credentials.py create mode 100644 sdk-python/src/sentryagent_idp/services/token.py create mode 100644 sdk-python/src/sentryagent_idp/token_manager.py create mode 100644 sdk-python/src/sentryagent_idp/types.py create mode 100644 sdk-python/tests/__init__.py create mode 100644 sdk-python/tests/__pycache__/__init__.cpython-312.pyc create mode 100644 sdk-python/tests/__pycache__/test_errors.cpython-312-pytest-9.0.2.pyc create mode 100644 sdk-python/tests/__pycache__/test_services.cpython-312-pytest-9.0.2.pyc create mode 100644 sdk-python/tests/__pycache__/test_token_manager.cpython-312-pytest-9.0.2.pyc create mode 100644 sdk-python/tests/__pycache__/test_types.cpython-312-pytest-9.0.2.pyc create mode 100644 sdk-python/tests/test_errors.py create mode 100644 sdk-python/tests/test_services.py create mode 100644 sdk-python/tests/test_token_manager.py create mode 100644 sdk-python/tests/test_types.py 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 0000000000000000000000000000000000000000..14fabfffb3c1660524dc5fe7b9bb0f518d8d9eb6 GIT binary patch literal 53248 zcmeI4TWlOx8OP7;jQ2Y06O>w8$0|E7$Z_LKO{hW&rOrYKQ7WY+34t`}WOjG#oo08& znVEHBMU}m6rIe>iydWwbDtHWPo+?GCBo#$SP>?_Z!9ztXfgn;vQ`CS_Ok%z>7w_7! zt5#a8t@R)6?#!Iacln*~oU>cuUcN z5}(js>Nwu$FvCV?zMJRYVruf+j6a>yY4gqVewaW)Ol!&(CA6JU^&!nQCr#J1E2bA(vD2y!Wrv=h%cct3w@Vkzz$e44 z&?~pf8%k)qa&VFaRUEe}tS`HUU8$Mgux6}ySaHbC^M2^9>omrEN2^2)TxtAatzC0C zaKm1eW)*dCY>r2i_*ojL7)gc zdfD>5m4nQxpjk=YAaq3j#&IKh$?6~xy{KN-oXMceh}v4%I26sDeP%3%VYs5BX--s- zEd5xcO&CiyXyv+7UL9*e#CRrk5E={OVv|j~BD#tve5g1xrfW=@qfu@+EPUL7M4>aD zNf!1OHwvAV5YWe04zsQPWU4SQAU&4}!YFF3XqQ?QLUBv3GmOSr4b$+NZak2D&>$Jt zXpfR{ABl3vosm?suy0@^ccOCocyy3%rIb{muTN@)Jwl92;@z{#36aw70&6CM4{!do z;n%c#Z5PR8_+U@6@TIPNoI_q6Q|1+>p&_Ec&pC{Eq`3CHs+npVZM zForG0Y#O zP8attI%qTuyBf_`Vj2#m(%3ILr_d7)B2-Zuq)zEk7hNMW78>P>5i6EII!#Q3)P_+F zySr$V4vFV#<~`!V+30JAZ~9il)I^cMm>{su8J<>gP0Ebs(!ekxR$FUTDCQfK6OW;x zH$#K+2gQzPVtrhEidAx2e3fezrBq?pE~(XwdT=;6=^lL(Xc)1t(P?5;WUa|;2iaj7 zrRghDXK)GN9Sp^3s6mW~VY8P*baAQZ3sQDSEbt}1#OQ?$1V8`;KmY_l00ck)1V8`; zKmY_lVCxZ(rG%6c>;D8_VEkSBzy<;!00JNY0w4eaAOHd&00JNY0wD0IB%mhb2YCFq zkcYNO@<4y|AAmih}(UChFB#c~yFG#?B_(ExlpNk1CAx0emapri zW-3W)j&xOx;mz6=J!o{D?((OYbV<>7dPvcpt}60pr%ln?ClnIBx2vK}*L7SW+mK22 zfv(D4OXftP>*iPL7Ig+P|D;5+yWA1l!6UB!tGOEEKUQDP|AHUreKCJZ{co3QjBo+TEq-6<}<^U~65m)x6t`r7i9yDwdso4+vk_Vs19cysy6V(Pve zm48q!UH;W>c56ah0df4*MvkQ7!zJ0s+le;NN%TQJ+Tgu=6 zyFW?u$BXQ}N7%wUY~ii>tMC4C@u!!U68ER6#OKx0Z|ASxmDqc8S@wPD**i;*HKk&T zawgKH{OJjuUAfKXkFzU@`Caps3H|u>OP4Om$s`r%O-b8(Xune_^}n+G75>J}S$374 z($}X@;&7sLr$=S%hRo;^KD*5(?vp9`!DQsYDfZOm^uoC>_a#K(Olf49vA?{}FOVg# z|D*VyoTOwjI}(g7$ZR6G|DV6g_!9p+e~n+~ukwHLKl9)7Tl`J_I{)=2w25IB2!H?x zfB*=900@8p2!H?xfB*<=VFKc(0jWm~c5*J*<+8ypn+bMmI@qOC!7dg2mLMrT!C68H zc1bzdWfBVg3=mxZXWO 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 0000000000000000000000000000000000000000..2affff3b9ded3b7047009ea4fd8f68cf3b8292c0 GIT binary patch literal 1729 zcmd5+&2QsG6t|rwX`HwpN&2}3nFxsn(YS(3RZ*m<+e3w5qjnK!E|%AxZHD!D%*;5D zBWJEiTsU##FX6y{Fj7yPxFBE;mG;EEakfb*T5&;Q4nMzn^WN|M{O03l&vPw3zW?nf z(yLq6FEDvIoSE_bTWx%2B^I(0J8@E*I>@0FRIy=RNtb99RgJxr)@U8oja^NbX#+Kk zT}xfsL`~X4E$ShUuAmjVidN|wTBGY|owiY%c2I|QQI~F@4Z4Xo=@#0e+i08apdGr4 zcIgB3fbO9^+q$xn`VTgGZMxiVMBiwU?j_D7pAN6|XAlwJ80X2zA5G<$W&Y*E4+pM0 z;%q_^EPOG|;xT6#D}*0qi61RG;*@9>;Xh^Emt#EV8jj92$=O#C%~R~}Lr(wr*mZq9 zo^nQgVMIhAA|y%f6PhzFZwXEfaT#A0@lXD?xNpdS@JS?aSn%|uHp=tr~lZE`w{=ImPB7hM82{yzuNk$^c}Tw_eWJ{c>mw} zw(?$vopH{%cC;R|XE^&P%50 zu9=)oblX+S*pCVsGm4da%5kFIBudrV+@A4u=blfA9ABElqr8VWPuY~}w6koY+UNS- zF_Gg~USN^4OlaCi!$QZ$Oe@g_hEzynwTSOr)ESO;hW zbO5>l8vvUCTL9YtI~qRjzpmOB?zopO*JrD+)cXg$~UtQVgJV|_e=;z^OoBA9$0Mr3yJ!vs%)g=bDbGkGAA zX9u}?X9L0G#btN}gD@l+kzqK#b|+-5aN4J2%#S`19?(rt@{3_jK}KWBdT| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a125f2c28c800e9d2a2bd663c691c8c33a7a65e9 GIT binary patch literal 4659 zcmeHKU2GHC6~5!~{MeqclQ@KgkTAsI#}Hy*DNwfTHk)LZz&1ocrLMQEnRq6|jy>bO zGfr?ip$JwL+akegQDL>M7WIi$wi2!S(3e#OWYvfEfxt?!ru%@js#;zu(@;UPedxI} zo;ZZ4;-RY4s@L+FbI(2J{+v1Ie0P2)ia`Wz*I#~{IV~deCFyvLb{T5^Dlk`&f|5v~ z6km?Y`;tD2@N|yO`;-1WlVtL2lFf5T&XN0be3FN8e=d*=D2&49g85J~M4aW$vNr3v13CLR^KkFhAEAzxq^xf$rzQfsz}2n zNzUaYL&f8nv?`@@8C5fl?%<$qN(C94l0G7tqpDOW4(Bpysb^@QJNSUOAWqpKS<`e= zHZ!_r#Qiqiqm^vF7p9YkbE@s{%cM=4KU^T=a&GVed}|43ec(~yv@k(Upi}f4Xo89dR=7-ynHow+? ztVPvN4yVX)s_LTdLdh0LI(q_~ zfTc+VGW<~8&!Eaq6pCI!lBkKd}!_#y8%kP<&-oCp2M*T0^&evb)y+r-!)puJy=U=`r)>k;hG**Nl zv-gW=>s&N88;zBtE#tuw(Yf6RXLldG7d~`{ zKU5Ai-tjm7eLjH#-=v;AT8o6nxlre9sPnIgf_)IP_-r_SJ$5hLeWUjdzvrt84Fd~C z2YBO`!QKG-X&A`OhSuH)x*du3?x$~eH1+PKZ|`M*e+EF$5di%eRL(<)`X2yM2Rf^O zzYCqC6y|#X>_4Wj&_7@p~p!XAgx(ep!P}SC7wH4qJ20stv&H)51otr}6 z7ghpdC9nw8LYCy&vIamcWLGZ}LiZ`44uj4LKnw*x;#p?bcwFsi5xW&G2i_R~sKZYK z^&WKLGW<`$KN~|kkaJ2uc+l~}9>gGnngC#b1EPgO7bacseG1-%+WH>nz`M9)uPa<0 zEOFYqsEH1|i>JjJy#JgAh!4ut(-043|GyXHYcTzPDEIF7&jsa}WH2UShRHP*6BruA z^+4kkCYcmx9Pq0_n*)tB-aBP7GCK-cUf!NHbX%@Cqx`1%mYZ>^>J zIRC9SKjGK+bb#>;O+jadp+ISdbNB$;*UHUAsJ;earj8^0YL4i%NBaeOW=B&$M}NjK zz<(ytFhkroI9R>(IU$Xm&?CX^#-GDELFjNK5+_CP%ivA;7C-I;=4A7tjC_#430KxG ziHshH+pKe;$AAWqK{l64DSA4UvV*DAND;33YAS^RYaxWqQX!+AbQCc`Q%t`7F!}h! zYYEu_#Acmqt>NYrxz1q{yC0)@pvZ~s1^yd(T`A_&m+(s#&kBn|;R;7M9>G|E ka6uGmu7n6DAaR3ZR|z9FFdrc@i~4uX_lV4g_rnSP2}*`wWdHyG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..890eef82b31c512cce556053fe37bca8fecf62ca GIT binary patch literal 5689 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 Vh98pQy@(+Q>YYo^2-bX&{{r+D>CFHD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..aa7ee43aa7f5b3403c865d8b2e15586bb2ee0bd8 GIT binary patch literal 4877 zcmbtYU2Ggz6~41Gv)=!89RDSb?Mc!mS>oMwQi_7H0M|)rg5wlB0lF$3PiDs6vDY)( zduQXUyM|H~$%R&x0mt zPI+K#-+S&o_n!N6&iT%{{&PGYLGT^<=bwyX9HEbC!~aB=;I@AZ#5&SY4r!bgC~%8` zT!5oAU*K~*tA(5ZwNMZjgSjB%iG{YsP%h++3+KXGP-`nh7NfZ+^aZt0A+{LL#eojz z5_&LzzJ|2Ot4NF93b^au^7fHSGF}XLai5oKFY~EH>0KBSnJ$}Z9$ThWBGOfBK{qce zrZT5v>F+Q9RLU!+R?vxbWvpc9rL&n~$)-ajMSZbk5W_Ysb4XGZHE)=6LlO*@us(}* zlFvk#EFvjbhrYByNJ-ZurDR)+ifyP$p-`5V@*qmTSu`-PEG#Vmj z)~w4fXCn9Lc~YDcRZP>eL55|LNoa>P#a7gULI^ZMSBjLO6z*~E==j*2ZrT^MXD?uE zVHgo-`O8~aw$B5*d373>c$FG{NaLXvwXh~Y9n>P42z6UIn2I_vcB`IADj$GW zBNBX9^H^83G*M=CiL@9nJBs8VCBnvzQCkI@Wade2b(cN6nsUvWWF)Zj76bK+Stm@8 zo!33{XNGIol0kf%9idh@GBb7I>8T6T&&W?*d-~}MQ}XnM@hg+(r)OI2*xeJJBO{EW zr@5{WT`u9mh~!%yxt0u8~jQDTAtA%RNeSdkH`=)6<-=&1@F{(-9^tb-@v3(B?u) zbOfRoW}Q$&Zn!Bn?vb9NXj_`*SdedKl}w$6H3dUB-ZQ-g&8R(&dBQGz>*_9n5_KMPu64R%5I~WJ zI5xXAW{*f0XFb?$$vUK2#VC*_7)_=En5qU-4Wz>OI8=^c8;byK(i{hATmFjG8!jEdw7KH}XK_k0e&lzdlh9Ay{K{ZFKA4Q1#%@P9MnHq5JuS77xim*ykx`+1sCinZJO%^H+ftcsBqN zG*AIVdf)!|0KNMlqQ?JHSP6uY9rkBG3ZpbZ6AexazSnk3aL2Cj6@H!?!k({zeIoEX z_b{PJq$VcL(;&6ytI&D9dpe=%W8o{D-RE=XkN8xxu84kSFpZA4p#6NTImV|RL%456 zXxYy`NL&$oc@^Q6fEHR2zAG$oOUT2`@bc{jQayWR`l^&wibnRv88?TfnT~bcR^Sbz z09G#Tg1%PFV~D#4^q9H%?-FSiTZ_zk$DX~|I`e;{Q9&0pdQ`swtL1hV^VerB- zb$iLe3zAM>LrwHtj_*2$)8VBX&FqiBizPCL704{@a#6=8;8O8PC>)XHBFnLH%d}z9 z^i^OlA61IQg13J5W_oF9DGg4#n1&=*H&sj1wR;0FImJ5yO`M&!Jf!nFCQciLY^ww> zq$H;5#{IOQWAPvqPRPq7i6ayt{dL->+|2UAO;u-4Kio!##l3^d?4XV?Pb||3SPRYp zda<%aZ|JPYj1qWFmWWKZg{ikXVKzuJR67;)U>`P)zvykiMSX|YZ>o-P-O|d0IvWjK zUJSr#XfGey1t6`_<(t4Je}sYs008}gwSkRx>8>ZiF`t^ep&|8TW` zxYl)O{rR=$?}x?q$f{6}g~FqEdIr~5)>dlm2Z32TbaEp#vC(_E)_eFtSnN%#K2z^N z?Y(cVyteYA@2v{8WY3!~{qUtb9fMmPXQ~}%e)rh>p{;Wh)pHY@9hWy^m+vHxY$Z=r zlPBJ?Hex*kNGJ@qz9g-~x_Jxr+xI?!2 zaDvQ^iEG><^+3W*6%WxNQChGA?LOqg(5U;5g)d_ov}mLwlmNwX-2DL03IB>9?)b+j z`Zv`65jy%2>iZZa|3L`}PJZ~v2I_`75Zws`xb8ZFVw;C%oi=wO-{v?-5opQI`(JLZ BkJkVI literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ca4230e2ee3458dec752979b8afdf63b6865cf42 GIT binary patch literal 10952 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 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 0000000000000000000000000000000000000000..3673052321a81b06504242374b8b48b0fdedf8db GIT binary patch literal 194 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 VVp#wK%Iih$2{!y61_U+1e*x5Qb|(M; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d0213f515c31829fffb17a2105d56fc94bf056ef GIT binary patch literal 5224 zcmc&&U2NOd6~2^6{aLo;-z2iz-b6`iWw8}6Nq>YbotC7j(ZbG>1{f5$3@y?&6N*$X zDF^c0AzOnX8!~JKhIzm?ppO;Y4t*G~Jq#QAw1>T<@IwT?EZ7Egc^Q;BLkd6boJ&fU zdP{wUzEYx) z5J-TWA!_(KQ6uXCZ>2(#%c3BQwPgL(o=m(l2Qy;%imE&G4Qr9w8e7(j8Z8>SW;!zt^O2-Y|b~1c;QFA8ds}s6Pt+K`xN4HFSGIkfY zkP+M-#WXF)lV(IWm^Z6#kr8Z2l# zZZF?O^AX|Fij&(&dbF}FRSV?gx)0_%3ZK}{^#Q=wXT~ipmfzS2Si@vqE&CpTs zENoJ4xm976$b10FY5<;u=3|xAgqd2vjmpi9x-q%6k6O?bZOvG4BdyVBYtY6e;e=an zWtYIC*~)yybSl~9%d(=&3Le&$Rc$$I1IMad0Kr(!+Uk{wa@AR~%&g6d?ZYx3_Cnsy zDwqY6We7`I)=k}!<&`0BbI(DOP3bP!#^wN!York)seL!Zn|<|E`iA(!#3mkdrWnDS zfAztg+vX-PYs6_;LIo>b_7k)@8Sg6C>$21?@%VLL1lA3#23N&ZX*IMOR)fC?e<-f| z^WTX$;il&0_FXh}BkTV9=SfCVmSOo;FnojOGdBV?017lrBAZ^-oh3+UFD2=MZa9$q zlk^;iYD8@+U z9t0QV=&O8HI$!l<^umSdQ`>9sxjg+lW^znZxSTrG_D!JzfDOVzyM`~K<94N3)NGqV z-YqD)QDIu9bF~F#m1GsLBuA&u&(fD(I5I_TM_~>e%F!IXq|tHREE*MCU+&g*tZq#W zp!M;3WiUsM0*GnUtd!;<8^@<6FiX^~%^7-0cXHq$yJCR<0E#KevZC8s8{VL9-bb3#j9Dy4XHA+vJB{WuHRr0ut~AZKZACYD=JcCey;-HK zXP2kE)T@QvFl-bt%ME#sbHlz<+z8tI6j;{{ITpW*cq&)o$E^u&sH`xhWM?E6gXW*s9 zc0fTW;L!7JV4e_qgrAdNJ_dl+{r=^hz+=EB&;~X^B@W2b%JwswLltW4=Axld4|6!) zV2dAT^B+k8hLNz< z*O3C*o#7jYS?o(dYC%76n+Ib@K-w&dek5YCUJXiX(Ijpsf|SDRwTp2E)!zA;YDuHb{N|O+)975p}2T) z?l6EOn{z{6SK*2?mZ{akGkD#_a&#AywU%TPaNFu_wtp8-EWZ;nmE%h(Ad*%pVn>;`pc4=|zGD@a~Kf*4T~*>U7wY)R4d07}pfm`X{LW$0Ao zlBHG*%QvHxG9XgUZ*I(BE^xrPLH z(Ca_g2qPCE1LKV-axpTrx6y-KoWQ(ZNs@#79gP7Jdv!nEjrw*2>-Wrlqw8$>b!q$AsT ztgY&Mp9a=~-;*TL)v1cm zsY=aGwR`T7`lDkY#_SjPt#C<{|~fcKwLTUgbhWP`;Wp|XLdThRdd-fn9!MY2Hu$cnmZ2YI*xfSxo@OB=*hE7M;Q##OeOw?$xDtL;+N=jB z@qpA7(_4Yn2eiq}7!U}s?-_C7ZD9F3qL2=@X(LHt*Y2 dli3Y2`yezU2#4+wqz~o;Q^KKt5+K|U{{}Vf{*wRz literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9b177b93db8f7e16640e21cd9487bd9f93c1737d GIT binary patch literal 8076 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..58555cb269f624cadffd603d06b8afb0bcaf45d1 GIT binary patch literal 171 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 D=ep7n literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..477257966f5d39d22a2d1ab9e47c137c9ea278db GIT binary patch literal 41572 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 X`pZFT&M&~TZRD75!SB;XFy{XQlq&PV literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b815660f0ab05e4c90467e9f07d96ac46e4aba7e GIT binary patch literal 15192 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 D2}fwz literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4844d155a6865f879d87ee87e2f41c6b9d028e34 GIT binary patch literal 18487 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 TI 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"