Public Access
356 lines
10 KiB
Rust
356 lines
10 KiB
Rust
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
|
|
}
|
|
}
|