bitwarden_access_connector/integrations/active_directory/
mod.rs1mod classify;
23mod password_write;
24mod session;
25#[cfg(test)]
26mod test_support;
27mod tls;
28
29use std::time::Duration;
30
31use async_trait::async_trait;
32use bitwarden_pam::RotationFailureCode;
33use bitwarden_sensitive_value::ExposeSensitive as _;
34use ldap3::LdapError;
35
36use self::{
37 classify::{Phase, classify_ldap_error},
38 password_write::{UnsettledWrites, apply_password_write},
39 session::{LdapSession, Timeouts, ldaps_url},
40};
41use super::{Integration, IntegrationError, RotateContext, TargetEffect};
42use crate::{
43 error::{Retry, SafeDetail},
44 resolver::{
45 ResolvedCredentials,
46 env::{
47 BASE_DN_SUFFIX, BIND_DN_SUFFIX, BIND_PASSWORD_SUFFIX, CA_CERTIFICATE_SUFFIX,
48 LDAP_HOST_SUFFIX,
49 },
50 },
51};
52
53const TIMEOUTS: Timeouts = Timeouts {
56 connect: Duration::from_secs(15),
57 operation: Duration::from_secs(30),
58};
59
60#[derive(Debug)]
62pub(crate) struct ActiveDirectoryIntegration {
63 timeouts: Timeouts,
64 unsettled_writes: UnsettledWrites,
65}
66
67impl ActiveDirectoryIntegration {
68 pub(crate) fn new() -> Self {
69 Self {
70 timeouts: TIMEOUTS,
71 unsettled_writes: UnsettledWrites::default(),
72 }
73 }
74}
75
76struct Credentials<'a> {
77 url: String,
78 bind_dn: &'a str,
79 base_dn: &'a str,
80 bind_password: &'a str,
81 ca_certificate: Option<&'a str>,
83}
84
85impl<'a> Credentials<'a> {
86 fn resolve(creds: &'a ResolvedCredentials) -> Result<Self, IntegrationError> {
87 Ok(Self {
88 url: ldaps_url(get_cred(creds, LDAP_HOST_SUFFIX)?)?,
89 bind_dn: get_cred(creds, BIND_DN_SUFFIX)?,
90 base_dn: get_cred(creds, BASE_DN_SUFFIX)?,
91 bind_password: get_cred(creds, BIND_PASSWORD_SUFFIX)?,
92 ca_certificate: optional_cred(creds, CA_CERTIFICATE_SUFFIX),
93 })
94 }
95}
96
97fn optional_cred<'a>(creds: &'a ResolvedCredentials, suffix: &'static str) -> Option<&'a str> {
98 creds.get(suffix).map(|s| s.expose().as_ref() as &str)
99}
100
101fn get_cred<'a>(
102 creds: &'a ResolvedCredentials,
103 suffix: &'static str,
104) -> Result<&'a str, IntegrationError> {
105 optional_cred(creds, suffix).ok_or_else(|| {
106 IntegrationError::new(
107 Retry::Permanent,
108 TargetEffect::NotApplied,
109 RotationFailureCode::CredentialsUnresolved,
110 SafeDetail::from_kind(suffix),
111 )
112 })
113}
114
115fn as_applied(mut err: IntegrationError) -> IntegrationError {
116 err.effect = TargetEffect::Applied;
117 err
118}
119
120#[async_trait]
121impl Integration for ActiveDirectoryIntegration {
122 async fn rotate(&self, ctx: &RotateContext) -> Result<(), IntegrationError> {
123 let creds = Credentials::resolve(&ctx.creds)?;
124 let earlier_write_unsettled = self.unsettled_writes.contains(ctx);
125 let (mut session, account_dn) = LdapSession::open_and_locate(
126 &creds,
127 &ctx.account_identity,
128 self.timeouts,
129 Phase::BeforeWrite,
130 )
131 .await
132 .map_err(|mut err| {
133 if earlier_write_unsettled {
135 err.effect = TargetEffect::Unknown;
136 }
137 err
138 })?;
139 let written = apply_password_write(
140 &mut session,
141 &account_dn,
142 &ctx.new_password,
143 earlier_write_unsettled,
144 )
145 .await;
146 self.unsettled_writes.record(ctx, &written);
147 written?;
148 session.close().await;
149 Ok(())
150 }
151
152 async fn verify(&self, ctx: &RotateContext) -> Result<(), IntegrationError> {
153 let creds = Credentials::resolve(&ctx.creds).map_err(as_applied)?;
155 let (mut session, account_dn) = LdapSession::open_and_locate(
156 &creds,
157 &ctx.account_identity,
158 self.timeouts,
159 Phase::AfterWrite,
160 )
161 .await?;
162 let bind = session.simple_bind(&account_dn, &ctx.new_password).await;
163 session.close().await;
164
165 match bind {
166 Ok(()) => Ok(()),
167 Err(LdapError::LdapResult { result }) => Err(IntegrationError::new(
168 Retry::Permanent,
169 TargetEffect::Applied,
170 RotationFailureCode::VerificationFailed,
171 SafeDetail::from_ldap_result_code(result.rc),
172 )),
173 Err(other) => Err(classify_ldap_error(&other, Phase::AfterWrite)),
174 }
175 }
176
177 async fn terminate_sessions(&self, _ctx: &RotateContext) -> Result<(), IntegrationError> {
178 Err(IntegrationError::new(
181 Retry::Permanent,
182 TargetEffect::NotApplied,
183 RotationFailureCode::UnsupportedKind,
184 SafeDetail::from_kind("ActiveDirectorySessionTerminationUnsupported"),
185 ))
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use chrono::Utc;
192 use uuid::Uuid;
193
194 use super::*;
195 use crate::integrations::active_directory::test_support::SENTINEL_PASSWORD;
196
197 fn make_ctx(creds: ResolvedCredentials) -> RotateContext {
198 RotateContext {
199 target_system_id: Uuid::nil(),
200 account_identity: "svc-app".to_string(),
201 new_password: SENTINEL_PASSWORD.to_string(),
202 creds,
203 rotation_started_at: Utc::now(),
204 }
205 }
206
207 fn complete_creds(host: &str) -> ResolvedCredentials {
208 let mut creds = ResolvedCredentials::new();
209 creds.insert(LDAP_HOST_SUFFIX.to_string(), host.to_string());
210 creds.insert(BIND_DN_SUFFIX.to_string(), "CN=svc,DC=corp".to_string());
211 creds.insert(BASE_DN_SUFFIX.to_string(), "DC=corp".to_string());
212 creds.insert(
213 BIND_PASSWORD_SUFFIX.to_string(),
214 SENTINEL_PASSWORD.to_string(),
215 );
216 creds
217 }
218
219 #[test]
220 fn integration_debug_holds_no_secrets() {
221 let integration = ActiveDirectoryIntegration::new();
222 let debug = format!("{integration:?}");
223 assert!(
224 !debug.contains(SENTINEL_PASSWORD),
225 "debug leaked a secret: {debug}"
226 );
227 }
228
229 #[tokio::test]
230 async fn rotate_context_debug_redacts_the_new_password() {
231 let mut creds = ResolvedCredentials::new();
232 creds.insert(
233 BIND_PASSWORD_SUFFIX.to_string(),
234 SENTINEL_PASSWORD.to_string(),
235 );
236 let ctx = make_ctx(creds);
237 let debug = format!("{ctx:?}");
238 assert!(
239 !debug.contains(SENTINEL_PASSWORD),
240 "password leaked: {debug}"
241 );
242 }
243
244 #[tokio::test]
245 async fn missing_credential_error_names_the_suffix_only() {
246 let integration = ActiveDirectoryIntegration::new();
247 let ctx = make_ctx(ResolvedCredentials::new());
248 let err = integration
249 .rotate(&ctx)
250 .await
251 .expect_err("empty credentials must fail before connecting");
252 assert_eq!(err.retry, Retry::Permanent);
253 assert_eq!(err.code, RotationFailureCode::CredentialsUnresolved);
254 assert_eq!(err.effect, TargetEffect::NotApplied);
255 assert_eq!(err.detail.as_str(), "error kind: LDAP_HOST");
256 }
257
258 #[tokio::test]
259 async fn malformed_host_fails_before_connecting() {
260 let integration = ActiveDirectoryIntegration::new();
261 let ctx = make_ctx(complete_creds("ldap://dc01"));
262
263 let err = integration
264 .rotate(&ctx)
265 .await
266 .expect_err("a non-LDAPS host must be refused");
267 assert_eq!(err.code, RotationFailureCode::CredentialsUnresolved);
268 assert_eq!(err.detail.as_str(), "error kind: MalformedLdapHost");
269 assert!(!err.to_string().contains(SENTINEL_PASSWORD));
270 }
271
272 #[test]
273 fn ca_certificate_is_optional_and_absent_by_default() {
274 let creds = complete_creds("dc01.corp.example");
275
276 let resolved = Credentials::resolve(&creds).expect("credentials resolve without a CA");
277 assert!(
278 resolved.ca_certificate.is_none(),
279 "an absent CA_CERTIFICATE must leave the connection on platform roots"
280 );
281 }
282
283 #[test]
284 fn ca_certificate_is_picked_up_when_present() {
285 let mut creds = complete_creds("dc01.corp.example");
286 creds.insert(
287 CA_CERTIFICATE_SUFFIX.to_string(),
288 "/etc/pki/ad-ca.pem".to_string(),
289 );
290
291 let resolved = Credentials::resolve(&creds).expect("credentials resolve");
292 assert_eq!(resolved.ca_certificate, Some("/etc/pki/ad-ca.pem"));
293 }
294
295 #[tokio::test]
296 async fn a_failure_before_the_write_after_an_unanswered_one_is_reported_unknown() {
297 let closed_port = std::net::TcpListener::bind("127.0.0.1:0")
298 .and_then(|listener| listener.local_addr())
299 .expect("a free loopback port")
300 .port();
301 let integration = ActiveDirectoryIntegration::new();
302 let ctx = make_ctx(complete_creds(&format!("127.0.0.1:{closed_port}")));
303
304 let first_try = integration
305 .rotate(&ctx)
306 .await
307 .expect_err("nothing listens on the port");
308 assert_eq!(first_try.effect, TargetEffect::NotApplied);
309
310 let unanswered = Err(IntegrationError::new(
311 Retry::Transient,
312 TargetEffect::Unknown,
313 RotationFailureCode::TargetUnreachable,
314 SafeDetail::from_kind("ConnectionClosed"),
315 ));
316 integration.unsettled_writes.record(&ctx, &unanswered);
317
318 let retry = integration
319 .rotate(&ctx)
320 .await
321 .expect_err("nothing listens on the port");
322 assert_eq!(
323 retry.effect,
324 TargetEffect::Unknown,
325 "the earlier write may have landed, whatever stopped this try"
326 );
327 assert!(
328 integration.unsettled_writes.contains(&ctx),
329 "a try that wrote nothing must not settle the earlier write"
330 );
331 }
332
333 #[tokio::test]
334 async fn terminate_sessions_is_unsupported() {
335 let integration = ActiveDirectoryIntegration::new();
336 let ctx = make_ctx(ResolvedCredentials::new());
337 let err = integration
338 .terminate_sessions(&ctx)
339 .await
340 .expect_err("session termination is not supported for Active Directory");
341 assert_eq!(err.retry, Retry::Permanent);
342 assert_eq!(err.code, RotationFailureCode::UnsupportedKind);
343 assert_eq!(err.effect, TargetEffect::NotApplied);
344 assert_eq!(
345 err.detail.as_str(),
346 "error kind: ActiveDirectorySessionTerminationUnsupported"
347 );
348 }
349}