Skip to main content

bitwarden_access_connector/integrations/active_directory/
classify.rs

1use bitwarden_pam::RotationFailureCode;
2use ldap3::LdapError;
3
4use crate::{
5    error::{Retry, SafeDetail},
6    integrations::{IntegrationError, TargetEffect},
7};
8
9// LDAP result codes from <https://www.rfc-editor.org/rfc/rfc4511#appendix-A.1>.
10
11/// `adminLimitExceeded`: the operation exceeded a limit the server's administrator set.
12const RC_ADMIN_LIMIT_EXCEEDED: u32 = 11;
13/// `invalidCredentials`: the bind's name or password is wrong. Active Directory also answers it for
14/// a locked-out or disabled account.
15pub(super) const RC_INVALID_CREDENTIALS: u32 = 49;
16/// `busy`: the server is too busy to handle the operation now.
17const RC_BUSY: u32 = 51;
18/// `unavailable`: the server is shutting down, or a part it needs is offline.
19const RC_UNAVAILABLE: u32 = 52;
20
21/// Where in a rotation an LDAP failure happened, which decides what it says about the target.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub(super) enum Phase {
24    /// Connect, bind and account lookup; nothing has been written.
25    BeforeWrite,
26    /// The `unicodePwd` write.
27    Write,
28    /// After a successful write.
29    AfterWrite,
30}
31
32impl Phase {
33    /// The effect when the outcome is known: the directory answered, or nothing was sent.
34    pub(super) fn settled(self) -> TargetEffect {
35        match self {
36            Self::BeforeWrite | Self::Write => TargetEffect::NotApplied,
37            Self::AfterWrite => TargetEffect::Applied,
38        }
39    }
40
41    /// The effect when the request may have gone out before the failure.
42    fn indeterminate(self) -> TargetEffect {
43        match self {
44            Self::BeforeWrite => TargetEffect::NotApplied,
45            Self::Write => TargetEffect::Unknown,
46            Self::AfterWrite => TargetEffect::Applied,
47        }
48    }
49}
50
51/// Transport failures are transient; directory decisions and TLS or configuration failures are
52/// permanent. Only a result code or a fixed kind reaches the detail, never the directory's text,
53/// which can echo account names.
54pub(super) fn classify_ldap_error(err: &LdapError, phase: Phase) -> IntegrationError {
55    let settled = phase.settled();
56    let indeterminate = phase.indeterminate();
57    match err {
58        LdapError::Io { source } if is_tls_error(source) => {
59            target_unreachable(Retry::Permanent, indeterminate, "TlsHandshakeFailed")
60        }
61        LdapError::Io { source } => target_unreachable(
62            Retry::Transient,
63            indeterminate,
64            io_error_kind_name(source.kind()),
65        ),
66        LdapError::Timeout { .. } => {
67            target_unreachable(Retry::Transient, indeterminate, "OperationTimeout")
68        }
69        LdapError::EndOfStream
70        | LdapError::OpSend { .. }
71        | LdapError::ResultRecv { .. }
72        | LdapError::IdScrubSend { .. }
73        | LdapError::MiscSend { .. } => {
74            target_unreachable(Retry::Transient, indeterminate, "ConnectionClosed")
75        }
76        // ldap3 0.12 reports TLS failures as `Io`; this arm guards a future version.
77        LdapError::Rustls { .. } => {
78            target_unreachable(Retry::Permanent, indeterminate, "TlsHandshakeFailed")
79        }
80        LdapError::DNSName { .. } => {
81            target_unreachable(Retry::Permanent, settled, "TlsInvalidServerName")
82        }
83        LdapError::UrlParsing { .. }
84        | LdapError::UnknownScheme(_)
85        | LdapError::EmptyUnixPath
86        | LdapError::PortInUnixPath
87        | LdapError::MismatchedStreamType => IntegrationError::new(
88            Retry::Permanent,
89            settled,
90            RotationFailureCode::CredentialsUnresolved,
91            SafeDetail::from_kind("MalformedLdapHost"),
92        ),
93        LdapError::LdapResult { result } => classify_result_code(result.rc, phase),
94        _ => IntegrationError::new(
95            Retry::Permanent,
96            settled,
97            RotationFailureCode::Internal,
98            SafeDetail::from_kind("LdapClientError"),
99        ),
100    }
101}
102
103/// Load shedding is transient; any other rejection is a decision the directory will repeat.
104fn classify_result_code(rc: u32, phase: Phase) -> IntegrationError {
105    let (retry, code) = match rc {
106        RC_ADMIN_LIMIT_EXCEEDED | RC_BUSY | RC_UNAVAILABLE => {
107            (Retry::Transient, RotationFailureCode::TargetUnreachable)
108        }
109        _ => (Retry::Permanent, RotationFailureCode::TargetRejected),
110    };
111    IntegrationError::new(
112        retry,
113        phase.settled(),
114        code,
115        SafeDetail::from_ldap_result_code(rc),
116    )
117}
118
119pub(super) fn target_rejected(effect: TargetEffect, kind: &'static str) -> IntegrationError {
120    IntegrationError::new(
121        Retry::Permanent,
122        effect,
123        RotationFailureCode::TargetRejected,
124        SafeDetail::from_kind(kind),
125    )
126}
127
128pub(super) fn target_unreachable(
129    retry: Retry,
130    effect: TargetEffect,
131    kind: &'static str,
132) -> IntegrationError {
133    IntegrationError::new(
134        retry,
135        effect,
136        RotationFailureCode::TargetUnreachable,
137        SafeDetail::from_kind(kind),
138    )
139}
140
141/// tokio-rustls reports a rejected certificate as `io::Error(InvalidData, rustls::Error)`, which
142/// ldap3 forwards as `Io`. It must not be retried like a network fault: it needs an operator.
143fn is_tls_error(source: &std::io::Error) -> bool {
144    source
145        .get_ref()
146        .is_some_and(|inner| inner.is::<rustls::Error>())
147}
148
149fn io_error_kind_name(kind: std::io::ErrorKind) -> &'static str {
150    use std::io::ErrorKind;
151    match kind {
152        ErrorKind::ConnectionRefused => "ConnectionRefused",
153        ErrorKind::ConnectionReset => "ConnectionReset",
154        ErrorKind::ConnectionAborted => "ConnectionAborted",
155        ErrorKind::NotConnected => "NotConnected",
156        ErrorKind::BrokenPipe => "BrokenPipe",
157        ErrorKind::TimedOut => "ConnectTimeout",
158        ErrorKind::UnexpectedEof => "UnexpectedEof",
159        _ => "NetworkError",
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use std::time::Duration;
166
167    use super::*;
168    use crate::integrations::active_directory::test_support::{SENTINEL_PASSWORD, ldap_result};
169
170    #[test]
171    fn directory_rejections_are_fatal() {
172        // 13 confidentialityRequired, 19 constraintViolation, 32 noSuchObject,
173        // 49 invalidCredentials, 50 insufficientAccessRights, 53 unwillingToPerform,
174        // 16/17/21 schema errors.
175        for rc in [13, 16, 17, 19, 21, 32, 49, 50, 53] {
176            let err = classify_result_code(rc, Phase::BeforeWrite);
177            assert_eq!(err.retry, Retry::Permanent, "rc {rc} must be fatal");
178            assert_eq!(err.code, RotationFailureCode::TargetRejected, "rc {rc}");
179            assert_eq!(err.effect, TargetEffect::NotApplied, "rc {rc}");
180        }
181    }
182
183    #[test]
184    fn load_shedding_codes_are_transient() {
185        for rc in [RC_ADMIN_LIMIT_EXCEEDED, RC_BUSY, RC_UNAVAILABLE] {
186            let err = classify_result_code(rc, Phase::BeforeWrite);
187            assert_eq!(err.retry, Retry::Transient, "rc {rc} must be transient");
188            assert_eq!(err.code, RotationFailureCode::TargetUnreachable, "rc {rc}");
189        }
190    }
191
192    #[test]
193    fn network_errors_are_transient() {
194        let err = classify_ldap_error(
195            &LdapError::Io {
196                source: std::io::Error::from(std::io::ErrorKind::ConnectionRefused),
197            },
198            Phase::BeforeWrite,
199        );
200        assert_eq!(err.retry, Retry::Transient);
201        assert_eq!(err.code, RotationFailureCode::TargetUnreachable);
202        assert_eq!(err.detail.as_str(), "error kind: ConnectionRefused");
203    }
204
205    #[test]
206    fn dropped_connection_during_modify_is_unknown_not_not_applied() {
207        let err = classify_ldap_error(
208            &LdapError::Io {
209                source: std::io::Error::from(std::io::ErrorKind::ConnectionReset),
210            },
211            Phase::Write,
212        );
213        assert_eq!(
214            err.effect,
215            TargetEffect::Unknown,
216            "a transport failure after the modify is sent leaves the outcome unknown"
217        );
218    }
219
220    #[tokio::test(start_paused = true)]
221    async fn operation_timeout_during_modify_is_unknown() {
222        let elapsed = tokio::time::timeout(Duration::from_millis(1), std::future::pending::<()>())
223            .await
224            .expect_err("must elapse");
225        let err = classify_ldap_error(&LdapError::from(elapsed), Phase::Write);
226        assert_eq!(err.effect, TargetEffect::Unknown);
227        assert_eq!(err.retry, Retry::Transient);
228        assert_eq!(err.detail.as_str(), "error kind: OperationTimeout");
229    }
230
231    #[test]
232    fn rejected_certificate_is_fatal_not_transient() {
233        let rustls_err = rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer);
234        let err = classify_ldap_error(
235            &LdapError::Io {
236                source: std::io::Error::new(std::io::ErrorKind::InvalidData, rustls_err),
237            },
238            Phase::BeforeWrite,
239        );
240        assert_eq!(
241            err.retry,
242            Retry::Permanent,
243            "an untrusted certificate must not be retried as a network blip"
244        );
245        assert_eq!(err.effect, TargetEffect::NotApplied);
246        assert_eq!(err.detail.as_str(), "error kind: TlsHandshakeFailed");
247    }
248
249    #[test]
250    fn tls_failure_during_the_modify_leaves_the_effect_unknown() {
251        let rustls_err = rustls::Error::InvalidCertificate(rustls::CertificateError::Expired);
252        let err = classify_ldap_error(
253            &LdapError::Io {
254                source: std::io::Error::new(std::io::ErrorKind::InvalidData, rustls_err),
255            },
256            Phase::Write,
257        );
258        assert_eq!(err.effect, TargetEffect::Unknown);
259    }
260
261    #[test]
262    fn plain_io_errors_stay_transient() {
263        let err = classify_ldap_error(
264            &LdapError::Io {
265                source: std::io::Error::new(std::io::ErrorKind::InvalidData, "not a tls error"),
266            },
267            Phase::BeforeWrite,
268        );
269        assert_eq!(err.retry, Retry::Transient);
270    }
271
272    #[test]
273    fn tls_failures_are_fatal() {
274        let err = classify_ldap_error(
275            &LdapError::DNSName {
276                source: rustls::pki_types::ServerName::try_from("not a host name")
277                    .expect_err("must be invalid"),
278            },
279            Phase::BeforeWrite,
280        );
281        assert_eq!(err.retry, Retry::Permanent);
282        assert_eq!(err.detail.as_str(), "error kind: TlsInvalidServerName");
283    }
284
285    #[test]
286    fn errors_after_rotation_report_applied() {
287        let err = classify_ldap_error(
288            &LdapError::LdapResult {
289                result: ldap_result(49, "invalid credentials"),
290            },
291            Phase::AfterWrite,
292        );
293        assert_eq!(err.effect, TargetEffect::Applied);
294    }
295
296    #[test]
297    fn detail_carries_only_the_result_code_not_directory_text() {
298        let err = classify_ldap_error(
299            &LdapError::LdapResult {
300                result: ldap_result(53, &format!("rejected password {SENTINEL_PASSWORD}")),
301            },
302            Phase::Write,
303        );
304        assert_eq!(err.detail.as_str(), "LDAP result code 53");
305        let rendered = err.to_string();
306        assert!(
307            !rendered.contains(SENTINEL_PASSWORD),
308            "password leaked: {rendered}"
309        );
310        assert!(
311            !rendered.contains("OU=Service Accounts"),
312            "matched DN leaked: {rendered}"
313        );
314    }
315}