Skip to main content

bitwarden_access_connector/integrations/active_directory/
mod.rs

1//! On-premises Active Directory, rotated by an administrative reset of the account's `unicodePwd`
2//! over LDAPS.
3//!
4//! `rotate` connects to `LDAP_HOST` and binds as the delegated service account in `BIND_DN`. It
5//! searches below `BASE_DN` for the one user whose `sAMAccountName` or `userPrincipalName` is the
6//! account identity, then replaces that user's `unicodePwd` with the new password. A reset doesn't
7//! need the current password and isn't checked against password history, so a retry can resend the
8//! same password. `verify` opens a new session, finds the account again and binds as it with the
9//! new password. Session termination isn't supported, since a reset can't revoke Kerberos tickets
10//! already issued.
11//!
12//! Every connection is LDAPS and trusts the platform roots plus any `CA_CERTIFICATE` anchors. There
13//! is no plaintext, StartTLS or skip-verification path, since a domain controller refuses a
14//! `unicodePwd` write without encryption anyway.
15//!
16//! What a failure says about the target depends on its `Phase`. A failure before the write and a
17//! rejected write both leave the target unchanged, while a write that got no answer may have
18//! landed. Once a write in an attempt has gone unanswered, a later try that fails before writing
19//! reports the effect as unknown, and a permanent rejection is settled by binding as the account
20//! with the new password. Operator-facing behaviour is in the crate README.
21
22mod 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
53/// ldap3 sets no connect or operation timeout, so without these a domain controller that stops
54/// answering would hang the rotation.
55const TIMEOUTS: Timeouts = Timeouts {
56    connect: Duration::from_secs(15),
57    operation: Duration::from_secs(30),
58};
59
60/// The [`Integration`] for Active Directory targets.
61#[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    /// A PEM file of extra trust anchors for a DC with a private or self-signed certificate.
82    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            // This try wrote nothing, but the earlier write may have landed.
134            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        // A bind with the new password can't be stale, unlike an attribute read.
154        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        // AD targets are sealed without session termination, since a reset can't revoke Kerberos
179        // tickets already issued, so the executor never calls this.
180        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}