Public Access
initial-commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user