migration: Implement import from LDAP
This commit is contained in:
committed by
nitnelave
parent
aa83f6cab6
commit
31cf9b8e2c
432
migration-tool/src/ldap.rs
Normal file
432
migration-tool/src/ldap.rs
Normal 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
506
migration-tool/src/lldap.rs
Normal 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
205
migration-tool/src/main.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user