-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Migration script for LDAP sync
- Loading branch information
Showing
3 changed files
with
314 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
//! This binary is used to migrate user IDs from base64 to hex encoding. | ||
use std::{path::Path, str::FromStr}; | ||
|
||
use anyhow::{anyhow, Context, Result}; | ||
use base64::{engine::general_purpose, Engine as _}; | ||
use famedly_sync::Config; | ||
use futures::StreamExt; | ||
use tracing::level_filters::LevelFilter; | ||
use zitadel_rust_client::v2::{ | ||
users::{ | ||
ListUsersRequest, SearchQuery, SetHumanProfile, TypeQuery, UpdateHumanUserRequest, | ||
UserFieldName, Userv2Type, | ||
}, | ||
Zitadel, | ||
}; | ||
|
||
#[tokio::main] | ||
async fn main() -> Result<()> { | ||
// Config | ||
let config_path = | ||
std::env::var("FAMEDLY_SYNC_CONFIG").unwrap_or_else(|_| "./config.yaml".to_owned()); | ||
let config = Config::new(Path::new(&config_path))?; | ||
|
||
// Tracing | ||
let subscriber = tracing_subscriber::FmtSubscriber::builder() | ||
.with_max_level( | ||
config | ||
.log_level | ||
.as_ref() | ||
.map_or(Ok(LevelFilter::INFO), |s| LevelFilter::from_str(s))?, | ||
) | ||
.finish(); | ||
tracing::subscriber::set_global_default(subscriber) | ||
.context("Setting default tracing subscriber failed")?; | ||
|
||
tracing::info!("Starting migration"); | ||
tracing::debug!("Old external IDs will be base64 decoded and re-encoded as hex"); | ||
tracing::debug!("Note: External IDs are stored in the nick_name field of the user's profile in Zitadel, often referred to as uid."); | ||
|
||
// Zitadel | ||
let zitadel_config = config.zitadel.clone(); | ||
let mut zitadel = Zitadel::new(zitadel_config.url, zitadel_config.key_file).await?; | ||
|
||
// Get all users | ||
let mut stream = zitadel.list_users( | ||
ListUsersRequest::new(vec![ | ||
SearchQuery::new().with_type_query(TypeQuery::new(Userv2Type::Human)) | ||
]) | ||
.with_asc(true) | ||
.with_sorting_column(UserFieldName::NickName), | ||
)?; | ||
|
||
// Process each user | ||
while let Some(user) = stream.next().await { | ||
let user_id = user.user_id().ok_or_else(|| anyhow!("User lacks ID"))?; | ||
let human = user.human().ok_or_else(|| anyhow!("User not human"))?; | ||
let profile = human.profile().ok_or_else(|| anyhow!("User lacks profile"))?; | ||
|
||
let given_name = profile.given_name(); | ||
let family_name = profile.family_name(); | ||
let display_name = profile.display_name(); | ||
|
||
let Some(nick_name) = profile.nick_name() else { | ||
tracing::warn!(user_id = %user_id, "User lacks nick_name (external uid), skipping"); | ||
continue; | ||
}; | ||
|
||
tracing::info!(user_id = %user_id, old_uid = %nick_name, "Starting migration for user"); | ||
|
||
let new_external_id = if nick_name.is_empty() { | ||
// Keep empty string as is, will skip it later | ||
nick_name.to_string() | ||
} else { | ||
// First check if it's valid hex | ||
if nick_name.chars().all(|c| c.is_ascii_hexdigit()) && nick_name.len() % 2 == 0 { | ||
// If valid hex, keep as is | ||
tracing::debug!( user_id = %user_id, old_uid = %nick_name,"Valid hex uid detected, keeping as is"); | ||
nick_name.to_string() | ||
} else { | ||
// Try base64, if fails use plain text | ||
let decoded = general_purpose::STANDARD.decode(nick_name).unwrap_or_else(|_| { | ||
// Not base64, treat as plain text | ||
tracing::debug!( user_id = %user_id, old_uid = %nick_name,"Decoding uid failed, going with plain uid"); | ||
nick_name.as_bytes().to_vec() | ||
}); | ||
|
||
// Encode using hex | ||
hex::encode(decoded) | ||
} | ||
}; | ||
|
||
// Skip empty uid | ||
if new_external_id.is_empty() { | ||
tracing::warn!( | ||
user_id = %user_id, | ||
old_uid = %nick_name, | ||
new_uid = %new_external_id, | ||
"Skipping user due to empty uid"); | ||
continue; | ||
} | ||
|
||
// Update uid (external ID, nick_name) in Zitadel | ||
let mut request = UpdateHumanUserRequest::new(); | ||
request.set_profile( | ||
SetHumanProfile::new( | ||
given_name.ok_or_else(|| anyhow!("User lacks given name"))?.to_owned(), | ||
family_name.ok_or_else(|| anyhow!("User lacks family name"))?.to_owned(), | ||
) | ||
.with_display_name( | ||
display_name.ok_or_else(|| anyhow!("User lacks display name"))?.to_owned(), | ||
) | ||
.with_nick_name(new_external_id.clone()), | ||
); | ||
|
||
zitadel.update_human_user(user_id, request).await?; | ||
|
||
tracing::info!( | ||
user_id = %user_id, | ||
old_uid = %nick_name, | ||
new_uid = %new_external_id, | ||
"User migrated" | ||
); | ||
} | ||
|
||
tracing::info!("Migration completed."); | ||
Ok(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ | |
/// E2E integration tests | ||
use std::{collections::HashSet, path::Path, time::Duration}; | ||
|
||
use base64::{engine::general_purpose, Engine as _}; | ||
use famedly_sync::{ | ||
csv_test_helpers::temp_csv_file, | ||
perform_sync, | ||
|
@@ -1396,6 +1397,112 @@ async fn test_e2e_sso_linking() { | |
); | ||
} | ||
|
||
#[test(tokio::test)] | ||
#[test_log(default_log_filter = "debug")] | ||
async fn test_e2e_migrate_base64_id() { | ||
let uid = "migrate_test"; | ||
let email = "[email protected]"; | ||
let user_name = "migrate_user"; | ||
|
||
// Base64-encoded External ID | ||
let base64_id = general_purpose::STANDARD.encode(uid); | ||
|
||
run_migration_test(email, user_name, base64_id.clone(), hex::encode(uid.as_bytes())).await; | ||
} | ||
|
||
#[test(tokio::test)] | ||
#[test_log(default_log_filter = "debug")] | ||
async fn test_e2e_migrate_plain_id() { | ||
let uid = "plain_test"; | ||
let email = "[email protected]"; | ||
let user_name = "plain_user"; | ||
|
||
// Plain unencoded External ID | ||
let plain_id = uid.to_owned(); | ||
|
||
run_migration_test(email, user_name, plain_id.clone(), hex::encode(uid.as_bytes())).await; | ||
} | ||
|
||
#[test(tokio::test)] | ||
#[test_log(default_log_filter = "debug")] | ||
async fn test_e2e_migrate_hex_id() { | ||
let uid = "hex_test"; | ||
let email = "[email protected]"; | ||
let user_name = "hex_user"; | ||
|
||
// Already hex-encoded External ID | ||
let hex_id = hex::encode(uid.as_bytes()); | ||
|
||
run_migration_test(email, user_name, hex_id.clone(), hex_id.clone()).await; | ||
} | ||
|
||
#[test(tokio::test)] | ||
#[test_log(default_log_filter = "debug")] | ||
async fn test_e2e_migrate_empty_id() { | ||
let email = "[email protected]"; | ||
let user_name = "empty_user"; | ||
|
||
// Empty External ID | ||
let empty_id = "".to_owned(); | ||
|
||
run_migration_test(email, user_name, empty_id.clone(), empty_id.clone()).await; | ||
} | ||
|
||
#[test(tokio::test)] | ||
#[test_log(default_log_filter = "debug")] | ||
async fn test_e2e_migrate_then_ldap_sync() { | ||
let uid = "migrate_sync_test"; | ||
let email = "[email protected]"; | ||
let user_name = "migrate_sync_user"; | ||
|
||
// Base64-encoded ID | ||
let base64_id = general_purpose::STANDARD.encode(uid); | ||
|
||
run_migration_test(email, user_name, base64_id.clone(), hex::encode(uid.as_bytes())).await; | ||
|
||
// LDAP with updated First Name | ||
let config = ldap_config().await; | ||
let mut ldap = Ldap::new().await; | ||
ldap.create_user( | ||
"New First Name", | ||
"User", | ||
"User, Test", // !NOTE: Display name from LDAP isn't picked up by the sync | ||
email, | ||
Some("+12345678901"), | ||
uid, | ||
false, | ||
) | ||
.await; | ||
|
||
perform_sync(config).await.expect("LDAP sync failed"); | ||
|
||
// Verify both External ID encoding and updated First Name | ||
let zitadel = open_zitadel_connection().await; | ||
let user = zitadel | ||
.get_user_by_login_name(user_name) | ||
.await | ||
.expect("Failed to get user after LDAP sync") | ||
.expect("User not found after LDAP sync"); | ||
|
||
match user.r#type { | ||
Some(UserType::Human(human)) => { | ||
let profile = human.profile.expect("User lacks profile after LDAP sync"); | ||
let expected_hex_id = hex::encode(uid.as_bytes()); | ||
assert_eq!( | ||
profile.nick_name, expected_hex_id, | ||
"External ID not in hex encoding after LDAP sync for user '{}'", | ||
); | ||
assert_eq!( | ||
profile.first_name, "New First Name", | ||
"Fist name was not updated by LDAP sync for user '{}'", | ||
); | ||
} | ||
_ => panic!("User lacks human details after LDAP sync for user '{}'", email), | ||
} | ||
} | ||
|
||
struct Ldap { | ||
client: LdapClient, | ||
} | ||
|
@@ -1530,6 +1637,82 @@ async fn open_zitadel_connection() -> Zitadel { | |
.expect("failed to set up Zitadel client") | ||
} | ||
|
||
/// Helper function to create a user, run migration, and verify the encoding. | ||
async fn run_migration_test( | ||
email: &str, | ||
user_name: &str, | ||
initial_nick_name: String, | ||
expected_nick_name: String, | ||
) { | ||
// Get config and Zitadel client | ||
let config = ldap_config().await; | ||
let zitadel = open_zitadel_connection().await; | ||
|
||
// Create user in Zitadel | ||
let user = ImportHumanUserRequest { | ||
user_name: user_name.to_owned(), | ||
profile: Some(Profile { | ||
first_name: "Test".to_owned(), | ||
last_name: "User".to_owned(), | ||
display_name: "User, Test".to_owned(), | ||
gender: Gender::Unspecified.into(), | ||
nick_name: initial_nick_name.clone(), | ||
preferred_language: String::default(), | ||
}), | ||
email: Some(Email { email: email.to_owned(), is_email_verified: true }), | ||
phone: Some(Phone { phone: "+12345678901".to_owned(), is_phone_verified: true }), | ||
password: String::default(), | ||
hashed_password: None, | ||
password_change_required: false, | ||
request_passwordless_registration: false, | ||
otp_code: String::default(), | ||
idps: vec![], | ||
}; | ||
|
||
zitadel | ||
.create_human_user(&config.zitadel.organization_id, user) | ||
.await | ||
.expect("Failed to create user"); | ||
|
||
// Run migration | ||
run_migration_binary(); | ||
|
||
// Verify External ID after migration | ||
let user = zitadel | ||
.get_user_by_login_name(user_name) | ||
.await | ||
.expect("Failed to get user") | ||
.expect("User not found"); | ||
|
||
match user.r#type { | ||
Some(user_type) => { | ||
if let UserType::Human(human) = user_type { | ||
let profile = human.profile.expect("User lacks profile"); | ||
assert_eq!( | ||
profile.nick_name, expected_nick_name, | ||
"Nickname encoding mismatch for user '{}'", | ||
); | ||
} else { | ||
panic!("User is not of type Human for user '{}'", email); | ||
} | ||
} | ||
None => panic!("User type is None for user '{}'", email), | ||
} | ||
} | ||
|
||
/// Helper function to run the migration binary. | ||
fn run_migration_binary() { | ||
let mut config_path = std::env::current_dir().unwrap(); | ||
config_path.push("tests/environment/config.yaml"); | ||
std::env::set_var("FAMEDLY_SYNC_CONFIG", config_path); | ||
|
||
let status = std::process::Command::new(env!("CARGO_BIN_EXE_migrate")) | ||
.status() | ||
.expect("Failed to execute migration binary"); | ||
assert!(status.success(), "Migration binary exited with status: {}", status); | ||
} | ||
|
||
/// Get the module's test environment config | ||
async fn ldap_config() -> &'static Config { | ||
CONFIG_WITH_LDAP | ||
|