From f0bc36375549d45d99d1dd2bd09f66e564bad474 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sat, 21 Mar 2026 20:20:08 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20KratosClient=20=E2=80=94=20identity=20m?= =?UTF-8?q?anagement=20(30=20endpoints)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed Kratos admin API client covering identities, sessions, recovery, schemas, courier messages, and health checks. Bump: sunbeam-sdk v0.3.0 --- Cargo.lock | 2 +- sunbeam-sdk/Cargo.toml | 2 +- sunbeam-sdk/src/identity/mod.rs | 378 ++++++++++++++++++++++++++++++ sunbeam-sdk/src/identity/types.rs | 264 +++++++++++++++++++++ 4 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 sunbeam-sdk/src/identity/mod.rs create mode 100644 sunbeam-sdk/src/identity/types.rs diff --git a/Cargo.lock b/Cargo.lock index 2f1124e..485b772 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3591,7 +3591,7 @@ dependencies = [ [[package]] name = "sunbeam-sdk" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64", "bytes", diff --git a/sunbeam-sdk/Cargo.toml b/sunbeam-sdk/Cargo.toml index 1cadfb1..b5e2e9c 100644 --- a/sunbeam-sdk/Cargo.toml +++ b/sunbeam-sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sunbeam-sdk" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Sunbeam SDK — reusable library for cluster management" repository = "https://src.sunbeam.pt/studio/cli" diff --git a/sunbeam-sdk/src/identity/mod.rs b/sunbeam-sdk/src/identity/mod.rs new file mode 100644 index 0000000..434c5f2 --- /dev/null +++ b/sunbeam-sdk/src/identity/mod.rs @@ -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, + page_size: Option, + ) -> Result> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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, + page_token: Option<&str>, + active: Option, + ) -> Result> { + 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 { + 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 { + 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> { + 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 { + 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 { + 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> { + 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 { + 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, + page_token: Option<&str>, + ) -> Result> { + 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 { + 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 { + self.transport + .json( + Method::GET, + "health/alive", + Option::<&()>::None, + "kratos alive", + ) + .await + } + + /// Ready health check. + pub async fn ready(&self) -> Result { + 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"); + } +} diff --git a/sunbeam-sdk/src/identity/types.rs b/sunbeam-sdk/src/identity/types.rs new file mode 100644 index 0000000..c27d2a5 --- /dev/null +++ b/sunbeam-sdk/src/identity/types.rs @@ -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, + #[serde(default)] + pub metadata_public: Option, + #[serde(default)] + pub metadata_admin: Option, + #[serde(default)] + pub verifiable_addresses: Option>, + #[serde(default)] + pub recovery_addresses: Option>, + #[serde(default)] + pub credentials: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub state_changed_at: Option, +} + +/// 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, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} + +/// 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, + #[serde(default)] + pub updated_at: Option, +} + +/// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata_public: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata_admin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credentials: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verifiable_addresses: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recovery_addresses: Option>, +} + +/// 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(default, skip_serializing_if = "Option::is_none")] + pub metadata_admin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credentials: Option, +} + +/// Body for batch patching identities. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchPatchIdentitiesBody { + pub identities: Vec, +} + +/// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub patch_id: Option, +} + +/// Result of a batch patch operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchPatchResult { + #[serde(default)] + pub identities: Vec, +} + +/// 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, + #[serde(default)] + pub patch_id: Option, +} + +/// A Kratos session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: String, + #[serde(default)] + pub active: Option, + #[serde(default)] + pub expires_at: Option, + #[serde(default)] + pub authenticated_at: Option, + #[serde(default)] + pub authenticator_assurance_level: Option, + #[serde(default)] + pub identity: Option, + #[serde(default)] + pub devices: Option>, +} + +/// 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, + #[serde(default)] + pub user_agent: Option, + #[serde(default)] + pub location: Option, +} + +/// 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, +} + +/// Recovery link creation result. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecoveryLinkResult { + #[serde(default)] + pub recovery_link: String, + #[serde(default)] + pub expires_at: Option, +} + +/// An identity schema definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentitySchema { + pub id: String, + #[serde(default)] + pub schema: Option, +} + +/// 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, + #[serde(default)] + pub updated_at: Option, +} + +/// 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"); + } +}