Compare commits
5 Commits
c8f916b849
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cdab72fea | ||
|
|
91c759f455 | ||
|
|
c93562e685 | ||
|
|
90a4addb21 | ||
|
|
7593bfe1c1 |
@@ -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
197
docs/devops/vault-setup.md
Normal 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).
|
||||||
3
openspec/changes/phase-2-production-ready/.openspec.yaml
Normal file
3
openspec/changes/phase-2-production-ready/.openspec.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
change: phase-2-production-ready
|
||||||
|
status: proposed
|
||||||
|
date: 2026-03-28
|
||||||
218
openspec/changes/phase-2-production-ready/design.md
Normal file
218
openspec/changes/phase-2-production-ready/design.md
Normal 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 │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
96
openspec/changes/phase-2-production-ready/proposal.md
Normal file
96
openspec/changes/phase-2-production-ready/proposal.md
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
127
openspec/changes/phase-2-production-ready/tasks.md
Normal file
127
openspec/changes/phase-2-production-ready/tasks.md
Normal 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
154
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
200
sdk-go/README.md
Normal 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
113
sdk-go/agents.go
Normal 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
181
sdk-go/agents_test.go
Normal 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
80
sdk-go/audit.go
Normal 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
126
sdk-go/audit_test.go
Normal 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
83
sdk-go/client.go
Normal 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
124
sdk-go/client_test.go
Normal 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
93
sdk-go/credentials.go
Normal 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
146
sdk-go/credentials_test.go
Normal 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
83
sdk-go/errors.go
Normal 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
85
sdk-go/errors_test.go
Normal 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
3
sdk-go/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/sentryagent/idp-sdk-go
|
||||||
|
|
||||||
|
go 1.21
|
||||||
79
sdk-go/request.go
Normal file
79
sdk-go/request.go
Normal 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
129
sdk-go/token_manager.go
Normal 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
|
||||||
|
}
|
||||||
169
sdk-go/token_manager_test.go
Normal file
169
sdk-go/token_manager_test.go
Normal 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
103
sdk-go/token_service.go
Normal 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
|
||||||
|
}
|
||||||
108
sdk-go/token_service_test.go
Normal file
108
sdk-go/token_service_test.go
Normal 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
131
sdk-go/types.go
Normal 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
1
sdk-java/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
190
sdk-java/README.md
Normal file
190
sdk-java/README.md
Normal 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
100
sdk-java/pom.xml
Normal 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>
|
||||||
@@ -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(); }
|
||||||
|
}
|
||||||
@@ -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() + "'}";
|
||||||
|
}
|
||||||
|
}
|
||||||
101
sdk-java/src/main/java/ai/sentryagent/idp/TokenManager.java
Normal file
101
sdk-java/src/main/java/ai/sentryagent/idp/TokenManager.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
sdk-java/src/main/java/ai/sentryagent/idp/models/Agent.java
Normal file
39
sdk-java/src/main/java/ai/sentryagent/idp/models/Agent.java
Normal 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 + "'}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + "'}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + "'}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
73
sdk-java/src/test/java/ai/sentryagent/idp/MockServer.java
Normal file
73
sdk-java/src/test/java/ai/sentryagent/idp/MockServer.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
sdk-java/src/test/java/ai/sentryagent/idp/TokenManagerTest.java
Normal file
102
sdk-java/src/test/java/ai/sentryagent/idp/TokenManagerTest.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
BIN
sdk-python/.coverage
Normal file
Binary file not shown.
214
sdk-python/README.md
Normal file
214
sdk-python/README.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# sentryagent-idp
|
||||||
|
|
||||||
|
Python SDK for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — the open-source Identity Provider for AI agents.
|
||||||
|
|
||||||
|
Handles token acquisition and caching automatically. Covers all 14 AgentIdP API endpoints. Provides both synchronous (`requests`) and asynchronous (`httpx`) clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.9 or later
|
||||||
|
- A running AgentIdP server
|
||||||
|
- A registered agent with a valid `client_id` and `client_secret`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install sentryagent-idp
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Synchronous
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sentryagent_idp import AgentIdPClient
|
||||||
|
|
||||||
|
client = AgentIdPClient(
|
||||||
|
base_url="http://localhost:3000",
|
||||||
|
client_id="your-agent-id", # the agent's agentId (UUID)
|
||||||
|
client_secret="your-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
# List agents — token is acquired and cached automatically
|
||||||
|
result = client.agents.list_agents()
|
||||||
|
print(result.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Asynchronous
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from sentryagent_idp import AsyncAgentIdPClient
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
client = AsyncAgentIdPClient(
|
||||||
|
base_url="http://localhost:3000",
|
||||||
|
client_id="your-agent-id",
|
||||||
|
client_secret="your-client-secret",
|
||||||
|
)
|
||||||
|
result = await client.agents.list_agents()
|
||||||
|
print(result.data)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = AgentIdPClient(
|
||||||
|
base_url="http://localhost:3000",
|
||||||
|
client_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
client_secret="your-client-secret",
|
||||||
|
# Optional: restrict scopes. Defaults to all four.
|
||||||
|
scopes=["agents:read", "tokens:read"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Required | Description |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `base_url` | Yes | Base URL of the AgentIdP server |
|
||||||
|
| `client_id` | Yes | The agent's `agentId` (UUID) |
|
||||||
|
| `client_secret` | Yes | The credential secret |
|
||||||
|
| `scopes` | No | OAuth 2.0 scopes to request. Defaults to all four. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token management
|
||||||
|
|
||||||
|
The SDK fetches and caches access tokens automatically. A new token is requested when the cached token is within 60 seconds of expiry.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Force a fresh token on the next request (e.g. after rotating credentials)
|
||||||
|
client.clear_token_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Registry
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sentryagent_idp import RegisterAgentRequest, UpdateAgentRequest
|
||||||
|
|
||||||
|
# Register a new agent
|
||||||
|
agent = client.agents.register_agent(RegisterAgentRequest(
|
||||||
|
email="classifier-v2@myorg.ai",
|
||||||
|
agent_type="classifier",
|
||||||
|
version="2.0.0",
|
||||||
|
capabilities=["text-classification", "sentiment-analysis"],
|
||||||
|
owner="platform-team",
|
||||||
|
deployment_env="production",
|
||||||
|
))
|
||||||
|
print(agent.agent_id) # UUID assigned by AgentIdP
|
||||||
|
|
||||||
|
# List agents
|
||||||
|
result = client.agents.list_agents(status="active", limit=20)
|
||||||
|
|
||||||
|
# Get a single agent
|
||||||
|
agent = client.agents.get_agent("a1b2c3d4-...")
|
||||||
|
|
||||||
|
# Update an agent
|
||||||
|
updated = client.agents.update_agent("a1b2c3d4-...", UpdateAgentRequest(
|
||||||
|
version="2.1.0",
|
||||||
|
capabilities=["text-classification", "sentiment-analysis", "intent-detection"],
|
||||||
|
))
|
||||||
|
|
||||||
|
# Decommission (irreversible)
|
||||||
|
client.agents.decommission_agent("a1b2c3d4-...")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Generate a credential — client_secret shown once, store it securely
|
||||||
|
cred = client.credentials.generate_credential("a1b2c3d4-...")
|
||||||
|
print(cred.client_secret) # only available here
|
||||||
|
|
||||||
|
# List credentials
|
||||||
|
result = client.credentials.list_credentials("a1b2c3d4-...")
|
||||||
|
|
||||||
|
# Rotate — same credential_id, new secret, old secret immediately invalid
|
||||||
|
rotated = client.credentials.rotate_credential("a1b2c3d4-...", "cred-uuid")
|
||||||
|
print(rotated.client_secret) # new secret — store immediately
|
||||||
|
|
||||||
|
# Revoke
|
||||||
|
client.credentials.revoke_credential("a1b2c3d4-...", "cred-uuid")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token operations
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Introspect — check whether a token is active
|
||||||
|
result = client.tokens.introspect_token(some_token)
|
||||||
|
if result.active:
|
||||||
|
print(f"Token valid, expires at {result.exp}")
|
||||||
|
else:
|
||||||
|
print("Token is expired or revoked")
|
||||||
|
|
||||||
|
# Revoke — immediately invalidates the token
|
||||||
|
client.tokens.revoke_token(some_token)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit log
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Query audit events
|
||||||
|
result = client.audit.query_audit_log(
|
||||||
|
agent_id="a1b2c3d4-...",
|
||||||
|
action="token.issued",
|
||||||
|
outcome="success",
|
||||||
|
from_date="2026-03-01T00:00:00Z",
|
||||||
|
to_date="2026-03-31T23:59:59Z",
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get a single event
|
||||||
|
event = client.audit.get_audit_event("event-uuid")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
All API errors are raised as `AgentIdPError`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sentryagent_idp import AgentIdPClient, AgentIdPError
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.agents.get_agent("non-existent-id")
|
||||||
|
except AgentIdPError as err:
|
||||||
|
print(err.code) # e.g. "AgentNotFoundError"
|
||||||
|
print(err.http_status) # e.g. 404
|
||||||
|
print(str(err)) # human-readable description
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available scopes
|
||||||
|
|
||||||
|
| Scope | What it allows |
|
||||||
|
|-------|----------------|
|
||||||
|
| `agents:read` | Read agent records |
|
||||||
|
| `agents:write` | Create, update, decommission agents |
|
||||||
|
| `tokens:read` | Introspect tokens |
|
||||||
|
| `audit:read` | Query audit logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache 2.0 — see `LICENSE` in the repository root.
|
||||||
61
sdk-python/pyproject.toml
Normal file
61
sdk-python/pyproject.toml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "sentryagent-idp"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Python SDK for the SentryAgent.ai AgentIdP — Identity Provider for AI agents"
|
||||||
|
readme = "README.md"
|
||||||
|
license = { text = "Apache-2.0" }
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
keywords = ["ai", "agents", "identity", "oauth2", "agntcy"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Internet :: WWW/HTTP",
|
||||||
|
"Topic :: Security",
|
||||||
|
"Typing :: Typed",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.28.0",
|
||||||
|
"httpx>=0.25.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"mypy>=1.8.0",
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"respx>=0.20.0",
|
||||||
|
"responses>=0.24.0",
|
||||||
|
"types-requests>=2.31.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/sentryagent_idp"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
python_version = "3.9"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_any_generics = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "--cov=src/sentryagent_idp --cov-report=term-missing --cov-fail-under=80"
|
||||||
82
sdk-python/src/sentryagent_idp/__init__.py
Normal file
82
sdk-python/src/sentryagent_idp/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
SentryAgent.ai AgentIdP Python SDK.
|
||||||
|
|
||||||
|
Provides synchronous and asynchronous clients for the AgentIdP API.
|
||||||
|
|
||||||
|
Example (sync)::
|
||||||
|
|
||||||
|
from sentryagent_idp import AgentIdPClient
|
||||||
|
|
||||||
|
client = AgentIdPClient(
|
||||||
|
base_url="http://localhost:3000",
|
||||||
|
client_id="your-agent-id",
|
||||||
|
client_secret="your-client-secret",
|
||||||
|
)
|
||||||
|
result = client.agents.list_agents()
|
||||||
|
|
||||||
|
Example (async)::
|
||||||
|
|
||||||
|
from sentryagent_idp import AsyncAgentIdPClient
|
||||||
|
|
||||||
|
client = AsyncAgentIdPClient(
|
||||||
|
base_url="http://localhost:3000",
|
||||||
|
client_id="your-agent-id",
|
||||||
|
client_secret="your-client-secret",
|
||||||
|
)
|
||||||
|
result = await client.agents.list_agents()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .client import AgentIdPClient, AsyncAgentIdPClient
|
||||||
|
from .errors import AgentIdPError
|
||||||
|
from .token_manager import TokenManager
|
||||||
|
from .async_token_manager import AsyncTokenManager
|
||||||
|
from .types import (
|
||||||
|
Agent,
|
||||||
|
AgentStatus,
|
||||||
|
AgentType,
|
||||||
|
AuditAction,
|
||||||
|
AuditEvent,
|
||||||
|
AuditOutcome,
|
||||||
|
Credential,
|
||||||
|
CredentialStatus,
|
||||||
|
CredentialWithSecret,
|
||||||
|
DeploymentEnv,
|
||||||
|
IntrospectResponse,
|
||||||
|
OAuthScope,
|
||||||
|
PaginatedAgents,
|
||||||
|
PaginatedAuditEvents,
|
||||||
|
PaginatedCredentials,
|
||||||
|
RegisterAgentRequest,
|
||||||
|
TokenResponse,
|
||||||
|
UpdateAgentRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Clients
|
||||||
|
"AgentIdPClient",
|
||||||
|
"AsyncAgentIdPClient",
|
||||||
|
# Errors
|
||||||
|
"AgentIdPError",
|
||||||
|
# Token managers (for advanced use)
|
||||||
|
"TokenManager",
|
||||||
|
"AsyncTokenManager",
|
||||||
|
# Types
|
||||||
|
"Agent",
|
||||||
|
"AgentStatus",
|
||||||
|
"AgentType",
|
||||||
|
"AuditAction",
|
||||||
|
"AuditEvent",
|
||||||
|
"AuditOutcome",
|
||||||
|
"Credential",
|
||||||
|
"CredentialStatus",
|
||||||
|
"CredentialWithSecret",
|
||||||
|
"DeploymentEnv",
|
||||||
|
"IntrospectResponse",
|
||||||
|
"OAuthScope",
|
||||||
|
"PaginatedAgents",
|
||||||
|
"PaginatedAuditEvents",
|
||||||
|
"PaginatedCredentials",
|
||||||
|
"RegisterAgentRequest",
|
||||||
|
"TokenResponse",
|
||||||
|
"UpdateAgentRequest",
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
sdk-python/src/sentryagent_idp/__pycache__/types.cpython-312.pyc
Normal file
BIN
sdk-python/src/sentryagent_idp/__pycache__/types.cpython-312.pyc
Normal file
Binary file not shown.
127
sdk-python/src/sentryagent_idp/_request.py
Normal file
127
sdk-python/src/sentryagent_idp/_request.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Internal HTTP request helpers shared by all service clients.
|
||||||
|
Not part of the public API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .errors import AgentIdPError
|
||||||
|
|
||||||
|
|
||||||
|
def sync_request(
|
||||||
|
method: str,
|
||||||
|
base_url: str,
|
||||||
|
path: str,
|
||||||
|
token: str,
|
||||||
|
body: Optional[Any] = None,
|
||||||
|
params: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Make a synchronous authenticated JSON request to the AgentIdP API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, PATCH, DELETE).
|
||||||
|
base_url: AgentIdP base URL.
|
||||||
|
path: API path (e.g. ``/api/v1/agents``).
|
||||||
|
token: Bearer access token.
|
||||||
|
body: Optional request body (serialised as JSON).
|
||||||
|
params: Optional query parameters (None values are excluded).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON response body, or None for 204 responses.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On any API or network failure.
|
||||||
|
"""
|
||||||
|
url = base_url.rstrip("/") + path
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
filtered_params: Optional[Dict[str, str]] = (
|
||||||
|
{k: str(v) for k, v in params.items() if v is not None}
|
||||||
|
if params
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
params=filtered_params,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise AgentIdPError.network_error(exc) from exc
|
||||||
|
|
||||||
|
if response.status_code == 204:
|
||||||
|
return None
|
||||||
|
|
||||||
|
resp_body = response.json() if response.content else {}
|
||||||
|
if not response.ok:
|
||||||
|
raise AgentIdPError.from_api_error(resp_body, response.status_code)
|
||||||
|
return resp_body
|
||||||
|
|
||||||
|
|
||||||
|
async def async_request(
|
||||||
|
method: str,
|
||||||
|
base_url: str,
|
||||||
|
path: str,
|
||||||
|
token: str,
|
||||||
|
body: Optional[Any] = None,
|
||||||
|
params: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Make an asynchronous authenticated JSON request to the AgentIdP API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, PATCH, DELETE).
|
||||||
|
base_url: AgentIdP base URL.
|
||||||
|
path: API path.
|
||||||
|
token: Bearer access token.
|
||||||
|
body: Optional request body (serialised as JSON).
|
||||||
|
params: Optional query parameters (None values are excluded).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON response body, or None for 204 responses.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On any API or network failure.
|
||||||
|
"""
|
||||||
|
url = base_url.rstrip("/") + path
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
filtered_params: Optional[Dict[str, str]] = (
|
||||||
|
{k: str(v) for k, v in params.items() if v is not None}
|
||||||
|
if params
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
json=body,
|
||||||
|
params=filtered_params,
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise AgentIdPError.network_error(exc) from exc
|
||||||
|
|
||||||
|
if response.status_code == 204:
|
||||||
|
return None
|
||||||
|
|
||||||
|
resp_body = response.json() if response.content else {}
|
||||||
|
if not response.is_success:
|
||||||
|
raise AgentIdPError.from_api_error(resp_body, response.status_code)
|
||||||
|
return resp_body
|
||||||
117
sdk-python/src/sentryagent_idp/async_token_manager.py
Normal file
117
sdk-python/src/sentryagent_idp/async_token_manager.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Asynchronous TokenManager — handles OAuth 2.0 token acquisition, caching, and refresh.
|
||||||
|
Uses httpx for async HTTP. Tokens are re-issued automatically when within 60 seconds of expiry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .errors import AgentIdPError
|
||||||
|
from .types import TokenResponse
|
||||||
|
|
||||||
|
#: Seconds before expiry at which a token refresh is triggered.
|
||||||
|
REFRESH_BUFFER_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CachedToken:
|
||||||
|
access_token: str
|
||||||
|
expires_at: float # Unix timestamp (seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncTokenManager:
|
||||||
|
"""
|
||||||
|
Asyncio-safe asynchronous token manager.
|
||||||
|
|
||||||
|
Acquires and caches OAuth 2.0 access tokens. Automatically refreshes
|
||||||
|
the token when it is within :data:`REFRESH_BUFFER_SECONDS` of expiry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL (e.g. ``http://localhost:3000``).
|
||||||
|
client_id: The agent's ``agentId`` (UUID).
|
||||||
|
client_secret: The agent's credential secret.
|
||||||
|
scopes: Space-separated OAuth 2.0 scopes to request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
scopes: str,
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._client_id = client_id
|
||||||
|
self._client_secret = client_secret
|
||||||
|
self._scopes = scopes
|
||||||
|
self._cached: Optional[_CachedToken] = None
|
||||||
|
self._lock: Optional[asyncio.Lock] = None
|
||||||
|
|
||||||
|
def _get_lock(self) -> asyncio.Lock:
|
||||||
|
"""Lazily create the asyncio.Lock on first use (supports different event loops)."""
|
||||||
|
if self._lock is None:
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
return self._lock
|
||||||
|
|
||||||
|
async def get_token(self) -> str:
|
||||||
|
"""
|
||||||
|
Return a valid access token, refreshing if necessary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A valid JWT access token string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: If token acquisition fails.
|
||||||
|
"""
|
||||||
|
async with self._get_lock():
|
||||||
|
now = time.time()
|
||||||
|
if (
|
||||||
|
self._cached is not None
|
||||||
|
and self._cached.expires_at - now > REFRESH_BUFFER_SECONDS
|
||||||
|
):
|
||||||
|
return self._cached.access_token
|
||||||
|
|
||||||
|
token_response = await self._issue_token()
|
||||||
|
self._cached = _CachedToken(
|
||||||
|
access_token=token_response.access_token,
|
||||||
|
expires_at=now + token_response.expires_in,
|
||||||
|
)
|
||||||
|
return self._cached.access_token
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the cached token, forcing re-acquisition on the next call."""
|
||||||
|
self._cached = None
|
||||||
|
|
||||||
|
async def _issue_token(self) -> TokenResponse:
|
||||||
|
"""
|
||||||
|
POST /api/v1/token to obtain a new access token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TokenResponse from the API.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On authentication failure or network error.
|
||||||
|
"""
|
||||||
|
url = f"{self._base_url}/api/v1/token"
|
||||||
|
data = {
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self._client_id,
|
||||||
|
"client_secret": self._client_secret,
|
||||||
|
"scope": self._scopes,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(url, data=data)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise AgentIdPError.network_error(exc) from exc
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
if not response.is_success:
|
||||||
|
raise AgentIdPError.from_oauth2_error(body, response.status_code)
|
||||||
|
return TokenResponse.from_dict(body)
|
||||||
128
sdk-python/src/sentryagent_idp/client.py
Normal file
128
sdk-python/src/sentryagent_idp/client.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Top-level client for the SentryAgent.ai AgentIdP API.
|
||||||
|
Provides both synchronous (AgentIdPClient) and asynchronous (AsyncAgentIdPClient) variants.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from .token_manager import TokenManager
|
||||||
|
from .async_token_manager import AsyncTokenManager
|
||||||
|
from .services.agents import AgentRegistryClient, AsyncAgentRegistryClient
|
||||||
|
from .services.credentials import CredentialClient, AsyncCredentialClient
|
||||||
|
from .services.token import TokenClient, AsyncTokenClient
|
||||||
|
from .services.audit import AuditClient, AsyncAuditClient
|
||||||
|
from .types import OAuthScope
|
||||||
|
|
||||||
|
_DEFAULT_SCOPES: List[OAuthScope] = [
|
||||||
|
"agents:read",
|
||||||
|
"agents:write",
|
||||||
|
"tokens:read",
|
||||||
|
"audit:read",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentIdPClient:
|
||||||
|
"""
|
||||||
|
Synchronous client for the SentryAgent.ai AgentIdP API.
|
||||||
|
|
||||||
|
Composes all service clients under a single entry point. Handles token
|
||||||
|
acquisition and caching automatically via :class:`~.token_manager.TokenManager`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of the AgentIdP server (e.g. ``http://localhost:3000``).
|
||||||
|
client_id: The agent's ``agentId`` (UUID).
|
||||||
|
client_secret: The credential secret.
|
||||||
|
scopes: OAuth 2.0 scopes to request. Defaults to all four scopes.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from sentryagent_idp import AgentIdPClient, RegisterAgentRequest
|
||||||
|
|
||||||
|
client = AgentIdPClient(
|
||||||
|
base_url="http://localhost:3000",
|
||||||
|
client_id="your-agent-id",
|
||||||
|
client_secret="your-client-secret",
|
||||||
|
)
|
||||||
|
agents = client.agents.list_agents()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
scopes: Optional[List[OAuthScope]] = None,
|
||||||
|
) -> None:
|
||||||
|
scope_str = " ".join(scopes if scopes is not None else _DEFAULT_SCOPES)
|
||||||
|
self._token_manager = TokenManager(base_url, client_id, client_secret, scope_str)
|
||||||
|
|
||||||
|
get_token = self._token_manager.get_token
|
||||||
|
|
||||||
|
#: Agent Registry operations: register, list, get, update, decommission.
|
||||||
|
self.agents = AgentRegistryClient(base_url, get_token)
|
||||||
|
#: Credential operations: generate, list, rotate, revoke.
|
||||||
|
self.credentials = CredentialClient(base_url, get_token)
|
||||||
|
#: Token operations: introspect, revoke.
|
||||||
|
self.tokens = TokenClient(base_url, get_token)
|
||||||
|
#: Audit log operations: query, get event.
|
||||||
|
self.audit = AuditClient(base_url, get_token)
|
||||||
|
|
||||||
|
def clear_token_cache(self) -> None:
|
||||||
|
"""
|
||||||
|
Clear the cached access token.
|
||||||
|
The next API call will request a new token. Use this after rotating credentials.
|
||||||
|
"""
|
||||||
|
self._token_manager.clear_cache()
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncAgentIdPClient:
|
||||||
|
"""
|
||||||
|
Asynchronous client for the SentryAgent.ai AgentIdP API.
|
||||||
|
|
||||||
|
All methods are coroutines and must be awaited. Token acquisition and caching
|
||||||
|
are handled automatically via :class:`~.async_token_manager.AsyncTokenManager`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of the AgentIdP server.
|
||||||
|
client_id: The agent's ``agentId`` (UUID).
|
||||||
|
client_secret: The credential secret.
|
||||||
|
scopes: OAuth 2.0 scopes to request. Defaults to all four scopes.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
from sentryagent_idp import AsyncAgentIdPClient
|
||||||
|
|
||||||
|
client = AsyncAgentIdPClient(
|
||||||
|
base_url="http://localhost:3000",
|
||||||
|
client_id="your-agent-id",
|
||||||
|
client_secret="your-client-secret",
|
||||||
|
)
|
||||||
|
agents = await client.agents.list_agents()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
scopes: Optional[List[OAuthScope]] = None,
|
||||||
|
) -> None:
|
||||||
|
scope_str = " ".join(scopes if scopes is not None else _DEFAULT_SCOPES)
|
||||||
|
self._token_manager = AsyncTokenManager(base_url, client_id, client_secret, scope_str)
|
||||||
|
|
||||||
|
get_token = self._token_manager.get_token
|
||||||
|
|
||||||
|
#: Agent Registry operations (async).
|
||||||
|
self.agents = AsyncAgentRegistryClient(base_url, get_token)
|
||||||
|
#: Credential operations (async).
|
||||||
|
self.credentials = AsyncCredentialClient(base_url, get_token)
|
||||||
|
#: Token operations (async).
|
||||||
|
self.tokens = AsyncTokenClient(base_url, get_token)
|
||||||
|
#: Audit log operations (async).
|
||||||
|
self.audit = AsyncAuditClient(base_url, get_token)
|
||||||
|
|
||||||
|
def clear_token_cache(self) -> None:
|
||||||
|
"""Clear the cached access token."""
|
||||||
|
self._token_manager.clear_cache()
|
||||||
108
sdk-python/src/sentryagent_idp/errors.py
Normal file
108
sdk-python/src/sentryagent_idp/errors.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Error types for the SentryAgent.ai AgentIdP Python SDK.
|
||||||
|
All API failures are raised as AgentIdPError — never as raw requests/httpx exceptions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class AgentIdPError(Exception):
|
||||||
|
"""
|
||||||
|
Typed exception raised for all AgentIdP API failures.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
code: Machine-readable error code from the API (e.g. ``AgentNotFoundError``).
|
||||||
|
http_status: HTTP status code of the failed response.
|
||||||
|
details: Optional structured details from the API error response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
http_status: int,
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.http_status = http_status
|
||||||
|
self.details = details
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"AgentIdPError(code={self.code!r}, "
|
||||||
|
f"http_status={self.http_status}, "
|
||||||
|
f"message={str(self)!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_error(
|
||||||
|
cls, body: Any, http_status: int
|
||||||
|
) -> "AgentIdPError":
|
||||||
|
"""
|
||||||
|
Create an AgentIdPError from a standard API error response body.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: Parsed response body (dict or unknown).
|
||||||
|
http_status: HTTP status code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentIdPError instance.
|
||||||
|
"""
|
||||||
|
if isinstance(body, dict) and "code" in body and "message" in body:
|
||||||
|
return cls(
|
||||||
|
code=str(body["code"]),
|
||||||
|
message=str(body["message"]),
|
||||||
|
http_status=http_status,
|
||||||
|
details=body.get("details"),
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
code="UNKNOWN_ERROR",
|
||||||
|
message=str(body),
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_oauth2_error(
|
||||||
|
cls, body: Any, http_status: int
|
||||||
|
) -> "AgentIdPError":
|
||||||
|
"""
|
||||||
|
Create an AgentIdPError from an OAuth 2.0 error response body.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: Parsed response body.
|
||||||
|
http_status: HTTP status code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentIdPError instance.
|
||||||
|
"""
|
||||||
|
if isinstance(body, dict):
|
||||||
|
return cls(
|
||||||
|
code=str(body.get("error", "unknown_error")),
|
||||||
|
message=str(body.get("error_description", "Token request failed.")),
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
code="unknown_error",
|
||||||
|
message=str(body),
|
||||||
|
http_status=http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def network_error(cls, cause: Exception) -> "AgentIdPError":
|
||||||
|
"""
|
||||||
|
Create an AgentIdPError for a network-level failure (no HTTP response).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cause: The underlying exception.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentIdPError with http_status=0.
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
code="NETWORK_ERROR",
|
||||||
|
message=f"Network error: {cause}",
|
||||||
|
http_status=0,
|
||||||
|
)
|
||||||
1
sdk-python/src/sentryagent_idp/services/__init__.py
Normal file
1
sdk-python/src/sentryagent_idp/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Services package
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
202
sdk-python/src/sentryagent_idp/services/agents.py
Normal file
202
sdk-python/src/sentryagent_idp/services/agents.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Agent Registry service clients — sync and async.
|
||||||
|
Covers all five agent endpoints: register, list, get, update, decommission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Coroutine, Dict, Optional
|
||||||
|
|
||||||
|
from .._request import sync_request, async_request
|
||||||
|
from ..types import (
|
||||||
|
Agent,
|
||||||
|
AgentStatus,
|
||||||
|
AgentType,
|
||||||
|
PaginatedAgents,
|
||||||
|
RegisterAgentRequest,
|
||||||
|
UpdateAgentRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRegistryClient:
|
||||||
|
"""
|
||||||
|
Synchronous client for the Agent Registry service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL.
|
||||||
|
get_token: Callable that returns a valid Bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
get_token: Callable[[], str],
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url
|
||||||
|
self._get_token = get_token
|
||||||
|
|
||||||
|
def register_agent(self, request: RegisterAgentRequest) -> Agent:
|
||||||
|
"""
|
||||||
|
Register a new AI agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Agent registration parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created Agent record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
data = sync_request(
|
||||||
|
"POST", self._base_url, "/api/v1/agents",
|
||||||
|
self._get_token(), body=request.to_dict(),
|
||||||
|
)
|
||||||
|
return Agent.from_dict(data)
|
||||||
|
|
||||||
|
def list_agents(
|
||||||
|
self,
|
||||||
|
status: Optional[AgentStatus] = None,
|
||||||
|
agent_type: Optional[AgentType] = None,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> PaginatedAgents:
|
||||||
|
"""
|
||||||
|
List all registered agents with optional filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Filter by lifecycle status.
|
||||||
|
agent_type: Filter by agent type.
|
||||||
|
page: Page number (1-based).
|
||||||
|
limit: Results per page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedAgents response.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
data = sync_request(
|
||||||
|
"GET", self._base_url, "/api/v1/agents",
|
||||||
|
self._get_token(),
|
||||||
|
params={"status": status, "agentType": agent_type, "page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
return PaginatedAgents.from_dict(data)
|
||||||
|
|
||||||
|
def get_agent(self, agent_id: str) -> Agent:
|
||||||
|
"""
|
||||||
|
Get a single agent by its agentId.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: The agent UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Agent record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: If agent not found or network failure.
|
||||||
|
"""
|
||||||
|
data = sync_request(
|
||||||
|
"GET", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||||
|
self._get_token(),
|
||||||
|
)
|
||||||
|
return Agent.from_dict(data)
|
||||||
|
|
||||||
|
def update_agent(self, agent_id: str, request: UpdateAgentRequest) -> Agent:
|
||||||
|
"""
|
||||||
|
Update mutable fields on an existing agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: The agent UUID.
|
||||||
|
request: Fields to update.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Agent record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
data = sync_request(
|
||||||
|
"PATCH", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||||
|
self._get_token(), body=request.to_dict(),
|
||||||
|
)
|
||||||
|
return Agent.from_dict(data)
|
||||||
|
|
||||||
|
def decommission_agent(self, agent_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Decommission an agent. This is irreversible.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: The agent UUID.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
sync_request(
|
||||||
|
"DELETE", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||||
|
self._get_token(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncAgentRegistryClient:
|
||||||
|
"""
|
||||||
|
Asynchronous client for the Agent Registry service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL.
|
||||||
|
get_token: Async callable that returns a valid Bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
get_token: Callable[[], Coroutine[Any, Any, str]],
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url
|
||||||
|
self._get_token = get_token
|
||||||
|
|
||||||
|
async def register_agent(self, request: RegisterAgentRequest) -> Agent:
|
||||||
|
"""Register a new AI agent (async)."""
|
||||||
|
data = await async_request(
|
||||||
|
"POST", self._base_url, "/api/v1/agents",
|
||||||
|
await self._get_token(), body=request.to_dict(),
|
||||||
|
)
|
||||||
|
return Agent.from_dict(data)
|
||||||
|
|
||||||
|
async def list_agents(
|
||||||
|
self,
|
||||||
|
status: Optional[AgentStatus] = None,
|
||||||
|
agent_type: Optional[AgentType] = None,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> PaginatedAgents:
|
||||||
|
"""List all registered agents with optional filters (async)."""
|
||||||
|
data = await async_request(
|
||||||
|
"GET", self._base_url, "/api/v1/agents",
|
||||||
|
await self._get_token(),
|
||||||
|
params={"status": status, "agentType": agent_type, "page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
return PaginatedAgents.from_dict(data)
|
||||||
|
|
||||||
|
async def get_agent(self, agent_id: str) -> Agent:
|
||||||
|
"""Get a single agent by its agentId (async)."""
|
||||||
|
data = await async_request(
|
||||||
|
"GET", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||||
|
await self._get_token(),
|
||||||
|
)
|
||||||
|
return Agent.from_dict(data)
|
||||||
|
|
||||||
|
async def update_agent(self, agent_id: str, request: UpdateAgentRequest) -> Agent:
|
||||||
|
"""Update mutable fields on an existing agent (async)."""
|
||||||
|
data = await async_request(
|
||||||
|
"PATCH", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||||
|
await self._get_token(), body=request.to_dict(),
|
||||||
|
)
|
||||||
|
return Agent.from_dict(data)
|
||||||
|
|
||||||
|
async def decommission_agent(self, agent_id: str) -> None:
|
||||||
|
"""Decommission an agent — irreversible (async)."""
|
||||||
|
await async_request(
|
||||||
|
"DELETE", self._base_url, f"/api/v1/agents/{agent_id}",
|
||||||
|
await self._get_token(),
|
||||||
|
)
|
||||||
144
sdk-python/src/sentryagent_idp/services/audit.py
Normal file
144
sdk-python/src/sentryagent_idp/services/audit.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
Audit Log service clients — sync and async.
|
||||||
|
Covers query (list) and get-by-id operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Coroutine, Optional
|
||||||
|
|
||||||
|
from .._request import sync_request, async_request
|
||||||
|
from ..types import AuditAction, AuditEvent, AuditOutcome, PaginatedAuditEvents
|
||||||
|
|
||||||
|
|
||||||
|
class AuditClient:
|
||||||
|
"""
|
||||||
|
Synchronous client for the Audit Log service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL.
|
||||||
|
get_token: Callable that returns a valid Bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
get_token: Callable[[], str],
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url
|
||||||
|
self._get_token = get_token
|
||||||
|
|
||||||
|
def query_audit_log(
|
||||||
|
self,
|
||||||
|
agent_id: Optional[str] = None,
|
||||||
|
action: Optional[AuditAction] = None,
|
||||||
|
outcome: Optional[AuditOutcome] = None,
|
||||||
|
from_date: Optional[str] = None,
|
||||||
|
to_date: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> PaginatedAuditEvents:
|
||||||
|
"""
|
||||||
|
Query audit log events with optional filters. Requires ``audit:read`` scope.
|
||||||
|
Events are retained for 90 days.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: Filter by agent UUID.
|
||||||
|
action: Filter by audit action type.
|
||||||
|
outcome: Filter by outcome (success or failure).
|
||||||
|
from_date: ISO 8601 start datetime (inclusive).
|
||||||
|
to_date: ISO 8601 end datetime (inclusive).
|
||||||
|
page: Page number (1-based).
|
||||||
|
limit: Results per page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedAuditEvents response.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
data = sync_request(
|
||||||
|
"GET", self._base_url, "/api/v1/audit",
|
||||||
|
self._get_token(),
|
||||||
|
params={
|
||||||
|
"agentId": agent_id,
|
||||||
|
"action": action,
|
||||||
|
"outcome": outcome,
|
||||||
|
"fromDate": from_date,
|
||||||
|
"toDate": to_date,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return PaginatedAuditEvents.from_dict(data)
|
||||||
|
|
||||||
|
def get_audit_event(self, event_id: str) -> AuditEvent:
|
||||||
|
"""
|
||||||
|
Get a single audit event by its eventId. Requires ``audit:read`` scope.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_id: The audit event UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AuditEvent record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
data = sync_request(
|
||||||
|
"GET", self._base_url, f"/api/v1/audit/{event_id}",
|
||||||
|
self._get_token(),
|
||||||
|
)
|
||||||
|
return AuditEvent.from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncAuditClient:
|
||||||
|
"""
|
||||||
|
Asynchronous client for the Audit Log service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL.
|
||||||
|
get_token: Async callable that returns a valid Bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
get_token: Callable[[], Coroutine[Any, Any, str]],
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url
|
||||||
|
self._get_token = get_token
|
||||||
|
|
||||||
|
async def query_audit_log(
|
||||||
|
self,
|
||||||
|
agent_id: Optional[str] = None,
|
||||||
|
action: Optional[AuditAction] = None,
|
||||||
|
outcome: Optional[AuditOutcome] = None,
|
||||||
|
from_date: Optional[str] = None,
|
||||||
|
to_date: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> PaginatedAuditEvents:
|
||||||
|
"""Query audit log events with optional filters (async)."""
|
||||||
|
data = await async_request(
|
||||||
|
"GET", self._base_url, "/api/v1/audit",
|
||||||
|
await self._get_token(),
|
||||||
|
params={
|
||||||
|
"agentId": agent_id,
|
||||||
|
"action": action,
|
||||||
|
"outcome": outcome,
|
||||||
|
"fromDate": from_date,
|
||||||
|
"toDate": to_date,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return PaginatedAuditEvents.from_dict(data)
|
||||||
|
|
||||||
|
async def get_audit_event(self, event_id: str) -> AuditEvent:
|
||||||
|
"""Get a single audit event by its eventId (async)."""
|
||||||
|
data = await async_request(
|
||||||
|
"GET", self._base_url, f"/api/v1/audit/{event_id}",
|
||||||
|
await self._get_token(),
|
||||||
|
)
|
||||||
|
return AuditEvent.from_dict(data)
|
||||||
209
sdk-python/src/sentryagent_idp/services/credentials.py
Normal file
209
sdk-python/src/sentryagent_idp/services/credentials.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
Credential Management service clients — sync and async.
|
||||||
|
Covers generate, list, rotate, and revoke operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Coroutine, Optional
|
||||||
|
|
||||||
|
from .._request import sync_request, async_request
|
||||||
|
from ..types import (
|
||||||
|
Credential,
|
||||||
|
CredentialStatus,
|
||||||
|
CredentialWithSecret,
|
||||||
|
PaginatedCredentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialClient:
|
||||||
|
"""
|
||||||
|
Synchronous client for the Credential Management service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL.
|
||||||
|
get_token: Callable that returns a valid Bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
get_token: Callable[[], str],
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url
|
||||||
|
self._get_token = get_token
|
||||||
|
|
||||||
|
def generate_credential(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
expires_at: Optional[str] = None,
|
||||||
|
) -> CredentialWithSecret:
|
||||||
|
"""
|
||||||
|
Generate a new credential for an agent.
|
||||||
|
The ``client_secret`` is shown **once** — store it securely immediately.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: The agent UUID.
|
||||||
|
expires_at: Optional ISO 8601 expiry date string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CredentialWithSecret including the one-time plain-text secret.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
body = {"expiresAt": expires_at} if expires_at is not None else None
|
||||||
|
data = sync_request(
|
||||||
|
"POST", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
|
||||||
|
self._get_token(), body=body,
|
||||||
|
)
|
||||||
|
return CredentialWithSecret.from_dict(data)
|
||||||
|
|
||||||
|
def list_credentials(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
status: Optional[CredentialStatus] = None,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> PaginatedCredentials:
|
||||||
|
"""
|
||||||
|
List credentials for an agent. Secrets are never returned in list responses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: The agent UUID.
|
||||||
|
status: Filter by credential status.
|
||||||
|
page: Page number (1-based).
|
||||||
|
limit: Results per page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedCredentials response.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
data = sync_request(
|
||||||
|
"GET", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
|
||||||
|
self._get_token(),
|
||||||
|
params={"status": status, "page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
return PaginatedCredentials.from_dict(data)
|
||||||
|
|
||||||
|
def rotate_credential(
|
||||||
|
self, agent_id: str, credential_id: str
|
||||||
|
) -> CredentialWithSecret:
|
||||||
|
"""
|
||||||
|
Rotate a credential. The same ``credential_id`` is retained; a new secret is issued.
|
||||||
|
The old secret is immediately invalidated.
|
||||||
|
The new ``client_secret`` is shown **once** — store it securely immediately.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: The agent UUID.
|
||||||
|
credential_id: The credential UUID to rotate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CredentialWithSecret with the new one-time secret.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
data = sync_request(
|
||||||
|
"POST",
|
||||||
|
self._base_url,
|
||||||
|
f"/api/v1/agents/{agent_id}/credentials/{credential_id}/rotate",
|
||||||
|
self._get_token(),
|
||||||
|
)
|
||||||
|
return CredentialWithSecret.from_dict(data)
|
||||||
|
|
||||||
|
def revoke_credential(
|
||||||
|
self, agent_id: str, credential_id: str
|
||||||
|
) -> Credential:
|
||||||
|
"""
|
||||||
|
Revoke a credential permanently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_id: The agent UUID.
|
||||||
|
credential_id: The credential UUID to revoke.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The revoked Credential record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
data = sync_request(
|
||||||
|
"DELETE",
|
||||||
|
self._base_url,
|
||||||
|
f"/api/v1/agents/{agent_id}/credentials/{credential_id}",
|
||||||
|
self._get_token(),
|
||||||
|
)
|
||||||
|
return Credential.from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncCredentialClient:
|
||||||
|
"""
|
||||||
|
Asynchronous client for the Credential Management service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL.
|
||||||
|
get_token: Async callable that returns a valid Bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
get_token: Callable[[], Coroutine[Any, Any, str]],
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url
|
||||||
|
self._get_token = get_token
|
||||||
|
|
||||||
|
async def generate_credential(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
expires_at: Optional[str] = None,
|
||||||
|
) -> CredentialWithSecret:
|
||||||
|
"""Generate a new credential for an agent (async)."""
|
||||||
|
body = {"expiresAt": expires_at} if expires_at is not None else None
|
||||||
|
data = await async_request(
|
||||||
|
"POST", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
|
||||||
|
await self._get_token(), body=body,
|
||||||
|
)
|
||||||
|
return CredentialWithSecret.from_dict(data)
|
||||||
|
|
||||||
|
async def list_credentials(
|
||||||
|
self,
|
||||||
|
agent_id: str,
|
||||||
|
status: Optional[CredentialStatus] = None,
|
||||||
|
page: int = 1,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> PaginatedCredentials:
|
||||||
|
"""List credentials for an agent (async)."""
|
||||||
|
data = await async_request(
|
||||||
|
"GET", self._base_url, f"/api/v1/agents/{agent_id}/credentials",
|
||||||
|
await self._get_token(),
|
||||||
|
params={"status": status, "page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
return PaginatedCredentials.from_dict(data)
|
||||||
|
|
||||||
|
async def rotate_credential(
|
||||||
|
self, agent_id: str, credential_id: str
|
||||||
|
) -> CredentialWithSecret:
|
||||||
|
"""Rotate a credential (async)."""
|
||||||
|
data = await async_request(
|
||||||
|
"POST",
|
||||||
|
self._base_url,
|
||||||
|
f"/api/v1/agents/{agent_id}/credentials/{credential_id}/rotate",
|
||||||
|
await self._get_token(),
|
||||||
|
)
|
||||||
|
return CredentialWithSecret.from_dict(data)
|
||||||
|
|
||||||
|
async def revoke_credential(
|
||||||
|
self, agent_id: str, credential_id: str
|
||||||
|
) -> Credential:
|
||||||
|
"""Revoke a credential permanently (async)."""
|
||||||
|
data = await async_request(
|
||||||
|
"DELETE",
|
||||||
|
self._base_url,
|
||||||
|
f"/api/v1/agents/{agent_id}/credentials/{credential_id}",
|
||||||
|
await self._get_token(),
|
||||||
|
)
|
||||||
|
return Credential.from_dict(data)
|
||||||
154
sdk-python/src/sentryagent_idp/services/token.py
Normal file
154
sdk-python/src/sentryagent_idp/services/token.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
Token service clients (introspect and revoke) — sync and async.
|
||||||
|
Token issuance is handled by TokenManager / AsyncTokenManager.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Coroutine
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..errors import AgentIdPError
|
||||||
|
from ..types import IntrospectResponse
|
||||||
|
|
||||||
|
|
||||||
|
class TokenClient:
|
||||||
|
"""
|
||||||
|
Synchronous client for token introspection and revocation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL.
|
||||||
|
get_token: Callable that returns a valid Bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
get_token: Callable[[], str],
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._get_token = get_token
|
||||||
|
|
||||||
|
def introspect_token(self, token_to_check: str) -> IntrospectResponse:
|
||||||
|
"""
|
||||||
|
Check whether a token is currently active.
|
||||||
|
Always returns successfully — check ``response.active`` for validity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_to_check: The JWT string to introspect.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IntrospectResponse with ``active`` field set.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
url = f"{self._base_url}/api/v1/token/introspect"
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
data={"token": token_to_check},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self._get_token()}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise AgentIdPError.network_error(exc) from exc
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
if not response.ok:
|
||||||
|
raise AgentIdPError.from_api_error(body, response.status_code)
|
||||||
|
return IntrospectResponse.from_dict(body)
|
||||||
|
|
||||||
|
def revoke_token(self, token_to_revoke: str) -> None:
|
||||||
|
"""
|
||||||
|
Revoke a token immediately. Idempotent (RFC 7009).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_to_revoke: The JWT string to revoke.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On API or network failure.
|
||||||
|
"""
|
||||||
|
url = f"{self._base_url}/api/v1/token/revoke"
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
data={"token": token_to_revoke},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self._get_token()}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise AgentIdPError.network_error(exc) from exc
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
body = response.json() if response.content else {}
|
||||||
|
raise AgentIdPError.from_api_error(body, response.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncTokenClient:
|
||||||
|
"""
|
||||||
|
Asynchronous client for token introspection and revocation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL.
|
||||||
|
get_token: Async callable that returns a valid Bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
get_token: Callable[[], Coroutine[Any, Any, str]],
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._get_token = get_token
|
||||||
|
|
||||||
|
async def introspect_token(self, token_to_check: str) -> IntrospectResponse:
|
||||||
|
"""Check whether a token is currently active (async)."""
|
||||||
|
url = f"{self._base_url}/api/v1/token/introspect"
|
||||||
|
token = await self._get_token()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
data={"token": token_to_check},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise AgentIdPError.network_error(exc) from exc
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
if not response.is_success:
|
||||||
|
raise AgentIdPError.from_api_error(body, response.status_code)
|
||||||
|
return IntrospectResponse.from_dict(body)
|
||||||
|
|
||||||
|
async def revoke_token(self, token_to_revoke: str) -> None:
|
||||||
|
"""Revoke a token immediately — idempotent (async)."""
|
||||||
|
url = f"{self._base_url}/api/v1/token/revoke"
|
||||||
|
token = await self._get_token()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
data={"token": token_to_revoke},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise AgentIdPError.network_error(exc) from exc
|
||||||
|
|
||||||
|
if not response.is_success:
|
||||||
|
body = response.json() if response.content else {}
|
||||||
|
raise AgentIdPError.from_api_error(body, response.status_code)
|
||||||
116
sdk-python/src/sentryagent_idp/token_manager.py
Normal file
116
sdk-python/src/sentryagent_idp/token_manager.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Synchronous TokenManager — handles OAuth 2.0 token acquisition, caching, and refresh.
|
||||||
|
Tokens are re-issued automatically when expired or within 60 seconds of expiry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .errors import AgentIdPError
|
||||||
|
from .types import TokenResponse
|
||||||
|
|
||||||
|
#: Seconds before expiry at which a token refresh is triggered.
|
||||||
|
REFRESH_BUFFER_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CachedToken:
|
||||||
|
access_token: str
|
||||||
|
expires_at: float # Unix timestamp (seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenManager:
|
||||||
|
"""
|
||||||
|
Thread-safe synchronous token manager.
|
||||||
|
|
||||||
|
Acquires and caches OAuth 2.0 access tokens. Automatically refreshes
|
||||||
|
the token when it is within :data:`REFRESH_BUFFER_SECONDS` of expiry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: AgentIdP server base URL (e.g. ``http://localhost:3000``).
|
||||||
|
client_id: The agent's ``agentId`` (UUID).
|
||||||
|
client_secret: The agent's credential secret.
|
||||||
|
scopes: Space-separated OAuth 2.0 scopes to request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
scopes: str,
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._client_id = client_id
|
||||||
|
self._client_secret = client_secret
|
||||||
|
self._scopes = scopes
|
||||||
|
self._cached: Optional[_CachedToken] = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get_token(self) -> str:
|
||||||
|
"""
|
||||||
|
Return a valid access token, refreshing if necessary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A valid JWT access token string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: If token acquisition fails.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
now = time.time()
|
||||||
|
if (
|
||||||
|
self._cached is not None
|
||||||
|
and self._cached.expires_at - now > REFRESH_BUFFER_SECONDS
|
||||||
|
):
|
||||||
|
return self._cached.access_token
|
||||||
|
|
||||||
|
token_response = self._issue_token()
|
||||||
|
self._cached = _CachedToken(
|
||||||
|
access_token=token_response.access_token,
|
||||||
|
expires_at=now + token_response.expires_in,
|
||||||
|
)
|
||||||
|
return self._cached.access_token
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the cached token, forcing re-acquisition on the next call."""
|
||||||
|
with self._lock:
|
||||||
|
self._cached = None
|
||||||
|
|
||||||
|
def _issue_token(self) -> TokenResponse:
|
||||||
|
"""
|
||||||
|
POST /api/v1/token to obtain a new access token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TokenResponse from the API.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentIdPError: On authentication failure or network error.
|
||||||
|
"""
|
||||||
|
url = f"{self._base_url}/api/v1/token"
|
||||||
|
data = {
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self._client_id,
|
||||||
|
"client_secret": self._client_secret,
|
||||||
|
"scope": self._scopes,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise AgentIdPError.network_error(exc) from exc
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
if not response.ok:
|
||||||
|
raise AgentIdPError.from_oauth2_error(body, response.status_code)
|
||||||
|
return TokenResponse.from_dict(body)
|
||||||
323
sdk-python/src/sentryagent_idp/types.py
Normal file
323
sdk-python/src/sentryagent_idp/types.py
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
"""
|
||||||
|
Type definitions for the SentryAgent.ai AgentIdP Python SDK.
|
||||||
|
All request and response shapes derived from the AgentIdP OpenAPI 3.0 specs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Literal, Optional
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Enums / Literal types
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
AgentType = Literal[
|
||||||
|
"screener",
|
||||||
|
"classifier",
|
||||||
|
"orchestrator",
|
||||||
|
"extractor",
|
||||||
|
"summarizer",
|
||||||
|
"router",
|
||||||
|
"monitor",
|
||||||
|
"custom",
|
||||||
|
]
|
||||||
|
|
||||||
|
AgentStatus = Literal["active", "suspended", "decommissioned"]
|
||||||
|
|
||||||
|
DeploymentEnv = Literal["development", "staging", "production"]
|
||||||
|
|
||||||
|
CredentialStatus = Literal["active", "revoked"]
|
||||||
|
|
||||||
|
OAuthScope = Literal["agents:read", "agents:write", "tokens:read", "audit:read"]
|
||||||
|
|
||||||
|
AuditAction = Literal[
|
||||||
|
"agent.created",
|
||||||
|
"agent.updated",
|
||||||
|
"agent.decommissioned",
|
||||||
|
"agent.suspended",
|
||||||
|
"agent.reactivated",
|
||||||
|
"token.issued",
|
||||||
|
"token.revoked",
|
||||||
|
"token.introspected",
|
||||||
|
"credential.generated",
|
||||||
|
"credential.rotated",
|
||||||
|
"credential.revoked",
|
||||||
|
"auth.failed",
|
||||||
|
]
|
||||||
|
|
||||||
|
AuditOutcome = Literal["success", "failure"]
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Agent Registry
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Agent:
|
||||||
|
"""A registered AI agent identity."""
|
||||||
|
|
||||||
|
agent_id: str
|
||||||
|
email: str
|
||||||
|
agent_type: AgentType
|
||||||
|
version: str
|
||||||
|
capabilities: List[str]
|
||||||
|
owner: str
|
||||||
|
deployment_env: DeploymentEnv
|
||||||
|
status: AgentStatus
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "Agent":
|
||||||
|
"""Deserialise from an API response dict."""
|
||||||
|
return cls(
|
||||||
|
agent_id=data["agentId"],
|
||||||
|
email=data["email"],
|
||||||
|
agent_type=data["agentType"],
|
||||||
|
version=data["version"],
|
||||||
|
capabilities=data["capabilities"],
|
||||||
|
owner=data["owner"],
|
||||||
|
deployment_env=data["deploymentEnv"],
|
||||||
|
status=data["status"],
|
||||||
|
created_at=data["createdAt"],
|
||||||
|
updated_at=data["updatedAt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegisterAgentRequest:
|
||||||
|
"""Request body for registering a new AI agent."""
|
||||||
|
|
||||||
|
email: str
|
||||||
|
agent_type: AgentType
|
||||||
|
version: str
|
||||||
|
capabilities: List[str]
|
||||||
|
owner: str
|
||||||
|
deployment_env: DeploymentEnv
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialise to API request dict."""
|
||||||
|
return {
|
||||||
|
"email": self.email,
|
||||||
|
"agentType": self.agent_type,
|
||||||
|
"version": self.version,
|
||||||
|
"capabilities": self.capabilities,
|
||||||
|
"owner": self.owner,
|
||||||
|
"deploymentEnv": self.deployment_env,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpdateAgentRequest:
|
||||||
|
"""Request body for partially updating an agent (all fields optional)."""
|
||||||
|
|
||||||
|
agent_type: Optional[AgentType] = None
|
||||||
|
version: Optional[str] = None
|
||||||
|
capabilities: Optional[List[str]] = None
|
||||||
|
owner: Optional[str] = None
|
||||||
|
deployment_env: Optional[DeploymentEnv] = None
|
||||||
|
status: Optional[AgentStatus] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialise to API request dict, omitting None fields."""
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
if self.agent_type is not None:
|
||||||
|
out["agentType"] = self.agent_type
|
||||||
|
if self.version is not None:
|
||||||
|
out["version"] = self.version
|
||||||
|
if self.capabilities is not None:
|
||||||
|
out["capabilities"] = self.capabilities
|
||||||
|
if self.owner is not None:
|
||||||
|
out["owner"] = self.owner
|
||||||
|
if self.deployment_env is not None:
|
||||||
|
out["deploymentEnv"] = self.deployment_env
|
||||||
|
if self.status is not None:
|
||||||
|
out["status"] = self.status
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaginatedAgents:
|
||||||
|
"""Paginated list of agents."""
|
||||||
|
|
||||||
|
data: List[Agent]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedAgents":
|
||||||
|
return cls(
|
||||||
|
data=[Agent.from_dict(a) for a in d["data"]],
|
||||||
|
total=d["total"],
|
||||||
|
page=d["page"],
|
||||||
|
limit=d["limit"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Credential Management
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Credential:
|
||||||
|
"""A credential record (client_secret never included)."""
|
||||||
|
|
||||||
|
credential_id: str
|
||||||
|
client_id: str
|
||||||
|
status: CredentialStatus
|
||||||
|
created_at: str
|
||||||
|
expires_at: Optional[str]
|
||||||
|
revoked_at: Optional[str]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "Credential":
|
||||||
|
return cls(
|
||||||
|
credential_id=data["credentialId"],
|
||||||
|
client_id=data["clientId"],
|
||||||
|
status=data["status"],
|
||||||
|
created_at=data["createdAt"],
|
||||||
|
expires_at=data.get("expiresAt"),
|
||||||
|
revoked_at=data.get("revokedAt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CredentialWithSecret(Credential):
|
||||||
|
"""Credential with plain-text secret — returned once only on create/rotate."""
|
||||||
|
|
||||||
|
client_secret: str = field(default="")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "CredentialWithSecret":
|
||||||
|
base = Credential.from_dict(data)
|
||||||
|
return cls(
|
||||||
|
credential_id=base.credential_id,
|
||||||
|
client_id=base.client_id,
|
||||||
|
status=base.status,
|
||||||
|
created_at=base.created_at,
|
||||||
|
expires_at=base.expires_at,
|
||||||
|
revoked_at=base.revoked_at,
|
||||||
|
client_secret=data["clientSecret"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaginatedCredentials:
|
||||||
|
"""Paginated list of credentials."""
|
||||||
|
|
||||||
|
data: List[Credential]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedCredentials":
|
||||||
|
return cls(
|
||||||
|
data=[Credential.from_dict(c) for c in d["data"]],
|
||||||
|
total=d["total"],
|
||||||
|
page=d["page"],
|
||||||
|
limit=d["limit"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# OAuth 2.0 Tokens
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TokenResponse:
|
||||||
|
"""OAuth 2.0 access token response."""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
expires_in: int
|
||||||
|
scope: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "TokenResponse":
|
||||||
|
return cls(
|
||||||
|
access_token=data["access_token"],
|
||||||
|
token_type=data["token_type"],
|
||||||
|
expires_in=data["expires_in"],
|
||||||
|
scope=data["scope"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IntrospectResponse:
|
||||||
|
"""Token introspection response (RFC 7662)."""
|
||||||
|
|
||||||
|
active: bool
|
||||||
|
sub: Optional[str] = None
|
||||||
|
client_id: Optional[str] = None
|
||||||
|
scope: Optional[str] = None
|
||||||
|
token_type: Optional[str] = None
|
||||||
|
iat: Optional[int] = None
|
||||||
|
exp: Optional[int] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "IntrospectResponse":
|
||||||
|
return cls(
|
||||||
|
active=data["active"],
|
||||||
|
sub=data.get("sub"),
|
||||||
|
client_id=data.get("client_id"),
|
||||||
|
scope=data.get("scope"),
|
||||||
|
token_type=data.get("token_type"),
|
||||||
|
iat=data.get("iat"),
|
||||||
|
exp=data.get("exp"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Audit Log
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuditEvent:
|
||||||
|
"""An immutable audit event record."""
|
||||||
|
|
||||||
|
event_id: str
|
||||||
|
agent_id: str
|
||||||
|
action: AuditAction
|
||||||
|
outcome: AuditOutcome
|
||||||
|
ip_address: str
|
||||||
|
user_agent: str
|
||||||
|
metadata: Dict[str, Any]
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "AuditEvent":
|
||||||
|
return cls(
|
||||||
|
event_id=data["eventId"],
|
||||||
|
agent_id=data["agentId"],
|
||||||
|
action=data["action"],
|
||||||
|
outcome=data["outcome"],
|
||||||
|
ip_address=data["ipAddress"],
|
||||||
|
user_agent=data["userAgent"],
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
timestamp=data["timestamp"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaginatedAuditEvents:
|
||||||
|
"""Paginated list of audit events."""
|
||||||
|
|
||||||
|
data: List[AuditEvent]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: Dict[str, Any]) -> "PaginatedAuditEvents":
|
||||||
|
return cls(
|
||||||
|
data=[AuditEvent.from_dict(e) for e in d["data"]],
|
||||||
|
total=d["total"],
|
||||||
|
page=d["page"],
|
||||||
|
limit=d["limit"],
|
||||||
|
)
|
||||||
0
sdk-python/tests/__init__.py
Normal file
0
sdk-python/tests/__init__.py
Normal file
BIN
sdk-python/tests/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
sdk-python/tests/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
52
sdk-python/tests/test_errors.py
Normal file
52
sdk-python/tests/test_errors.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Tests for AgentIdPError."""
|
||||||
|
|
||||||
|
from sentryagent_idp.errors import AgentIdPError
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_construction() -> None:
|
||||||
|
err = AgentIdPError("AgentNotFoundError", "Agent not found.", 404)
|
||||||
|
assert err.code == "AgentNotFoundError"
|
||||||
|
assert err.http_status == 404
|
||||||
|
assert str(err) == "Agent not found."
|
||||||
|
assert err.details is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_api_error_valid_body() -> None:
|
||||||
|
body = {"code": "AgentNotFoundError", "message": "Not found.", "details": {"id": "x"}}
|
||||||
|
err = AgentIdPError.from_api_error(body, 404)
|
||||||
|
assert err.code == "AgentNotFoundError"
|
||||||
|
assert err.http_status == 404
|
||||||
|
assert err.details == {"id": "x"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_api_error_unknown_body() -> None:
|
||||||
|
err = AgentIdPError.from_api_error("plain string", 500)
|
||||||
|
assert err.code == "UNKNOWN_ERROR"
|
||||||
|
assert err.http_status == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_oauth2_error() -> None:
|
||||||
|
body = {"error": "invalid_client", "error_description": "Bad credentials."}
|
||||||
|
err = AgentIdPError.from_oauth2_error(body, 401)
|
||||||
|
assert err.code == "invalid_client"
|
||||||
|
assert str(err) == "Bad credentials."
|
||||||
|
assert err.http_status == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_oauth2_error_unknown() -> None:
|
||||||
|
err = AgentIdPError.from_oauth2_error("garbage", 400)
|
||||||
|
assert err.code == "unknown_error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_error() -> None:
|
||||||
|
cause = ConnectionError("refused")
|
||||||
|
err = AgentIdPError.network_error(cause)
|
||||||
|
assert err.code == "NETWORK_ERROR"
|
||||||
|
assert err.http_status == 0
|
||||||
|
assert "refused" in str(err)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repr() -> None:
|
||||||
|
err = AgentIdPError("CODE", "msg", 400)
|
||||||
|
assert "AgentIdPError" in repr(err)
|
||||||
|
assert "CODE" in repr(err)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user