feat(phase-5): WS1 — Rust SDK
Implements the sentryagent-idp Rust SDK crate (sdk-rust/) with: - TokenManager with Arc<Mutex<TokenCache>> for thread-safe token caching - AgentIdPClient with full method coverage: agents, oauth2, credentials, audit, marketplace, delegation - Error hierarchy via thiserror (AgentIdPError enum) - All model types with serde derive - 429 RateLimited handling with Retry-After parsing; zero unwrap() calls - Unit tests (mockito), doc tests, and integration tests (#[ignore]) - quickstart example, full README, cargo doc clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1824
sdk-rust/Cargo.lock
generated
Normal file
1824
sdk-rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
sdk-rust/Cargo.toml
Normal file
28
sdk-rust/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "sentryagent-idp"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Production-grade Rust SDK for SentryAgent.ai AgentIdP — agent identity, credentials, and A2A delegation"
|
||||||
|
authors = ["SentryAgent.ai <sdk@sentryagent.ai>"]
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/sentryagent/sentryagent-idp"
|
||||||
|
documentation = "https://docs.rs/sentryagent-idp"
|
||||||
|
keywords = ["agent", "identity", "oauth2", "ai", "oidc"]
|
||||||
|
categories = ["authentication", "web-programming::http-client"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1.35", features = ["full"] }
|
||||||
|
reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
uuid = { version = "1.6", features = ["v4"] }
|
||||||
|
thiserror = "1.0"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
|
mockito = "1.2"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "quickstart"
|
||||||
|
path = "examples/quickstart.rs"
|
||||||
171
sdk-rust/README.md
Normal file
171
sdk-rust/README.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# sentryagent-idp — Rust SDK
|
||||||
|
|
||||||
|
Production-grade Rust client for the [SentryAgent.ai](https://sentryagent.ai) AgentIdP API. Covers all 14 API endpoints across agent identity, OAuth 2.0 token management, credential rotation, audit logs, the public marketplace, and A2A delegation.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Async-first — every API call is `async` and backed by `tokio`
|
||||||
|
- Thread-safe token cache — `TokenManager` refreshes tokens automatically before expiry
|
||||||
|
- Typed errors — every failure maps to a variant of `AgentIdPError`
|
||||||
|
- Zero `unwrap()` in library code — all errors propagated with `?`
|
||||||
|
- Full `//!` and `///` doc coverage — `cargo doc --no-deps` generates clean docs
|
||||||
|
- `#![deny(warnings)]` enforced — zero clippy warnings
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add to your `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
sentryagent-idp = "1.0"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `AGENTIDP_API_URL` | Base URL of the AgentIdP API (e.g. `https://api.sentryagent.ai`) |
|
||||||
|
| `AGENTIDP_CLIENT_ID` | OAuth 2.0 client identifier |
|
||||||
|
| `AGENTIDP_CLIENT_SECRET` | OAuth 2.0 client secret |
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use sentryagent_idp::{AgentIdPClient, RegisterAgentRequest};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Build from environment variables.
|
||||||
|
let client = AgentIdPClient::from_env()?;
|
||||||
|
|
||||||
|
// Register a new agent.
|
||||||
|
let agent = client.register_agent(RegisterAgentRequest {
|
||||||
|
name: "my-agent".to_owned(),
|
||||||
|
description: Some("Does useful things".to_owned()),
|
||||||
|
agent_type: "worker".to_owned(),
|
||||||
|
capabilities: vec!["read:data".to_owned()],
|
||||||
|
metadata: None,
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
println!("Agent registered: {} (DID: {})", agent.id, agent.did);
|
||||||
|
|
||||||
|
// Issue a scoped access token.
|
||||||
|
let token = client.issue_token(&agent.id, &["agents:read"]).await?;
|
||||||
|
println!("Token issued, expires in {}s", token.expires_in);
|
||||||
|
|
||||||
|
// Clean up.
|
||||||
|
client.delete_agent(&agent.id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Method Reference
|
||||||
|
|
||||||
|
### Agent Registry
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `register_agent(req)` | `POST /agents` | Register a new agent identity |
|
||||||
|
| `get_agent(id)` | `GET /agents/{id}` | Retrieve an agent by ID |
|
||||||
|
| `list_agents(page, per_page)` | `GET /agents` | List all agents (paginated) |
|
||||||
|
| `update_agent(id, req)` | `PATCH /agents/{id}` | Partially update an agent |
|
||||||
|
| `delete_agent(id)` | `DELETE /agents/{id}` | Permanently delete an agent |
|
||||||
|
|
||||||
|
### OAuth 2.0
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `issue_token(agent_id, scopes)` | `POST /oauth2/token` | Issue a scoped access token |
|
||||||
|
|
||||||
|
### Credentials
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `generate_credentials(agent_id)` | `POST /agents/{id}/credentials` | Generate credentials (returns secret once) |
|
||||||
|
| `rotate_credentials(agent_id)` | `POST /agents/{id}/credentials/rotate` | Rotate credentials (invalidates previous) |
|
||||||
|
| `revoke_credentials(agent_id, cred_id)` | `DELETE /agents/{id}/credentials/{cred_id}` | Revoke a specific credential set |
|
||||||
|
|
||||||
|
### Audit Logs
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `list_audit_logs(filters)` | `GET /audit-logs` | Query audit events with optional filters |
|
||||||
|
|
||||||
|
### Marketplace (unauthenticated)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `list_public_agents(filters)` | `GET /marketplace/agents` | Browse public marketplace agents |
|
||||||
|
| `get_public_agent(id)` | `GET /marketplace/agents/{id}` | Retrieve a single marketplace agent |
|
||||||
|
|
||||||
|
### Delegation
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `delegate(req)` | `POST /delegation` | Create an A2A delegation token |
|
||||||
|
| `verify_delegation(token)` | `POST /delegation/verify` | Verify and decode a delegation token |
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All methods return `Result<T, AgentIdPError>`. Match on variants for fine-grained handling:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use sentryagent_idp::AgentIdPError;
|
||||||
|
|
||||||
|
match client.get_agent("unknown-id").await {
|
||||||
|
Err(AgentIdPError::NotFound(msg)) => {
|
||||||
|
eprintln!("Agent not found: {}", msg);
|
||||||
|
}
|
||||||
|
Err(AgentIdPError::RateLimited { retry_after_secs }) => {
|
||||||
|
eprintln!("Rate limited — retry after {}s", retry_after_secs);
|
||||||
|
}
|
||||||
|
Err(AgentIdPError::AuthError(msg)) => {
|
||||||
|
eprintln!("Authentication failed: {}", msg);
|
||||||
|
}
|
||||||
|
Err(AgentIdPError::ApiError { status, message, code }) => {
|
||||||
|
eprintln!("API error {}: {} (code: {:?})", status, message, code);
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Unexpected error: {}", e),
|
||||||
|
Ok(agent) => println!("Found: {}", agent.name),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Variants
|
||||||
|
|
||||||
|
| Variant | Cause |
|
||||||
|
|---|---|
|
||||||
|
| `HttpError(reqwest::Error)` | Network-level transport failure |
|
||||||
|
| `ApiError { status, message, code }` | Non-2xx HTTP response with error body |
|
||||||
|
| `AuthError(String)` | 401 or 403 — invalid credentials or insufficient scope |
|
||||||
|
| `NotFound(String)` | 404 — resource does not exist |
|
||||||
|
| `RateLimited { retry_after_secs }` | 429 — too many requests |
|
||||||
|
| `ConfigError(String)` | Missing environment variable on `from_env()` |
|
||||||
|
| `SerdeError(serde_json::Error)` | JSON parsing failure |
|
||||||
|
| `DelegationError(String)` | Invalid or revoked delegation chain |
|
||||||
|
|
||||||
|
## Running Integration Tests
|
||||||
|
|
||||||
|
Integration tests are ignored by default. Set the three environment variables and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENTIDP_API_URL=https://api.sentryagent.ai \
|
||||||
|
AGENTIDP_CLIENT_ID=your-client-id \
|
||||||
|
AGENTIDP_CLIENT_SECRET=your-client-secret \
|
||||||
|
cargo test -- --ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing to crates.io
|
||||||
|
|
||||||
|
This crate is published as `sentryagent-idp` version `1.0.0`. To publish a new version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update version in Cargo.toml, then:
|
||||||
|
cargo publish --registry crates-io
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensure `CARGO_REGISTRY_TOKEN` is set to a valid crates.io API token before publishing.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see LICENSE for details.
|
||||||
1045
sdk-rust/conversation_backup.txt
Normal file
1045
sdk-rust/conversation_backup.txt
Normal file
File diff suppressed because it is too large
Load Diff
88
sdk-rust/examples/quickstart.rs
Normal file
88
sdk-rust/examples/quickstart.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! Quickstart example — register an agent, issue a token, then look it up.
|
||||||
|
//!
|
||||||
|
//! Run with:
|
||||||
|
//! ```bash
|
||||||
|
//! AGENTIDP_API_URL=https://api.sentryagent.ai \
|
||||||
|
//! AGENTIDP_CLIENT_ID=your-client-id \
|
||||||
|
//! AGENTIDP_CLIENT_SECRET=your-client-secret \
|
||||||
|
//! cargo run --example quickstart
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use sentryagent_idp::{AgentIdPClient, AuditLogFilters, MarketplaceFilters, RegisterAgentRequest};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Build client from environment variables.
|
||||||
|
let client = AgentIdPClient::from_env()?;
|
||||||
|
|
||||||
|
// ── Register a new agent ──────────────────────────────────────────────────
|
||||||
|
println!("Registering agent...");
|
||||||
|
let agent = client
|
||||||
|
.register_agent(RegisterAgentRequest {
|
||||||
|
name: "quickstart-agent".to_owned(),
|
||||||
|
description: Some("Created by the quickstart example".to_owned()),
|
||||||
|
agent_type: "worker".to_owned(),
|
||||||
|
capabilities: vec!["read:data".to_owned(), "write:reports".to_owned()],
|
||||||
|
metadata: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("Registered agent:");
|
||||||
|
println!(" ID: {}", agent.id);
|
||||||
|
println!(" DID: {}", agent.did);
|
||||||
|
|
||||||
|
// ── Issue a scoped token ──────────────────────────────────────────────────
|
||||||
|
println!("\nIssuing token...");
|
||||||
|
let token_resp = client
|
||||||
|
.issue_token(&agent.id, &["agents:read", "agents:write"])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("Token issued:");
|
||||||
|
println!(" type: {}", token_resp.token_type);
|
||||||
|
println!(" expires_in: {}s", token_resp.expires_in);
|
||||||
|
println!(" scope: {}", token_resp.scope);
|
||||||
|
|
||||||
|
// ── Retrieve the agent by ID ──────────────────────────────────────────────
|
||||||
|
println!("\nFetching agent {}...", agent.id);
|
||||||
|
let fetched = client.get_agent(&agent.id).await?;
|
||||||
|
println!("Fetched: {} (public: {})", fetched.name, fetched.is_public);
|
||||||
|
|
||||||
|
// ── List agents ───────────────────────────────────────────────────────────
|
||||||
|
println!("\nListing agents (page 1)...");
|
||||||
|
let list = client.list_agents(Some(1), Some(10)).await?;
|
||||||
|
println!("Total agents: {}", list.total);
|
||||||
|
|
||||||
|
// ── Audit logs ────────────────────────────────────────────────────────────
|
||||||
|
println!("\nFetching audit logs...");
|
||||||
|
let logs = client
|
||||||
|
.list_audit_logs(AuditLogFilters {
|
||||||
|
agent_id: Some(agent.id.clone()),
|
||||||
|
event_type: None,
|
||||||
|
from: None,
|
||||||
|
to: None,
|
||||||
|
page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
println!("Audit events: {}", logs.total);
|
||||||
|
|
||||||
|
// ── Marketplace ───────────────────────────────────────────────────────────
|
||||||
|
println!("\nBrowsing marketplace...");
|
||||||
|
let marketplace = client
|
||||||
|
.list_public_agents(MarketplaceFilters {
|
||||||
|
q: None,
|
||||||
|
capability: None,
|
||||||
|
publisher: None,
|
||||||
|
page: 1,
|
||||||
|
per_page: 5,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
println!("Public agents: {}", marketplace.total);
|
||||||
|
|
||||||
|
// ── Clean up ──────────────────────────────────────────────────────────────
|
||||||
|
println!("\nDeleting agent {}...", agent.id);
|
||||||
|
client.delete_agent(&agent.id).await?;
|
||||||
|
println!("Agent deleted. Done.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
205
sdk-rust/src/agents.rs
Normal file
205
sdk-rust/src/agents.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
//! Agent registry methods for `AgentIdPClient`.
|
||||||
|
//!
|
||||||
|
//! Covers `POST /agents`, `GET /agents`, `GET /agents/{id}`,
|
||||||
|
//! `PATCH /agents/{id}`, and `DELETE /agents/{id}`.
|
||||||
|
|
||||||
|
use crate::client::AgentIdPClient;
|
||||||
|
use crate::error::AgentIdPError;
|
||||||
|
use crate::models::{Agent, AgentList, RegisterAgentRequest, UpdateAgentRequest};
|
||||||
|
|
||||||
|
impl AgentIdPClient {
|
||||||
|
/// Registers a new AI agent identity.
|
||||||
|
///
|
||||||
|
/// `POST /agents` → `201 Agent`
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`AgentIdPError::AuthError`] on 401/403, or
|
||||||
|
/// [`AgentIdPError::ApiError`] for other non-2xx responses.
|
||||||
|
pub async fn register_agent(
|
||||||
|
&self,
|
||||||
|
req: RegisterAgentRequest,
|
||||||
|
) -> Result<Agent, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/agents", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a single agent by its unique identifier.
|
||||||
|
///
|
||||||
|
/// `GET /agents/{id}` → `200 Agent`
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`AgentIdPError::NotFound`] when the agent does not exist.
|
||||||
|
pub async fn get_agent(&self, agent_id: &str) -> Result<Agent, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/agents/{}", self.base_url, agent_id);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a paginated list of agents owned by the authenticated client.
|
||||||
|
///
|
||||||
|
/// `GET /agents` → `200 AgentList`
|
||||||
|
///
|
||||||
|
/// Pass `None` for `page` or `per_page` to use the server defaults.
|
||||||
|
pub async fn list_agents(
|
||||||
|
&self,
|
||||||
|
page: Option<u32>,
|
||||||
|
per_page: Option<u32>,
|
||||||
|
) -> Result<AgentList, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/agents", self.base_url);
|
||||||
|
|
||||||
|
let mut query: Vec<(&str, String)> = Vec::new();
|
||||||
|
if let Some(p) = page {
|
||||||
|
query.push(("page", p.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(pp) = per_page {
|
||||||
|
query.push(("per_page", pp.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.query(&query)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partially updates an existing agent.
|
||||||
|
///
|
||||||
|
/// `PATCH /agents/{id}` → `200 Agent`
|
||||||
|
///
|
||||||
|
/// Only fields set to `Some` in `req` are sent to the API.
|
||||||
|
pub async fn update_agent(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
req: UpdateAgentRequest,
|
||||||
|
) -> Result<Agent, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/agents/{}", self.base_url, agent_id);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.patch(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently deletes an agent.
|
||||||
|
///
|
||||||
|
/// `DELETE /agents/{id}` → `204 No Content`
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`AgentIdPError::NotFound`] when the agent does not exist.
|
||||||
|
pub async fn delete_agent(&self, agent_id: &str) -> Result<(), AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/agents/{}", self.base_url, agent_id);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.delete(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 204 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse parse_response to handle errors; the Ok(Agent) path will never
|
||||||
|
// be reached since 204 is handled above, but we need to satisfy the type.
|
||||||
|
let _: Agent = parse_response(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts an HTTP response into `T` or an appropriate `AgentIdPError`.
|
||||||
|
///
|
||||||
|
/// Status mapping:
|
||||||
|
/// - `2xx` → deserialise body as `T`
|
||||||
|
/// - `401` / `403` → [`AgentIdPError::AuthError`]
|
||||||
|
/// - `404` → [`AgentIdPError::NotFound`]
|
||||||
|
/// - `429` → [`AgentIdPError::RateLimited`] (parses `Retry-After` header)
|
||||||
|
/// - Other non-2xx → [`AgentIdPError::ApiError`]
|
||||||
|
pub(crate) async fn parse_response<T: serde::de::DeserializeOwned>(
|
||||||
|
resp: reqwest::Response,
|
||||||
|
) -> Result<T, AgentIdPError> {
|
||||||
|
let status = resp.status();
|
||||||
|
|
||||||
|
if status.is_success() {
|
||||||
|
let value: T = resp.json().await?;
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status_code = status.as_u16();
|
||||||
|
|
||||||
|
match status_code {
|
||||||
|
401 | 403 => {
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||||
|
let msg = extract_message(&body);
|
||||||
|
Err(AgentIdPError::AuthError(msg))
|
||||||
|
}
|
||||||
|
404 => {
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||||
|
let msg = extract_message(&body);
|
||||||
|
Err(AgentIdPError::NotFound(msg))
|
||||||
|
}
|
||||||
|
429 => {
|
||||||
|
let retry_after_secs = resp
|
||||||
|
.headers()
|
||||||
|
.get("Retry-After")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.unwrap_or(60);
|
||||||
|
Err(AgentIdPError::RateLimited { retry_after_secs })
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||||
|
let message = extract_message(&body);
|
||||||
|
let code = body
|
||||||
|
.get("code")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_owned);
|
||||||
|
Err(AgentIdPError::ApiError {
|
||||||
|
status: status_code,
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts a human-readable message from an API error body.
|
||||||
|
fn extract_message(body: &serde_json::Value) -> String {
|
||||||
|
body.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown error")
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
72
sdk-rust/src/audit.rs
Normal file
72
sdk-rust/src/audit.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
//! Audit log methods for `AgentIdPClient`.
|
||||||
|
//!
|
||||||
|
//! Covers `GET /audit-logs` with optional query-parameter filters.
|
||||||
|
|
||||||
|
use crate::agents::parse_response;
|
||||||
|
use crate::client::AgentIdPClient;
|
||||||
|
use crate::error::AgentIdPError;
|
||||||
|
use crate::models::{AuditLogFilters, AuditLogList};
|
||||||
|
|
||||||
|
impl AgentIdPClient {
|
||||||
|
/// Queries the audit log with optional filters.
|
||||||
|
///
|
||||||
|
/// `GET /audit-logs` → `200 AuditLogList`
|
||||||
|
///
|
||||||
|
/// Only `Some` fields in `filters` are appended as query parameters.
|
||||||
|
/// `page` and `per_page` are always included.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use sentryagent_idp::{AgentIdPClient, AuditLogFilters};
|
||||||
|
///
|
||||||
|
/// # async fn example(client: &AgentIdPClient) -> Result<(), sentryagent_idp::AgentIdPError> {
|
||||||
|
/// let logs = client.list_audit_logs(AuditLogFilters {
|
||||||
|
/// agent_id: Some("agent-uuid".to_owned()),
|
||||||
|
/// event_type: Some("token.issued".to_owned()),
|
||||||
|
/// from: None,
|
||||||
|
/// to: None,
|
||||||
|
/// page: 1,
|
||||||
|
/// per_page: 50,
|
||||||
|
/// }).await?;
|
||||||
|
/// println!("Total events: {}", logs.total);
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub async fn list_audit_logs(
|
||||||
|
&self,
|
||||||
|
filters: AuditLogFilters,
|
||||||
|
) -> Result<AuditLogList, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/audit-logs", self.base_url);
|
||||||
|
|
||||||
|
// Build query params, omitting None values.
|
||||||
|
let mut query: Vec<(&str, String)> = vec![
|
||||||
|
("page", filters.page.to_string()),
|
||||||
|
("per_page", filters.per_page.to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(ref agent_id) = filters.agent_id {
|
||||||
|
query.push(("agent_id", agent_id.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref event_type) = filters.event_type {
|
||||||
|
query.push(("event_type", event_type.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref from) = filters.from {
|
||||||
|
query.push(("from", from.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref to) = filters.to {
|
||||||
|
query.push(("to", to.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.query(&query)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
101
sdk-rust/src/client.rs
Normal file
101
sdk-rust/src/client.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//! Core `AgentIdPClient` — entry point for all SDK operations.
|
||||||
|
//!
|
||||||
|
//! Create a client via [`AgentIdPClient::new`] or [`AgentIdPClient::from_env`],
|
||||||
|
//! then call methods that correspond to each API endpoint. The client manages
|
||||||
|
//! token acquisition transparently through the embedded [`TokenManager`].
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::error::AgentIdPError;
|
||||||
|
use crate::token_manager::TokenManager;
|
||||||
|
|
||||||
|
/// The top-level client for the SentryAgent.ai AgentIdP API.
|
||||||
|
///
|
||||||
|
/// All methods are `async` and require a `tokio` runtime. The client is
|
||||||
|
/// cheap to clone — the underlying HTTP connection pool and token cache are
|
||||||
|
/// shared via `Arc`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use sentryagent_idp::AgentIdPClient;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
/// let client = AgentIdPClient::from_env()?;
|
||||||
|
/// let agents = client.list_agents(Some(1), Some(20)).await?;
|
||||||
|
/// println!("Total agents: {}", agents.total);
|
||||||
|
/// Ok(())
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct AgentIdPClient {
|
||||||
|
/// Base URL of the AgentIdP API (no trailing slash).
|
||||||
|
pub(crate) base_url: String,
|
||||||
|
/// Reusable `reqwest` HTTP client — created once, shared across all requests.
|
||||||
|
pub(crate) http: reqwest::Client,
|
||||||
|
/// Shared, async-safe token manager.
|
||||||
|
pub(crate) token_manager: Arc<Mutex<TokenManager>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentIdPClient {
|
||||||
|
/// Creates a new `AgentIdPClient`. No network calls are made at construction time.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `base_url` — Root URL of the AgentIdP API, e.g. `"https://api.sentryagent.ai"`.
|
||||||
|
/// * `client_id` — OAuth 2.0 client identifier.
|
||||||
|
/// * `client_secret` — OAuth 2.0 client secret.
|
||||||
|
pub fn new(base_url: &str, client_id: &str, client_secret: &str) -> Self {
|
||||||
|
let clean_url = base_url.trim_end_matches('/').to_owned();
|
||||||
|
let tm = TokenManager::new(&clean_url, client_id, client_secret);
|
||||||
|
Self {
|
||||||
|
base_url: clean_url,
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
token_manager: Arc::new(Mutex::new(tm)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a client from environment variables.
|
||||||
|
///
|
||||||
|
/// Reads the following variables:
|
||||||
|
///
|
||||||
|
/// | Variable | Purpose |
|
||||||
|
/// |---|---|
|
||||||
|
/// | `AGENTIDP_API_URL` | Base URL of the API |
|
||||||
|
/// | `AGENTIDP_CLIENT_ID` | OAuth 2.0 client ID |
|
||||||
|
/// | `AGENTIDP_CLIENT_SECRET` | OAuth 2.0 client secret |
|
||||||
|
///
|
||||||
|
/// Returns [`AgentIdPError::ConfigError`] if any variable is missing.
|
||||||
|
pub fn from_env() -> Result<Self, AgentIdPError> {
|
||||||
|
let api_url = env::var("AGENTIDP_API_URL").map_err(|_| {
|
||||||
|
AgentIdPError::ConfigError(
|
||||||
|
"AGENTIDP_API_URL environment variable is not set".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let client_id = env::var("AGENTIDP_CLIENT_ID").map_err(|_| {
|
||||||
|
AgentIdPError::ConfigError(
|
||||||
|
"AGENTIDP_CLIENT_ID environment variable is not set".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let client_secret = env::var("AGENTIDP_CLIENT_SECRET").map_err(|_| {
|
||||||
|
AgentIdPError::ConfigError(
|
||||||
|
"AGENTIDP_CLIENT_SECRET environment variable is not set".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Self::new(&api_url, &client_id, &client_secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a `Bearer <token>` string for the `Authorization` header.
|
||||||
|
///
|
||||||
|
/// Delegates to [`TokenManager::get_token`], which handles caching and
|
||||||
|
/// automatic refresh transparently.
|
||||||
|
pub(crate) async fn get_auth_header(&self) -> Result<String, AgentIdPError> {
|
||||||
|
let tm = self.token_manager.lock().await;
|
||||||
|
let token = tm.get_token().await?;
|
||||||
|
Ok(format!("Bearer {}", token))
|
||||||
|
}
|
||||||
|
}
|
||||||
98
sdk-rust/src/credentials.rs
Normal file
98
sdk-rust/src/credentials.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//! Credential management methods for `AgentIdPClient`.
|
||||||
|
//!
|
||||||
|
//! Covers `POST /agents/{id}/credentials` (generate),
|
||||||
|
//! `POST /agents/{id}/credentials/rotate`, and
|
||||||
|
//! `DELETE /agents/{id}/credentials/{cred_id}`.
|
||||||
|
|
||||||
|
use crate::agents::parse_response;
|
||||||
|
use crate::client::AgentIdPClient;
|
||||||
|
use crate::error::AgentIdPError;
|
||||||
|
use crate::models::Credentials;
|
||||||
|
|
||||||
|
impl AgentIdPClient {
|
||||||
|
/// Generates a new set of credentials (client ID + secret) for an agent.
|
||||||
|
///
|
||||||
|
/// `POST /agents/{id}/credentials` → `201 Credentials`
|
||||||
|
///
|
||||||
|
/// The `client_secret` field in the response is the **only time** the
|
||||||
|
/// plaintext secret is returned — store it securely.
|
||||||
|
pub async fn generate_credentials(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
) -> Result<Credentials, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/agents/{}/credentials", self.base_url, agent_id);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.header("Content-Length", "0")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotates the credentials for an agent, invalidating the previous secret.
|
||||||
|
///
|
||||||
|
/// `POST /agents/{id}/credentials/rotate` → `200 Credentials`
|
||||||
|
///
|
||||||
|
/// The new `client_secret` is returned in the response and will not be
|
||||||
|
/// retrievable again.
|
||||||
|
pub async fn rotate_credentials(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
) -> Result<Credentials, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!(
|
||||||
|
"{}/agents/{}/credentials/rotate",
|
||||||
|
self.base_url, agent_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.header("Content-Length", "0")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes a specific credential set for an agent.
|
||||||
|
///
|
||||||
|
/// `DELETE /agents/{id}/credentials/{cred_id}` → `204 No Content`
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::AgentIdPError::NotFound`] when the agent or
|
||||||
|
/// credential ID does not exist.
|
||||||
|
pub async fn revoke_credentials(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
cred_id: &str,
|
||||||
|
) -> Result<(), AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!(
|
||||||
|
"{}/agents/{}/credentials/{}",
|
||||||
|
self.base_url, agent_id, cred_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.delete(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if resp.status().as_u16() == 204 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate error handling to parse_response; the Ok branch is unreachable.
|
||||||
|
let _: Credentials = parse_response(resp).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
75
sdk-rust/src/delegation.rs
Normal file
75
sdk-rust/src/delegation.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
//! Agent-to-agent (A2A) delegation methods for `AgentIdPClient`.
|
||||||
|
//!
|
||||||
|
//! Covers `POST /delegation` and `POST /delegation/verify`.
|
||||||
|
|
||||||
|
use crate::agents::parse_response;
|
||||||
|
use crate::client::AgentIdPClient;
|
||||||
|
use crate::error::AgentIdPError;
|
||||||
|
use crate::models::{DelegateRequest, DelegationToken, DelegationVerification};
|
||||||
|
|
||||||
|
impl AgentIdPClient {
|
||||||
|
/// Creates an A2A delegation token granting a delegatee agent authority
|
||||||
|
/// to act on behalf of the calling (delegator) agent.
|
||||||
|
///
|
||||||
|
/// `POST /delegation` → `201 DelegationToken`
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`AgentIdPError::DelegationError`] when the delegation chain
|
||||||
|
/// would be invalid (e.g. cyclic delegation or insufficient scope).
|
||||||
|
pub async fn delegate(
|
||||||
|
&self,
|
||||||
|
req: DelegateRequest,
|
||||||
|
) -> Result<DelegationToken, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/delegation", self.base_url);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.json(&req)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Map 422 Unprocessable Entity to DelegationError.
|
||||||
|
if resp.status().as_u16() == 422 {
|
||||||
|
let body: serde_json::Value =
|
||||||
|
resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||||
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("invalid delegation chain")
|
||||||
|
.to_owned();
|
||||||
|
return Err(AgentIdPError::DelegationError(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies an A2A delegation token and returns its claims.
|
||||||
|
///
|
||||||
|
/// `POST /delegation/verify` → `200 DelegationVerification`
|
||||||
|
///
|
||||||
|
/// The response's `valid` field is `false` when the token is expired or
|
||||||
|
/// the chain has been revoked, rather than returning an error.
|
||||||
|
pub async fn verify_delegation(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<DelegationVerification, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/delegation/verify", self.base_url);
|
||||||
|
|
||||||
|
let body = serde_json::json!({ "delegation_token": token });
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
68
sdk-rust/src/error.rs
Normal file
68
sdk-rust/src/error.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//! Error types for the SentryAgent.ai AgentIdP Rust SDK.
|
||||||
|
//!
|
||||||
|
//! All fallible operations return `Result<T, AgentIdPError>`. Match on the
|
||||||
|
//! variants to handle specific conditions such as rate-limiting or
|
||||||
|
//! missing resources.
|
||||||
|
|
||||||
|
/// The unified error type returned by all SDK operations.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use sentryagent_idp::AgentIdPError;
|
||||||
|
///
|
||||||
|
/// async fn example(client: &sentryagent_idp::AgentIdPClient) {
|
||||||
|
/// match client.get_agent("unknown-id").await {
|
||||||
|
/// Err(AgentIdPError::NotFound(id)) => eprintln!("Agent not found: {}", id),
|
||||||
|
/// Err(AgentIdPError::RateLimited { retry_after_secs }) => {
|
||||||
|
/// eprintln!("Rate limited — retry after {}s", retry_after_secs);
|
||||||
|
/// }
|
||||||
|
/// Err(e) => eprintln!("Unexpected error: {}", e),
|
||||||
|
/// Ok(agent) => println!("Found: {:?}", agent),
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AgentIdPError {
|
||||||
|
/// An underlying HTTP transport error from `reqwest`.
|
||||||
|
#[error("HTTP request failed: {0}")]
|
||||||
|
HttpError(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
/// The API returned a non-2xx status code with a structured error body.
|
||||||
|
#[error("API error {status}: {message}")]
|
||||||
|
ApiError {
|
||||||
|
/// HTTP status code returned by the API.
|
||||||
|
status: u16,
|
||||||
|
/// Human-readable error message from the API.
|
||||||
|
message: String,
|
||||||
|
/// Machine-readable error code from the API, if present.
|
||||||
|
code: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Authentication or authorisation failed (401/403).
|
||||||
|
#[error("Authentication failed: {0}")]
|
||||||
|
AuthError(String),
|
||||||
|
|
||||||
|
/// The requested resource was not found (404).
|
||||||
|
#[error("Agent not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
/// The API rate-limited this client (429). Contains the retry delay.
|
||||||
|
#[error("Rate limit exceeded. Retry after {retry_after_secs}s")]
|
||||||
|
RateLimited {
|
||||||
|
/// Seconds to wait before retrying.
|
||||||
|
retry_after_secs: u64,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A required configuration value was missing or invalid.
|
||||||
|
#[error("Invalid configuration: {0}")]
|
||||||
|
ConfigError(String),
|
||||||
|
|
||||||
|
/// JSON serialization or deserialization failed.
|
||||||
|
#[error("Serialization error: {0}")]
|
||||||
|
SerdeError(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// A delegation chain was invalid or could not be verified.
|
||||||
|
#[error("Delegation chain invalid: {0}")]
|
||||||
|
DelegationError(String),
|
||||||
|
}
|
||||||
81
sdk-rust/src/lib.rs
Normal file
81
sdk-rust/src/lib.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
//! # sentryagent-idp
|
||||||
|
//!
|
||||||
|
//! Production-grade Rust SDK for the [SentryAgent.ai](https://sentryagent.ai)
|
||||||
|
//! AgentIdP API. Provides full coverage of the 14 API endpoints across agent
|
||||||
|
//! identity, OAuth 2.0 token management, credential rotation, audit logs, the
|
||||||
|
//! public marketplace, and agent-to-agent (A2A) delegation.
|
||||||
|
//!
|
||||||
|
//! ## Features
|
||||||
|
//!
|
||||||
|
//! - **Async-first** — every API call is `async` and backed by `tokio`.
|
||||||
|
//! - **Thread-safe token cache** — [`TokenManager`] refreshes tokens
|
||||||
|
//! automatically before expiry; safe for concurrent use across tasks.
|
||||||
|
//! - **Typed errors** — every failure maps to a variant of [`AgentIdPError`].
|
||||||
|
//! - **Zero `unwrap()`** — all error paths use `?` or explicit `match`.
|
||||||
|
//!
|
||||||
|
//! ## Quickstart
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use sentryagent_idp::{AgentIdPClient, RegisterAgentRequest};
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
//! let client = AgentIdPClient::from_env()?;
|
||||||
|
//!
|
||||||
|
//! let agent = client.register_agent(RegisterAgentRequest {
|
||||||
|
//! name: "my-agent".to_owned(),
|
||||||
|
//! description: Some("Does useful things".to_owned()),
|
||||||
|
//! agent_type: "worker".to_owned(),
|
||||||
|
//! capabilities: vec!["read:files".to_owned()],
|
||||||
|
//! metadata: None,
|
||||||
|
//! }).await?;
|
||||||
|
//!
|
||||||
|
//! println!("Registered agent: {}", agent.id);
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Environment Variables
|
||||||
|
//!
|
||||||
|
//! | Variable | Purpose |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `AGENTIDP_API_URL` | Base URL of the AgentIdP API |
|
||||||
|
//! | `AGENTIDP_CLIENT_ID` | OAuth 2.0 client identifier |
|
||||||
|
//! | `AGENTIDP_CLIENT_SECRET` | OAuth 2.0 client secret |
|
||||||
|
|
||||||
|
#![deny(warnings)]
|
||||||
|
|
||||||
|
pub mod agents;
|
||||||
|
pub mod audit;
|
||||||
|
pub mod client;
|
||||||
|
pub mod credentials;
|
||||||
|
pub mod delegation;
|
||||||
|
pub mod error;
|
||||||
|
pub mod marketplace;
|
||||||
|
pub mod models;
|
||||||
|
pub mod oauth2;
|
||||||
|
pub mod token_manager;
|
||||||
|
|
||||||
|
// Re-export the primary entry points at crate root for ergonomic use.
|
||||||
|
pub use client::AgentIdPClient;
|
||||||
|
pub use error::AgentIdPError;
|
||||||
|
pub use token_manager::TokenManager;
|
||||||
|
|
||||||
|
// Re-export all model types.
|
||||||
|
pub use models::{
|
||||||
|
Agent,
|
||||||
|
AgentList,
|
||||||
|
AuditLogEntry,
|
||||||
|
AuditLogFilters,
|
||||||
|
AuditLogList,
|
||||||
|
Credentials,
|
||||||
|
DelegateRequest,
|
||||||
|
DelegationToken,
|
||||||
|
DelegationVerification,
|
||||||
|
MarketplaceAgent,
|
||||||
|
MarketplaceAgentList,
|
||||||
|
MarketplaceFilters,
|
||||||
|
RegisterAgentRequest,
|
||||||
|
TokenResponse,
|
||||||
|
UpdateAgentRequest,
|
||||||
|
};
|
||||||
87
sdk-rust/src/marketplace.rs
Normal file
87
sdk-rust/src/marketplace.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
//! Public marketplace methods for `AgentIdPClient`.
|
||||||
|
//!
|
||||||
|
//! Covers `GET /marketplace/agents` and `GET /marketplace/agents/{id}`.
|
||||||
|
//! These endpoints are **unauthenticated** — no `Authorization` header is sent.
|
||||||
|
|
||||||
|
use crate::agents::parse_response;
|
||||||
|
use crate::client::AgentIdPClient;
|
||||||
|
use crate::error::AgentIdPError;
|
||||||
|
use crate::models::{MarketplaceAgent, MarketplaceAgentList, MarketplaceFilters};
|
||||||
|
|
||||||
|
impl AgentIdPClient {
|
||||||
|
/// Lists publicly available agents in the marketplace.
|
||||||
|
///
|
||||||
|
/// `GET /marketplace/agents` → `200 MarketplaceAgentList`
|
||||||
|
///
|
||||||
|
/// This endpoint does **not** require authentication. `None` filter fields
|
||||||
|
/// are omitted from the query string.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use sentryagent_idp::{AgentIdPClient, MarketplaceFilters};
|
||||||
|
///
|
||||||
|
/// # async fn example(client: &AgentIdPClient) -> Result<(), sentryagent_idp::AgentIdPError> {
|
||||||
|
/// let results = client.list_public_agents(MarketplaceFilters {
|
||||||
|
/// q: Some("summarizer".to_owned()),
|
||||||
|
/// capability: None,
|
||||||
|
/// publisher: None,
|
||||||
|
/// page: 1,
|
||||||
|
/// per_page: 20,
|
||||||
|
/// }).await?;
|
||||||
|
/// println!("Found {} agents", results.total);
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub async fn list_public_agents(
|
||||||
|
&self,
|
||||||
|
filters: MarketplaceFilters,
|
||||||
|
) -> Result<MarketplaceAgentList, AgentIdPError> {
|
||||||
|
let url = format!("{}/marketplace/agents", self.base_url);
|
||||||
|
|
||||||
|
let mut query: Vec<(&str, String)> = vec![
|
||||||
|
("page", filters.page.to_string()),
|
||||||
|
("per_page", filters.per_page.to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(ref q) = filters.q {
|
||||||
|
query.push(("q", q.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref capability) = filters.capability {
|
||||||
|
query.push(("capability", capability.clone()));
|
||||||
|
}
|
||||||
|
if let Some(ref publisher) = filters.publisher {
|
||||||
|
query.push(("publisher", publisher.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.query(&query)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a single publicly listed marketplace agent by ID.
|
||||||
|
///
|
||||||
|
/// `GET /marketplace/agents/{id}` → `200 MarketplaceAgent`
|
||||||
|
///
|
||||||
|
/// This endpoint does **not** require authentication.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`crate::error::AgentIdPError::NotFound`] when no public agent
|
||||||
|
/// with the given ID exists.
|
||||||
|
pub async fn get_public_agent(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
) -> Result<MarketplaceAgent, AgentIdPError> {
|
||||||
|
let url = format!("{}/marketplace/agents/{}", self.base_url, agent_id);
|
||||||
|
|
||||||
|
let resp = self.http.get(&url).send().await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
261
sdk-rust/src/models.rs
Normal file
261
sdk-rust/src/models.rs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
//! Request and response model types for the SentryAgent.ai AgentIdP API.
|
||||||
|
//!
|
||||||
|
//! All types implement `serde::Serialize` and `serde::Deserialize` for
|
||||||
|
//! transparent JSON encoding. `Option` fields are omitted from serialized
|
||||||
|
//! output when `None`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ─── Request types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Request body for `POST /agents` — register a new agent identity.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegisterAgentRequest {
|
||||||
|
/// Human-readable name for the agent.
|
||||||
|
pub name: String,
|
||||||
|
/// Optional description of the agent's purpose.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Functional category of the agent (e.g. `"worker"`, `"orchestrator"`).
|
||||||
|
pub agent_type: String,
|
||||||
|
/// List of capability strings the agent exposes (e.g. `"read:files"`).
|
||||||
|
pub capabilities: Vec<String>,
|
||||||
|
/// Arbitrary metadata to attach to the agent record.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `PATCH /agents/{id}` — partially update an existing agent.
|
||||||
|
///
|
||||||
|
/// Only fields that are `Some` are sent to the API; `None` fields are omitted.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateAgentRequest {
|
||||||
|
/// New human-readable name.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// New description.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Replacement capability list.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub capabilities: Option<Vec<String>>,
|
||||||
|
/// Whether to list the agent in the public marketplace.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub is_public: Option<bool>,
|
||||||
|
/// Replacement metadata object.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for `GET /audit-logs`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct AuditLogFilters {
|
||||||
|
/// Filter by agent ID.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_id: Option<String>,
|
||||||
|
/// Filter by event type string.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub event_type: Option<String>,
|
||||||
|
/// Start of time range (ISO 8601).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// End of time range (ISO 8601).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub to: Option<String>,
|
||||||
|
/// Page number (1-based).
|
||||||
|
pub page: u32,
|
||||||
|
/// Number of results per page.
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query parameters for `GET /marketplace/agents`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct MarketplaceFilters {
|
||||||
|
/// Free-text search query.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub q: Option<String>,
|
||||||
|
/// Filter by capability string.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub capability: Option<String>,
|
||||||
|
/// Filter by publisher identifier.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Page number (1-based).
|
||||||
|
pub page: u32,
|
||||||
|
/// Number of results per page.
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `POST /delegation` — create an A2A delegation token.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DelegateRequest {
|
||||||
|
/// The agent ID that receives delegated authority.
|
||||||
|
pub delegatee_agent_id: String,
|
||||||
|
/// Scopes being delegated.
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
/// Token lifetime in seconds.
|
||||||
|
pub ttl_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Response types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A registered AI agent identity returned by the API.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Agent {
|
||||||
|
/// Unique agent identifier (UUID).
|
||||||
|
pub id: String,
|
||||||
|
/// Human-readable name.
|
||||||
|
pub name: String,
|
||||||
|
/// Optional description.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Capabilities the agent exposes.
|
||||||
|
pub capabilities: Vec<String>,
|
||||||
|
/// Decentralised Identifier for the agent.
|
||||||
|
pub did: String,
|
||||||
|
/// Whether the agent is listed in the public marketplace.
|
||||||
|
pub is_public: bool,
|
||||||
|
/// ISO 8601 creation timestamp.
|
||||||
|
pub created_at: String,
|
||||||
|
/// ISO 8601 last-updated timestamp.
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated list of agents returned by `GET /agents`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentList {
|
||||||
|
/// Agents on the current page.
|
||||||
|
pub agents: Vec<Agent>,
|
||||||
|
/// Total number of agents matching the query.
|
||||||
|
pub total: u64,
|
||||||
|
/// Current page number (1-based).
|
||||||
|
pub page: u32,
|
||||||
|
/// Number of results per page.
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OAuth 2.0 access token response (RFC 6749 §4.4.3).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TokenResponse {
|
||||||
|
/// The bearer access token.
|
||||||
|
pub access_token: String,
|
||||||
|
/// Token type — always `"Bearer"`.
|
||||||
|
pub token_type: String,
|
||||||
|
/// Seconds until the token expires.
|
||||||
|
pub expires_in: u64,
|
||||||
|
/// Space-separated list of granted scopes.
|
||||||
|
pub scope: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent credentials — client ID and (on creation/rotation only) client secret.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Credentials {
|
||||||
|
/// OAuth 2.0 client ID.
|
||||||
|
pub client_id: String,
|
||||||
|
/// OAuth 2.0 client secret (only present on generate/rotate responses).
|
||||||
|
pub client_secret: String,
|
||||||
|
/// ISO 8601 creation timestamp.
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single audit log entry.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuditLogEntry {
|
||||||
|
/// Unique event identifier.
|
||||||
|
pub id: String,
|
||||||
|
/// Agent ID this event relates to.
|
||||||
|
pub agent_id: String,
|
||||||
|
/// Type of event that occurred.
|
||||||
|
pub event_type: String,
|
||||||
|
/// Identity of the actor that triggered the event.
|
||||||
|
pub actor: String,
|
||||||
|
/// Structured metadata associated with the event.
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
|
/// ISO 8601 timestamp of the event.
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated list of audit log entries.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuditLogList {
|
||||||
|
/// Entries on the current page.
|
||||||
|
pub entries: Vec<AuditLogEntry>,
|
||||||
|
/// Total number of entries matching the query.
|
||||||
|
pub total: u64,
|
||||||
|
/// Current page number (1-based).
|
||||||
|
pub page: u32,
|
||||||
|
/// Number of results per page.
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A publicly listed marketplace agent.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MarketplaceAgent {
|
||||||
|
/// Unique agent identifier (UUID).
|
||||||
|
pub id: String,
|
||||||
|
/// Human-readable name.
|
||||||
|
pub name: String,
|
||||||
|
/// Optional description.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Capabilities the agent exposes.
|
||||||
|
pub capabilities: Vec<String>,
|
||||||
|
/// Full W3C DID Document for the agent.
|
||||||
|
pub did_document: serde_json::Value,
|
||||||
|
/// Publisher identifier or organisation name.
|
||||||
|
pub publisher: String,
|
||||||
|
/// ISO 8601 creation timestamp.
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paginated list of marketplace agents.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MarketplaceAgentList {
|
||||||
|
/// Agents on the current page.
|
||||||
|
pub agents: Vec<MarketplaceAgent>,
|
||||||
|
/// Total number of agents matching the query.
|
||||||
|
pub total: u64,
|
||||||
|
/// Current page number (1-based).
|
||||||
|
pub page: u32,
|
||||||
|
/// Number of results per page.
|
||||||
|
pub per_page: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A delegation token granting a delegatee agent authority on behalf of the delegator.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DelegationToken {
|
||||||
|
/// Opaque signed delegation token — pass in `X-Delegation-Token` header.
|
||||||
|
pub delegation_token: String,
|
||||||
|
/// Unique identifier for this delegation chain.
|
||||||
|
pub chain_id: String,
|
||||||
|
/// Agent ID of the delegator (authority source).
|
||||||
|
pub delegator_agent_id: String,
|
||||||
|
/// Agent ID of the delegatee (authority recipient).
|
||||||
|
pub delegatee_agent_id: String,
|
||||||
|
/// Scopes that have been delegated.
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
/// ISO 8601 timestamp when the delegation expires.
|
||||||
|
pub expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of verifying a delegation token.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DelegationVerification {
|
||||||
|
/// Whether the delegation token is valid and unexpired.
|
||||||
|
pub valid: bool,
|
||||||
|
/// Delegation chain ID, present when `valid` is `true`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub chain_id: Option<String>,
|
||||||
|
/// Delegator agent ID, present when `valid` is `true`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub delegator_agent_id: Option<String>,
|
||||||
|
/// Delegatee agent ID, present when `valid` is `true`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub delegatee_agent_id: Option<String>,
|
||||||
|
/// Delegated scopes, present when `valid` is `true`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scopes: Option<Vec<String>>,
|
||||||
|
/// Expiry timestamp, present when `valid` is `true`.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
47
sdk-rust/src/oauth2.rs
Normal file
47
sdk-rust/src/oauth2.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//! OAuth 2.0 token issuance methods for `AgentIdPClient`.
|
||||||
|
//!
|
||||||
|
//! Covers `POST /oauth2/token` for issuing agent-scoped access tokens.
|
||||||
|
|
||||||
|
use crate::agents::parse_response;
|
||||||
|
use crate::client::AgentIdPClient;
|
||||||
|
use crate::error::AgentIdPError;
|
||||||
|
use crate::models::TokenResponse;
|
||||||
|
|
||||||
|
impl AgentIdPClient {
|
||||||
|
/// Issues an OAuth 2.0 access token for the given agent with the requested scopes.
|
||||||
|
///
|
||||||
|
/// `POST /oauth2/token` (form body) → `200 TokenResponse`
|
||||||
|
///
|
||||||
|
/// This differs from the internal `TokenManager` token fetch in that it
|
||||||
|
/// allows callers to request tokens for specific agent IDs and scope sets.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `agent_id` — The agent on whose behalf the token is issued.
|
||||||
|
/// * `scopes` — Scopes to request (e.g. `&["agents:read", "agents:write"]`).
|
||||||
|
pub async fn issue_token(
|
||||||
|
&self,
|
||||||
|
agent_id: &str,
|
||||||
|
scopes: &[&str],
|
||||||
|
) -> Result<TokenResponse, AgentIdPError> {
|
||||||
|
let auth = self.get_auth_header().await?;
|
||||||
|
let url = format!("{}/oauth2/token", self.base_url);
|
||||||
|
let scope_str = scopes.join(" ");
|
||||||
|
|
||||||
|
let params = [
|
||||||
|
("grant_type", "client_credentials"),
|
||||||
|
("agent_id", agent_id),
|
||||||
|
("scope", scope_str.as_str()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", auth)
|
||||||
|
.form(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
parse_response(resp).await
|
||||||
|
}
|
||||||
|
}
|
||||||
254
sdk-rust/src/token_manager.rs
Normal file
254
sdk-rust/src/token_manager.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
//! Thread-safe OAuth 2.0 token cache with automatic refresh.
|
||||||
|
//!
|
||||||
|
//! `TokenManager` holds a single `reqwest::Client` for token requests and
|
||||||
|
//! caches the current access token behind an async `Mutex`. Tokens are
|
||||||
|
//! proactively refreshed 60 seconds before they expire, preventing any
|
||||||
|
//! request from using a stale bearer token.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::error::AgentIdPError;
|
||||||
|
use crate::models::TokenResponse;
|
||||||
|
|
||||||
|
/// Internal token cache — holds the raw token string and its calculated expiry.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct TokenCache {
|
||||||
|
/// Cached bearer token, or `None` if no token has been fetched yet.
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
/// Monotonic instant at which the cached token expires (less the 60 s buffer).
|
||||||
|
pub expires_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenCache {
|
||||||
|
/// Returns `true` when the cached token is present and has not yet reached
|
||||||
|
/// its expiry instant (which already includes the 60 s refresh buffer).
|
||||||
|
fn is_valid(&self) -> bool {
|
||||||
|
match (&self.access_token, self.expires_at) {
|
||||||
|
(Some(_), Some(exp)) => Instant::now() < exp,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe OAuth 2.0 client-credentials token manager.
|
||||||
|
///
|
||||||
|
/// Obtains bearer tokens from the AgentIdP server and caches them until they
|
||||||
|
/// are within 60 seconds of expiry, at which point the next call to
|
||||||
|
/// [`get_token`](TokenManager::get_token) transparently fetches a fresh one.
|
||||||
|
///
|
||||||
|
/// The inner `Arc<Mutex<TokenCache>>` makes `TokenManager` cheap to clone and
|
||||||
|
/// safe to share across `tokio` tasks.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use sentryagent_idp::TokenManager;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
/// let tm = TokenManager::new("https://api.sentryagent.ai", "client_id", "client_secret");
|
||||||
|
/// let token = tm.get_token().await?;
|
||||||
|
/// println!("Bearer {}", token);
|
||||||
|
/// Ok(())
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct TokenManager {
|
||||||
|
/// Base URL of the AgentIdP API (no trailing slash).
|
||||||
|
pub(crate) api_url: String,
|
||||||
|
/// OAuth 2.0 client identifier.
|
||||||
|
pub(crate) client_id: String,
|
||||||
|
/// OAuth 2.0 client secret.
|
||||||
|
pub(crate) client_secret: String,
|
||||||
|
/// Shared async token cache.
|
||||||
|
pub(crate) cache: Arc<Mutex<TokenCache>>,
|
||||||
|
/// Reusable HTTP client for token endpoint requests.
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenManager {
|
||||||
|
/// Creates a new `TokenManager`. No network calls are made at construction time.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `api_url` — Base URL of the AgentIdP API (e.g. `"https://api.sentryagent.ai"`).
|
||||||
|
/// * `client_id` — OAuth 2.0 client identifier.
|
||||||
|
/// * `client_secret` — OAuth 2.0 client secret.
|
||||||
|
pub fn new(api_url: &str, client_id: &str, client_secret: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
api_url: api_url.trim_end_matches('/').to_owned(),
|
||||||
|
client_id: client_id.to_owned(),
|
||||||
|
client_secret: client_secret.to_owned(),
|
||||||
|
cache: Arc::new(Mutex::new(TokenCache::default())),
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a valid bearer access token.
|
||||||
|
///
|
||||||
|
/// If a cached token exists and will not expire within the next 60 seconds,
|
||||||
|
/// it is returned immediately without any network call. Otherwise a new
|
||||||
|
/// token is fetched from `POST /oauth2/token` and the cache is updated.
|
||||||
|
///
|
||||||
|
/// This method is safe to call concurrently from multiple `tokio` tasks —
|
||||||
|
/// the `Mutex` ensures only one token fetch occurs at a time.
|
||||||
|
pub async fn get_token(&self) -> Result<String, AgentIdPError> {
|
||||||
|
let mut cache = self.cache.lock().await;
|
||||||
|
|
||||||
|
if cache.is_valid() {
|
||||||
|
// Safety: is_valid() guarantees access_token is Some.
|
||||||
|
return Ok(cache.access_token.clone().expect("token present when valid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a fresh token.
|
||||||
|
let token_resp = self.fetch_token().await?;
|
||||||
|
|
||||||
|
// Expire the cache 60 s before the server-reported expiry so we never
|
||||||
|
// hand out a token that is about to become invalid.
|
||||||
|
let ttl = token_resp
|
||||||
|
.expires_in
|
||||||
|
.saturating_sub(60);
|
||||||
|
cache.access_token = Some(token_resp.access_token.clone());
|
||||||
|
cache.expires_at = Some(Instant::now() + Duration::from_secs(ttl));
|
||||||
|
|
||||||
|
Ok(token_resp.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs the OAuth 2.0 client-credentials grant against the token endpoint.
|
||||||
|
async fn fetch_token(&self) -> Result<TokenResponse, AgentIdPError> {
|
||||||
|
let token_url = format!("{}/oauth2/token", self.api_url);
|
||||||
|
|
||||||
|
let params = [
|
||||||
|
("grant_type", "client_credentials"),
|
||||||
|
("client_id", self.client_id.as_str()),
|
||||||
|
("client_secret", self.client_secret.as_str()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.http
|
||||||
|
.post(&token_url)
|
||||||
|
.form(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
|
||||||
|
let message = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("token request failed")
|
||||||
|
.to_owned();
|
||||||
|
return Err(AgentIdPError::AuthError(format!(
|
||||||
|
"token endpoint returned {}: {}",
|
||||||
|
status, message
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_resp: TokenResponse = resp.json().await?;
|
||||||
|
Ok(token_resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Unit tests ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use mockito::Server;
|
||||||
|
|
||||||
|
fn token_body(expires_in: u64) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"access_token":"test-token","token_type":"Bearer","expires_in":{},"scope":"agents:read"}}"#,
|
||||||
|
expires_in
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `get_token()` should return the cached token on a second call without
|
||||||
|
/// hitting the mock server again.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_returns_cached_token() {
|
||||||
|
let mut server = Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/oauth2/token")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(token_body(3600))
|
||||||
|
.expect(1) // Must be called exactly once.
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let tm = TokenManager::new(&server.url(), "id", "secret");
|
||||||
|
|
||||||
|
let t1 = tm.get_token().await.expect("first call succeeds");
|
||||||
|
let t2 = tm.get_token().await.expect("second call succeeds");
|
||||||
|
|
||||||
|
assert_eq!(t1, "test-token");
|
||||||
|
assert_eq!(t2, "test-token");
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the cached token's `expires_at` is in the past, `get_token()` must
|
||||||
|
/// fetch a new token (i.e. hit the mock server a second time).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_refreshes_expired_token() {
|
||||||
|
let mut server = Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/oauth2/token")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(token_body(3600))
|
||||||
|
.expect(2) // Must be called twice.
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let tm = TokenManager::new(&server.url(), "id", "secret");
|
||||||
|
|
||||||
|
// First call — populates cache.
|
||||||
|
let _ = tm.get_token().await.expect("first call succeeds");
|
||||||
|
|
||||||
|
// Manually expire the cache.
|
||||||
|
{
|
||||||
|
let mut cache = tm.cache.lock().await;
|
||||||
|
cache.expires_at = Some(Instant::now() - Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call — cache expired, must fetch again.
|
||||||
|
let t2 = tm.get_token().await.expect("second call succeeds");
|
||||||
|
assert_eq!(t2, "test-token");
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ten concurrent `get_token()` calls must all succeed and the token
|
||||||
|
/// endpoint must be called exactly once (all but the first see the cache).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_concurrent_no_race() {
|
||||||
|
let mut server = Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/oauth2/token")
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(token_body(3600))
|
||||||
|
.expect(1)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let tm = Arc::new(TokenManager::new(&server.url(), "id", "secret"));
|
||||||
|
|
||||||
|
let handles: Vec<_> = (0..10)
|
||||||
|
.map(|_| {
|
||||||
|
let tm_clone = Arc::clone(&tm);
|
||||||
|
tokio::spawn(async move { tm_clone.get_token().await })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for handle in handles {
|
||||||
|
let result = handle.await.expect("task did not panic");
|
||||||
|
assert_eq!(result.expect("get_token succeeded"), "test-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
369
sdk-rust/tests/integration_test.rs
Normal file
369
sdk-rust/tests/integration_test.rs
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
//! Integration tests for the SentryAgent.ai AgentIdP Rust SDK.
|
||||||
|
//!
|
||||||
|
//! These tests run against a real API instance. They are marked `#[ignore]`
|
||||||
|
//! and will not execute in CI unless explicitly opted in with:
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! AGENTIDP_API_URL=https://api.sentryagent.ai \
|
||||||
|
//! AGENTIDP_CLIENT_ID=... \
|
||||||
|
//! AGENTIDP_CLIENT_SECRET=... \
|
||||||
|
//! cargo test -- --ignored
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use sentryagent_idp::{
|
||||||
|
AgentIdPClient, AuditLogFilters, DelegateRequest, MarketplaceFilters, RegisterAgentRequest,
|
||||||
|
UpdateAgentRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Helper — build a client from environment variables, skipping the test when
|
||||||
|
/// any required variable is unset (rather than panicking).
|
||||||
|
fn client_from_env() -> Option<AgentIdPClient> {
|
||||||
|
AgentIdPClient::from_env().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Agent CRUD ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_register_and_delete_agent() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let agent = client
|
||||||
|
.register_agent(RegisterAgentRequest {
|
||||||
|
name: "integration-test-agent".to_owned(),
|
||||||
|
description: Some("Created by integration test".to_owned()),
|
||||||
|
agent_type: "worker".to_owned(),
|
||||||
|
capabilities: vec!["read:data".to_owned()],
|
||||||
|
metadata: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("register_agent should succeed");
|
||||||
|
|
||||||
|
assert!(!agent.id.is_empty());
|
||||||
|
assert_eq!(agent.name, "integration-test-agent");
|
||||||
|
|
||||||
|
client
|
||||||
|
.delete_agent(&agent.id)
|
||||||
|
.await
|
||||||
|
.expect("delete_agent should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_get_agent() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let created = client
|
||||||
|
.register_agent(RegisterAgentRequest {
|
||||||
|
name: "get-test-agent".to_owned(),
|
||||||
|
description: None,
|
||||||
|
agent_type: "worker".to_owned(),
|
||||||
|
capabilities: vec![],
|
||||||
|
metadata: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("register_agent should succeed");
|
||||||
|
|
||||||
|
let fetched = client
|
||||||
|
.get_agent(&created.id)
|
||||||
|
.await
|
||||||
|
.expect("get_agent should succeed");
|
||||||
|
|
||||||
|
assert_eq!(fetched.id, created.id);
|
||||||
|
assert_eq!(fetched.name, "get-test-agent");
|
||||||
|
|
||||||
|
client.delete_agent(&created.id).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_list_agents() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let list = client
|
||||||
|
.list_agents(Some(1), Some(10))
|
||||||
|
.await
|
||||||
|
.expect("list_agents should succeed");
|
||||||
|
|
||||||
|
// Must return a valid pagination envelope.
|
||||||
|
assert!(list.page >= 1);
|
||||||
|
assert!(list.per_page > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_update_agent() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let agent = client
|
||||||
|
.register_agent(RegisterAgentRequest {
|
||||||
|
name: "update-test-agent".to_owned(),
|
||||||
|
description: None,
|
||||||
|
agent_type: "worker".to_owned(),
|
||||||
|
capabilities: vec![],
|
||||||
|
metadata: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("register_agent should succeed");
|
||||||
|
|
||||||
|
let updated = client
|
||||||
|
.update_agent(
|
||||||
|
&agent.id,
|
||||||
|
UpdateAgentRequest {
|
||||||
|
name: Some("updated-name".to_owned()),
|
||||||
|
description: Some("Updated description".to_owned()),
|
||||||
|
capabilities: None,
|
||||||
|
is_public: None,
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("update_agent should succeed");
|
||||||
|
|
||||||
|
assert_eq!(updated.name, "updated-name");
|
||||||
|
|
||||||
|
client.delete_agent(&agent.id).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_get_agent_not_found() {
|
||||||
|
use sentryagent_idp::AgentIdPError;
|
||||||
|
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let result = client
|
||||||
|
.get_agent("00000000-0000-0000-0000-000000000000")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(AgentIdPError::NotFound(_))),
|
||||||
|
"Expected NotFound error, got: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Credentials ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_generate_and_rotate_credentials() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let agent = client
|
||||||
|
.register_agent(RegisterAgentRequest {
|
||||||
|
name: "creds-test-agent".to_owned(),
|
||||||
|
description: None,
|
||||||
|
agent_type: "worker".to_owned(),
|
||||||
|
capabilities: vec![],
|
||||||
|
metadata: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("register_agent should succeed");
|
||||||
|
|
||||||
|
let creds = client
|
||||||
|
.generate_credentials(&agent.id)
|
||||||
|
.await
|
||||||
|
.expect("generate_credentials should succeed");
|
||||||
|
|
||||||
|
assert!(!creds.client_id.is_empty());
|
||||||
|
assert!(!creds.client_secret.is_empty());
|
||||||
|
|
||||||
|
let rotated = client
|
||||||
|
.rotate_credentials(&agent.id)
|
||||||
|
.await
|
||||||
|
.expect("rotate_credentials should succeed");
|
||||||
|
|
||||||
|
// Rotated secret must differ from the original.
|
||||||
|
assert_ne!(rotated.client_secret, creds.client_secret);
|
||||||
|
|
||||||
|
client.delete_agent(&agent.id).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OAuth2 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_issue_token() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let agent = client
|
||||||
|
.register_agent(RegisterAgentRequest {
|
||||||
|
name: "token-test-agent".to_owned(),
|
||||||
|
description: None,
|
||||||
|
agent_type: "worker".to_owned(),
|
||||||
|
capabilities: vec![],
|
||||||
|
metadata: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("register_agent should succeed");
|
||||||
|
|
||||||
|
let token = client
|
||||||
|
.issue_token(&agent.id, &["agents:read"])
|
||||||
|
.await
|
||||||
|
.expect("issue_token should succeed");
|
||||||
|
|
||||||
|
assert!(!token.access_token.is_empty());
|
||||||
|
assert_eq!(token.token_type.to_lowercase(), "bearer");
|
||||||
|
|
||||||
|
client.delete_agent(&agent.id).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Audit logs ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_list_audit_logs() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let logs = client
|
||||||
|
.list_audit_logs(AuditLogFilters {
|
||||||
|
agent_id: None,
|
||||||
|
event_type: None,
|
||||||
|
from: None,
|
||||||
|
to: None,
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("list_audit_logs should succeed");
|
||||||
|
|
||||||
|
assert!(logs.page >= 1);
|
||||||
|
assert!(logs.per_page > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Marketplace ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_list_public_agents() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let results = client
|
||||||
|
.list_public_agents(MarketplaceFilters {
|
||||||
|
q: None,
|
||||||
|
capability: None,
|
||||||
|
publisher: None,
|
||||||
|
page: 1,
|
||||||
|
per_page: 10,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("list_public_agents should succeed");
|
||||||
|
|
||||||
|
assert!(results.page >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_marketplace_search() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let results = client
|
||||||
|
.list_public_agents(MarketplaceFilters {
|
||||||
|
q: Some("agent".to_owned()),
|
||||||
|
capability: None,
|
||||||
|
publisher: None,
|
||||||
|
page: 1,
|
||||||
|
per_page: 5,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("list_public_agents with query should succeed");
|
||||||
|
|
||||||
|
// Result may be empty but must be a valid envelope.
|
||||||
|
assert!(results.per_page > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Delegation ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_delegate_and_verify() {
|
||||||
|
let client = client_from_env().expect("AGENTIDP_* env vars must be set");
|
||||||
|
|
||||||
|
let delegator = client
|
||||||
|
.register_agent(RegisterAgentRequest {
|
||||||
|
name: "delegator-agent".to_owned(),
|
||||||
|
description: None,
|
||||||
|
agent_type: "orchestrator".to_owned(),
|
||||||
|
capabilities: vec!["agents:write".to_owned()],
|
||||||
|
metadata: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("register delegator should succeed");
|
||||||
|
|
||||||
|
let delegatee = client
|
||||||
|
.register_agent(RegisterAgentRequest {
|
||||||
|
name: "delegatee-agent".to_owned(),
|
||||||
|
description: None,
|
||||||
|
agent_type: "worker".to_owned(),
|
||||||
|
capabilities: vec!["agents:read".to_owned()],
|
||||||
|
metadata: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("register delegatee should succeed");
|
||||||
|
|
||||||
|
let delegation = client
|
||||||
|
.delegate(DelegateRequest {
|
||||||
|
delegatee_agent_id: delegatee.id.clone(),
|
||||||
|
scopes: vec!["agents:read".to_owned()],
|
||||||
|
ttl_seconds: 3600,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("delegate should succeed");
|
||||||
|
|
||||||
|
assert!(!delegation.delegation_token.is_empty());
|
||||||
|
assert!(!delegation.chain_id.is_empty());
|
||||||
|
|
||||||
|
let verification = client
|
||||||
|
.verify_delegation(&delegation.delegation_token)
|
||||||
|
.await
|
||||||
|
.expect("verify_delegation should succeed");
|
||||||
|
|
||||||
|
assert!(verification.valid);
|
||||||
|
assert_eq!(
|
||||||
|
verification.delegatee_agent_id.as_deref(),
|
||||||
|
Some(delegatee.id.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
|
client.delete_agent(&delegator.id).await.ok();
|
||||||
|
client.delete_agent(&delegatee.id).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Token manager concurrency ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn test_token_manager_concurrent_calls() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
use sentryagent_idp::TokenManager;
|
||||||
|
|
||||||
|
let api_url = std::env::var("AGENTIDP_API_URL").expect("AGENTIDP_API_URL must be set");
|
||||||
|
let client_id =
|
||||||
|
std::env::var("AGENTIDP_CLIENT_ID").expect("AGENTIDP_CLIENT_ID must be set");
|
||||||
|
let client_secret =
|
||||||
|
std::env::var("AGENTIDP_CLIENT_SECRET").expect("AGENTIDP_CLIENT_SECRET must be set");
|
||||||
|
|
||||||
|
let tm = Arc::new(TokenManager::new(&api_url, &client_id, &client_secret));
|
||||||
|
|
||||||
|
let handles: Vec<_> = (0..50)
|
||||||
|
.map(|_| {
|
||||||
|
let tm_clone = Arc::clone(&tm);
|
||||||
|
tokio::spawn(async move { tm_clone.get_token().await })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut tokens = Vec::with_capacity(50);
|
||||||
|
for handle in handles {
|
||||||
|
let token = handle
|
||||||
|
.await
|
||||||
|
.expect("task did not panic")
|
||||||
|
.expect("get_token succeeded");
|
||||||
|
tokens.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All 50 calls must return the same token (single fetch, all from cache).
|
||||||
|
let first = &tokens[0];
|
||||||
|
for t in &tokens[1..] {
|
||||||
|
assert_eq!(t, first, "all concurrent calls must return the same token");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user