migration: Implement import from LDAP

This commit is contained in:
Valentin Tolmer 2021-12-07 16:42:38 +01:00 committed by nitnelave
parent aa83f6cab6
commit 31cf9b8e2c
11 changed files with 1450 additions and 4 deletions

253
Cargo.lock generated
View File

@ -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"

View File

@ -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/'

23
migration-tool/Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "migration-tool"
version = "0.3.0-alpha.1"
edition = "2021"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
[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" ]

View File

@ -0,0 +1,5 @@
mutation AddUserToGroup($user: String!, $group: Int!) {
addUserToGroup(userId: $user, groupId: $group) {
ok
}
}

View File

@ -0,0 +1,6 @@
mutation CreateGroup($name: String!) {
createGroup(name: $name) {
id
displayName
}
}

View File

@ -0,0 +1,5 @@
mutation CreateUser($user: CreateUserInput!) {
createUser(user: $user) {
id
}
}

View File

@ -0,0 +1,9 @@
query ListGroups {
groups {
id
displayName
users {
id
}
}
}

View File

@ -0,0 +1,5 @@
query ListUsers {
users(filters: null) {
id
}
}

432
migration-tool/src/ldap.rs Normal file
View File

@ -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<Option<String>, 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<String> {
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<String>,
) -> Result<String> {
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<ResultEntry> for User {
type Error = anyhow::Error;
fn try_from(value: ResultEntry) -> Result<Self> {
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<String>, Vec<String>), 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<Vec<User>, 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::<Result<Vec<User>>>()
}
#[derive(Debug)]
pub struct LdapGroup {
pub name: String,
pub members: Vec<String>,
}
impl TryFrom<ResultEntry> 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<Self> {
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<Vec<LdapGroup>> {
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::<Result<Vec<LdapGroup>>>()?;
Ok(input_groups)
}
pub fn get_ldap_connection() -> Result<LdapClient, anyhow::Error> {
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(),
})
}

506
migration-tool/src/lldap.rs Normal file
View File

@ -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<Self> {
Ok(Self {
url: format!("{}/api/graphql", url),
auth_header: format!("Bearer {}", auth_token).parse()?,
client,
})
}
pub fn post<QueryType>(
&self,
variables: QueryType::Variables,
) -> Result<QueryType::ResponseData>
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::<Vec<_>>()
.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::<graphql_client::Response<QueryType::ResponseData>>()
.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<String>,
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<String>,
firstName: Option<String>,
lastName: Option<String>,
password: Option<String>,
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<String> {
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::<lldap_auth::login::ServerLoginStartResponse>()?;
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<String>,
) -> Result<String> {
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<GraphQLClient> {
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<User>,
existing_users: &mut Vec<String>,
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::<CreateUser>(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<LldapGroup>,
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::<Vec<_>>();
for group in new_groups {
let name = group.name.clone();
loop {
print!("Adding {}... ", &name);
match graphql_client
.post::<CreateGroup>(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<Vec<String>> {
Ok(graphql_client
.post::<ListUsers>(list_users::Variables {})?
.users
.into_iter()
.map(|u| u.id)
.collect())
}
pub fn get_lldap_groups(graphql_client: &GraphQLClient) -> Result<Vec<LldapGroup>> {
Ok(graphql_client
.post::<ListGroups>(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::<AddUserToGroup>(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<String> {
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(),
)
}

205
migration-tool/src/main.rs Normal file
View File

@ -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<bool> {
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<Option<Vec<User>>> {
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::<Vec<_>>()
.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<bool> {
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::<Vec<_>>()
.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<LdapGroup>,
lldap_groups: Vec<LldapGroup>,
}
fn migrate_groups(
graphql_client: &lldap::GraphQLClient,
ldap_connection: &mut ldap::LdapClient,
) -> Result<Option<GroupList>> {
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<String>,
ldap_users: Vec<User>,
}
fn migrate_users(
graphql_client: &lldap::GraphQLClient,
ldap_connection: &mut ldap::LdapClient,
) -> Result<Option<UserList>> {
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<UserList>,
group_list: Option<GroupList>,
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(())
}