ldap_utils/
lib.rs

1#![deny(unknown_lints)]
2#![deny(renamed_and_removed_lints)]
3#![forbid(unsafe_code)]
4#![deny(deprecated)]
5#![forbid(private_interfaces)]
6#![forbid(private_bounds)]
7#![forbid(non_fmt_panics)]
8#![deny(unreachable_code)]
9#![deny(unreachable_patterns)]
10#![forbid(unused_doc_comments)]
11#![forbid(unused_must_use)]
12#![deny(while_true)]
13#![deny(unused_parens)]
14#![deny(redundant_semicolons)]
15#![deny(non_ascii_idents)]
16#![deny(confusable_idents)]
17#![warn(missing_docs)]
18#![warn(clippy::missing_docs_in_private_items)]
19#![warn(clippy::cargo_common_metadata)]
20#![warn(rustdoc::missing_crate_level_docs)]
21#![deny(rustdoc::broken_intra_doc_links)]
22#![warn(missing_debug_implementations)]
23#![deny(clippy::mod_module_files)]
24#![doc = include_str!("../README.md")]
25
26use lazy_static::lazy_static;
27
28use diff::{Diff, VecDiffType};
29
30use chumsky::Parser;
31use ldap_types::basic::{ChumskyError, LDAPEntry, LDAPOperation, OIDWithLength, RootDSE};
32use ldap_types::schema::{
33    attribute_type_parser, ldap_syntax_parser, matching_rule_parser, matching_rule_use_parser,
34    object_class_parser, AttributeType, LDAPSchema, LDAPSyntax, MatchingRule, MatchingRuleUse,
35    ObjectClass,
36};
37
38use ldap3::exop::{WhoAmI, WhoAmIResp};
39use ldap3::result::SearchResult;
40use ldap3::{Ldap, LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
41use native_tls::{Certificate, Identity, TlsConnector};
42use oid::ObjectIdentifier;
43
44use std::collections::{HashMap, HashSet};
45use std::fmt::Debug;
46use std::fmt::Display;
47
48use openssl::pkcs12::Pkcs12;
49use openssl::pkey::PKey;
50use openssl::x509::X509;
51
52use std::fs::File;
53use std::io::Read;
54use std::path::Path;
55
56use regex::Regex;
57
58use dirs2::home_dir;
59
60use tracing::instrument;
61
62use derive_builder::Builder;
63
64use serde::Deserialize;
65
66use std::convert::TryInto;
67
68use thiserror::Error;
69
70/// creates a noop_control object for use with ldap3
71///
72/// the noop_control is supposed to perform the same operation
73/// and return the same errors as the real operation but not make
74/// any changes to the directory
75///
76/// OpenLDAP's implementation seems to be buggy, in my tests some uses of the
77/// NOOP control lead to problems displaying affected objects until the LDAP
78/// server was restarted
79pub fn noop_control() -> ldap3::controls::RawControl {
80    ldap3::controls::RawControl {
81        ctype: "1.3.6.1.4.1.4203.666.5.2".to_string(),
82        crit: true,
83        val: None,
84    }
85}
86
87/// error which can occur while parsing a scope
88#[derive(Debug, Clone, Error)]
89pub enum ScopeParserError {
90    /// could not parse the value as a scope
91    #[error("Could not parse {0} as an ldap scope")]
92    CouldNotParseAsScope(String),
93}
94
95/// parse an [ldap3::Scope] from the string one would specify to use the same
96/// scope with OpenLDAP's ldapsearch -s parameter
97pub fn parse_scope(src: &str) -> Result<ldap3::Scope, ScopeParserError> {
98    match src {
99        "base" => Ok(ldap3::Scope::Base),
100        "one" => Ok(ldap3::Scope::OneLevel),
101        "sub" => Ok(ldap3::Scope::Subtree),
102        s => Err(ScopeParserError::CouldNotParseAsScope(s.to_string())),
103    }
104}
105
106/// a set of parameters for connecting to an LDAP server, including client-side
107/// certificate auth support
108#[derive(Debug, Clone, Builder, Deserialize)]
109pub struct ConnectParameters {
110    /// CA certificate path
111    ca_cert_path: std::string::String,
112    /// client certificate path
113    client_cert_path: std::string::String,
114    /// client key path
115    client_key_path: std::string::String,
116    /// the LDAP URL to connect to
117    pub url: std::string::String,
118}
119
120/// errors which can happen when trying to retrieve connect parameters from openldap config
121#[derive(Debug, Error)]
122pub enum OpenLdapConnectParameterError {
123    /// an error when compiling or using a regular expression
124    #[error("regex error: {0}")]
125    RegexError(#[from] regex::Error),
126    /// an I/O error
127    #[error("I/O error: {0}")]
128    IOError(#[from] std::io::Error),
129}
130
131/// try to detect OpenLDAP connect parameters from its config files
132/// (ldap.conf in /etc/ldap or /etc/openldap and .ldaprc in the user home dir)
133#[instrument(skip(builder))]
134pub fn openldap_connect_parameters(
135    builder: &mut ConnectParametersBuilder,
136) -> Result<&mut ConnectParametersBuilder, OpenLdapConnectParameterError> {
137    let ldap_rc_content;
138    let ldap_conf_content;
139    if let Some(d) = home_dir() {
140        let mut ldap_rc_filename = d;
141        ldap_rc_filename.push(".ldaprc");
142        if ldap_rc_filename.exists() {
143            tracing::debug!("Using .ldaprc at {:?}", ldap_rc_filename);
144            ldap_rc_content = std::fs::read_to_string(ldap_rc_filename)?;
145
146            let ca_cert_re = Regex::new(r"^TLS_CACERT *(.*)$")?;
147            let client_cert_re = Regex::new(r"^TLS_CERT *(.*)$")?;
148            let client_key_re = Regex::new(r"^TLS_KEY *(.*)$")?;
149            for line in ldap_rc_content.lines() {
150                if let Some(caps) = ca_cert_re.captures(line) {
151                    let ca_cert_path = caps.get(1).unwrap().as_str();
152                    tracing::debug!("Extracted .ldaprc TLS_CACERT value {}", ca_cert_path);
153                    builder.ca_cert_path(ca_cert_path.to_string());
154                }
155
156                if let Some(caps) = client_cert_re.captures(line) {
157                    let client_cert_path = caps.get(1).unwrap().as_str();
158                    tracing::debug!("Extracted .ldaprc TLS_CERT value {}", client_cert_path);
159                    builder.client_cert_path(client_cert_path.to_string());
160                }
161
162                if let Some(caps) = client_key_re.captures(line) {
163                    let client_key_path = caps.get(1).unwrap().as_str();
164                    tracing::debug!("Extracted .ldaprc TLS_KEY value {}", client_key_path);
165                    builder.client_key_path(client_key_path.to_string());
166                }
167            }
168        }
169
170        let mut ldap_conf_filename = Path::new("/etc/ldap/ldap.conf");
171        if !ldap_conf_filename.exists() {
172            ldap_conf_filename = Path::new("/etc/openldap/ldap.conf");
173        }
174        if ldap_conf_filename.exists() {
175            tracing::debug!("Using ldap.conf at {:?}", ldap_conf_filename);
176            ldap_conf_content = std::fs::read_to_string(ldap_conf_filename)?;
177
178            let uri_re = Regex::new(r"^URI *(.*)$")?;
179            for line in ldap_conf_content.lines() {
180                if let Some(caps) = uri_re.captures(line) {
181                    let url = caps.get(1).unwrap().as_str();
182                    tracing::debug!("Extracted ldap.conf URI value {}", url);
183                    builder.url(url.to_string());
184                }
185            }
186        }
187    }
188    Ok(builder)
189}
190
191/// fill the builder with hardcoded default parameters
192///
193/// there is no default parameter for the URL
194#[instrument(skip(builder))]
195pub fn default_connect_parameters(
196    builder: &mut ConnectParametersBuilder,
197) -> &mut ConnectParametersBuilder {
198    if builder.ca_cert_path.is_none() {
199        builder.ca_cert_path("ca.crt".to_string());
200    }
201    if builder.client_cert_path.is_none() {
202        builder.client_cert_path("client.crt".to_string());
203    }
204    if builder.client_key_path.is_none() {
205        builder.client_key_path("client.key".to_string());
206    }
207    builder
208}
209
210/// error which can happen while reading connect parameters from a file
211#[derive(Debug, Error)]
212pub enum TomlConfigError {
213    /// an I/O error
214    #[error("I/O error: {0}")]
215    IOError(#[from] std::io::Error),
216    /// an error deserializing the TOML file
217    #[error("Toml deserialization error: {0}")]
218    TomlError(#[from] toml::de::Error),
219}
220
221/// load ldap connect parameters from a toml file
222#[instrument]
223pub fn toml_connect_parameters(
224    filename: std::path::PathBuf,
225) -> Result<ConnectParameters, TomlConfigError> {
226    let config = std::fs::read_to_string(filename)?;
227    let result: ConnectParameters = toml::from_str(&config)?;
228
229    Ok(result)
230}
231
232/// errors which can happen when connecting to an LDAP server
233#[derive(Debug, Error)]
234pub enum ConnectError {
235    /// an error when building the parameters, most likely a value
236    /// that could not be retrieved from any config source
237    #[error("Parameters builder error: {0}")]
238    ParametersBuilderError(#[from] ConnectParametersBuilderError),
239    /// an error when trying to retrieve connect parameters from OpenLDAP config files
240    #[error("Error retrieving OpenLDAP connect parameters: {0}")]
241    OpenLdapConnectParameterError(#[from] OpenLdapConnectParameterError),
242    /// an I/O error
243    #[error("I/O error: {0}")]
244    IOError(#[from] std::io::Error),
245    /// an error in the native_tls crate
246    #[error("Native TLS error: {0}")]
247    NativeTLSError(#[from] native_tls::Error),
248    /// an error in the ldap3 crate
249    #[error("ldap3 Ldap error: {0}")]
250    LdapError(#[from] ldap3::LdapError),
251    /// an error when compiling or using a regular expression
252    #[error("regex error: {0}")]
253    RegexError(#[from] regex::Error),
254    /// an error in the openssl library used to read certificates and keys
255    #[error("openssl error: {0}")]
256    OpenSSLError(#[from] openssl::error::ErrorStack),
257}
258
259/// try to connect to an LDAP server using ldap3 using the OpenLDAP config files
260/// supplemented by hardcoded default values
261#[instrument]
262pub async fn connect() -> Result<(Ldap, std::string::String), ConnectError> {
263    let mut builder = ConnectParametersBuilder::default();
264    openldap_connect_parameters(&mut builder)?;
265    match builder.build() {
266        Ok(result) => connect_with_parameters(result).await,
267        Err(err_msg) => {
268            tracing::error!(
269                "Building of ConnectParameters based on OpenLDAP config files failed: {}",
270                err_msg
271            );
272            let builder = default_connect_parameters(&mut builder);
273            match builder.build() {
274                Ok(result) => connect_with_parameters(result).await,
275                Err(err) => {
276                    tracing::error!("Building of ConnectParameters based on OpenLDAP config files and substituting default values for missing values failed: {}", err);
277                    Err(ConnectError::ParametersBuilderError(err))
278                }
279            }
280        }
281    }
282}
283
284/// connect to an LDAP server using ldap3 with the given set of default parameters
285#[instrument]
286pub async fn connect_with_parameters(
287    connect_parameters: ConnectParameters,
288) -> Result<(Ldap, std::string::String), ConnectError> {
289    let mut client_cert_contents = Vec::new();
290    {
291        let mut file = File::open(connect_parameters.client_cert_path)?;
292        file.read_to_end(&mut client_cert_contents)?;
293    }
294    let client_cert = X509::from_pem(&client_cert_contents)?;
295    let mut client_key_contents = Vec::new();
296    {
297        let mut file = File::open(connect_parameters.client_key_path)?;
298        file.read_to_end(&mut client_key_contents)?;
299    }
300    let client_key = PKey::private_key_from_pem(&client_key_contents)?;
301    let p12_password = "client";
302    let p12 = Pkcs12::builder()
303        .name("client")
304        .pkey(&client_key)
305        .cert(&client_cert)
306        .build2(p12_password)?;
307    let p12_contents = p12.to_der()?;
308    let mut ca_cert_contents = Vec::new();
309    {
310        let mut file = File::open(connect_parameters.ca_cert_path)?;
311        file.read_to_end(&mut ca_cert_contents)?;
312    }
313    let identity = Identity::from_pkcs12(&p12_contents, p12_password)?;
314    let ca_certificate = Certificate::from_pem(&ca_cert_contents)?;
315    let connector = TlsConnector::builder()
316        .identity(identity)
317        .add_root_certificate(ca_certificate)
318        .build()?;
319    let ldap_settings = LdapConnSettings::new().set_connector(connector);
320    let (ldap_conn_async, mut ldap) =
321        LdapConnAsync::with_settings(ldap_settings, &connect_parameters.url.clone()).await?;
322    ldap3::drive!(ldap_conn_async);
323    ldap.sasl_external_bind().await?;
324    let (exop, _res) = ldap.extended(WhoAmI).await?.success()?;
325    let who_am_i: WhoAmIResp = exop.parse();
326    let re = Regex::new(r"^.*,ou=[a-z]+,")?;
327    let base_dn = re.replace_all(&who_am_i.authzid, "").to_string();
328    Ok((ldap, base_dn))
329}
330
331/// an error during normal ldap operations (search, add, modify, update, delete,...)
332#[derive(Debug, Error)]
333pub enum LdapOperationError {
334    /// an error in the ldap3 library
335    #[error("ldap3 Ldap error: {0}")]
336    LdapError(#[from] ldap3::LdapError),
337    /// and error parsing an OID
338    #[error("OID error: {0}")]
339    OIDError(#[from] OIDError),
340}
341
342/// perform an LDAP search via ldap3, logging a proper error message if it fails
343/// and returning an iterator to already unwrapped search entries
344pub async fn ldap_search<'a, S: AsRef<str> + Clone + Display + Debug + Send + Sync>(
345    ldap: &mut Ldap,
346    base: &str,
347    scope: Scope,
348    filter: &str,
349    attrs: Vec<S>,
350) -> Result<Box<dyn Iterator<Item = SearchEntry> + 'a>, LdapOperationError> {
351    let search_result = ldap.search(base, scope, filter, attrs.clone()).await?;
352    let SearchResult(_rs, res) = &search_result;
353    if res.rc != 0 {
354        tracing::debug!(
355            "Non-zero return code {} in LDAP query\n  base: {}\n  scope: {:?}\n  filter: {}\n  attrs: {:#?}",
356            res.rc,
357            base,
358            scope,
359            filter,
360            attrs
361        );
362        tracing::debug!(
363            "ldapsearch -Q -LLL -o ldif-wrap=no -b '{}' -s {} '{}' {}",
364            base,
365            format!("{:?}", scope).to_lowercase(),
366            filter,
367            itertools::join(attrs.iter(), " ")
368        );
369    }
370    let (rs, _res) = search_result.success()?;
371    Ok(Box::new(rs.into_iter().map(SearchEntry::construct)))
372}
373
374/// an error type in case parsing an OID fails when querying the RootDSE from ldap3
375/// during the parsing of supported controls, extensions and features
376#[derive(Debug)]
377pub struct OIDError(oid::ObjectIdentifierError);
378
379impl Display for OIDError {
380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        write!(f, "Error parsing OID: {:?}", self.0)
382    }
383}
384
385impl std::error::Error for OIDError {
386    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
387        None
388    }
389}
390
391/// retrieve the [RootDSE] from an LDAP server using ldap3
392#[instrument(skip(ldap))]
393pub async fn query_root_dse(ldap: &mut Ldap) -> Result<Option<RootDSE>, LdapOperationError> {
394    let mut it = ldap_search(
395        ldap,
396        "",
397        Scope::Base,
398        "(objectClass=*)",
399        vec![
400            "supportedLDAPVersion",
401            "supportedControl",
402            "supportedExtension",
403            "supportedFeatures",
404            "supportedSASLMechanisms",
405            "configContext",
406            "namingContexts",
407            "subschemaSubentry",
408        ],
409    )
410    .await?;
411    if let Some(entry) = it.next() {
412        let supported_ldap_version = entry
413            .attrs
414            .get("supportedLDAPVersion")
415            .unwrap()
416            .first()
417            .unwrap();
418        let supported_controls = entry.attrs.get("supportedControl").unwrap();
419        let supported_extensions = entry.attrs.get("supportedExtension").unwrap();
420        let supported_features = entry.attrs.get("supportedFeatures").unwrap();
421        let supported_sasl_mechanisms = entry.attrs.get("supportedSASLMechanisms").unwrap();
422        let config_context = entry.attrs.get("configContext").unwrap().first().unwrap();
423        let naming_contexts = entry.attrs.get("namingContexts").unwrap();
424        let subschema_subentry = entry
425            .attrs
426            .get("subschemaSubentry")
427            .unwrap()
428            .first()
429            .unwrap();
430        return Ok(Some(RootDSE {
431            supported_ldap_version: supported_ldap_version.to_string(),
432            supported_controls: supported_controls
433                .iter()
434                .map(|x| x.clone().try_into())
435                .collect::<Result<_, _>>()
436                .map_err(OIDError)?,
437            supported_extensions: supported_extensions
438                .iter()
439                .map(|x| x.clone().try_into())
440                .collect::<Result<_, _>>()
441                .map_err(OIDError)?,
442            supported_features: supported_features
443                .iter()
444                .map(|x| x.clone().try_into())
445                .collect::<Result<_, _>>()
446                .map_err(OIDError)?,
447            supported_sasl_mechanisms: supported_sasl_mechanisms.to_vec(),
448            config_context: config_context.to_string(),
449            naming_contexts: naming_contexts.to_vec(),
450            subschema_subentry: subschema_subentry.to_string(),
451        }));
452    }
453    Ok(None)
454}
455
456/// error which can happen while retrieving and parsing the LDAP schema
457#[derive(Debug, Error)]
458pub enum LdapSchemaError {
459    /// an error in the ldap operations performed while retrieving the schema
460    #[error("Ldap operation error: {0}")]
461    LdapOperationError(#[from] LdapOperationError),
462    /// an error while parsing the retrieved schema
463    #[error("chumsky parser error: {0}")]
464    ChumskyError(#[from] ChumskyError),
465}
466
467/// Retrieve the LDAP schema from an LDAP server using ldap3
468///
469/// tested with OpenLDAP
470#[instrument(skip(ldap))]
471pub async fn query_ldap_schema(ldap: &mut Ldap) -> Result<Option<LDAPSchema>, LdapSchemaError> {
472    if let Some(root_dse) = query_root_dse(ldap).await? {
473        let mut it = ldap_search(
474            ldap,
475            &root_dse.subschema_subentry,
476            Scope::Base,
477            "(objectClass=*)",
478            vec![
479                "ldapSyntaxes",
480                "matchingRules",
481                "matchingRuleUse",
482                "attributeTypes",
483                "objectClasses",
484            ],
485        )
486        .await?;
487
488        if let Some(entry) = it.next() {
489            let ldap_syntaxes = entry
490                .attrs
491                .get("ldapSyntaxes")
492                .unwrap()
493                .iter()
494                .map(|x| match ldap_syntax_parser().parse_recovery(x.as_str()) {
495                    (Some(ldap_syntax), _) => Ok(ldap_syntax),
496                    (_, errs) => Err(ChumskyError {
497                        description: "ldap syntax".to_string(),
498                        source: x.to_string(),
499                        errors: errs,
500                    }),
501                })
502                .collect::<Result<Vec<LDAPSyntax>, ChumskyError>>()?;
503            let matching_rules = entry
504                .attrs
505                .get("matchingRules")
506                .unwrap()
507                .iter()
508                .map(
509                    |x| match matching_rule_parser().parse_recovery(x.as_str()) {
510                        (Some(matching_rule), _) => Ok(matching_rule),
511                        (_, errs) => Err(ChumskyError {
512                            description: "matching rule".to_string(),
513                            source: x.to_string(),
514                            errors: errs,
515                        }),
516                    },
517                )
518                .collect::<Result<Vec<MatchingRule>, ChumskyError>>()?;
519            let matching_rule_use = entry
520                .attrs
521                .get("matchingRuleUse")
522                .unwrap()
523                .iter()
524                .map(
525                    |x| match matching_rule_use_parser().parse_recovery(x.as_str()) {
526                        (Some(matching_rule_use), _) => Ok(matching_rule_use),
527                        (_, errs) => Err(ChumskyError {
528                            description: "matching rule use".to_string(),
529                            source: x.to_string(),
530                            errors: errs,
531                        }),
532                    },
533                )
534                .collect::<Result<Vec<MatchingRuleUse>, ChumskyError>>()?;
535            let attribute_types = entry
536                .attrs
537                .get("attributeTypes")
538                .unwrap()
539                .iter()
540                .map(
541                    |x| match attribute_type_parser().parse_recovery(x.as_str()) {
542                        (Some(attribute_type), _) => Ok(attribute_type),
543                        (_, errs) => Err(ChumskyError {
544                            description: "attribute type".to_string(),
545                            source: x.to_string(),
546                            errors: errs,
547                        }),
548                    },
549                )
550                .collect::<Result<Vec<AttributeType>, ChumskyError>>()?;
551            let object_classes = entry
552                .attrs
553                .get("objectClasses")
554                .unwrap()
555                .iter()
556                .map(|x| match object_class_parser().parse_recovery(x.as_str()) {
557                    (Some(object_class), _) => Ok(object_class),
558                    (_, errs) => Err(ChumskyError {
559                        description: "object class".to_string(),
560                        source: x.to_string(),
561                        errors: errs,
562                    }),
563                })
564                .collect::<Result<Vec<ObjectClass>, ChumskyError>>()?;
565            return Ok(Some(LDAPSchema {
566                ldap_syntaxes,
567                matching_rules,
568                matching_rule_use,
569                attribute_types,
570                object_classes,
571            }));
572        }
573    }
574    Ok(None)
575}
576
577/// check if an [ldap3::LdapResult] is either a success or the success code returned by an operation using the [noop_control]
578pub fn success_or_noop_success(
579    ldap_result: ldap3::LdapResult,
580) -> ldap3::result::Result<ldap3::LdapResult> {
581    // 16654 is success in the presence of the noop control https://ldap.com/ldap-result-code-reference-other-server-side-result-codes/#rc-noOperation
582    if ldap_result.rc == 0 || ldap_result.rc == 16654 {
583        Ok(ldap_result)
584    } else {
585        Err(ldap3::LdapError::from(ldap_result))
586    }
587}
588
589/// delete an LDAP entry recursively using ldap3
590#[instrument(skip(ldap))]
591pub async fn delete_recursive(
592    ldap: &mut Ldap,
593    dn: &str,
594    controls: Vec<ldap3::controls::RawControl>,
595) -> Result<(), LdapOperationError> {
596    tracing::debug!("Deleting {} recursively", dn);
597    let it = ldap_search(
598        ldap,
599        dn,
600        Scope::Subtree,
601        "(objectClass=*)",
602        Vec::<String>::new(),
603    )
604    .await?;
605    let mut entries = vec![];
606    for entry in it {
607        tracing::debug!("Found child entry to delete {}", entry.dn);
608        entries.push(entry.dn);
609    }
610    entries.sort_by_key(|b| std::cmp::Reverse(b.len()));
611    for dn in entries {
612        tracing::debug!("Deleting child entry {}", dn);
613        success_or_noop_success(ldap.with_controls(controls.to_owned()).delete(&dn).await?)?;
614    }
615    Ok(())
616}
617
618/// of the same modify operation because otherwise we might successfully apply the textual modifications
619/// and then fail on the binary ones, leaving behind a half-modified object
620pub fn mods_as_bin_mods<'a, T>(mods: T) -> Vec<ldap3::Mod<Vec<u8>>>
621where
622    T: IntoIterator<Item = &'a ldap3::Mod<String>>,
623{
624    let mut result: Vec<ldap3::Mod<Vec<u8>>> = vec![];
625    for m in mods {
626        match m {
627            ldap3::Mod::Add(k, v) => {
628                result.push(ldap3::Mod::Add(
629                    k.as_bytes().to_vec(),
630                    v.iter().map(|s| s.as_bytes().to_vec()).collect(),
631                ));
632            }
633            ldap3::Mod::Delete(k, v) => {
634                result.push(ldap3::Mod::Delete(
635                    k.as_bytes().to_vec(),
636                    v.iter().map(|s| s.as_bytes().to_vec()).collect(),
637                ));
638            }
639            ldap3::Mod::Replace(k, v) => {
640                result.push(ldap3::Mod::Replace(
641                    k.as_bytes().to_vec(),
642                    v.iter().map(|s| s.as_bytes().to_vec()).collect(),
643                ));
644            }
645            ldap3::Mod::Increment(k, v) => {
646                result.push(ldap3::Mod::Increment(
647                    k.as_bytes().to_vec(),
648                    v.as_bytes().to_vec(),
649                ));
650            }
651        }
652    }
653    result
654}
655
656/// apply the LDAP operations on a given LDAP server.
657///
658/// The operations should not include the Base-DN in its internally stored DNs
659/// It will be added automatically. This allows for easier generation of comparisons
660/// between objects on two different LDAP servers with different base DNs.
661#[instrument(skip(ldap, ldap_operations))]
662pub async fn apply_ldap_operations(
663    ldap: &mut Ldap,
664    ldap_base_dn: &str,
665    ldap_operations: &[LDAPOperation],
666    controls: Vec<ldap3::controls::RawControl>,
667) -> Result<(), LdapOperationError> {
668    tracing::debug!(
669        "The following operations use the LDAP controls: {:#?}",
670        controls
671    );
672    for op in ldap_operations {
673        match op {
674            LDAPOperation::Add(LDAPEntry {
675                dn,
676                attrs,
677                bin_attrs,
678            }) => {
679                let full_dn = format!("{},{}", dn, ldap_base_dn);
680                tracing::debug!(
681                    "Adding LDAP entry at {} with attributes\n{:#?}\nand binary attributes\n{:#?}",
682                    &full_dn,
683                    attrs,
684                    bin_attrs
685                );
686                // we need to perform the add in one operation or we will run into problems with
687                // objectclass requirements
688                let mut combined_attrs: Vec<(Vec<u8>, HashSet<Vec<u8>>)> = bin_attrs
689                    .iter()
690                    .map(|(k, v)| {
691                        (
692                            k.to_owned().as_bytes().to_vec(),
693                            v.iter().map(|s| s.to_owned()).collect::<HashSet<Vec<u8>>>(),
694                        )
695                    })
696                    .collect();
697                combined_attrs.extend(attrs.iter().map(|(k, v)| {
698                    (
699                        k.to_owned().as_bytes().to_vec(),
700                        v.iter()
701                            .map(|s| s.as_bytes().to_vec())
702                            .collect::<HashSet<Vec<u8>>>(),
703                    )
704                }));
705                ldap.with_controls(controls.to_owned())
706                    .add(&full_dn, combined_attrs)
707                    .await?
708                    .success()?;
709            }
710            LDAPOperation::Delete { dn } => {
711                let full_dn = format!("{},{}", dn, ldap_base_dn);
712                tracing::debug!("Deleting LDAP entry at {}", &full_dn);
713                delete_recursive(ldap, &full_dn, controls.to_owned()).await?;
714            }
715            LDAPOperation::Modify { dn, mods, bin_mods } => {
716                let full_dn = format!("{},{}", dn, ldap_base_dn);
717                tracing::debug!("Modifying LDAP entry at {} with modifications\n{:#?}\nand binary modifications\n{:#?}", &full_dn, mods, bin_mods);
718                let mut combined_mods = bin_mods.to_owned();
719                combined_mods.extend(mods_as_bin_mods(mods));
720                ldap.with_controls(controls.to_owned())
721                    .modify(&full_dn, combined_mods.to_vec())
722                    .await?
723                    .success()?;
724            }
725        }
726    }
727
728    Ok(())
729}
730
731/// helper function to search an LDAP server and generate [LDAPEntry] values
732/// with the base DN removed to make them server-independent
733#[instrument(skip(ldap, entries))]
734pub async fn search_entries(
735    ldap: &mut Ldap,
736    base_dn: &str,
737    search_base: &str,
738    scope: ldap3::Scope,
739    filter: &str,
740    attrs: &[String],
741    entries: &mut HashMap<String, LDAPEntry>,
742) -> Result<(), LdapOperationError> {
743    let it = ldap_search(
744        ldap,
745        &format!("{},{}", search_base, base_dn),
746        scope,
747        filter,
748        attrs.to_owned(),
749    )
750    .await?;
751    for entry in it {
752        tracing::debug!("Found entry {}", entry.dn);
753        if let Some(s) = entry.dn.strip_suffix(&format!(",{}", &base_dn)) {
754            entries.insert(
755                s.to_string(),
756                LDAPEntry {
757                    dn: s.to_string(),
758                    attrs: entry.attrs,
759                    bin_attrs: entry.bin_attrs,
760                },
761            );
762        } else {
763            tracing::error!(
764                "Failed to remove base dn {} from entry DN {}",
765                base_dn,
766                entry.dn
767            );
768        }
769    }
770    Ok(())
771}
772
773/// generate an [ldap3::Mod] if there is a DN-valued attribute in the source
774/// entry that needs its base DN translated to the destination base DN
775#[instrument(skip(
776    source_entry,
777    source_ldap_schema,
778    source_base_dn,
779    destination_entry,
780    destination_base_dn,
781    ignore_object_classes,
782))]
783pub fn mod_value(
784    attr_name: &str,
785    source_entry: &LDAPEntry,
786    source_ldap_schema: &LDAPSchema,
787    source_base_dn: &str,
788    destination_entry: Option<&LDAPEntry>,
789    destination_base_dn: &str,
790    ignore_object_classes: &[String],
791) -> Option<ldap3::Mod<String>> {
792    lazy_static! {
793        static ref DN_SYNTAX_OID: OIDWithLength = OIDWithLength {
794            oid: ObjectIdentifier::try_from("1.3.6.1.4.1.1466.115.121.1.12").unwrap(),
795            length: None
796        };
797    }
798    if let Some(values) = source_entry.attrs.get(attr_name) {
799        let mut replacement_values = HashSet::from_iter(values.iter().cloned());
800        if attr_name == "objectClass" {
801            for io in ignore_object_classes {
802                replacement_values.remove(io);
803            }
804        }
805        let attr_type_syntax =
806            source_ldap_schema.find_attribute_type_property(attr_name, |at| at.syntax.as_ref());
807        tracing::trace!(
808            "Attribute type syntax for altered attribute {}: {:#?}",
809            attr_name,
810            attr_type_syntax
811        );
812        if let Some(syntax) = attr_type_syntax {
813            if DN_SYNTAX_OID.eq(syntax) {
814                tracing::trace!(
815                    "Replacing base DN {} with base DN {}",
816                    source_base_dn,
817                    destination_base_dn
818                );
819                replacement_values = replacement_values
820                    .into_iter()
821                    .map(|s| s.replace(source_base_dn, destination_base_dn))
822                    .collect();
823            }
824        }
825        if let Some(destination_entry) = destination_entry {
826            if let Some(destination_values) = destination_entry.attrs.get(attr_name) {
827                let mut replacement_values_sorted: Vec<String> =
828                    replacement_values.iter().cloned().collect();
829                replacement_values_sorted.sort();
830                let mut destination_values: Vec<String> = destination_values.to_vec();
831                destination_values.sort();
832                tracing::trace!("Checking if replacement values and destination values are identical (case sensitive):\n{:#?}\n{:#?}", destination_values, replacement_values_sorted);
833                if replacement_values_sorted == destination_values {
834                    tracing::trace!("Skipping attribute {} because replacement values and destination values are identical (case sensitive)", attr_name);
835                    return None;
836                }
837                let attr_type_equality = source_ldap_schema
838                    .find_attribute_type_property(attr_name, |at| at.equality.as_ref());
839                tracing::trace!(
840                    "Attribute type equality for altered attribute {}: {:#?}",
841                    attr_name,
842                    attr_type_equality
843                );
844                if let Some(equality) = &attr_type_equality {
845                    if equality.describes_case_insensitive_match() {
846                        let mut lower_destination_values: Vec<String> = destination_values
847                            .iter()
848                            .map(|s| s.to_lowercase())
849                            .collect();
850                        lower_destination_values.sort();
851                        let mut lower_replacement_values: Vec<String> = replacement_values
852                            .iter()
853                            .map(|s| s.to_lowercase())
854                            .collect();
855                        lower_replacement_values.sort();
856                        tracing::trace!("Checking if replacement values and destination values are identical (case insensitive):\n{:#?}\n{:#?}", lower_destination_values, lower_replacement_values);
857                        if lower_destination_values == lower_replacement_values {
858                            tracing::trace!("Skipping attribute {} because replacement values and destination values are identical (case insensitive)", attr_name);
859                            return None;
860                        }
861                    }
862                }
863            }
864        }
865        Some(ldap3::Mod::Replace(
866            attr_name.to_string(),
867            replacement_values,
868        ))
869    } else {
870        Some(ldap3::Mod::Delete(attr_name.to_string(), HashSet::new()))
871    }
872}
873
874/// diff two sets of LDAPEntries which had their base DNs removed
875/// and generates LDAP operations (add, update, delete) to apply to
876/// the destination to make it identical to the source
877#[allow(clippy::too_many_arguments)]
878#[instrument(skip(source_ldap_schema))]
879pub fn diff_entries(
880    source_entries: &HashMap<String, LDAPEntry>,
881    destination_entries: &HashMap<String, LDAPEntry>,
882    source_base_dn: &str,
883    destination_base_dn: &str,
884    ignore_object_classes: &[String],
885    ignore_attributes: &[String],
886    source_ldap_schema: &LDAPSchema,
887    add: bool,
888    update: bool,
889    delete: bool,
890) -> Vec<LDAPOperation> {
891    lazy_static! {
892        static ref DN_SYNTAX_OID: OIDWithLength = OIDWithLength {
893            oid: ObjectIdentifier::try_from("1.3.6.1.4.1.1466.115.121.1.12").unwrap(),
894            length: None
895        };
896    }
897    let diff = Diff::diff(source_entries, destination_entries);
898    tracing::trace!("Diff:\n{:#?}", diff);
899    let mut ldap_operations: Vec<LDAPOperation> = vec![];
900    for (altered_dn, change) in diff.altered {
901        tracing::trace!("Processing altered DN {}", altered_dn);
902        let source_entry: Option<&LDAPEntry> = source_entries.get(&altered_dn);
903        let destination_entry: Option<&LDAPEntry> = destination_entries.get(&altered_dn);
904        if let Some(source_entry) = source_entry {
905            let mut ldap_mods: Vec<ldap3::Mod<String>> = vec![];
906            let mut ldap_bin_mods: Vec<ldap3::Mod<Vec<u8>>> = vec![];
907            for (attr_name, attr_value_changes) in &change.attrs.altered {
908                if ignore_attributes.contains(attr_name) {
909                    continue;
910                }
911                for attr_value_change in &attr_value_changes.0 {
912                    match attr_value_change {
913                        VecDiffType::Removed { .. }
914                        | VecDiffType::Inserted { .. }
915                        | VecDiffType::Altered { .. } => {
916                            let m = mod_value(
917                                attr_name,
918                                source_entry,
919                                source_ldap_schema,
920                                source_base_dn,
921                                destination_entry,
922                                destination_base_dn,
923                                ignore_object_classes,
924                            );
925                            if let Some(m) = m {
926                                if !ldap_mods.contains(&m) {
927                                    ldap_mods.push(m);
928                                }
929                            }
930                        }
931                    }
932                }
933            }
934            for attr_name in &change.attrs.removed {
935                if ignore_attributes.contains(attr_name) {
936                    continue;
937                }
938                let mut replacement_values =
939                    HashSet::from_iter(source_entry.attrs[attr_name].iter().cloned());
940                if attr_name == "objectClass" {
941                    for io in ignore_object_classes {
942                        replacement_values.remove(io);
943                    }
944                }
945                let attr_type_syntax = source_ldap_schema
946                    .find_attribute_type_property(attr_name, |at| at.syntax.as_ref());
947                tracing::trace!(
948                    "Attribute type syntax for deleted attribute {}: {:#?}",
949                    attr_name,
950                    attr_type_syntax
951                );
952                if let Some(syntax) = attr_type_syntax {
953                    if DN_SYNTAX_OID.eq(syntax) {
954                        tracing::trace!(
955                            "Replacing base DN {} with base DN {}",
956                            source_base_dn,
957                            destination_base_dn
958                        );
959                        replacement_values = replacement_values
960                            .into_iter()
961                            .map(|s| s.replace(source_base_dn, destination_base_dn))
962                            .collect();
963                    }
964                }
965                ldap_mods.push(ldap3::Mod::Add(attr_name.to_string(), replacement_values));
966            }
967            for (attr_name, attr_value_changes) in &change.bin_attrs.altered {
968                if ignore_attributes.contains(attr_name) {
969                    continue;
970                }
971                for attr_value_change in &attr_value_changes.0 {
972                    match attr_value_change {
973                        VecDiffType::Removed { .. }
974                        | VecDiffType::Inserted { .. }
975                        | VecDiffType::Altered { .. } => {
976                            if let Some(values) = source_entry.bin_attrs.get(attr_name) {
977                                let replace_mod = ldap3::Mod::Replace(
978                                    attr_name.as_bytes().to_vec(),
979                                    HashSet::from_iter(values.iter().cloned()),
980                                );
981                                if !ldap_bin_mods.contains(&replace_mod) {
982                                    ldap_bin_mods.push(replace_mod)
983                                }
984                            } else {
985                                ldap_bin_mods.push(ldap3::Mod::Delete(
986                                    attr_name.as_bytes().to_vec(),
987                                    HashSet::new(),
988                                ));
989                            }
990                        }
991                    }
992                }
993            }
994            for attr_name in &change.bin_attrs.removed {
995                if ignore_attributes.contains(attr_name) {
996                    continue;
997                }
998                ldap_bin_mods.push(ldap3::Mod::Add(
999                    attr_name.as_bytes().to_vec(),
1000                    HashSet::from_iter(source_entry.bin_attrs[attr_name].iter().cloned()),
1001                ));
1002            }
1003            if update && !(ldap_mods.is_empty() && ldap_bin_mods.is_empty()) {
1004                ldap_operations.push(LDAPOperation::Modify {
1005                    dn: source_entry.dn.clone(),
1006                    mods: ldap_mods,
1007                    bin_mods: ldap_bin_mods,
1008                });
1009            }
1010        } else if delete {
1011            ldap_operations.push(LDAPOperation::Delete {
1012                dn: altered_dn.clone(),
1013            });
1014        }
1015    }
1016    for removed_dn in diff.removed {
1017        if add {
1018            let mut new_entry = source_entries[&removed_dn].clone();
1019            for ia in ignore_attributes {
1020                new_entry.attrs.remove(ia);
1021                new_entry.bin_attrs.remove(ia);
1022            }
1023            if let Some((k, v)) = new_entry.attrs.remove_entry("objectClass") {
1024                let ioc = &ignore_object_classes;
1025                let new_v = v.into_iter().filter(|x| !ioc.contains(x)).collect();
1026                new_entry.attrs.insert(k, new_v);
1027            }
1028            for (attr_name, attr_values) in new_entry.attrs.iter_mut() {
1029                let attr_type_syntax = source_ldap_schema
1030                    .find_attribute_type_property(attr_name, |at| at.syntax.as_ref());
1031                tracing::trace!(
1032                    "Attribute type syntax for attribute {} in deleted entry {}: {:#?}",
1033                    attr_name,
1034                    removed_dn,
1035                    attr_type_syntax
1036                );
1037                if let Some(syntax) = attr_type_syntax {
1038                    if DN_SYNTAX_OID.eq(syntax) {
1039                        tracing::trace!(
1040                            "Replacing base DN {} with base DN {}",
1041                            source_base_dn,
1042                            destination_base_dn
1043                        );
1044                        for s in attr_values.iter_mut() {
1045                            *s = s.replace(source_base_dn, destination_base_dn);
1046                        }
1047                    }
1048                }
1049            }
1050            ldap_operations.push(LDAPOperation::Add(new_entry));
1051        }
1052    }
1053
1054    ldap_operations.sort_by(|a, b| {
1055        a.operation_apply_cmp(b)
1056            .unwrap_or(std::cmp::Ordering::Equal)
1057    });
1058
1059    ldap_operations
1060}