1mod 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub(crate) enum Operation {
70 Rotate,
72 Verify,
74 Terminate,
76}
77
78impl Operation {
79 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 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 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
107pub(crate) struct PowershellConfig {
109 pub(crate) host: Option<PathBuf>,
111 pub(crate) execution_policy: String,
113}
114
115pub(crate) struct CustomScriptIntegration {
117 pub(crate) script_root: Option<PathBuf>,
119 pub(crate) timeout: Duration,
121 pub(crate) powershell: PowershellConfig,
122 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
160fn 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#[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
183fn payload_json(operation: Operation, ctx: &RotateContext) -> Result<Vec<u8>, InvokeError> {
185 use bitwarden_sensitive_value::ExposeSensitive as _;
186
187 let new_password = match operation {
189 Operation::Rotate | Operation::Verify => Some(ctx.new_password.as_str()),
190 Operation::Terminate => None,
191 };
192
193 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 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 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}