Skip to main content

bitwarden_access_connector/integrations/
mod.rs

1//! Drivers that apply a rotation to the external system holding the credential.
2//!
3//! Each target system kind has one [`Integration`], registered against its
4//! [`SealedTargetSystemKind`] in an [`IntegrationRegistry`]. For a claimed rotation the executor
5//! looks up the driver for the target's kind and calls it in order:
6//!
7//! 1. [`rotate`](Integration::rotate) sets the account's password to the newly generated one.
8//! 2. [`verify`](Integration::verify) confirms the target accepted it, after a successful rotate.
9//! 3. [`terminate_sessions`](Integration::terminate_sessions) ends the account's active sessions,
10//!    only when the claim asks for it. Its failure never fails the rotation.
11//!
12//! All three receive the same [`RotateContext`]: the target, the account identity, the new
13//! password and the target's [`ResolvedCredentials`].
14//!
15//! Drivers rotate by administrative reset. The current password is never available to them, so a
16//! retry after a partial failure converges on the newest password instead of failing because the
17//! "old" one has already changed.
18//!
19//! # Failures
20//!
21//! An operation fails with an [`IntegrationError`], which carries everything the failure report
22//! needs: whether to [`Retry`], the [`RotationFailureCode`], a [`SafeDetail`], and the
23//! [`TargetEffect`] on the credential in the target. The effect becomes the reported
24//! [`RotationSyncState`], which tells the vault whether it is still in sync with the target. A
25//! driver that cannot tell whether a change landed must report [`TargetEffect::Unknown`] rather
26//! than guess.
27//!
28//! # Drivers
29//!
30//! - [`active_directory`]: on-premises Active Directory, over LDAPS.
31//! - [`entra`]: Microsoft Entra ID, through Microsoft Graph.
32//! - [`scripting`]: an operator-supplied script, run directly or through a PowerShell host.
33//!
34//! Operator-facing behaviour (required credentials, permissions, the script contract) is
35//! documented in the crate README.
36
37pub(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/// Effect of a failed operation on the target credential, mapped to a [`RotationSyncState`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub(crate) enum TargetEffect {
57    /// The credential rotation was not applied; target and vault are still in sync.
58    NotApplied,
59    /// The credential was successfully rotated in the target system.
60    Applied,
61    /// It is not known whether the rotation was applied (e.g. timeout after send).
62    Unknown,
63}
64
65impl From<TargetEffect> for RotationSyncState {
66    /// The sync state to report for this effect.
67    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/// An error returned by any [`Integration`] operation, carrying what the failure report needs.
77///
78/// Only `retry`, `effect`, `code` and `detail` reach the failure report. `source` holds the raw
79/// cause for local debugging and must stay out of both `Display` and anything built from `Debug`
80/// for the report: it can contain URLs and account identities.
81#[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    /// Keeps `cause` for local debugging; it never reaches `Display` or the failure report.
109    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
115/// Input to every [`Integration`] operation, built from the claim and the resolved credentials.
116pub(crate) struct RotateContext {
117    pub(crate) target_system_id: Uuid,
118    /// The opaque account identity string (e.g. a user principal name or object id).
119    pub(crate) account_identity: String,
120    pub(crate) new_password: String,
121    pub(crate) creds: ResolvedCredentials,
122    /// Wall-clock time after password generation (step 2); verify checks
123    /// `lastPasswordChangeDateTime` against it.
124    pub(crate) rotation_started_at: DateTime<Utc>,
125}
126
127// Hand-written to keep new_password, creds and account_identity (often a UPN/email) out of the
128// output.
129impl 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/// Trait implemented by each target-system driver, such as Entra or CustomScript.
141#[async_trait]
142pub(crate) trait Integration: Send + Sync {
143    /// Rotate the credential to `ctx.new_password`.
144    async fn rotate(&self, ctx: &RotateContext) -> Result<(), IntegrationError>;
145
146    /// Verify that the rotation applied in the target system.
147    async fn verify(&self, ctx: &RotateContext) -> Result<(), IntegrationError>;
148
149    /// Terminate active sessions for `ctx.account_identity` when the claim's `terminate_sessions`
150    /// flag is set. A failure here never fails the rotation.
151    async fn terminate_sessions(&self, ctx: &RotateContext) -> Result<(), IntegrationError>;
152}
153
154/// Maps a [`SealedTargetSystemKind`] to its [`Integration`] driver. Kinds without a registered
155/// integration return `None`, which the executor reports as `unsupported_kind`.
156pub(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}