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).
360 lines
11 KiB
Rust
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");
|
|
}
|
|
}
|