From 84278fc1f5583be7f06b17c316444b8c83e8c89f Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Mon, 23 Mar 2026 01:41:25 +0000 Subject: [PATCH] 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). --- src/sdk/kratos.rs | 359 ++++++++++++++++++++++++++++++++++++++++++ src/tools/identity.rs | 209 ++++++++++++++++++++++++ 2 files changed, 568 insertions(+) create mode 100644 src/sdk/kratos.rs create mode 100644 src/tools/identity.rs diff --git a/src/sdk/kratos.rs b/src/sdk/kratos.rs new file mode 100644 index 0000000..1ccae17 --- /dev/null +++ b/src/sdk/kratos.rs @@ -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, +} + +#[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 { + 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 = 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, + ) -> Result, 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 { + 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 { + 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 { + 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 { + self.set_state(email_or_id, "inactive").await + } + + pub async fn enable_user(&self, email_or_id: &str) -> Result { + self.set_state(email_or_id, "active").await + } + + async fn set_state(&self, email_or_id: &str, state: &str) -> Result { + 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, 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"); + } +} diff --git a/src/tools/identity.rs b/src/tools/identity.rs new file mode 100644 index 0000000..03ab7c5 --- /dev/null +++ b/src/tools/identity.rs @@ -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, + name: &str, + arguments: &str, +) -> anyhow::Result { + 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 { + 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"] + }), + ), + ] +}