Skip to main content

bb8_ldap/
lib.rs

1#![deny(missing_docs, missing_debug_implementations)]
2
3//! A [`bb8`] connection manager for [`ldap3`] LDAP connections.
4//!
5//! This crate provides [`LdapConnectionManager`], which implements [`bb8::ManageConnection`]
6//! to pool and reuse asynchronous LDAP connections. The manager handles connection creation,
7//! optional bind credentials, and health-check validation via lightweight LDAP searches.
8//!
9//! Both `bb8` and `ldap3` are re-exported for convenience, so you can use them directly
10//! without adding separate dependencies.
11//!
12//! # Example
13//!
14//! ```no_run
15//! use bb8::Pool;
16//! use bb8_ldap::LdapConnectionManager;
17//! use ldap3::LdapConnSettings;
18//!
19//! #[tokio::main]
20//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
21//!     let manager = LdapConnectionManager::new("ldap://localhost:1389")?
22//!         .with_connection_settings(LdapConnSettings::new().set_starttls(false))
23//!         .with_bind_credentials("cn=admin,dc=example,dc=org", "adminpassword")
24//!         .with_connect_timeout(std::time::Duration::from_secs(3))
25//!         .with_validation_timeout(std::time::Duration::from_secs(2));
26//!
27//!     let pool = Pool::builder().max_size(15).build(manager).await?;
28//!
29//!     let mut conn = pool.get().await?;
30//!     let (results, _res) = conn
31//!         .search("ou=users,dc=example,dc=org", ldap3::Scope::Subtree, "(cn=alice)", vec!["cn"])
32//!         .await?
33//!         .success()?;
34//!
35//!     println!("Found {} entries", results.len());
36//!     Ok(())
37//! }
38//! ```
39//!
40//! # Feature Flags
41//!
42//! | Feature | Description |
43//! |---------|-------------|
44//! | `tls-rustls-aws-lc-rs` | *(default)* Enable rustls with the aws-lc-rs crypto provider |
45//! | `tls-rustls-ring` | Enable rustls with the ring crypto provider |
46//! | `tls-native` | Enable native TLS support (use with `--no-default-features`) |
47//!
48//! Example using native TLS:
49//!
50//! ```toml
51//! [dependencies]
52//! bb8-ldap = { version = "*", default-features = false, features = ["tls-native"] }
53//! ```
54//!
55//! # Supported URL Schemes
56//!
57//! This crate supports the following URL schemes:
58//!
59//! - `ldap://` — Standard LDAP over TCP (optionally upgraded with StartTLS)
60//! - `ldapi://` — LDAP over Unix domain sockets
61//!
62//! Note: `ldaps://` (LDAP over implicit TLS) is **not** supported. To use TLS,
63//! connect via `ldap://` and enable StartTLS with [`LdapConnSettings::set_starttls(true)`](ldap3::LdapConnSettings::set_starttls).
64//!
65//! # Connection Lifecycle
66//!
67//! Each connection is established using [`ldap3::LdapConnAsync`], which returns a
68//! connection driver and an `Ldap` handle. The driver is spawned as a background
69//! task via [`ldap3::drive!()`](ldap3::drive), and the `Ldap` handle is what gets
70//! pooled and returned to callers. All LDAP operations go through this handle while
71//! the background task manages the underlying protocol I/O.
72
73/// Re-export the `bb8` crate for convenience.
74pub use bb8;
75/// Re-export the `ldap3` crate for convenience.
76pub use ldap3;
77
78use ldap3::{LdapConnAsync, LdapConnSettings, Scope};
79use std::fmt;
80use std::time::Duration;
81use url::Url;
82
83/// A `bb8::ManageConnection` implementation for `ldap3` async connections.
84#[derive(Clone)]
85pub struct LdapConnectionManager {
86    url: String,
87    settings: LdapConnSettings,
88    bind_dn: Option<String>,
89    bind_password: Option<String>,
90    connect_timeout: Option<Duration>,
91    validation_timeout: Duration,
92    validation_base_dn: String,
93    validation_filter: String,
94    validation_scope: Scope,
95    validation_attributes: Vec<String>,
96}
97
98impl fmt::Debug for LdapConnectionManager {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.debug_struct("LdapConnectionManager")
101            .field("url", &self.url)
102            .finish()
103    }
104}
105
106impl LdapConnectionManager {
107    /// Creates a new `LdapConnectionManager` with the given LDAP URL.
108    ///
109    /// The URL is parsed and validated. Valid schemes are `ldap` and `ldapi`.
110    ///
111    /// Note: `ldaps://` is not accepted. To use TLS, pass an `ldap://` URL and configure
112    /// StartTLS via [`LdapConnSettings::set_starttls(true)`](ldap3::LdapConnSettings::set_starttls).
113    ///
114    /// # Errors
115    ///
116    /// Returns [`LdapError::UrlParsing`](ldap3::LdapError::UrlParsing) if the URL is malformed.
117    /// Returns [`LdapError::UnknownScheme`](ldap3::LdapError::UnknownScheme) if the scheme is not `ldap` or `ldapi`.
118    pub fn new<S: Into<String>>(ldap_url: S) -> Result<Self, ldap3::LdapError> {
119        let url = ldap_url.into();
120        let parsed = Url::parse(&url).map_err(ldap3::LdapError::from)?;
121
122        match parsed.scheme() {
123            "ldap" | "ldapi" => Ok(LdapConnectionManager {
124                url,
125                settings: LdapConnSettings::new(),
126                bind_dn: None,
127                bind_password: None,
128                connect_timeout: None,
129                validation_timeout: Duration::from_secs(1),
130                validation_base_dn: String::new(),
131                validation_filter: "(objectClass=*)".to_string(),
132                validation_scope: Scope::Base,
133                validation_attributes: vec!["1.1".to_string()],
134            }),
135            _ => Err(ldap3::LdapError::UnknownScheme(
136                parsed.scheme().to_string(),
137            )),
138        }
139    }
140
141    /// Update the LDAP connection settings for this manager.
142    pub fn with_connection_settings(mut self, settings: LdapConnSettings) -> Self {
143        self.settings = settings;
144        self
145    }
146
147    /// Configures a simple bind to be performed when new connections are created.
148    ///
149    /// The `bind_dn` is the distinguished name to bind as (e.g., `"cn=admin,dc=example,dc=org"`).
150    /// The `bind_password` is the password for the bind DN.
151    ///
152    /// The bind is performed immediately after each connection is established,
153    /// before the connection is returned from the pool.
154    pub fn with_bind_credentials<S: Into<String>>(mut self, bind_dn: S, bind_password: S) -> Self {
155        self.bind_dn = Some(bind_dn.into());
156        self.bind_password = Some(bind_password.into());
157        self
158    }
159
160    /// Overrides the timeout used by `is_valid` health checks.
161    ///
162    /// The `timeout` specifies the maximum duration to wait for the validation search.
163    /// The default is 1 second.
164    pub fn with_validation_timeout(mut self, timeout: Duration) -> Self {
165        self.validation_timeout = timeout;
166        self
167    }
168
169    /// Overrides the validation search performed by `is_valid`.
170    ///
171    /// - `base_dn`: The base DN for the search. Default: `""` (root DSE).
172    /// - `scope`: The search scope. Default: [`Scope::Base`].
173    /// - `filter`: The search filter. Default: `"(objectClass=*)"`.
174    /// - `attributes`: The attributes to return. Default: `["1.1"]` (no attributes).
175    pub fn with_validation_search<S: Into<String>>(
176        mut self,
177        base_dn: S,
178        scope: Scope,
179        filter: S,
180        attributes: Vec<S>,
181    ) -> Self {
182        self.validation_base_dn = base_dn.into();
183        self.validation_scope = scope;
184        self.validation_filter = filter.into();
185        self.validation_attributes = attributes.into_iter().map(Into::into).collect();
186        self
187    }
188
189    /// Sets a timeout applied when establishing new connections.
190    ///
191    /// This timeout is applied during `connect()` on a clone of the configured
192    /// `LdapConnSettings`, and overrides any timeout previously set on those settings.
193    pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
194        self.connect_timeout = Some(timeout);
195        self
196    }
197}
198
199/// Implements [`bb8::ManageConnection`] to provide connection pooling for `ldap3::Ldap` connections.
200///
201/// - `connect()` establishes a new LDAP connection and optionally performs a simple bind if credentials are configured.
202/// - `is_valid()` validates a connection by performing a lightweight LDAP search with the configured timeout.
203/// - `has_broken()` returns `true` if the underlying channel is closed (though this doesn't guarantee full bidirectional health).
204impl bb8::ManageConnection for LdapConnectionManager {
205    type Connection = ldap3::Ldap;
206    type Error = ldap3::LdapError;
207
208    async fn connect(&self) -> Result<Self::Connection, Self::Error> {
209        let settings = match self.connect_timeout {
210            Some(timeout) => self.settings.clone().set_conn_timeout(timeout),
211            None => self.settings.clone(),
212        };
213        let (conn, ldap) = LdapConnAsync::with_settings(settings, &self.url).await?;
214
215        ldap3::drive!(conn);
216        let mut ldap = ldap;
217        if let (Some(bind_dn), Some(bind_password)) = (&self.bind_dn, &self.bind_password) {
218            ldap.simple_bind(bind_dn, bind_password).await?.success()?;
219        }
220        Ok(ldap)
221    }
222
223    async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
224        // Touch the root DSE with a lightweight base-scope search that is commonly allowed even for anonymous binds.
225        conn.with_timeout(self.validation_timeout)
226            .search(
227                &self.validation_base_dn,
228                self.validation_scope,
229                &self.validation_filter,
230                self.validation_attributes.clone(),
231            )
232            .await?
233            .success()?;
234        Ok(())
235    }
236
237    fn has_broken(&self, conn: &mut Self::Connection) -> bool {
238        // Check whether the transmit channel is open. This doesn't mean that the bidirectional
239        // communication is possible however
240        conn.is_closed()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use bb8::ManageConnection;
248    use testcontainers::{
249        core::{client::ClientError, error::TestcontainersError},
250        runners::AsyncRunner,
251    };
252    use testcontainers_modules::openldap::OpenLDAP;
253
254    #[test]
255    fn new_sets_default_settings() {
256        let manager = LdapConnectionManager::new("ldap://example.com").unwrap();
257        assert_eq!(manager.url, "ldap://example.com");
258        assert!(
259            !manager.settings.starttls(),
260            "starttls should be disabled by default"
261        );
262    }
263
264    #[test]
265    fn with_connection_settings_overrides_settings() {
266        let manager = LdapConnectionManager::new("ldap://example.com").unwrap();
267        assert!(
268            !manager.settings.starttls(),
269            "control: default settings keep starttls disabled"
270        );
271
272        let updated_settings = LdapConnSettings::new().set_starttls(true);
273        let updated_manager = manager.clone().with_connection_settings(updated_settings);
274
275        assert_eq!(updated_manager.url, manager.url);
276        assert!(
277            updated_manager.settings.starttls(),
278            "starttls should be enabled after overriding settings"
279        );
280    }
281
282    #[test]
283    fn with_connection_settings_leaves_original_untouched() {
284        let manager = LdapConnectionManager::new("ldap://example.com").unwrap();
285        let updated_manager = manager
286            .clone()
287            .with_connection_settings(LdapConnSettings::new().set_starttls(true));
288
289        assert!(
290            !manager.settings.starttls(),
291            "original manager should keep default settings"
292        );
293        assert!(
294            updated_manager.settings.starttls(),
295            "updated manager should reflect overrides"
296        );
297    }
298
299    #[test]
300    fn clone_preserves_custom_settings() {
301        let manager = LdapConnectionManager::new("ldap://example.com")
302            .unwrap()
303            .with_connection_settings(LdapConnSettings::new().set_starttls(true));
304
305        let cloned = manager.clone();
306
307        assert_eq!(cloned.url, manager.url);
308        assert!(
309            cloned.settings.starttls(),
310            "clone should maintain customized settings"
311        );
312    }
313
314    #[test]
315    fn new_accepts_owned_strings() {
316        let url = "ldap://example.com".to_string();
317        let manager = LdapConnectionManager::new(url).unwrap();
318        assert_eq!(manager.url, "ldap://example.com");
319    }
320
321    #[test]
322    fn with_bind_credentials_sets_values() {
323        let manager = LdapConnectionManager::new("ldap://example.com")
324            .unwrap()
325            .with_bind_credentials("cn=admin", "secret");
326
327        assert_eq!(manager.bind_dn.as_deref(), Some("cn=admin"));
328        assert_eq!(manager.bind_password.as_deref(), Some("secret"));
329    }
330
331    #[test]
332    fn with_validation_timeout_updates_value() {
333        let manager = LdapConnectionManager::new("ldap://example.com")
334            .unwrap()
335            .with_validation_timeout(Duration::from_secs(10));
336
337        assert_eq!(manager.validation_timeout, Duration::from_secs(10));
338    }
339
340    #[test]
341    fn with_validation_search_updates_values() {
342        let manager = LdapConnectionManager::new("ldap://example.com")
343            .unwrap()
344            .with_validation_search(
345                "dc=example,dc=org",
346                Scope::Subtree,
347                "(cn=alice)",
348                vec!["cn", "mail"],
349            );
350
351        assert_eq!(manager.validation_base_dn, "dc=example,dc=org");
352        assert_eq!(manager.validation_scope, Scope::Subtree);
353        assert_eq!(manager.validation_filter, "(cn=alice)");
354        assert_eq!(manager.validation_attributes, vec!["cn", "mail"]);
355    }
356
357    #[test]
358    fn with_connect_timeout_updates_settings() {
359        let manager = LdapConnectionManager::new("ldap://example.com")
360            .unwrap()
361            .with_connect_timeout(Duration::from_secs(5));
362
363        assert_eq!(manager.connect_timeout, Some(Duration::from_secs(5)));
364    }
365
366    #[test]
367    fn new_validates_urls() {
368        let manager =
369            LdapConnectionManager::new("ldap://example.com").expect("valid ldap URL should parse");
370        assert_eq!(manager.url, "ldap://example.com");
371
372        let err =
373            LdapConnectionManager::new("not a url").expect_err("invalid URLs should be rejected");
374        match err {
375            ldap3::LdapError::UrlParsing { .. } => {}
376            other => panic!("unexpected error: {other:?}"),
377        }
378
379        let err = LdapConnectionManager::new("http://example.com")
380            .expect_err("unsupported schemes should be rejected");
381        match err {
382            ldap3::LdapError::UnknownScheme(_) => {}
383            other => panic!("unexpected error: {other:?}"),
384        }
385    }
386
387    #[tokio::test]
388    async fn connection_pool() -> anyhow::Result<()> {
389        let node = match OpenLDAP::default()
390            .with_user("test_user", "test_password")
391            .start()
392            .await
393        {
394            Ok(node) => node,
395            Err(err @ TestcontainersError::Client(ClientError::PullImage { .. }))
396            | Err(err @ TestcontainersError::Client(ClientError::Init(_))) => {
397                eprintln!("skipping connection_pool test: {err}");
398                return Ok(());
399            }
400            Err(err) => return Err(err.into()),
401        };
402
403        let url = format!("ldap://127.0.0.1:{}", node.get_host_port_ipv4(1389).await?);
404        let conn_mgr = LdapConnectionManager::new(url)?
405            .with_bind_credentials("cn=admin,dc=example,dc=org", "adminpassword");
406
407        let mut conn = conn_mgr.connect().await?;
408
409        let search_res = conn
410            .search(
411                "ou=users,dc=example,dc=org",
412                ldap3::Scope::Subtree,
413                "(cn=*)",
414                vec!["cn"],
415            )
416            .await;
417
418        assert_eq!(search_res.iter().len(), 1);
419
420        assert!(
421            !conn_mgr.has_broken(&mut conn),
422            "freshly connected session should be healthy"
423        );
424
425        conn.unbind().await?;
426        // Give the background driver task time to process the unbind and close the channel
427        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
428        assert!(
429            conn_mgr.has_broken(&mut conn),
430            "connection should be flagged as broken after unbind"
431        );
432
433        Ok(())
434    }
435}