Skip to main content

pdk_unit/backends/
ldap.rs

1// Copyright (c) 2026, Salesforce, Inc.,
2// All rights reserved.
3// For full license text, see the LICENSE.txt file
4
5use crate::{Backend, UnitHttpMessage, UnitHttpRequest, UnitHttpResponse};
6use base64::engine::general_purpose::STANDARD as BASE64;
7use base64::Engine;
8use std::cell::RefCell;
9use std::collections::HashMap;
10
11#[derive(Default)]
12pub struct LdapBackend {
13    #[allow(clippy::type_complexity)]
14    configs: RefCell<HashMap<Option<UnitLdapConfig>, Vec<(String, String)>>>,
15}
16
17/// LDAP server configuration used to scope credential pairs registered with [`UnitTest::add_ldap_data`].
18///
19/// A registered credential pair is considered a match when its associated `UnitLdapConfig`
20/// equals the LDAP connection parameters used by the policy.
21///
22/// Construct a value using the builder methods, starting from [`Default::default`]:
23///
24/// ```ignore
25/// let config = UnitLdapConfig::default()
26///     .server_url("ldap://ldap.example.com:389")
27///     .server_user_dn("cn=admin,dc=example,dc=com")
28///     .server_user_password("secret")
29///     .search_base("ou=users,dc=example,dc=com")
30///     .search_filter("(uid={0})")
31///     .search_in_subtree();
32/// ```
33#[derive(PartialEq, Eq, Hash, Default)]
34pub struct UnitLdapConfig {
35    server_url: String,
36    server_user_dn: String,
37    server_user_password: String,
38    search_base: String,
39    search_filter: String,
40    search_in_subtree: bool,
41}
42
43impl UnitLdapConfig {
44    /// Sets the LDAP server URL (e.g. `ldap://ldap.example.com:389`).
45    pub fn server_url(mut self, url: impl Into<String>) -> Self {
46        self.server_url = url.into();
47        self
48    }
49
50    /// Sets the distinguished name used to bind to the LDAP server (e.g. `cn=admin,dc=example,dc=com`).
51    pub fn server_user_dn(mut self, dn: impl Into<String>) -> Self {
52        self.server_user_dn = dn.into();
53        self
54    }
55
56    /// Sets the password used together with [`server_user_dn`](Self::server_user_dn) to bind to the LDAP server.
57    pub fn server_user_password(mut self, pass: impl Into<String>) -> Self {
58        self.server_user_password = pass.into();
59        self
60    }
61
62    /// Sets the base DN from which the user search is performed (e.g. `ou=users,dc=example,dc=com`).
63    pub fn search_base(mut self, base: impl Into<String>) -> Self {
64        self.search_base = base.into();
65        self
66    }
67
68    /// Sets the LDAP search filter used to locate the authenticating user (e.g. `(uid={0})`).
69    pub fn search_filter(mut self, filter: impl Into<String>) -> Self {
70        self.search_filter = filter.into();
71        self
72    }
73
74    /// Enables recursive subtree searching instead of a single-level search.
75    pub fn search_in_subtree(mut self) -> Self {
76        self.search_in_subtree = true;
77        self
78    }
79}
80
81impl LdapBackend {
82    pub fn add_data<U: Into<String>, P: Into<String>>(
83        &self,
84        config: Option<UnitLdapConfig>,
85        user: U,
86        pass: P,
87    ) {
88        self.configs
89            .borrow_mut()
90            .entry(config)
91            .or_default()
92            .push((user.into(), pass.into()));
93    }
94}
95
96impl Backend for LdapBackend {
97    fn call(&self, req: UnitHttpRequest) -> UnitHttpResponse {
98        let headers = req.headers();
99
100        let headers: HashMap<String, String> = headers
101            .iter()
102            .map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
103            .collect();
104
105        let config = headers
106            .get("x-flex-authentication-ldap-url")
107            .map(|url| UnitLdapConfig {
108                server_url: url.clone(),
109                server_user_dn: headers
110                    .get("x-flex-authentication-ldap-bind-dn")
111                    .cloned()
112                    .unwrap_or_default(),
113                server_user_password: headers
114                    .get("x-flex-authentication-ldap-bind-pass")
115                    .cloned()
116                    .unwrap_or_default(),
117                search_base: headers
118                    .get("x-flex-authentication-ldap-search-base")
119                    .cloned()
120                    .unwrap_or_default(),
121                search_filter: headers
122                    .get("x-flex-authentication-ldap-search-filter")
123                    .cloned()
124                    .unwrap_or_default(),
125                search_in_subtree: headers
126                    .get("x-flex-authentication-ldap-search-in-subtree")
127                    .is_some_and(|v| v == "true"),
128            });
129
130        let credential = match headers
131            .get("authorization")
132            .and_then(|v| v.strip_prefix("Basic "))
133            .map(|v| v.to_string())
134        {
135            Some(c) => c,
136            None => return UnitHttpResponse::new(401),
137        };
138
139        let (user, pass) = match base64_decode_credentials(&credential) {
140            Some(pair) => pair,
141            None => return UnitHttpResponse::new(400),
142        };
143
144        let configs = self.configs.borrow();
145        let found = configs
146            .get(&config)
147            .into_iter()
148            .chain(configs.get(&None))
149            .flat_map(|pairs| pairs.iter())
150            .any(|(u, p)| u == &user && p == &pass);
151
152        if found {
153            UnitHttpResponse::new(200)
154        } else {
155            UnitHttpResponse::new(401)
156        }
157    }
158}
159
160fn base64_decode_credentials(encoded: &str) -> Option<(String, String)> {
161    let decoded = BASE64.decode(encoded.as_bytes()).ok()?;
162    let s = String::from_utf8(decoded).ok()?;
163    let mut parts = s.splitn(2, ':');
164    let user = parts.next()?.to_string();
165    let pass = parts.next()?.to_string();
166    Some((user, pass))
167}