1use std::{collections::HashMap, sync::Arc};
6
7use async_trait::async_trait;
8use bitwarden_pam_sealed::SealedTargetSystemKind;
9use uuid::Uuid;
10
11use super::{CredentialResolver, ResolveError, ResolvedCredentials};
12use crate::{
13 resolver::env::{
14 BASE_DN_SUFFIX, BIND_DN_SUFFIX, CA_CERTIFICATE_SUFFIX, LDAP_HOST_SUFFIX, prefix_for,
15 required_suffixes,
16 },
17 sys::EnvSource,
18};
19
20#[derive(Debug, Clone, serde::Deserialize)]
23#[serde(deny_unknown_fields)]
24pub(crate) struct TargetEntry {
25 pub(crate) script: Option<String>,
27 pub(crate) script_type: Option<crate::integrations::scripting::ScriptType>,
29 pub(crate) tenant_id: Option<String>,
31 pub(crate) client_id: Option<String>,
33 pub(crate) ldap_host: Option<String>,
35 pub(crate) bind_dn: Option<String>,
37 pub(crate) base_dn: Option<String>,
39 pub(crate) ca_certificate: Option<String>,
41}
42
43impl TargetEntry {
44 fn overrides(&self) -> impl Iterator<Item = (&'static str, &str)> {
45 [
46 ("SCRIPT", self.script.as_deref()),
47 ("SCRIPT_TYPE", self.script_type.map(|t| t.as_str())),
48 ("TENANT_ID", self.tenant_id.as_deref()),
49 ("CLIENT_ID", self.client_id.as_deref()),
50 (LDAP_HOST_SUFFIX, self.ldap_host.as_deref()),
51 (BIND_DN_SUFFIX, self.bind_dn.as_deref()),
52 (BASE_DN_SUFFIX, self.base_dn.as_deref()),
53 (CA_CERTIFICATE_SUFFIX, self.ca_certificate.as_deref()),
54 ]
55 .into_iter()
56 .filter_map(|(suffix, opt)| opt.map(|v| (suffix, v)))
57 }
58}
59
60pub(crate) struct ConfigCredentialResolver {
63 targets: HashMap<Uuid, TargetEntry>,
64 env: Arc<dyn EnvSource>,
65}
66
67impl ConfigCredentialResolver {
68 pub(crate) fn new(targets: HashMap<Uuid, TargetEntry>, env: Arc<dyn EnvSource>) -> Self {
69 Self { targets, env }
70 }
71}
72
73#[async_trait]
74impl CredentialResolver for ConfigCredentialResolver {
75 async fn resolve(
76 &self,
77 target_system_id: Uuid,
78 kind: SealedTargetSystemKind,
79 ) -> Result<ResolvedCredentials, ResolveError> {
80 let prefix = prefix_for(target_system_id);
81 let required = required_suffixes(kind);
82
83 let mut creds = ResolvedCredentials::new();
84 for (name, value) in self.env.vars() {
85 if let Some(suffix) = name.strip_prefix(&prefix)
86 && !suffix.is_empty()
87 {
88 creds.insert(suffix.to_string(), value);
89 }
90 }
91
92 if let Some(entry) = self.targets.get(&target_system_id) {
94 for (suffix, value) in entry.overrides() {
95 creds.insert(suffix.to_string(), value.to_string());
96 }
97 }
98
99 let missing: Vec<String> = required
100 .iter()
101 .filter(|&&suffix| creds.get(suffix).is_none())
102 .map(|&suffix| format!("{prefix}{suffix}"))
103 .collect();
104
105 if !missing.is_empty() {
106 return Err(ResolveError::Missing(missing));
107 }
108
109 Ok(creds)
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use std::collections::HashMap;
116
117 use uuid::Uuid;
118
119 use super::*;
120 use crate::{resolver::env::prefix_for, sys::FakeEnv};
121
122 fn run_resolver_with_env(
124 id: Uuid,
125 kind: SealedTargetSystemKind,
126 targets: HashMap<Uuid, TargetEntry>,
127 vars: &HashMap<String, String>,
128 ) -> Result<ResolvedCredentials, ResolveError> {
129 let env = vars
130 .iter()
131 .fold(FakeEnv::empty(), |env, (k, v)| env.with(k, v));
132 let resolver = ConfigCredentialResolver::new(targets, Arc::new(env));
133
134 let rt = tokio::runtime::Builder::new_current_thread()
135 .build()
136 .unwrap();
137 rt.block_on(resolver.resolve(id, kind))
138 }
139
140 #[test]
141 fn config_only_custom_script_resolved() {
142 let id = Uuid::new_v4();
143 let mut targets = HashMap::new();
144 targets.insert(
145 id,
146 TargetEntry {
147 script: Some("/opt/scripts/rotate.sh".to_string()),
148 script_type: None,
149 tenant_id: None,
150 client_id: None,
151 ldap_host: None,
152 bind_dn: None,
153 base_dn: None,
154 ca_certificate: None,
155 },
156 );
157 let creds = run_resolver_with_env(
158 id,
159 SealedTargetSystemKind::CustomScript,
160 targets,
161 &HashMap::new(),
162 )
163 .expect("config-only script should resolve");
164 use bitwarden_sensitive_value::ExposeSensitive as _;
165 let script_val = creds
166 .get("SCRIPT")
167 .expect("SCRIPT must be present")
168 .expose();
169 assert_eq!(*script_val, "/opt/scripts/rotate.sh");
170 }
171
172 #[test]
173 fn config_tenant_id_shadows_env_var() {
174 let id = Uuid::new_v4();
175 let prefix = prefix_for(id);
176
177 let mut targets = HashMap::new();
178 targets.insert(
179 id,
180 TargetEntry {
181 script: None,
182 script_type: None,
183 tenant_id: Some("config-tenant".to_string()),
184 client_id: None,
185 ldap_host: None,
186 bind_dn: None,
187 base_dn: None,
188 ca_certificate: None,
189 },
190 );
191
192 let mut vars = HashMap::new();
193 vars.insert(format!("{prefix}TENANT_ID"), "env-tenant".to_string());
194 vars.insert(format!("{prefix}CLIENT_ID"), "my-client".to_string());
195 vars.insert(format!("{prefix}CLIENT_SECRET"), "my-secret".to_string());
196
197 let creds = run_resolver_with_env(id, SealedTargetSystemKind::Entra, targets, &vars)
198 .expect("should resolve with config override");
199
200 use bitwarden_sensitive_value::ExposeSensitive as _;
201 let tenant = creds.get("TENANT_ID").expect("TENANT_ID present").expose();
202 assert_eq!(*tenant, "config-tenant");
203
204 let client = creds.get("CLIENT_ID").expect("CLIENT_ID present").expose();
205 assert_eq!(*client, "my-client");
206
207 assert!(creds.get("CLIENT_SECRET").is_some());
208 }
209
210 #[test]
211 fn active_directory_config_keys_resolve_with_env_only_password() {
212 let id = Uuid::new_v4();
213 let prefix = prefix_for(id);
214
215 let mut targets = HashMap::new();
216 targets.insert(
217 id,
218 TargetEntry {
219 script: None,
220 script_type: None,
221 tenant_id: None,
222 client_id: None,
223 ldap_host: Some("dc01.corp.example".to_string()),
224 bind_dn: Some("CN=svc-rotate,OU=Service Accounts,DC=corp,DC=example".to_string()),
225 base_dn: Some("DC=corp,DC=example".to_string()),
226 ca_certificate: None,
227 },
228 );
229
230 let mut vars = HashMap::new();
231 vars.insert(format!("{prefix}BIND_PASSWORD"), "bind-secret".to_string());
232
233 let creds =
234 run_resolver_with_env(id, SealedTargetSystemKind::ActiveDirectory, targets, &vars)
235 .expect("config host/DNs plus env password should resolve");
236
237 use bitwarden_sensitive_value::ExposeSensitive as _;
238 assert_eq!(
239 *creds.get("LDAP_HOST").expect("host").expose(),
240 "dc01.corp.example"
241 );
242 assert_eq!(
243 *creds.get("BASE_DN").expect("base dn").expose(),
244 "DC=corp,DC=example"
245 );
246 assert!(creds.get("BIND_PASSWORD").is_some());
247 }
248
249 #[test]
250 fn active_directory_bind_password_absent_is_reported_as_env_var() {
251 let id = Uuid::new_v4();
252 let prefix = prefix_for(id);
253
254 let mut targets = HashMap::new();
255 targets.insert(
256 id,
257 TargetEntry {
258 script: None,
259 script_type: None,
260 tenant_id: None,
261 client_id: None,
262 ldap_host: Some("dc01.corp.example".to_string()),
263 bind_dn: Some("CN=svc-rotate,DC=corp,DC=example".to_string()),
264 base_dn: Some("DC=corp,DC=example".to_string()),
265 ca_certificate: None,
266 },
267 );
268
269 let err = run_resolver_with_env(
270 id,
271 SealedTargetSystemKind::ActiveDirectory,
272 targets,
273 &HashMap::new(),
274 )
275 .expect_err("bind password cannot come from the config file");
276
277 match err {
278 ResolveError::Missing(names) => {
279 assert_eq!(names, vec![format!("{prefix}BIND_PASSWORD")]);
280 }
281 }
282 }
283
284 #[test]
285 fn active_directory_bind_password_in_config_is_an_unknown_field() {
286 let toml_src = r#"
287ldap_host = "dc01.corp.example"
288bind_password = "should-not-be-accepted"
289"#;
290 let err = toml::from_str::<TargetEntry>(toml_src)
291 .expect_err("bind_password must not be accepted in a [targets] entry");
292 assert!(
293 err.to_string().contains("unknown field `bind_password`"),
294 "must be rejected as an unknown field: {err}"
295 );
296 }
297
298 #[test]
299 fn active_directory_ca_certificate_config_shadows_env() {
300 let id = Uuid::new_v4();
301 let prefix = prefix_for(id);
302
303 let mut targets = HashMap::new();
304 targets.insert(
305 id,
306 TargetEntry {
307 script: None,
308 script_type: None,
309 tenant_id: None,
310 client_id: None,
311 ldap_host: Some("dc01.corp.example".to_string()),
312 bind_dn: Some("CN=svc-rotate,DC=corp,DC=example".to_string()),
313 base_dn: Some("DC=corp,DC=example".to_string()),
314 ca_certificate: Some("/etc/pki/from-config.pem".to_string()),
315 },
316 );
317
318 let mut vars = HashMap::new();
319 vars.insert(format!("{prefix}BIND_PASSWORD"), "bind-secret".to_string());
320 vars.insert(
321 format!("{prefix}CA_CERTIFICATE"),
322 "/etc/pki/from-env.pem".to_string(),
323 );
324
325 let creds =
326 run_resolver_with_env(id, SealedTargetSystemKind::ActiveDirectory, targets, &vars)
327 .expect("should resolve");
328
329 use bitwarden_sensitive_value::ExposeSensitive as _;
330 assert_eq!(
331 *creds.get("CA_CERTIFICATE").expect("ca cert").expose(),
332 "/etc/pki/from-config.pem",
333 "config must win over env for ca_certificate"
334 );
335 }
336
337 #[test]
338 fn active_directory_ca_certificate_falls_back_to_env() {
339 let id = Uuid::new_v4();
340 let prefix = prefix_for(id);
341
342 let mut targets = HashMap::new();
343 targets.insert(
344 id,
345 TargetEntry {
346 script: None,
347 script_type: None,
348 tenant_id: None,
349 client_id: None,
350 ldap_host: Some("dc01.corp.example".to_string()),
351 bind_dn: Some("CN=svc-rotate,DC=corp,DC=example".to_string()),
352 base_dn: Some("DC=corp,DC=example".to_string()),
353 ca_certificate: None,
354 },
355 );
356
357 let mut vars = HashMap::new();
358 vars.insert(format!("{prefix}BIND_PASSWORD"), "bind-secret".to_string());
359 vars.insert(
360 format!("{prefix}CA_CERTIFICATE"),
361 "/etc/pki/from-env.pem".to_string(),
362 );
363
364 let creds =
365 run_resolver_with_env(id, SealedTargetSystemKind::ActiveDirectory, targets, &vars)
366 .expect("should resolve");
367
368 use bitwarden_sensitive_value::ExposeSensitive as _;
369 assert_eq!(
370 *creds.get("CA_CERTIFICATE").expect("ca cert").expose(),
371 "/etc/pki/from-env.pem"
372 );
373 }
374
375 #[test]
376 fn active_directory_ca_certificate_absent_from_both_sources() {
377 let id = Uuid::new_v4();
378 let prefix = prefix_for(id);
379
380 let mut vars = HashMap::new();
381 vars.insert(
382 format!("{prefix}LDAP_HOST"),
383 "dc01.corp.example".to_string(),
384 );
385 vars.insert(format!("{prefix}BIND_DN"), "CN=svc,DC=corp".to_string());
386 vars.insert(format!("{prefix}BASE_DN"), "DC=corp".to_string());
387 vars.insert(format!("{prefix}BIND_PASSWORD"), "bind-secret".to_string());
388
389 let creds = run_resolver_with_env(
390 id,
391 SealedTargetSystemKind::ActiveDirectory,
392 HashMap::new(),
393 &vars,
394 )
395 .expect("ca_certificate is optional");
396 assert!(
397 creds.get("CA_CERTIFICATE").is_none(),
398 "an unset ca_certificate must stay unset"
399 );
400 }
401
402 #[test]
403 fn env_fallback_when_config_absent() {
404 let id = Uuid::new_v4();
405 let prefix = prefix_for(id);
406
407 let targets: HashMap<Uuid, TargetEntry> = HashMap::new();
408
409 let mut vars = HashMap::new();
410 vars.insert(
411 format!("{prefix}SCRIPT"),
412 "/usr/local/bin/rotate.sh".to_string(),
413 );
414
415 let creds = run_resolver_with_env(id, SealedTargetSystemKind::CustomScript, targets, &vars)
416 .expect("env fallback should resolve");
417
418 use bitwarden_sensitive_value::ExposeSensitive as _;
419 let script = creds.get("SCRIPT").expect("SCRIPT present").expose();
420 assert_eq!(*script, "/usr/local/bin/rotate.sh");
421 }
422
423 #[test]
424 fn missing_key_reports_env_var_name() {
425 let id = Uuid::new_v4();
426 let prefix = prefix_for(id);
427
428 let mut targets = HashMap::new();
429 targets.insert(
430 id,
431 TargetEntry {
432 script: None,
433 script_type: None,
434 tenant_id: Some("my-tenant".to_string()),
435 client_id: None,
436 ldap_host: None,
437 bind_dn: None,
438 base_dn: None,
439 ca_certificate: None,
440 },
441 );
442
443 let vars: HashMap<String, String> = HashMap::new();
444
445 let err = run_resolver_with_env(id, SealedTargetSystemKind::Entra, targets, &vars)
446 .expect_err("should fail with missing vars");
447
448 match err {
449 ResolveError::Missing(names) => {
450 assert!(
451 names.iter().any(|n| n == &format!("{prefix}CLIENT_ID")),
452 "must list CLIENT_ID env var: {names:?}"
453 );
454 assert!(
455 names.iter().any(|n| n == &format!("{prefix}CLIENT_SECRET")),
456 "must list CLIENT_SECRET env var: {names:?}"
457 );
458 assert!(
459 !names.iter().any(|n| n.ends_with("TENANT_ID")),
460 "TENANT_ID was in config and must not be listed as missing: {names:?}"
461 );
462 }
463 }
464 }
465
466 #[test]
467 fn entra_all_required_vars_present() {
468 let id = Uuid::new_v4();
469 let prefix = prefix_for(id);
470 let mut vars = HashMap::new();
471 vars.insert(format!("{prefix}TENANT_ID"), "my-tenant".to_string());
472 vars.insert(format!("{prefix}CLIENT_ID"), "my-client".to_string());
473 vars.insert(format!("{prefix}CLIENT_SECRET"), "my-secret".to_string());
474 let creds = run_resolver_with_env(id, SealedTargetSystemKind::Entra, HashMap::new(), &vars)
475 .unwrap();
476 assert!(creds.get("TENANT_ID").is_some());
477 assert!(creds.get("CLIENT_ID").is_some());
478 assert!(creds.get("CLIENT_SECRET").is_some());
479 }
480
481 #[test]
482 fn extra_vars_collected_into_map() {
483 let id = Uuid::new_v4();
484 let prefix = prefix_for(id);
485 let mut vars = HashMap::new();
486 vars.insert(format!("{prefix}SCRIPT"), "/bin/sh".to_string());
487 vars.insert(format!("{prefix}OUT_PATH"), "/tmp/out.txt".to_string());
488 let creds = run_resolver_with_env(
489 id,
490 SealedTargetSystemKind::CustomScript,
491 HashMap::new(),
492 &vars,
493 )
494 .unwrap();
495 assert!(
496 creds.get("OUT_PATH").is_some(),
497 "extra var should be collected"
498 );
499 }
500
501 #[test]
502 fn entra_missing_all_reports_full_names() {
503 let id: Uuid = "ec2c1d46-6a4b-4751-a310-af9601317f2d".parse().unwrap();
504 let err = run_resolver_with_env(
505 id,
506 SealedTargetSystemKind::Entra,
507 HashMap::new(),
508 &HashMap::new(),
509 )
510 .unwrap_err();
511 match err {
512 ResolveError::Missing(names) => {
513 assert!(
514 names.contains(&"EC2C1D46_6A4B_4751_A310_AF9601317F2D_TENANT_ID".to_string()),
515 "must list TENANT_ID: {names:?}"
516 );
517 assert!(
518 names.contains(&"EC2C1D46_6A4B_4751_A310_AF9601317F2D_CLIENT_ID".to_string()),
519 "must list CLIENT_ID: {names:?}"
520 );
521 assert!(
522 names.contains(
523 &"EC2C1D46_6A4B_4751_A310_AF9601317F2D_CLIENT_SECRET".to_string()
524 ),
525 "must list CLIENT_SECRET: {names:?}"
526 );
527 }
528 }
529 }
530
531 #[test]
532 fn custom_script_missing_script_var() {
533 let id = Uuid::new_v4();
534 let err = run_resolver_with_env(
535 id,
536 SealedTargetSystemKind::CustomScript,
537 HashMap::new(),
538 &HashMap::new(),
539 )
540 .unwrap_err();
541 let prefix = prefix_for(id);
542 match err {
543 ResolveError::Missing(names) => {
544 assert!(
545 names.iter().any(|n| n == &format!("{prefix}SCRIPT")),
546 "must list SCRIPT var: {names:?}"
547 );
548 }
549 }
550 }
551
552 #[test]
553 fn partial_entra_missing_lists_only_absent() {
554 let id = Uuid::new_v4();
555 let prefix = prefix_for(id);
556 let mut vars = HashMap::new();
557 vars.insert(format!("{prefix}TENANT_ID"), "t".to_string());
558 let err = run_resolver_with_env(id, SealedTargetSystemKind::Entra, HashMap::new(), &vars)
559 .unwrap_err();
560 match err {
561 ResolveError::Missing(names) => {
562 assert_eq!(names.len(), 2, "only 2 vars missing: {names:?}");
563 assert!(names.iter().any(|n| n.ends_with("CLIENT_ID")));
564 assert!(names.iter().any(|n| n.ends_with("CLIENT_SECRET")));
565 assert!(!names.iter().any(|n| n.ends_with("TENANT_ID")));
566 }
567 }
568 }
569
570 #[test]
571 fn active_directory_all_required_vars_present() {
572 let id = Uuid::new_v4();
573 let prefix = prefix_for(id);
574 let mut vars = HashMap::new();
575 vars.insert(
576 format!("{prefix}LDAP_HOST"),
577 "dc01.corp.example".to_string(),
578 );
579 vars.insert(
580 format!("{prefix}BIND_DN"),
581 "CN=svc-rotate,OU=Service Accounts,DC=corp,DC=example".to_string(),
582 );
583 vars.insert(format!("{prefix}BASE_DN"), "DC=corp,DC=example".to_string());
584 vars.insert(format!("{prefix}BIND_PASSWORD"), "bind-secret".to_string());
585 let creds = run_resolver_with_env(
586 id,
587 SealedTargetSystemKind::ActiveDirectory,
588 HashMap::new(),
589 &vars,
590 )
591 .unwrap();
592 assert!(creds.get("LDAP_HOST").is_some());
593 assert!(creds.get("BIND_DN").is_some());
594 assert!(creds.get("BASE_DN").is_some());
595 assert!(creds.get("BIND_PASSWORD").is_some());
596 }
597
598 #[test]
599 fn active_directory_missing_all_lists_every_required_var() {
600 let id = Uuid::new_v4();
601 let err = run_resolver_with_env(
602 id,
603 SealedTargetSystemKind::ActiveDirectory,
604 HashMap::new(),
605 &HashMap::new(),
606 )
607 .unwrap_err();
608 let prefix = prefix_for(id);
609 match err {
610 ResolveError::Missing(names) => {
611 assert!(names.iter().any(|n| n == &format!("{prefix}LDAP_HOST")));
612 assert!(names.iter().any(|n| n == &format!("{prefix}BIND_DN")));
613 assert!(names.iter().any(|n| n == &format!("{prefix}BASE_DN")));
614 assert!(names.iter().any(|n| n == &format!("{prefix}BIND_PASSWORD")));
615 }
616 }
617 }
618}