1pub mod registry;
7
8#[cfg(feature = "secrets-aws-sm")]
9mod aws_sm;
10#[cfg(feature = "secrets-azure-kv")]
11mod azure_kv;
12#[cfg(feature = "secrets-gcp-sm")]
13mod gcp_sm;
14#[cfg(feature = "secrets-vault")]
15mod vault;
16
17use crate::config::PipelineConfig;
18use crate::error::{CliError, CliResult};
19use crate::interpolate::{self, Directive};
20use async_trait::async_trait;
21use futures::stream::{self, StreamExt, TryStreamExt};
22use serde_json::Value;
23use std::collections::{BTreeSet, HashMap};
24use std::sync::Arc;
25
26pub const SECRET_SCHEMES: &[&str] = &["vault", "aws-sm", "gcp-sm", "azure-kv"];
28
29pub type SecretRef = (String, String);
31
32#[async_trait]
33pub trait SecretResolver: Send + Sync {
34 fn scheme(&self) -> &'static str;
36 async fn resolve(&self, reference: &str) -> CliResult<String>;
38}
39
40#[allow(dead_code)] pub(crate) fn split_field(reference: &str) -> (&str, Option<&str>) {
43 match reference.split_once('#') {
44 Some((path, field)) => (path, Some(field)),
45 None => (reference, None),
46 }
47}
48
49#[allow(dead_code)] pub(crate) fn extract_field(
53 scheme: &str,
54 reference: &str,
55 body: &str,
56 field: &str,
57) -> CliResult<String> {
58 let json: Value = serde_json::from_str(body).map_err(|_| CliError::SecretNotJson {
59 scheme: scheme.to_owned(),
60 reference: reference.to_owned(),
61 })?;
62 let obj = json.as_object().ok_or_else(|| CliError::SecretNotJson {
63 scheme: scheme.to_owned(),
64 reference: reference.to_owned(),
65 })?;
66 match obj.get(field) {
67 Some(Value::String(s)) => Ok(s.clone()),
68 Some(other) => Ok(other.to_string()),
69 None => Err(CliError::SecretFieldMissing {
70 scheme: scheme.to_owned(),
71 reference: reference.to_owned(),
72 field: field.to_owned(),
73 available: obj.keys().cloned().collect(),
74 }),
75 }
76}
77
78fn for_each_string<F: FnMut(&str)>(value: &Value, f: &mut F) {
80 match value {
81 Value::String(s) => f(s),
82 Value::Array(a) => a.iter().for_each(|v| for_each_string(v, f)),
83 Value::Object(m) => m.values().for_each(|v| for_each_string(v, f)),
84 _ => {}
85 }
86}
87
88fn for_each_string_mut<F: FnMut(&mut String) -> CliResult<()>>(
90 value: &mut Value,
91 f: &mut F,
92) -> CliResult<()> {
93 match value {
94 Value::String(s) => f(s),
95 Value::Array(a) => a.iter_mut().try_for_each(|v| for_each_string_mut(v, f)),
96 Value::Object(m) => m.values_mut().try_for_each(|v| for_each_string_mut(v, f)),
97 _ => Ok(()),
98 }
99}
100
101fn collect_refs_in_str(s: &str, out: &mut BTreeSet<SecretRef>) {
103 for (_token, dir) in interpolate::iter_directives(s) {
104 if let Directive::LoadTime { prefix, body } = dir
105 && SECRET_SCHEMES.contains(&prefix)
106 {
107 out.insert((prefix.to_owned(), body.to_owned()));
108 }
109 }
110}
111
112pub fn collect_refs(value: &Value, out: &mut BTreeSet<SecretRef>) {
114 for_each_string(value, &mut |s| collect_refs_in_str(s, out));
115}
116
117pub fn substitute(value: &mut Value, cache: &HashMap<SecretRef, String>) -> CliResult<()> {
120 for_each_string_mut(value, &mut |s| {
121 let new = interpolate::rewrite(s, |body| match interpolate::classify_directive(body) {
122 Directive::LoadTime { prefix, body: b } if SECRET_SCHEMES.contains(&prefix) => {
123 Ok(Some(
124 cache
125 .get(&(prefix.to_owned(), b.to_owned()))
126 .cloned()
127 .expect("scan collected every secret ref before fetch"),
128 ))
129 }
130 _ => Ok(None),
131 })?;
132 *s = new;
133 Ok(())
134 })
135}
136
137#[derive(Default, Clone)]
139pub struct ResolverSet {
140 resolvers: HashMap<&'static str, Arc<dyn SecretResolver>>,
141}
142
143impl ResolverSet {
144 pub fn insert(&mut self, resolver: Arc<dyn SecretResolver>) {
145 self.resolvers.insert(resolver.scheme(), resolver);
146 }
147 fn get(&self, scheme: &str) -> Option<&Arc<dyn SecretResolver>> {
148 self.resolvers.get(scheme)
149 }
150}
151
152fn make_resolver(scheme: &str) -> CliResult<Arc<dyn SecretResolver>> {
156 match scheme {
157 #[cfg(feature = "secrets-vault")]
158 "vault" => Ok(Arc::new(vault::VaultResolver::from_env()?)),
159 #[cfg(feature = "secrets-aws-sm")]
160 "aws-sm" => Ok(Arc::new(aws_sm::AwsSmResolver::new())),
161 #[cfg(feature = "secrets-gcp-sm")]
162 "gcp-sm" => Ok(Arc::new(gcp_sm::GcpSmResolver::new())),
163 #[cfg(feature = "secrets-azure-kv")]
164 "azure-kv" => Ok(Arc::new(azure_kv::AzureKvResolver::new())),
165 other => Err(CliError::SecretBackendDisabled {
166 scheme: other.to_owned(),
167 }),
168 }
169}
170
171fn visit_config_values<F: FnMut(&Value)>(cfg: &PipelineConfig, mut f: F) {
174 if let Some(auth) = cfg.auth.as_ref() {
178 for spec in auth.values() {
179 f(spec);
180 }
181 }
182 if let Some(vars) = cfg.vars.as_ref() {
183 for v in vars.values() {
184 f(v);
185 }
186 }
187 for spec in cfg.pipeline.sources.values() {
188 f(&spec.config);
189 }
190 for spec in cfg.pipeline.sinks.values() {
191 f(&spec.config);
192 }
193 if let Some(spec) = cfg.pipeline.source.as_ref() {
194 f(&spec.config);
195 }
196 if let Some(spec) = cfg.pipeline.sink.as_ref() {
197 f(&spec.config);
198 }
199 for t in cfg.pipeline.transforms.iter() {
200 f(&t.config);
201 }
202 if let Some(s) = cfg.pipeline.state.as_ref() {
203 f(&s.config);
204 }
205 if let Some(d) = cfg.pipeline.dlq.as_ref() {
206 f(&d.sink.config);
207 }
208 for row in cfg.matrix.iter() {
209 if let Some(p) = row.source.as_ref()
210 && let Some(c) = p.config.as_ref()
211 {
212 f(c);
213 }
214 if let Some(p) = row.sink.as_ref()
215 && let Some(c) = p.config.as_ref()
216 {
217 f(c);
218 }
219 if let Some(ts) = row.transforms.as_ref() {
220 for t in ts.iter() {
221 f(&t.config);
222 }
223 }
224 if let Some(s) = row.state.as_ref() {
225 f(&s.config);
226 }
227 if let Some(Some(d)) = row.dlq.as_ref() {
228 f(&d.sink.config);
229 }
230 }
231}
232
233fn visit_config_values_mut<F: FnMut(&mut Value) -> CliResult<()>>(
235 cfg: &mut PipelineConfig,
236 mut f: F,
237) -> CliResult<()> {
238 if let Some(auth) = cfg.auth.as_mut() {
241 for spec in auth.values_mut() {
242 f(spec)?;
243 }
244 }
245 if let Some(vars) = cfg.vars.as_mut() {
246 for v in vars.values_mut() {
247 f(v)?;
248 }
249 }
250 for spec in cfg.pipeline.sources.values_mut() {
251 f(&mut spec.config)?;
252 }
253 for spec in cfg.pipeline.sinks.values_mut() {
254 f(&mut spec.config)?;
255 }
256 if let Some(spec) = cfg.pipeline.source.as_mut() {
257 f(&mut spec.config)?;
258 }
259 if let Some(spec) = cfg.pipeline.sink.as_mut() {
260 f(&mut spec.config)?;
261 }
262 for t in cfg.pipeline.transforms.iter_mut() {
263 f(&mut t.config)?;
264 }
265 if let Some(s) = cfg.pipeline.state.as_mut() {
266 f(&mut s.config)?;
267 }
268 if let Some(d) = cfg.pipeline.dlq.as_mut() {
269 f(&mut d.sink.config)?;
270 }
271 for row in cfg.matrix.iter_mut() {
272 if let Some(p) = row.source.as_mut()
273 && let Some(c) = p.config.as_mut()
274 {
275 f(c)?;
276 }
277 if let Some(p) = row.sink.as_mut()
278 && let Some(c) = p.config.as_mut()
279 {
280 f(c)?;
281 }
282 if let Some(ts) = row.transforms.as_mut() {
283 for t in ts.iter_mut() {
284 f(&mut t.config)?;
285 }
286 }
287 if let Some(s) = row.state.as_mut() {
288 f(&mut s.config)?;
289 }
290 if let Some(Some(d)) = row.dlq.as_mut() {
291 f(&mut d.sink.config)?;
292 }
293 }
294 Ok(())
295}
296
297pub(crate) fn scan_config(cfg: &PipelineConfig) -> BTreeSet<SecretRef> {
299 let mut refs = BTreeSet::new();
300 visit_config_values(cfg, |v| collect_refs(v, &mut refs));
301 refs
302}
303
304pub fn scan_path_refs(path: &std::path::Path) -> CliResult<BTreeSet<SecretRef>> {
306 let cfg = PipelineConfig::from_path_tolerating_secrets(path)?;
307 Ok(scan_config(&cfg))
308}
309
310pub fn ensure_no_secret_directives(cfg: &PipelineConfig) -> CliResult<()> {
313 if scan_config(cfg).is_empty() {
314 Ok(())
315 } else {
316 Err(CliError::SecretsRequireAsyncLoad)
317 }
318}
319
320pub async fn resolve_secrets(cfg: &mut PipelineConfig) -> CliResult<()> {
323 let refs = scan_config(cfg);
324 if refs.is_empty() {
325 return Ok(());
326 }
327 let mut set = ResolverSet::default();
328 let schemes: BTreeSet<&str> = refs.iter().map(|(s, _)| s.as_str()).collect();
329 for scheme in schemes {
330 set.insert(make_resolver(scheme)?);
331 }
332 resolve_secrets_with(cfg, &set).await
333}
334
335pub async fn resolve_secrets_with(cfg: &mut PipelineConfig, set: &ResolverSet) -> CliResult<()> {
338 let refs = scan_config(cfg);
339 if refs.is_empty() {
340 return Ok(());
341 }
342 let cache = fetch_all(&refs, set).await?;
343 visit_config_values_mut(cfg, |v| substitute(v, &cache))
344}
345
346async fn fetch_all(
349 refs: &BTreeSet<SecretRef>,
350 set: &ResolverSet,
351) -> CliResult<HashMap<SecretRef, String>> {
352 const MAX_CONCURRENCY: usize = 8;
353 let pairs: Vec<(SecretRef, String)> =
354 stream::iter(refs.iter().cloned())
355 .map(|(scheme, reference)| async move {
356 let resolver = Arc::clone(set.get(&scheme).ok_or_else(|| {
359 CliError::SecretBackendDisabled {
360 scheme: scheme.clone(),
361 }
362 })?);
363 let value = resolver.resolve(&reference).await?;
364 registry::register(&value);
365 Ok::<(SecretRef, String), CliError>(((scheme, reference), value))
366 })
367 .buffer_unordered(MAX_CONCURRENCY)
368 .try_collect()
369 .await?;
370 Ok(pairs.into_iter().collect())
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use serde_json::json;
377
378 #[test]
379 fn collects_unique_refs_and_ignores_other_directives() {
380 let v = json!({
381 "a": "${vault:secret/data/app#token}",
382 "b": "${aws-sm:prod/db#password}",
383 "c": "${vault:secret/data/app#token}",
384 "d": "${users.id}",
385 "e": "${env:HOME}",
386 "nested": ["${gcp-sm:projects/p/secrets/s/versions/latest}"]
387 });
388 let mut refs = BTreeSet::new();
389 collect_refs(&v, &mut refs);
390 assert_eq!(refs.len(), 3);
391 assert!(refs.contains(&("vault".into(), "secret/data/app#token".into())));
392 assert!(refs.contains(&("aws-sm".into(), "prod/db#password".into())));
393 assert!(refs.contains(&(
394 "gcp-sm".into(),
395 "projects/p/secrets/s/versions/latest".into()
396 )));
397 }
398
399 #[test]
400 fn substitutes_from_cache_and_preserves_runtime_refs() {
401 let mut v = json!({
402 "token": "Bearer ${vault:secret/data/app#token}",
403 "path": "/v1/${users.id}"
404 });
405 let mut cache = HashMap::new();
406 cache.insert(
407 ("vault".into(), "secret/data/app#token".into()),
408 "abc123".into(),
409 );
410 substitute(&mut v, &cache).unwrap();
411 assert_eq!(v["token"], "Bearer abc123");
412 assert_eq!(v["path"], "/v1/${users.id}");
413 }
414
415 #[test]
416 fn extract_field_picks_key_or_errors_with_available() {
417 let body = r#"{"username":"u","password":"p"}"#;
418 assert_eq!(
419 extract_field("aws-sm", "ref", body, "password").unwrap(),
420 "p"
421 );
422 match extract_field("aws-sm", "ref", body, "missing").unwrap_err() {
423 CliError::SecretFieldMissing { available, .. } => {
424 assert!(available.contains(&"username".to_string()));
425 }
426 other => panic!("expected SecretFieldMissing, got {other:?}"),
427 }
428 match extract_field("aws-sm", "ref", "not json", "x").unwrap_err() {
429 CliError::SecretNotJson { .. } => {}
430 other => panic!("expected SecretNotJson, got {other:?}"),
431 }
432 }
433
434 struct FakeResolver {
435 scheme: &'static str,
436 value: String,
437 }
438 #[async_trait]
439 impl SecretResolver for FakeResolver {
440 fn scheme(&self) -> &'static str {
441 self.scheme
442 }
443 async fn resolve(&self, _reference: &str) -> CliResult<String> {
444 Ok(self.value.clone())
445 }
446 }
447
448 #[tokio::test]
449 async fn resolve_secrets_with_substitutes_via_injected_resolvers() {
450 let mut set = ResolverSet::default();
451 set.insert(Arc::new(FakeResolver {
452 scheme: "vault",
453 value: "RESOLVED".into(),
454 }));
455 let cfg_yaml = r#"
456version: 1
457pipeline:
458 source: { type: rest, config: { base_url: https://x, auth: { type: bearer, config: { token: "${vault:secret/data/app#token}" } } } }
459 sink: { type: jsonl, config: { path: ./o.jsonl } }
460"#;
461 let mut cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
462 resolve_secrets_with(&mut cfg, &set).await.unwrap();
463 let token = &cfg.pipeline.source.as_ref().unwrap().config["auth"]["config"]["token"];
464 assert_eq!(token, "RESOLVED");
465 }
466
467 #[tokio::test]
468 async fn resolve_secrets_resolves_auth_catalog_and_vars_block() {
469 let mut set = ResolverSet::default();
473 set.insert(Arc::new(FakeResolver {
474 scheme: "vault",
475 value: "RESOLVED".into(),
476 }));
477 let cfg_yaml = r#"
478version: 1
479vars:
480 shared_token: "${vault:secret/data/app#token}"
481auth:
482 idp: { type: static, config: { token: "${vault:secret/data/idp#token}" } }
483pipeline:
484 source: { type: rest, config: { base_url: https://x, auth: { ref: idp } } }
485 sink: { type: jsonl, config: { path: ./o.jsonl } }
486"#;
487 let mut cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
488 resolve_secrets_with(&mut cfg, &set).await.unwrap();
489
490 let auth_token = &cfg.auth.as_ref().unwrap()["idp"]["config"]["token"];
491 assert_eq!(auth_token, "RESOLVED", "auth-catalog secret should resolve");
492
493 let var_value = &cfg.vars.as_ref().unwrap()["shared_token"];
494 assert_eq!(var_value, "RESOLVED", "vars-block secret should resolve");
495 }
496
497 #[tokio::test]
498 async fn scan_config_collects_refs_from_auth_and_vars() {
499 let cfg_yaml = r#"
502version: 1
503vars:
504 v: "${aws-sm:prod/api#key}"
505auth:
506 idp: { type: static, config: { token: "${vault:secret/data/idp#token}" } }
507pipeline:
508 source: { type: rest, config: { base_url: https://x, auth: { ref: idp } } }
509 sink: { type: jsonl, config: { path: ./o.jsonl } }
510"#;
511 let cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
512 let refs = scan_config(&cfg);
513 assert!(refs.contains(&("vault".into(), "secret/data/idp#token".into())));
514 assert!(refs.contains(&("aws-sm".into(), "prod/api#key".into())));
515 }
516
517 #[tokio::test]
518 async fn resolve_secrets_errors_when_backend_not_built() {
519 let set = ResolverSet::default();
520 let cfg_yaml = r#"
521version: 1
522pipeline:
523 source: { type: rest, config: { url: "${vault:secret/x}" } }
524 sink: { type: jsonl, config: { path: ./o.jsonl } }
525"#;
526 let mut cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
527 match resolve_secrets_with(&mut cfg, &set).await.unwrap_err() {
528 CliError::SecretBackendDisabled { scheme } => assert_eq!(scheme, "vault"),
529 other => panic!("expected SecretBackendDisabled, got {other:?}"),
530 }
531 }
532
533 #[test]
534 fn ensure_no_secret_directives_flags_vault() {
535 let cfg_yaml = r#"
536version: 1
537pipeline:
538 source: { type: rest, config: { url: "${vault:secret/x}" } }
539 sink: { type: jsonl, config: { path: ./o.jsonl } }
540"#;
541 let cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
542 assert!(matches!(
543 ensure_no_secret_directives(&cfg),
544 Err(CliError::SecretsRequireAsyncLoad)
545 ));
546 }
547}