feat: KratosClient — identity management (30 endpoints)

Typed Kratos admin API client covering identities, sessions,
recovery, schemas, courier messages, and health checks.

Bump: sunbeam-sdk v0.3.0
This commit is contained in:
2026-03-21 20:20:08 +00:00
parent 6823772055
commit f0bc363755
4 changed files with 644 additions and 2 deletions

2
Cargo.lock generated
View File

@@ -3591,7 +3591,7 @@ dependencies = [
[[package]] [[package]]
name = "sunbeam-sdk" name = "sunbeam-sdk"
version = "0.2.0" version = "0.3.0"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "sunbeam-sdk" name = "sunbeam-sdk"
version = "0.2.0" version = "0.3.0"
edition = "2024" edition = "2024"
description = "Sunbeam SDK — reusable library for cluster management" description = "Sunbeam SDK — reusable library for cluster management"
repository = "https://src.sunbeam.pt/studio/cli" repository = "https://src.sunbeam.pt/studio/cli"

View File

@@ -0,0 +1,378 @@
//! Kratos identity management client.
pub mod types;
use crate::client::{AuthMethod, HttpTransport, ServiceClient};
use crate::error::Result;
use reqwest::Method;
use types::*;
/// Client for the Ory Kratos Admin API.
pub struct KratosClient {
pub(crate) transport: HttpTransport,
}
impl ServiceClient for KratosClient {
fn service_name(&self) -> &'static str {
"kratos"
}
fn base_url(&self) -> &str {
&self.transport.base_url
}
fn from_parts(base_url: String, auth: AuthMethod) -> Self {
Self {
transport: HttpTransport::new(&base_url, auth),
}
}
}
impl KratosClient {
/// Build a KratosClient from domain (e.g. `https://id.{domain}`).
pub fn connect(domain: &str) -> Self {
let base_url = format!("https://id.{domain}");
Self::from_parts(base_url, AuthMethod::None)
}
// -- Identities ---------------------------------------------------------
/// List identities with optional pagination.
pub async fn list_identities(
&self,
page: Option<u32>,
page_size: Option<u32>,
) -> Result<Vec<Identity>> {
let page = page.unwrap_or(1);
let size = page_size.unwrap_or(20);
self.transport
.json(
Method::GET,
&format!("admin/identities?page={page}&page_size={size}"),
Option::<&()>::None,
"kratos list identities",
)
.await
}
/// Create a new identity.
pub async fn create_identity(&self, body: &CreateIdentityBody) -> Result<Identity> {
self.transport
.json(Method::POST, "admin/identities", Some(body), "kratos create identity")
.await
}
/// Get a single identity by ID.
pub async fn get_identity(&self, id: &str) -> Result<Identity> {
self.transport
.json(
Method::GET,
&format!("admin/identities/{id}"),
Option::<&()>::None,
"kratos get identity",
)
.await
}
/// Update an identity (full replace).
pub async fn update_identity(&self, id: &str, body: &UpdateIdentityBody) -> Result<Identity> {
self.transport
.json(
Method::PUT,
&format!("admin/identities/{id}"),
Some(body),
"kratos update identity",
)
.await
}
/// Patch an identity (partial update).
pub async fn patch_identity(
&self,
id: &str,
patches: &[serde_json::Value],
) -> Result<Identity> {
self.transport
.json(
Method::PATCH,
&format!("admin/identities/{id}"),
Some(&patches),
"kratos patch identity",
)
.await
}
/// Delete an identity.
pub async fn delete_identity(&self, id: &str) -> Result<()> {
self.transport
.send(
Method::DELETE,
&format!("admin/identities/{id}"),
Option::<&()>::None,
"kratos delete identity",
)
.await
}
/// Batch patch identities.
pub async fn batch_patch_identities(
&self,
body: &BatchPatchIdentitiesBody,
) -> Result<BatchPatchResult> {
self.transport
.json(Method::PATCH, "admin/identities", Some(body), "kratos batch patch")
.await
}
/// Get identity by external credential identifier (e.g. email).
pub async fn get_by_credential_identifier(&self, identifier: &str) -> Result<Vec<Identity>> {
self.transport
.json(
Method::GET,
&format!(
"admin/identities?credentials_identifier={}&page_size=1",
identifier
),
Option::<&()>::None,
"kratos get by credential",
)
.await
}
/// Delete a specific credential from an identity.
pub async fn delete_credential(
&self,
id: &str,
credential_type: &str,
) -> Result<()> {
self.transport
.send(
Method::DELETE,
&format!("admin/identities/{id}/credentials/{credential_type}"),
Option::<&()>::None,
"kratos delete credential",
)
.await
}
// -- Sessions -----------------------------------------------------------
/// List all sessions across identities.
pub async fn list_sessions(
&self,
page_size: Option<u32>,
page_token: Option<&str>,
active: Option<bool>,
) -> Result<Vec<Session>> {
let mut path = format!(
"admin/sessions?page_size={}",
page_size.unwrap_or(20)
);
if let Some(token) = page_token {
path.push_str(&format!("&page_token={token}"));
}
if let Some(active) = active {
path.push_str(&format!("&active={active}"));
}
self.transport
.json(Method::GET, &path, Option::<&()>::None, "kratos list sessions")
.await
}
/// Get a specific session.
pub async fn get_session(&self, id: &str) -> Result<Session> {
self.transport
.json(
Method::GET,
&format!("admin/sessions/{id}"),
Option::<&()>::None,
"kratos get session",
)
.await
}
/// Extend a session.
pub async fn extend_session(&self, id: &str) -> Result<Session> {
self.transport
.json(
Method::PATCH,
&format!("admin/sessions/{id}/extend"),
Option::<&()>::None,
"kratos extend session",
)
.await
}
/// Disable (revoke) a session.
pub async fn disable_session(&self, id: &str) -> Result<()> {
self.transport
.send(
Method::DELETE,
&format!("admin/sessions/{id}"),
Option::<&()>::None,
"kratos disable session",
)
.await
}
/// List sessions for a specific identity.
pub async fn list_identity_sessions(&self, identity_id: &str) -> Result<Vec<Session>> {
self.transport
.json(
Method::GET,
&format!("admin/identities/{identity_id}/sessions"),
Option::<&()>::None,
"kratos list identity sessions",
)
.await
}
/// Delete all sessions for a specific identity.
pub async fn delete_identity_sessions(&self, identity_id: &str) -> Result<()> {
self.transport
.send(
Method::DELETE,
&format!("admin/identities/{identity_id}/sessions"),
Option::<&()>::None,
"kratos delete identity sessions",
)
.await
}
// -- Recovery -----------------------------------------------------------
/// Create a recovery code for an identity.
pub async fn create_recovery_code(
&self,
identity_id: &str,
expires_in: Option<&str>,
) -> Result<RecoveryCodeResult> {
let body = serde_json::json!({
"identity_id": identity_id,
"expires_in": expires_in.unwrap_or("24h"),
});
self.transport
.json(Method::POST, "admin/recovery/code", Some(&body), "kratos recovery code")
.await
}
/// Create a recovery link for an identity.
pub async fn create_recovery_link(
&self,
identity_id: &str,
expires_in: Option<&str>,
) -> Result<RecoveryLinkResult> {
let body = serde_json::json!({
"identity_id": identity_id,
"expires_in": expires_in.unwrap_or("24h"),
});
self.transport
.json(Method::POST, "admin/recovery/link", Some(&body), "kratos recovery link")
.await
}
// -- Schemas ------------------------------------------------------------
/// List identity schemas.
pub async fn list_schemas(&self) -> Result<Vec<IdentitySchema>> {
self.transport
.json(
Method::GET,
"schemas",
Option::<&()>::None,
"kratos list schemas",
)
.await
}
/// Get a specific identity schema.
pub async fn get_schema(&self, id: &str) -> Result<serde_json::Value> {
self.transport
.json(
Method::GET,
&format!("schemas/{id}"),
Option::<&()>::None,
"kratos get schema",
)
.await
}
// -- Courier messages ---------------------------------------------------
/// List courier messages.
pub async fn list_courier_messages(
&self,
page_size: Option<u32>,
page_token: Option<&str>,
) -> Result<Vec<CourierMessage>> {
let mut path = format!(
"admin/courier/messages?page_size={}",
page_size.unwrap_or(20)
);
if let Some(token) = page_token {
path.push_str(&format!("&page_token={token}"));
}
self.transport
.json(Method::GET, &path, Option::<&()>::None, "kratos list courier")
.await
}
/// Get a specific courier message.
pub async fn get_courier_message(&self, id: &str) -> Result<CourierMessage> {
self.transport
.json(
Method::GET,
&format!("admin/courier/messages/{id}"),
Option::<&()>::None,
"kratos get courier message",
)
.await
}
// -- Health -------------------------------------------------------------
/// Alive health check.
pub async fn alive(&self) -> Result<HealthStatus> {
self.transport
.json(
Method::GET,
"health/alive",
Option::<&()>::None,
"kratos alive",
)
.await
}
/// Ready health check.
pub async fn ready(&self) -> Result<HealthStatus> {
self.transport
.json(
Method::GET,
"health/ready",
Option::<&()>::None,
"kratos ready",
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connect_url() {
let c = KratosClient::connect("sunbeam.pt");
assert_eq!(c.base_url(), "https://id.sunbeam.pt");
assert_eq!(c.service_name(), "kratos");
}
#[test]
fn test_from_parts() {
let c = KratosClient::from_parts(
"http://localhost:4434".into(),
AuthMethod::None,
);
assert_eq!(c.base_url(), "http://localhost:4434");
}
}

View File

@@ -0,0 +1,264 @@
//! Kratos identity types.
use serde::{Deserialize, Serialize};
/// A Kratos identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
pub id: String,
pub schema_id: String,
#[serde(default)]
pub schema_url: String,
pub traits: serde_json::Value,
#[serde(default)]
pub state: Option<String>,
#[serde(default)]
pub metadata_public: Option<serde_json::Value>,
#[serde(default)]
pub metadata_admin: Option<serde_json::Value>,
#[serde(default)]
pub verifiable_addresses: Option<Vec<VerifiableAddress>>,
#[serde(default)]
pub recovery_addresses: Option<Vec<RecoveryAddress>>,
#[serde(default)]
pub credentials: Option<serde_json::Value>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub state_changed_at: Option<String>,
}
/// A verifiable address (e.g. email).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifiableAddress {
pub id: String,
pub value: String,
pub via: String,
pub status: String,
#[serde(default)]
pub verified: bool,
#[serde(default)]
pub verified_at: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
/// A recovery address.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryAddress {
pub id: String,
pub value: String,
pub via: String,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
/// Body for creating an identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateIdentityBody {
pub schema_id: String,
pub traits: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata_public: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata_admin: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credentials: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verifiable_addresses: Option<Vec<VerifiableAddress>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recovery_addresses: Option<Vec<RecoveryAddress>>,
}
/// Body for updating an identity (PUT).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateIdentityBody {
pub schema_id: String,
pub traits: serde_json::Value,
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata_public: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata_admin: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credentials: Option<serde_json::Value>,
}
/// Body for batch patching identities.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchPatchIdentitiesBody {
pub identities: Vec<BatchPatchEntry>,
}
/// A single entry in a batch patch.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchPatchEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub create: Option<CreateIdentityBody>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patch_id: Option<String>,
}
/// Result of a batch patch operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchPatchResult {
#[serde(default)]
pub identities: Vec<BatchPatchResultEntry>,
}
/// A single entry in batch patch results.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchPatchResultEntry {
#[serde(default)]
pub action: String,
#[serde(default)]
pub identity: Option<String>,
#[serde(default)]
pub patch_id: Option<String>,
}
/// A Kratos session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
#[serde(default)]
pub active: Option<bool>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub authenticated_at: Option<String>,
#[serde(default)]
pub authenticator_assurance_level: Option<String>,
#[serde(default)]
pub identity: Option<Identity>,
#[serde(default)]
pub devices: Option<Vec<SessionDevice>>,
}
/// Device info attached to a session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionDevice {
#[serde(default)]
pub id: String,
#[serde(default)]
pub ip_address: Option<String>,
#[serde(default)]
pub user_agent: Option<String>,
#[serde(default)]
pub location: Option<String>,
}
/// Recovery code creation result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryCodeResult {
#[serde(default)]
pub recovery_link: String,
#[serde(default)]
pub recovery_code: String,
#[serde(default)]
pub expires_at: Option<String>,
}
/// Recovery link creation result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryLinkResult {
#[serde(default)]
pub recovery_link: String,
#[serde(default)]
pub expires_at: Option<String>,
}
/// An identity schema definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentitySchema {
pub id: String,
#[serde(default)]
pub schema: Option<serde_json::Value>,
}
/// A courier message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CourierMessage {
pub id: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub recipient: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub subject: String,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
/// Health check response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthStatus {
pub status: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identity_roundtrip() {
let json = serde_json::json!({
"id": "abc-123",
"schema_id": "employee",
"traits": { "email": "test@example.com" },
"state": "active"
});
let identity: Identity = serde_json::from_value(json).unwrap();
assert_eq!(identity.id, "abc-123");
assert_eq!(identity.schema_id, "employee");
assert_eq!(identity.state, Some("active".to_string()));
}
#[test]
fn test_create_identity_body() {
let body = CreateIdentityBody {
schema_id: "default".into(),
traits: serde_json::json!({"email": "new@example.com"}),
state: Some("active".into()),
metadata_public: None,
metadata_admin: None,
credentials: None,
verifiable_addresses: None,
recovery_addresses: None,
};
let json = serde_json::to_value(&body).unwrap();
assert_eq!(json["schema_id"], "default");
assert!(json.get("metadata_public").is_none());
}
#[test]
fn test_health_status() {
let json = serde_json::json!({"status": "ok"});
let h: HealthStatus = serde_json::from_value(json).unwrap();
assert_eq!(h.status, "ok");
}
#[test]
fn test_recovery_code_result() {
let json = serde_json::json!({
"recovery_link": "https://example.com/recover",
"recovery_code": "abc123"
});
let r: RecoveryCodeResult = serde_json::from_value(json).unwrap();
assert_eq!(r.recovery_code, "abc123");
}
}