use anyhow::{anyhow, Result}; use async_trait::async_trait; use charybdis::database::EntityRepository; use charybdis::plugins::http_client::{AuthConfig, PluginHttpClient}; use charybdis::plugins::{Plugin, PluginConfig, PluginType, SyncConfig, SyncPlugin, SyncResult}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tracing::info; mod sync; /// Keycloak plugin configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeycloakConfig { /// Keycloak server base URL (e.g., "https://keycloak.company.com") pub base_url: String, /// Keycloak realm to sync from pub realm: String, /// Client ID for service account authentication pub client_id: String, /// Client secret for service account authentication pub client_secret: String, /// Sync configuration #[serde(default)] pub sync: SyncOptions, } /// Sync options #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SyncOptions { /// Cron schedule (e.g., "0 */5 * * * *" for every 5 minutes) #[serde(default)] pub schedule: Option, /// Sync on startup #[serde(default = "default_true")] pub on_startup: bool, /// Allow manual trigger via API #[serde(default = "default_true")] pub manual_trigger: bool, /// Sync users #[serde(default = "default_true")] pub sync_users: bool, /// Sync groups #[serde(default = "default_true")] pub sync_groups: bool, /// Namespace to assign to synced entities #[serde(default = "default_namespace")] pub namespace: String, /// Max results per API page #[serde(default = "default_page_size")] pub page_size: i32, } fn default_true() -> bool { true } fn default_namespace() -> String { "keycloak".to_string() } fn default_page_size() -> i32 { 100 } impl Default for SyncOptions { fn default() -> Self { Self { schedule: None, on_startup: true, manual_trigger: true, sync_users: true, sync_groups: true, namespace: default_namespace(), page_size: default_page_size(), } } } impl KeycloakConfig { /// Load Keycloak configuration from TOML config map pub fn from_toml(config: &HashMap) -> Result { let value = toml::Value::Table(config.clone().into_iter().collect()); let config: KeycloakConfig = value .try_into() .map_err(|e| anyhow!("Failed to parse Keycloak configuration: {}", e))?; Ok(config) } } /// Keycloak API client #[derive(Clone)] pub struct KeycloakClient { http: PluginHttpClient, raw_client: reqwest::Client, realm: String, token_url: String, client_id: String, client_secret: String, /// Cached access token access_token: Arc>>, } /// Keycloak user representation from the Admin REST API #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KeycloakUser { pub id: String, pub username: String, #[serde(default)] pub email: Option, #[serde(default)] pub first_name: Option, #[serde(default)] pub last_name: Option, #[serde(default)] pub enabled: bool, #[serde(default)] pub email_verified: bool, #[serde(default)] pub attributes: Option>>, } /// Keycloak group representation from the Admin REST API #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KeycloakGroup { pub id: String, pub name: String, #[serde(default)] pub path: String, #[serde(default)] pub sub_groups: Vec, #[serde(default)] pub attributes: Option>>, } impl KeycloakClient { pub fn new(config: &KeycloakConfig) -> Result { let base_url = config.base_url.trim_end_matches('/'); let admin_url = format!("{}/admin/realms/{}", base_url, config.realm); // Create HTTP client without auth — we'll add the token manually after obtaining it let http = PluginHttpClient::new(admin_url, AuthConfig::None)?; let raw_client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| anyhow!("Failed to create HTTP client for Keycloak: {}", e))?; let token_url = format!( "{}/realms/{}/protocol/openid-connect/token", base_url, config.realm ); Ok(Self { http, raw_client, realm: config.realm.clone(), token_url, client_id: config.client_id.clone(), client_secret: config.client_secret.clone(), access_token: Arc::new(tokio::sync::RwLock::new(None)), }) } /// Obtain an access token using client credentials grant pub async fn authenticate(&self) -> Result<()> { info!("Authenticating with Keycloak (client credentials grant)"); let response = self.raw_client .post(&self.token_url) .form(&[ ("grant_type", "client_credentials"), ("client_id", &self.client_id), ("client_secret", &self.client_secret), ]) .send() .await .map_err(|e| anyhow!("Keycloak token request failed: {}", e))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(anyhow!( "Keycloak authentication failed ({}): {}", status, body )); } let token_response: serde_json::Value = response.json().await?; let access_token = token_response["access_token"] .as_str() .ok_or_else(|| anyhow!("No access_token in Keycloak response"))? .to_string(); let mut token = self.access_token.write().await; *token = Some(access_token); info!("Keycloak authentication successful"); Ok(()) } /// Make an authenticated GET request to Keycloak Admin API async fn get(&self, path: &str) -> Result { let token = self.access_token.read().await; let token = token .as_ref() .ok_or_else(|| anyhow!("Not authenticated — call authenticate() first"))?; let url = format!("{}/{}", self.http.base_url(), path.trim_start_matches('/')); let response = self.raw_client .get(&url) .header("Authorization", format!("Bearer {}", token)) .header("Accept", "application/json") .send() .await?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(anyhow!("Keycloak API error ({}): {}", status, body)); } Ok(response.json().await?) } /// Fetch all users from the realm (paginated) pub async fn fetch_users(&self, page_size: i32) -> Result> { let mut all_users = Vec::new(); let mut first = 0; loop { let response = self .get(&format!("users?first={}&max={}", first, page_size)) .await?; let users: Vec = serde_json::from_value(response)?; let count = users.len(); all_users.extend(users); if (count as i32) < page_size { break; } first += page_size; } info!("Fetched {} users from Keycloak realm '{}'", all_users.len(), self.realm); Ok(all_users) } /// Fetch all groups from the realm pub async fn fetch_groups(&self) -> Result> { let response = self.get("groups?briefRepresentation=false").await?; let groups: Vec = serde_json::from_value(response)?; info!("Fetched {} top-level groups from Keycloak realm '{}'", groups.len(), self.realm); Ok(groups) } /// Fetch members of a group pub async fn fetch_group_members(&self, group_id: &str) -> Result> { let response = self.get(&format!("groups/{}/members", group_id)).await?; let members: Vec = serde_json::from_value(response)?; Ok(members) } } /// Keycloak sync plugin pub struct KeycloakPlugin { config: KeycloakConfig, client: KeycloakClient, repository: Arc, sync_config: SyncConfig, } impl KeycloakPlugin { pub fn new(config: KeycloakConfig, repository: Arc) -> Result { if config.base_url.is_empty() { return Err(anyhow!("Keycloak base_url cannot be empty")); } if config.realm.is_empty() { return Err(anyhow!("Keycloak realm cannot be empty")); } if config.client_id.is_empty() { return Err(anyhow!("Keycloak client_id cannot be empty")); } if config.client_secret.is_empty() { return Err(anyhow!("Keycloak client_secret cannot be empty")); } let client = KeycloakClient::new(&config)?; let sync_config = SyncConfig { schedule: config.sync.schedule.clone(), on_startup: config.sync.on_startup, manual_trigger: config.sync.manual_trigger, }; Ok(Self { config, client, repository, sync_config, }) } } #[async_trait] impl Plugin for KeycloakPlugin { fn name(&self) -> &str { "keycloak" } fn plugin_type(&self) -> PluginType { PluginType::Sync } fn load_config(&mut self, _config: PluginConfig) -> Result<()> { Ok(()) } fn validate_config(&self) -> Result<()> { if self.config.base_url.is_empty() { return Err(anyhow!("Keycloak base_url cannot be empty")); } if self.config.realm.is_empty() { return Err(anyhow!("Keycloak realm cannot be empty")); } Ok(()) } } #[async_trait] impl SyncPlugin for KeycloakPlugin { fn sync_config(&self) -> &SyncConfig { &self.sync_config } async fn sync(&self) -> Result { sync::run_sync(&self.client, &self.repository, &self.config).await } }