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
+24
View File
@@ -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"
+27
View File
@@ -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;
}
+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
}
}
+545
View File
@@ -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"),
}
}
}