bitwarden_access_connector/integrations/active_directory/
classify.rs1use bitwarden_pam::RotationFailureCode;
2use ldap3::LdapError;
3
4use crate::{
5 error::{Retry, SafeDetail},
6 integrations::{IntegrationError, TargetEffect},
7};
8
9const RC_ADMIN_LIMIT_EXCEEDED: u32 = 11;
13pub(super) const RC_INVALID_CREDENTIALS: u32 = 49;
16const RC_BUSY: u32 = 51;
18const RC_UNAVAILABLE: u32 = 52;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub(super) enum Phase {
24 BeforeWrite,
26 Write,
28 AfterWrite,
30}
31
32impl Phase {
33 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 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
51pub(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 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
103fn 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
141fn 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 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}