Skip to content

Commit

Permalink
feat: Migration script for LDAP sync
Browse files Browse the repository at this point in the history
  • Loading branch information
jannden committed Nov 14, 2024
1 parent 482cd7d commit 4866de8
Show file tree
Hide file tree
Showing 3 changed files with 316 additions and 3 deletions.
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
127 changes: 127 additions & 0 deletions src/bin/migrate.rs
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(())
}
188 changes: 185 additions & 3 deletions tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

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,
Expand Down Expand Up @@ -756,9 +757,8 @@ async fn test_e2e_binary_uid() {
match user.r#type {
Some(UserType::Human(user)) => {
let profile = user.profile.expect("user lacks profile");
println!("profile: {:#?}", profile);
// Verify ID was updated
assert_eq!(profile.nick_name, hex::encode(&new_binary_id));
assert_eq!(profile.nick_name, hex::encode(new_binary_id));
}
_ => panic!("user lost human details after update"),
}
Expand All @@ -784,7 +784,7 @@ async fn test_e2e_binary_uid() {
Some(UserType::Human(user)) => {
let profile = user.profile.expect("user lacks profile");
// Verify ID was updated
assert_eq!(profile.nick_name, hex::encode(&invalid_binary_id));
assert_eq!(profile.nick_name, hex::encode(invalid_binary_id));
}
_ => panic!("user lost human details after update"),
}
Expand Down Expand Up @@ -1348,6 +1348,112 @@ async fn test_e2e_ldap_with_ukt_sync() {
assert!(user.is_err_and(|error| matches!(error, ZitadelError::TonicResponseError(status) if status.code() == TonicErrorCode::NotFound)));
}

#[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,
}
Expand Down Expand Up @@ -1482,6 +1588,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
Expand Down

0 comments on commit 4866de8

Please sign in to comment.