From 4b75b03e6421aef189baa94933e864572efe1a4b Mon Sep 17 00:00:00 2001 From: Jan Cibulka Date: Wed, 4 Dec 2024 16:35:38 +0200 Subject: [PATCH] feat: Migration script for LDAP sync --- Cargo.toml | 4 + src/bin/migrate.rs | 127 +++++++++++++++++++++++++++++++ tests/e2e.rs | 183 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 src/bin/migrate.rs diff --git a/Cargo.toml b/Cargo.toml index 1b530e9..55fa3a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ authors = [] edition = "2021" publish = false +[[bin]] +name = "migrate" +path = "src/bin/migrate.rs" + [dependencies] anyhow = { version = "1.0.81", features = ["backtrace"] } async-trait = "0.1.82" diff --git a/src/bin/migrate.rs b/src/bin/migrate.rs new file mode 100644 index 0000000..055e5c2 --- /dev/null +++ b/src/bin/migrate.rs @@ -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(()) +} diff --git a/tests/e2e.rs b/tests/e2e.rs index 45a1fba..d758488 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -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 = "migrate_test@famedly.de"; + 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 = "plain_test@famedly.de"; + 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 = "hex_test@famedly.de"; + 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 = "empty_id@famedly.de"; + 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 = "migrate_sync@famedly.de"; + 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 '{}'", + email + ); + assert_eq!( + profile.first_name, "New First Name", + "Fist name was not updated by LDAP sync for user '{}'", + email + ); + } + _ => 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 '{}'", + email + ); + } 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