Skip to main content

bitwarden_access_connector/resolver/
config.rs

1//! Config-file-based credential resolver: `[targets.<uuid>]` entries win per key over the
2//! target's environment variables. `client_secret` and `bind_password` are env-only, since the
3//! config file is often checked into a repo.
4
5use 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/// Per-target overrides from the `[targets]` TOML section; each `Some` field shadows its env var.
21/// No `client_secret` or `bind_password` field, so secrets stay env-only.
22#[derive(Debug, Clone, serde::Deserialize)]
23#[serde(deny_unknown_fields)]
24pub(crate) struct TargetEntry {
25    /// Path to the custom-script executable (`SCRIPT` suffix).
26    pub(crate) script: Option<String>,
27    /// How to launch the script (`SCRIPT_TYPE` suffix).
28    pub(crate) script_type: Option<crate::integrations::scripting::ScriptType>,
29    /// Azure AD tenant identifier (`TENANT_ID` suffix).
30    pub(crate) tenant_id: Option<String>,
31    /// Application (client) ID of the service principal (`CLIENT_ID` suffix).
32    pub(crate) client_id: Option<String>,
33    /// Host name of the Active Directory domain controller (`LDAP_HOST` suffix).
34    pub(crate) ldap_host: Option<String>,
35    /// DN of the delegated Active Directory service account (`BIND_DN` suffix).
36    pub(crate) bind_dn: Option<String>,
37    /// Search base DN for Active Directory account lookup (`BASE_DN` suffix).
38    pub(crate) base_dn: Option<String>,
39    /// PEM file of extra TLS trust anchors (`CA_CERTIFICATE` suffix); a path, so not env-only.
40    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
60/// Merges config-file overrides over the target's environment variables, reporting a missing
61/// required key by its env var name.
62pub(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        // Config-file values win per key.
93        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    /// Runs the resolver against exactly `vars`, touching no process state.
123    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}