bitwarden_access_connector/integrations/
mod.rs1pub(crate) mod active_directory;
38pub(crate) mod entra;
39pub(crate) mod scripting;
40
41use std::{collections::HashMap, error::Error, sync::Arc};
42
43use async_trait::async_trait;
44use bitwarden_pam::{RotationFailureCode, RotationSyncState};
45use bitwarden_pam_sealed::SealedTargetSystemKind;
46use chrono::{DateTime, Utc};
47use uuid::Uuid;
48
49use crate::{
50 error::{Retry, SafeDetail},
51 resolver::ResolvedCredentials,
52};
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub(crate) enum TargetEffect {
57 NotApplied,
59 Applied,
61 Unknown,
63}
64
65impl From<TargetEffect> for RotationSyncState {
66 fn from(effect: TargetEffect) -> Self {
68 match effect {
69 TargetEffect::NotApplied => RotationSyncState::TargetUnchanged,
70 TargetEffect::Unknown => RotationSyncState::Indeterminate,
71 TargetEffect::Applied => RotationSyncState::TargetUpdated,
72 }
73 }
74}
75
76#[derive(Debug, thiserror::Error)]
82#[error("{retry:?}/{effect:?} ({code:?}): {detail}")]
83pub(crate) struct IntegrationError {
84 pub(crate) retry: Retry,
85 pub(crate) effect: TargetEffect,
86 pub(crate) code: RotationFailureCode,
87 pub(crate) detail: SafeDetail,
88 #[source]
89 source: Option<Box<dyn Error + Send + Sync>>,
90}
91
92impl IntegrationError {
93 pub(crate) fn new(
94 retry: Retry,
95 effect: TargetEffect,
96 code: RotationFailureCode,
97 detail: SafeDetail,
98 ) -> Self {
99 Self {
100 retry,
101 effect,
102 code,
103 detail,
104 source: None,
105 }
106 }
107
108 pub(crate) fn with_source(mut self, cause: impl Into<Box<dyn Error + Send + Sync>>) -> Self {
110 self.source = Some(cause.into());
111 self
112 }
113}
114
115pub(crate) struct RotateContext {
117 pub(crate) target_system_id: Uuid,
118 pub(crate) account_identity: String,
120 pub(crate) new_password: String,
121 pub(crate) creds: ResolvedCredentials,
122 pub(crate) rotation_started_at: DateTime<Utc>,
125}
126
127impl std::fmt::Debug for RotateContext {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.debug_struct("RotateContext")
132 .field("target_system_id", &self.target_system_id)
133 .field("account_identity", &"[REDACTED]")
134 .field("new_password", &"[REDACTED]")
135 .field("rotation_started_at", &self.rotation_started_at)
136 .finish()
137 }
138}
139
140#[async_trait]
142pub(crate) trait Integration: Send + Sync {
143 async fn rotate(&self, ctx: &RotateContext) -> Result<(), IntegrationError>;
145
146 async fn verify(&self, ctx: &RotateContext) -> Result<(), IntegrationError>;
148
149 async fn terminate_sessions(&self, ctx: &RotateContext) -> Result<(), IntegrationError>;
152}
153
154pub(crate) struct IntegrationRegistry {
157 map: HashMap<SealedTargetSystemKind, Arc<dyn Integration>>,
158}
159
160impl IntegrationRegistry {
161 pub(crate) fn new() -> Self {
162 Self {
163 map: HashMap::new(),
164 }
165 }
166
167 pub(crate) fn register(
168 &mut self,
169 kind: SealedTargetSystemKind,
170 integration: Arc<dyn Integration>,
171 ) {
172 tracing::debug!(?kind, "registered integration");
173 self.map.insert(kind, integration);
174 }
175
176 pub(crate) fn get(&self, kind: SealedTargetSystemKind) -> Option<Arc<dyn Integration>> {
177 self.map.get(&kind).cloned()
178 }
179}
180
181impl Default for IntegrationRegistry {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 struct AlwaysOk;
192
193 #[async_trait]
194 impl Integration for AlwaysOk {
195 async fn rotate(&self, _ctx: &RotateContext) -> Result<(), IntegrationError> {
196 Ok(())
197 }
198 async fn verify(&self, _ctx: &RotateContext) -> Result<(), IntegrationError> {
199 Ok(())
200 }
201 async fn terminate_sessions(&self, _ctx: &RotateContext) -> Result<(), IntegrationError> {
202 Ok(())
203 }
204 }
205
206 #[test]
207 fn registry_get_registered_kind() {
208 let mut reg = IntegrationRegistry::new();
209 reg.register(SealedTargetSystemKind::CustomScript, Arc::new(AlwaysOk));
210 assert!(reg.get(SealedTargetSystemKind::CustomScript).is_some());
211 }
212
213 #[test]
214 fn registry_get_unregistered_kind_returns_none() {
215 let reg = IntegrationRegistry::new();
216 assert!(reg.get(SealedTargetSystemKind::Entra).is_none());
217 }
218
219 #[test]
220 fn target_effect_to_sync_state_mapping() {
221 assert_eq!(
222 RotationSyncState::from(TargetEffect::NotApplied),
223 RotationSyncState::TargetUnchanged
224 );
225 assert_eq!(
226 RotationSyncState::from(TargetEffect::Unknown),
227 RotationSyncState::Indeterminate
228 );
229 assert_eq!(
230 RotationSyncState::from(TargetEffect::Applied),
231 RotationSyncState::TargetUpdated
232 );
233 }
234
235 #[test]
236 fn target_effect_variants_exist() {
237 let _a = TargetEffect::NotApplied;
238 let _b = TargetEffect::Applied;
239 let _c = TargetEffect::Unknown;
240 }
241
242 #[test]
243 fn integration_error_display() {
244 let e = IntegrationError::new(
245 Retry::Permanent,
246 TargetEffect::NotApplied,
247 RotationFailureCode::ScriptFailed,
248 SafeDetail::from_exit_code(1),
249 );
250 let s = e.to_string();
251 assert!(s.contains("ScriptFailed"));
252 assert!(s.contains("exit code 1"));
253 }
254
255 #[test]
256 fn integration_error_display_excludes_the_source() {
257 let e = IntegrationError::new(
258 Retry::Transient,
259 TargetEffect::Unknown,
260 RotationFailureCode::TargetUnreachable,
261 SafeDetail::from_kind("NetworkError"),
262 )
263 .with_source(std::io::Error::other("SECRET-SENTINEL [email protected]"));
264 assert!(!e.to_string().contains("SECRET-SENTINEL"));
265 let source = Error::source(&e).expect("source is kept");
266 assert!(source.to_string().contains("SECRET-SENTINEL"));
267 }
268
269 #[test]
270 fn rotate_context_debug_redacts_password_and_identity() {
271 let ctx = RotateContext {
272 target_system_id: Uuid::nil(),
273 account_identity: "[email protected]".to_string(),
274 new_password: "super-secret-pw".to_string(),
275 creds: ResolvedCredentials::new(),
276 rotation_started_at: Utc::now(),
277 };
278 let debug = format!("{ctx:?}");
279 assert!(
280 !debug.contains("super-secret-pw"),
281 "password leaked: {debug}"
282 );
283 assert!(
284 !debug.contains("[email protected]"),
285 "account identity leaked: {debug}"
286 );
287 assert!(debug.contains("REDACTED"));
288 }
289}