initial-commit

This commit is contained in:
Guillaume GRABÉ
2026-05-12 17:06:43 +02:00
commit 051a080dfa
110 changed files with 26377 additions and 0 deletions
+355
View File
@@ -0,0 +1,355 @@
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<String>,
/// 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<String, toml::Value>) -> Result<Self> {
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<tokio::sync::RwLock<Option<String>>>,
}
/// 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<String>,
#[serde(default)]
pub first_name: Option<String>,
#[serde(default)]
pub last_name: Option<String>,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub email_verified: bool,
#[serde(default)]
pub attributes: Option<HashMap<String, Vec<String>>>,
}
/// 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<KeycloakGroup>,
#[serde(default)]
pub attributes: Option<HashMap<String, Vec<String>>>,
}
impl KeycloakClient {
pub fn new(config: &KeycloakConfig) -> Result<Self> {
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<serde_json::Value> {
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<Vec<KeycloakUser>> {
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<KeycloakUser> = 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<Vec<KeycloakGroup>> {
let response = self.get("groups?briefRepresentation=false").await?;
let groups: Vec<KeycloakGroup> = 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<Vec<KeycloakUser>> {
let response = self.get(&format!("groups/{}/members", group_id)).await?;
let members: Vec<KeycloakUser> = serde_json::from_value(response)?;
Ok(members)
}
}
/// Keycloak sync plugin
pub struct KeycloakPlugin {
config: KeycloakConfig,
client: KeycloakClient,
repository: Arc<EntityRepository>,
sync_config: SyncConfig,
}
impl KeycloakPlugin {
pub fn new(config: KeycloakConfig, repository: Arc<EntityRepository>) -> Result<Self> {
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<SyncResult> {
sync::run_sync(&self.client, &self.repository, &self.config).await
}
}