add identity agent: 7 kratos admin API tools
list_users, get_user, create_user, recover_user, disable_user, enable_user, list_sessions. all via kratos admin API (cluster- internal, no auth needed). email-to-UUID resolution with fallback search. delete_user and set_password excluded (CLI-only).
This commit is contained in:
359
src/sdk/kratos.rs
Normal file
359
src/sdk/kratos.rs
Normal file
@@ -0,0 +1,359 @@
|
||||
use reqwest::Client as HttpClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct KratosClient {
|
||||
admin_url: String,
|
||||
http: HttpClient,
|
||||
}
|
||||
|
||||
// ── Response types ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Identity {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub state: String,
|
||||
pub traits: IdentityTraits,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct IdentityTraits {
|
||||
#[serde(default)]
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<NameTraits>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NameTraits {
|
||||
#[serde(default)]
|
||||
pub first: String,
|
||||
#[serde(default)]
|
||||
pub last: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub active: bool,
|
||||
#[serde(default)]
|
||||
pub authenticated_at: String,
|
||||
#[serde(default)]
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RecoveryResponse {
|
||||
#[serde(default)]
|
||||
pub recovery_link: String,
|
||||
#[serde(default)]
|
||||
pub recovery_code: String,
|
||||
}
|
||||
|
||||
// ── Implementation ──────────────────────────────────────────────────────────
|
||||
|
||||
impl KratosClient {
|
||||
pub fn new(admin_url: String) -> Self {
|
||||
Self {
|
||||
admin_url: admin_url.trim_end_matches('/').to_string(),
|
||||
http: HttpClient::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an email or UUID to an identity ID.
|
||||
/// If the input looks like a UUID, use it directly.
|
||||
/// Otherwise, search by credentials_identifier (email).
|
||||
async fn resolve_id(&self, email_or_id: &str) -> Result<String, String> {
|
||||
if is_uuid(email_or_id) {
|
||||
return Ok(email_or_id.to_string());
|
||||
}
|
||||
|
||||
// Search by email
|
||||
let url = format!(
|
||||
"{}/admin/identities?credentials_identifier={}",
|
||||
self.admin_url,
|
||||
urlencoding::encode(email_or_id)
|
||||
);
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("failed to search identities: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("identity search failed: {text}"));
|
||||
}
|
||||
|
||||
let identities: Vec<Identity> = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse identities: {e}"))?;
|
||||
|
||||
identities
|
||||
.first()
|
||||
.map(|i| i.id.clone())
|
||||
.ok_or_else(|| format!("no identity found for '{email_or_id}'"))
|
||||
}
|
||||
|
||||
pub async fn list_users(
|
||||
&self,
|
||||
search: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<Identity>, String> {
|
||||
let mut url = format!("{}/admin/identities", self.admin_url);
|
||||
let mut params = vec![];
|
||||
if let Some(s) = search {
|
||||
params.push(format!(
|
||||
"credentials_identifier={}",
|
||||
urlencoding::encode(s)
|
||||
));
|
||||
}
|
||||
params.push(format!("page_size={}", limit.unwrap_or(50)));
|
||||
if !params.is_empty() {
|
||||
url.push_str(&format!("?{}", params.join("&")));
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("failed to list identities: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("list identities failed: {text}"));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse identities: {e}"))
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, email_or_id: &str) -> Result<Identity, String> {
|
||||
let id = self.resolve_id(email_or_id).await?;
|
||||
let url = format!("{}/admin/identities/{}", self.admin_url, id);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("failed to get identity: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("get identity failed: {text}"));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse identity: {e}"))
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
&self,
|
||||
email: &str,
|
||||
first_name: Option<&str>,
|
||||
last_name: Option<&str>,
|
||||
) -> Result<Identity, String> {
|
||||
let mut traits = serde_json::json!({ "email": email });
|
||||
if first_name.is_some() || last_name.is_some() {
|
||||
traits["name"] = serde_json::json!({
|
||||
"first": first_name.unwrap_or(""),
|
||||
"last": last_name.unwrap_or(""),
|
||||
});
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
});
|
||||
|
||||
let url = format!("{}/admin/identities", self.admin_url);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("failed to create identity: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("create identity failed: {text}"));
|
||||
}
|
||||
|
||||
let identity: Identity = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse identity: {e}"))?;
|
||||
|
||||
info!(id = identity.id.as_str(), email, "Created identity");
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
pub async fn recover_user(&self, email_or_id: &str) -> Result<RecoveryResponse, String> {
|
||||
let id = self.resolve_id(email_or_id).await?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"identity_id": id,
|
||||
});
|
||||
|
||||
let url = format!("{}/admin/recovery/code", self.admin_url);
|
||||
let resp = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("failed to create recovery: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("create recovery failed: {text}"));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse recovery response: {e}"))
|
||||
}
|
||||
|
||||
pub async fn disable_user(&self, email_or_id: &str) -> Result<Identity, String> {
|
||||
self.set_state(email_or_id, "inactive").await
|
||||
}
|
||||
|
||||
pub async fn enable_user(&self, email_or_id: &str) -> Result<Identity, String> {
|
||||
self.set_state(email_or_id, "active").await
|
||||
}
|
||||
|
||||
async fn set_state(&self, email_or_id: &str, state: &str) -> Result<Identity, String> {
|
||||
let id = self.resolve_id(email_or_id).await?;
|
||||
let url = format!("{}/admin/identities/{}", self.admin_url, id);
|
||||
|
||||
let body = serde_json::json!({ "state": state });
|
||||
let resp = self
|
||||
.http
|
||||
.put(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("failed to update identity state: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("update identity state failed: {text}"));
|
||||
}
|
||||
|
||||
info!(id = id.as_str(), state, "Updated identity state");
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse identity: {e}"))
|
||||
}
|
||||
|
||||
pub async fn list_sessions(&self, email_or_id: &str) -> Result<Vec<Session>, String> {
|
||||
let id = self.resolve_id(email_or_id).await?;
|
||||
let url = format!("{}/admin/identities/{}/sessions", self.admin_url, id);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("failed to list sessions: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("list sessions failed: {text}"));
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse sessions: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a string looks like a UUID (Kratos identity ID format).
|
||||
fn is_uuid(s: &str) -> bool {
|
||||
s.len() == 36
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() || c == '-')
|
||||
&& s.matches('-').count() == 4
|
||||
}
|
||||
|
||||
// ── URL encoding helper ─────────────────────────────────────────────────────
|
||||
|
||||
mod urlencoding {
|
||||
pub fn encode(s: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_identity_deserialize() {
|
||||
let json = serde_json::json!({
|
||||
"id": "cd0a2db5-1234-5678-9abc-def012345678",
|
||||
"state": "active",
|
||||
"traits": {
|
||||
"email": "sienna@sunbeam.pt",
|
||||
"name": { "first": "Sienna", "last": "V" }
|
||||
},
|
||||
"created_at": "2026-03-05T10:00:00Z",
|
||||
"updated_at": "2026-03-20T12:00:00Z",
|
||||
});
|
||||
let id: Identity = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(id.state, "active");
|
||||
assert_eq!(id.traits.email, "sienna@sunbeam.pt");
|
||||
assert_eq!(id.traits.name.as_ref().unwrap().first, "Sienna");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_minimal_traits() {
|
||||
let json = serde_json::json!({
|
||||
"id": "abc-123",
|
||||
"traits": { "email": "test@example.com" },
|
||||
});
|
||||
let id: Identity = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(id.traits.email, "test@example.com");
|
||||
assert!(id.traits.name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_deserialize() {
|
||||
let json = serde_json::json!({
|
||||
"id": "sess-123",
|
||||
"active": true,
|
||||
"authenticated_at": "2026-03-22T10:00:00Z",
|
||||
"expires_at": "2026-04-21T10:00:00Z",
|
||||
});
|
||||
let sess: Session = serde_json::from_value(json).unwrap();
|
||||
assert!(sess.active);
|
||||
assert_eq!(sess.id, "sess-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_uuid() {
|
||||
assert!(is_uuid("cd0a2db5-1234-5678-9abc-def012345678"));
|
||||
assert!(!is_uuid("sienna@sunbeam.pt"));
|
||||
assert!(!is_uuid("not-a-uuid"));
|
||||
assert!(!is_uuid(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_urlencoding() {
|
||||
assert_eq!(urlencoding::encode("hello@world.com"), "hello%40world.com");
|
||||
assert_eq!(urlencoding::encode("plain"), "plain");
|
||||
}
|
||||
}
|
||||
209
src/tools/identity.rs
Normal file
209
src/tools/identity.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::sdk::kratos::KratosClient;
|
||||
|
||||
/// Execute an identity tool call. Returns a JSON string result.
|
||||
pub async fn execute(
|
||||
kratos: &Arc<KratosClient>,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let args: Value = serde_json::from_str(arguments)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid tool arguments: {e}"))?;
|
||||
|
||||
match name {
|
||||
"identity_list_users" => {
|
||||
let search = args["search"].as_str();
|
||||
let limit = args["limit"].as_u64().map(|n| n as u32);
|
||||
|
||||
match kratos.list_users(search, limit).await {
|
||||
Ok(users) => Ok(serde_json::to_string(&users).unwrap_or_default()),
|
||||
Err(e) => Ok(json!({"error": e}).to_string()),
|
||||
}
|
||||
}
|
||||
"identity_get_user" => {
|
||||
let email_or_id = args["email_or_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'email_or_id'"))?;
|
||||
|
||||
match kratos.get_user(email_or_id).await {
|
||||
Ok(user) => Ok(serde_json::to_string(&user).unwrap_or_default()),
|
||||
Err(e) => Ok(json!({"error": e}).to_string()),
|
||||
}
|
||||
}
|
||||
"identity_create_user" => {
|
||||
let email = args["email"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'email'"))?;
|
||||
let first_name = args["first_name"].as_str();
|
||||
let last_name = args["last_name"].as_str();
|
||||
|
||||
match kratos.create_user(email, first_name, last_name).await {
|
||||
Ok(user) => Ok(serde_json::to_string(&user).unwrap_or_default()),
|
||||
Err(e) => Ok(json!({"error": e}).to_string()),
|
||||
}
|
||||
}
|
||||
"identity_recover_user" => {
|
||||
let email_or_id = args["email_or_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'email_or_id'"))?;
|
||||
|
||||
match kratos.recover_user(email_or_id).await {
|
||||
Ok(recovery) => Ok(serde_json::to_string(&recovery).unwrap_or_default()),
|
||||
Err(e) => Ok(json!({"error": e}).to_string()),
|
||||
}
|
||||
}
|
||||
"identity_disable_user" => {
|
||||
let email_or_id = args["email_or_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'email_or_id'"))?;
|
||||
|
||||
match kratos.disable_user(email_or_id).await {
|
||||
Ok(user) => Ok(serde_json::to_string(&user).unwrap_or_default()),
|
||||
Err(e) => Ok(json!({"error": e}).to_string()),
|
||||
}
|
||||
}
|
||||
"identity_enable_user" => {
|
||||
let email_or_id = args["email_or_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'email_or_id'"))?;
|
||||
|
||||
match kratos.enable_user(email_or_id).await {
|
||||
Ok(user) => Ok(serde_json::to_string(&user).unwrap_or_default()),
|
||||
Err(e) => Ok(json!({"error": e}).to_string()),
|
||||
}
|
||||
}
|
||||
"identity_list_sessions" => {
|
||||
let email_or_id = args["email_or_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'email_or_id'"))?;
|
||||
|
||||
match kratos.list_sessions(email_or_id).await {
|
||||
Ok(sessions) => Ok(serde_json::to_string(&sessions).unwrap_or_default()),
|
||||
Err(e) => Ok(json!({"error": e}).to_string()),
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!("Unknown identity tool: {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return Mistral tool definitions for identity tools.
|
||||
pub fn tool_definitions() -> Vec<mistralai_client::v1::tool::Tool> {
|
||||
use mistralai_client::v1::tool::Tool;
|
||||
|
||||
vec![
|
||||
Tool::new(
|
||||
"identity_list_users".into(),
|
||||
"List or search user accounts on the platform.".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search": {
|
||||
"type": "string",
|
||||
"description": "Search by email address or identifier"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results (default 50)"
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"identity_get_user".into(),
|
||||
"Get full details of a user account by email or ID.".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email_or_id": {
|
||||
"type": "string",
|
||||
"description": "Email address or identity UUID"
|
||||
}
|
||||
},
|
||||
"required": ["email_or_id"]
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"identity_create_user".into(),
|
||||
"Create a new user account on the platform.".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email address for the new account"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string",
|
||||
"description": "First name"
|
||||
},
|
||||
"last_name": {
|
||||
"type": "string",
|
||||
"description": "Last name"
|
||||
}
|
||||
},
|
||||
"required": ["email"]
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"identity_recover_user".into(),
|
||||
"Generate a one-time recovery link for a user account. \
|
||||
Use this when someone is locked out or needs to reset their password."
|
||||
.into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email_or_id": {
|
||||
"type": "string",
|
||||
"description": "Email address or identity UUID"
|
||||
}
|
||||
},
|
||||
"required": ["email_or_id"]
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"identity_disable_user".into(),
|
||||
"Disable (lock out) a user account. They will not be able to log in.".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email_or_id": {
|
||||
"type": "string",
|
||||
"description": "Email address or identity UUID"
|
||||
}
|
||||
},
|
||||
"required": ["email_or_id"]
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"identity_enable_user".into(),
|
||||
"Re-enable a previously disabled user account.".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email_or_id": {
|
||||
"type": "string",
|
||||
"description": "Email address or identity UUID"
|
||||
}
|
||||
},
|
||||
"required": ["email_or_id"]
|
||||
}),
|
||||
),
|
||||
Tool::new(
|
||||
"identity_list_sessions".into(),
|
||||
"List active sessions for a user account.".into(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email_or_id": {
|
||||
"type": "string",
|
||||
"description": "Email address or identity UUID"
|
||||
}
|
||||
},
|
||||
"required": ["email_or_id"]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user