Public Access
initial-commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "charybdis-keycloak-plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "charybdis_keycloak"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
charybdis = { path = "../.." }
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "0.8"
|
||||
tokio = { version = "1.48", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.18", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
@@ -0,0 +1,27 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package charybdis.plugins.keycloak;
|
||||
|
||||
// Keycloak plugin metadata
|
||||
// Used for plugin-specific sync status entities
|
||||
message KeycloakMetadata {
|
||||
string name = 1;
|
||||
string description = 2;
|
||||
|
||||
// Resource type (user, group)
|
||||
string resource_type = 3;
|
||||
|
||||
// Keycloak realm this resource belongs to
|
||||
string realm = 4;
|
||||
}
|
||||
|
||||
// Keycloak plugin spec
|
||||
message KeycloakSpec {
|
||||
// Sync status information
|
||||
string sync_status = 1;
|
||||
string last_sync = 2;
|
||||
string error_message = 3;
|
||||
|
||||
// Configuration stored as JSON
|
||||
string config_json = 4;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
use anyhow::Result;
|
||||
use charybdis::charybdis::core::{
|
||||
GroupMetadata, GroupProfile, GroupSpec, UserMetadata, UserProfile, UserSpec,
|
||||
};
|
||||
use charybdis::charybdis::entities::{entity, Entity};
|
||||
use charybdis::database::EntityRepository;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::{KeycloakClient, KeycloakConfig, KeycloakGroup, KeycloakUser};
|
||||
use charybdis::plugins::SyncResult;
|
||||
|
||||
/// Run a full sync cycle: fetch users and groups from Keycloak, reconcile with Charybdis.
|
||||
///
|
||||
/// This is a pure pull operation — it only reads from Keycloak and writes to Charybdis.
|
||||
/// Nothing is ever pushed back to Keycloak.
|
||||
pub async fn run_sync(
|
||||
client: &KeycloakClient,
|
||||
repository: &EntityRepository,
|
||||
config: &KeycloakConfig,
|
||||
) -> Result<SyncResult> {
|
||||
info!("Starting Keycloak sync for realm '{}'", config.realm);
|
||||
|
||||
// Authenticate with Keycloak
|
||||
client.authenticate().await?;
|
||||
|
||||
let mut result = SyncResult {
|
||||
entities_created: 0,
|
||||
entities_updated: 0,
|
||||
entities_deleted: 0,
|
||||
errors: Vec::new(),
|
||||
};
|
||||
|
||||
// Load existing Charybdis entities that were previously synced from Keycloak
|
||||
let existing_entities = repository.list_all().await?;
|
||||
let existing_users: HashMap<String, Entity> = existing_entities
|
||||
.iter()
|
||||
.filter(|e| e.kind == "User")
|
||||
.filter_map(|e| {
|
||||
e.annotations
|
||||
.get("keycloak.com/user-id")
|
||||
.map(|kc_id| (kc_id.clone(), e.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let existing_groups: HashMap<String, Entity> = existing_entities
|
||||
.iter()
|
||||
.filter(|e| e.kind == "Group")
|
||||
.filter_map(|e| {
|
||||
e.annotations
|
||||
.get("keycloak.com/group-id")
|
||||
.map(|kc_id| (kc_id.clone(), e.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sync users
|
||||
if config.sync.sync_users {
|
||||
match client.fetch_users(config.sync.page_size).await {
|
||||
Ok(users) => {
|
||||
info!("Syncing {} users from Keycloak", users.len());
|
||||
for user in &users {
|
||||
match sync_user(user, &existing_users, repository, config).await {
|
||||
Ok(SyncAction::Created) => result.entities_created += 1,
|
||||
Ok(SyncAction::Updated) => result.entities_updated += 1,
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to sync user '{}': {}", user.username, e);
|
||||
warn!("{}", msg);
|
||||
result.errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to fetch users from Keycloak: {}", e);
|
||||
error!("{}", msg);
|
||||
result.errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync groups
|
||||
if config.sync.sync_groups {
|
||||
match client.fetch_groups().await {
|
||||
Ok(groups) => {
|
||||
let flat_groups = flatten_groups(&groups);
|
||||
info!(
|
||||
"Syncing {} groups from Keycloak (flattened)",
|
||||
flat_groups.len()
|
||||
);
|
||||
for group in &flat_groups {
|
||||
match sync_group(group, &existing_groups, client, repository, config).await {
|
||||
Ok(SyncAction::Created) => result.entities_created += 1,
|
||||
Ok(SyncAction::Updated) => result.entities_updated += 1,
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to sync group '{}': {}", group.name, e);
|
||||
warn!("{}", msg);
|
||||
result.errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to fetch groups from Keycloak: {}", e);
|
||||
error!("{}", msg);
|
||||
result.errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Keycloak sync complete: created={}, updated={}, errors={}",
|
||||
result.entities_created,
|
||||
result.entities_updated,
|
||||
result.errors.len()
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Result of syncing a single entity
|
||||
enum SyncAction {
|
||||
Created,
|
||||
Updated,
|
||||
}
|
||||
|
||||
/// Build a Charybdis User entity from a Keycloak user
|
||||
fn build_user_entity(kc_user: &KeycloakUser, config: &KeycloakConfig) -> Entity {
|
||||
let display_name = match (&kc_user.first_name, &kc_user.last_name) {
|
||||
(Some(first), Some(last)) => format!("{} {}", first, last),
|
||||
(Some(first), None) => first.clone(),
|
||||
(None, Some(last)) => last.clone(),
|
||||
(None, None) => kc_user.username.clone(),
|
||||
};
|
||||
|
||||
let description = format!("User synced from Keycloak realm '{}'", config.realm);
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("keycloak.realm".to_string(), config.realm.clone());
|
||||
labels.insert("enabled".to_string(), kc_user.enabled.to_string());
|
||||
if kc_user.email_verified {
|
||||
labels.insert("email-verified".to_string(), "true".to_string());
|
||||
}
|
||||
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert("keycloak.com/user-id".to_string(), kc_user.id.clone());
|
||||
annotations.insert(
|
||||
"keycloak.com/username".to_string(),
|
||||
kc_user.username.clone(),
|
||||
);
|
||||
annotations.insert("keycloak.com/realm".to_string(), config.realm.clone());
|
||||
if let Some(email) = &kc_user.email {
|
||||
annotations.insert("keycloak.com/email".to_string(), email.clone());
|
||||
}
|
||||
|
||||
Entity {
|
||||
id: String::new(), // Will be set by server on create, or overwritten for updates
|
||||
kind: "User".to_string(),
|
||||
metadata: Some(entity::Metadata::UserMetadata(UserMetadata {
|
||||
name: kc_user.username.clone(),
|
||||
namespace: config.sync.namespace.clone(),
|
||||
description,
|
||||
labels,
|
||||
tags: vec!["keycloak".to_string(), "synced".to_string()],
|
||||
links: vec![],
|
||||
})),
|
||||
spec: Some(entity::Spec::UserSpec(UserSpec {
|
||||
profile: Some(UserProfile {
|
||||
display_name,
|
||||
email: kc_user.email.clone().unwrap_or_default(),
|
||||
picture: String::new(),
|
||||
}),
|
||||
member_of: vec![], // Populated during group sync via annotations
|
||||
})),
|
||||
annotations,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Charybdis Group entity from a Keycloak group
|
||||
fn build_group_entity(
|
||||
kc_group: &KeycloakGroup,
|
||||
member_usernames: Vec<String>,
|
||||
config: &KeycloakConfig,
|
||||
) -> Entity {
|
||||
let description = format!(
|
||||
"Group synced from Keycloak realm '{}' (path: {})",
|
||||
config.realm, kc_group.path
|
||||
);
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("keycloak.realm".to_string(), config.realm.clone());
|
||||
labels.insert("keycloak.path".to_string(), kc_group.path.clone());
|
||||
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert("keycloak.com/group-id".to_string(), kc_group.id.clone());
|
||||
annotations.insert(
|
||||
"keycloak.com/group-path".to_string(),
|
||||
kc_group.path.clone(),
|
||||
);
|
||||
annotations.insert("keycloak.com/realm".to_string(), config.realm.clone());
|
||||
annotations.insert(
|
||||
"keycloak.com/member-count".to_string(),
|
||||
member_usernames.len().to_string(),
|
||||
);
|
||||
|
||||
let parent = extract_parent_group(&kc_group.path);
|
||||
let children: Vec<String> = kc_group.sub_groups.iter().map(|g| g.name.clone()).collect();
|
||||
|
||||
Entity {
|
||||
id: String::new(),
|
||||
kind: "Group".to_string(),
|
||||
metadata: Some(entity::Metadata::GroupMetadata(GroupMetadata {
|
||||
name: kc_group.name.clone(),
|
||||
namespace: config.sync.namespace.clone(),
|
||||
description,
|
||||
labels,
|
||||
tags: vec!["keycloak".to_string(), "synced".to_string()],
|
||||
links: vec![],
|
||||
})),
|
||||
spec: Some(entity::Spec::GroupSpec(GroupSpec {
|
||||
r#type: "team".to_string(),
|
||||
profile: Some(GroupProfile {
|
||||
display_name: kc_group.name.clone(),
|
||||
email: String::new(),
|
||||
picture: String::new(),
|
||||
}),
|
||||
parent: parent.unwrap_or_default(),
|
||||
children,
|
||||
members: member_usernames,
|
||||
})),
|
||||
annotations,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync a single Keycloak user to a Charybdis User entity.
|
||||
///
|
||||
/// If the entity already exists (matched by `keycloak.com/user-id` annotation),
|
||||
/// it is always updated to reflect the current Keycloak state.
|
||||
/// Non-Keycloak annotations on the existing entity are preserved.
|
||||
async fn sync_user(
|
||||
kc_user: &KeycloakUser,
|
||||
existing: &HashMap<String, Entity>,
|
||||
repository: &EntityRepository,
|
||||
config: &KeycloakConfig,
|
||||
) -> Result<SyncAction> {
|
||||
let entity = build_user_entity(kc_user, config);
|
||||
|
||||
if let Some(existing_entity) = existing.get(&kc_user.id) {
|
||||
debug!(
|
||||
"Updating user '{}' (Keycloak ID: {})",
|
||||
kc_user.username, kc_user.id
|
||||
);
|
||||
let mut updated = entity;
|
||||
updated.id = existing_entity.id.clone();
|
||||
|
||||
// Preserve annotations that aren't managed by this plugin
|
||||
for (k, v) in &existing_entity.annotations {
|
||||
if !k.starts_with("keycloak.com/") {
|
||||
updated.annotations.entry(k.clone()).or_insert(v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
repository.update(&existing_entity.id, &updated).await?;
|
||||
return Ok(SyncAction::Updated);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Creating user '{}' (Keycloak ID: {})",
|
||||
kc_user.username, kc_user.id
|
||||
);
|
||||
repository.create(&entity).await?;
|
||||
Ok(SyncAction::Created)
|
||||
}
|
||||
|
||||
/// Sync a single Keycloak group to a Charybdis Group entity.
|
||||
///
|
||||
/// If the entity already exists (matched by `keycloak.com/group-id` annotation),
|
||||
/// it is always updated to reflect the current Keycloak state.
|
||||
/// Non-Keycloak annotations on the existing entity are preserved.
|
||||
async fn sync_group(
|
||||
kc_group: &KeycloakGroup,
|
||||
existing: &HashMap<String, Entity>,
|
||||
client: &KeycloakClient,
|
||||
repository: &EntityRepository,
|
||||
config: &KeycloakConfig,
|
||||
) -> Result<SyncAction> {
|
||||
// Fetch current group members from Keycloak
|
||||
let members = client.fetch_group_members(&kc_group.id).await?;
|
||||
let member_usernames: Vec<String> = members.iter().map(|m| m.username.clone()).collect();
|
||||
|
||||
let entity = build_group_entity(kc_group, member_usernames, config);
|
||||
|
||||
if let Some(existing_entity) = existing.get(&kc_group.id) {
|
||||
debug!(
|
||||
"Updating group '{}' (Keycloak ID: {})",
|
||||
kc_group.name, kc_group.id
|
||||
);
|
||||
let mut updated = entity;
|
||||
updated.id = existing_entity.id.clone();
|
||||
|
||||
// Preserve annotations that aren't managed by this plugin
|
||||
for (k, v) in &existing_entity.annotations {
|
||||
if !k.starts_with("keycloak.com/") {
|
||||
updated.annotations.entry(k.clone()).or_insert(v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
repository.update(&existing_entity.id, &updated).await?;
|
||||
return Ok(SyncAction::Updated);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Creating group '{}' (Keycloak ID: {})",
|
||||
kc_group.name, kc_group.id
|
||||
);
|
||||
repository.create(&entity).await?;
|
||||
Ok(SyncAction::Created)
|
||||
}
|
||||
|
||||
/// Flatten a nested group tree into a flat list
|
||||
fn flatten_groups(groups: &[KeycloakGroup]) -> Vec<KeycloakGroup> {
|
||||
let mut flat = Vec::new();
|
||||
for group in groups {
|
||||
flat.push(group.clone());
|
||||
if !group.sub_groups.is_empty() {
|
||||
flat.extend(flatten_groups(&group.sub_groups));
|
||||
}
|
||||
}
|
||||
flat
|
||||
}
|
||||
|
||||
/// Extract parent group name from a Keycloak group path.
|
||||
/// e.g., "/engineering/backend" -> Some("engineering")
|
||||
fn extract_parent_group(path: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
if parts.len() > 1 {
|
||||
Some(parts[parts.len() - 2].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_flatten_groups() {
|
||||
let groups = vec![KeycloakGroup {
|
||||
id: "1".to_string(),
|
||||
name: "engineering".to_string(),
|
||||
path: "/engineering".to_string(),
|
||||
sub_groups: vec![
|
||||
KeycloakGroup {
|
||||
id: "2".to_string(),
|
||||
name: "backend".to_string(),
|
||||
path: "/engineering/backend".to_string(),
|
||||
sub_groups: vec![],
|
||||
attributes: None,
|
||||
},
|
||||
KeycloakGroup {
|
||||
id: "3".to_string(),
|
||||
name: "frontend".to_string(),
|
||||
path: "/engineering/frontend".to_string(),
|
||||
sub_groups: vec![],
|
||||
attributes: None,
|
||||
},
|
||||
],
|
||||
attributes: None,
|
||||
}];
|
||||
|
||||
let flat = flatten_groups(&groups);
|
||||
assert_eq!(flat.len(), 3);
|
||||
assert_eq!(flat[0].name, "engineering");
|
||||
assert_eq!(flat[1].name, "backend");
|
||||
assert_eq!(flat[2].name, "frontend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_parent_group() {
|
||||
assert_eq!(extract_parent_group("/engineering"), None);
|
||||
assert_eq!(
|
||||
extract_parent_group("/engineering/backend"),
|
||||
Some("engineering".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_parent_group("/org/engineering/backend"),
|
||||
Some("engineering".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_user_entity() {
|
||||
let kc_user = KeycloakUser {
|
||||
id: "kc-user-123".to_string(),
|
||||
username: "jdoe".to_string(),
|
||||
email: Some("john.doe@example.com".to_string()),
|
||||
first_name: Some("John".to_string()),
|
||||
last_name: Some("Doe".to_string()),
|
||||
enabled: true,
|
||||
email_verified: true,
|
||||
attributes: None,
|
||||
};
|
||||
|
||||
let config = KeycloakConfig {
|
||||
base_url: "https://keycloak.example.com".to_string(),
|
||||
realm: "test-realm".to_string(),
|
||||
client_id: "test-client".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
sync: crate::SyncOptions::default(),
|
||||
};
|
||||
|
||||
let entity = build_user_entity(&kc_user, &config);
|
||||
|
||||
assert_eq!(entity.kind, "User");
|
||||
assert_eq!(
|
||||
entity.annotations.get("keycloak.com/user-id"),
|
||||
Some(&"kc-user-123".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
entity.annotations.get("keycloak.com/email"),
|
||||
Some(&"john.doe@example.com".to_string())
|
||||
);
|
||||
|
||||
// Check metadata
|
||||
match &entity.metadata {
|
||||
Some(entity::Metadata::UserMetadata(m)) => {
|
||||
assert_eq!(m.name, "jdoe");
|
||||
assert_eq!(m.namespace, "keycloak");
|
||||
assert!(m.tags.contains(&"keycloak".to_string()));
|
||||
}
|
||||
_ => panic!("Expected UserMetadata"),
|
||||
}
|
||||
|
||||
// Check spec
|
||||
match &entity.spec {
|
||||
Some(entity::Spec::UserSpec(s)) => {
|
||||
let profile = s.profile.as_ref().unwrap();
|
||||
assert_eq!(profile.display_name, "John Doe");
|
||||
assert_eq!(profile.email, "john.doe@example.com");
|
||||
}
|
||||
_ => panic!("Expected UserSpec"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_group_entity() {
|
||||
let kc_group = KeycloakGroup {
|
||||
id: "kc-group-456".to_string(),
|
||||
name: "backend".to_string(),
|
||||
path: "/engineering/backend".to_string(),
|
||||
sub_groups: vec![],
|
||||
attributes: None,
|
||||
};
|
||||
|
||||
let config = KeycloakConfig {
|
||||
base_url: "https://keycloak.example.com".to_string(),
|
||||
realm: "test-realm".to_string(),
|
||||
client_id: "test-client".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
sync: crate::SyncOptions::default(),
|
||||
};
|
||||
|
||||
let members = vec!["jdoe".to_string(), "asmith".to_string()];
|
||||
let entity = build_group_entity(&kc_group, members, &config);
|
||||
|
||||
assert_eq!(entity.kind, "Group");
|
||||
assert_eq!(
|
||||
entity.annotations.get("keycloak.com/group-id"),
|
||||
Some(&"kc-group-456".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
entity.annotations.get("keycloak.com/member-count"),
|
||||
Some(&"2".to_string())
|
||||
);
|
||||
|
||||
// Check metadata
|
||||
match &entity.metadata {
|
||||
Some(entity::Metadata::GroupMetadata(m)) => {
|
||||
assert_eq!(m.name, "backend");
|
||||
assert_eq!(m.namespace, "keycloak");
|
||||
}
|
||||
_ => panic!("Expected GroupMetadata"),
|
||||
}
|
||||
|
||||
// Check spec
|
||||
match &entity.spec {
|
||||
Some(entity::Spec::GroupSpec(s)) => {
|
||||
assert_eq!(s.parent, "engineering");
|
||||
assert_eq!(s.members, vec!["jdoe", "asmith"]);
|
||||
assert_eq!(s.r#type, "team");
|
||||
}
|
||||
_ => panic!("Expected GroupSpec"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_user_entity_minimal() {
|
||||
let kc_user = KeycloakUser {
|
||||
id: "kc-user-minimal".to_string(),
|
||||
username: "ghost".to_string(),
|
||||
email: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
enabled: false,
|
||||
email_verified: false,
|
||||
attributes: None,
|
||||
};
|
||||
|
||||
let config = KeycloakConfig {
|
||||
base_url: "https://keycloak.example.com".to_string(),
|
||||
realm: "minimal".to_string(),
|
||||
client_id: "c".to_string(),
|
||||
client_secret: "s".to_string(),
|
||||
sync: crate::SyncOptions::default(),
|
||||
};
|
||||
|
||||
let entity = build_user_entity(&kc_user, &config);
|
||||
|
||||
// No email annotation when email is None
|
||||
assert!(!entity.annotations.contains_key("keycloak.com/email"));
|
||||
|
||||
// Display name falls back to username
|
||||
match &entity.spec {
|
||||
Some(entity::Spec::UserSpec(s)) => {
|
||||
let profile = s.profile.as_ref().unwrap();
|
||||
assert_eq!(profile.display_name, "ghost");
|
||||
assert_eq!(profile.email, "");
|
||||
}
|
||||
_ => panic!("Expected UserSpec"),
|
||||
}
|
||||
|
||||
// Labels should reflect disabled state
|
||||
match &entity.metadata {
|
||||
Some(entity::Metadata::UserMetadata(m)) => {
|
||||
assert_eq!(m.labels.get("enabled"), Some(&"false".to_string()));
|
||||
assert!(!m.labels.contains_key("email-verified"));
|
||||
}
|
||||
_ => panic!("Expected UserMetadata"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user