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:
2026-03-23 01:41:25 +00:00
parent 8e7c572381
commit 84278fc1f5
2 changed files with 568 additions and 0 deletions

359
src/sdk/kratos.rs Normal file
View 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
View 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"]
}),
),
]
}