Files
sol/src/sdk/kratos.rs
Sienna Meridian Satterwhite 84278fc1f5 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).
2026-03-23 01:41:25 +00:00

360 lines
11 KiB
Rust

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");
}
}