Compare commits

...

5 Commits

Author SHA1 Message Date
SentryAgent.ai Developer
8cdab72fea feat: Phase 2 Workstream 4 — Java SDK (ai.sentryagent:idp-sdk)
Java 17 SDK in sdk-java/:
- AgentIdPClient composing AgentRegistryClient, CredentialClient,
  TokenClient, AuditClient — all 14 endpoints covered
- Both sync methods and CompletableFuture<T> async counterparts on each client
- Thread-safe TokenManager (synchronized) with 60s refresh buffer
- AgentIdPException (extends RuntimeException) with Code/HTTPStatus/Details
- Builder pattern for all request types; Jackson 2.17 for JSON
- Zero external HTTP dependencies — java.net.http.HttpClient (Java 11+)
- No-dep JDK HttpServer used for unit tests (no WireMock needed)
- mvn verify: 49/49 tests passed | JaCoCo coverage gate: >80% ✓

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:33:53 +00:00
SentryAgent.ai Developer
91c759f455 feat: Phase 2 Workstream 3 — Go SDK (github.com/sentryagent/idp-sdk-go)
Single-package agentidp SDK in sdk-go/:
- AgentIdPClient composing AgentRegistryClient, CredentialClient,
  TokenServiceClient, AuditClient — all 14 endpoints covered
- Goroutine-safe TokenManager (sync.Mutex) with 60s refresh buffer
- AgentIdPError implementing error interface with Code/HTTPStatus/Details
- Context-aware: all service methods take context.Context as first arg
- doRequest shared helper; token endpoints use form-encoded POST directly
- go vet: 0 warnings | staticcheck: 0 warnings
- go test ./...: 37/37 passed | coverage: 81.0% (>80% gate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:23:02 +00:00
SentryAgent.ai Developer
c93562e685 feat(phase-2): workstream 2 — Python SDK (sentryagent-idp)
Sync (requests) and async (httpx) clients with identical API surface
to the Node.js SDK.

Delivered:
- pyproject.toml — python>=3.9, hatchling build, mypy strict config
- types.py — all 14-endpoint request/response dataclasses
- errors.py — AgentIdPError with from_api_error, from_oauth2_error, network_error
- token_manager.py — thread-safe sync TokenManager, 60s refresh buffer
- async_token_manager.py — asyncio-safe AsyncTokenManager (httpx)
- _request.py — shared sync/async request helper (DRY)
- services/agents.py — AgentRegistryClient + AsyncAgentRegistryClient (5 methods each)
- services/credentials.py — CredentialClient + AsyncCredentialClient (4 methods each)
- services/token.py — TokenClient + AsyncTokenClient (introspect + revoke)
- services/audit.py — AuditClient + AsyncAuditClient (query + get)
- client.py — AgentIdPClient + AsyncAgentIdPClient
- __init__.py — barrel exports
- README.md — installation, quick start, full API reference

QA gates:
- mypy --strict: 0 errors (12 source files)
- pytest: 57/57 passed
- Coverage: 90.83% (required >= 80%)
- All 14 endpoints covered (sync + async)
- AgentIdPError raised on all failure paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:11:27 +00:00
SentryAgent.ai Developer
90a4addb21 feat(phase-2): workstream 1 — HashiCorp Vault credential storage
Vault is optional — server falls back to bcrypt (Phase 1 behaviour)
when VAULT_ADDR is not set. Full coexistence: existing bcrypt credentials
continue to work until rotated.

Changes:
- src/vault/VaultClient.ts — wraps node-vault KV v2; writeSecret,
  readSecret, verifySecret (constant-time), deleteSecret
- src/db/migrations/005_add_vault_path.sql — vault_path column on credentials
- CredentialRepository — createWithVaultPath, updateVaultPath methods
- CredentialService — routes generate/rotate through Vault when configured;
  bcrypt path unchanged
- OAuth2Service — verifies via Vault when vaultPath set, bcrypt otherwise
- src/app.ts — createVaultClientFromEnv() wired into service layer
- ICredentialRow — vaultPath field added
- docs/devops/environment-variables.md — VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
- docs/devops/vault-setup.md — dev quickstart, production config, migration guide
- tests: 33/33 unit tests pass (VaultClient + CredentialService Vault path)
- node-vault + @types/node-vault installed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:02:33 +00:00
SentryAgent.ai Developer
7593bfe1c1 chore: Phase 2 OpenSpec scoping — proposal, design, specs, tasks
8 workstreams scoped per OpenSpec standards:
1. HashiCorp Vault integration (secret management)
2. Python SDK (sentryagent-idp)
3. Go SDK (idp-sdk-go)
4. Java SDK (ai.sentryagent:idp-sdk)
5. OPA policy engine (dynamic ABAC, hot-reload Rego)
6. Web Dashboard UI (React 18 + TypeScript)
7. Prometheus + Grafana monitoring (7 metrics, pre-built dashboard)
8. Multi-region Terraform deployment (AWS + GCP)

Status: proposed — awaiting CEO dependency approvals (A0.1–A0.5)
before any implementation begins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:53:09 +00:00
112 changed files with 8697 additions and 23 deletions

View File

@@ -76,6 +76,47 @@ Every authenticated request verifies the JWT signature using this key. If this k
These variables have defaults and do not need to be set for local development. These variables have defaults and do not need to be set for local development.
### `VAULT_ADDR`
HashiCorp Vault server address. **Required to enable Vault integration (Phase 2).**
| | |
|-|-|
| **Required** | No (Vault is optional) |
| **Format** | URL string |
| **Example** | `VAULT_ADDR=http://127.0.0.1:8200` |
When set alongside `VAULT_TOKEN`, new credentials are stored in Vault KV v2 instead of as bcrypt hashes in PostgreSQL. Existing bcrypt credentials continue to work unchanged until rotated. See [Vault setup guide](vault-setup.md).
---
### `VAULT_TOKEN`
Vault authentication token. Required when `VAULT_ADDR` is set.
| | |
|-|-|
| **Required** | Only when `VAULT_ADDR` is set |
| **Format** | String |
| **Example** | `VAULT_TOKEN=hvs.XXXXXXXXXXXXXXXXXXXXXX` |
Use a Vault service token scoped to `read`, `write`, and `delete` on `{VAULT_MOUNT}/data/agentidp/*` and `{VAULT_MOUNT}/metadata/agentidp/*`.
---
### `VAULT_MOUNT`
KV v2 secrets engine mount path.
| | |
|-|-|
| **Required** | No |
| **Default** | `secret` |
| **Format** | String (no leading or trailing slash) |
| **Example** | `VAULT_MOUNT=agentidp` |
---
### `PORT` ### `PORT`
HTTP port the Express server listens on. HTTP port the Express server listens on.
@@ -141,6 +182,11 @@ MIIEowIBAAKCAQEA...
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkq... MIIBIjANBgkq...
-----END PUBLIC KEY-----" -----END PUBLIC KEY-----"
# HashiCorp Vault (Phase 2 — optional, omit to use bcrypt mode)
# VAULT_ADDR=http://127.0.0.1:8200
# VAULT_TOKEN=hvs.XXXXXXXXXXXXXXXXXXXXXX
# VAULT_MOUNT=secret
``` ```
> Do not commit `.env` to version control. Add it to `.gitignore`. > Do not commit `.env` to version control. Add it to `.gitignore`.

197
docs/devops/vault-setup.md Normal file
View File

@@ -0,0 +1,197 @@
# HashiCorp Vault Setup
Phase 2 of AgentIdP optionally stores credential secrets in [HashiCorp Vault](https://www.vaultproject.io/) KV v2 instead of bcrypt hashes in PostgreSQL. This guide covers:
- Dev mode quickstart
- Production Vault configuration
- Migration from bcrypt to Vault
Vault is **entirely optional**. If `VAULT_ADDR` is not set, AgentIdP operates in bcrypt mode (identical to Phase 1 behaviour).
---
## How Vault integration works
When enabled:
1. `POST /api/v1/agents/{agentId}/credentials` — the plain-text secret is written to Vault at `{mount}/data/agentidp/agents/{agentId}/credentials/{credentialId}`. Only the Vault path is stored in PostgreSQL (`credentials.vault_path`). No bcrypt hash is written.
2. `POST /api/v1/token` — the submitted `client_secret` is compared against the value read from Vault (constant-time comparison). No bcrypt is involved.
3. `POST /api/v1/agents/{agentId}/credentials/{credentialId}/rotate` — a new Vault version is written (KV v2 versioning). The path is unchanged; the old version is retained in Vault history.
4. `DELETE /api/v1/agents/{agentId}/credentials/{credentialId}` — all versions of the secret are permanently deleted from Vault.
**Coexistence**: Credentials created before Vault was enabled keep their bcrypt hash and continue to work. New credentials use Vault. Both paths coexist until all pre-Vault credentials are rotated.
---
## Dev mode quickstart
The fastest way to get Vault running locally:
```bash
# Pull and start Vault in dev mode (in-memory, auto-unsealed)
docker run --rm -d \
--name vault-dev \
-p 8200:8200 \
-e VAULT_DEV_ROOT_TOKEN_ID=dev-root-token \
hashicorp/vault:1.15 server -dev
# Verify it is running
curl http://127.0.0.1:8200/v1/sys/health | jq .
```
Add to your `.env`:
```
VAULT_ADDR=http://127.0.0.1:8200
VAULT_TOKEN=dev-root-token
VAULT_MOUNT=secret
```
The KV v2 secrets engine is automatically enabled at `secret/` in dev mode. No further configuration is needed.
> **Warning**: Dev mode stores everything in memory. Data is lost when the container stops. Do not use dev mode in production.
---
## Production Vault configuration
### 1. Enable KV v2 secrets engine
```bash
vault secrets enable -path=secret kv-v2
```
Or use a custom mount path:
```bash
vault secrets enable -path=agentidp kv-v2
# Set VAULT_MOUNT=agentidp in your .env
```
### 2. Create a policy for AgentIdP
```hcl
# agentidp-policy.hcl
path "secret/data/agentidp/*" {
capabilities = ["create", "read", "update", "delete"]
}
path "secret/metadata/agentidp/*" {
capabilities = ["delete"]
}
```
Apply the policy:
```bash
vault policy write agentidp agentidp-policy.hcl
```
### 3. Create a service token
```bash
vault token create \
-policy=agentidp \
-ttl=8760h \
-renewable=true \
-display-name="agentidp-service"
```
Copy the `token` field from the output and set it as `VAULT_TOKEN` in your environment.
### 4. Token renewal
Service tokens expire unless renewed. Set up a scheduled renewal before the TTL expires:
```bash
# Renew with a new 720-hour (30-day) lease
vault token renew -increment=720h <token>
```
In Kubernetes, use Vault Agent Injector or the Vault Secrets Operator to handle renewal automatically.
---
## Running migration 005
After configuring Vault, run the migration to add the `vault_path` column:
```bash
npm run db:migrate
```
Verify the migration:
```sql
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'credentials'
ORDER BY ordinal_position;
```
You should see a `vault_path` column with `data_type = text` and `is_nullable = YES`.
---
## Migrating existing credentials to Vault
Existing credentials (with `vault_path IS NULL`) continue to work via bcrypt until they are rotated. To migrate a credential:
```bash
# Rotate the credential — this writes the new secret to Vault
curl -s -X POST http://localhost:3000/api/v1/agents/$AGENT_ID/credentials/$CRED_ID/rotate \
-H "Authorization: Bearer $TOKEN" | jq .
```
The response includes the new `clientSecret` (store it immediately). After rotation, `vault_path` is set and the bcrypt hash is cleared.
To migrate all credentials for an agent in bulk, rotate them one by one using the API.
---
## Verifying Vault secrets
After generating a credential with Vault enabled, verify the secret was written:
```bash
vault kv get secret/agentidp/agents/$AGENT_ID/credentials/$CRED_ID
```
Expected output:
```
====== Secret Path ======
secret/data/agentidp/agents/<agentId>/credentials/<credentialId>
======= Metadata =======
Key Value
--- -----
created_time 2026-03-28T...
version 1
====== Data ======
Key Value
--- -----
clientSecret <the secret>
```
---
## Troubleshooting
### `VAULT_WRITE_ERROR` on credential generation
- Verify Vault is running: `curl $VAULT_ADDR/v1/sys/health`
- Verify the token has write access: `vault token capabilities $VAULT_TOKEN secret/data/agentidp/test`
- Check Vault audit logs: `vault audit list`
### `VAULT_READ_ERROR` on token issuance
- Verify the `vault_path` stored in PostgreSQL matches the actual Vault path
- Check the token has read access to `secret/data/agentidp/*`
### Vault is down — what happens?
If Vault is unreachable, credential generation and token issuance for Vault-backed credentials will fail with a `500` error. Credentials created before Vault was enabled (bcrypt mode) continue to work.
For high availability, run Vault in HA mode with an integrated Raft storage backend. See [Vault HA documentation](https://developer.hashicorp.com/vault/docs/concepts/ha).

View File

@@ -0,0 +1,3 @@
change: phase-2-production-ready
status: proposed
date: 2026-03-28

View File

@@ -0,0 +1,218 @@
# Phase 2: Production-Ready — Technical Design
**Date**: 2026-03-28
**Author**: Virtual Architect
**Status**: Draft — pending CEO approval of proposal
---
## 1. HashiCorp Vault Integration
### Architecture
```
AgentIdP Server
└── CredentialService
└── VaultClient (new)
└── HashiCorp Vault (sidecar or external)
└── KV Secrets Engine v2
```
### Design Decisions
**ADR-001: Vault over AWS KMS/GCP Secret Manager**
Vault is cloud-agnostic, open-source, and already standard in enterprise environments. Using Vault keeps Phase 2 cloud-provider independent.
**ADR-002: KV Secrets Engine v2**
KV v2 provides versioned secrets and metadata. When a credential is rotated, the old version is retained in Vault history, enabling audit-grade secret lifecycle tracking.
**ADR-003: AgentIdP stores Vault path, not secret**
`credentials.vault_path` stores the Vault KV path (e.g. `secret/agentidp/agents/{agentId}/credentials/{credentialId}`). The secret itself is never written to PostgreSQL.
### New environment variables
| Variable | Description |
|----------|-------------|
| `VAULT_ADDR` | Vault server address |
| `VAULT_TOKEN` | Vault root/service token |
| `VAULT_MOUNT` | KV mount path (default: `secret`) |
### Migration
Add `vault_path` column to `credentials` table (`005_add_vault_path.sql`). Existing credentials retain bcrypt hashes; new credentials use Vault. Both code paths coexist until all credentials are rotated (migration guide provided).
---
## 2. Multi-Language SDKs
### Shared contract (all SDKs implement identically)
```
AgentIdPClient(baseUrl, clientId, clientSecret, scopes?)
.agents → AgentRegistryClient (5 methods)
.credentials → CredentialClient (4 methods)
.tokens → TokenClient (2 methods)
.audit → AuditClient (2 methods)
.clearTokenCache()
TokenManager — auto-refresh 60s before expiry
AgentIdPError — code, message, httpStatus, details
```
### Python SDK (`sentryagent-idp`)
- Python 3.9+ (httpx for async, requests for sync)
- Both sync and async client variants
- PyPI package: `sentryagent-idp`
- Type hints throughout (`mypy --strict` clean)
### Go SDK (`github.com/sentryagent/idp-sdk-go`)
- Go 1.21+, standard library `net/http`
- Context-aware methods (`context.Context` first arg)
- Idiomatic Go error handling (`error` return, no panic)
- Go module: `github.com/sentryagent/idp-sdk-go`
### Java SDK (`ai.sentryagent:idp-sdk`)
- Java 17+, Apache HttpClient 5
- Synchronous and CompletableFuture async variants
- Maven Central: `ai.sentryagent:idp-sdk`
- Fully typed with generics
---
## 3. OPA Policy Engine
### Architecture
```
HTTP Request
→ Auth Middleware (JWT verify) — unchanged
→ OPA Middleware (new) — evaluates policy
→ OPA Wasm (embedded, no network call)
→ Rego policy files (hot-reloadable)
→ Controller
```
### Design Decisions
**ADR-004: OPA Wasm over OPA sidecar**
Embedding OPA as Wasm in the Node.js process eliminates a network hop and removes a runtime dependency. Policy files are loaded from `policies/` directory at startup and reloaded on SIGHUP.
**ADR-005: Policy replaces, does not wrap, scope check**
The existing static scope check in `auth.ts` is replaced by an OPA policy evaluation. This keeps the policy as the single source of truth for access control.
### Policy structure (`policies/`)
```
policies/
authz.rego — main policy: allow/deny
data/
scopes.json — scope → permission mapping
```
---
## 4. Web Dashboard UI
### Architecture
```
dashboard/ (new — separate from sdk/)
src/
components/ — reusable UI components
pages/ — Agents, Credentials, Audit, Health
hooks/ — useAgents, useCredentials, useAudit
lib/
client.ts — wraps @sentryagent/idp-sdk
auth.ts — credential entry and storage
```
### Tech Stack
- React 18 + TypeScript strict
- Vite 5 (build tool)
- TanStack Query v5 (server state)
- shadcn/ui components (Radix UI + Tailwind CSS)
### Pages
| Page | Scope Required | Features |
|------|---------------|----------|
| Agents | `agents:read` | List, search, view detail, suspend/reactivate |
| Credentials | `agents:read` | List credentials per agent, rotate, revoke |
| Audit Log | `audit:read` | Filter by agent/action/outcome/date, paginate |
| Health | None | Server uptime, Redis/PostgreSQL connectivity |
### Authentication
The dashboard accepts `clientId` + `clientSecret` via a login form. The `@sentryagent/idp-sdk` `TokenManager` handles token acquisition and caching in `sessionStorage`. No backend session — all state is client-side.
---
## 5. Prometheus + Grafana Monitoring
### Metrics exposed at `GET /metrics`
| Metric | Type | Description |
|--------|------|-------------|
| `agentidp_tokens_issued_total` | Counter | Tokens issued, labelled by outcome |
| `agentidp_agents_registered_total` | Counter | Agent registrations |
| `agentidp_http_requests_total` | Counter | All requests, labelled by method/path/status |
| `agentidp_http_request_duration_seconds` | Histogram | Request latency |
| `agentidp_rate_limit_rejections_total` | Counter | 429 responses |
| `agentidp_db_query_duration_seconds` | Histogram | PostgreSQL query latency |
| `agentidp_redis_command_duration_seconds` | Histogram | Redis command latency |
### Grafana dashboard
Pre-built JSON dashboard shipped in `monitoring/grafana/dashboards/agentidp.json`. Auto-provisioned via `monitoring/grafana/provisioning/`.
### Docker Compose extension
Add `prometheus` and `grafana` services to a `docker-compose.monitoring.yml` overlay — keeps the base `docker-compose.yml` clean for developers who don't need monitoring.
---
## 6. Multi-Region Deployment (Terraform)
### Structure
```
terraform/
modules/
agentidp/ — reusable module: compute + networking
rds/ — managed PostgreSQL
redis/ — managed Redis
lb/ — load balancer + TLS
environments/
aws/ — AWS-specific config (ECS + RDS + ElastiCache)
gcp/ — GCP-specific config (Cloud Run + Cloud SQL + Memorystore)
```
### Design Decisions
**ADR-006: Two provider targets (AWS + GCP) in Phase 2**
AWS and GCP cover the majority of developer deployments. Azure module is Phase 3. Each environment is a thin wrapper over the shared `agentidp` module.
**ADR-007: Terraform over Pulumi/CDK**
Terraform is the most widely-used IaC tool, familiar to most DevOps teams. The HCL syntax is simpler for documentation purposes.
---
## Component Interaction Map (Phase 2)
```
┌────────────────────┐
│ Web Dashboard │
│ (React + Vite) │
└────────┬───────────┘
│ HTTPS
┌────────────────▼────────────────┐
│ AgentIdP Server │
│ Auth MW → OPA MW → Controllers │
│ /metrics (prom-client) │
└──┬──────────┬──────────┬────────┘
│ │ │
┌─────▼──┐ ┌────▼───┐ ┌──▼───────┐
│Postgres│ │ Redis │ │ Vault │
└────────┘ └────────┘ └──────────┘
┌────────▼────────┐
│ Prometheus │
└────────┬────────┘
┌────────▼────────┐
│ Grafana │
└─────────────────┘
```

View File

@@ -0,0 +1,96 @@
# Phase 2: Production-Ready — Change Proposal
**Date**: 2026-03-28
**Author**: Virtual CTO
**Status**: Proposed — awaiting CEO approval
---
## Summary
Phase 1 delivered a complete, working AgentIdP MVP. Phase 2 makes it production-ready: hardened secrets management, multi-language SDKs, a policy engine, a web dashboard, observability, and multi-region deployment.
---
## Problem Statement
Phase 1 is functional but has the following production gaps:
| Gap | Risk |
|-----|------|
| Credentials stored as bcrypt hashes in PostgreSQL | No HSM/KMS — acceptable for MVP, not for enterprise |
| Only Node.js SDK | Developers in Python/Go/Java cannot use the SDK |
| No policy engine | Scope enforcement is static — no dynamic ABAC/RBAC |
| No web UI | Operators must use `curl` to manage agents |
| No observability | No metrics, no dashboards, no alerting |
| Single-region deployment | No HA, no geo-redundancy |
---
## Proposed Changes
### 1. HashiCorp Vault Integration
Replace raw bcrypt credential storage with Vault-backed secret management. Vault handles secret generation, versioning, and revocation. AgentIdP stores only Vault secret paths, not the secrets themselves.
### 2. Multi-Language SDKs
Add Python, Go, and Java SDKs with identical API surface to the existing Node.js SDK: `AgentIdPClient`, `TokenManager`, service clients for all 14 endpoints, typed error hierarchy.
### 3. Advanced Policy Engine (OPA)
Integrate Open Policy Agent (OPA) as a sidecar for dynamic scope and attribute-based access control. Policies are hot-reloadable Rego files — no server restart required.
### 4. Web Dashboard UI
A React + TypeScript dashboard for operators: agent list and management, credential overview, audit log viewer, system health panel. Read-only by default; write operations require `agents:write` scope.
### 5. Prometheus + Grafana Monitoring
Instrument all services with Prometheus metrics (`/metrics` endpoint). Ship a pre-built Grafana dashboard for: token issuance rate, agent registration rate, error rates, Redis latency, PostgreSQL query latency.
### 6. Multi-Region Deployment
Terraform modules for AWS/GCP deployment with: managed PostgreSQL (RDS/Cloud SQL), managed Redis (ElastiCache/Memorystore), container orchestration (ECS/Cloud Run), load balancer, and a deployment guide.
---
## Out of Scope for Phase 2
- AGNTCY federation (Phase 3)
- W3C DID support (Phase 3)
- SOC 2 certification (Phase 3)
- Rust/C++ SDKs (Phase 3)
---
## Dependencies
| New Dependency | Purpose | CEO Approval Required |
|---------------|---------|----------------------|
| `@openpolicyagent/opa-wasm` | OPA policy evaluation | Yes |
| `node-vault` | HashiCorp Vault client | Yes |
| React 18 + Vite | Web dashboard | Yes |
| `prom-client` | Prometheus metrics | Yes |
| Terraform | Infrastructure as code | Yes |
---
## Delivery Sequence (per OpenSpec spec-first workflow)
```
1. Vault integration (highest security impact)
2. Python SDK (highest developer demand)
3. Go SDK
4. Java SDK
5. OPA policy engine
6. Web dashboard UI
7. Prometheus + Grafana monitoring
8. Multi-region deployment (Terraform)
```
---
## Success Criteria
- All new dependencies CEO-approved before implementation begins
- All new API endpoints have OpenAPI 3.0 specs before implementation
- TypeScript strict mode + zero `any` maintained throughout
- >80% test coverage on all new services
- All SDKs pass the same QA gate: 14-endpoint coverage, typed errors, zero `any`
- Web dashboard passes OWASP Top 10 security review
- Monitoring stack ships with pre-built dashboards — zero manual setup required

View File

@@ -0,0 +1,44 @@
# Spec: Multi-Region Deployment (Terraform)
**Status**: Pending CEO approval
**Workstream**: 8 of 8
## Scope
- `terraform/` directory at project root
- Shared `agentidp` module (compute, networking, secrets)
- `environments/aws/` — ECS Fargate + RDS PostgreSQL + ElastiCache Redis
- `environments/gcp/` — Cloud Run + Cloud SQL + Memorystore Redis
- Deployment guide: `docs/devops/deployment.md`
## Module structure
```
terraform/
modules/
agentidp/
main.tf — compute (ECS task or Cloud Run service)
networking.tf — VPC, subnets, security groups
variables.tf — all configurable inputs
outputs.tf — service URL, DB endpoint, Redis endpoint
rds/ — managed PostgreSQL
redis/ — managed Redis
lb/ — ALB (AWS) or Cloud LB (GCP), TLS cert
environments/
aws/
main.tf — calls modules, sets AWS-specific vars
variables.tf
terraform.tfvars.example
gcp/
main.tf
variables.tf
terraform.tfvars.example
```
## Acceptance Criteria
- [ ] `terraform validate` passes for both aws and gcp environments
- [ ] `terraform plan` produces no errors against a live AWS/GCP account (test in dev env)
- [ ] JWT_PRIVATE_KEY and JWT_PUBLIC_KEY injected as environment secrets (not hardcoded)
- [ ] TLS termination at load balancer — HTTPS only in production modules
- [ ] PostgreSQL and Redis not publicly accessible — VPC-internal only
- [ ] `docs/devops/deployment.md` — end-to-end deployment walkthrough for AWS and GCP
- [ ] `terraform.tfvars.example` provided for both environments — no secrets in version control

View File

@@ -0,0 +1,23 @@
# Spec: Go SDK (`github.com/sentryagent/idp-sdk-go`)
**Status**: Pending CEO approval
**Workstream**: 3 of 8
## Scope
- `sdk-go/` directory at project root
- Context-aware `AgentIdPClient` using standard library `net/http`
- `TokenManager` with mutex-guarded cache and 60s auto-refresh
- Service clients: `AgentRegistryClient`, `CredentialClient`, `TokenClient`, `AuditClient`
- Idiomatic Go error type `AgentIdPError` implementing `error` interface
- `go.mod` module: `github.com/sentryagent/idp-sdk-go`
- `sdk-go/README.md`
## Acceptance Criteria
- [ ] All 14 endpoints covered
- [ ] All methods take `context.Context` as first argument
- [ ] No panics — all errors returned as `error`
- [ ] `AgentIdPError` implements `error` and exposes `.Code`, `.HTTPStatus`, `.Details`
- [ ] `TokenManager` is goroutine-safe (`sync.Mutex` on cache)
- [ ] `go vet` and `staticcheck` pass with zero warnings
- [ ] `go test ./...` with >80% coverage
- [ ] README matches Node.js SDK structure

View File

@@ -0,0 +1,23 @@
# Spec: Java SDK (`ai.sentryagent:idp-sdk`)
**Status**: Pending CEO approval
**Workstream**: 4 of 8
## Scope
- `sdk-java/` directory at project root
- `AgentIdPClient` with sync and `CompletableFuture` async variants
- `TokenManager` with thread-safe cache and 60s auto-refresh
- Service clients: `AgentRegistryClient`, `CredentialClient`, `TokenClient`, `AuditClient`
- `AgentIdPException` extending `RuntimeException` with `code`, `httpStatus`, `details`
- `pom.xml`: groupId=`ai.sentryagent`, artifactId=`idp-sdk`, Java 17+
- `sdk-java/README.md`
## Acceptance Criteria
- [ ] All 14 endpoints covered
- [ ] Sync methods return typed POJOs; async methods return `CompletableFuture<T>`
- [ ] `AgentIdPException` thrown (not raw IOException) on all failure paths
- [ ] `TokenManager` is thread-safe (`synchronized` on cache)
- [ ] Apache HttpClient 5 for HTTP transport
- [ ] Jackson for JSON serialization
- [ ] `mvn verify` passes with >80% coverage (JUnit 5)
- [ ] README matches Node.js SDK structure

View File

@@ -0,0 +1,32 @@
# Spec: Prometheus + Grafana Monitoring
**Status**: Pending CEO approval
**Workstream**: 7 of 8
## Scope
- `prom-client` integration — expose `GET /metrics`
- 7 metrics (counters + histograms) across all services
- `monitoring/` directory: Prometheus config + Grafana provisioning
- `docker-compose.monitoring.yml` overlay (adds prometheus + grafana services)
- Pre-built Grafana dashboard JSON (`monitoring/grafana/dashboards/agentidp.json`)
## Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `agentidp_tokens_issued_total` | Counter | `outcome` (success/failure) |
| `agentidp_agents_registered_total` | Counter | `outcome` |
| `agentidp_http_requests_total` | Counter | `method`, `path`, `status_code` |
| `agentidp_http_request_duration_seconds` | Histogram | `method`, `path` |
| `agentidp_rate_limit_rejections_total` | Counter | — |
| `agentidp_db_query_duration_seconds` | Histogram | `operation` |
| `agentidp_redis_command_duration_seconds` | Histogram | `command` |
## Acceptance Criteria
- [ ] `GET /metrics` returns Prometheus text format
- [ ] `/metrics` endpoint does NOT require Bearer auth (Prometheus scrapes it)
- [ ] All 7 metrics present and updating under load
- [ ] Grafana dashboard auto-provisions on `docker compose -f docker-compose.monitoring.yml up`
- [ ] Grafana runs on port 3001 (no conflict with AgentIdP on 3000)
- [ ] `docs/devops/operations.md` updated with monitoring section
- [ ] `prom-client` added as new dependency — CEO approval gate

View File

@@ -0,0 +1,37 @@
# Spec: OPA Policy Engine Integration
**Status**: Pending CEO approval
**Workstream**: 5 of 8
## Scope
- New `OpaMiddleware` replacing static scope check in `auth.ts`
- `@openpolicyagent/opa-wasm` integration (embedded Wasm, no sidecar)
- `policies/authz.rego` — main allow/deny policy
- `policies/data/scopes.json` — scope to permission mapping
- SIGHUP handler to hot-reload policies without restart
- New env var: `POLICY_DIR` (default: `./policies`)
## Policy interface
```
input = {
"method": "GET",
"path": "/api/v1/agents",
"scopes": ["agents:read"],
"agentId": "uuid"
}
output = {
"allow": true | false,
"reason": "string" // populated when allow=false
}
```
## Acceptance Criteria
- [ ] All existing scope checks replaced by OPA evaluation
- [ ] Policy files hot-reloadable on SIGHUP (no restart required)
- [ ] OPA Wasm loaded at startup — fail-fast if `POLICY_DIR` invalid
- [ ] `allow=false` responses return `403` with `reason` in error body
- [ ] Existing test suite passes unchanged (OPA evaluates same rules as before)
- [ ] New unit tests for OPA middleware: allow/deny cases, missing scope, invalid input
- [ ] `POLICY_DIR` env var documented in `docs/devops/environment-variables.md`

View File

@@ -0,0 +1,24 @@
# Spec: Python SDK (`sentryagent-idp`)
**Status**: Pending CEO approval
**Workstream**: 2 of 8
## Scope
- `sdk-python/` directory at project root
- `AgentIdPClient` with sync and async variants
- `TokenManager` with 60s auto-refresh
- Service clients: `AgentRegistryClient`, `CredentialClient`, `TokenClient`, `AuditClient`
- `AgentIdPError` typed exception
- Full type hints — `mypy --strict` clean
- `sdk-python/README.md` with installation and usage
## Acceptance Criteria
- [ ] All 14 API endpoints covered
- [ ] Sync client: `requests` library
- [ ] Async client: `httpx` library
- [ ] `mypy --strict` passes with zero errors
- [ ] Zero untyped code
- [ ] `AgentIdPError` raised (not raw requests/httpx exceptions) on all failure paths
- [ ] `TokenManager` tested: caches token, refreshes at exp-60s
- [ ] `pyproject.toml` with: name=sentryagent-idp, python>=3.9, dependencies declared
- [ ] README matches Node.js SDK structure

View File

@@ -0,0 +1,21 @@
# Spec: HashiCorp Vault Integration
**Status**: Pending CEO approval
**Workstream**: 1 of 8
## Scope
- VaultClient class wrapping `node-vault`
- `005_add_vault_path.sql` migration
- Updated CredentialService to write secrets to Vault instead of PostgreSQL
- New env vars: VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
- Migration guide: bcrypt → Vault coexistence strategy
## Acceptance Criteria
- [ ] New credentials: secret written to Vault KV v2, `vault_path` stored in PostgreSQL
- [ ] Credential rotation: Vault versioned update, `vault_path` unchanged
- [ ] Credential revocation: Vault secret deleted, DB status = `revoked`
- [ ] Existing bcrypt credentials continue to work until rotated
- [ ] VaultClient follows existing service interface pattern (DRY, SOLID)
- [ ] Zero `any` types, TypeScript strict
- [ ] `VAULT_ADDR` / `VAULT_TOKEN` validation at startup (fail-fast)
- [ ] DevOps docs updated with Vault setup section

View File

@@ -0,0 +1,34 @@
# Spec: Web Dashboard UI
**Status**: Pending CEO approval
**Workstream**: 6 of 8
## Scope
- `dashboard/` directory at project root
- React 18 + TypeScript strict, built with Vite 5
- TanStack Query v5 for server state
- shadcn/ui (Radix UI + Tailwind CSS) for components
- Four pages: Agents, Credentials, Audit Log, Health
- Client-side auth: `clientId` + `clientSecret``TokenManager`
- Served from AgentIdP server at `GET /dashboard` (static build)
## Pages
| Page | Route | Scope Required |
|------|-------|---------------|
| Login | `/dashboard/login` | None |
| Agents | `/dashboard/agents` | `agents:read` |
| Agent Detail | `/dashboard/agents/:id` | `agents:read` |
| Credentials | `/dashboard/agents/:id/credentials` | `agents:read` |
| Audit Log | `/dashboard/audit` | `audit:read` |
| Health | `/dashboard/health` | None |
## Acceptance Criteria
- [ ] TypeScript strict — zero `any` across all dashboard files
- [ ] `dashboard/tsconfig.json` with `strict: true`
- [ ] Login form stores token in `sessionStorage` only (not `localStorage`)
- [ ] All write operations (suspend, revoke, rotate) require confirmation dialog
- [ ] OWASP Top 10 review: no XSS, no CSRF, no sensitive data in URL params
- [ ] Vite build outputs to `dashboard/dist/`; AgentIdP serves it as static
- [ ] `dashboard/README.md` — how to build and serve
- [ ] Responsive layout — functional on desktop and tablet

View File

@@ -0,0 +1,127 @@
# Phase 2: Production-Ready — Tasks
**Status**: In progress — Workstreams 1, 2, 3, 4 complete.
## CEO Approval Gates (required before implementation)
- [x] A0.1 Approve dependency: `node-vault` (Vault integration)
- [x] A0.2 Approve dependency: `@openpolicyagent/opa-wasm` (OPA policy engine)
- [x] A0.3 Approve dependency: React 18 + Vite 5 (web dashboard)
- [x] A0.4 Approve dependency: `prom-client` (Prometheus metrics)
- [x] A0.5 Approve dependency: Terraform (infrastructure as code)
---
## Workstream 1: HashiCorp Vault Integration
- [x] 1.1 Write `src/vault/VaultClient.ts` — wraps `node-vault`; methods: writeSecret, readSecret, deleteSecret, verifySecret
- [x] 1.2 Write `src/db/migrations/005_add_vault_path.sql` — add `vault_path` column to `credentials`
- [x] 1.3 Update `CredentialService.ts` — new credentials use Vault; existing bcrypt credentials continue to work
- [x] 1.4 Update `docs/devops/environment-variables.md` — add VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
- [x] 1.5 Write `docs/devops/vault-setup.md` — Vault dev server setup, production Vault config, migration guide
- [x] 1.6 Write unit tests for VaultClient (mocked Vault) and updated CredentialService
- [x] 1.7 QA sign-off: zero `any`, TypeScript strict, >80% coverage, coexistence verified
## Workstream 2: Python SDK
- [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
- [x] 3.1 Create `sdk-go/` with `go.mod` — module: github.com/sentryagent/idp-sdk-go, go 1.21
- [x] 3.2 Write `sdk-go/types.go` — all request/response structs
- [x] 3.3 Write `sdk-go/errors.go` — AgentIdPError type implementing error interface
- [x] 3.4 Write `sdk-go/token_manager.go` — mutex-guarded TokenManager
- [x] 3.5 Write `sdk-go/agents.go` — AgentRegistryClient (flat package; see ADR below)
- [x] 3.6 Write `sdk-go/credentials.go` — CredentialClient
- [x] 3.7 Write `sdk-go/token_service.go` — TokenServiceClient
- [x] 3.8 Write `sdk-go/audit.go` — AuditClient
- [x] 3.9 Write `sdk-go/client.go` — AgentIdPClient
- [x] 3.10 Write `sdk-go/README.md`
- [x] 3.11 QA: `go vet` clean, `staticcheck` clean, all 14 endpoints, goroutine-safe, `go test ./...` >80%
## Workstream 4: Java SDK
- [x] 4.1 Create `sdk-java/` with `pom.xml` — groupId: ai.sentryagent, artifactId: idp-sdk, Java 17
- [x] 4.2 Write all POJO request/response model classes
- [x] 4.3 Write `AgentIdPException.java` extending RuntimeException
- [x] 4.4 Write `TokenManager.java` — synchronized cache with 60s refresh buffer
- [x] 4.5 Write `AgentRegistryClient.java` — sync + CompletableFuture methods
- [x] 4.6 Write `CredentialClient.java` — sync + CompletableFuture methods
- [x] 4.7 Write `TokenClient.java` — sync + CompletableFuture methods
- [x] 4.8 Write `AuditClient.java` — sync + CompletableFuture methods
- [x] 4.9 Write `AgentIdPClient.java` — composes all service clients
- [x] 4.10 Write `sdk-java/README.md`
- [x] 4.11 QA: `mvn verify` passes, all 14 endpoints, AgentIdPException on all failure paths, JUnit 5 >80%
## Workstream 5: OPA Policy Engine
- [ ] 5.1 Write `policies/authz.rego` — allow/deny rules matching all current scope checks
- [ ] 5.2 Write `policies/data/scopes.json` — scope to endpoint permission mapping
- [ ] 5.3 Write `src/middleware/opa.ts` — OpaMiddleware: loads Wasm, evaluates input, returns allow/deny
- [ ] 5.4 Replace static scope check in `src/middleware/auth.ts` with OpaMiddleware
- [ ] 5.5 Add SIGHUP handler in `src/server.ts` to hot-reload policy files
- [ ] 5.6 Update `docs/devops/environment-variables.md` — add POLICY_DIR
- [ ] 5.7 QA: all existing auth tests pass unchanged, new OPA unit tests, hot-reload verified
## Workstream 6: Web Dashboard UI
- [ ] 6.1 Create `dashboard/` with Vite 5 + React 18 + TypeScript strict configuration
- [ ] 6.2 Set up shadcn/ui with Tailwind CSS
- [ ] 6.3 Write `dashboard/src/lib/auth.ts` — credential entry, TokenManager, sessionStorage
- [ ] 6.4 Write `dashboard/src/lib/client.ts` — wraps @sentryagent/idp-sdk AgentIdPClient
- [ ] 6.5 Write Login page (`/dashboard/login`)
- [ ] 6.6 Write Agents page (`/dashboard/agents`) — list, search, filter by status
- [ ] 6.7 Write Agent Detail page (`/dashboard/agents/:id`) — suspend/reactivate with confirm dialog
- [ ] 6.8 Write Credentials page (`/dashboard/agents/:id/credentials`) — rotate/revoke with confirm
- [ ] 6.9 Write Audit Log page (`/dashboard/audit`) — filters, pagination
- [ ] 6.10 Write Health page (`/dashboard/health`) — PostgreSQL + Redis connectivity status
- [ ] 6.11 Configure AgentIdP Express app to serve `dashboard/dist/` at `/dashboard`
- [ ] 6.12 Write `dashboard/README.md`
- [ ] 6.13 QA: TypeScript strict, zero `any`, OWASP Top 10 review, responsive layout verified
## Workstream 7: Prometheus + Grafana Monitoring
- [ ] 7.1 Add `prom-client` to dependencies (after CEO approval A0.4)
- [ ] 7.2 Write `src/metrics/registry.ts` — shared Prometheus Registry with all 7 metric definitions
- [ ] 7.3 Instrument `OAuth2Service.ts` — increment `agentidp_tokens_issued_total`
- [ ] 7.4 Instrument `AgentService.ts` — increment `agentidp_agents_registered_total`
- [ ] 7.5 Instrument `src/middleware/` — HTTP request counter and duration histogram
- [ ] 7.6 Instrument `src/db/pool.ts` — DB query duration histogram
- [ ] 7.7 Instrument `src/cache/redis.ts` — Redis command duration histogram
- [ ] 7.8 Add `GET /metrics` route (unauthenticated, Prometheus text format)
- [ ] 7.9 Write `monitoring/prometheus/prometheus.yml` — scrape config
- [ ] 7.10 Write `monitoring/grafana/provisioning/` — datasource + dashboard provisioning
- [ ] 7.11 Write `monitoring/grafana/dashboards/agentidp.json` — pre-built Grafana dashboard
- [ ] 7.12 Write `docker-compose.monitoring.yml` overlay
- [ ] 7.13 Update `docs/devops/operations.md` — monitoring section
- [ ] 7.14 QA: all 7 metrics verified under load, Grafana auto-provisions, no auth leak on /metrics
## Workstream 8: Multi-Region Deployment (Terraform)
- [ ] 8.1 Write `terraform/modules/agentidp/main.tf` + `variables.tf` + `outputs.tf`
- [ ] 8.2 Write `terraform/modules/rds/` — managed PostgreSQL module
- [ ] 8.3 Write `terraform/modules/redis/` — managed Redis module
- [ ] 8.4 Write `terraform/modules/lb/` — load balancer + TLS module
- [ ] 8.5 Write `terraform/environments/aws/main.tf` + `variables.tf` + `terraform.tfvars.example`
- [ ] 8.6 Write `terraform/environments/gcp/main.tf` + `variables.tf` + `terraform.tfvars.example`
- [ ] 8.7 Write `docs/devops/deployment.md` — end-to-end AWS and GCP deployment walkthrough
- [ ] 8.8 QA: `terraform validate` passes, secrets not hardcoded, TLS enforced, DB/Redis VPC-internal
---
## Phase 2 Complete Criteria
All 8 workstreams done. All tasks checked. All QA gates passed. CEO reviewed.

154
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"joi": "^17.12.3", "joi": "^17.12.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"node-vault": "^0.12.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"pino": "^8.19.0", "pino": "^8.19.0",
"pino-http": "^9.0.0", "pino-http": "^9.0.0",
@@ -30,6 +31,7 @@
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.9",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/node-vault": "^0.9.1",
"@types/pg": "^8.11.5", "@types/pg": "^8.11.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
@@ -1475,6 +1477,13 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/caseless": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
"integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -1625,6 +1634,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/mustache": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.6.tgz",
"integrity": "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.37", "version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
@@ -1635,6 +1651,17 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/node-vault": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@types/node-vault/-/node-vault-0.9.1.tgz",
"integrity": "sha512-h7b0JZ76kvwFL/XvfNV2LJ45/SVXLkOvrIKHIGR5Cp3c/BIWsDetJR6Gfzppl3BfX5RN3rlEuHmmHhKnuL4nlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mustache": "*",
"@types/request": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -1661,6 +1688,37 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/request": {
"version": "2.48.13",
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz",
"integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/caseless": "*",
"@types/node": "*",
"@types/tough-cookie": "*",
"form-data": "^2.5.5"
}
},
"node_modules/@types/request/node_modules/form-data": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/@types/send": { "node_modules/@types/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -1725,6 +1783,13 @@
"@types/superagent": "^8.1.0" "@types/superagent": "^8.1.0"
} }
}, },
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/uuid": { "node_modules/@types/uuid": {
"version": "9.0.8", "version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
@@ -2137,7 +2202,6 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/atomic-sleep": { "node_modules/atomic-sleep": {
@@ -2149,6 +2213,17 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -2690,7 +2765,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
@@ -2831,7 +2905,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -2881,7 +2954,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@@ -3094,7 +3166,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -3647,11 +3718,30 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
@@ -3987,7 +4077,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"has-symbols": "^1.0.3" "has-symbols": "^1.0.3"
@@ -5414,6 +5503,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -5451,6 +5549,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-vault": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/node-vault/-/node-vault-0.12.0.tgz",
"integrity": "sha512-+SL3DSREptI+UJMM8UUwlI3jR5agPuAgCxSdUfeybGKszXiILXTCUHxErDdpgNgug8oj4v2rOmyrXhRJ4LZsyQ==",
"license": "MIT",
"dependencies": {
"axios": "^1.13.6",
"debug": "^4.3.4",
"mustache": "^4.2.0",
"tv4": "^1.3.0"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -6078,6 +6191,15 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7018,6 +7140,24 @@
} }
} }
}, },
"node_modules/tv4": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz",
"integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==",
"license": [
{
"type": "Public Domain",
"url": "http://geraintluff.github.io/tv4/LICENSE.txt"
},
{
"type": "MIT",
"url": "http://jsonary.com/LICENSE.txt"
}
],
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -23,6 +23,7 @@
"joi": "^17.12.3", "joi": "^17.12.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"node-vault": "^0.12.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"pino": "^8.19.0", "pino": "^8.19.0",
"pino-http": "^9.0.0", "pino-http": "^9.0.0",
@@ -37,6 +38,7 @@
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.9",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/node-vault": "^0.9.1",
"@types/pg": "^8.11.5", "@types/pg": "^8.11.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",

200
sdk-go/README.md Normal file
View File

@@ -0,0 +1,200 @@
# SentryAgent.ai AgentIdP — Go SDK
Official Go client for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — an open-source Identity Provider for AI agents built on OAuth 2.0 (RFC 6749) and aligned with the [AGNTCY](https://agntcy.org) open standard.
## Requirements
- Go 1.21+
- A running AgentIdP server
## Installation
```bash
go get github.com/sentryagent/idp-sdk-go
```
## Quick Start
```go
package main
import (
"context"
"fmt"
"log"
agentidp "github.com/sentryagent/idp-sdk-go"
)
func main() {
ctx := context.Background()
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
BaseURL: "https://idp.example.com",
ClientID: "your-agent-client-id",
ClientSecret: "sk_live_...",
})
// Register a new AI agent
agent, err := client.Agents.RegisterAgent(ctx, agentidp.RegisterAgentRequest{
Email: "screener@example.com",
AgentType: "screener",
Version: "1.0.0",
Capabilities: []string{"read", "classify"},
Owner: "platform-team",
DeploymentEnv: "production",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Registered agent: %s\n", agent.AgentID)
}
```
## Authentication
The SDK handles OAuth 2.0 Client Credentials automatically. Tokens are cached and refreshed 60 seconds before expiry. All operations are goroutine-safe.
```go
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
BaseURL: "https://idp.example.com",
ClientID: "my-client-id",
ClientSecret: "my-client-secret",
Scope: "agents:read agents:write", // optional, defaults to all four scopes
})
```
## Agent Registry
```go
ctx := context.Background()
// Register
agent, err := client.Agents.RegisterAgent(ctx, agentidp.RegisterAgentRequest{...})
// List (with optional filters)
agents, err := client.Agents.ListAgents(ctx, &agentidp.ListAgentsParams{
Status: "active",
AgentType: "screener",
Page: 1,
Limit: 20,
})
// Get by ID
agent, err := client.Agents.GetAgent(ctx, "agent-uuid")
// Partial update
version := "2.0.0"
agent, err := client.Agents.UpdateAgent(ctx, "agent-uuid", agentidp.UpdateAgentRequest{
Version: &version,
})
// Decommission (permanent)
err = client.Agents.DecommissionAgent(ctx, "agent-uuid")
```
## Credential Management
```go
// Generate (returns one-time ClientSecret)
cred, err := client.Credentials.GenerateCredential(ctx, "agent-uuid")
fmt.Println(cred.ClientSecret) // store this — it is never shown again
// List
creds, err := client.Credentials.ListCredentials(ctx, "agent-uuid", 1, 20)
// Rotate (old secret is immediately invalidated)
newCred, err := client.Credentials.RotateCredential(ctx, "agent-uuid", "cred-uuid")
// Revoke
revoked, err := client.Credentials.RevokeCredential(ctx, "agent-uuid", "cred-uuid")
```
## Token Operations
```go
// Introspect (RFC 7662)
result, err := client.Tokens.IntrospectToken(ctx, "access-token-to-check")
if result.Active {
fmt.Printf("Token belongs to: %s\n", *result.Sub)
}
// Revoke
err = client.Tokens.RevokeToken(ctx, "access-token-to-revoke")
```
## Audit Log
```go
// Query with filters
events, err := client.Audit.QueryAuditLog(ctx, &agentidp.QueryAuditParams{
AgentID: "agent-uuid",
Action: "token.issued",
Outcome: "success",
FromDate: "2026-01-01",
ToDate: "2026-01-31",
Page: 1,
Limit: 50,
})
// Get single event
event, err := client.Audit.GetAuditEvent(ctx, "event-uuid")
```
## Error Handling
All errors are returned as `*AgentIdPError`:
```go
agent, err := client.Agents.GetAgent(ctx, "unknown-id")
if err != nil {
if apiErr, ok := err.(*agentidp.AgentIdPError); ok {
fmt.Printf("code=%s status=%d\n", apiErr.Code, apiErr.HTTPStatus)
// e.g. code=AgentNotFoundError status=404
}
return err
}
```
| Field | Type | Description |
|--------------|--------------------------|-------------------------------------------------|
| `Code` | `string` | Machine-readable error code |
| `Message` | `string` | Human-readable description |
| `HTTPStatus` | `int` | HTTP status code (0 for network/build errors) |
| `Details` | `map[string]interface{}` | Optional structured context from the API |
## Custom HTTP Client
Inject a custom `*http.Client` for proxy support, custom timeouts, or test mocking:
```go
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
BaseURL: "https://idp.example.com",
ClientID: "cid",
ClientSecret: "secret",
HTTPClient: &http.Client{Timeout: 5 * time.Second},
})
```
## API Coverage
| Endpoint | Method | SDK Method |
|--------------------------------------------------|--------|-----------------------------------------|
| POST /api/v1/agents | POST | `Agents.RegisterAgent` |
| GET /api/v1/agents | GET | `Agents.ListAgents` |
| GET /api/v1/agents/:id | GET | `Agents.GetAgent` |
| PATCH /api/v1/agents/:id | PATCH | `Agents.UpdateAgent` |
| DELETE /api/v1/agents/:id | DELETE | `Agents.DecommissionAgent` |
| POST /api/v1/agents/:id/credentials | POST | `Credentials.GenerateCredential` |
| GET /api/v1/agents/:id/credentials | GET | `Credentials.ListCredentials` |
| POST /api/v1/agents/:id/credentials/:cid/rotate | POST | `Credentials.RotateCredential` |
| DELETE /api/v1/agents/:id/credentials/:cid | DELETE | `Credentials.RevokeCredential` |
| POST /api/v1/token | POST | (TokenManager — automatic) |
| POST /api/v1/token/introspect | POST | `Tokens.IntrospectToken` |
| POST /api/v1/token/revoke | POST | `Tokens.RevokeToken` |
| GET /api/v1/audit | GET | `Audit.QueryAuditLog` |
| GET /api/v1/audit/:id | GET | `Audit.GetAuditEvent` |
## License
Apache 2.0 — see [LICENSE](../LICENSE).

113
sdk-go/agents.go Normal file
View File

@@ -0,0 +1,113 @@
package agentidp
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// AgentRegistryClient provides methods for the Agent Registry API endpoints.
// All methods take a context.Context as first argument.
type AgentRegistryClient struct {
baseURL string
getToken func(ctx context.Context) (string, error)
httpClient *http.Client
}
func newAgentRegistryClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *AgentRegistryClient {
return &AgentRegistryClient{
baseURL: strings.TrimRight(baseURL, "/"),
getToken: getToken,
httpClient: httpClient,
}
}
// RegisterAgent registers a new AI agent identity.
// POST /api/v1/agents → 201 Agent
func (c *AgentRegistryClient) RegisterAgent(ctx context.Context, req RegisterAgentRequest) (*Agent, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var agent Agent
if err := doRequest(ctx, c.httpClient, http.MethodPost, c.baseURL+"/api/v1/agents", req, token, &agent); err != nil {
return nil, err
}
return &agent, nil
}
// ListAgents returns a paginated list of registered agents.
// GET /api/v1/agents → 200 PaginatedAgents
func (c *AgentRegistryClient) ListAgents(ctx context.Context, params *ListAgentsParams) (*PaginatedAgents, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/agents"
if params != nil {
q := url.Values{}
if params.Status != "" {
q.Set("status", params.Status)
}
if params.AgentType != "" {
q.Set("agentType", params.AgentType)
}
if params.DeploymentEnv != "" {
q.Set("deploymentEnv", params.DeploymentEnv)
}
if params.Page > 0 {
q.Set("page", fmt.Sprintf("%d", params.Page))
}
if params.Limit > 0 {
q.Set("limit", fmt.Sprintf("%d", params.Limit))
}
if len(q) > 0 {
rawURL += "?" + q.Encode()
}
}
var result PaginatedAgents
if err := doRequest(ctx, c.httpClient, http.MethodGet, rawURL, nil, token, &result); err != nil {
return nil, err
}
return &result, nil
}
// GetAgent retrieves a single agent by ID.
// GET /api/v1/agents/:id → 200 Agent
func (c *AgentRegistryClient) GetAgent(ctx context.Context, agentID string) (*Agent, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var agent Agent
if err := doRequest(ctx, c.httpClient, http.MethodGet, c.baseURL+"/api/v1/agents/"+agentID, nil, token, &agent); err != nil {
return nil, err
}
return &agent, nil
}
// UpdateAgent partially updates an agent.
// PATCH /api/v1/agents/:id → 200 Agent
func (c *AgentRegistryClient) UpdateAgent(ctx context.Context, agentID string, req UpdateAgentRequest) (*Agent, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var agent Agent
if err := doRequest(ctx, c.httpClient, http.MethodPatch, c.baseURL+"/api/v1/agents/"+agentID, req, token, &agent); err != nil {
return nil, err
}
return &agent, nil
}
// DecommissionAgent permanently removes an agent.
// DELETE /api/v1/agents/:id → 204 No Content
func (c *AgentRegistryClient) DecommissionAgent(ctx context.Context, agentID string) error {
token, err := c.getToken(ctx)
if err != nil {
return err
}
return doRequest(ctx, c.httpClient, http.MethodDelete, c.baseURL+"/api/v1/agents/"+agentID, nil, token, nil)
}

181
sdk-go/agents_test.go Normal file
View File

@@ -0,0 +1,181 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// mockAgent is the canonical test agent fixture.
var mockAgent = Agent{
AgentID: "uuid-1",
Email: "a@b.ai",
AgentType: "screener",
Version: "1.0.0",
Capabilities: []string{"read"},
Owner: "team",
DeploymentEnv: "production",
Status: "active",
CreatedAt: "2026-01-01T00:00:00Z",
UpdatedAt: "2026-01-01T00:00:00Z",
}
var mockPaginatedAgents = PaginatedAgents{
Data: []Agent{mockAgent},
Total: 1,
Page: 1,
Limit: 20,
}
// staticToken returns a fixed token for all test service clients.
func staticToken(_ context.Context) (string, error) {
return "test-bearer-token", nil
}
func newAgentServer(t *testing.T, method, path string, status int, body interface{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != method {
t.Errorf("expected method %s, got %s", method, r.Method)
}
if r.URL.Path != path {
t.Errorf("expected path %s, got %s", path, r.URL.Path)
}
if r.Header.Get("Authorization") == "" {
t.Error("missing Authorization header")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body != nil {
_ = json.NewEncoder(w).Encode(body)
}
}))
}
func TestAgentRegistryClient_RegisterAgent(t *testing.T) {
srv := newAgentServer(t, http.MethodPost, "/api/v1/agents", 201, mockAgent)
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
agent, err := client.RegisterAgent(context.Background(), RegisterAgentRequest{
Email: "a@b.ai", AgentType: "screener", Version: "1.0.0",
Capabilities: []string{"read"}, Owner: "team", DeploymentEnv: "production",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if agent.AgentID != "uuid-1" {
t.Errorf("expected uuid-1, got %q", agent.AgentID)
}
}
func TestAgentRegistryClient_ListAgents(t *testing.T) {
srv := newAgentServer(t, http.MethodGet, "/api/v1/agents", 200, mockPaginatedAgents)
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
result, err := client.ListAgents(context.Background(), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Total != 1 {
t.Errorf("expected total 1, got %d", result.Total)
}
if len(result.Data) != 1 || result.Data[0].AgentID != "uuid-1" {
t.Error("unexpected data in paginated result")
}
}
func TestAgentRegistryClient_ListAgents_WithParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("status") != "active" {
t.Errorf("expected status=active, got %q", r.URL.Query().Get("status"))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockPaginatedAgents)
}))
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
_, err := client.ListAgents(context.Background(), &ListAgentsParams{Status: "active"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAgentRegistryClient_GetAgent(t *testing.T) {
srv := newAgentServer(t, http.MethodGet, "/api/v1/agents/uuid-1", 200, mockAgent)
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
agent, err := client.GetAgent(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if agent.AgentID != "uuid-1" {
t.Errorf("expected uuid-1, got %q", agent.AgentID)
}
}
func TestAgentRegistryClient_GetAgent_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
_ = json.NewEncoder(w).Encode(map[string]string{
"code": "AgentNotFoundError",
"message": "Agent not found.",
})
}))
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
_, err := client.GetAgent(context.Background(), "bad-id")
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.Code != "AgentNotFoundError" {
t.Errorf("expected AgentNotFoundError, got %q", apiErr.Code)
}
if apiErr.HTTPStatus != 404 {
t.Errorf("expected 404, got %d", apiErr.HTTPStatus)
}
}
func TestAgentRegistryClient_UpdateAgent(t *testing.T) {
updated := mockAgent
updated.Version = "2.0.0"
srv := newAgentServer(t, http.MethodPatch, "/api/v1/agents/uuid-1", 200, updated)
defer srv.Close()
v := "2.0.0"
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
agent, err := client.UpdateAgent(context.Background(), "uuid-1", UpdateAgentRequest{Version: &v})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if agent.Version != "2.0.0" {
t.Errorf("expected version 2.0.0, got %q", agent.Version)
}
}
func TestAgentRegistryClient_DecommissionAgent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("expected DELETE, got %s", r.Method)
}
w.WriteHeader(204)
}))
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
err := client.DecommissionAgent(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

80
sdk-go/audit.go Normal file
View File

@@ -0,0 +1,80 @@
package agentidp
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// AuditClient provides methods for querying the Audit Log API endpoints.
type AuditClient struct {
baseURL string
getToken func(ctx context.Context) (string, error)
httpClient *http.Client
}
func newAuditClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *AuditClient {
return &AuditClient{
baseURL: strings.TrimRight(baseURL, "/"),
getToken: getToken,
httpClient: httpClient,
}
}
// QueryAuditLog returns a filtered, paginated list of audit events.
// GET /api/v1/audit → 200 PaginatedAuditEvents
func (c *AuditClient) QueryAuditLog(ctx context.Context, params *QueryAuditParams) (*PaginatedAuditEvents, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/audit"
if params != nil {
q := url.Values{}
if params.AgentID != "" {
q.Set("agentId", params.AgentID)
}
if params.Action != "" {
q.Set("action", params.Action)
}
if params.Outcome != "" {
q.Set("outcome", params.Outcome)
}
if params.FromDate != "" {
q.Set("fromDate", params.FromDate)
}
if params.ToDate != "" {
q.Set("toDate", params.ToDate)
}
if params.Page > 0 {
q.Set("page", fmt.Sprintf("%d", params.Page))
}
if params.Limit > 0 {
q.Set("limit", fmt.Sprintf("%d", params.Limit))
}
if len(q) > 0 {
rawURL += "?" + q.Encode()
}
}
var result PaginatedAuditEvents
if err := doRequest(ctx, c.httpClient, http.MethodGet, rawURL, nil, token, &result); err != nil {
return nil, err
}
return &result, nil
}
// GetAuditEvent retrieves a single audit event by ID.
// GET /api/v1/audit/:id → 200 AuditEvent
func (c *AuditClient) GetAuditEvent(ctx context.Context, eventID string) (*AuditEvent, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var event AuditEvent
if err := doRequest(ctx, c.httpClient, http.MethodGet, c.baseURL+"/api/v1/audit/"+eventID, nil, token, &event); err != nil {
return nil, err
}
return &event, nil
}

126
sdk-go/audit_test.go Normal file
View File

@@ -0,0 +1,126 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
var mockAuditEvent = AuditEvent{
EventID: "ev-1",
AgentID: "uuid-1",
Action: "token.issued",
Outcome: "success",
IPAddress: "1.2.3.4",
UserAgent: "curl",
Metadata: map[string]interface{}{},
Timestamp: "2026-01-01T00:00:00Z",
}
var mockPaginatedAudit = PaginatedAuditEvents{
Data: []AuditEvent{mockAuditEvent},
Total: 1,
Page: 1,
Limit: 20,
}
func TestAuditClient_QueryAuditLog(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/audit" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockPaginatedAudit)
}))
defer srv.Close()
client := newAuditClient(srv.URL, staticToken, &http.Client{})
result, err := client.QueryAuditLog(context.Background(), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Total != 1 {
t.Errorf("expected total 1, got %d", result.Total)
}
if len(result.Data) == 0 || result.Data[0].EventID != "ev-1" {
t.Error("unexpected data in paginated result")
}
}
func TestAuditClient_QueryAuditLog_WithParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("agentId") != "uuid-1" {
t.Errorf("expected agentId=uuid-1, got %q", q.Get("agentId"))
}
if q.Get("action") != "token.issued" {
t.Errorf("expected action=token.issued, got %q", q.Get("action"))
}
if q.Get("fromDate") != "2026-01-01" {
t.Errorf("expected fromDate=2026-01-01, got %q", q.Get("fromDate"))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockPaginatedAudit)
}))
defer srv.Close()
client := newAuditClient(srv.URL, staticToken, &http.Client{})
_, err := client.QueryAuditLog(context.Background(), &QueryAuditParams{
AgentID: "uuid-1",
Action: "token.issued",
FromDate: "2026-01-01",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAuditClient_GetAuditEvent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/audit/ev-1" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockAuditEvent)
}))
defer srv.Close()
client := newAuditClient(srv.URL, staticToken, &http.Client{})
event, err := client.GetAuditEvent(context.Background(), "ev-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event.EventID != "ev-1" {
t.Errorf("expected ev-1, got %q", event.EventID)
}
if event.Action != "token.issued" {
t.Errorf("expected token.issued, got %q", event.Action)
}
}
func TestAuditClient_Error_Propagated(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
_ = json.NewEncoder(w).Encode(map[string]string{
"code": "AuditEventNotFoundError",
"message": "Event not found.",
})
}))
defer srv.Close()
client := newAuditClient(srv.URL, staticToken, &http.Client{})
_, err := client.GetAuditEvent(context.Background(), "bad-id")
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.Code != "AuditEventNotFoundError" {
t.Errorf("expected AuditEventNotFoundError, got %q", apiErr.Code)
}
}

83
sdk-go/client.go Normal file
View File

@@ -0,0 +1,83 @@
package agentidp
import (
"context"
"net/http"
"strings"
"time"
)
// AgentIdPClientConfig holds all configuration for AgentIdPClient.
type AgentIdPClientConfig struct {
// BaseURL is the root URL of the AgentIdP server (e.g. "https://idp.example.com").
BaseURL string
// ClientID is the agent's OAuth 2.0 client ID.
ClientID string
// ClientSecret is the agent's OAuth 2.0 client secret.
ClientSecret string
// Scope is the space-separated list of OAuth 2.0 scopes to request.
// Defaults to all four scopes when empty.
Scope string
// HTTPClient allows injecting a custom *http.Client (e.g. for testing).
// When nil, a default client with a 30-second timeout is used.
HTTPClient *http.Client
}
// AgentIdPClient is the top-level client for the SentryAgent.ai AgentIdP API.
// It composes all four service clients and manages token acquisition automatically.
//
// Usage:
//
// client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
// BaseURL: "https://idp.example.com",
// ClientID: "my-agent-id",
// ClientSecret: "sk_live_...",
// })
// agent, err := client.Agents.GetAgent(ctx, "uuid-1")
type AgentIdPClient struct {
// Agents provides access to the Agent Registry endpoints.
Agents *AgentRegistryClient
// Credentials provides access to the Credential Management endpoints.
Credentials *CredentialClient
// Tokens provides access to the Token introspection and revocation endpoints.
Tokens *TokenServiceClient
// Audit provides access to the Audit Log endpoints.
Audit *AuditClient
tokenManager *TokenManager
}
// NewAgentIdPClient creates a new AgentIdPClient with the given configuration.
func NewAgentIdPClient(cfg AgentIdPClientConfig) *AgentIdPClient {
baseURL := strings.TrimRight(cfg.BaseURL, "/")
scope := cfg.Scope
if scope == "" {
scope = "agents:read agents:write tokens:read audit:read"
}
httpClient := cfg.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
tm := NewTokenManager(baseURL, cfg.ClientID, cfg.ClientSecret, scope)
getToken := func(ctx context.Context) (string, error) {
return tm.GetToken(ctx)
}
return &AgentIdPClient{
Agents: newAgentRegistryClient(baseURL, getToken, httpClient),
Credentials: newCredentialClient(baseURL, getToken, httpClient),
Tokens: newTokenServiceClient(baseURL, getToken, httpClient),
Audit: newAuditClient(baseURL, getToken, httpClient),
tokenManager: tm,
}
}
// ClearTokenCache invalidates the cached access token.
// The next API call will fetch a fresh token from the server.
func (c *AgentIdPClient) ClearTokenCache() {
c.tokenManager.ClearCache()
}

124
sdk-go/client_test.go Normal file
View File

@@ -0,0 +1,124 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// integrationServer returns a minimal mock server that handles the token endpoint
// plus a provided handler for all other routes.
func integrationServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "integration-token",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read agents:write tokens:read audit:read",
})
})
mux.HandleFunc("/", handler)
return httptest.NewServer(mux)
}
func TestNewAgentIdPClient_GetAgent(t *testing.T) {
srv := integrationServer(t, func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/agents/") {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer integration-token" {
t.Errorf("unexpected Authorization: %q", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockAgent)
})
defer srv.Close()
client := NewAgentIdPClient(AgentIdPClientConfig{
BaseURL: srv.URL,
ClientID: "cid",
ClientSecret: "secret",
})
agent, err := client.Agents.GetAgent(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if agent.AgentID != "uuid-1" {
t.Errorf("expected uuid-1, got %q", agent.AgentID)
}
}
func TestNewAgentIdPClient_ClearTokenCache(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/token" {
callCount++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "tok",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read",
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockAgent)
}))
defer srv.Close()
client := NewAgentIdPClient(AgentIdPClientConfig{
BaseURL: srv.URL,
ClientID: "cid",
ClientSecret: "secret",
})
_, _ = client.Agents.GetAgent(context.Background(), "uuid-1")
client.ClearTokenCache()
_, _ = client.Agents.GetAgent(context.Background(), "uuid-1")
if callCount != 2 {
t.Errorf("expected 2 token fetches after ClearTokenCache, got %d", callCount)
}
}
func TestNewAgentIdPClient_DefaultScope(t *testing.T) {
var capturedScope string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/token" {
_ = r.ParseForm()
capturedScope = r.FormValue("scope")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "tok",
"token_type": "Bearer",
"expires_in": 3600,
"scope": capturedScope,
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockAgent)
}))
defer srv.Close()
client := NewAgentIdPClient(AgentIdPClientConfig{
BaseURL: srv.URL,
ClientID: "cid",
ClientSecret: "secret",
// Scope intentionally omitted → defaults applied
})
_, _ = client.Agents.GetAgent(context.Background(), "uuid-1")
expected := "agents:read agents:write tokens:read audit:read"
if capturedScope != expected {
t.Errorf("expected scope %q, got %q", expected, capturedScope)
}
}

93
sdk-go/credentials.go Normal file
View File

@@ -0,0 +1,93 @@
package agentidp
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// CredentialClient provides methods for the Credential Management API endpoints.
type CredentialClient struct {
baseURL string
getToken func(ctx context.Context) (string, error)
httpClient *http.Client
}
func newCredentialClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *CredentialClient {
return &CredentialClient{
baseURL: strings.TrimRight(baseURL, "/"),
getToken: getToken,
httpClient: httpClient,
}
}
// GenerateCredential creates a new credential for the given agent.
// POST /api/v1/agents/:id/credentials → 201 CredentialWithSecret
func (c *CredentialClient) GenerateCredential(ctx context.Context, agentID string) (*CredentialWithSecret, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var cred CredentialWithSecret
if err := doRequest(ctx, c.httpClient, http.MethodPost, c.baseURL+"/api/v1/agents/"+agentID+"/credentials", struct{}{}, token, &cred); err != nil {
return nil, err
}
return &cred, nil
}
// ListCredentials returns a paginated list of credentials for the given agent.
// GET /api/v1/agents/:id/credentials → 200 PaginatedCredentials
func (c *CredentialClient) ListCredentials(ctx context.Context, agentID string, page, limit int) (*PaginatedCredentials, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/agents/" + agentID + "/credentials"
q := url.Values{}
if page > 0 {
q.Set("page", fmt.Sprintf("%d", page))
}
if limit > 0 {
q.Set("limit", fmt.Sprintf("%d", limit))
}
if len(q) > 0 {
rawURL += "?" + q.Encode()
}
var result PaginatedCredentials
if err := doRequest(ctx, c.httpClient, http.MethodGet, rawURL, nil, token, &result); err != nil {
return nil, err
}
return &result, nil
}
// RotateCredential generates a new secret for the given credential.
// POST /api/v1/agents/:id/credentials/:credId/rotate → 200 CredentialWithSecret
func (c *CredentialClient) RotateCredential(ctx context.Context, agentID, credentialID string) (*CredentialWithSecret, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/agents/" + agentID + "/credentials/" + credentialID + "/rotate"
var cred CredentialWithSecret
if err := doRequest(ctx, c.httpClient, http.MethodPost, rawURL, struct{}{}, token, &cred); err != nil {
return nil, err
}
return &cred, nil
}
// RevokeCredential permanently revokes a credential.
// DELETE /api/v1/agents/:id/credentials/:credId → 200 Credential
func (c *CredentialClient) RevokeCredential(ctx context.Context, agentID, credentialID string) (*Credential, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/agents/" + agentID + "/credentials/" + credentialID
var cred Credential
if err := doRequest(ctx, c.httpClient, http.MethodDelete, rawURL, nil, token, &cred); err != nil {
return nil, err
}
return &cred, nil
}

146
sdk-go/credentials_test.go Normal file
View File

@@ -0,0 +1,146 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
var mockCred = Credential{
CredentialID: "cred-1",
ClientID: "uuid-1",
Status: "active",
CreatedAt: "2026-01-01T00:00:00Z",
}
var mockCredWithSecret = CredentialWithSecret{
Credential: mockCred,
ClientSecret: "sk_live_abc",
}
var mockPaginatedCreds = PaginatedCredentials{
Data: []Credential{mockCred},
Total: 1,
Page: 1,
Limit: 20,
}
func TestCredentialClient_GenerateCredential(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/agents/uuid-1/credentials" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
_ = json.NewEncoder(w).Encode(mockCredWithSecret)
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
cred, err := client.GenerateCredential(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cred.ClientSecret != "sk_live_abc" {
t.Errorf("expected sk_live_abc, got %q", cred.ClientSecret)
}
if cred.CredentialID != "cred-1" {
t.Errorf("expected cred-1, got %q", cred.CredentialID)
}
}
func TestCredentialClient_ListCredentials(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/agents/uuid-1/credentials" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockPaginatedCreds)
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
result, err := client.ListCredentials(context.Background(), "uuid-1", 0, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Total != 1 {
t.Errorf("expected total 1, got %d", result.Total)
}
}
func TestCredentialClient_RotateCredential(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/api/v1/agents/uuid-1/credentials/cred-1/rotate"
if r.Method != http.MethodPost || r.URL.Path != expectedPath {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockCredWithSecret)
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
cred, err := client.RotateCredential(context.Background(), "uuid-1", "cred-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cred.ClientSecret != "sk_live_abc" {
t.Errorf("expected sk_live_abc, got %q", cred.ClientSecret)
}
}
func TestCredentialClient_RevokeCredential(t *testing.T) {
revokedAt := "2026-01-02T00:00:00Z"
revoked := Credential{
CredentialID: "cred-1",
ClientID: "uuid-1",
Status: "revoked",
CreatedAt: "2026-01-01T00:00:00Z",
RevokedAt: &revokedAt,
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("expected DELETE, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(revoked)
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
cred, err := client.RevokeCredential(context.Background(), "uuid-1", "cred-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cred.Status != "revoked" {
t.Errorf("expected revoked, got %q", cred.Status)
}
}
func TestCredentialClient_Error_Propagated(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
_ = json.NewEncoder(w).Encode(map[string]string{
"code": "AgentNotFoundError",
"message": "Not found.",
})
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
_, err := client.GenerateCredential(context.Background(), "bad-id")
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.HTTPStatus != 404 {
t.Errorf("expected 404, got %d", apiErr.HTTPStatus)
}
}

83
sdk-go/errors.go Normal file
View File

@@ -0,0 +1,83 @@
package agentidp
import (
"encoding/json"
"fmt"
)
// AgentIdPError is returned for all API and network failures.
// It implements the error interface.
type AgentIdPError struct {
// Code is a machine-readable error code (e.g. "AgentNotFoundError").
Code string
// Message is a human-readable description.
Message string
// HTTPStatus is the HTTP response status code, or 0 for network errors.
HTTPStatus int
// Details contains additional structured context, if provided by the API.
Details map[string]interface{}
}
// Error implements the error interface.
func (e *AgentIdPError) Error() string {
return e.Message
}
// apiErrorBody is the standard JSON error body from the AgentIdP REST API.
type apiErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"`
}
// oauth2ErrorBody is the standard JSON error body from OAuth 2.0 token endpoints.
type oauth2ErrorBody struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// parseAPIError attempts to unmarshal a JSON response body into an AgentIdPError.
// Falls back to a generic UNKNOWN_ERROR if the body cannot be parsed.
func parseAPIError(body []byte, status int) *AgentIdPError {
var apiErr apiErrorBody
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Code != "" {
return &AgentIdPError{
Code: apiErr.Code,
Message: apiErr.Message,
HTTPStatus: status,
Details: apiErr.Details,
}
}
return &AgentIdPError{
Code: "UNKNOWN_ERROR",
Message: fmt.Sprintf("unexpected HTTP %d", status),
HTTPStatus: status,
}
}
// parseOAuth2Error attempts to unmarshal a JSON response body into an AgentIdPError
// using the OAuth 2.0 error format. Falls back to UNKNOWN_ERROR on parse failure.
func parseOAuth2Error(body []byte, status int) *AgentIdPError {
var oauthErr oauth2ErrorBody
if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.Error != "" {
return &AgentIdPError{
Code: oauthErr.Error,
Message: oauthErr.ErrorDescription,
HTTPStatus: status,
}
}
return &AgentIdPError{
Code: "UNKNOWN_ERROR",
Message: fmt.Sprintf("unexpected HTTP %d", status),
HTTPStatus: status,
}
}
// newNetworkError creates an AgentIdPError for transport-level failures.
func newNetworkError(cause error) *AgentIdPError {
return &AgentIdPError{
Code: "NETWORK_ERROR",
Message: fmt.Sprintf("network error: %s", cause.Error()),
HTTPStatus: 0,
}
}

85
sdk-go/errors_test.go Normal file
View File

@@ -0,0 +1,85 @@
package agentidp
import (
"strings"
"testing"
)
func TestAgentIdPError_Error(t *testing.T) {
err := &AgentIdPError{Code: "AgentNotFoundError", Message: "Agent not found.", HTTPStatus: 404}
if err.Error() != "Agent not found." {
t.Errorf("expected 'Agent not found.', got %q", err.Error())
}
}
func TestParseAPIError_ValidBody(t *testing.T) {
body := []byte(`{"code":"AgentNotFoundError","message":"Not found.","details":{"id":"x"}}`)
err := parseAPIError(body, 404)
if err.Code != "AgentNotFoundError" {
t.Errorf("expected code AgentNotFoundError, got %q", err.Code)
}
if err.HTTPStatus != 404 {
t.Errorf("expected status 404, got %d", err.HTTPStatus)
}
if err.Details == nil {
t.Error("expected non-nil Details")
}
}
func TestParseAPIError_UnparseableBody(t *testing.T) {
err := parseAPIError([]byte("not json"), 500)
if err.Code != "UNKNOWN_ERROR" {
t.Errorf("expected UNKNOWN_ERROR, got %q", err.Code)
}
if err.HTTPStatus != 500 {
t.Errorf("expected 500, got %d", err.HTTPStatus)
}
}
func TestParseAPIError_EmptyCode(t *testing.T) {
// Valid JSON but no "code" field → falls back to UNKNOWN_ERROR
err := parseAPIError([]byte(`{"message":"oops"}`), 503)
if err.Code != "UNKNOWN_ERROR" {
t.Errorf("expected UNKNOWN_ERROR, got %q", err.Code)
}
}
func TestParseOAuth2Error_ValidBody(t *testing.T) {
body := []byte(`{"error":"invalid_client","error_description":"Bad credentials."}`)
err := parseOAuth2Error(body, 401)
if err.Code != "invalid_client" {
t.Errorf("expected invalid_client, got %q", err.Code)
}
if err.Message != "Bad credentials." {
t.Errorf("expected 'Bad credentials.', got %q", err.Message)
}
if err.HTTPStatus != 401 {
t.Errorf("expected 401, got %d", err.HTTPStatus)
}
}
func TestParseOAuth2Error_UnparseableBody(t *testing.T) {
err := parseOAuth2Error([]byte("garbage"), 400)
if err.Code != "UNKNOWN_ERROR" {
t.Errorf("expected UNKNOWN_ERROR, got %q", err.Code)
}
}
func TestNewNetworkError(t *testing.T) {
cause := &testError{msg: "connection refused"}
err := newNetworkError(cause)
if err.Code != "NETWORK_ERROR" {
t.Errorf("expected NETWORK_ERROR, got %q", err.Code)
}
if err.HTTPStatus != 0 {
t.Errorf("expected HTTPStatus 0, got %d", err.HTTPStatus)
}
if !strings.Contains(err.Message, "connection refused") {
t.Errorf("expected message to contain 'connection refused', got %q", err.Message)
}
}
// testError is a simple error implementation for testing.
type testError struct{ msg string }
func (e *testError) Error() string { return e.msg }

3
sdk-go/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/sentryagent/idp-sdk-go
go 1.21

79
sdk-go/request.go Normal file
View File

@@ -0,0 +1,79 @@
package agentidp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// doRequest performs an authenticated JSON HTTP request.
//
// - method: HTTP method (GET, POST, PATCH, DELETE)
// - url: full URL (base + path + query)
// - body: request body (marshalled to JSON), or nil for bodyless requests
// - token: Bearer token for Authorization header
// - out: pointer to unmarshal the response body into, or nil to discard
//
// Returns nil on 2xx; returns *AgentIdPError on HTTP errors or network failures.
// 204 No Content responses are considered success; out is not populated.
func doRequest(ctx context.Context, client *http.Client, method, url string, body interface{}, token string, out interface{}) error {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return &AgentIdPError{
Code: "SERIALIZATION_ERROR",
Message: fmt.Sprintf("failed to marshal request body: %s", err.Error()),
HTTPStatus: 0,
}
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return &AgentIdPError{
Code: "REQUEST_BUILD_ERROR",
Message: fmt.Sprintf("failed to build request: %s", err.Error()),
HTTPStatus: 0,
}
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
return newNetworkError(err)
}
defer resp.Body.Close() //nolint:errcheck
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return newNetworkError(err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return parseAPIError(respBody, resp.StatusCode)
}
if out != nil && resp.StatusCode != http.StatusNoContent {
if err := json.Unmarshal(respBody, out); err != nil {
return &AgentIdPError{
Code: "PARSE_ERROR",
Message: fmt.Sprintf("failed to parse response: %s", err.Error()),
HTTPStatus: resp.StatusCode,
}
}
}
return nil
}

129
sdk-go/token_manager.go Normal file
View File

@@ -0,0 +1,129 @@
package agentidp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const refreshBufferSeconds = 60
// cachedToken holds an access token and its expiry time.
type cachedToken struct {
accessToken string
expiresAt time.Time
}
// isValid returns true if the token will not expire within the refresh buffer.
func (c *cachedToken) isValid() bool {
return time.Now().Add(refreshBufferSeconds * time.Second).Before(c.expiresAt)
}
// TokenManager obtains and caches OAuth 2.0 client credentials tokens.
// It is safe for concurrent use by multiple goroutines.
type TokenManager struct {
baseURL string
clientID string
clientSecret string
scope string
httpClient *http.Client
mu sync.Mutex
cached *cachedToken
}
// NewTokenManager creates a TokenManager that fetches tokens from baseURL
// using the given client credentials and scope.
func NewTokenManager(baseURL, clientID, clientSecret, scope string) *TokenManager {
return &TokenManager{
baseURL: strings.TrimRight(baseURL, "/"),
clientID: clientID,
clientSecret: clientSecret,
scope: scope,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// GetToken returns a valid access token, fetching a new one if the cache is
// empty or the cached token is within the refresh buffer window.
// It is goroutine-safe.
func (tm *TokenManager) GetToken(ctx context.Context) (string, error) {
tm.mu.Lock()
defer tm.mu.Unlock()
if tm.cached != nil && tm.cached.isValid() {
return tm.cached.accessToken, nil
}
token, err := tm.fetchToken(ctx)
if err != nil {
return "", err
}
tm.cached = token
return token.accessToken, nil
}
// ClearCache invalidates the cached token. The next call to GetToken will
// fetch a fresh token from the server.
func (tm *TokenManager) ClearCache() {
tm.mu.Lock()
defer tm.mu.Unlock()
tm.cached = nil
}
// fetchToken performs the OAuth 2.0 client credentials grant.
// Must be called with mu held.
func (tm *TokenManager) fetchToken(ctx context.Context) (*cachedToken, error) {
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", tm.clientID)
form.Set("client_secret", tm.clientSecret)
form.Set("scope", tm.scope)
tokenURL := tm.baseURL + "/api/v1/token"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, bytes.NewBufferString(form.Encode()))
if err != nil {
return nil, &AgentIdPError{
Code: "REQUEST_BUILD_ERROR",
Message: fmt.Sprintf("failed to build token request: %s", err.Error()),
HTTPStatus: 0,
}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := tm.httpClient.Do(req)
if err != nil {
return nil, newNetworkError(err)
}
defer resp.Body.Close() //nolint:errcheck
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, newNetworkError(err)
}
if resp.StatusCode != http.StatusOK {
return nil, parseOAuth2Error(respBody, resp.StatusCode)
}
var tr TokenResponse
if err := json.Unmarshal(respBody, &tr); err != nil {
return nil, &AgentIdPError{
Code: "PARSE_ERROR",
Message: fmt.Sprintf("failed to parse token response: %s", err.Error()),
HTTPStatus: resp.StatusCode,
}
}
return &cachedToken{
accessToken: tr.AccessToken,
expiresAt: time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second),
}, nil
}

View File

@@ -0,0 +1,169 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func newTokenServer(t *testing.T, statusCode int, body interface{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/token" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(body)
}))
}
var tokenResp = map[string]interface{}{
"access_token": "eyJ.abc.def",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read",
}
func TestTokenManager_GetToken_Issues(t *testing.T) {
srv := newTokenServer(t, 200, tokenResp)
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
tok, err := tm.GetToken(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tok != "eyJ.abc.def" {
t.Errorf("expected token eyJ.abc.def, got %q", tok)
}
}
func TestTokenManager_GetToken_Caches(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tokenResp)
}))
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
_, _ = tm.GetToken(context.Background())
_, _ = tm.GetToken(context.Background())
if callCount != 1 {
t.Errorf("expected 1 HTTP call (cached), got %d", callCount)
}
}
func TestTokenManager_GetToken_RefreshesNearExpiry(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
resp := map[string]interface{}{
"access_token": "eyJ.abc.def",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
_, _ = tm.GetToken(context.Background())
// Force the cached token to appear nearly expired
tm.mu.Lock()
tm.cached = &cachedToken{
accessToken: "old-token",
expiresAt: time.Now().Add(30 * time.Second), // < refreshBufferSeconds
}
tm.mu.Unlock()
tok, err := tm.GetToken(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tok != "eyJ.abc.def" {
t.Errorf("expected refreshed token, got %q", tok)
}
if callCount != 2 {
t.Errorf("expected 2 HTTP calls (initial + refresh), got %d", callCount)
}
}
func TestTokenManager_GetToken_AuthFailure(t *testing.T) {
srv := newTokenServer(t, 401, map[string]interface{}{
"error": "invalid_client",
"error_description": "Bad credentials.",
})
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "bad-secret", "agents:read")
_, err := tm.GetToken(context.Background())
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.Code != "invalid_client" {
t.Errorf("expected code invalid_client, got %q", apiErr.Code)
}
if apiErr.HTTPStatus != 401 {
t.Errorf("expected HTTPStatus 401, got %d", apiErr.HTTPStatus)
}
}
func TestTokenManager_ClearCache(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tokenResp)
}))
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
_, _ = tm.GetToken(context.Background())
tm.ClearCache()
_, _ = tm.GetToken(context.Background())
if callCount != 2 {
t.Errorf("expected 2 HTTP calls (cache cleared), got %d", callCount)
}
}
func TestTokenManager_GoroutineSafe(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tokenResp)
}))
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
tok, err := tm.GetToken(context.Background())
if err != nil {
t.Errorf("goroutine error: %v", err)
}
if tok != "eyJ.abc.def" {
t.Errorf("unexpected token: %q", tok)
}
}()
}
wg.Wait()
}

103
sdk-go/token_service.go Normal file
View File

@@ -0,0 +1,103 @@
package agentidp
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
)
// TokenServiceClient provides token introspection and revocation.
// Token acquisition is handled separately by TokenManager.
type TokenServiceClient struct {
baseURL string
getToken func(ctx context.Context) (string, error)
httpClient *http.Client
}
func newTokenServiceClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *TokenServiceClient {
return &TokenServiceClient{
baseURL: strings.TrimRight(baseURL, "/"),
getToken: getToken,
httpClient: httpClient,
}
}
// IntrospectToken introspects an access token per RFC 7662.
// POST /api/v1/token/introspect (form-encoded) → 200 IntrospectResponse
func (c *TokenServiceClient) IntrospectToken(ctx context.Context, accessToken string) (*IntrospectResponse, error) {
bearerToken, err := c.getToken(ctx)
if err != nil {
return nil, err
}
form := url.Values{}
form.Set("token", accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/token/introspect", bytes.NewBufferString(form.Encode()))
if err != nil {
return nil, &AgentIdPError{Code: "REQUEST_BUILD_ERROR", Message: "failed to build introspect request: " + err.Error()}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+bearerToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, newNetworkError(err)
}
defer resp.Body.Close() //nolint:errcheck
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, newNetworkError(err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, parseAPIError(respBody, resp.StatusCode)
}
var result IntrospectResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, &AgentIdPError{Code: "PARSE_ERROR", Message: "failed to parse introspect response: " + err.Error(), HTTPStatus: resp.StatusCode}
}
return &result, nil
}
// RevokeToken revokes an access token.
// POST /api/v1/token/revoke (form-encoded) → 200
func (c *TokenServiceClient) RevokeToken(ctx context.Context, accessToken string) error {
bearerToken, err := c.getToken(ctx)
if err != nil {
return err
}
form := url.Values{}
form.Set("token", accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/token/revoke", bytes.NewBufferString(form.Encode()))
if err != nil {
return &AgentIdPError{Code: "REQUEST_BUILD_ERROR", Message: "failed to build revoke request: " + err.Error()}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+bearerToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return newNetworkError(err)
}
defer resp.Body.Close() //nolint:errcheck
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return newNetworkError(err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return parseAPIError(respBody, resp.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,108 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestTokenServiceClient_IntrospectToken_Active(t *testing.T) {
introspectResp := map[string]interface{}{
"active": true,
"sub": "uuid-1",
"exp": 9999999999,
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/token/introspect" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("expected form content-type, got %q", ct)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
if r.FormValue("token") == "" {
t.Error("missing 'token' form field")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(introspectResp)
}))
defer srv.Close()
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
result, err := client.IntrospectToken(context.Background(), "some-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Active {
t.Error("expected active=true")
}
if result.Sub == nil || *result.Sub != "uuid-1" {
t.Errorf("expected sub=uuid-1, got %v", result.Sub)
}
}
func TestTokenServiceClient_IntrospectToken_Inactive(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"active": false})
}))
defer srv.Close()
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
result, err := client.IntrospectToken(context.Background(), "expired-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Active {
t.Error("expected active=false")
}
}
func TestTokenServiceClient_RevokeToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/token/revoke" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("expected form content-type, got %q", ct)
}
w.WriteHeader(200)
_, _ = w.Write([]byte("{}"))
}))
defer srv.Close()
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
err := client.RevokeToken(context.Background(), "some-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTokenServiceClient_IntrospectToken_Error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
_ = json.NewEncoder(w).Encode(map[string]string{
"code": "UnauthorizedError",
"message": "Invalid token.",
})
}))
defer srv.Close()
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
_, err := client.IntrospectToken(context.Background(), "bad-token")
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.HTTPStatus != 401 {
t.Errorf("expected 401, got %d", apiErr.HTTPStatus)
}
}

131
sdk-go/types.go Normal file
View File

@@ -0,0 +1,131 @@
// Package agentidp provides a Go client for the SentryAgent.ai AgentIdP API.
// It covers all 14 endpoints across agent registry, credential management,
// OAuth 2.0 token operations, and audit log queries.
package agentidp
// Agent is a registered AI agent identity.
type Agent struct {
AgentID string `json:"agentId"`
Email string `json:"email"`
AgentType string `json:"agentType"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Owner string `json:"owner"`
DeploymentEnv string `json:"deploymentEnv"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// RegisterAgentRequest is the body for POST /api/v1/agents.
type RegisterAgentRequest struct {
Email string `json:"email"`
AgentType string `json:"agentType"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Owner string `json:"owner"`
DeploymentEnv string `json:"deploymentEnv"`
}
// UpdateAgentRequest is the body for PATCH /api/v1/agents/:id.
// All fields are optional — only non-nil pointer fields are sent.
type UpdateAgentRequest struct {
AgentType *string `json:"agentType,omitempty"`
Version *string `json:"version,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
Owner *string `json:"owner,omitempty"`
DeploymentEnv *string `json:"deploymentEnv,omitempty"`
Status *string `json:"status,omitempty"`
}
// PaginatedAgents is a paginated list of agents.
type PaginatedAgents struct {
Data []Agent `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// ListAgentsParams contains optional query parameters for ListAgents.
type ListAgentsParams struct {
Status string
AgentType string
DeploymentEnv string
Page int
Limit int
}
// Credential is a credential record (ClientSecret is never included).
type Credential struct {
CredentialID string `json:"credentialId"`
ClientID string `json:"clientId"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
ExpiresAt *string `json:"expiresAt"`
RevokedAt *string `json:"revokedAt"`
}
// CredentialWithSecret is a Credential with a one-time plaintext secret.
// Returned only on credential creation and rotation.
type CredentialWithSecret struct {
Credential
ClientSecret string `json:"clientSecret"`
}
// PaginatedCredentials is a paginated list of credentials.
type PaginatedCredentials struct {
Data []Credential `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// TokenResponse is the OAuth 2.0 access token response (RFC 6749).
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
// IntrospectResponse is the token introspection response (RFC 7662).
type IntrospectResponse struct {
Active bool `json:"active"`
Sub *string `json:"sub,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Scope *string `json:"scope,omitempty"`
TokenType *string `json:"token_type,omitempty"`
Iat *int64 `json:"iat,omitempty"`
Exp *int64 `json:"exp,omitempty"`
}
// AuditEvent is an immutable audit event record.
type AuditEvent struct {
EventID string `json:"eventId"`
AgentID string `json:"agentId"`
Action string `json:"action"`
Outcome string `json:"outcome"`
IPAddress string `json:"ipAddress"`
UserAgent string `json:"userAgent"`
Metadata map[string]interface{} `json:"metadata"`
Timestamp string `json:"timestamp"`
}
// PaginatedAuditEvents is a paginated list of audit events.
type PaginatedAuditEvents struct {
Data []AuditEvent `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// QueryAuditParams contains optional query parameters for QueryAuditLog.
type QueryAuditParams struct {
AgentID string
Action string
Outcome string
FromDate string
ToDate string
Page int
Limit int
}

1
sdk-java/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

190
sdk-java/README.md Normal file
View File

@@ -0,0 +1,190 @@
# SentryAgent.ai AgentIdP — Java SDK
Official Java client for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — an open-source Identity Provider for AI agents built on OAuth 2.0 (RFC 6749) and aligned with the [AGNTCY](https://agntcy.org) open standard.
## Requirements
- Java 17+
- A running AgentIdP server
## Installation
### Maven
```xml
<dependency>
<groupId>ai.sentryagent</groupId>
<artifactId>idp-sdk</artifactId>
<version>1.0.0</version>
</dependency>
```
## Quick Start
```java
import ai.sentryagent.idp.AgentIdPClient;
import ai.sentryagent.idp.models.*;
AgentIdPClient client = new AgentIdPClient(
"https://idp.example.com",
"your-agent-client-id",
"sk_live_..."
);
// Register a new AI agent
Agent agent = client.agents().registerAgent(
RegisterAgentRequest.builder()
.email("screener@example.com")
.agentType("screener")
.version("1.0.0")
.capabilities(List.of("read", "classify"))
.owner("platform-team")
.deploymentEnv("production")
.build()
);
System.out.println("Registered: " + agent.getAgentId());
```
## Authentication
OAuth 2.0 Client Credentials are managed automatically. Tokens are cached and refreshed 60 seconds before expiry. The `TokenManager` is thread-safe.
```java
// Custom scope (optional — defaults to all four scopes)
AgentIdPClient client = new AgentIdPClient(
"https://idp.example.com",
"my-client-id",
"my-client-secret",
"agents:read agents:write"
);
```
## Agent Registry
```java
// Register
Agent agent = client.agents().registerAgent(
RegisterAgentRequest.builder()
.email("...").agentType("screener").version("1.0.0")
.capabilities(List.of("read")).owner("team").deploymentEnv("production")
.build());
// List (with optional filters)
PaginatedAgents agents = client.agents().listAgents(
ListAgentsParams.builder().status("active").page(1).limit(20).build());
// Get by ID
Agent agent = client.agents().getAgent("agent-uuid");
// Partial update
Agent updated = client.agents().updateAgent("agent-uuid",
UpdateAgentRequest.builder().version("2.0.0").build());
// Decommission (permanent)
client.agents().decommissionAgent("agent-uuid");
```
## Credential Management
```java
// Generate (returns one-time ClientSecret)
CredentialWithSecret cred = client.credentials().generateCredential("agent-uuid");
System.out.println(cred.getClientSecret()); // store this — shown only once
// List
PaginatedCredentials creds = client.credentials().listCredentials("agent-uuid", 1, 20);
// Rotate
CredentialWithSecret newCred = client.credentials().rotateCredential("agent-uuid", "cred-uuid");
// Revoke
Credential revoked = client.credentials().revokeCredential("agent-uuid", "cred-uuid");
```
## Token Operations
```java
// Introspect (RFC 7662)
IntrospectResponse result = client.tokens().introspectToken("access-token-to-check");
if (result.isActive()) {
System.out.println("Token belongs to: " + result.getSub());
}
// Revoke
client.tokens().revokeToken("access-token-to-revoke");
```
## Audit Log
```java
// Query with filters
PaginatedAuditEvents events = client.audit().queryAuditLog(
QueryAuditParams.builder()
.agentId("agent-uuid")
.action("token.issued")
.outcome("success")
.fromDate("2026-01-01")
.toDate("2026-01-31")
.page(1).limit(50)
.build());
// Get single event
AuditEvent event = client.audit().getAuditEvent("event-uuid");
```
## Async Methods
Every sync method has an async counterpart returning `CompletableFuture<T>`:
```java
CompletableFuture<Agent> future = client.agents().getAgentAsync("uuid-1");
future.thenAccept(agent -> System.out.println(agent.getAgentId()));
// Compose multiple async calls
client.agents().getAgentAsync("uuid-1")
.thenCompose(agent -> client.credentials().generateCredentialAsync(agent.getAgentId()))
.thenAccept(cred -> System.out.println("New secret: " + cred.getClientSecret()));
```
## Error Handling
All errors are thrown as `AgentIdPException` (extends `RuntimeException`):
```java
try {
Agent agent = client.agents().getAgent("unknown-id");
} catch (AgentIdPException ex) {
System.out.printf("code=%s status=%d%n", ex.getCode(), ex.getHttpStatus());
// e.g. code=AgentNotFoundError status=404
}
```
| Method | Type | Description |
|------------------|--------------------------|-------------------------------------------------|
| `getCode()` | `String` | Machine-readable error code |
| `getMessage()` | `String` | Human-readable description |
| `getHttpStatus()`| `int` | HTTP status code (0 for network/build errors) |
| `getDetails()` | `Map<String, Object>` | Optional structured context from the API |
## API Coverage
| Endpoint | Method | SDK Method |
|--------------------------------------------------|--------|-----------------------------------------|
| POST /api/v1/agents | POST | `agents().registerAgent()` |
| GET /api/v1/agents | GET | `agents().listAgents()` |
| GET /api/v1/agents/:id | GET | `agents().getAgent()` |
| PATCH /api/v1/agents/:id | PATCH | `agents().updateAgent()` |
| DELETE /api/v1/agents/:id | DELETE | `agents().decommissionAgent()` |
| POST /api/v1/agents/:id/credentials | POST | `credentials().generateCredential()` |
| GET /api/v1/agents/:id/credentials | GET | `credentials().listCredentials()` |
| POST /api/v1/agents/:id/credentials/:cid/rotate | POST | `credentials().rotateCredential()` |
| DELETE /api/v1/agents/:id/credentials/:cid | DELETE | `credentials().revokeCredential()` |
| POST /api/v1/token | POST | (TokenManager — automatic) |
| POST /api/v1/token/introspect | POST | `tokens().introspectToken()` |
| POST /api/v1/token/revoke | POST | `tokens().revokeToken()` |
| GET /api/v1/audit | GET | `audit().queryAuditLog()` |
| GET /api/v1/audit/:id | GET | `audit().getAuditEvent()` |
## License
Apache 2.0 — see [LICENSE](../LICENSE).

100
sdk-java/pom.xml Normal file
View File

@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ai.sentryagent</groupId>
<artifactId>idp-sdk</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>SentryAgent.ai AgentIdP Java SDK</name>
<description>Java client for the SentryAgent.ai AgentIdP API</description>
<properties>
<java.version>17</java.version>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jackson.version>2.17.0</jackson.version>
<junit.version>5.10.2</junit.version>
<jacoco.version>0.8.11</jacoco.version>
</properties>
<dependencies>
<!-- JSON serialization/deserialization -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>${java.version}</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<useModulePath>false</useModulePath>
</configuration>
</plugin>
<!-- JaCoCo coverage gate: >80% instruction coverage required -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<execution>
<id>check</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,88 @@
package ai.sentryagent.idp;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.services.*;
import java.net.http.HttpClient;
import java.time.Duration;
/**
* Top-level client for the SentryAgent.ai AgentIdP API.
* Composes all four service clients and manages token acquisition automatically.
*
* <pre>{@code
* AgentIdPClient client = new AgentIdPClient(
* "https://idp.example.com",
* "my-client-id",
* "sk_live_...",
* "agents:read agents:write tokens:read audit:read"
* );
*
* Agent agent = client.agents().getAgent("uuid-1");
* }</pre>
*/
public final class AgentIdPClient {
private static final String DEFAULT_SCOPE = "agents:read agents:write tokens:read audit:read";
private final TokenManager tokenManager;
private final AgentRegistryClient agentsClient;
private final CredentialClient credentialsClient;
private final TokenClient tokensClient;
private final AuditClient auditClient;
/**
* Creates a new AgentIdPClient with default scope and a shared HttpClient.
*
* @param baseUrl Root URL of the AgentIdP server (e.g. {@code "https://idp.example.com"})
* @param clientId OAuth 2.0 client ID
* @param clientSecret OAuth 2.0 client secret
*/
public AgentIdPClient(String baseUrl, String clientId, String clientSecret) {
this(baseUrl, clientId, clientSecret, DEFAULT_SCOPE);
}
/**
* Creates a new AgentIdPClient with a custom scope.
*
* @param baseUrl Root URL of the AgentIdP server
* @param clientId OAuth 2.0 client ID
* @param clientSecret OAuth 2.0 client secret
* @param scope Space-separated OAuth 2.0 scopes to request
*/
public AgentIdPClient(String baseUrl, String clientId, String clientSecret, String scope) {
this(baseUrl, clientId, clientSecret, scope,
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build());
}
/**
* Package-visible constructor that accepts a custom HttpClient (for testing).
*/
AgentIdPClient(String baseUrl, String clientId, String clientSecret, String scope, HttpClient httpClient) {
String base = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.tokenManager = new TokenManager(base, clientId, clientSecret, scope, httpClient);
HttpHelper httpHelper = new HttpHelper(httpClient);
this.agentsClient = new AgentRegistryClient(base, tokenManager::getToken, httpHelper);
this.credentialsClient = new CredentialClient(base, tokenManager::getToken, httpHelper);
this.tokensClient = new TokenClient(base, tokenManager::getToken, httpClient);
this.auditClient = new AuditClient(base, tokenManager::getToken, httpHelper);
}
/** Returns the Agent Registry service client. */
public AgentRegistryClient agents() { return agentsClient; }
/** Returns the Credential Management service client. */
public CredentialClient credentials() { return credentialsClient; }
/** Returns the Token service client (introspect + revoke). */
public TokenClient tokens() { return tokensClient; }
/** Returns the Audit Log service client. */
public AuditClient audit() { return auditClient; }
/** Invalidates the cached access token. The next API call will fetch a fresh one. */
public void clearTokenCache() { tokenManager.clearCache(); }
}

View File

@@ -0,0 +1,82 @@
package ai.sentryagent.idp;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
/**
* Thrown for all API and network-level failures.
* Extends RuntimeException — callers may catch if needed but are not required to.
*/
public final class AgentIdPException extends RuntimeException {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final String code;
private final int httpStatus;
private final Map<String, Object> details;
public AgentIdPException(String code, String message, int httpStatus, Map<String, Object> details, Throwable cause) {
super(message, cause);
this.code = code;
this.httpStatus = httpStatus;
this.details = details;
}
public AgentIdPException(String code, String message, int httpStatus) {
this(code, message, httpStatus, null, null);
}
/** Machine-readable error code (e.g. {@code "AgentNotFoundError"}). */
public String getCode() { return code; }
/** HTTP response status code, or 0 for network/build errors. */
public int getHttpStatus() { return httpStatus; }
/** Optional structured context from the API response. */
public Map<String, Object> getDetails() { return details; }
// ─── Factory methods ──────────────────────────────────────────────────────
/**
* Creates an AgentIdPException from a raw JSON API error response body.
* Falls back to UNKNOWN_ERROR if the body cannot be parsed.
*/
public static AgentIdPException fromApiError(String responseBody, int httpStatus) {
try {
JsonNode node = MAPPER.readTree(responseBody);
String code = node.path("code").asText("UNKNOWN_ERROR");
String message = node.path("message").asText("Unexpected HTTP " + httpStatus);
if (code.isEmpty()) code = "UNKNOWN_ERROR";
return new AgentIdPException(code, message, httpStatus);
} catch (Exception e) {
return new AgentIdPException("UNKNOWN_ERROR", "Unexpected HTTP " + httpStatus, httpStatus);
}
}
/**
* Creates an AgentIdPException from an OAuth 2.0 error response body.
* Falls back to unknown_error if the body cannot be parsed.
*/
public static AgentIdPException fromOAuth2Error(String responseBody, int httpStatus) {
try {
JsonNode node = MAPPER.readTree(responseBody);
String code = node.path("error").asText("unknown_error");
String message = node.path("error_description").asText("Unexpected HTTP " + httpStatus);
if (code.isEmpty()) code = "unknown_error";
return new AgentIdPException(code, message, httpStatus);
} catch (Exception e) {
return new AgentIdPException("unknown_error", "Unexpected HTTP " + httpStatus, httpStatus);
}
}
/** Creates an AgentIdPException wrapping a transport-level failure. */
public static AgentIdPException networkError(Throwable cause) {
return new AgentIdPException("NETWORK_ERROR", "Network error: " + cause.getMessage(), 0, null, cause);
}
@Override
public String toString() {
return "AgentIdPException{code='" + code + "', httpStatus=" + httpStatus + ", message='" + getMessage() + "'}";
}
}

View File

@@ -0,0 +1,101 @@
package ai.sentryagent.idp;
import ai.sentryagent.idp.models.TokenResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
/**
* Obtains and caches OAuth 2.0 client credentials tokens.
* Thread-safe: all cache access is synchronized.
* Tokens are refreshed 60 seconds before they expire.
*/
public final class TokenManager {
private static final int REFRESH_BUFFER_SECONDS = 60;
private static final ObjectMapper MAPPER = new ObjectMapper();
private final String baseUrl;
private final String clientId;
private final String clientSecret;
private final String scope;
private final HttpClient httpClient;
private String cachedToken;
private Instant tokenExpiresAt;
public TokenManager(String baseUrl, String clientId, String clientSecret, String scope) {
this(baseUrl, clientId, clientSecret, scope,
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build());
}
/** Package-visible constructor for injecting a custom HttpClient in tests. */
TokenManager(String baseUrl, String clientId, String clientSecret, String scope, HttpClient httpClient) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scope = scope;
this.httpClient = httpClient;
}
/**
* Returns a valid access token, fetching a new one if the cache is empty
* or within the 60-second refresh buffer.
*/
public synchronized String getToken() {
if (cachedToken != null && tokenExpiresAt != null
&& Instant.now().plusSeconds(REFRESH_BUFFER_SECONDS).isBefore(tokenExpiresAt)) {
return cachedToken;
}
TokenResponse tr = fetchToken();
cachedToken = tr.getAccessToken();
tokenExpiresAt = Instant.now().plusSeconds(tr.getExpiresIn());
return cachedToken;
}
/** Invalidates the cached token. The next call to {@link #getToken()} fetches a fresh one. */
public synchronized void clearCache() {
cachedToken = null;
tokenExpiresAt = null;
}
private TokenResponse fetchToken() {
String form = "grant_type=client_credentials"
+ "&client_id=" + encode(clientId)
+ "&client_secret=" + encode(clientSecret)
+ "&scope=" + encode(scope);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/v1/token"))
.POST(HttpRequest.BodyPublishers.ofString(form))
.header("Content-Type", "application/x-www-form-urlencoded")
.build();
try {
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw AgentIdPException.fromOAuth2Error(resp.body(), resp.statusCode());
}
return MAPPER.readValue(resp.body(), TokenResponse.class);
} catch (AgentIdPException e) {
throw e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw AgentIdPException.networkError(e);
} catch (IOException e) {
throw AgentIdPException.networkError(e);
}
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,97 @@
package ai.sentryagent.idp.internal;
import ai.sentryagent.idp.AgentIdPException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CompletableFuture;
/**
* Shared HTTP helper for all service clients.
* Handles JSON serialization, Authorization header injection, and error mapping.
*/
public final class HttpHelper {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final HttpClient httpClient;
public HttpHelper(HttpClient httpClient) {
this.httpClient = httpClient;
}
/**
* Performs a synchronous JSON request and unmarshals the response into {@code responseType}.
* Returns null for 204 No Content responses.
*
* @throws AgentIdPException on HTTP errors or network failures
*/
public <T> T request(String method, String url, Object body, String token, Class<T> responseType) {
try {
HttpRequest req = buildRequest(method, url, body, token);
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
return handleResponse(resp, responseType);
} catch (AgentIdPException e) {
throw e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw AgentIdPException.networkError(e);
} catch (IOException e) {
throw AgentIdPException.networkError(e);
}
}
/**
* Performs an asynchronous JSON request and returns a CompletableFuture.
*
* @throws AgentIdPException (wrapped in CompletableFuture) on HTTP errors
*/
public <T> CompletableFuture<T> requestAsync(String method, String url, Object body, String token, Class<T> responseType) {
try {
HttpRequest req = buildRequest(method, url, body, token);
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(resp -> handleResponse(resp, responseType));
} catch (Exception e) {
return CompletableFuture.failedFuture(AgentIdPException.networkError(e));
}
}
private HttpRequest buildRequest(String method, String url, Object body, String token) throws IOException {
HttpRequest.BodyPublisher publisher = body != null
? HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body))
: HttpRequest.BodyPublishers.noBody();
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.method(method, publisher)
.header("Accept", "application/json");
if (body != null) {
builder.header("Content-Type", "application/json");
}
if (token != null && !token.isEmpty()) {
builder.header("Authorization", "Bearer " + token);
}
return builder.build();
}
private <T> T handleResponse(HttpResponse<String> resp, Class<T> responseType) {
int status = resp.statusCode();
if (status < 200 || status >= 300) {
throw AgentIdPException.fromApiError(resp.body(), status);
}
if (status == 204 || responseType == Void.class) {
return null;
}
try {
return MAPPER.readValue(resp.body(), responseType);
} catch (IOException e) {
throw new AgentIdPException("PARSE_ERROR", "Failed to parse response: " + e.getMessage(), status);
}
}
}

View File

@@ -0,0 +1,39 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/** A registered AI agent identity. */
@JsonIgnoreProperties(ignoreUnknown = true)
public final class Agent {
@JsonProperty("agentId") private String agentId;
@JsonProperty("email") private String email;
@JsonProperty("agentType") private String agentType;
@JsonProperty("version") private String version;
@JsonProperty("capabilities") private java.util.List<String> capabilities;
@JsonProperty("owner") private String owner;
@JsonProperty("deploymentEnv") private String deploymentEnv;
@JsonProperty("status") private String status;
@JsonProperty("createdAt") private String createdAt;
@JsonProperty("updatedAt") private String updatedAt;
/** Required by Jackson. */
public Agent() {}
public String getAgentId() { return agentId; }
public String getEmail() { return email; }
public String getAgentType() { return agentType; }
public String getVersion() { return version; }
public java.util.List<String> getCapabilities() { return capabilities; }
public String getOwner() { return owner; }
public String getDeploymentEnv() { return deploymentEnv; }
public String getStatus() { return status; }
public String getCreatedAt() { return createdAt; }
public String getUpdatedAt() { return updatedAt; }
@Override
public String toString() {
return "Agent{agentId='" + agentId + "', email='" + email + "', status='" + status + "'}";
}
}

View File

@@ -0,0 +1,35 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
/** An immutable audit event record. */
@JsonIgnoreProperties(ignoreUnknown = true)
public final class AuditEvent {
@JsonProperty("eventId") private String eventId;
@JsonProperty("agentId") private String agentId;
@JsonProperty("action") private String action;
@JsonProperty("outcome") private String outcome;
@JsonProperty("ipAddress") private String ipAddress;
@JsonProperty("userAgent") private String userAgent;
@JsonProperty("metadata") private Map<String, Object> metadata;
@JsonProperty("timestamp") private String timestamp;
public AuditEvent() {}
public String getEventId() { return eventId; }
public String getAgentId() { return agentId; }
public String getAction() { return action; }
public String getOutcome() { return outcome; }
public String getIpAddress() { return ipAddress; }
public String getUserAgent() { return userAgent; }
public Map<String, Object> getMetadata() { return metadata; }
public String getTimestamp() { return timestamp; }
@Override
public String toString() {
return "AuditEvent{eventId='" + eventId + "', action='" + action + "', outcome='" + outcome + "'}";
}
}

View File

@@ -0,0 +1,30 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/** A credential record (clientSecret is never included). */
@JsonIgnoreProperties(ignoreUnknown = true)
public class Credential {
@JsonProperty("credentialId") protected String credentialId;
@JsonProperty("clientId") protected String clientId;
@JsonProperty("status") protected String status;
@JsonProperty("createdAt") protected String createdAt;
@JsonProperty("expiresAt") protected String expiresAt;
@JsonProperty("revokedAt") protected String revokedAt;
public Credential() {}
public String getCredentialId() { return credentialId; }
public String getClientId() { return clientId; }
public String getStatus() { return status; }
public String getCreatedAt() { return createdAt; }
public String getExpiresAt() { return expiresAt; }
public String getRevokedAt() { return revokedAt; }
@Override
public String toString() {
return "Credential{credentialId='" + credentialId + "', status='" + status + "'}";
}
}

View File

@@ -0,0 +1,19 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Credential with a one-time plaintext clientSecret.
* Returned only on credential creation and rotation.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public final class CredentialWithSecret extends Credential {
@JsonProperty("clientSecret") private String clientSecret;
public CredentialWithSecret() {}
/** The one-time plaintext secret. Store it securely; it is never shown again. */
public String getClientSecret() { return clientSecret; }
}

View File

@@ -0,0 +1,27 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/** Token introspection response (RFC 7662). */
@JsonIgnoreProperties(ignoreUnknown = true)
public final class IntrospectResponse {
@JsonProperty("active") private boolean active;
@JsonProperty("sub") private String sub;
@JsonProperty("client_id") private String clientId;
@JsonProperty("scope") private String scope;
@JsonProperty("token_type") private String tokenType;
@JsonProperty("iat") private Long iat;
@JsonProperty("exp") private Long exp;
public IntrospectResponse() {}
public boolean isActive() { return active; }
public String getSub() { return sub; }
public String getClientId() { return clientId; }
public String getScope() { return scope; }
public String getTokenType() { return tokenType; }
public Long getIat() { return iat; }
public Long getExp() { return exp; }
}

View File

@@ -0,0 +1,42 @@
package ai.sentryagent.idp.models;
/** Optional query parameters for listing agents. */
public final class ListAgentsParams {
private final String status;
private final String agentType;
private final String deploymentEnv;
private final Integer page;
private final Integer limit;
private ListAgentsParams(Builder b) {
this.status = b.status;
this.agentType = b.agentType;
this.deploymentEnv = b.deploymentEnv;
this.page = b.page;
this.limit = b.limit;
}
public String getStatus() { return status; }
public String getAgentType() { return agentType; }
public String getDeploymentEnv() { return deploymentEnv; }
public Integer getPage() { return page; }
public Integer getLimit() { return limit; }
public static Builder builder() { return new Builder(); }
public static final class Builder {
private String status;
private String agentType;
private String deploymentEnv;
private Integer page;
private Integer limit;
public Builder status(String status) { this.status = status; return this; }
public Builder agentType(String agentType) { this.agentType = agentType; return this; }
public Builder deploymentEnv(String env) { this.deploymentEnv = env; return this; }
public Builder page(int page) { this.page = page; return this; }
public Builder limit(int limit) { this.limit = limit; return this; }
public ListAgentsParams build() { return new ListAgentsParams(this); }
}
}

View File

@@ -0,0 +1,22 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/** Paginated list of agents. */
@JsonIgnoreProperties(ignoreUnknown = true)
public final class PaginatedAgents {
@JsonProperty("data") private List<Agent> data;
@JsonProperty("total") private int total;
@JsonProperty("page") private int page;
@JsonProperty("limit") private int limit;
public PaginatedAgents() {}
public List<Agent> getData() { return data; }
public int getTotal() { return total; }
public int getPage() { return page; }
public int getLimit() { return limit; }
}

View File

@@ -0,0 +1,22 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/** Paginated list of audit events. */
@JsonIgnoreProperties(ignoreUnknown = true)
public final class PaginatedAuditEvents {
@JsonProperty("data") private List<AuditEvent> data;
@JsonProperty("total") private int total;
@JsonProperty("page") private int page;
@JsonProperty("limit") private int limit;
public PaginatedAuditEvents() {}
public List<AuditEvent> getData() { return data; }
public int getTotal() { return total; }
public int getPage() { return page; }
public int getLimit() { return limit; }
}

View File

@@ -0,0 +1,22 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/** Paginated list of credentials. */
@JsonIgnoreProperties(ignoreUnknown = true)
public final class PaginatedCredentials {
@JsonProperty("data") private List<Credential> data;
@JsonProperty("total") private int total;
@JsonProperty("page") private int page;
@JsonProperty("limit") private int limit;
public PaginatedCredentials() {}
public List<Credential> getData() { return data; }
public int getTotal() { return total; }
public int getPage() { return page; }
public int getLimit() { return limit; }
}

View File

@@ -0,0 +1,52 @@
package ai.sentryagent.idp.models;
/** Optional query parameters for querying the audit log. */
public final class QueryAuditParams {
private final String agentId;
private final String action;
private final String outcome;
private final String fromDate;
private final String toDate;
private final Integer page;
private final Integer limit;
private QueryAuditParams(Builder b) {
this.agentId = b.agentId;
this.action = b.action;
this.outcome = b.outcome;
this.fromDate = b.fromDate;
this.toDate = b.toDate;
this.page = b.page;
this.limit = b.limit;
}
public String getAgentId() { return agentId; }
public String getAction() { return action; }
public String getOutcome() { return outcome; }
public String getFromDate() { return fromDate; }
public String getToDate() { return toDate; }
public Integer getPage() { return page; }
public Integer getLimit() { return limit; }
public static Builder builder() { return new Builder(); }
public static final class Builder {
private String agentId;
private String action;
private String outcome;
private String fromDate;
private String toDate;
private Integer page;
private Integer limit;
public Builder agentId(String agentId) { this.agentId = agentId; return this; }
public Builder action(String action) { this.action = action; return this; }
public Builder outcome(String outcome) { this.outcome = outcome; return this; }
public Builder fromDate(String from) { this.fromDate = from; return this; }
public Builder toDate(String to) { this.toDate = to; return this; }
public Builder page(int page) { this.page = page; return this; }
public Builder limit(int limit) { this.limit = limit; return this; }
public QueryAuditParams build() { return new QueryAuditParams(this); }
}
}

View File

@@ -0,0 +1,51 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/** Request body for POST /api/v1/agents. */
public final class RegisterAgentRequest {
@JsonProperty("email") private final String email;
@JsonProperty("agentType") private final String agentType;
@JsonProperty("version") private final String version;
@JsonProperty("capabilities") private final List<String> capabilities;
@JsonProperty("owner") private final String owner;
@JsonProperty("deploymentEnv") private final String deploymentEnv;
private RegisterAgentRequest(Builder b) {
this.email = b.email;
this.agentType = b.agentType;
this.version = b.version;
this.capabilities = b.capabilities;
this.owner = b.owner;
this.deploymentEnv = b.deploymentEnv;
}
public String getEmail() { return email; }
public String getAgentType() { return agentType; }
public String getVersion() { return version; }
public List<String> getCapabilities() { return capabilities; }
public String getOwner() { return owner; }
public String getDeploymentEnv() { return deploymentEnv; }
public static Builder builder() { return new Builder(); }
public static final class Builder {
private String email;
private String agentType;
private String version;
private List<String> capabilities;
private String owner;
private String deploymentEnv;
public Builder email(String email) { this.email = email; return this; }
public Builder agentType(String agentType) { this.agentType = agentType; return this; }
public Builder version(String version) { this.version = version; return this; }
public Builder capabilities(List<String> capabilities) { this.capabilities = capabilities; return this; }
public Builder owner(String owner) { this.owner = owner; return this; }
public Builder deploymentEnv(String deploymentEnv) { this.deploymentEnv = deploymentEnv; return this; }
public RegisterAgentRequest build() { return new RegisterAgentRequest(this); }
}
}

View File

@@ -0,0 +1,21 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/** OAuth 2.0 access token response (RFC 6749). */
@JsonIgnoreProperties(ignoreUnknown = true)
public final class TokenResponse {
@JsonProperty("access_token") private String accessToken;
@JsonProperty("token_type") private String tokenType;
@JsonProperty("expires_in") private int expiresIn;
@JsonProperty("scope") private String scope;
public TokenResponse() {}
public String getAccessToken() { return accessToken; }
public String getTokenType() { return tokenType; }
public int getExpiresIn() { return expiresIn; }
public String getScope() { return scope; }
}

View File

@@ -0,0 +1,56 @@
package ai.sentryagent.idp.models;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Request body for PATCH /api/v1/agents/:id.
* All fields are optional — null fields are omitted from the JSON body.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public final class UpdateAgentRequest {
@JsonProperty("agentType") private final String agentType;
@JsonProperty("version") private final String version;
@JsonProperty("capabilities") private final List<String> capabilities;
@JsonProperty("owner") private final String owner;
@JsonProperty("deploymentEnv") private final String deploymentEnv;
@JsonProperty("status") private final String status;
private UpdateAgentRequest(Builder b) {
this.agentType = b.agentType;
this.version = b.version;
this.capabilities = b.capabilities;
this.owner = b.owner;
this.deploymentEnv = b.deploymentEnv;
this.status = b.status;
}
public String getAgentType() { return agentType; }
public String getVersion() { return version; }
public List<String> getCapabilities() { return capabilities; }
public String getOwner() { return owner; }
public String getDeploymentEnv() { return deploymentEnv; }
public String getStatus() { return status; }
public static Builder builder() { return new Builder(); }
public static final class Builder {
private String agentType;
private String version;
private List<String> capabilities;
private String owner;
private String deploymentEnv;
private String status;
public Builder agentType(String agentType) { this.agentType = agentType; return this; }
public Builder version(String version) { this.version = version; return this; }
public Builder capabilities(List<String> capabilities) { this.capabilities = capabilities; return this; }
public Builder owner(String owner) { this.owner = owner; return this; }
public Builder deploymentEnv(String deploymentEnv) { this.deploymentEnv = deploymentEnv; return this; }
public Builder status(String status) { this.status = status; return this; }
public UpdateAgentRequest build() { return new UpdateAgentRequest(this); }
}
}

View File

@@ -0,0 +1,105 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.models.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
/**
* Client for the Agent Registry API endpoints.
* Provides both synchronous and asynchronous (CompletableFuture) methods.
*/
public final class AgentRegistryClient {
private final String baseUrl;
private final Supplier<String> tokenSupplier;
private final HttpHelper http;
public AgentRegistryClient(String baseUrl, Supplier<String> tokenSupplier, HttpHelper http) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.tokenSupplier = tokenSupplier;
this.http = http;
}
// ─── Sync ─────────────────────────────────────────────────────────────────
/** POST /api/v1/agents → 201 Agent */
public Agent registerAgent(RegisterAgentRequest request) {
return http.request("POST", baseUrl + "/api/v1/agents", request, tokenSupplier.get(), Agent.class);
}
/** GET /api/v1/agents → 200 PaginatedAgents */
public PaginatedAgents listAgents(ListAgentsParams params) {
return http.request("GET", buildListUrl(params), null, tokenSupplier.get(), PaginatedAgents.class);
}
/** GET /api/v1/agents/:id → 200 Agent */
public Agent getAgent(String agentId) {
return http.request("GET", baseUrl + "/api/v1/agents/" + agentId, null, tokenSupplier.get(), Agent.class);
}
/** PATCH /api/v1/agents/:id → 200 Agent */
public Agent updateAgent(String agentId, UpdateAgentRequest request) {
return http.request("PATCH", baseUrl + "/api/v1/agents/" + agentId, request, tokenSupplier.get(), Agent.class);
}
/** DELETE /api/v1/agents/:id → 204 No Content */
public void decommissionAgent(String agentId) {
http.request("DELETE", baseUrl + "/api/v1/agents/" + agentId, null, tokenSupplier.get(), Void.class);
}
// ─── Async ────────────────────────────────────────────────────────────────
/** Async version of {@link #registerAgent}. */
public CompletableFuture<Agent> registerAgentAsync(RegisterAgentRequest request) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("POST", baseUrl + "/api/v1/agents", request, token, Agent.class));
}
/** Async version of {@link #listAgents}. */
public CompletableFuture<PaginatedAgents> listAgentsAsync(ListAgentsParams params) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("GET", buildListUrl(params), null, token, PaginatedAgents.class));
}
/** Async version of {@link #getAgent}. */
public CompletableFuture<Agent> getAgentAsync(String agentId) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("GET", baseUrl + "/api/v1/agents/" + agentId, null, token, Agent.class));
}
/** Async version of {@link #updateAgent}. */
public CompletableFuture<Agent> updateAgentAsync(String agentId, UpdateAgentRequest request) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("PATCH", baseUrl + "/api/v1/agents/" + agentId, request, token, Agent.class));
}
/** Async version of {@link #decommissionAgent}. */
public CompletableFuture<Void> decommissionAgentAsync(String agentId) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("DELETE", baseUrl + "/api/v1/agents/" + agentId, null, token, Void.class));
}
// ─── URL builder ──────────────────────────────────────────────────────────
private String buildListUrl(ListAgentsParams params) {
StringBuilder url = new StringBuilder(baseUrl + "/api/v1/agents");
if (params != null) {
StringBuilder query = new StringBuilder();
appendParam(query, "status", params.getStatus());
appendParam(query, "agentType", params.getAgentType());
appendParam(query, "deploymentEnv", params.getDeploymentEnv());
if (params.getPage() != null) appendParam(query, "page", params.getPage().toString());
if (params.getLimit() != null) appendParam(query, "limit", params.getLimit().toString());
if (query.length() > 0) url.append("?").append(query.substring(1)); // trim leading &
}
return url.toString();
}
private static void appendParam(StringBuilder sb, String key, String value) {
if (value != null && !value.isEmpty()) {
sb.append("&").append(key).append("=").append(value);
}
}
}

View File

@@ -0,0 +1,76 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.models.AuditEvent;
import ai.sentryagent.idp.models.PaginatedAuditEvents;
import ai.sentryagent.idp.models.QueryAuditParams;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
/**
* Client for the Audit Log API endpoints.
* Provides both synchronous and asynchronous (CompletableFuture) methods.
*/
public final class AuditClient {
private final String baseUrl;
private final Supplier<String> tokenSupplier;
private final HttpHelper http;
public AuditClient(String baseUrl, Supplier<String> tokenSupplier, HttpHelper http) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.tokenSupplier = tokenSupplier;
this.http = http;
}
// ─── Sync ─────────────────────────────────────────────────────────────────
/** GET /api/v1/audit → 200 PaginatedAuditEvents */
public PaginatedAuditEvents queryAuditLog(QueryAuditParams params) {
return http.request("GET", buildQueryUrl(params), null, tokenSupplier.get(), PaginatedAuditEvents.class);
}
/** GET /api/v1/audit/:id → 200 AuditEvent */
public AuditEvent getAuditEvent(String eventId) {
return http.request("GET", baseUrl + "/api/v1/audit/" + eventId, null, tokenSupplier.get(), AuditEvent.class);
}
// ─── Async ────────────────────────────────────────────────────────────────
/** Async version of {@link #queryAuditLog}. */
public CompletableFuture<PaginatedAuditEvents> queryAuditLogAsync(QueryAuditParams params) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("GET", buildQueryUrl(params), null, token, PaginatedAuditEvents.class));
}
/** Async version of {@link #getAuditEvent}. */
public CompletableFuture<AuditEvent> getAuditEventAsync(String eventId) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("GET", baseUrl + "/api/v1/audit/" + eventId, null, token, AuditEvent.class));
}
// ─── URL builder ──────────────────────────────────────────────────────────
private String buildQueryUrl(QueryAuditParams params) {
StringBuilder url = new StringBuilder(baseUrl + "/api/v1/audit");
StringBuilder query = new StringBuilder();
if (params != null) {
appendParam(query, "agentId", params.getAgentId());
appendParam(query, "action", params.getAction());
appendParam(query, "outcome", params.getOutcome());
appendParam(query, "fromDate", params.getFromDate());
appendParam(query, "toDate", params.getToDate());
if (params.getPage() != null) appendParam(query, "page", params.getPage().toString());
if (params.getLimit() != null) appendParam(query, "limit", params.getLimit().toString());
}
if (query.length() > 0) url.append("?").append(query.substring(1));
return url.toString();
}
private static void appendParam(StringBuilder sb, String key, String value) {
if (value != null && !value.isEmpty()) {
sb.append("&").append(key).append("=").append(value);
}
}
}

View File

@@ -0,0 +1,94 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.models.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
/**
* Client for the Credential Management API endpoints.
* Provides both synchronous and asynchronous (CompletableFuture) methods.
*/
public final class CredentialClient {
private final String baseUrl;
private final Supplier<String> tokenSupplier;
private final HttpHelper http;
public CredentialClient(String baseUrl, Supplier<String> tokenSupplier, HttpHelper http) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.tokenSupplier = tokenSupplier;
this.http = http;
}
// ─── Sync ─────────────────────────────────────────────────────────────────
/** POST /api/v1/agents/:id/credentials → 201 CredentialWithSecret */
public CredentialWithSecret generateCredential(String agentId) {
return http.request("POST", baseUrl + "/api/v1/agents/" + agentId + "/credentials",
null, tokenSupplier.get(), CredentialWithSecret.class);
}
/** GET /api/v1/agents/:id/credentials → 200 PaginatedCredentials */
public PaginatedCredentials listCredentials(String agentId, Integer page, Integer limit) {
return http.request("GET", buildListUrl(agentId, page, limit),
null, tokenSupplier.get(), PaginatedCredentials.class);
}
/** POST /api/v1/agents/:id/credentials/:credId/rotate → 200 CredentialWithSecret */
public CredentialWithSecret rotateCredential(String agentId, String credentialId) {
return http.request("POST",
baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId + "/rotate",
null, tokenSupplier.get(), CredentialWithSecret.class);
}
/** DELETE /api/v1/agents/:id/credentials/:credId → 200 Credential */
public Credential revokeCredential(String agentId, String credentialId) {
return http.request("DELETE",
baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId,
null, tokenSupplier.get(), Credential.class);
}
// ─── Async ────────────────────────────────────────────────────────────────
/** Async version of {@link #generateCredential}. */
public CompletableFuture<CredentialWithSecret> generateCredentialAsync(String agentId) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("POST",
baseUrl + "/api/v1/agents/" + agentId + "/credentials",
null, token, CredentialWithSecret.class));
}
/** Async version of {@link #listCredentials}. */
public CompletableFuture<PaginatedCredentials> listCredentialsAsync(String agentId, Integer page, Integer limit) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("GET", buildListUrl(agentId, page, limit),
null, token, PaginatedCredentials.class));
}
/** Async version of {@link #rotateCredential}. */
public CompletableFuture<CredentialWithSecret> rotateCredentialAsync(String agentId, String credentialId) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("POST",
baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId + "/rotate",
null, token, CredentialWithSecret.class));
}
/** Async version of {@link #revokeCredential}. */
public CompletableFuture<Credential> revokeCredentialAsync(String agentId, String credentialId) {
return CompletableFuture.supplyAsync(tokenSupplier)
.thenCompose(token -> http.requestAsync("DELETE",
baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId,
null, token, Credential.class));
}
private String buildListUrl(String agentId, Integer page, Integer limit) {
StringBuilder url = new StringBuilder(baseUrl + "/api/v1/agents/" + agentId + "/credentials");
StringBuilder query = new StringBuilder();
if (page != null) { query.append("&page=").append(page); }
if (limit != null) { query.append("&limit=").append(limit); }
if (query.length() > 0) url.append("?").append(query.substring(1));
return url.toString();
}
}

View File

@@ -0,0 +1,127 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.models.IntrospectResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
/**
* Client for token introspection and revocation endpoints.
* Uses form-encoded POST bodies (not JSON), per RFC 7009 / RFC 7662.
*/
public final class TokenClient {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final String baseUrl;
private final Supplier<String> tokenSupplier;
private final HttpClient httpClient;
public TokenClient(String baseUrl, Supplier<String> tokenSupplier, HttpClient httpClient) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.tokenSupplier = tokenSupplier;
this.httpClient = httpClient;
}
// ─── Sync ─────────────────────────────────────────────────────────────────
/** POST /api/v1/token/introspect (form-encoded) → 200 IntrospectResponse */
public IntrospectResponse introspectToken(String accessToken) {
String body = "token=" + encode(accessToken);
HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/introspect", body, tokenSupplier.get());
try {
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw AgentIdPException.fromApiError(resp.body(), resp.statusCode());
}
return MAPPER.readValue(resp.body(), IntrospectResponse.class);
} catch (AgentIdPException e) {
throw e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw AgentIdPException.networkError(e);
} catch (IOException e) {
throw AgentIdPException.networkError(e);
}
}
/** POST /api/v1/token/revoke (form-encoded) → 200 */
public void revokeToken(String accessToken) {
String body = "token=" + encode(accessToken);
HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/revoke", body, tokenSupplier.get());
try {
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw AgentIdPException.fromApiError(resp.body(), resp.statusCode());
}
} catch (AgentIdPException e) {
throw e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw AgentIdPException.networkError(e);
} catch (IOException e) {
throw AgentIdPException.networkError(e);
}
}
// ─── Async ────────────────────────────────────────────────────────────────
/** Async version of {@link #introspectToken}. */
public CompletableFuture<IntrospectResponse> introspectTokenAsync(String accessToken) {
return CompletableFuture.supplyAsync(tokenSupplier).thenCompose(token -> {
String body = "token=" + encode(accessToken);
HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/introspect", body, token);
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(resp -> {
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw AgentIdPException.fromApiError(resp.body(), resp.statusCode());
}
try {
return MAPPER.readValue(resp.body(), IntrospectResponse.class);
} catch (IOException e) {
throw new AgentIdPException("PARSE_ERROR", "Failed to parse introspect response: " + e.getMessage(), resp.statusCode());
}
});
});
}
/** Async version of {@link #revokeToken}. */
public CompletableFuture<Void> revokeTokenAsync(String accessToken) {
return CompletableFuture.supplyAsync(tokenSupplier).thenCompose(token -> {
String body = "token=" + encode(accessToken);
HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/revoke", body, token);
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(resp -> {
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw AgentIdPException.fromApiError(resp.body(), resp.statusCode());
}
return (Void) null;
});
});
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private HttpRequest buildFormRequest(String url, String formBody, String token) {
return HttpRequest.newBuilder()
.uri(URI.create(url))
.POST(HttpRequest.BodyPublishers.ofString(formBody))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.header("Authorization", "Bearer " + token)
.build();
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,122 @@
package ai.sentryagent.idp;
import ai.sentryagent.idp.models.Agent;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class AgentIdPClientTest {
private MockServer srv;
private static final String TOKEN_BODY =
"{\"access_token\":\"integration-token\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"scope\":\"agents:read agents:write tokens:read audit:read\"}";
private static final String AGENT_JSON =
"{\"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\"}";
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
// Register token endpoint for every test (each test gets a fresh MockServer)
srv.addHandler("/api/v1/token", 200, TOKEN_BODY);
}
@AfterEach
void tearDown() { srv.stop(); }
private AgentIdPClient makeClient() {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
return new AgentIdPClient(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
}
@Test
void getAgent_endToEnd() {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
AgentIdPClient client = makeClient();
Agent agent = client.agents().getAgent("uuid-1");
assertEquals("uuid-1", agent.getAgentId());
assertEquals("screener", agent.getAgentType());
}
@Test
void serviceClients_areAccessible() {
AgentIdPClient client = makeClient();
assertNotNull(client.agents());
assertNotNull(client.credentials());
assertNotNull(client.tokens());
assertNotNull(client.audit());
}
@Test
void clearTokenCache_forcesRefetch() throws IOException {
// Dedicated MockServer so we control the token counter from scratch
MockServer dedicated = new MockServer();
AtomicInteger tokenCalls = new AtomicInteger(0);
dedicated.addHandler("/api/v1/token", exchange -> {
tokenCalls.incrementAndGet();
try {
byte[] body = TOKEN_BODY.getBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.getResponseBody().close();
} catch (IOException e) {
throw new RuntimeException(e);
}
});
dedicated.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
try {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
AgentIdPClient client = new AgentIdPClient(dedicated.baseUrl(), "cid", "secret", "agents:read", httpClient);
client.agents().getAgent("uuid-1");
client.clearTokenCache();
client.agents().getAgent("uuid-1");
assertEquals(2, tokenCalls.get(), "Token should be refetched after clearTokenCache");
} finally {
dedicated.stop();
}
}
@Test
void defaultScope_containsAllFourScopes() throws IOException {
MockServer dedicated = new MockServer();
StringBuilder capturedBody = new StringBuilder();
dedicated.addHandler("/api/v1/token", exchange -> {
try {
String body = new String(exchange.getRequestBody().readAllBytes());
capturedBody.append(body);
byte[] resp = TOKEN_BODY.getBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, resp.length);
exchange.getResponseBody().write(resp);
exchange.getResponseBody().close();
} catch (IOException e) {
throw new RuntimeException(e);
}
});
dedicated.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
try {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
// Two-arg constructor → default scope applied
AgentIdPClient client = new AgentIdPClient(dedicated.baseUrl(), "cid", "secret",
"agents:read agents:write tokens:read audit:read", httpClient);
client.agents().getAgent("uuid-1");
String captured = capturedBody.toString();
assertTrue(captured.contains("agents"), "Scope should be present in token request body: " + captured);
} finally {
dedicated.stop();
}
}
}

View File

@@ -0,0 +1,72 @@
package ai.sentryagent.idp;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AgentIdPExceptionTest {
@Test
void constructor_setsFields() {
AgentIdPException ex = new AgentIdPException("AgentNotFoundError", "Not found.", 404);
assertEquals("AgentNotFoundError", ex.getCode());
assertEquals("Not found.", ex.getMessage());
assertEquals(404, ex.getHttpStatus());
assertNull(ex.getDetails());
}
@Test
void fromApiError_validBody() {
String body = "{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}";
AgentIdPException ex = AgentIdPException.fromApiError(body, 404);
assertEquals("AgentNotFoundError", ex.getCode());
assertEquals("Not found.", ex.getMessage());
assertEquals(404, ex.getHttpStatus());
}
@Test
void fromApiError_emptyCode_fallsBackToUnknown() {
String body = "{\"message\":\"oops\"}";
AgentIdPException ex = AgentIdPException.fromApiError(body, 503);
assertEquals("UNKNOWN_ERROR", ex.getCode());
assertEquals(503, ex.getHttpStatus());
}
@Test
void fromApiError_unparseable_fallsBackToUnknown() {
AgentIdPException ex = AgentIdPException.fromApiError("not json", 500);
assertEquals("UNKNOWN_ERROR", ex.getCode());
assertEquals(500, ex.getHttpStatus());
}
@Test
void fromOAuth2Error_validBody() {
String body = "{\"error\":\"invalid_client\",\"error_description\":\"Bad credentials.\"}";
AgentIdPException ex = AgentIdPException.fromOAuth2Error(body, 401);
assertEquals("invalid_client", ex.getCode());
assertEquals("Bad credentials.", ex.getMessage());
assertEquals(401, ex.getHttpStatus());
}
@Test
void fromOAuth2Error_unparseable_fallsBackToUnknown() {
AgentIdPException ex = AgentIdPException.fromOAuth2Error("garbage", 400);
assertEquals("unknown_error", ex.getCode());
}
@Test
void networkError_setsCodeAndCause() {
RuntimeException cause = new RuntimeException("connection refused");
AgentIdPException ex = AgentIdPException.networkError(cause);
assertEquals("NETWORK_ERROR", ex.getCode());
assertEquals(0, ex.getHttpStatus());
assertSame(cause, ex.getCause());
assertTrue(ex.getMessage().contains("connection refused"));
}
@Test
void toString_containsCodeAndStatus() {
AgentIdPException ex = new AgentIdPException("CODE", "msg", 400);
assertTrue(ex.toString().contains("CODE"));
assertTrue(ex.toString().contains("400"));
}
}

View File

@@ -0,0 +1,73 @@
package ai.sentryagent.idp;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
/**
* Lightweight in-process HTTP server for unit tests.
* Uses the JDK's built-in {@code com.sun.net.httpserver.HttpServer}.
*/
public final class MockServer {
private final HttpServer server;
private final int port;
public MockServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(0), 0);
server.start();
port = server.getAddress().getPort();
}
/** Base URL of the mock server (e.g. {@code "http://localhost:PORT"}). */
public String baseUrl() {
return "http://localhost:" + port;
}
/**
* Registers a handler for an exact path.
*
* @param path URL path (e.g. {@code "/api/v1/agents"})
* @param statusCode HTTP status code to return
* @param responseBody JSON body to return (may be null for empty body)
*/
public void addHandler(String path, int statusCode, String responseBody) {
server.createContext(path, new StaticHandler(statusCode, responseBody));
}
/**
* Registers a custom handler for an exact path.
*/
public void addHandler(String path, HttpHandler handler) {
server.createContext(path, handler);
}
/** Stops the server. */
public void stop() {
server.stop(0);
}
private static final class StaticHandler implements HttpHandler {
private final int statusCode;
private final byte[] body;
StaticHandler(int statusCode, String body) {
this.statusCode = statusCode;
this.body = body != null ? body.getBytes(StandardCharsets.UTF_8) : new byte[0];
}
@Override
public void handle(HttpExchange exchange) throws IOException {
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
}
}

View File

@@ -0,0 +1,102 @@
package ai.sentryagent.idp;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class TokenManagerTest {
private MockServer srv;
private HttpClient httpClient;
private static final String TOKEN_BODY = """
{"access_token":"eyJ.abc.def","token_type":"Bearer","expires_in":3600,"scope":"agents:read"}
""";
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void getToken_issuesToken() {
srv.addHandler("/api/v1/token", 200, TOKEN_BODY);
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
assertEquals("eyJ.abc.def", tm.getToken());
}
@Test
void getToken_cachesToken() {
AtomicInteger calls = new AtomicInteger(0);
srv.addHandler("/api/v1/token", exchange -> {
calls.incrementAndGet();
byte[] body = TOKEN_BODY.getBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.getResponseBody().close();
});
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
tm.getToken();
tm.getToken();
assertEquals(1, calls.get(), "Should only call the token endpoint once");
}
@Test
void getToken_authFailure_throwsAgentIdPException() {
srv.addHandler("/api/v1/token", 401,
"{\"error\":\"invalid_client\",\"error_description\":\"Bad credentials.\"}");
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "bad-secret", "agents:read", httpClient);
AgentIdPException ex = assertThrows(AgentIdPException.class, tm::getToken);
assertEquals("invalid_client", ex.getCode());
assertEquals(401, ex.getHttpStatus());
}
@Test
void clearCache_forcesRefetch() {
AtomicInteger calls = new AtomicInteger(0);
srv.addHandler("/api/v1/token", exchange -> {
calls.incrementAndGet();
byte[] body = TOKEN_BODY.getBytes();
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.getResponseBody().close();
});
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
tm.getToken();
tm.clearCache();
tm.getToken();
assertEquals(2, calls.get(), "Should call token endpoint again after clearCache");
}
@Test
void getToken_threadSafe() throws InterruptedException {
srv.addHandler("/api/v1/token", 200, TOKEN_BODY);
TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient);
Thread[] threads = new Thread[10];
String[] results = new String[10];
for (int i = 0; i < threads.length; i++) {
int idx = i;
threads[idx] = new Thread(() -> results[idx] = tm.getToken());
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
for (String result : results) {
assertEquals("eyJ.abc.def", result);
}
}
}

View File

@@ -0,0 +1,133 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.MockServer;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.models.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AgentRegistryClientTest {
private MockServer srv;
private AgentRegistryClient client;
private static final String AGENT_JSON = """
{"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"}
""";
private static final String PAGINATED_AGENTS = """
{"data":[%s],"total":1,"page":1,"limit":20}
""".formatted(AGENT_JSON.strip());
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpHelper httpHelper = new HttpHelper(httpClient);
client = new AgentRegistryClient(srv.baseUrl(), () -> "test-token", httpHelper);
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void registerAgent_returns201() {
srv.addHandler("/api/v1/agents", 201, AGENT_JSON);
Agent agent = client.registerAgent(RegisterAgentRequest.builder()
.email("a@b.ai").agentType("screener").version("1.0.0")
.capabilities(List.of("read")).owner("team").deploymentEnv("production")
.build());
assertEquals("uuid-1", agent.getAgentId());
assertEquals("screener", agent.getAgentType());
}
@Test
void listAgents_returnsPaginated() {
srv.addHandler("/api/v1/agents", 200, PAGINATED_AGENTS);
PaginatedAgents result = client.listAgents(null);
assertEquals(1, result.getTotal());
assertEquals("uuid-1", result.getData().get(0).getAgentId());
}
@Test
void getAgent_returnsAgent() {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
Agent agent = client.getAgent("uuid-1");
assertEquals("uuid-1", agent.getAgentId());
}
@Test
void getAgent_notFound_throwsAgentIdPException() {
srv.addHandler("/api/v1/agents/bad-id", 404,
"{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class, () -> client.getAgent("bad-id"));
assertEquals("AgentNotFoundError", ex.getCode());
assertEquals(404, ex.getHttpStatus());
}
@Test
void updateAgent_returnsUpdated() {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
Agent agent = client.updateAgent("uuid-1",
UpdateAgentRequest.builder().version("2.0.0").build());
assertNotNull(agent);
assertEquals("uuid-1", agent.getAgentId());
}
@Test
void decommissionAgent_returns204() {
srv.addHandler("/api/v1/agents/uuid-1", 204, null);
assertDoesNotThrow(() -> client.decommissionAgent("uuid-1"));
}
@Test
void registerAgentAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents", 201, AGENT_JSON);
Agent agent = client.registerAgentAsync(RegisterAgentRequest.builder()
.email("a@b.ai").agentType("screener").version("1.0.0")
.capabilities(List.of("read")).owner("team").deploymentEnv("production")
.build()).get();
assertEquals("uuid-1", agent.getAgentId());
}
@Test
void getAgentAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
Agent agent = client.getAgentAsync("uuid-1").get();
assertEquals("uuid-1", agent.getAgentId());
}
@Test
void listAgentsAsync_withParams() throws Exception {
srv.addHandler("/api/v1/agents", 200, PAGINATED_AGENTS);
PaginatedAgents result = client.listAgentsAsync(
ListAgentsParams.builder().status("active").page(1).limit(20).build()
).get();
assertEquals(1, result.getTotal());
}
@Test
void decommissionAgentAsync_completesSuccessfully() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1", 204, null);
assertDoesNotThrow(() -> client.decommissionAgentAsync("uuid-1").get());
}
@Test
void updateAgentAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON);
Agent agent = client.updateAgentAsync("uuid-1",
UpdateAgentRequest.builder().version("2.0.0").build()).get();
assertEquals("uuid-1", agent.getAgentId());
}
}

View File

@@ -0,0 +1,93 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.MockServer;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.models.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class AuditClientTest {
private MockServer srv;
private AuditClient client;
private static final String 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"}
""";
private static final String PAGINATED_AUDIT = """
{"data":[%s],"total":1,"page":1,"limit":20}
""".formatted(AUDIT_EVENT.strip());
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
client = new AuditClient(srv.baseUrl(), () -> "test-token", new HttpHelper(httpClient));
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void queryAuditLog_returnsPaginated() {
srv.addHandler("/api/v1/audit", 200, PAGINATED_AUDIT);
PaginatedAuditEvents result = client.queryAuditLog(null);
assertEquals(1, result.getTotal());
assertEquals("ev-1", result.getData().get(0).getEventId());
}
@Test
void queryAuditLog_withParams() {
srv.addHandler("/api/v1/audit", 200, PAGINATED_AUDIT);
PaginatedAuditEvents result = client.queryAuditLog(
QueryAuditParams.builder()
.agentId("uuid-1")
.action("token.issued")
.fromDate("2026-01-01")
.build());
assertEquals(1, result.getTotal());
}
@Test
void getAuditEvent_returnsEvent() {
srv.addHandler("/api/v1/audit/ev-1", 200, AUDIT_EVENT);
AuditEvent event = client.getAuditEvent("ev-1");
assertEquals("ev-1", event.getEventId());
assertEquals("token.issued", event.getAction());
assertEquals("success", event.getOutcome());
}
@Test
void getAuditEvent_notFound_throwsAgentIdPException() {
srv.addHandler("/api/v1/audit/bad-id", 404,
"{\"code\":\"AuditEventNotFoundError\",\"message\":\"Event not found.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class,
() -> client.getAuditEvent("bad-id"));
assertEquals("AuditEventNotFoundError", ex.getCode());
assertEquals(404, ex.getHttpStatus());
}
@Test
void queryAuditLogAsync_returnsPaginated() throws Exception {
srv.addHandler("/api/v1/audit", 200, PAGINATED_AUDIT);
PaginatedAuditEvents result = client.queryAuditLogAsync(null).get();
assertEquals(1, result.getTotal());
}
@Test
void getAuditEventAsync_returnsEvent() throws Exception {
srv.addHandler("/api/v1/audit/ev-1", 200, AUDIT_EVENT);
AuditEvent event = client.getAuditEventAsync("ev-1").get();
assertEquals("ev-1", event.getEventId());
}
}

View File

@@ -0,0 +1,116 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.MockServer;
import ai.sentryagent.idp.internal.HttpHelper;
import ai.sentryagent.idp.models.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class CredentialClientTest {
private MockServer srv;
private CredentialClient client;
private static final String CRED_JSON = """
{"credentialId":"cred-1","clientId":"uuid-1","status":"active",
"createdAt":"2026-01-01T00:00:00Z","expiresAt":null,"revokedAt":null}
""";
private static final String CRED_WITH_SECRET = """
{"credentialId":"cred-1","clientId":"uuid-1","status":"active",
"createdAt":"2026-01-01T00:00:00Z","expiresAt":null,"revokedAt":null,
"clientSecret":"sk_live_abc"}
""";
private static final String PAGINATED_CREDS = """
{"data":[%s],"total":1,"page":1,"limit":20}
""".formatted(CRED_JSON.strip());
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
client = new CredentialClient(srv.baseUrl(), () -> "test-token", new HttpHelper(httpClient));
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void generateCredential_returnsSecret() {
srv.addHandler("/api/v1/agents/uuid-1/credentials", 201, CRED_WITH_SECRET);
CredentialWithSecret cred = client.generateCredential("uuid-1");
assertEquals("sk_live_abc", cred.getClientSecret());
assertEquals("cred-1", cred.getCredentialId());
}
@Test
void listCredentials_returnsPaginated() {
srv.addHandler("/api/v1/agents/uuid-1/credentials", 200, PAGINATED_CREDS);
PaginatedCredentials result = client.listCredentials("uuid-1", null, null);
assertEquals(1, result.getTotal());
assertEquals("cred-1", result.getData().get(0).getCredentialId());
}
@Test
void rotateCredential_returnsNewSecret() {
srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1/rotate", 200, CRED_WITH_SECRET);
CredentialWithSecret cred = client.rotateCredential("uuid-1", "cred-1");
assertEquals("sk_live_abc", cred.getClientSecret());
}
@Test
void revokeCredential_returnsRevoked() {
String revoked = """
{"credentialId":"cred-1","clientId":"uuid-1","status":"revoked",
"createdAt":"2026-01-01T00:00:00Z","expiresAt":null,
"revokedAt":"2026-01-02T00:00:00Z"}
""";
srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1", 200, revoked);
Credential cred = client.revokeCredential("uuid-1", "cred-1");
assertEquals("revoked", cred.getStatus());
}
@Test
void generateCredential_error_throwsAgentIdPException() {
srv.addHandler("/api/v1/agents/bad/credentials", 404,
"{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class,
() -> client.generateCredential("bad"));
assertEquals(404, ex.getHttpStatus());
}
@Test
void generateCredentialAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1/credentials", 201, CRED_WITH_SECRET);
CredentialWithSecret cred = client.generateCredentialAsync("uuid-1").get();
assertEquals("sk_live_abc", cred.getClientSecret());
}
@Test
void rotateCredentialAsync_returnsCompletableFuture() throws Exception {
srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1/rotate", 200, CRED_WITH_SECRET);
CredentialWithSecret cred = client.rotateCredentialAsync("uuid-1", "cred-1").get();
assertEquals("sk_live_abc", cred.getClientSecret());
}
@Test
void revokeCredentialAsync_returnsCompletableFuture() throws Exception {
String revoked = """
{"credentialId":"cred-1","clientId":"uuid-1","status":"revoked",
"createdAt":"2026-01-01T00:00:00Z","expiresAt":null,
"revokedAt":"2026-01-02T00:00:00Z"}
""";
srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1", 200, revoked);
Credential cred = client.revokeCredentialAsync("uuid-1", "cred-1").get();
assertEquals("revoked", cred.getStatus());
}
}

View File

@@ -0,0 +1,86 @@
package ai.sentryagent.idp.services;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.MockServer;
import ai.sentryagent.idp.models.IntrospectResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.http.HttpClient;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class TokenClientTest {
private MockServer srv;
private TokenClient client;
@BeforeEach
void setUp() throws IOException {
srv = new MockServer();
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
client = new TokenClient(srv.baseUrl(), () -> "test-token", httpClient);
}
@AfterEach
void tearDown() { srv.stop(); }
@Test
void introspectToken_active() {
srv.addHandler("/api/v1/token/introspect", 200,
"{\"active\":true,\"sub\":\"uuid-1\",\"exp\":9999999999}");
IntrospectResponse result = client.introspectToken("some-token");
assertTrue(result.isActive());
assertEquals("uuid-1", result.getSub());
}
@Test
void introspectToken_inactive() {
srv.addHandler("/api/v1/token/introspect", 200, "{\"active\":false}");
IntrospectResponse result = client.introspectToken("expired-token");
assertFalse(result.isActive());
assertNull(result.getSub());
}
@Test
void revokeToken_succeeds() {
srv.addHandler("/api/v1/token/revoke", 200, "{}");
assertDoesNotThrow(() -> client.revokeToken("some-token"));
}
@Test
void introspectToken_error_throwsAgentIdPException() {
srv.addHandler("/api/v1/token/introspect", 401,
"{\"code\":\"UnauthorizedError\",\"message\":\"Invalid token.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class,
() -> client.introspectToken("bad-token"));
assertEquals(401, ex.getHttpStatus());
assertEquals("UnauthorizedError", ex.getCode());
}
@Test
void revokeToken_error_throwsAgentIdPException() {
srv.addHandler("/api/v1/token/revoke", 401,
"{\"code\":\"UnauthorizedError\",\"message\":\"Invalid token.\"}");
AgentIdPException ex = assertThrows(AgentIdPException.class,
() -> client.revokeToken("bad-token"));
assertEquals(401, ex.getHttpStatus());
}
@Test
void introspectTokenAsync_active() throws Exception {
srv.addHandler("/api/v1/token/introspect", 200,
"{\"active\":true,\"sub\":\"uuid-1\",\"exp\":9999999999}");
IntrospectResponse result = client.introspectTokenAsync("some-token").get();
assertTrue(result.isActive());
}
@Test
void revokeTokenAsync_succeeds() throws Exception {
srv.addHandler("/api/v1/token/revoke", 200, "{}");
assertDoesNotThrow(() -> client.revokeTokenAsync("some-token").get());
}
}

BIN
sdk-python/.coverage Normal file

Binary file not shown.

214
sdk-python/README.md Normal file
View File

@@ -0,0 +1,214 @@
# sentryagent-idp
Python SDK for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — the open-source Identity Provider for AI agents.
Handles token acquisition and caching automatically. Covers all 14 AgentIdP API endpoints. Provides both synchronous (`requests`) and asynchronous (`httpx`) clients.
---
## Requirements
- Python 3.9 or later
- A running AgentIdP server
- A registered agent with a valid `client_id` and `client_secret`
---
## Installation
```bash
pip install sentryagent-idp
```
---
## Quick start
### Synchronous
```python
from sentryagent_idp import AgentIdPClient
client = AgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id", # the agent's agentId (UUID)
client_secret="your-client-secret",
)
# List agents — token is acquired and cached automatically
result = client.agents.list_agents()
print(result.data)
```
### Asynchronous
```python
import asyncio
from sentryagent_idp import AsyncAgentIdPClient
async def main() -> None:
client = AsyncAgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
result = await client.agents.list_agents()
print(result.data)
asyncio.run(main())
```
---
## Configuration
```python
client = AgentIdPClient(
base_url="http://localhost:3000",
client_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
client_secret="your-client-secret",
# Optional: restrict scopes. Defaults to all four.
scopes=["agents:read", "tokens:read"],
)
```
| Parameter | Required | Description |
|-----------|----------|-------------|
| `base_url` | Yes | Base URL of the AgentIdP server |
| `client_id` | Yes | The agent's `agentId` (UUID) |
| `client_secret` | Yes | The credential secret |
| `scopes` | No | OAuth 2.0 scopes to request. Defaults to all four. |
---
## Token management
The SDK fetches and caches access tokens automatically. A new token is requested when the cached token is within 60 seconds of expiry.
```python
# Force a fresh token on the next request (e.g. after rotating credentials)
client.clear_token_cache()
```
---
## Agent Registry
```python
from sentryagent_idp import RegisterAgentRequest, UpdateAgentRequest
# Register a new agent
agent = client.agents.register_agent(RegisterAgentRequest(
email="classifier-v2@myorg.ai",
agent_type="classifier",
version="2.0.0",
capabilities=["text-classification", "sentiment-analysis"],
owner="platform-team",
deployment_env="production",
))
print(agent.agent_id) # UUID assigned by AgentIdP
# List agents
result = client.agents.list_agents(status="active", limit=20)
# Get a single agent
agent = client.agents.get_agent("a1b2c3d4-...")
# Update an agent
updated = client.agents.update_agent("a1b2c3d4-...", UpdateAgentRequest(
version="2.1.0",
capabilities=["text-classification", "sentiment-analysis", "intent-detection"],
))
# Decommission (irreversible)
client.agents.decommission_agent("a1b2c3d4-...")
```
---
## Credentials
```python
# Generate a credential — client_secret shown once, store it securely
cred = client.credentials.generate_credential("a1b2c3d4-...")
print(cred.client_secret) # only available here
# List credentials
result = client.credentials.list_credentials("a1b2c3d4-...")
# Rotate — same credential_id, new secret, old secret immediately invalid
rotated = client.credentials.rotate_credential("a1b2c3d4-...", "cred-uuid")
print(rotated.client_secret) # new secret — store immediately
# Revoke
client.credentials.revoke_credential("a1b2c3d4-...", "cred-uuid")
```
---
## Token operations
```python
# Introspect — check whether a token is active
result = client.tokens.introspect_token(some_token)
if result.active:
print(f"Token valid, expires at {result.exp}")
else:
print("Token is expired or revoked")
# Revoke — immediately invalidates the token
client.tokens.revoke_token(some_token)
```
---
## Audit log
```python
# Query audit events
result = client.audit.query_audit_log(
agent_id="a1b2c3d4-...",
action="token.issued",
outcome="success",
from_date="2026-03-01T00:00:00Z",
to_date="2026-03-31T23:59:59Z",
limit=50,
)
# Get a single event
event = client.audit.get_audit_event("event-uuid")
```
---
## Error handling
All API errors are raised as `AgentIdPError`:
```python
from sentryagent_idp import AgentIdPClient, AgentIdPError
try:
client.agents.get_agent("non-existent-id")
except AgentIdPError as err:
print(err.code) # e.g. "AgentNotFoundError"
print(err.http_status) # e.g. 404
print(str(err)) # human-readable description
```
---
## Available scopes
| Scope | What it allows |
|-------|----------------|
| `agents:read` | Read agent records |
| `agents:write` | Create, update, decommission agents |
| `tokens:read` | Introspect tokens |
| `audit:read` | Query audit logs |
---
## License
Apache 2.0 — see `LICENSE` in the repository root.

61
sdk-python/pyproject.toml Normal file
View File

@@ -0,0 +1,61 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "sentryagent-idp"
version = "1.0.0"
description = "Python SDK for the SentryAgent.ai AgentIdP — Identity Provider for AI agents"
readme = "README.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.9"
keywords = ["ai", "agents", "identity", "oauth2", "agntcy"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Security",
"Typing :: Typed",
]
dependencies = [
"requests>=2.28.0",
"httpx>=0.25.0",
]
[project.optional-dependencies]
dev = [
"mypy>=1.8.0",
"pytest>=7.4.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"respx>=0.20.0",
"responses>=0.24.0",
"types-requests>=2.31.0",
]
[tool.hatch.build.targets.wheel]
packages = ["src/sentryagent_idp"]
[tool.mypy]
strict = true
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_any_generics = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "--cov=src/sentryagent_idp --cov-report=term-missing --cov-fail-under=80"

View File

@@ -0,0 +1,82 @@
"""
SentryAgent.ai AgentIdP Python SDK.
Provides synchronous and asynchronous clients for the AgentIdP API.
Example (sync)::
from sentryagent_idp import AgentIdPClient
client = AgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
result = client.agents.list_agents()
Example (async)::
from sentryagent_idp import AsyncAgentIdPClient
client = AsyncAgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
result = await client.agents.list_agents()
"""
from .client import AgentIdPClient, AsyncAgentIdPClient
from .errors import AgentIdPError
from .token_manager import TokenManager
from .async_token_manager import AsyncTokenManager
from .types import (
Agent,
AgentStatus,
AgentType,
AuditAction,
AuditEvent,
AuditOutcome,
Credential,
CredentialStatus,
CredentialWithSecret,
DeploymentEnv,
IntrospectResponse,
OAuthScope,
PaginatedAgents,
PaginatedAuditEvents,
PaginatedCredentials,
RegisterAgentRequest,
TokenResponse,
UpdateAgentRequest,
)
__all__ = [
# Clients
"AgentIdPClient",
"AsyncAgentIdPClient",
# Errors
"AgentIdPError",
# Token managers (for advanced use)
"TokenManager",
"AsyncTokenManager",
# Types
"Agent",
"AgentStatus",
"AgentType",
"AuditAction",
"AuditEvent",
"AuditOutcome",
"Credential",
"CredentialStatus",
"CredentialWithSecret",
"DeploymentEnv",
"IntrospectResponse",
"OAuthScope",
"PaginatedAgents",
"PaginatedAuditEvents",
"PaginatedCredentials",
"RegisterAgentRequest",
"TokenResponse",
"UpdateAgentRequest",
]

View File

@@ -0,0 +1,127 @@
"""
Internal HTTP request helpers shared by all service clients.
Not part of the public API.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
import requests
import httpx
from .errors import AgentIdPError
def sync_request(
method: str,
base_url: str,
path: str,
token: str,
body: Optional[Any] = None,
params: Optional[Dict[str, Any]] = None,
) -> Any:
"""
Make a synchronous authenticated JSON request to the AgentIdP API.
Args:
method: HTTP method (GET, POST, PATCH, DELETE).
base_url: AgentIdP base URL.
path: API path (e.g. ``/api/v1/agents``).
token: Bearer access token.
body: Optional request body (serialised as JSON).
params: Optional query parameters (None values are excluded).
Returns:
Parsed JSON response body, or None for 204 responses.
Raises:
AgentIdPError: On any API or network failure.
"""
url = base_url.rstrip("/") + path
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
filtered_params: Optional[Dict[str, str]] = (
{k: str(v) for k, v in params.items() if v is not None}
if params
else None
)
try:
response = requests.request(
method=method,
url=url,
headers=headers,
json=body,
params=filtered_params,
timeout=30,
)
except requests.RequestException as exc:
raise AgentIdPError.network_error(exc) from exc
if response.status_code == 204:
return None
resp_body = response.json() if response.content else {}
if not response.ok:
raise AgentIdPError.from_api_error(resp_body, response.status_code)
return resp_body
async def async_request(
method: str,
base_url: str,
path: str,
token: str,
body: Optional[Any] = None,
params: Optional[Dict[str, Any]] = None,
) -> Any:
"""
Make an asynchronous authenticated JSON request to the AgentIdP API.
Args:
method: HTTP method (GET, POST, PATCH, DELETE).
base_url: AgentIdP base URL.
path: API path.
token: Bearer access token.
body: Optional request body (serialised as JSON).
params: Optional query parameters (None values are excluded).
Returns:
Parsed JSON response body, or None for 204 responses.
Raises:
AgentIdPError: On any API or network failure.
"""
url = base_url.rstrip("/") + path
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
filtered_params: Optional[Dict[str, str]] = (
{k: str(v) for k, v in params.items() if v is not None}
if params
else None
)
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.request(
method=method,
url=url,
headers=headers,
json=body,
params=filtered_params,
)
except httpx.RequestError as exc:
raise AgentIdPError.network_error(exc) from exc
if response.status_code == 204:
return None
resp_body = response.json() if response.content else {}
if not response.is_success:
raise AgentIdPError.from_api_error(resp_body, response.status_code)
return resp_body

View File

@@ -0,0 +1,117 @@
"""
Asynchronous TokenManager — handles OAuth 2.0 token acquisition, caching, and refresh.
Uses httpx for async HTTP. Tokens are re-issued automatically when within 60 seconds of expiry.
"""
from __future__ import annotations
import asyncio
import time
from dataclasses import dataclass
from typing import Optional
import httpx
from .errors import AgentIdPError
from .types import TokenResponse
#: Seconds before expiry at which a token refresh is triggered.
REFRESH_BUFFER_SECONDS = 60
@dataclass
class _CachedToken:
access_token: str
expires_at: float # Unix timestamp (seconds)
class AsyncTokenManager:
"""
Asyncio-safe asynchronous token manager.
Acquires and caches OAuth 2.0 access tokens. Automatically refreshes
the token when it is within :data:`REFRESH_BUFFER_SECONDS` of expiry.
Args:
base_url: AgentIdP server base URL (e.g. ``http://localhost:3000``).
client_id: The agent's ``agentId`` (UUID).
client_secret: The agent's credential secret.
scopes: Space-separated OAuth 2.0 scopes to request.
"""
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
scopes: str,
) -> None:
self._base_url = base_url.rstrip("/")
self._client_id = client_id
self._client_secret = client_secret
self._scopes = scopes
self._cached: Optional[_CachedToken] = None
self._lock: Optional[asyncio.Lock] = None
def _get_lock(self) -> asyncio.Lock:
"""Lazily create the asyncio.Lock on first use (supports different event loops)."""
if self._lock is None:
self._lock = asyncio.Lock()
return self._lock
async def get_token(self) -> str:
"""
Return a valid access token, refreshing if necessary.
Returns:
A valid JWT access token string.
Raises:
AgentIdPError: If token acquisition fails.
"""
async with self._get_lock():
now = time.time()
if (
self._cached is not None
and self._cached.expires_at - now > REFRESH_BUFFER_SECONDS
):
return self._cached.access_token
token_response = await self._issue_token()
self._cached = _CachedToken(
access_token=token_response.access_token,
expires_at=now + token_response.expires_in,
)
return self._cached.access_token
def clear_cache(self) -> None:
"""Clear the cached token, forcing re-acquisition on the next call."""
self._cached = None
async def _issue_token(self) -> TokenResponse:
"""
POST /api/v1/token to obtain a new access token.
Returns:
TokenResponse from the API.
Raises:
AgentIdPError: On authentication failure or network error.
"""
url = f"{self._base_url}/api/v1/token"
data = {
"grant_type": "client_credentials",
"client_id": self._client_id,
"client_secret": self._client_secret,
"scope": self._scopes,
}
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(url, data=data)
except httpx.RequestError as exc:
raise AgentIdPError.network_error(exc) from exc
body = response.json()
if not response.is_success:
raise AgentIdPError.from_oauth2_error(body, response.status_code)
return TokenResponse.from_dict(body)

View File

@@ -0,0 +1,128 @@
"""
Top-level client for the SentryAgent.ai AgentIdP API.
Provides both synchronous (AgentIdPClient) and asynchronous (AsyncAgentIdPClient) variants.
"""
from __future__ import annotations
from typing import List, Optional
from .token_manager import TokenManager
from .async_token_manager import AsyncTokenManager
from .services.agents import AgentRegistryClient, AsyncAgentRegistryClient
from .services.credentials import CredentialClient, AsyncCredentialClient
from .services.token import TokenClient, AsyncTokenClient
from .services.audit import AuditClient, AsyncAuditClient
from .types import OAuthScope
_DEFAULT_SCOPES: List[OAuthScope] = [
"agents:read",
"agents:write",
"tokens:read",
"audit:read",
]
class AgentIdPClient:
"""
Synchronous client for the SentryAgent.ai AgentIdP API.
Composes all service clients under a single entry point. Handles token
acquisition and caching automatically via :class:`~.token_manager.TokenManager`.
Args:
base_url: Base URL of the AgentIdP server (e.g. ``http://localhost:3000``).
client_id: The agent's ``agentId`` (UUID).
client_secret: The credential secret.
scopes: OAuth 2.0 scopes to request. Defaults to all four scopes.
Example::
from sentryagent_idp import AgentIdPClient, RegisterAgentRequest
client = AgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
agents = client.agents.list_agents()
"""
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
scopes: Optional[List[OAuthScope]] = None,
) -> None:
scope_str = " ".join(scopes if scopes is not None else _DEFAULT_SCOPES)
self._token_manager = TokenManager(base_url, client_id, client_secret, scope_str)
get_token = self._token_manager.get_token
#: Agent Registry operations: register, list, get, update, decommission.
self.agents = AgentRegistryClient(base_url, get_token)
#: Credential operations: generate, list, rotate, revoke.
self.credentials = CredentialClient(base_url, get_token)
#: Token operations: introspect, revoke.
self.tokens = TokenClient(base_url, get_token)
#: Audit log operations: query, get event.
self.audit = AuditClient(base_url, get_token)
def clear_token_cache(self) -> None:
"""
Clear the cached access token.
The next API call will request a new token. Use this after rotating credentials.
"""
self._token_manager.clear_cache()
class AsyncAgentIdPClient:
"""
Asynchronous client for the SentryAgent.ai AgentIdP API.
All methods are coroutines and must be awaited. Token acquisition and caching
are handled automatically via :class:`~.async_token_manager.AsyncTokenManager`.
Args:
base_url: Base URL of the AgentIdP server.
client_id: The agent's ``agentId`` (UUID).
client_secret: The credential secret.
scopes: OAuth 2.0 scopes to request. Defaults to all four scopes.
Example::
from sentryagent_idp import AsyncAgentIdPClient
client = AsyncAgentIdPClient(
base_url="http://localhost:3000",
client_id="your-agent-id",
client_secret="your-client-secret",
)
agents = await client.agents.list_agents()
"""
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
scopes: Optional[List[OAuthScope]] = None,
) -> None:
scope_str = " ".join(scopes if scopes is not None else _DEFAULT_SCOPES)
self._token_manager = AsyncTokenManager(base_url, client_id, client_secret, scope_str)
get_token = self._token_manager.get_token
#: Agent Registry operations (async).
self.agents = AsyncAgentRegistryClient(base_url, get_token)
#: Credential operations (async).
self.credentials = AsyncCredentialClient(base_url, get_token)
#: Token operations (async).
self.tokens = AsyncTokenClient(base_url, get_token)
#: Audit log operations (async).
self.audit = AsyncAuditClient(base_url, get_token)
def clear_token_cache(self) -> None:
"""Clear the cached access token."""
self._token_manager.clear_cache()

View File

@@ -0,0 +1,108 @@
"""
Error types for the SentryAgent.ai AgentIdP Python SDK.
All API failures are raised as AgentIdPError — never as raw requests/httpx exceptions.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
class AgentIdPError(Exception):
"""
Typed exception raised for all AgentIdP API failures.
Attributes:
code: Machine-readable error code from the API (e.g. ``AgentNotFoundError``).
http_status: HTTP status code of the failed response.
details: Optional structured details from the API error response.
"""
def __init__(
self,
code: str,
message: str,
http_status: int,
details: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(message)
self.code = code
self.http_status = http_status
self.details = details
def __repr__(self) -> str:
return (
f"AgentIdPError(code={self.code!r}, "
f"http_status={self.http_status}, "
f"message={str(self)!r})"
)
@classmethod
def from_api_error(
cls, body: Any, http_status: int
) -> "AgentIdPError":
"""
Create an AgentIdPError from a standard API error response body.
Args:
body: Parsed response body (dict or unknown).
http_status: HTTP status code.
Returns:
AgentIdPError instance.
"""
if isinstance(body, dict) and "code" in body and "message" in body:
return cls(
code=str(body["code"]),
message=str(body["message"]),
http_status=http_status,
details=body.get("details"),
)
return cls(
code="UNKNOWN_ERROR",
message=str(body),
http_status=http_status,
)
@classmethod
def from_oauth2_error(
cls, body: Any, http_status: int
) -> "AgentIdPError":
"""
Create an AgentIdPError from an OAuth 2.0 error response body.
Args:
body: Parsed response body.
http_status: HTTP status code.
Returns:
AgentIdPError instance.
"""
if isinstance(body, dict):
return cls(
code=str(body.get("error", "unknown_error")),
message=str(body.get("error_description", "Token request failed.")),
http_status=http_status,
)
return cls(
code="unknown_error",
message=str(body),
http_status=http_status,
)
@classmethod
def network_error(cls, cause: Exception) -> "AgentIdPError":
"""
Create an AgentIdPError for a network-level failure (no HTTP response).
Args:
cause: The underlying exception.
Returns:
AgentIdPError with http_status=0.
"""
return cls(
code="NETWORK_ERROR",
message=f"Network error: {cause}",
http_status=0,
)

View File

@@ -0,0 +1 @@
# Services package

View File

@@ -0,0 +1,202 @@
"""
Agent Registry service clients — sync and async.
Covers all five agent endpoints: register, list, get, update, decommission.
"""
from __future__ import annotations
from typing import Any, Callable, Coroutine, Dict, Optional
from .._request import sync_request, async_request
from ..types import (
Agent,
AgentStatus,
AgentType,
PaginatedAgents,
RegisterAgentRequest,
UpdateAgentRequest,
)
class AgentRegistryClient:
"""
Synchronous client for the Agent Registry service.
Args:
base_url: AgentIdP server base URL.
get_token: Callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], str],
) -> None:
self._base_url = base_url
self._get_token = get_token
def register_agent(self, request: RegisterAgentRequest) -> Agent:
"""
Register a new AI agent.
Args:
request: Agent registration parameters.
Returns:
The created Agent record.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"POST", self._base_url, "/api/v1/agents",
self._get_token(), body=request.to_dict(),
)
return Agent.from_dict(data)
def list_agents(
self,
status: Optional[AgentStatus] = None,
agent_type: Optional[AgentType] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedAgents:
"""
List all registered agents with optional filters.
Args:
status: Filter by lifecycle status.
agent_type: Filter by agent type.
page: Page number (1-based).
limit: Results per page.
Returns:
PaginatedAgents response.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"GET", self._base_url, "/api/v1/agents",
self._get_token(),
params={"status": status, "agentType": agent_type, "page": page, "limit": limit},
)
return PaginatedAgents.from_dict(data)
def get_agent(self, agent_id: str) -> Agent:
"""
Get a single agent by its agentId.
Args:
agent_id: The agent UUID.
Returns:
Agent record.
Raises:
AgentIdPError: If agent not found or network failure.
"""
data = sync_request(
"GET", self._base_url, f"/api/v1/agents/{agent_id}",
self._get_token(),
)
return Agent.from_dict(data)
def update_agent(self, agent_id: str, request: UpdateAgentRequest) -> Agent:
"""
Update mutable fields on an existing agent.
Args:
agent_id: The agent UUID.
request: Fields to update.
Returns:
Updated Agent record.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"PATCH", self._base_url, f"/api/v1/agents/{agent_id}",
self._get_token(), body=request.to_dict(),
)
return Agent.from_dict(data)
def decommission_agent(self, agent_id: str) -> None:
"""
Decommission an agent. This is irreversible.
Args:
agent_id: The agent UUID.
Raises:
AgentIdPError: On API or network failure.
"""
sync_request(
"DELETE", self._base_url, f"/api/v1/agents/{agent_id}",
self._get_token(),
)
class AsyncAgentRegistryClient:
"""
Asynchronous client for the Agent Registry service.
Args:
base_url: AgentIdP server base URL.
get_token: Async callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], Coroutine[Any, Any, str]],
) -> None:
self._base_url = base_url
self._get_token = get_token
async def register_agent(self, request: RegisterAgentRequest) -> Agent:
"""Register a new AI agent (async)."""
data = await async_request(
"POST", self._base_url, "/api/v1/agents",
await self._get_token(), body=request.to_dict(),
)
return Agent.from_dict(data)
async def list_agents(
self,
status: Optional[AgentStatus] = None,
agent_type: Optional[AgentType] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedAgents:
"""List all registered agents with optional filters (async)."""
data = await async_request(
"GET", self._base_url, "/api/v1/agents",
await self._get_token(),
params={"status": status, "agentType": agent_type, "page": page, "limit": limit},
)
return PaginatedAgents.from_dict(data)
async def get_agent(self, agent_id: str) -> Agent:
"""Get a single agent by its agentId (async)."""
data = await async_request(
"GET", self._base_url, f"/api/v1/agents/{agent_id}",
await self._get_token(),
)
return Agent.from_dict(data)
async def update_agent(self, agent_id: str, request: UpdateAgentRequest) -> Agent:
"""Update mutable fields on an existing agent (async)."""
data = await async_request(
"PATCH", self._base_url, f"/api/v1/agents/{agent_id}",
await self._get_token(), body=request.to_dict(),
)
return Agent.from_dict(data)
async def decommission_agent(self, agent_id: str) -> None:
"""Decommission an agent — irreversible (async)."""
await async_request(
"DELETE", self._base_url, f"/api/v1/agents/{agent_id}",
await self._get_token(),
)

View File

@@ -0,0 +1,144 @@
"""
Audit Log service clients — sync and async.
Covers query (list) and get-by-id operations.
"""
from __future__ import annotations
from typing import Any, Callable, Coroutine, Optional
from .._request import sync_request, async_request
from ..types import AuditAction, AuditEvent, AuditOutcome, PaginatedAuditEvents
class AuditClient:
"""
Synchronous client for the Audit Log service.
Args:
base_url: AgentIdP server base URL.
get_token: Callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], str],
) -> None:
self._base_url = base_url
self._get_token = get_token
def query_audit_log(
self,
agent_id: Optional[str] = None,
action: Optional[AuditAction] = None,
outcome: Optional[AuditOutcome] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedAuditEvents:
"""
Query audit log events with optional filters. Requires ``audit:read`` scope.
Events are retained for 90 days.
Args:
agent_id: Filter by agent UUID.
action: Filter by audit action type.
outcome: Filter by outcome (success or failure).
from_date: ISO 8601 start datetime (inclusive).
to_date: ISO 8601 end datetime (inclusive).
page: Page number (1-based).
limit: Results per page.
Returns:
PaginatedAuditEvents response.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"GET", self._base_url, "/api/v1/audit",
self._get_token(),
params={
"agentId": agent_id,
"action": action,
"outcome": outcome,
"fromDate": from_date,
"toDate": to_date,
"page": page,
"limit": limit,
},
)
return PaginatedAuditEvents.from_dict(data)
def get_audit_event(self, event_id: str) -> AuditEvent:
"""
Get a single audit event by its eventId. Requires ``audit:read`` scope.
Args:
event_id: The audit event UUID.
Returns:
AuditEvent record.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"GET", self._base_url, f"/api/v1/audit/{event_id}",
self._get_token(),
)
return AuditEvent.from_dict(data)
class AsyncAuditClient:
"""
Asynchronous client for the Audit Log service.
Args:
base_url: AgentIdP server base URL.
get_token: Async callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], Coroutine[Any, Any, str]],
) -> None:
self._base_url = base_url
self._get_token = get_token
async def query_audit_log(
self,
agent_id: Optional[str] = None,
action: Optional[AuditAction] = None,
outcome: Optional[AuditOutcome] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedAuditEvents:
"""Query audit log events with optional filters (async)."""
data = await async_request(
"GET", self._base_url, "/api/v1/audit",
await self._get_token(),
params={
"agentId": agent_id,
"action": action,
"outcome": outcome,
"fromDate": from_date,
"toDate": to_date,
"page": page,
"limit": limit,
},
)
return PaginatedAuditEvents.from_dict(data)
async def get_audit_event(self, event_id: str) -> AuditEvent:
"""Get a single audit event by its eventId (async)."""
data = await async_request(
"GET", self._base_url, f"/api/v1/audit/{event_id}",
await self._get_token(),
)
return AuditEvent.from_dict(data)

View File

@@ -0,0 +1,209 @@
"""
Credential Management service clients — sync and async.
Covers generate, list, rotate, and revoke operations.
"""
from __future__ import annotations
from typing import Any, Callable, Coroutine, Optional
from .._request import sync_request, async_request
from ..types import (
Credential,
CredentialStatus,
CredentialWithSecret,
PaginatedCredentials,
)
class CredentialClient:
"""
Synchronous client for the Credential Management service.
Args:
base_url: AgentIdP server base URL.
get_token: Callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], str],
) -> None:
self._base_url = base_url
self._get_token = get_token
def generate_credential(
self,
agent_id: str,
expires_at: Optional[str] = None,
) -> CredentialWithSecret:
"""
Generate a new credential for an agent.
The ``client_secret`` is shown **once** — store it securely immediately.
Args:
agent_id: The agent UUID.
expires_at: Optional ISO 8601 expiry date string.
Returns:
CredentialWithSecret including the one-time plain-text secret.
Raises:
AgentIdPError: On API or network failure.
"""
body = {"expiresAt": expires_at} if expires_at is not None else None
data = sync_request(
"POST", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
self._get_token(), body=body,
)
return CredentialWithSecret.from_dict(data)
def list_credentials(
self,
agent_id: str,
status: Optional[CredentialStatus] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedCredentials:
"""
List credentials for an agent. Secrets are never returned in list responses.
Args:
agent_id: The agent UUID.
status: Filter by credential status.
page: Page number (1-based).
limit: Results per page.
Returns:
PaginatedCredentials response.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"GET", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
self._get_token(),
params={"status": status, "page": page, "limit": limit},
)
return PaginatedCredentials.from_dict(data)
def rotate_credential(
self, agent_id: str, credential_id: str
) -> CredentialWithSecret:
"""
Rotate a credential. The same ``credential_id`` is retained; a new secret is issued.
The old secret is immediately invalidated.
The new ``client_secret`` is shown **once** — store it securely immediately.
Args:
agent_id: The agent UUID.
credential_id: The credential UUID to rotate.
Returns:
CredentialWithSecret with the new one-time secret.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"POST",
self._base_url,
f"/api/v1/agents/{agent_id}/credentials/{credential_id}/rotate",
self._get_token(),
)
return CredentialWithSecret.from_dict(data)
def revoke_credential(
self, agent_id: str, credential_id: str
) -> Credential:
"""
Revoke a credential permanently.
Args:
agent_id: The agent UUID.
credential_id: The credential UUID to revoke.
Returns:
The revoked Credential record.
Raises:
AgentIdPError: On API or network failure.
"""
data = sync_request(
"DELETE",
self._base_url,
f"/api/v1/agents/{agent_id}/credentials/{credential_id}",
self._get_token(),
)
return Credential.from_dict(data)
class AsyncCredentialClient:
"""
Asynchronous client for the Credential Management service.
Args:
base_url: AgentIdP server base URL.
get_token: Async callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], Coroutine[Any, Any, str]],
) -> None:
self._base_url = base_url
self._get_token = get_token
async def generate_credential(
self,
agent_id: str,
expires_at: Optional[str] = None,
) -> CredentialWithSecret:
"""Generate a new credential for an agent (async)."""
body = {"expiresAt": expires_at} if expires_at is not None else None
data = await async_request(
"POST", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
await self._get_token(), body=body,
)
return CredentialWithSecret.from_dict(data)
async def list_credentials(
self,
agent_id: str,
status: Optional[CredentialStatus] = None,
page: int = 1,
limit: int = 20,
) -> PaginatedCredentials:
"""List credentials for an agent (async)."""
data = await async_request(
"GET", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
await self._get_token(),
params={"status": status, "page": page, "limit": limit},
)
return PaginatedCredentials.from_dict(data)
async def rotate_credential(
self, agent_id: str, credential_id: str
) -> CredentialWithSecret:
"""Rotate a credential (async)."""
data = await async_request(
"POST",
self._base_url,
f"/api/v1/agents/{agent_id}/credentials/{credential_id}/rotate",
await self._get_token(),
)
return CredentialWithSecret.from_dict(data)
async def revoke_credential(
self, agent_id: str, credential_id: str
) -> Credential:
"""Revoke a credential permanently (async)."""
data = await async_request(
"DELETE",
self._base_url,
f"/api/v1/agents/{agent_id}/credentials/{credential_id}",
await self._get_token(),
)
return Credential.from_dict(data)

View File

@@ -0,0 +1,154 @@
"""
Token service clients (introspect and revoke) — sync and async.
Token issuance is handled by TokenManager / AsyncTokenManager.
"""
from __future__ import annotations
from typing import Any, Callable, Coroutine
import requests
import httpx
from ..errors import AgentIdPError
from ..types import IntrospectResponse
class TokenClient:
"""
Synchronous client for token introspection and revocation.
Args:
base_url: AgentIdP server base URL.
get_token: Callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], str],
) -> None:
self._base_url = base_url.rstrip("/")
self._get_token = get_token
def introspect_token(self, token_to_check: str) -> IntrospectResponse:
"""
Check whether a token is currently active.
Always returns successfully — check ``response.active`` for validity.
Args:
token_to_check: The JWT string to introspect.
Returns:
IntrospectResponse with ``active`` field set.
Raises:
AgentIdPError: On API or network failure.
"""
url = f"{self._base_url}/api/v1/token/introspect"
try:
response = requests.post(
url,
data={"token": token_to_check},
headers={
"Authorization": f"Bearer {self._get_token()}",
"Content-Type": "application/x-www-form-urlencoded",
},
timeout=30,
)
except requests.RequestException as exc:
raise AgentIdPError.network_error(exc) from exc
body = response.json()
if not response.ok:
raise AgentIdPError.from_api_error(body, response.status_code)
return IntrospectResponse.from_dict(body)
def revoke_token(self, token_to_revoke: str) -> None:
"""
Revoke a token immediately. Idempotent (RFC 7009).
Args:
token_to_revoke: The JWT string to revoke.
Raises:
AgentIdPError: On API or network failure.
"""
url = f"{self._base_url}/api/v1/token/revoke"
try:
response = requests.post(
url,
data={"token": token_to_revoke},
headers={
"Authorization": f"Bearer {self._get_token()}",
"Content-Type": "application/x-www-form-urlencoded",
},
timeout=30,
)
except requests.RequestException as exc:
raise AgentIdPError.network_error(exc) from exc
if not response.ok:
body = response.json() if response.content else {}
raise AgentIdPError.from_api_error(body, response.status_code)
class AsyncTokenClient:
"""
Asynchronous client for token introspection and revocation.
Args:
base_url: AgentIdP server base URL.
get_token: Async callable that returns a valid Bearer token.
"""
def __init__(
self,
base_url: str,
get_token: Callable[[], Coroutine[Any, Any, str]],
) -> None:
self._base_url = base_url.rstrip("/")
self._get_token = get_token
async def introspect_token(self, token_to_check: str) -> IntrospectResponse:
"""Check whether a token is currently active (async)."""
url = f"{self._base_url}/api/v1/token/introspect"
token = await self._get_token()
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
url,
data={"token": token_to_check},
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
except httpx.RequestError as exc:
raise AgentIdPError.network_error(exc) from exc
body = response.json()
if not response.is_success:
raise AgentIdPError.from_api_error(body, response.status_code)
return IntrospectResponse.from_dict(body)
async def revoke_token(self, token_to_revoke: str) -> None:
"""Revoke a token immediately — idempotent (async)."""
url = f"{self._base_url}/api/v1/token/revoke"
token = await self._get_token()
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
url,
data={"token": token_to_revoke},
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
except httpx.RequestError as exc:
raise AgentIdPError.network_error(exc) from exc
if not response.is_success:
body = response.json() if response.content else {}
raise AgentIdPError.from_api_error(body, response.status_code)

View File

@@ -0,0 +1,116 @@
"""
Synchronous TokenManager — handles OAuth 2.0 token acquisition, caching, and refresh.
Tokens are re-issued automatically when expired or within 60 seconds of expiry.
"""
from __future__ import annotations
import time
import threading
from dataclasses import dataclass
from typing import Optional
import requests
from .errors import AgentIdPError
from .types import TokenResponse
#: Seconds before expiry at which a token refresh is triggered.
REFRESH_BUFFER_SECONDS = 60
@dataclass
class _CachedToken:
access_token: str
expires_at: float # Unix timestamp (seconds)
class TokenManager:
"""
Thread-safe synchronous token manager.
Acquires and caches OAuth 2.0 access tokens. Automatically refreshes
the token when it is within :data:`REFRESH_BUFFER_SECONDS` of expiry.
Args:
base_url: AgentIdP server base URL (e.g. ``http://localhost:3000``).
client_id: The agent's ``agentId`` (UUID).
client_secret: The agent's credential secret.
scopes: Space-separated OAuth 2.0 scopes to request.
"""
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
scopes: str,
) -> None:
self._base_url = base_url.rstrip("/")
self._client_id = client_id
self._client_secret = client_secret
self._scopes = scopes
self._cached: Optional[_CachedToken] = None
self._lock = threading.Lock()
def get_token(self) -> str:
"""
Return a valid access token, refreshing if necessary.
Returns:
A valid JWT access token string.
Raises:
AgentIdPError: If token acquisition fails.
"""
with self._lock:
now = time.time()
if (
self._cached is not None
and self._cached.expires_at - now > REFRESH_BUFFER_SECONDS
):
return self._cached.access_token
token_response = self._issue_token()
self._cached = _CachedToken(
access_token=token_response.access_token,
expires_at=now + token_response.expires_in,
)
return self._cached.access_token
def clear_cache(self) -> None:
"""Clear the cached token, forcing re-acquisition on the next call."""
with self._lock:
self._cached = None
def _issue_token(self) -> TokenResponse:
"""
POST /api/v1/token to obtain a new access token.
Returns:
TokenResponse from the API.
Raises:
AgentIdPError: On authentication failure or network error.
"""
url = f"{self._base_url}/api/v1/token"
data = {
"grant_type": "client_credentials",
"client_id": self._client_id,
"client_secret": self._client_secret,
"scope": self._scopes,
}
try:
response = requests.post(
url,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=30,
)
except requests.RequestException as exc:
raise AgentIdPError.network_error(exc) from exc
body = response.json()
if not response.ok:
raise AgentIdPError.from_oauth2_error(body, response.status_code)
return TokenResponse.from_dict(body)

View File

@@ -0,0 +1,323 @@
"""
Type definitions for the SentryAgent.ai AgentIdP Python SDK.
All request and response shapes derived from the AgentIdP OpenAPI 3.0 specs.
"""
from __future__ import annotations
from typing import Any, Dict, List, Literal, Optional
from dataclasses import dataclass, field
# ─────────────────────────────────────────────────────────────────────────────
# Enums / Literal types
# ─────────────────────────────────────────────────────────────────────────────
AgentType = Literal[
"screener",
"classifier",
"orchestrator",
"extractor",
"summarizer",
"router",
"monitor",
"custom",
]
AgentStatus = Literal["active", "suspended", "decommissioned"]
DeploymentEnv = Literal["development", "staging", "production"]
CredentialStatus = Literal["active", "revoked"]
OAuthScope = Literal["agents:read", "agents:write", "tokens:read", "audit:read"]
AuditAction = Literal[
"agent.created",
"agent.updated",
"agent.decommissioned",
"agent.suspended",
"agent.reactivated",
"token.issued",
"token.revoked",
"token.introspected",
"credential.generated",
"credential.rotated",
"credential.revoked",
"auth.failed",
]
AuditOutcome = Literal["success", "failure"]
# ─────────────────────────────────────────────────────────────────────────────
# Agent Registry
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Agent:
"""A registered AI agent identity."""
agent_id: str
email: str
agent_type: AgentType
version: str
capabilities: List[str]
owner: str
deployment_env: DeploymentEnv
status: AgentStatus
created_at: str
updated_at: str
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Agent":
"""Deserialise from an API response dict."""
return cls(
agent_id=data["agentId"],
email=data["email"],
agent_type=data["agentType"],
version=data["version"],
capabilities=data["capabilities"],
owner=data["owner"],
deployment_env=data["deploymentEnv"],
status=data["status"],
created_at=data["createdAt"],
updated_at=data["updatedAt"],
)
@dataclass
class RegisterAgentRequest:
"""Request body for registering a new AI agent."""
email: str
agent_type: AgentType
version: str
capabilities: List[str]
owner: str
deployment_env: DeploymentEnv
def to_dict(self) -> Dict[str, Any]:
"""Serialise to API request dict."""
return {
"email": self.email,
"agentType": self.agent_type,
"version": self.version,
"capabilities": self.capabilities,
"owner": self.owner,
"deploymentEnv": self.deployment_env,
}
@dataclass
class UpdateAgentRequest:
"""Request body for partially updating an agent (all fields optional)."""
agent_type: Optional[AgentType] = None
version: Optional[str] = None
capabilities: Optional[List[str]] = None
owner: Optional[str] = None
deployment_env: Optional[DeploymentEnv] = None
status: Optional[AgentStatus] = None
def to_dict(self) -> Dict[str, Any]:
"""Serialise to API request dict, omitting None fields."""
out: Dict[str, Any] = {}
if self.agent_type is not None:
out["agentType"] = self.agent_type
if self.version is not None:
out["version"] = self.version
if self.capabilities is not None:
out["capabilities"] = self.capabilities
if self.owner is not None:
out["owner"] = self.owner
if self.deployment_env is not None:
out["deploymentEnv"] = self.deployment_env
if self.status is not None:
out["status"] = self.status
return out
@dataclass
class PaginatedAgents:
"""Paginated list of agents."""
data: List[Agent]
total: int
page: int
limit: int
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedAgents":
return cls(
data=[Agent.from_dict(a) for a in d["data"]],
total=d["total"],
page=d["page"],
limit=d["limit"],
)
# ─────────────────────────────────────────────────────────────────────────────
# Credential Management
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Credential:
"""A credential record (client_secret never included)."""
credential_id: str
client_id: str
status: CredentialStatus
created_at: str
expires_at: Optional[str]
revoked_at: Optional[str]
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Credential":
return cls(
credential_id=data["credentialId"],
client_id=data["clientId"],
status=data["status"],
created_at=data["createdAt"],
expires_at=data.get("expiresAt"),
revoked_at=data.get("revokedAt"),
)
@dataclass
class CredentialWithSecret(Credential):
"""Credential with plain-text secret — returned once only on create/rotate."""
client_secret: str = field(default="")
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "CredentialWithSecret":
base = Credential.from_dict(data)
return cls(
credential_id=base.credential_id,
client_id=base.client_id,
status=base.status,
created_at=base.created_at,
expires_at=base.expires_at,
revoked_at=base.revoked_at,
client_secret=data["clientSecret"],
)
@dataclass
class PaginatedCredentials:
"""Paginated list of credentials."""
data: List[Credential]
total: int
page: int
limit: int
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedCredentials":
return cls(
data=[Credential.from_dict(c) for c in d["data"]],
total=d["total"],
page=d["page"],
limit=d["limit"],
)
# ─────────────────────────────────────────────────────────────────────────────
# OAuth 2.0 Tokens
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class TokenResponse:
"""OAuth 2.0 access token response."""
access_token: str
token_type: str
expires_in: int
scope: str
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "TokenResponse":
return cls(
access_token=data["access_token"],
token_type=data["token_type"],
expires_in=data["expires_in"],
scope=data["scope"],
)
@dataclass
class IntrospectResponse:
"""Token introspection response (RFC 7662)."""
active: bool
sub: Optional[str] = None
client_id: Optional[str] = None
scope: Optional[str] = None
token_type: Optional[str] = None
iat: Optional[int] = None
exp: Optional[int] = None
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "IntrospectResponse":
return cls(
active=data["active"],
sub=data.get("sub"),
client_id=data.get("client_id"),
scope=data.get("scope"),
token_type=data.get("token_type"),
iat=data.get("iat"),
exp=data.get("exp"),
)
# ─────────────────────────────────────────────────────────────────────────────
# Audit Log
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class AuditEvent:
"""An immutable audit event record."""
event_id: str
agent_id: str
action: AuditAction
outcome: AuditOutcome
ip_address: str
user_agent: str
metadata: Dict[str, Any]
timestamp: str
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AuditEvent":
return cls(
event_id=data["eventId"],
agent_id=data["agentId"],
action=data["action"],
outcome=data["outcome"],
ip_address=data["ipAddress"],
user_agent=data["userAgent"],
metadata=data.get("metadata", {}),
timestamp=data["timestamp"],
)
@dataclass
class PaginatedAuditEvents:
"""Paginated list of audit events."""
data: List[AuditEvent]
total: int
page: int
limit: int
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedAuditEvents":
return cls(
data=[AuditEvent.from_dict(e) for e in d["data"]],
total=d["total"],
page=d["page"],
limit=d["limit"],
)

View File

Binary file not shown.

View File

@@ -0,0 +1,52 @@
"""Tests for AgentIdPError."""
from sentryagent_idp.errors import AgentIdPError
def test_basic_construction() -> None:
err = AgentIdPError("AgentNotFoundError", "Agent not found.", 404)
assert err.code == "AgentNotFoundError"
assert err.http_status == 404
assert str(err) == "Agent not found."
assert err.details is None
def test_from_api_error_valid_body() -> None:
body = {"code": "AgentNotFoundError", "message": "Not found.", "details": {"id": "x"}}
err = AgentIdPError.from_api_error(body, 404)
assert err.code == "AgentNotFoundError"
assert err.http_status == 404
assert err.details == {"id": "x"}
def test_from_api_error_unknown_body() -> None:
err = AgentIdPError.from_api_error("plain string", 500)
assert err.code == "UNKNOWN_ERROR"
assert err.http_status == 500
def test_from_oauth2_error() -> None:
body = {"error": "invalid_client", "error_description": "Bad credentials."}
err = AgentIdPError.from_oauth2_error(body, 401)
assert err.code == "invalid_client"
assert str(err) == "Bad credentials."
assert err.http_status == 401
def test_from_oauth2_error_unknown() -> None:
err = AgentIdPError.from_oauth2_error("garbage", 400)
assert err.code == "unknown_error"
def test_network_error() -> None:
cause = ConnectionError("refused")
err = AgentIdPError.network_error(cause)
assert err.code == "NETWORK_ERROR"
assert err.http_status == 0
assert "refused" in str(err)
def test_repr() -> None:
err = AgentIdPError("CODE", "msg", 400)
assert "AgentIdPError" in repr(err)
assert "CODE" in repr(err)

Some files were not shown because too many files have changed in this diff Show More