From 31cf9b8e2cb10c378a6d8d7f7c186ba0dde8e005 Mon Sep 17 00:00:00 2001 From: Valentin Tolmer Date: Tue, 7 Dec 2021 16:42:38 +0100 Subject: [PATCH] migration: Implement import from LDAP --- Cargo.lock | 253 ++++++++- Cargo.toml | 5 +- migration-tool/Cargo.toml | 23 + .../queries/add_user_to_group.graphql | 5 + migration-tool/queries/create_group.graphql | 6 + migration-tool/queries/create_user.graphql | 5 + migration-tool/queries/list_groups.graphql | 9 + migration-tool/queries/list_users.graphql | 5 + migration-tool/src/ldap.rs | 432 +++++++++++++++ migration-tool/src/lldap.rs | 506 ++++++++++++++++++ migration-tool/src/main.rs | 205 +++++++ 11 files changed, 1450 insertions(+), 4 deletions(-) create mode 100644 migration-tool/Cargo.toml create mode 100644 migration-tool/queries/add_user_to_group.graphql create mode 100644 migration-tool/queries/create_group.graphql create mode 100644 migration-tool/queries/create_user.graphql create mode 100644 migration-tool/queries/list_groups.graphql create mode 100644 migration-tool/queries/list_users.graphql create mode 100644 migration-tool/src/ldap.rs create mode 100644 migration-tool/src/lldap.rs create mode 100644 migration-tool/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 8f55c1b..7f2d3f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -798,6 +798,31 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "crossterm" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85525306c4291d1b73ce93c8acf9c339f9b213aef6c1d85c3830cbf1c16325c" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-mac" version = "0.10.1" @@ -1492,6 +1517,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + [[package]] name = "http-range" version = "0.1.4" @@ -1510,6 +1546,43 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440" +[[package]] +name = "hyper" +version = "0.14.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436ec0091e4f20e655156a30a0df3770fe2900aa301e548e08446ec794b6953c" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1559,6 +1632,12 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "ipnet" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9" + [[package]] name = "itertools" version = "0.10.1" @@ -1686,6 +1765,31 @@ dependencies = [ "nom 2.2.1", ] +[[package]] +name = "ldap3" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bdad98cd197646a9fd7be985cb711cffaded69d8dc0d87d83f8d88bcbc1691" +dependencies = [ + "async-trait", + "bytes", + "futures", + "futures-util", + "lazy_static", + "lber", + "log", + "maplit", + "native-tls", + "nom 2.2.1", + "percent-encoding", + "thiserror", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "url", +] + [[package]] name = "ldap3_server" version = "0.1.9" @@ -1767,7 +1871,7 @@ checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" [[package]] name = "lldap" -version = "0.2.0" +version = "0.3.0-alpha.1" dependencies = [ "actix", "actix-files", @@ -1823,7 +1927,7 @@ dependencies = [ [[package]] name = "lldap_app" -version = "0.2.0" +version = "0.3.0-alpha.1" dependencies = [ "anyhow", "chrono", @@ -1847,7 +1951,7 @@ dependencies = [ [[package]] name = "lldap_auth" -version = "0.2.0" +version = "0.3.0-alpha.1" dependencies = [ "chrono", "curve25519-dalek", @@ -1933,6 +2037,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +[[package]] +name = "migration-tool" +version = "0.3.0-alpha.1" +dependencies = [ + "anyhow", + "graphql_client", + "ldap3", + "lldap_auth", + "rand 0.8.4", + "requestty", + "reqwest", + "serde", + "serde_json", + "smallvec", +] + [[package]] name = "mime" version = "0.3.16" @@ -2639,6 +2759,64 @@ dependencies = [ "winapi", ] +[[package]] +name = "requestty" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411059399ea4d5007971959900eee777750eaf539e4fdfecb9bb5d9b3fb99c40" +dependencies = [ + "requestty-ui", + "smallvec", + "tempfile", +] + +[[package]] +name = "requestty-ui" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7f8e70d25cbc5d14d73c4f0c313ef505450a7c2a39b7e2ca421bc456a4574f6" +dependencies = [ + "bitflags", + "crossterm", + "textwrap", + "unicode-segmentation", +] + +[[package]] +name = "reqwest" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bea77bc708afa10e59905c3d4af7c8fd43c9214251673095ff8b14345fcbc5" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "lazy_static", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-native-tls", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rsa" version = "0.3.0" @@ -2901,6 +3079,27 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c98891d737e271a2954825ef19e46bd16bdb98e2746f2eec4f7a4ef7946efd1" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fd5867f1c4f2c5be079aee7a2adf1152ebb04a4bc4d341f504b7dece607ed4" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.0" @@ -2942,6 +3141,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "smawk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" + [[package]] name = "socket2" version = "0.4.1" @@ -3209,6 +3414,8 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80" dependencies = [ + "smawk", + "unicode-linebreak", "unicode-width", ] @@ -3379,6 +3586,12 @@ dependencies = [ "serde", ] +[[package]] +name = "tower-service" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" + [[package]] name = "tracing" version = "0.1.26" @@ -3465,6 +3678,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7f741b240f1a48843f9b8e0444fb55fb2a4ff67293b50a9179dfd5ea67f8d41" +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + [[package]] name = "typenum" version = "1.14.0" @@ -3501,6 +3720,15 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246f4c42e67e7a4e3c6106ff716a5d067d4132a642840b242e357e468a2a0085" +[[package]] +name = "unicode-linebreak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f" +dependencies = [ + "regex", +] + [[package]] name = "unicode-normalization" version = "0.1.19" @@ -3630,6 +3858,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -3761,6 +3999,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winreg" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0120db82e8a1e0b9fb3345a539c478767c0048d842860994d96113d5b667bd69" +dependencies = [ + "winapi", +] + [[package]] name = "wyz" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 51d81b8..ed466dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,12 @@ members = [ "server", "auth", - "app" + "app", + "migration-tool" ] +default-members = ["server"] + # TODO: remove when there's a new release. [patch.crates-io.yew_form] git = 'https://github.com/sassman/yew_form/' diff --git a/migration-tool/Cargo.toml b/migration-tool/Cargo.toml new file mode 100644 index 0000000..22ac40f --- /dev/null +++ b/migration-tool/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "migration-tool" +version = "0.3.0-alpha.1" +edition = "2021" +authors = ["Valentin Tolmer "] + +[dependencies] +anyhow = "*" +graphql_client = "0.10" +ldap3 = "*" +rand = "0.8" +requestty = "*" +serde = "1" +serde_json = "1" +smallvec = "*" + +[dependencies.lldap_auth] +path = "../auth" +features = [ "opaque_client" ] + +[dependencies.reqwest] +version = "*" +features = [ "json", "blocking" ] diff --git a/migration-tool/queries/add_user_to_group.graphql b/migration-tool/queries/add_user_to_group.graphql new file mode 100644 index 0000000..8921d2e --- /dev/null +++ b/migration-tool/queries/add_user_to_group.graphql @@ -0,0 +1,5 @@ +mutation AddUserToGroup($user: String!, $group: Int!) { + addUserToGroup(userId: $user, groupId: $group) { + ok + } +} diff --git a/migration-tool/queries/create_group.graphql b/migration-tool/queries/create_group.graphql new file mode 100644 index 0000000..96ea2fa --- /dev/null +++ b/migration-tool/queries/create_group.graphql @@ -0,0 +1,6 @@ +mutation CreateGroup($name: String!) { + createGroup(name: $name) { + id + displayName + } +} diff --git a/migration-tool/queries/create_user.graphql b/migration-tool/queries/create_user.graphql new file mode 100644 index 0000000..7b72108 --- /dev/null +++ b/migration-tool/queries/create_user.graphql @@ -0,0 +1,5 @@ +mutation CreateUser($user: CreateUserInput!) { + createUser(user: $user) { + id + } +} diff --git a/migration-tool/queries/list_groups.graphql b/migration-tool/queries/list_groups.graphql new file mode 100644 index 0000000..5997e19 --- /dev/null +++ b/migration-tool/queries/list_groups.graphql @@ -0,0 +1,9 @@ +query ListGroups { + groups { + id + displayName + users { + id + } + } +} diff --git a/migration-tool/queries/list_users.graphql b/migration-tool/queries/list_users.graphql new file mode 100644 index 0000000..5dc8694 --- /dev/null +++ b/migration-tool/queries/list_users.graphql @@ -0,0 +1,5 @@ +query ListUsers { + users(filters: null) { + id + } +} diff --git a/migration-tool/src/ldap.rs b/migration-tool/src/ldap.rs new file mode 100644 index 0000000..1f86d4c --- /dev/null +++ b/migration-tool/src/ldap.rs @@ -0,0 +1,432 @@ +use anyhow::{anyhow, Context, Result}; +use ldap3::{ResultEntry, SearchEntry}; +use requestty::{prompt_one, Question}; +use smallvec::SmallVec; + +use crate::lldap::User; + +pub struct LdapClient { + domain: String, + connection: ldap3::LdapConn, +} + +/// Checks if the URL starts with the protocol, and whether the host is valid (DNS and listening), +/// potentially with the given port. Returns the address + port that managed to connect, if any. +pub fn check_host_exists( + url: &str, + protocol_and_port: &[(&str, u16)], +) -> std::result::Result, String> { + for (protocol, port) in protocol_and_port { + if url.starts_with(protocol) { + use std::net::ToSocketAddrs; + let trimmed_url = url.trim_start_matches(protocol); + return match trimmed_url.to_socket_addrs() { + Ok(_) => Ok(Some(url.to_owned())), + Err(_) => { + let new_url = format!("{}:{}", trimmed_url, port); + new_url + .to_socket_addrs() + .map_err(|_| format!("Could not resolve host: '{}'", trimmed_url)) + .map(|_| Some(format!("{}{}", protocol, new_url))) + } + }; + } + } + Ok(None) +} + +fn autocomplete_domain_suffix(input: String, domain: &str) -> SmallVec<[String; 1]> { + let mut answers = SmallVec::<[String; 1]>::new(); + for part in input.split(',') { + if !part.starts_with('d') { + continue; + } + if domain.starts_with(part) { + answers.push(input.clone() + domain.trim_start_matches(part)); + } + } + answers.push(input); + answers +} + +/// Asks the user for the URL of the LDAP server, and checks that a connection can be established. +/// Returns the LDAP URL. +fn get_ldap_url() -> Result { + let ldap_protocols = &[("ldap://", 389), ("ldaps://", 636)]; + let question = Question::input("ldap_url") + .message("LDAP_URL (ldap://...)") + .auto_complete(|answer, _| { + let mut answers = SmallVec::<[String; 1]>::new(); + if "ldap://".starts_with(&answer) { + answers.push("ldap://".to_owned()); + } + if "ldaps://".starts_with(&answer) { + answers.push("ldaps://".to_owned()); + } + answers.push(answer); + answers + }) + .validate(|url, _| { + if let Some(url) = check_host_exists(url, ldap_protocols)? { + ldap3::LdapConn::new(&url) + .map_err(|e| format!("Could not connect to LDAP server: {}", e))?; + Ok(()) + } else { + Err("LDAP URL should start with 'ldap://' or 'ldaps://'".to_owned()) + } + }) + .build(); + let answer = prompt_one(question)?; + Ok( + check_host_exists(answer.as_string().unwrap(), ldap_protocols) + .unwrap() + .unwrap(), + ) +} + +/// Binds the LDAP connection by asking the user for the bind DN and password, and returns the bind +/// DN. +fn bind_ldap( + ldap_connection: &mut ldap3::LdapConn, + previous_binddn: Option, +) -> Result { + let binddn = { + let question = Question::input("ldap_binddn") + .message("LDAP_BIND_DN (cn=...)") + .validate(|dn, _| { + if dn.contains(',') && dn.contains('=') { + Ok(()) + } else { + Err( + "Invalid bind DN, expected something like 'cn=admin,dc=example,dc=com'" + .to_owned(), + ) + } + }) + .auto_complete(|answer, _| { + let mut answers = SmallVec::<[String; 1]>::new(); + if let Some(binddn) = &previous_binddn { + answers.push(binddn.clone()); + } + answers.push(answer); + answers + }) + .build(); + let answer = prompt_one(question)?; + answer.as_string().unwrap().to_owned() + }; + let password = { + let question = Question::password("ldap_bind_password") + .message("LDAP_BIND_PASSWORD") + .validate(|password, _| { + if !password.is_empty() { + Ok(()) + } else { + Err("Empty password".to_owned()) + } + }) + .build(); + let answer = prompt_one(question)?; + answer.as_string().unwrap().to_owned() + }; + if let Err(e) = ldap_connection + .simple_bind(&binddn, &password) + .and_then(|r| r.success()) + { + println!("Error connecting as '{}': {}", binddn, e); + bind_ldap(ldap_connection, Some(binddn)) + } else { + Ok(binddn) + } +} + +impl TryFrom for User { + type Error = anyhow::Error; + + fn try_from(value: ResultEntry) -> Result { + let entry = SearchEntry::construct(value); + let get_required_attribute = |attr| { + entry + .attrs + .get(attr) + .ok_or_else(|| anyhow!("Missing {} for user", attr)) + .and_then(|u| { + if u.len() > 1 { + Err(anyhow!("Too many {}s", attr)) + } else { + Ok(u.first().unwrap().to_owned()) + } + }) + }; + let id = get_required_attribute("uid") + .or_else(|_| get_required_attribute("sAMAccountName")) + .or_else(|_| get_required_attribute("userPrincipalName"))?; + let email = get_required_attribute("mail") + .or_else(|_| get_required_attribute("rfc822mailbox")) + .context(format!("for user '{}'", id))?; + + let get_optional_attribute = |attr: &str| { + entry + .attrs + .get(attr) + .and_then(|v| v.first().map(|s| s.as_str())) + .and_then(|s| { + if s.is_empty() { + None + } else { + Some(s.to_owned()) + } + }) + }; + let last_name = get_optional_attribute("sn").or_else(|| get_optional_attribute("surname")); + let display_name = get_optional_attribute("cn") + .or_else(|| get_optional_attribute("commonName")) + .or_else(|| get_optional_attribute("name")) + .or_else(|| get_optional_attribute("displayName")); + let first_name = get_optional_attribute("givenName"); + let password = + get_optional_attribute("userPassword").or_else(|| get_optional_attribute("password")); + Ok(User::new( + id, + email, + display_name, + first_name, + last_name, + password, + entry.dn, + )) + } +} + +enum OuType { + User, + Group, +} + +fn detect_ou( + ldap_connection: &mut ldap3::LdapConn, + domain: &str, + for_type: OuType, +) -> Result<(Option, Vec), anyhow::Error> { + let ous = ldap_connection + .search( + domain, + ldap3::Scope::Subtree, + "(objectClass=organizationalUnit)", + vec!["dn"], + )? + .success()? + .0; + let mut detected_ou = None; + let mut all_ous = Vec::new(); + for result_entry in ous { + let dn = SearchEntry::construct(result_entry).dn; + match for_type { + OuType::User => { + if dn.contains("user") || dn.contains("people") || dn.contains("person") { + detected_ou = Some(dn.clone()); + } + } + OuType::Group => { + if dn.contains("group") { + detected_ou = Some(dn.clone()); + } + } + } + all_ous.push(dn); + } + Ok((detected_ou, all_ous)) +} + +pub fn get_users(connection: &mut LdapClient) -> Result, anyhow::Error> { + let LdapClient { + connection: ldap_connection, + domain, + } = connection; + let domain = domain.as_str(); + let (maybe_user_ou, all_ous) = detect_ou(ldap_connection, domain, OuType::User)?; + let user_ou = { + let question = Question::input("ldap_user_ou") + .message(format!( + "Where are the users located (under '{}')? {}(LDAP_USERS_DN)", + domain, + maybe_user_ou + .as_ref() + .map(|ou| format!("Detected: {}", ou)) + .unwrap_or_default() + )) + .validate(|dn, _| { + if dn.contains('=') { + Ok(()) + } else { + Err(format!( + "Invalid bind DN, expected something like 'ou=people,{}'", + domain + )) + } + }) + .default(maybe_user_ou.unwrap_or_default()) + .auto_complete(|s, _| { + let mut answers = autocomplete_domain_suffix(s, domain); + answers.extend(all_ous.clone().into_iter()); + answers + }) + .build(); + let answer = prompt_one(question)?; + let mut answer = answer.as_string().unwrap().to_owned(); + if !answer.ends_with(domain) { + if !answer.is_empty() { + answer += ","; + } + answer += domain; + } + answer + }; + let users = ldap_connection + .search( + &user_ou, + ldap3::Scope::Subtree, + "(|(objectClass=inetOrgPerson)(objectClass=person)(objectClass=mailAccount)(objectClass=posixAccount)(objectClass=user)(objectClass=organizationalPerson))", + vec![ + "uid", + "sAMAccountName", + "userPrincipalName", + "mail", + "rfc822mailbox", + "givenName", + "sn", + "surname", + "cn", + "commonName", + "displayName", + "name", + "userPassword", + ], + )? + .success()? + .0; + users + .into_iter() + .map(TryFrom::try_from) + .collect::>>() +} + +#[derive(Debug)] +pub struct LdapGroup { + pub name: String, + pub members: Vec, +} + +impl TryFrom for LdapGroup { + type Error = anyhow::Error; + + // https://github.com/graphql-rust/graphql-client/issues/386 + #[allow(non_snake_case)] + fn try_from(value: ResultEntry) -> Result { + let entry = SearchEntry::construct(value); + let get_required_attribute = |attr| { + entry + .attrs + .get(attr) + .ok_or_else(|| anyhow!("Missing {} for user", attr)) + .and_then(|u| { + if u.len() > 1 { + Err(anyhow!("Too many {}s", attr)) + } else { + Ok(u.first().unwrap().to_owned()) + } + }) + }; + let name = get_required_attribute("cn") + .or_else(|_| get_required_attribute("commonName")) + .or_else(|_| get_required_attribute("displayName")) + .or_else(|_| get_required_attribute("name"))?; + + let get_repeated_attribute = |attr: &str| entry.attrs.get(attr).map(|v| v.to_owned()); + let members = get_repeated_attribute("member") + .or_else(|| get_repeated_attribute("uniqueMember")) + .unwrap_or_default(); + Ok(LdapGroup { name, members }) + } +} + +pub fn get_groups(connection: &mut LdapClient) -> Result> { + let LdapClient { + connection: ldap_connection, + domain, + } = connection; + let domain = domain.as_str(); + let (maybe_group_ou, all_ous) = detect_ou(ldap_connection, domain, OuType::Group)?; + let group_ou = { + let question = Question::input("ldap_group_ou") + .message(format!( + "Where are the groups located (under '{}')? {}(LDAP_GROUPS_DN)", + domain, + maybe_group_ou + .as_ref() + .map(|ou| format!("Detected: {}", ou)) + .unwrap_or_default() + )) + .validate(|dn, _| { + if dn.contains('=') { + Ok(()) + } else { + Err(format!( + "Invalid bind DN, expected something like 'ou=groups,{}'", + domain + )) + } + }) + .default(maybe_group_ou.unwrap_or_default()) + .auto_complete(|s, _| { + let mut answers = autocomplete_domain_suffix(s, domain); + answers.extend(all_ous.clone().into_iter()); + answers + }) + .build(); + let answer = prompt_one(question)?; + let mut answer = answer.as_string().unwrap().to_owned(); + if !answer.ends_with(domain) { + if !answer.is_empty() { + answer += ","; + } + answer += domain; + } + answer + }; + let groups = ldap_connection + .search( + &group_ou, + ldap3::Scope::Subtree, + "(|(objectClass=group)(objectClass=groupOfNames)(objectClass=groupOfUniqueNames))", + vec![ + "cn", + "commonName", + "displayName", + "name", + "member", + "uniqueMember", + ], + )? + .success()? + .0; + let input_groups = groups + .into_iter() + .map(TryFrom::try_from) + .collect::>>()?; + Ok(input_groups) +} + +pub fn get_ldap_connection() -> Result { + let url = get_ldap_url()?; + let mut ldap_connection = ldap3::LdapConn::new(&url)?; + println!("Server found"); + let bind_dn = bind_ldap(&mut ldap_connection, None)?; + println!("Connection established"); + let domain = &bind_dn[(bind_dn.find(",dc=").expect("Could not find domain?!") + 1)..]; + // domain is 'dc=example,dc=com' + Ok(LdapClient { + connection: ldap_connection, + domain: domain.to_owned(), + }) +} diff --git a/migration-tool/src/lldap.rs b/migration-tool/src/lldap.rs new file mode 100644 index 0000000..1a9ab2b --- /dev/null +++ b/migration-tool/src/lldap.rs @@ -0,0 +1,506 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{anyhow, bail, Context, Result}; +use graphql_client::GraphQLQuery; +use requestty::{prompt_one, Question}; +use reqwest::blocking::{Client, ClientBuilder}; +use smallvec::SmallVec; + +use crate::ldap::{check_host_exists, LdapGroup}; + +pub struct GraphQLClient { + url: String, + auth_header: reqwest::header::HeaderValue, + client: Client, +} + +impl GraphQLClient { + fn new(url: String, auth_token: &str, client: Client) -> Result { + Ok(Self { + url: format!("{}/api/graphql", url), + auth_header: format!("Bearer {}", auth_token).parse()?, + client, + }) + } + + pub fn post( + &self, + variables: QueryType::Variables, + ) -> Result + where + QueryType: GraphQLQuery + 'static, + { + let unwrap_graphql_response = |graphql_client::Response { data, errors }| { + data.ok_or_else(|| { + anyhow!( + "Errors: [{}]", + errors + .unwrap_or_default() + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ) + }) + }; + self.client + .post(&self.url) + .header(reqwest::header::AUTHORIZATION, &self.auth_header) + // Request body. + .json(&QueryType::build_query(variables)) + .send() + .context("while sending a request to the LLDAP server")? + .error_for_status() + .context("error from an LLDAP response")? + // Parse response as Json. + .json::>() + .context("while parsing backend response") + .and_then(unwrap_graphql_response) + .context("GraphQL error from an LLDAP response") + } +} + +#[derive(Clone, Debug)] +pub struct User { + pub user_input: create_user::CreateUserInput, + pub password: Option, + pub dn: String, +} + +impl User { + // https://github.com/graphql-rust/graphql-client/issues/386 + #[allow(non_snake_case)] + pub fn new( + id: String, + email: String, + displayName: Option, + firstName: Option, + lastName: Option, + password: Option, + dn: String, + ) -> User { + User { + user_input: create_user::CreateUserInput { + id, + email, + displayName, + firstName, + lastName, + }, + password, + dn, + } + } +} + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/create_user.graphql", + response_derives = "Debug", + variables_derives = "Debug,Clone", + custom_scalars_module = "crate::infra::graphql" +)] +struct CreateUser; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/create_group.graphql", + response_derives = "Debug", + variables_derives = "Debug,Clone", + custom_scalars_module = "crate::infra::graphql" +)] +struct CreateGroup; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/list_users.graphql", + response_derives = "Debug", + custom_scalars_module = "crate::infra::graphql" +)] +struct ListUsers; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/list_groups.graphql", + response_derives = "Debug", + custom_scalars_module = "crate::infra::graphql" +)] +struct ListGroups; + +pub type LldapGroup = list_groups::ListGroupsGroups; + +fn try_login( + lldap_server: &str, + username: &str, + password: &str, + client: &Client, +) -> Result { + let mut rng = rand::rngs::OsRng; + use lldap_auth::login::*; + use lldap_auth::opaque::client::login::*; + let ClientLoginStartResult { state, message } = + start_login(password, &mut rng).context("Could not initialize login")?; + let req = ClientLoginStartRequest { + username: username.to_owned(), + login_start_request: message, + }; + let response = client + .post(format!("{}/auth/opaque/login/start", lldap_server)) + .json(&req) + .send() + .context("while trying to login to LLDAP")?; + if !response.status().is_success() { + bail!( + "Failed to start logging in to LLDAP: {}", + response.status().as_str() + ); + } + let login_start_response = response.json::()?; + let login_finish = finish_login(state, login_start_response.credential_response)?; + let req = ClientLoginFinishRequest { + server_data: login_start_response.server_data, + credential_finalization: login_finish.message, + }; + let response = client + .post(format!("{}/auth/opaque/login/finish", lldap_server)) + .json(&req) + .send()?; + if !response.status().is_success() { + bail!( + "Failed to finish logging in to LLDAP: {}", + response.status().as_str() + ); + } + Ok(response.text()?) +} + +pub fn get_lldap_user_and_password( + lldap_server: &str, + client: &Client, + previous_username: Option, +) -> Result { + let username = { + let question = Question::input("lldap_username") + .message("LLDAP_USERNAME (default=admin)") + .default("admin") + .auto_complete(|answer, _| { + let mut answers = SmallVec::<[String; 1]>::new(); + if let Some(username) = &previous_username { + answers.push(username.clone()); + } + answers.push(answer); + answers + }) + .build(); + let answer = prompt_one(question)?; + answer.as_string().unwrap().to_owned() + }; + let password = { + let question = Question::password("lldap_password") + .message("LLDAP_PASSWORD") + .validate(|password, _| { + if !password.is_empty() { + Ok(()) + } else { + Err("Empty password".to_owned()) + } + }) + .build(); + let answer = prompt_one(question)?; + answer.as_string().unwrap().to_owned() + }; + match try_login(lldap_server, &username, &password, client) { + Err(e) => { + println!("Could not login: {:#?}", e); + get_lldap_user_and_password(lldap_server, client, Some(username)) + } + Ok(token) => Ok(token), + } +} + +pub fn get_lldap_client() -> Result { + let client = ClientBuilder::new() + .connect_timeout(std::time::Duration::from_secs(2)) + .timeout(std::time::Duration::from_secs(5)) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let lldap_server = get_lldap_server(&client)?; + let token = get_lldap_user_and_password(&lldap_server, &client, None)?; + println!("Successfully connected to LLDAP"); + GraphQLClient::new(lldap_server, &token, client) +} + +pub fn insert_users_into_lldap( + users: Vec, + existing_users: &mut Vec, + graphql_client: &GraphQLClient, +) -> Result<()> { + let mut added_users_count = 0; + let mut skip_all = false; + for user in users { + let uid = user.user_input.id.clone(); + loop { + print!("Adding {}... ", &uid); + match graphql_client + .post::(create_user::Variables { + user: user.user_input.clone(), + }) + .context(format!("while creating user '{}'", uid)) + { + Err(e) => { + println!("Error: {:#?}", e); + if skip_all { + break; + } + let question = requestty::Question::select("skip_user") + .message(format!("Error while adding user {}", &uid)) + .choices(vec!["Skip", "Retry", "Skip all"]) + .default_separator() + .choice("Abort") + .build(); + let answer = prompt_one(question)?; + let choice = answer.as_list_item().unwrap(); + match choice.text.as_str() { + "Skip" => break, + "Retry" => continue, + "Skip all" => { + skip_all = true; + break; + } + "Abort" => return Err(e), + _ => unreachable!(), + } + } + Ok(response) => { + println!("Done!"); + added_users_count += 1; + existing_users.push(response.create_user.id); + break; + } + } + } + } + println!("{} users successfully added", added_users_count); + Ok(()) +} + +pub fn insert_groups_into_lldap( + groups: &[LdapGroup], + lldap_groups: &mut Vec, + graphql_client: &GraphQLClient, +) -> Result<()> { + let mut added_groups_count = 0; + let mut skip_all = false; + let existing_group_names = + HashSet::<&str>::from_iter(lldap_groups.iter().map(|g| g.display_name.as_str())); + let new_groups = groups + .iter() + .filter(|g| !existing_group_names.contains(g.name.as_str())) + .collect::>(); + for group in new_groups { + let name = group.name.clone(); + loop { + print!("Adding {}... ", &name); + match graphql_client + .post::(create_group::Variables { name: name.clone() }) + .context(format!("while creating group '{}'", &name)) + { + Err(e) => { + println!("Error: {:#?}", e); + if skip_all { + break; + } + let question = requestty::Question::select("skip_group") + .message(format!("Error while adding group {}", &name)) + .choices(vec!["Skip", "Retry", "Skip all"]) + .default_separator() + .choice("Abort") + .build(); + let answer = prompt_one(question)?; + let choice = answer.as_list_item().unwrap(); + match choice.text.as_str() { + "Skip" => break, + "Retry" => continue, + "Skip all" => { + skip_all = true; + break; + } + "Abort" => return Err(e), + _ => unreachable!(), + } + } + Ok(response) => { + println!("Done!"); + added_groups_count += 1; + lldap_groups.push(LldapGroup { + id: response.create_group.id, + display_name: group.name.clone(), + users: Vec::new(), + }); + break; + } + } + } + } + println!("{} groups successfully added", added_groups_count); + Ok(()) +} + +pub fn get_lldap_users(graphql_client: &GraphQLClient) -> Result> { + Ok(graphql_client + .post::(list_users::Variables {})? + .users + .into_iter() + .map(|u| u.id) + .collect()) +} + +pub fn get_lldap_groups(graphql_client: &GraphQLClient) -> Result> { + Ok(graphql_client + .post::(list_groups::Variables {})? + .groups) +} + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "../schema.graphql", + query_path = "queries/add_user_to_group.graphql", + response_derives = "Debug", + variables_derives = "Debug,Clone", + custom_scalars_module = "crate::infra::graphql" +)] +struct AddUserToGroup; + +pub fn insert_group_memberships_into_lldap( + ldap_users: &[User], + ldap_groups: &[LdapGroup], + existing_users: &[String], + existing_groups: &[LldapGroup], + graphql_client: &GraphQLClient, +) -> Result<()> { + let existing_users = HashSet::<&str>::from_iter(existing_users.iter().map(String::as_str)); + let existing_groups = HashMap::<&str, &LldapGroup>::from_iter( + existing_groups.iter().map(|g| (g.display_name.as_str(), g)), + ); + let dn_resolver = HashMap::<&str, &str>::from_iter( + ldap_users + .iter() + .map(|u| (u.dn.as_str(), u.user_input.id.as_str())), + ); + let mut skip_all = false; + let mut added_membership_count = 0; + for group in ldap_groups { + if let Some(lldap_group) = existing_groups.get(group.name.as_str()) { + let lldap_members = + HashSet::<&str>::from_iter(lldap_group.users.iter().map(|u| u.id.as_str())); + let mut skip_group = false; + for user in &group.members { + let user = if let Some(id) = dn_resolver.get(user.as_str()) { + id + } else { + continue; + }; + if lldap_members.contains(user) || !existing_users.contains(user) { + continue; + } + loop { + print!("Adding '{}' to '{}'... ", &user, &group.name); + if let Err(e) = graphql_client + .post::(add_user_to_group::Variables { + user: user.to_string(), + group: lldap_group.id, + }) + .context(format!( + "while adding user '{}' to group '{}'", + &user, &group.name + )) + { + println!("Error: {:#?}", e); + if skip_all || skip_group { + break; + } + let question = requestty::Question::select("skip_membership") + .message(format!( + "Error while adding '{}' to group '{}", + &user, &group.name + )) + .choices(vec!["Skip", "Retry", "Skip group", "Skip all"]) + .default_separator() + .choice("Abort") + .build(); + let answer = prompt_one(question)?; + let choice = answer.as_list_item().unwrap(); + match choice.text.as_str() { + "Skip" => break, + "Retry" => continue, + "Skip group" => { + skip_group = true; + break; + } + "Skip all" => { + skip_all = true; + break; + } + "Abort" => return Err(e), + _ => unreachable!(), + } + } else { + println!("Done!"); + added_membership_count += 1; + break; + } + } + } + } + } + println!("{} memberships successfully added", added_membership_count); + Ok(()) +} + +fn get_lldap_server(client: &Client) -> Result { + let http_protocols = &[("http://", 17170), ("https://", 17170)]; + let question = Question::input("lldap_url") + .message("LLDAP_URL (http://...)") + .auto_complete(|answer, _| { + let mut answers = SmallVec::<[String; 1]>::new(); + if "http://".starts_with(&answer) { + answers.push("http://".to_owned()); + } + if "https://".starts_with(&answer) { + answers.push("https://".to_owned()); + } + answers.push(answer); + answers + }) + .validate(|url, _| { + if let Some(url) = check_host_exists(url, http_protocols)? { + client + .get(format!("{}/api/graphql", url)) + .send() + .map_err(|e| format!("Host did not answer: {}", e)) + .and_then(|response| { + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + Ok(()) + } else { + Err("Host doesn't seem to be an LLDAP server".to_owned()) + } + }) + } else { + Err( + "Could not resolve host (make sure it starts with 'http://' or 'https://')" + .to_owned(), + ) + } + }) + .build(); + let answer = prompt_one(question)?; + Ok( + check_host_exists(answer.as_string().unwrap(), http_protocols) + .unwrap() + .unwrap(), + ) +} diff --git a/migration-tool/src/main.rs b/migration-tool/src/main.rs new file mode 100644 index 0000000..7685a9c --- /dev/null +++ b/migration-tool/src/main.rs @@ -0,0 +1,205 @@ +use std::collections::HashSet; + +use anyhow::{anyhow, Result}; +use requestty::{prompt_one, Question}; + +mod ldap; +mod lldap; + +use ldap::LdapGroup; +use lldap::{LldapGroup, User}; + +fn ask_generic_confirmation(name: &str, message: &str) -> Result { + let confirm = Question::confirm(name) + .message(message) + .default(true) + .build(); + let answer = prompt_one(confirm)?; + Ok(answer.as_bool().unwrap()) +} + +fn get_users_to_add(users: &[User], existing_users: &[String]) -> Result>> { + let existing_users = HashSet::<&String>::from_iter(existing_users); + let num_found_users = users.len(); + let input_users: Vec<_> = users + .iter() + .filter(|u| !existing_users.contains(&u.user_input.id)) + .map(User::clone) + .collect(); + println!( + "Found {} users, of which {} new users: [\n {}\n]", + num_found_users, + input_users.len(), + input_users + .iter() + .map(|u| format!( + "\"{}\" ({})", + &u.user_input.id, + if u.password.is_some() { + "with password" + } else { + "no password" + } + )) + .collect::>() + .join(",\n ") + ); + if !input_users.is_empty() + && ask_generic_confirmation( + "proceed_users", + "Do you want to proceed to add those users to LLDAP?", + )? + { + Ok(Some(input_users)) + } else { + Ok(None) + } +} + +fn should_insert_groups( + input_groups: &[LdapGroup], + existing_groups: &[LldapGroup], +) -> Result { + let existing_group_names = + HashSet::<&str>::from_iter(existing_groups.iter().map(|g| g.display_name.as_str())); + let new_groups = input_groups + .iter() + .filter(|g| !existing_group_names.contains(g.name.as_str())); + let num_new_groups = new_groups.clone().count(); + println!( + "Found {} groups, of which {} new groups: [\n {}\n]", + input_groups.len(), + num_new_groups, + new_groups + .map(|g| g.name.as_str()) + .collect::>() + .join(",\n ") + ); + Ok(num_new_groups != 0 + && ask_generic_confirmation( + "proceed_groups", + "Do you want to proceed to add those groups to LLDAP?", + )?) +} + +struct GroupList { + ldap_groups: Vec, + lldap_groups: Vec, +} + +fn migrate_groups( + graphql_client: &lldap::GraphQLClient, + ldap_connection: &mut ldap::LdapClient, +) -> Result> { + Ok( + if ask_generic_confirmation("should_import_groups", "Do you want to import groups?")? { + let mut existing_groups = lldap::get_lldap_groups(graphql_client)?; + let ldap_groups = ldap::get_groups(ldap_connection)?; + if should_insert_groups(&ldap_groups, &existing_groups)? { + lldap::insert_groups_into_lldap( + &ldap_groups, + &mut existing_groups, + graphql_client, + )?; + } + Some(GroupList { + ldap_groups, + lldap_groups: existing_groups, + }) + } else { + None + }, + ) +} + +struct UserList { + lldap_users: Vec, + ldap_users: Vec, +} + +fn migrate_users( + graphql_client: &lldap::GraphQLClient, + ldap_connection: &mut ldap::LdapClient, +) -> Result> { + Ok( + if ask_generic_confirmation("should_import_users", "Do you want to import users?")? { + let mut existing_users = lldap::get_lldap_users(graphql_client)?; + let users = ldap::get_users(ldap_connection)?; + if let Some(users_to_add) = get_users_to_add(&users, &existing_users)? { + lldap::insert_users_into_lldap(users_to_add, &mut existing_users, graphql_client)?; + } + Some(UserList { + lldap_users: existing_users, + ldap_users: users, + }) + } else { + None + }, + ) +} + +fn migrate_memberships( + user_list: Option, + group_list: Option, + graphql_client: lldap::GraphQLClient, + ldap_connection: &mut ldap::LdapClient, +) -> Result<()> { + let (ldap_users, existing_users) = user_list + .map( + |UserList { + ldap_users, + lldap_users, + }| (Some(ldap_users), Some(lldap_users)), + ) + .unwrap_or_default(); + let (ldap_groups, existing_groups) = group_list + .map( + |GroupList { + ldap_groups, + lldap_groups, + }| (Some(ldap_groups), Some(lldap_groups)), + ) + .unwrap_or_default(); + let ldap_users = ldap_users + .ok_or_else(|| anyhow!("Missing LDAP users")) + .or_else(|_| ldap::get_users(ldap_connection))?; + let ldap_groups = ldap_groups + .ok_or_else(|| anyhow!("Missing LDAP groups")) + .or_else(|_| ldap::get_groups(ldap_connection))?; + let existing_groups = existing_groups + .ok_or_else(|| anyhow!("Missing LLDAP groups")) + .or_else(|_| lldap::get_lldap_groups(&graphql_client))?; + let existing_users = existing_users + .ok_or_else(|| anyhow!("Missing LLDAP users")) + .or_else(|_| lldap::get_lldap_users(&graphql_client))?; + lldap::insert_group_memberships_into_lldap( + &ldap_users, + &ldap_groups, + &existing_users, + &existing_groups, + &graphql_client, + )?; + Ok(()) +} + +fn main() -> Result<()> { + println!( + "The migration tool requires access to both the original LDAP \ + server and the HTTP API of the target LLDAP server." + ); + if !ask_generic_confirmation("setup_ready", "Are you ready to start?")? { + return Ok(()); + } + let mut ldap_connection = ldap::get_ldap_connection()?; + let graphql_client = lldap::get_lldap_client()?; + let user_list = migrate_users(&graphql_client, &mut ldap_connection)?; + let group_list = migrate_groups(&graphql_client, &mut ldap_connection)?; + if ask_generic_confirmation( + "should_import_memberships", + "Do you want to import group memberships?", + )? { + migrate_memberships(user_list, group_list, graphql_client, &mut ldap_connection)?; + } + + Ok(()) +}