Skip to main content

bitwarden_access_connector/integrations/scripting/
mod.rs

1//! Rotation through an operator-supplied script, run directly or through a PowerShell host.
2//!
3//! [`CustomScriptIntegration`] runs the target's script once per [`Operation`], passing the
4//! operation name as its only argument and a JSON payload on stdin. Each run has four steps:
5//!
6//! 1. **Resolve** ([`command`]): the `SCRIPT` credential becomes a canonical path, confined to the
7//!    script root when one is configured. `SCRIPT_TYPE`, or else the file extension, picks the
8//!    [`ScriptType`].
9//! 2. **Build** ([`command`]): the script and its type become a
10//!    [`CommandSpec`](command::CommandSpec), a plain value naming the program, its arguments and
11//!    its complete environment. A PowerShell script runs as an argument to a discovered or
12//!    configured host.
13//! 3. **Run** ([`runner`]): a [`ScriptRunner`] spawns the process, writes the payload and waits for
14//!    it to exit, killing it at the timeout. Tests substitute a recording runner, so nothing is
15//!    spawned.
16//! 4. **Classify** ([`outcome`]): the exit code, signal or invocation error becomes success or an
17//!    [`IntegrationError`].
18//!
19//! A configuration problem in the first two steps fails with `credentials_unresolved` before
20//! anything runs, so the target is reported unchanged.
21//!
22//! # Secrets
23//!
24//! The new password and the target's credentials reach the script only through the stdin
25//! payload. Argv carries just the operation name. A directly executed script starts with an empty
26//! environment, and a PowerShell host gets only a fixed allowlist of system variables. The
27//! script's stdout and stderr go to the null device, since either could echo what it was given.
28//! The payload omits `newPassword` for `terminate`, and never includes `SCRIPT` or `SCRIPT_TYPE`,
29//! which configure the connector rather than the target.
30//!
31//! # Target effect
32//!
33//! Exit codes 1 to 4 report what happened to the target. When the script ends without one of
34//! them (an exit code outside the contract, a signal, a broken pipe, or the timeout), the effect
35//! is inferred from the operation: only `rotate` can have changed the credential, so it reports
36//! [`TargetEffect::Unknown`] and the others report [`TargetEffect::NotApplied`]. The exception
37//! is a `verify` that times out, which reports [`TargetEffect::Applied`]: it only runs after a
38//! successful `rotate`. See [`Operation::effect_if_interrupted`] and
39//! [`Operation::effect_on_timeout`].
40//!
41//! The operator-facing contract (argv, stdin payload, exit codes, timeouts, PowerShell launching)
42//! is documented in the crate README under `## Custom-script integration` and
43//! `## PowerShell scripts`.
44
45mod command;
46mod outcome;
47mod runner;
48
49use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
50
51use async_trait::async_trait;
52use bitwarden_pam::RotationFailureCode;
53use serde::Serialize;
54
55pub(crate) use self::{command::ScriptType, runner::ProcessScriptRunner};
56use self::{
57    command::{build_command, resolve_script_path, resolve_script_type},
58    outcome::classify_outcome,
59    runner::{InvokeError, ScriptRunner},
60};
61use super::{Integration, IntegrationError, RotateContext, TargetEffect};
62use crate::{
63    error::{Retry, SafeDetail},
64    sys::Platform,
65};
66
67/// The operation a script is asked to perform.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub(crate) enum Operation {
70    /// Change the account's password to the new one.
71    Rotate,
72    /// Check that the account accepts the new password.
73    Verify,
74    /// End the account's existing sessions.
75    Terminate,
76}
77
78impl Operation {
79    /// The script's argv operand and the payload's `operation` value.
80    pub(crate) fn as_str(self) -> &'static str {
81        match self {
82            Self::Rotate => "rotate",
83            Self::Verify => "verify",
84            Self::Terminate => "terminate",
85        }
86    }
87
88    /// The target's state when the script ended without a result the contract defines: only a
89    /// rotate may have changed it.
90    pub(crate) fn effect_if_interrupted(self) -> TargetEffect {
91        match self {
92            Self::Rotate => TargetEffect::Unknown,
93            Self::Verify | Self::Terminate => TargetEffect::NotApplied,
94        }
95    }
96
97    /// The target's state when the script was killed for exceeding its timeout.
98    pub(crate) fn effect_on_timeout(self) -> TargetEffect {
99        match self {
100            Self::Rotate => TargetEffect::Unknown,
101            Self::Verify => TargetEffect::Applied,
102            Self::Terminate => TargetEffect::NotApplied,
103        }
104    }
105}
106
107/// How PowerShell scripts are launched.
108pub(crate) struct PowershellConfig {
109    /// Explicit PowerShell host path; `None` discovers one on `PATH`.
110    pub(crate) host: Option<PathBuf>,
111    /// Value of the host's `-ExecutionPolicy` argument.
112    pub(crate) execution_policy: String,
113}
114
115/// Runs an operator-supplied script for each rotation operation.
116pub(crate) struct CustomScriptIntegration {
117    /// Directory the script must resolve into; `None` allows any path.
118    pub(crate) script_root: Option<PathBuf>,
119    /// How long a script may run before it is killed.
120    pub(crate) timeout: Duration,
121    pub(crate) powershell: PowershellConfig,
122    /// Where host discovery, the environment allowlist and path resolution read from.
123    pub(crate) platform: Platform,
124    pub(crate) runner: Arc<dyn ScriptRunner>,
125}
126
127impl CustomScriptIntegration {
128    async fn run(&self, ctx: &RotateContext, op: Operation) -> Result<(), IntegrationError> {
129        let script = resolve_script_path(
130            &ctx.creds,
131            self.script_root.as_deref(),
132            self.platform.fs.as_ref(),
133        )?;
134        let script_type = resolve_script_type(&ctx.creds, &script)?;
135
136        let spec = build_command(&script, script_type, op, &self.powershell, &self.platform)?;
137        let outcome = match payload_json(op, ctx) {
138            Ok(payload) => self.runner.run(spec, &payload, self.timeout).await,
139            Err(e) => Err(e),
140        };
141        classify_outcome(outcome, op, self.timeout.as_secs())
142    }
143}
144
145#[async_trait]
146impl Integration for CustomScriptIntegration {
147    async fn rotate(&self, ctx: &RotateContext) -> Result<(), IntegrationError> {
148        self.run(ctx, Operation::Rotate).await
149    }
150
151    async fn verify(&self, ctx: &RotateContext) -> Result<(), IntegrationError> {
152        self.run(ctx, Operation::Verify).await
153    }
154
155    async fn terminate_sessions(&self, ctx: &RotateContext) -> Result<(), IntegrationError> {
156        self.run(ctx, Operation::Terminate).await
157    }
158}
159
160/// A failure caused by missing or invalid operator configuration; nothing ran, so the target is
161/// untouched.
162fn misconfigured(kind: &'static str) -> IntegrationError {
163    IntegrationError::new(
164        Retry::Permanent,
165        TargetEffect::NotApplied,
166        RotationFailureCode::CredentialsUnresolved,
167        SafeDetail::from_kind(kind),
168    )
169}
170
171/// The JSON document written to the script's stdin.
172#[derive(Serialize)]
173#[serde(rename_all = "camelCase")]
174struct ScriptPayload<'a> {
175    operation: &'a str,
176    target_system_id: &'a uuid::Uuid,
177    account_identity: &'a str,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    new_password: Option<&'a str>,
180    credentials: HashMap<&'a str, &'a str>,
181}
182
183/// Serializes the stdin payload for `operation`.
184fn payload_json(operation: Operation, ctx: &RotateContext) -> Result<Vec<u8>, InvokeError> {
185    use bitwarden_sensitive_value::ExposeSensitive as _;
186
187    // Terminate gets no new password, so script-side logging cannot echo it.
188    let new_password = match operation {
189        Operation::Rotate | Operation::Verify => Some(ctx.new_password.as_str()),
190        Operation::Terminate => None,
191    };
192
193    // SCRIPT and SCRIPT_TYPE configure the connector's own invocation, not the target, so the
194    // script does not get them.
195    let mut credentials: HashMap<&str, &str> = HashMap::new();
196    for (k, v) in ctx.creds.iter() {
197        if k != "SCRIPT" && k != "SCRIPT_TYPE" {
198            credentials.insert(k.as_str(), v.expose().as_ref() as &str);
199        }
200    }
201
202    let payload = ScriptPayload {
203        operation: operation.as_str(),
204        target_system_id: &ctx.target_system_id,
205        account_identity: &ctx.account_identity,
206        new_password,
207        credentials,
208    };
209
210    serde_json::to_vec(&payload).map_err(InvokeError::Serialize)
211}
212
213#[cfg(test)]
214mod tests {
215    use bitwarden_pam::RotationFailureCode;
216    use chrono::Utc;
217    use uuid::Uuid;
218
219    use super::{runner::RecordingRunner, *};
220    use crate::{
221        resolver::ResolvedCredentials,
222        sys::{FakeEnv, FakeFs},
223    };
224
225    const SH_SCRIPT: &str = "/opt/bwac/rotate.sh";
226    const HOST: &str = "/usr/local/bin/pwsh";
227
228    fn ctx_with(mut creds: ResolvedCredentials) -> RotateContext {
229        if creds.get("SCRIPT").is_none() {
230            creds.insert("SCRIPT".to_string(), SH_SCRIPT.to_string());
231        }
232        RotateContext {
233            target_system_id: Uuid::nil(),
234            account_identity: "[email protected]".to_string(),
235            new_password: "super-secret-pw-SENTINEL".to_string(),
236            creds,
237            rotation_started_at: Utc::now(),
238        }
239    }
240
241    fn ctx() -> RotateContext {
242        ctx_with(ResolvedCredentials::new())
243    }
244
245    /// Both seams faked, the script present and a PowerShell host pinned: no discovery, no
246    /// process.
247    fn integration(runner: Arc<RecordingRunner>) -> CustomScriptIntegration {
248        CustomScriptIntegration {
249            script_root: None,
250            timeout: Duration::from_secs(30),
251            powershell: PowershellConfig {
252                host: Some(PathBuf::from(HOST)),
253                execution_policy: "Bypass".to_string(),
254            },
255            platform: Platform::fake(FakeEnv::empty(), FakeFs::empty().with_file(SH_SCRIPT)),
256            runner,
257        }
258    }
259
260    #[test]
261    fn operation_names_are_the_contract_spellings() {
262        assert_eq!(Operation::Rotate.as_str(), "rotate");
263        assert_eq!(Operation::Verify.as_str(), "verify");
264        assert_eq!(Operation::Terminate.as_str(), "terminate");
265    }
266
267    #[tokio::test]
268    async fn payload_carries_the_operation_and_identity() {
269        let runner = Arc::new(RecordingRunner::exiting(0));
270        let integ = integration(runner.clone());
271
272        integ.run(&ctx(), Operation::Rotate).await.unwrap();
273
274        let payload = runner.only_payload();
275        assert_eq!(payload["operation"], "rotate");
276        assert_eq!(payload["targetSystemId"], Uuid::nil().to_string().as_str());
277        assert_eq!(payload["accountIdentity"], "[email protected]");
278        assert_eq!(payload["newPassword"], "super-secret-pw-SENTINEL");
279    }
280
281    #[tokio::test]
282    async fn payload_carries_new_password_for_verify() {
283        let runner = Arc::new(RecordingRunner::exiting(0));
284        let integ = integration(runner.clone());
285
286        integ.run(&ctx(), Operation::Verify).await.unwrap();
287
288        let payload = runner.only_payload();
289        assert_eq!(payload["operation"], "verify");
290        assert_eq!(payload["newPassword"], "super-secret-pw-SENTINEL");
291    }
292
293    #[tokio::test]
294    async fn payload_omits_new_password_for_terminate() {
295        let runner = Arc::new(RecordingRunner::exiting(0));
296        let integ = integration(runner.clone());
297
298        integ.run(&ctx(), Operation::Terminate).await.unwrap();
299
300        let payload = runner.only_payload();
301        assert_eq!(payload["operation"], "terminate");
302        assert!(
303            payload.get("newPassword").is_none(),
304            "newPassword must be absent for terminate: {payload}"
305        );
306    }
307
308    #[tokio::test]
309    async fn payload_excludes_the_keys_that_configure_the_connector() {
310        let mut creds = ResolvedCredentials::new();
311        creds.insert("SCRIPT".to_string(), SH_SCRIPT.to_string());
312        creds.insert("SCRIPT_TYPE".to_string(), "direct".to_string());
313        creds.insert("OUT_PATH".to_string(), "/tmp/out".to_string());
314
315        let runner = Arc::new(RecordingRunner::exiting(0));
316        let integ = integration(runner.clone());
317
318        integ
319            .run(&ctx_with(creds), Operation::Rotate)
320            .await
321            .unwrap();
322
323        let payload = runner.only_payload();
324        assert!(payload["credentials"].get("SCRIPT").is_none());
325        assert!(payload["credentials"].get("SCRIPT_TYPE").is_none());
326        assert!(
327            payload["credentials"].get("OUT_PATH").is_some(),
328            "unrelated credentials are still forwarded: {payload}"
329        );
330    }
331
332    async fn outcome_of(
333        runner: RecordingRunner,
334        operation: Operation,
335    ) -> Result<(), IntegrationError> {
336        integration(Arc::new(runner)).run(&ctx(), operation).await
337    }
338
339    #[tokio::test]
340    async fn exit_codes_map_to_sync_state() {
341        outcome_of(RecordingRunner::exiting(0), Operation::Rotate)
342            .await
343            .unwrap();
344
345        for (code, retry, effect) in [
346            (1, Retry::Permanent, TargetEffect::NotApplied),
347            (2, Retry::Permanent, TargetEffect::Applied),
348            (3, Retry::Permanent, TargetEffect::Unknown),
349            (4, Retry::Transient, TargetEffect::NotApplied),
350            (99, Retry::Permanent, TargetEffect::Unknown),
351        ] {
352            let err = outcome_of(RecordingRunner::exiting(code), Operation::Rotate)
353                .await
354                .unwrap_err();
355            assert_eq!(err.retry, retry, "exit {code}");
356            assert_eq!(err.effect, effect, "exit {code}");
357        }
358
359        // An unknown code means "unknown" only for rotate; nothing else can have changed the
360        // credential.
361        let err = outcome_of(RecordingRunner::exiting(99), Operation::Verify)
362            .await
363            .unwrap_err();
364        assert_eq!(err.effect, TargetEffect::NotApplied);
365    }
366
367    #[tokio::test]
368    async fn timeout_effect_depends_on_the_operation() {
369        for (operation, effect) in [
370            (Operation::Rotate, TargetEffect::Unknown),
371            (Operation::Verify, TargetEffect::Applied),
372            (Operation::Terminate, TargetEffect::NotApplied),
373        ] {
374            let err = outcome_of(RecordingRunner::failing(|| InvokeError::Timeout), operation)
375                .await
376                .unwrap_err();
377            assert_eq!(
378                err.code,
379                RotationFailureCode::ScriptTimeout,
380                "{operation:?}"
381            );
382            assert_eq!(err.effect, effect, "{operation:?}");
383        }
384    }
385}