Skip to main content

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    AttributeType, LDAPSchema, LDAPSyntax, MatchingRule, MatchingRuleUse, ObjectClass,
9    attribute_type_parser, ldap_syntax_parser, matching_rule_parser, matching_rule_use_parser,
10    object_class_parser,
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 = fs_err::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 = fs_err::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 = fs_err::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!(
290                        "Building of ConnectParameters based on OpenLDAP config files and substituting default values for missing values failed: {}",
291                        err
292                    );
293                    Err(ConnectError::ParametersBuilderError(err))
294                }
295            }
296        }
297    }
298}
299
300/// connect to an LDAP server using ldap3 with the given set of default parameters
301///
302/// # Errors
303///
304/// fails if reading or parsing of client certificates fails or if the
305/// actual connection attempt fails
306#[instrument]
307pub async fn connect_with_parameters(
308    connect_parameters: ConnectParameters,
309) -> Result<(Ldap, std::string::String), ConnectError> {
310    let mut client_cert_contents = Vec::new();
311    {
312        let mut file = File::open(connect_parameters.client_cert_path)?;
313        file.read_to_end(&mut client_cert_contents)?;
314    }
315    let client_cert = X509::from_pem(&client_cert_contents)?;
316    let mut client_key_contents = Vec::new();
317    {
318        let mut file = File::open(connect_parameters.client_key_path)?;
319        file.read_to_end(&mut client_key_contents)?;
320    }
321    let client_key = PKey::private_key_from_pem(&client_key_contents)?;
322    let p12_password = "client";
323    let p12 = Pkcs12::builder()
324        .name("client")
325        .pkey(&client_key)
326        .cert(&client_cert)
327        .build2(p12_password)?;
328    let p12_contents = p12.to_der()?;
329    let mut ca_cert_contents = Vec::new();
330    {
331        let mut file = File::open(connect_parameters.ca_cert_path)?;
332        file.read_to_end(&mut ca_cert_contents)?;
333    }
334    let identity = Identity::from_pkcs12(&p12_contents, p12_password)?;
335    let ca_certificate = Certificate::from_pem(&ca_cert_contents)?;
336    let connector = TlsConnector::builder()
337        .identity(identity)
338        .add_root_certificate(ca_certificate)
339        .build()?;
340    let ldap_settings = LdapConnSettings::new().set_connector(connector);
341    let (ldap_conn_async, mut ldap) =
342        LdapConnAsync::with_settings(ldap_settings, &connect_parameters.url.clone()).await?;
343    ldap3::drive!(ldap_conn_async);
344    ldap.sasl_external_bind().await?;
345    let (exop, _res) = ldap.extended(WhoAmI).await?.success()?;
346    let who_am_i: WhoAmIResp = exop.parse();
347    let re = Regex::new(r"^.*,ou=[a-z]+,")?;
348    let base_dn = re.replace_all(&who_am_i.authzid, "").to_string();
349    Ok((ldap, base_dn))
350}
351
352/// an error during normal ldap operations (search, add, modify, update, delete,...)
353#[derive(Debug, Error)]
354pub enum LdapOperationError {
355    /// an error in the ldap3 library
356    #[error("ldap3 Ldap error: {0}")]
357    LdapError(#[from] ldap3::LdapError),
358    /// and error parsing an OID
359    #[error("OID error: {0}")]
360    OIDError(#[from] OIDError),
361    /// An expected attribute was missing from the LDAP entry.
362    #[error("Missing expected attribute: {0}")]
363    MissingAttribute(String),
364}
365
366/// perform an LDAP search via ldap3, logging a proper error message if it fails
367/// and returning an iterator to already unwrapped search entries
368///
369/// # Errors
370///
371/// fails if the underlying ldap search operation fails or returns a non-success code
372pub async fn ldap_search<'a, S: AsRef<str> + Clone + Display + Debug + Send + Sync>(
373    ldap: &mut Ldap,
374    base: &str,
375    scope: Scope,
376    filter: &str,
377    attrs: Vec<S>,
378) -> Result<Box<dyn Iterator<Item = SearchEntry> + 'a>, LdapOperationError> {
379    let search_result = ldap.search(base, scope, filter, attrs.clone()).await?;
380    let SearchResult(_rs, res) = &search_result;
381    if res.rc != 0 {
382        tracing::debug!(
383            "Non-zero return code {} in LDAP query\n  base: {}\n  scope: {:?}\n  filter: {}\n  attrs: {:#?}",
384            res.rc,
385            base,
386            scope,
387            filter,
388            attrs
389        );
390        tracing::debug!(
391            "ldapsearch -Q -LLL -o ldif-wrap=no -b '{}' -s {} '{}' {}",
392            base,
393            format!("{scope:?}").to_lowercase(),
394            filter,
395            itertools::join(attrs.iter(), " ")
396        );
397    }
398    let (rs, _res) = search_result.success()?;
399    Ok(Box::new(rs.into_iter().map(SearchEntry::construct)))
400}
401
402/// an error type in case parsing an OID fails when querying the RootDSE from ldap3
403/// during the parsing of supported controls, extensions and features
404#[derive(Debug)]
405pub struct OIDError(oid::ObjectIdentifierError);
406
407impl Display for OIDError {
408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        write!(f, "Error parsing OID: {:?}", self.0)
410    }
411}
412
413impl std::error::Error for OIDError {
414    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
415        None
416    }
417}
418
419/// retrieve the [RootDSE] from an LDAP server using ldap3
420///
421/// # Errors
422///
423/// Returns `LdapOperationError` if the LDAP search fails, or if a required attribute
424/// is missing from the RootDSE entry, or if an OID cannot be parsed.
425#[instrument(skip(ldap))]
426pub async fn query_root_dse(ldap: &mut Ldap) -> Result<Option<RootDSE>, LdapOperationError> {
427    let mut it = ldap_search(
428        ldap,
429        "",
430        Scope::Base,
431        "(objectClass=*)",
432        vec![
433            "supportedLDAPVersion",
434            "supportedControl",
435            "supportedExtension",
436            "supportedFeatures",
437            "supportedSASLMechanisms",
438            "configContext",
439            "namingContexts",
440            "subschemaSubentry",
441        ],
442    )
443    .await?;
444    if let Some(entry) = it.next() {
445        let supported_ldap_version = entry
446            .attrs
447            .get("supportedLDAPVersion")
448            .ok_or(LdapOperationError::MissingAttribute(
449                "supportedLDAPVersion".to_string(),
450            ))?
451            .first()
452            .ok_or(LdapOperationError::MissingAttribute(
453                "supportedLDAPVersion".to_string(),
454            ))?;
455        let supported_controls =
456            entry
457                .attrs
458                .get("supportedControl")
459                .ok_or(LdapOperationError::MissingAttribute(
460                    "supportedControl".to_string(),
461                ))?;
462        let supported_extensions =
463            entry
464                .attrs
465                .get("supportedExtension")
466                .ok_or(LdapOperationError::MissingAttribute(
467                    "supportedExtension".to_string(),
468                ))?;
469        let supported_features =
470            entry
471                .attrs
472                .get("supportedFeatures")
473                .ok_or(LdapOperationError::MissingAttribute(
474                    "supportedFeatures".to_string(),
475                ))?;
476        let supported_sasl_mechanisms = entry.attrs.get("supportedSASLMechanisms").ok_or(
477            LdapOperationError::MissingAttribute("supportedSASLMechanisms".to_string()),
478        )?;
479        let config_context = entry
480            .attrs
481            .get("configContext")
482            .ok_or(LdapOperationError::MissingAttribute(
483                "configContext".to_string(),
484            ))?
485            .first()
486            .ok_or(LdapOperationError::MissingAttribute(
487                "configContext".to_string(),
488            ))?;
489        let naming_contexts =
490            entry
491                .attrs
492                .get("namingContexts")
493                .ok_or(LdapOperationError::MissingAttribute(
494                    "namingContexts".to_string(),
495                ))?;
496        let subschema_subentry = entry
497            .attrs
498            .get("subschemaSubentry")
499            .ok_or(LdapOperationError::MissingAttribute(
500                "subschemaSubentry".to_string(),
501            ))?
502            .first()
503            .ok_or(LdapOperationError::MissingAttribute(
504                "subschemaSubentry".to_string(),
505            ))?;
506        return Ok(Some(RootDSE {
507            supported_ldap_version: supported_ldap_version.to_string(),
508            supported_controls: supported_controls
509                .iter()
510                .map(|x| x.clone().try_into())
511                .collect::<Result<_, _>>()
512                .map_err(OIDError)?,
513            supported_extensions: supported_extensions
514                .iter()
515                .map(|x| x.clone().try_into())
516                .collect::<Result<_, _>>()
517                .map_err(OIDError)?,
518            supported_features: supported_features
519                .iter()
520                .map(|x| x.clone().try_into())
521                .collect::<Result<_, _>>()
522                .map_err(OIDError)?,
523            supported_sasl_mechanisms: supported_sasl_mechanisms.to_vec(),
524            config_context: config_context.to_string(),
525            naming_contexts: naming_contexts.to_vec(),
526            subschema_subentry: subschema_subentry.to_string(),
527        }));
528    }
529    Ok(None)
530}
531
532/// error which can happen while retrieving and parsing the LDAP schema
533#[derive(Debug, Error)]
534pub enum LdapSchemaError {
535    /// an error in the ldap operations performed while retrieving the schema
536    #[error("Ldap operation error: {0}")]
537    LdapOperationError(#[from] LdapOperationError),
538    /// an error while parsing the retrieved schema
539    #[error("chumsky parser error: {0}")]
540    ChumskyError(#[from] ChumskyError<chumsky::error::Rich<'static, char>>),
541}
542
543/// Retrieve the LDAP schema from an LDAP server using ldap3
544///
545/// tested with OpenLDAP
546///
547/// # Errors
548///
549/// fails if the underlying ldap query or the parsing of the results of that query fail
550#[instrument(skip(ldap))]
551pub async fn query_ldap_schema(ldap: &mut Ldap) -> Result<Option<LDAPSchema>, LdapSchemaError> {
552    if let Some(root_dse) = query_root_dse(ldap).await? {
553        let mut it = ldap_search(
554            ldap,
555            &root_dse.subschema_subentry,
556            Scope::Base,
557            "(objectClass=*)",
558            vec![
559                "ldapSyntaxes",
560                "matchingRules",
561                "matchingRuleUse",
562                "attributeTypes",
563                "objectClasses",
564            ],
565        )
566        .await?;
567
568        if let Some(entry) = it.next() {
569            let ldap_syntaxes = entry
570                .attrs
571                .get("ldapSyntaxes")
572                .ok_or(LdapOperationError::MissingAttribute(
573                    "ldapSyntaxes".to_string(),
574                ))?
575                .iter()
576                .map(|x| match ldap_syntax_parser().parse(x.as_str()).into_output_errors() {
577                    (Some(ldap_syntax), _) => Ok(ldap_syntax),
578                    (_, errs) => Err(ChumskyError {
579                        description: "ldap syntax".to_string(),
580                        source: x.to_string(),
581                        errors: errs.into_iter().map(|e| e.into_owned()).collect(),
582                    }),
583                })
584                .collect::<Result<Vec<LDAPSyntax>, ChumskyError<chumsky::error::Rich<'static, char>>>>()?;
585            let matching_rules = entry
586                .attrs
587                .get("matchingRules")
588                .ok_or(LdapOperationError::MissingAttribute(
589                    "matchingRules".to_string(),
590                ))?
591                .iter()
592                .map(
593                    |x| match matching_rule_parser().parse(x.as_str()).into_output_errors() {
594                        (Some(matching_rule), _) => Ok(matching_rule),
595                        (_, errs) => Err(ChumskyError {
596                            description: "matching rule".to_string(),
597                            source: x.to_string(),
598                            errors: errs.into_iter().map(|e| e.into_owned()).collect(),
599                        }),
600                    },
601                )
602                .collect::<Result<Vec<MatchingRule>, ChumskyError<chumsky::error::Rich<'static, char>>>>()?;
603            let matching_rule_use =
604                entry
605                    .attrs
606                    .get("matchingRuleUse")
607                    .ok_or(LdapOperationError::MissingAttribute(
608                        "matchingRuleUse".to_string(),
609                    ))?
610                    .iter()
611                    .map(|x| {
612                        match matching_rule_use_parser()
613                            .parse(x.as_str())
614                            .into_output_errors()
615                        {
616                            (Some(matching_rule_use), _) => Ok(matching_rule_use),
617                            (_, errs) => Err(ChumskyError {
618                                description: "matching rule use".to_string(),
619                                source: x.to_string(),
620                                errors: errs.into_iter().map(|e| e.into_owned()).collect(),
621                            }),
622                        }
623                    })
624                    .collect::<Result<
625                        Vec<MatchingRuleUse>,
626                        ChumskyError<chumsky::error::Rich<'static, char>>,
627                    >>()?;
628            let attribute_types = entry
629                .attrs
630                .get("attributeTypes")
631                .ok_or(LdapOperationError::MissingAttribute(
632                    "attributeTypes".to_string(),
633                ))?
634                .iter()
635                .map(
636                    |x| match attribute_type_parser().parse(x.as_str()).into_output_errors() {
637                        (Some(attribute_type), _) => Ok(attribute_type),
638                        (_, errs) => Err(ChumskyError {
639                            description: "attribute type".to_string(),
640                            source: x.to_string(),
641                            errors: errs.into_iter().map(|e| e.into_owned()).collect(),
642                        }),
643                    },
644                )
645                .collect::<Result<Vec<AttributeType>, ChumskyError<chumsky::error::Rich<'static, char>>>>()?;
646            let object_classes = entry
647                .attrs
648                .get("objectClasses")
649                .ok_or(LdapOperationError::MissingAttribute(
650                    "objectClasses".to_string(),
651                ))?
652                .iter()
653                .map(|x| match object_class_parser().parse(x.as_str()).into_output_errors() {
654                    (Some(object_class), _) => Ok(object_class),
655                    (_, errs) => Err(ChumskyError {
656                        description: "object class".to_string(),
657                        source: x.to_string(),
658                        errors: errs.into_iter().map(|e| e.into_owned()).collect(),
659                    }),
660                })
661                .collect::<Result<Vec<ObjectClass>, ChumskyError<chumsky::error::Rich<'static, char>>>>()?;
662            return Ok(Some(LDAPSchema {
663                ldap_syntaxes,
664                matching_rules,
665                matching_rule_use,
666                attribute_types,
667                object_classes,
668            }));
669        }
670    }
671    Ok(None)
672}
673
674/// check if an [ldap3::LdapResult] is either a success or the success code returned by an operation using the [noop_control]
675///
676/// # Errors
677///
678/// fails if the LDAP result code is neither of the success codes
679pub fn success_or_noop_success(
680    ldap_result: ldap3::LdapResult,
681) -> ldap3::result::Result<ldap3::LdapResult> {
682    // 16654 is success in the presence of the noop control https://ldap.com/ldap-result-code-reference-other-server-side-result-codes/#rc-noOperation
683    if ldap_result.rc == 0 || ldap_result.rc == 16654 {
684        Ok(ldap_result)
685    } else {
686        Err(ldap3::LdapError::from(ldap_result))
687    }
688}
689
690/// delete an LDAP entry recursively using ldap3
691///
692/// # Errors
693///
694/// fails if the underlying individual delete operations fail
695#[instrument(skip(ldap))]
696pub async fn delete_recursive(
697    ldap: &mut Ldap,
698    dn: &str,
699    controls: Vec<ldap3::controls::RawControl>,
700) -> Result<(), LdapOperationError> {
701    tracing::debug!("Deleting {} recursively", dn);
702    let it = ldap_search(
703        ldap,
704        dn,
705        Scope::Subtree,
706        "(objectClass=*)",
707        Vec::<String>::new(),
708    )
709    .await?;
710    let mut entries = vec![];
711    for entry in it {
712        tracing::debug!("Found child entry to delete {}", entry.dn);
713        entries.push(entry.dn);
714    }
715    entries.sort_by_key(|b| std::cmp::Reverse(b.len()));
716    for dn in entries {
717        tracing::debug!("Deleting child entry {}", dn);
718        success_or_noop_success(ldap.with_controls(controls.to_owned()).delete(&dn).await?)?;
719    }
720    Ok(())
721}
722
723/// of the same modify operation because otherwise we might successfully apply the textual modifications
724/// and then fail on the binary ones, leaving behind a half-modified object
725pub fn mods_as_bin_mods<'a, T>(mods: T) -> Vec<ldap3::Mod<Vec<u8>>>
726where
727    T: IntoIterator<Item = &'a ldap3::Mod<String>>,
728{
729    let mut result: Vec<ldap3::Mod<Vec<u8>>> = vec![];
730    for m in mods {
731        match m {
732            ldap3::Mod::Add(k, v) => {
733                result.push(ldap3::Mod::Add(
734                    k.as_bytes().to_vec(),
735                    v.iter().map(|s| s.as_bytes().to_vec()).collect(),
736                ));
737            }
738            ldap3::Mod::Delete(k, v) => {
739                result.push(ldap3::Mod::Delete(
740                    k.as_bytes().to_vec(),
741                    v.iter().map(|s| s.as_bytes().to_vec()).collect(),
742                ));
743            }
744            ldap3::Mod::Replace(k, v) => {
745                result.push(ldap3::Mod::Replace(
746                    k.as_bytes().to_vec(),
747                    v.iter().map(|s| s.as_bytes().to_vec()).collect(),
748                ));
749            }
750            ldap3::Mod::Increment(k, v) => {
751                result.push(ldap3::Mod::Increment(
752                    k.as_bytes().to_vec(),
753                    v.as_bytes().to_vec(),
754                ));
755            }
756        }
757    }
758    result
759}
760
761/// apply the LDAP operations on a given LDAP server.
762///
763/// The operations should not include the Base-DN in its internally stored DNs
764/// It will be added automatically. This allows for easier generation of comparisons
765/// between objects on two different LDAP servers with different base DNs.
766///
767/// # Errors
768///
769/// fails if the underlying individual ldap operations fail
770#[instrument(skip(ldap, ldap_operations))]
771pub async fn apply_ldap_operations(
772    ldap: &mut Ldap,
773    ldap_base_dn: &str,
774    ldap_operations: &[LDAPOperation],
775    controls: Vec<ldap3::controls::RawControl>,
776) -> Result<(), LdapOperationError> {
777    tracing::debug!(
778        "The following operations use the LDAP controls: {:#?}",
779        controls
780    );
781    for op in ldap_operations {
782        match op {
783            LDAPOperation::Add(LDAPEntry {
784                dn,
785                attrs,
786                bin_attrs,
787            }) => {
788                let full_dn = format!("{dn},{ldap_base_dn}");
789                tracing::debug!(
790                    "Adding LDAP entry at {} with attributes\n{:#?}\nand binary attributes\n{:#?}",
791                    &full_dn,
792                    attrs,
793                    bin_attrs
794                );
795                // we need to perform the add in one operation or we will run into problems with
796                // objectclass requirements
797                let mut combined_attrs: Vec<(Vec<u8>, HashSet<Vec<u8>>)> = bin_attrs
798                    .iter()
799                    .map(|(k, v)| {
800                        (
801                            k.to_owned().as_bytes().to_vec(),
802                            v.iter().map(|s| s.to_owned()).collect::<HashSet<Vec<u8>>>(),
803                        )
804                    })
805                    .collect();
806                combined_attrs.extend(attrs.iter().map(|(k, v)| {
807                    (
808                        k.to_owned().as_bytes().to_vec(),
809                        v.iter()
810                            .map(|s| s.as_bytes().to_vec())
811                            .collect::<HashSet<Vec<u8>>>(),
812                    )
813                }));
814                ldap.with_controls(controls.to_owned())
815                    .add(&full_dn, combined_attrs)
816                    .await?
817                    .success()?;
818            }
819            LDAPOperation::Delete { dn } => {
820                let full_dn = format!("{dn},{ldap_base_dn}");
821                tracing::debug!("Deleting LDAP entry at {}", &full_dn);
822                delete_recursive(ldap, &full_dn, controls.to_owned()).await?;
823            }
824            LDAPOperation::Modify { dn, mods, bin_mods } => {
825                let full_dn = format!("{dn},{ldap_base_dn}");
826                tracing::debug!(
827                    "Modifying LDAP entry at {} with modifications\n{:#?}\nand binary modifications\n{:#?}",
828                    &full_dn,
829                    mods,
830                    bin_mods
831                );
832                let mut combined_mods = bin_mods.to_owned();
833                combined_mods.extend(mods_as_bin_mods(mods));
834                ldap.with_controls(controls.to_owned())
835                    .modify(&full_dn, combined_mods.to_vec())
836                    .await?
837                    .success()?;
838            }
839        }
840    }
841
842    Ok(())
843}
844
845/// helper function to search an LDAP server and generate [LDAPEntry] values
846/// with the base DN removed to make them server-independent
847///
848/// # Errors
849///
850/// fails if the underlying ldap_search fails
851#[instrument(skip(ldap, entries))]
852pub async fn search_entries(
853    ldap: &mut Ldap,
854    base_dn: &str,
855    search_base: &str,
856    scope: ldap3::Scope,
857    filter: &str,
858    attrs: &[String],
859    entries: &mut HashMap<String, LDAPEntry>,
860) -> Result<(), LdapOperationError> {
861    let it = ldap_search(
862        ldap,
863        &format!("{search_base},{base_dn}"),
864        scope,
865        filter,
866        attrs.to_owned(),
867    )
868    .await?;
869    for entry in it {
870        tracing::debug!("Found entry {}", entry.dn);
871        if let Some(s) = entry.dn.strip_suffix(&format!(",{}", &base_dn)) {
872            entries.insert(
873                s.to_string(),
874                LDAPEntry {
875                    dn: s.to_string(),
876                    attrs: entry.attrs,
877                    bin_attrs: entry.bin_attrs,
878                },
879            );
880        } else {
881            tracing::error!(
882                "Failed to remove base dn {} from entry DN {}",
883                base_dn,
884                entry.dn
885            );
886        }
887    }
888    Ok(())
889}
890
891/// generate an [ldap3::Mod] if there is a DN-valued attribute in the source
892/// entry that needs its base DN translated to the destination base DN
893///
894/// # Errors
895///
896/// Returns `LdapOperationError` if there is an issue parsing OIDs or if a required attribute is missing.
897#[instrument(skip(
898    source_entry,
899    source_ldap_schema,
900    source_base_dn,
901    destination_entry,
902    destination_base_dn,
903    ignore_object_classes,
904))]
905pub fn mod_value(
906    attr_name: &str,
907    source_entry: &LDAPEntry,
908    source_ldap_schema: &LDAPSchema,
909    source_base_dn: &str,
910    destination_entry: Option<&LDAPEntry>,
911    destination_base_dn: &str,
912    ignore_object_classes: &[String],
913) -> Result<Option<ldap3::Mod<String>>, LdapOperationError> {
914    if let Some(values) = source_entry.attrs.get(attr_name) {
915        let mut replacement_values = HashSet::from_iter(values.iter().cloned());
916        if attr_name == "objectClass" {
917            for io in ignore_object_classes {
918                replacement_values.remove(io);
919            }
920        }
921        let attr_type_syntax =
922            source_ldap_schema.find_attribute_type_property(attr_name, |at| at.syntax.as_ref());
923        tracing::trace!(
924            "Attribute type syntax for altered attribute {}: {:#?}",
925            attr_name,
926            attr_type_syntax
927        );
928        if let Some(syntax) = attr_type_syntax
929            && (*DN_SYNTAX_OID)
930                .as_ref()
931                .map_err(|e| OIDError(*e))?
932                .eq(syntax)
933        {
934            tracing::trace!(
935                "Replacing base DN {} with base DN {}",
936                source_base_dn,
937                destination_base_dn
938            );
939            replacement_values = replacement_values
940                .into_iter()
941                .map(|s| s.replace(source_base_dn, destination_base_dn))
942                .collect();
943        }
944        if let Some(destination_entry) = destination_entry
945            && let Some(destination_values) = destination_entry.attrs.get(attr_name)
946        {
947            let mut replacement_values_sorted: Vec<String> =
948                replacement_values.iter().cloned().collect();
949            replacement_values_sorted.sort();
950            let mut destination_values: Vec<String> = destination_values.to_vec();
951            destination_values.sort();
952            tracing::trace!(
953                "Checking if replacement values and destination values are identical (case sensitive):\n{:#?}\n{:#?}",
954                destination_values,
955                replacement_values_sorted
956            );
957            if replacement_values_sorted == destination_values {
958                tracing::trace!(
959                    "Skipping attribute {} because replacement values and destination values are identical (case sensitive)",
960                    attr_name
961                );
962                return Ok(None);
963            }
964            let attr_type_equality = source_ldap_schema
965                .find_attribute_type_property(attr_name, |at| at.equality.as_ref());
966            tracing::trace!(
967                "Attribute type equality for altered attribute {}: {:#?}",
968                attr_name,
969                attr_type_equality
970            );
971            if let Some(equality) = &attr_type_equality
972                && equality.describes_case_insensitive_match()
973            {
974                let mut lower_destination_values: Vec<String> = destination_values
975                    .iter()
976                    .map(|s| s.to_lowercase())
977                    .collect();
978                lower_destination_values.sort();
979                let mut lower_replacement_values: Vec<String> = replacement_values
980                    .iter()
981                    .map(|s| s.to_lowercase())
982                    .collect();
983                lower_replacement_values.sort();
984                tracing::trace!(
985                    "Checking if replacement values and destination values are identical (case insensitive):\n{:#?}\n{:#?}",
986                    lower_destination_values,
987                    lower_replacement_values
988                );
989                if lower_destination_values == lower_replacement_values {
990                    tracing::trace!(
991                        "Skipping attribute {} because replacement values and destination values are identical (case insensitive)",
992                        attr_name
993                    );
994                    return Ok(None);
995                }
996            }
997        }
998        Ok(Some(ldap3::Mod::Replace(
999            attr_name.to_string(),
1000            replacement_values,
1001        )))
1002    } else {
1003        Ok(Some(ldap3::Mod::Delete(
1004            attr_name.to_string(),
1005            HashSet::new(),
1006        )))
1007    }
1008}
1009
1010/// diff two sets of LDAPEntries which had their base DNs removed
1011/// and generates LDAP operations (add, update, delete) to apply to
1012/// the destination to make it identical to the source
1013///
1014/// # Errors
1015///
1016/// Returns `LdapOperationError` if there is an issue parsing OIDs or if a required attribute is missing.
1017#[expect(
1018    clippy::too_many_arguments,
1019    reason = "factoring parameters into objects is not sensible since this is basically standalone without many connections to the rest of the crate"
1020)]
1021#[instrument(skip(source_ldap_schema))]
1022pub fn diff_entries(
1023    source_entries: &HashMap<String, LDAPEntry>,
1024    destination_entries: &HashMap<String, LDAPEntry>,
1025    source_base_dn: &str,
1026    destination_base_dn: &str,
1027    ignore_object_classes: &[String],
1028    ignore_attributes: &[String],
1029    source_ldap_schema: &LDAPSchema,
1030    add: bool,
1031    update: bool,
1032    delete: bool,
1033) -> Result<Vec<LDAPOperation>, LdapOperationError> {
1034    let diff = Diff::diff(source_entries, destination_entries);
1035    tracing::trace!("Diff:\n{:#?}", diff);
1036    let mut ldap_operations: Vec<LDAPOperation> = vec![];
1037    for (altered_dn, change) in diff.altered {
1038        tracing::trace!("Processing altered DN {}", altered_dn);
1039        let source_entry: Option<&LDAPEntry> = source_entries.get(&altered_dn);
1040        let destination_entry: Option<&LDAPEntry> = destination_entries.get(&altered_dn);
1041        if let Some(source_entry) = source_entry {
1042            let mut ldap_mods: Vec<ldap3::Mod<String>> = vec![];
1043            let mut ldap_bin_mods: Vec<ldap3::Mod<Vec<u8>>> = vec![];
1044            for (attr_name, attr_value_changes) in &change.attrs.altered {
1045                if ignore_attributes.contains(attr_name) {
1046                    continue;
1047                }
1048                for attr_value_change in &attr_value_changes.0 {
1049                    match attr_value_change {
1050                        VecDiffType::Removed { .. }
1051                        | VecDiffType::Inserted { .. }
1052                        | VecDiffType::Altered { .. } => {
1053                            let m = mod_value(
1054                                attr_name,
1055                                source_entry,
1056                                source_ldap_schema,
1057                                source_base_dn,
1058                                destination_entry,
1059                                destination_base_dn,
1060                                ignore_object_classes,
1061                            )?;
1062                            if let Some(m) = m
1063                                && !ldap_mods.contains(&m)
1064                            {
1065                                ldap_mods.push(m);
1066                            }
1067                        }
1068                    }
1069                }
1070            }
1071            for attr_name in &change.attrs.removed {
1072                if ignore_attributes.contains(attr_name) {
1073                    continue;
1074                }
1075                let mut replacement_values = HashSet::from_iter(
1076                    source_entry
1077                        .attrs
1078                        .get(attr_name)
1079                        .ok_or(LdapOperationError::MissingAttribute(attr_name.clone()))?
1080                        .iter()
1081                        .cloned(),
1082                );
1083                if attr_name == "objectClass" {
1084                    for io in ignore_object_classes {
1085                        replacement_values.remove(io);
1086                    }
1087                }
1088                let attr_type_syntax = source_ldap_schema
1089                    .find_attribute_type_property(attr_name, |at| at.syntax.as_ref());
1090                tracing::trace!(
1091                    "Attribute type syntax for deleted attribute {}: {:#?}",
1092                    attr_name,
1093                    attr_type_syntax
1094                );
1095                if let Some(syntax) = attr_type_syntax
1096                    && (*DN_SYNTAX_OID)
1097                        .as_ref()
1098                        .map_err(|e| OIDError(*e))?
1099                        .eq(syntax)
1100                {
1101                    tracing::trace!(
1102                        "Replacing base DN {} with base DN {}",
1103                        source_base_dn,
1104                        destination_base_dn
1105                    );
1106                    replacement_values = replacement_values
1107                        .into_iter()
1108                        .map(|s| s.replace(source_base_dn, destination_base_dn))
1109                        .collect();
1110                }
1111                ldap_mods.push(ldap3::Mod::Add(attr_name.to_string(), replacement_values));
1112            }
1113            for (attr_name, attr_value_changes) in &change.bin_attrs.altered {
1114                if ignore_attributes.contains(attr_name) {
1115                    continue;
1116                }
1117                for attr_value_change in &attr_value_changes.0 {
1118                    match attr_value_change {
1119                        VecDiffType::Removed { .. }
1120                        | VecDiffType::Inserted { .. }
1121                        | VecDiffType::Altered { .. } => {
1122                            if let Some(values) = source_entry.bin_attrs.get(attr_name) {
1123                                let replace_mod = ldap3::Mod::Replace(
1124                                    attr_name.as_bytes().to_vec(),
1125                                    HashSet::from_iter(values.iter().cloned()),
1126                                );
1127                                if !ldap_bin_mods.contains(&replace_mod) {
1128                                    ldap_bin_mods.push(replace_mod);
1129                                }
1130                            } else {
1131                                ldap_bin_mods.push(ldap3::Mod::Delete(
1132                                    attr_name.as_bytes().to_vec(),
1133                                    HashSet::new(),
1134                                ));
1135                            }
1136                        }
1137                    }
1138                }
1139            }
1140            for attr_name in &change.bin_attrs.removed {
1141                if ignore_attributes.contains(attr_name) {
1142                    continue;
1143                }
1144                ldap_bin_mods.push(ldap3::Mod::Add(
1145                    attr_name.as_bytes().to_vec(),
1146                    HashSet::from_iter(
1147                        source_entry
1148                            .bin_attrs
1149                            .get(attr_name)
1150                            .ok_or(LdapOperationError::MissingAttribute(attr_name.clone()))?
1151                            .iter()
1152                            .cloned(),
1153                    ),
1154                ));
1155            }
1156            if update && !(ldap_mods.is_empty() && ldap_bin_mods.is_empty()) {
1157                ldap_operations.push(LDAPOperation::Modify {
1158                    dn: source_entry.dn.clone(),
1159                    mods: ldap_mods,
1160                    bin_mods: ldap_bin_mods,
1161                });
1162            }
1163        } else if delete {
1164            ldap_operations.push(LDAPOperation::Delete {
1165                dn: altered_dn.clone(),
1166            });
1167        }
1168    }
1169    for removed_dn in diff.removed {
1170        if add {
1171            let mut new_entry = source_entries[&removed_dn].clone();
1172            for ia in ignore_attributes {
1173                new_entry.attrs.remove(ia);
1174                new_entry.bin_attrs.remove(ia);
1175            }
1176            if let Some((k, v)) = new_entry.attrs.remove_entry("objectClass") {
1177                let ioc = &ignore_object_classes;
1178                let new_v = v.into_iter().filter(|x| !ioc.contains(x)).collect();
1179                new_entry.attrs.insert(k, new_v);
1180            }
1181            for (attr_name, attr_values) in &mut new_entry.attrs {
1182                let attr_type_syntax = source_ldap_schema
1183                    .find_attribute_type_property(attr_name, |at| at.syntax.as_ref());
1184                tracing::trace!(
1185                    "Attribute type syntax for attribute {} in deleted entry {}: {:#?}",
1186                    attr_name,
1187                    removed_dn,
1188                    attr_type_syntax
1189                );
1190                if let Some(syntax) = attr_type_syntax
1191                    && (*DN_SYNTAX_OID)
1192                        .as_ref()
1193                        .map_err(|e| OIDError(*e))?
1194                        .eq(syntax)
1195                {
1196                    tracing::trace!(
1197                        "Replacing base DN {} with base DN {}",
1198                        source_base_dn,
1199                        destination_base_dn
1200                    );
1201                    for s in attr_values.iter_mut() {
1202                        *s = s.replace(source_base_dn, destination_base_dn);
1203                    }
1204                }
1205            }
1206            ldap_operations.push(LDAPOperation::Add(new_entry));
1207        }
1208    }
1209
1210    ldap_operations.sort_by(|a, b| {
1211        a.operation_apply_cmp(b)
1212            .unwrap_or(std::cmp::Ordering::Equal)
1213    });
1214
1215    Ok(ldap_operations)
1216}