ldap_utils/
lib.rs

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