1use std::{
2 collections::{BTreeMap, HashSet},
3 env,
4 fs::File,
5 io::Read,
6 path::{Path, PathBuf},
7 sync::Arc,
8};
9
10use anyhow::{Context, Error as AnyhowError, Result as AnyhowResult, anyhow};
11use async_trait::async_trait;
12use greentic_secrets_lib::env::EnvSecretsManager;
13use greentic_secrets_lib::{Result as SecretResult, SecretError, SecretsManager};
14use serde::Deserialize;
15use serde_cbor::value::Value as CborValue;
16use serde_json;
17use tokio::runtime::Builder;
18use tracing::info;
19use zip::{ZipArchive, result::ZipError};
20
21use crate::operator_log;
22use crate::secret_name;
23use crate::secret_value::SecretValue;
24use crate::secrets_backend::SecretsBackendKind;
25use crate::secrets_client::SecretsClient;
26use crate::secrets_manager;
27
28type CborMap = BTreeMap<CborValue, CborValue>;
29
30pub type DynSecretsManager = Arc<dyn SecretsManager>;
31
32struct LoggingSecretsManager {
33 inner: DynSecretsManager,
34 dev_store_path_display: String,
35 using_env_fallback: bool,
36}
37
38impl LoggingSecretsManager {
39 fn new(
40 inner: DynSecretsManager,
41 dev_store_path: Option<&Path>,
42 using_env_fallback: bool,
43 ) -> Self {
44 let dev_store_path_display = dev_store_path
45 .map(|path| path.display().to_string())
46 .unwrap_or_else(|| "<default>".to_string());
47 Self {
48 inner,
49 dev_store_path_display,
50 using_env_fallback,
51 }
52 }
53}
54
55#[async_trait]
56impl SecretsManager for LoggingSecretsManager {
57 async fn read(&self, path: &str) -> SecretResult<Vec<u8>> {
58 operator_log::info(
59 module_path!(),
60 format!(
61 "WASM secrets read requested uri={path}; backend dev_store_path={} using_env_fallback={}",
62 self.dev_store_path_display, self.using_env_fallback,
63 ),
64 );
65 match self.inner.read(path).await {
66 Ok(value) => {
67 operator_log::debug(
68 module_path!(),
69 format!(
70 "WASM secrets read resolved uri={path}; value={}",
71 SecretValue::new(value.as_slice()),
72 ),
73 );
74 Ok(value)
75 }
76 Err(err) => {
77 if let Some(fallback_path) = team_wildcard_fallback(path) {
81 operator_log::info(
82 module_path!(),
83 format!(
84 "WASM secrets read fallback: team-specific not found, trying uri={fallback_path}",
85 ),
86 );
87 if let Ok(value) = self.inner.read(&fallback_path).await {
88 operator_log::debug(
89 module_path!(),
90 format!(
91 "WASM secrets read fallback resolved uri={fallback_path}; value={}",
92 SecretValue::new(value.as_slice()),
93 ),
94 );
95 return Ok(value);
96 }
97 }
98 Err(err)
99 }
100 }
101 }
102
103 async fn write(&self, path: &str, value: &[u8]) -> SecretResult<()> {
104 self.inner.write(path, value).await
105 }
106
107 async fn delete(&self, path: &str) -> SecretResult<()> {
108 self.inner.delete(path).await
109 }
110}
111
112fn team_wildcard_fallback(path: &str) -> Option<String> {
115 let trimmed = path.strip_prefix("secrets://")?;
116 let segments: Vec<&str> = trimmed.split('/').collect();
117 if segments.len() != 5 {
118 return None;
119 }
120 let team = segments[2];
121 if team == "_" || team.is_empty() {
122 return None; }
124 Some(format!(
125 "secrets://{}/{}/{}/{}/{}",
126 segments[0], segments[1], "_", segments[3], segments[4]
127 ))
128}
129const ENV_ALLOW_ENV_SECRETS: &str = "GREENTIC_ALLOW_ENV_SECRETS";
130
131#[derive(Clone)]
132pub struct SecretsManagerHandle {
133 manager: DynSecretsManager,
134 pub selection: secrets_manager::SecretsManagerSelection,
135 pub dev_store_path: Option<PathBuf>,
136 pub canonical_team: String,
137 pub using_env_fallback: bool,
138}
139
140impl SecretsManagerHandle {
141 pub fn manager(&self) -> DynSecretsManager {
142 self.manager.clone()
143 }
144
145 pub fn runtime_manager(&self, _pack_id: Option<&str>) -> DynSecretsManager {
146 Arc::new(LoggingSecretsManager::new(
147 self.manager(),
148 self.dev_store_path.as_deref(),
149 self.using_env_fallback,
150 ))
151 }
152}
153
154pub fn resolve_secrets_manager(
155 bundle_root: &Path,
156 tenant: &str,
157 team: Option<&str>,
158) -> AnyhowResult<SecretsManagerHandle> {
159 let canonical_team = secrets_manager::canonical_team(team);
160 let team_owned = canonical_team.into_owned();
161 let selection = secrets_manager::select_secrets_manager(bundle_root, tenant, &team_owned)?;
162 let allow_env = matches!(env::var(ENV_ALLOW_ENV_SECRETS).as_deref(), Ok("1"));
163 let pack_desc = selection
164 .pack_path
165 .as_ref()
166 .map(|path| path.display().to_string())
167 .unwrap_or_else(|| "<none>".to_string());
168 let backend_kind_result = selection.kind();
169 let backend_label = match &backend_kind_result {
170 Ok(kind) => kind.to_string(),
171 Err(_) => "<unknown>".to_string(),
172 };
173 let selection_kind_desc = backend_kind_result
174 .as_ref()
175 .map(|kind| kind.to_string())
176 .unwrap_or_else(|err| format!("ERR({err})"));
177 let dev_secrets_path =
178 env::var("GREENTIC_DEV_SECRETS_PATH").unwrap_or_else(|_| "<unset>".to_string());
179 operator_log::info(
180 module_path!(),
181 format!(
182 "secrets selection: kind={} pack_path={} bundle_root={} env_allow_env_secrets={} GREENTIC_DEV_SECRETS_PATH={}",
183 selection_kind_desc,
184 pack_desc,
185 bundle_root.display(),
186 allow_env,
187 dev_secrets_path,
188 ),
189 );
190 let (manager, store_path, using_env_fallback) = instantiate_manager_from_selection(
191 bundle_root,
192 &selection,
193 allow_env,
194 &pack_desc,
195 backend_kind_result,
196 )?;
197 operator_log::info(
198 module_path!(),
199 format!(
200 "secrets runtime backend chosen: dev_store_path={} using_env_fallback={}",
201 store_path
202 .as_ref()
203 .map(|path| path.display().to_string())
204 .unwrap_or_else(|| "<none>".to_string()),
205 using_env_fallback
206 ),
207 );
208 let runtime_dev_store_desc = store_path
209 .as_ref()
210 .map(|path| path.display().to_string())
211 .unwrap_or_else(|| "<none>".to_string());
212 eprintln!(
213 "secrets: backend={} using_env_fallback={} dev_store_path={} selection_pack={} GREENTIC_DEV_SECRETS_PATH={}",
214 backend_label, using_env_fallback, runtime_dev_store_desc, pack_desc, dev_secrets_path,
215 );
216 if let Some(pack_path) = &selection.pack_path {
217 let dev_store_desc = store_path
218 .as_ref()
219 .map(|path| path.display().to_string())
220 .unwrap_or_else(|| "<default>".to_string());
221 operator_log::info(
222 module_path!(),
223 format!(
224 "secrets manager selected: {} (backend={} dev_store={})",
225 pack_path.display(),
226 backend_label,
227 dev_store_desc
228 ),
229 );
230 }
231 Ok(SecretsManagerHandle {
232 manager,
233 selection,
234 dev_store_path: store_path,
235 canonical_team: team_owned,
236 using_env_fallback,
237 })
238}
239
240fn instantiate_manager_from_selection(
241 bundle_root: &Path,
242 selection: &secrets_manager::SecretsManagerSelection,
243 allow_env: bool,
244 pack_desc: &str,
245 backend_kind_result: Result<SecretsBackendKind, AnyhowError>,
246) -> AnyhowResult<(DynSecretsManager, Option<PathBuf>, bool)> {
247 match backend_kind_result {
248 Ok(kind) => match instantiate_manager_for_backend(bundle_root, selection, kind) {
249 Ok((manager, path)) => Ok((manager, path, false)),
250 Err(err) => fallback_to_env(allow_env, kind.to_string(), pack_desc, err),
251 },
252 Err(err) => fallback_to_env(allow_env, "<unknown>".to_string(), pack_desc, err),
253 }
254}
255
256fn fallback_to_env(
257 allow_env: bool,
258 kind_label: String,
259 pack_desc: &str,
260 err: AnyhowError,
261) -> AnyhowResult<(DynSecretsManager, Option<PathBuf>, bool)> {
262 if allow_env {
263 operator_log::warn(
264 module_path!(),
265 format!(
266 "secrets backend {kind} ({pack}) failed to initialize; falling back to env secrets backend: {err}",
267 kind = kind_label,
268 pack = pack_desc,
269 ),
270 );
271 Ok((Arc::new(EnvSecretsManager) as DynSecretsManager, None, true))
272 } else {
273 Err(err)
274 }
275}
276
277fn instantiate_manager_for_backend(
278 bundle_root: &Path,
279 _selection: &secrets_manager::SecretsManagerSelection,
280 backend_kind: SecretsBackendKind,
281) -> AnyhowResult<(DynSecretsManager, Option<PathBuf>)> {
282 match backend_kind {
283 SecretsBackendKind::DevStore => open_dev_store_manager(bundle_root),
284 SecretsBackendKind::Env => Ok((Arc::new(EnvSecretsManager) as DynSecretsManager, None)),
285 }
286}
287
288fn open_dev_store_manager(
289 bundle_root: &Path,
290) -> AnyhowResult<(DynSecretsManager, Option<PathBuf>)> {
291 let client = SecretsClient::open(bundle_root)?;
292 let path = client.store_path().map(|path| path.to_path_buf());
293 Ok((Arc::new(client) as DynSecretsManager, path))
294}
295
296pub fn canonical_secret_uri(
298 env: &str,
299 tenant: &str,
300 team: Option<&str>,
301 provider: &str,
302 key: &str,
303) -> String {
304 let team_segment = secrets_manager::canonical_team(team);
305 let provider_segment = if provider.is_empty() {
306 "messaging".to_string()
307 } else {
308 provider.to_string()
309 };
310 let normalized_key = secret_name::canonical_secret_name(key);
311 format!(
312 "secrets://{}/{}/{}/{}/{}",
313 env, tenant, team_segment, provider_segment, normalized_key
314 )
315}
316
317pub fn canonical_secret_store_key(uri: &str) -> Option<String> {
318 let trimmed = uri.strip_prefix("secrets://")?;
319 let segments: Vec<&str> = trimmed.split('/').collect();
320 if segments.len() != 5 {
321 return None;
322 }
323 let normalized = segments
324 .into_iter()
325 .map(normalize_store_segment)
326 .collect::<Vec<_>>();
327 let mut parts = vec!["GREENTIC_SECRET".to_string()];
328 parts.extend(normalized);
329 Some(parts.join("__"))
330}
331
332fn normalize_store_segment(segment: &str) -> String {
333 let mut normalized = String::with_capacity(segment.len());
334 for ch in segment.chars() {
335 let replacement = match ch {
336 'A'..='Z' | '0'..='9' => ch,
337 'a'..='z' => ch.to_ascii_uppercase(),
338 '_' => '_',
339 _ => '_',
340 };
341 normalized.push(replacement);
342 }
343 normalized
344}
345
346fn secret_uri_candidates(
347 env: &str,
348 tenant: &str,
349 canonical_team: &str,
350 key: &str,
351 provider_id: &str,
352) -> Vec<String> {
353 let normalized_key = secret_name::canonical_secret_name(key);
354 let prefix = format!("secrets://{}/{}/{}/", env, tenant, canonical_team);
355 vec![format!("{prefix}{provider_id}/{normalized_key}")]
356}
357
358fn display_secret_candidates(
359 env: &str,
360 tenant: &str,
361 canonical_team: &str,
362 key: &str,
363 provider_id: &str,
364) -> Vec<String> {
365 let normalized_key = secret_name::canonical_secret_name(key);
366 let prefix = format!("secrets://{}/{}/{}/", env, tenant, canonical_team);
367 vec![format!("{prefix}{provider_id}/{normalized_key}")]
368}
369
370#[allow(clippy::too_many_arguments)]
372pub fn check_provider_secrets(
373 manager: &DynSecretsManager,
374 env: &str,
375 tenant: &str,
376 team: Option<&str>,
377 pack_path: &Path,
378 provider_id: &str,
379 _provider_type: Option<&str>,
380 store_path: Option<&Path>,
381 using_env_fallback: bool,
382) -> anyhow::Result<Option<Vec<String>>> {
383 let keys = load_secret_keys_from_pack(pack_path)?;
384 if keys.is_empty() {
385 return Ok(None);
386 }
387
388 let canonical_team = secrets_manager::canonical_team(team);
389 let canonical_team_owned = canonical_team.into_owned();
390 let team_display = team.unwrap_or("default");
391 let store_desc = store_path
392 .map(|path| path.display().to_string())
393 .unwrap_or_else(|| {
394 if using_env_fallback {
395 "<env store>".to_string()
396 } else {
397 "<default dev store>".to_string()
398 }
399 });
400 let store_path_display = store_path
401 .map(|path| path.display().to_string())
402 .unwrap_or_else(|| "<none>".to_string());
403
404 let runtime = Builder::new_current_thread()
405 .enable_all()
406 .build()
407 .context("build secrets runtime")?;
408 runtime.block_on(async {
409 let mut missing = Vec::new();
410 for key in keys {
411 let normalized_key = secret_name::canonical_secret_name(&key);
412 let candidates = secret_uri_candidates(
413 env,
414 tenant,
415 &canonical_team_owned,
416 &key,
417 provider_id,
418 );
419 let display_candidates = display_secret_candidates(
420 env,
421 tenant,
422 &canonical_team_owned,
423 &key,
424 provider_id,
425 );
426 operator_log::info(
427 module_path!(),
428 format!(
429 "checking secret URIs for provider {}: {}",
430 provider_id,
431 candidates
432 .iter()
433 .map(|uri| uri.as_str())
434 .collect::<Vec<_>>()
435 .join("; ")
436 ),
437 );
438 if !display_candidates.is_empty() {
439 let candidate_list = display_candidates
440 .iter()
441 .map(|uri| format!(" - {}", uri))
442 .collect::<Vec<_>>()
443 .join("\n");
444 info!(
445 target: "secrets",
446 "checked secret URIs (store={} dev_store_path={}):\n{}",
447 store_desc,
448 store_path_display,
449 candidate_list
450 );
451 }
452 let mut resolved = false;
453 let mut candidate_missing = Vec::new();
454 let mut matched_uri: Option<String> = None;
455 for uri in &candidates {
456 info!(
457 target: "secrets",
458 "secret lookup: uri={} secret_key={} dev_store_path={}",
459 uri,
460 normalized_key,
461 store_path_display
462 );
463 match manager.read(uri).await {
464 Ok(_) => {
465 resolved = true;
466 matched_uri = Some(uri.clone());
467 break;
468 }
469 Err(SecretError::NotFound(_)) => {
470 candidate_missing.push(uri.clone());
471 }
472 Err(err) => {
473 candidate_missing.push(uri.clone());
474 operator_log::warn(
475 module_path!(),
476 format!("secret lookup failed for {uri}: {err}"),
477 );
478 }
479 }
480 }
481 let matched_display = matched_uri
482 .as_deref()
483 .map(|uri| uri.to_string())
484 .unwrap_or_else(|| "<none>".to_string());
485 operator_log::debug(
486 module_path!(),
487 format!(
488 "secrets: resolved {key}; store={} env={} tenant={} team={} canonical_team={} provider={} tried_keys={:?} matched_key={matched_display}",
489 store_desc,
490 env,
491 tenant,
492 team_display,
493 canonical_team_owned,
494 provider_id,
495 candidates
496 ),
497 );
498 if !resolved {
499 let display_set: HashSet<_> =
500 display_candidates.iter().collect();
501 missing.extend(
502 candidate_missing
503 .into_iter()
504 .filter(|uri| display_set.contains(uri)),
505 );
506 }
507 }
508 if missing.is_empty() {
509 Ok(None)
510 } else {
511 Ok(Some(missing))
512 }
513 })
514}
515
516fn load_secret_keys_from_pack(pack_path: &Path) -> anyhow::Result<Vec<String>> {
517 let keys = load_keys_from_assets(pack_path)?;
518 if !keys.is_empty() {
519 return Ok(keys);
520 }
521 load_keys_from_manifest(pack_path)
522}
523
524fn load_keys_from_assets(pack_path: &Path) -> anyhow::Result<Vec<String>> {
525 let file = File::open(pack_path)?;
526 let mut archive = ZipArchive::new(file)?;
527 const ASSET_PATHS: &[&str] = &[
528 "assets/secret-requirements.json",
529 "assets/secret_requirements.json",
530 "secret-requirements.json",
531 "secret_requirements.json",
532 ];
533 for asset in ASSET_PATHS {
534 if let Ok(mut entry) = archive.by_name(asset) {
535 let mut contents = String::new();
536 entry.read_to_string(&mut contents)?;
537 let requirements: Vec<AssetSecretRequirement> = serde_json::from_str(&contents)?;
538 return Ok(requirements
539 .into_iter()
540 .filter(|req| req.required.unwrap_or(true))
541 .filter_map(|req| req.key)
542 .map(|key| key.to_lowercase())
543 .collect());
544 }
545 }
546 Ok(Vec::new())
547}
548
549fn load_keys_from_manifest(pack_path: &Path) -> anyhow::Result<Vec<String>> {
550 let file = File::open(pack_path)?;
551 let mut archive = ZipArchive::new(file)?;
552 let mut manifest = match archive.by_name("manifest.cbor") {
553 Ok(file) => file,
554 Err(ZipError::FileNotFound) => return Ok(Vec::new()),
555 Err(err) => return Err(err.into()),
556 };
557 let mut bytes = Vec::new();
558 manifest.read_to_end(&mut bytes)?;
559 let value: CborValue = serde_cbor::from_slice(&bytes)?;
560 if let CborValue::Map(map) = &value {
561 return extract_keys_from_manifest_map(map);
562 }
563 Ok(Vec::new())
564}
565
566fn extract_keys_from_manifest_map(map: &CborMap) -> anyhow::Result<Vec<String>> {
567 let symbols = symbols_map(map);
568 let mut keys = Vec::new();
569 if let Some(CborValue::Array(entries)) = map_get(map, "secret_requirements") {
570 for entry in entries {
571 if let CborValue::Map(entry_map) = entry {
572 if !is_required(entry_map) {
573 continue;
574 }
575 if let Some(key_value) = map_get(entry_map, "key")
576 && let Some(key) =
577 resolve_string_symbol(Some(key_value), symbols, "secret_requirements")?
578 {
579 keys.push(key.to_lowercase());
580 }
581 }
582 }
583 }
584 Ok(keys)
585}
586
587fn is_required(entry: &CborMap) -> bool {
588 match map_get(entry, "required") {
589 Some(CborValue::Bool(value)) => *value,
590 _ => true,
591 }
592}
593
594fn map_get<'a>(map: &'a CborMap, key: &str) -> Option<&'a CborValue> {
595 map.iter().find_map(|(k, v)| match k {
596 CborValue::Text(text) if text == key => Some(v),
597 _ => None,
598 })
599}
600
601fn symbols_map(map: &CborMap) -> Option<&CborMap> {
602 let symbols = map_get(map, "symbols")?;
603 match symbols {
604 CborValue::Map(map) => Some(map),
605 _ => None,
606 }
607}
608
609fn resolve_string_symbol(
610 value: Option<&CborValue>,
611 symbols: Option<&CborMap>,
612 symbol_key: &str,
613) -> anyhow::Result<Option<String>> {
614 let Some(value) = value else {
615 return Ok(None);
616 };
617 match value {
618 CborValue::Text(text) => Ok(Some(text.clone())),
619 CborValue::Integer(idx) => {
620 let Some(symbols) = symbols else {
621 return Ok(Some(idx.to_string()));
622 };
623 let Some(values) = symbol_array(symbols, symbol_key) else {
624 return Ok(Some(idx.to_string()));
625 };
626 let idx = usize::try_from(*idx).unwrap_or(usize::MAX);
627 match values.get(idx) {
628 Some(CborValue::Text(text)) => Ok(Some(text.clone())),
629 _ => Ok(Some(idx.to_string())),
630 }
631 }
632 _ => Err(anyhow!("expected string or symbol index")),
633 }
634}
635
636fn symbol_array<'a>(symbols: &'a CborMap, key: &'a str) -> Option<&'a Vec<CborValue>> {
637 if let Some(CborValue::Array(values)) = map_get(symbols, key) {
638 return Some(values);
639 }
640 if let Some(stripped) = key.strip_suffix('s')
641 && let Some(CborValue::Array(values)) = map_get(symbols, stripped)
642 {
643 return Some(values);
644 }
645 None
646}
647
648#[derive(Deserialize)]
649struct AssetSecretRequirement {
650 key: Option<String>,
651 #[serde(default)]
652 required: Option<bool>,
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658 use async_trait::async_trait;
659 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
660 use greentic_secrets_lib::Result as SecretResult;
661 use greentic_secrets_lib::core::seed::{ApplyOptions, DevStore, apply_seed};
662 use greentic_secrets_lib::{SecretFormat, SeedDoc, SeedEntry, SeedValue};
663 use once_cell::sync::Lazy;
664 use rand::RngExt;
665 use std::collections::HashMap;
666 use std::env;
667 use std::fs;
668 use std::fs::File;
669 use std::io::Write;
670 use std::path::{Path, PathBuf};
671 use tempfile::tempdir;
672 use tokio::runtime::Runtime;
673 use zip::ZipWriter;
674 use zip::write::FileOptions;
675
676 static PACK_FIXTURE: Lazy<PackFixture> = Lazy::new(build_test_pack);
677
678 struct PackFixture {
679 _dir: tempfile::TempDir,
680 path: PathBuf,
681 }
682
683 struct FakeManager {
684 values: HashMap<String, Vec<u8>>,
685 }
686
687 impl FakeManager {
688 fn new(values: HashMap<String, Vec<u8>>) -> Self {
689 Self { values }
690 }
691 }
692
693 #[async_trait]
694 impl SecretsManager for FakeManager {
695 async fn read(&self, path: &str) -> SecretResult<Vec<u8>> {
696 self.values
697 .get(path)
698 .cloned()
699 .ok_or_else(|| SecretError::NotFound(path.to_string()))
700 }
701
702 async fn write(&self, _: &str, _: &[u8]) -> SecretResult<()> {
703 Err(SecretError::Permission("read-only".into()))
704 }
705
706 async fn delete(&self, _: &str) -> SecretResult<()> {
707 Err(SecretError::Permission("read-only".into()))
708 }
709 }
710
711 fn telegram_pack_path() -> PathBuf {
712 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
713 path.push("..");
714 path.push("tests/demo-bundle/providers/messaging/messaging-telegram.gtpack");
715 if path.exists() {
716 return path.canonicalize().unwrap_or(path);
717 }
718 PACK_FIXTURE.path.clone()
719 }
720
721 fn build_test_pack() -> PackFixture {
722 let dir = tempdir().expect("create temp dir for test pack");
723 let path = dir.path().join("messaging-telegram.gtpack");
724 let file = File::create(&path).expect("create test pack file");
725 let mut zip = ZipWriter::new(file);
726 let options = FileOptions::<()>::default();
727 zip.start_file("assets/secret-requirements.json", options)
728 .expect("add secret requirements asset");
729 zip.write_all(br#"[{"key":"telegram_bot_token","required":true}]"#)
730 .expect("write secret requirements");
731 zip.finish().expect("finish test pack");
732 PackFixture { _dir: dir, path }
733 }
734
735 #[test]
736 fn canonical_uri_uses_team_placeholder() {
737 let uri = canonical_secret_uri("demo", "acme", None, "messaging", "FOO");
738 assert_eq!(uri, "secrets://demo/acme/_/messaging/foo");
739 }
740
741 #[test]
742 fn provider_secrets_missing_when_unsupported() -> anyhow::Result<()> {
743 let manager: DynSecretsManager = Arc::new(FakeManager::new(HashMap::new()));
744 let result = check_provider_secrets(
745 &manager,
746 "demo",
747 "tenant",
748 Some("default"),
749 &telegram_pack_path(),
750 "messaging-telegram",
751 Some("messaging.telegram.bot"),
752 None,
753 false,
754 )?;
755 assert_eq!(
756 result,
757 Some(vec![
758 "secrets://demo/tenant/_/messaging-telegram/telegram_bot_token".to_string()
759 ])
760 );
761 Ok(())
762 }
763
764 #[test]
765 fn provider_secrets_pass_when_supplied() -> anyhow::Result<()> {
766 let mut values = HashMap::new();
767 values.insert(
768 "secrets://demo/tenant/_/messaging-telegram/telegram_bot_token".to_string(),
769 b"token".to_vec(),
770 );
771 let manager: DynSecretsManager = Arc::new(FakeManager::new(values));
772 let result = check_provider_secrets(
773 &manager,
774 "demo",
775 "tenant",
776 None,
777 &telegram_pack_path(),
778 "messaging-telegram",
779 Some("messaging.telegram.bot"),
780 None,
781 false,
782 )?;
783 assert!(result.is_none());
784 Ok(())
785 }
786
787 #[test]
788 fn reads_provider_namespace_secret() -> anyhow::Result<()> {
789 let dir = tempdir().unwrap();
790 let store_path = dir.path().join("secrets.env");
791 let store = DevStore::with_path(store_path.clone())?;
792 let seed = SeedDoc {
793 entries: vec![SeedEntry {
794 uri: "secrets://demo/3point/_/messaging-telegram/telegram_bot_token".to_string(),
795 format: SecretFormat::Text,
796 value: SeedValue::Text {
797 text: "token".to_string(),
798 },
799 description: None,
800 }],
801 };
802 let runtime = Runtime::new()?;
803 let report =
804 runtime.block_on(async { apply_seed(&store, &seed, ApplyOptions::default()).await });
805 assert_eq!(report.ok, 1);
806 let env_guard = crate::test_env_lock().lock().unwrap();
807 unsafe {
808 env::set_var("GREENTIC_DEV_SECRETS_PATH", store_path.clone());
809 }
810 let handle = resolve_secrets_manager(dir.path(), "3point", Some("default"))?;
811 unsafe {
812 env::remove_var("GREENTIC_DEV_SECRETS_PATH");
813 }
814 drop(env_guard);
815 let missing = check_provider_secrets(
816 &handle.manager(),
817 "demo",
818 "3point",
819 Some("default"),
820 &telegram_pack_path(),
821 "messaging-telegram",
822 Some("messaging.telegram.bot"),
823 handle.dev_store_path.as_deref(),
824 handle.using_env_fallback,
825 )?;
826 assert!(missing.is_none());
827 Ok(())
828 }
829
830 #[test]
831 fn resolves_dev_store_secret_with_canonical_team() -> anyhow::Result<()> {
832 let dir = tempdir().unwrap();
833 let store_path = dir.path().join("secrets.env");
834 let store = DevStore::with_path(store_path.clone())?;
835 let seed = SeedDoc {
836 entries: vec![SeedEntry {
837 uri: "secrets://demo/3point/_/messaging-telegram/telegram_bot_token".to_string(),
838 format: SecretFormat::Text,
839 value: SeedValue::Text {
840 text: "XYZ".to_string(),
841 },
842 description: None,
843 }],
844 };
845 let runtime = Runtime::new()?;
846 let report =
847 runtime.block_on(async { apply_seed(&store, &seed, ApplyOptions::default()).await });
848 assert_eq!(report.ok, 1);
849 let env_guard = crate::test_env_lock().lock().unwrap();
850 unsafe {
851 env::set_var("GREENTIC_DEV_SECRETS_PATH", store_path);
852 }
853 let handle = resolve_secrets_manager(dir.path(), "3point", Some("default"))?;
854 unsafe {
855 env::remove_var("GREENTIC_DEV_SECRETS_PATH");
856 }
857 drop(env_guard);
858 let missing = check_provider_secrets(
859 &handle.manager(),
860 "demo",
861 "3point",
862 Some("default"),
863 &telegram_pack_path(),
864 "messaging-telegram",
865 Some("messaging.telegram.bot"),
866 handle.dev_store_path.as_deref(),
867 handle.using_env_fallback,
868 )?;
869 assert!(missing.is_none());
870 Ok(())
871 }
872
873 #[test]
874 fn secrets_handle_reads_dev_store_secret() -> anyhow::Result<()> {
875 let dir = tempdir()?;
876 let store_path = dir.path().join("secrets.env");
877 let store = DevStore::with_path(store_path.clone())?;
878 let seed = SeedDoc {
879 entries: vec![SeedEntry {
880 uri: "secrets://demo/3point/_/messaging-telegram/telegram_bot_token".to_string(),
881 format: SecretFormat::Text,
882 value: SeedValue::Text {
883 text: "token".to_string(),
884 },
885 description: None,
886 }],
887 };
888 let runtime = Runtime::new()?;
889 let report =
890 runtime.block_on(async { apply_seed(&store, &seed, ApplyOptions::default()).await });
891 assert_eq!(report.ok, 1);
892 let env_guard = crate::test_env_lock().lock().unwrap();
893 unsafe {
894 env::set_var("GREENTIC_DEV_SECRETS_PATH", store_path.clone());
895 }
896 let handle = resolve_secrets_manager(dir.path(), "demo", Some("default"))?;
897 unsafe {
898 env::remove_var("GREENTIC_DEV_SECRETS_PATH");
899 }
900 drop(env_guard);
901 let value = runtime.block_on(async {
902 handle
903 .manager()
904 .read("secrets://demo/3point/_/messaging-telegram/telegram_bot_token")
905 .await
906 })?;
907 assert_eq!(value, b"token".to_vec());
908 assert_eq!(handle.dev_store_path.as_deref(), Some(store_path.as_path()));
909 Ok(())
910 }
911
912 #[test]
913 fn dev_store_selection_uses_secrets_client() -> anyhow::Result<()> {
914 let bundle_root = tempdir()?;
915 let handle = resolve_secrets_manager(bundle_root.path(), "demo", Some("default"))?;
916 assert!(handle.dev_store_path.is_some());
917 assert!(!handle.using_env_fallback);
918 Ok(())
919 }
920
921 #[test]
922 fn resolve_secrets_manager_defaults_to_devstore_when_no_pack() -> anyhow::Result<()> {
923 let bundle_root = tempdir()?;
924 let handle = resolve_secrets_manager(bundle_root.path(), "demo", Some("default"))?;
925 assert!(handle.selection.pack_path.is_none());
926 assert!(handle.dev_store_path.is_some());
927 assert!(!handle.using_env_fallback);
928 Ok(())
929 }
930
931 #[test]
932 fn env_selection_pack_uses_env_manager() -> anyhow::Result<()> {
933 let bundle_root = tempdir()?;
934 let tenant = "demo";
935 let team = "default";
936 let pack_dir = secrets_pack_dir(bundle_root.path(), tenant, team);
937 let pack_path =
938 write_secrets_pack(&pack_dir, "env-backend.gtpack", r#"{"backend":"env"}"#)?;
939 let handle = resolve_secrets_manager(bundle_root.path(), tenant, Some(team))?;
940 assert_eq!(
941 handle.selection.pack_path.as_deref(),
942 Some(pack_path.as_path())
943 );
944 assert!(handle.dev_store_path.is_none());
945 assert!(!handle.using_env_fallback);
946 let secret_value = random_secret_value();
947 let expected_bytes = secret_value.clone().into_bytes();
948 let secret_uri = canonical_secret_uri(
949 "demo",
950 tenant,
951 Some(team),
952 "messaging-webex",
953 "webex_bot_token",
954 );
955 let runtime = Runtime::new()?;
956 {
957 let _env_guard = crate::test_env_lock().lock().unwrap();
958 unsafe {
959 env::set_var(&secret_uri, secret_value);
960 }
961 let value = runtime.block_on(async { handle.manager().read(&secret_uri).await })?;
962 unsafe {
963 env::remove_var(&secret_uri);
964 }
965 assert_eq!(value, expected_bytes);
966 }
967 Ok(())
968 }
969
970 #[test]
971 fn resolve_secrets_manager_env_fallback_only_when_allowed() -> anyhow::Result<()> {
972 let bundle_root = tempdir()?;
973 let tenant = "demo";
974 let team = "default";
975 let pack_dir = secrets_pack_dir(bundle_root.path(), tenant, team);
976 let _ = write_secrets_pack(&pack_dir, "bad-backend.gtpack", r#"{"backend":"vault"}"#)?;
977 let env_guard = crate::test_env_lock().lock().unwrap();
978 unsafe {
979 env::remove_var(ENV_ALLOW_ENV_SECRETS);
980 }
981 let result = resolve_secrets_manager(bundle_root.path(), tenant, Some(team));
982 drop(env_guard);
983 assert!(result.is_err());
984 Ok(())
985 }
986
987 #[test]
988 fn resolve_secrets_manager_env_fallback_is_allowed_with_flag() -> anyhow::Result<()> {
989 let bundle_root = tempdir()?;
990 let tenant = "demo";
991 let team = "default";
992 let pack_dir = secrets_pack_dir(bundle_root.path(), tenant, team);
993 let _ = write_secrets_pack(&pack_dir, "bad-backend.gtpack", r#"{"backend":"vault"}"#)?;
994 let env_guard = crate::test_env_lock().lock().unwrap();
995 unsafe {
996 env::set_var(ENV_ALLOW_ENV_SECRETS, "1");
997 }
998 let handle = resolve_secrets_manager(bundle_root.path(), tenant, Some(team))?;
999 unsafe {
1000 env::remove_var(ENV_ALLOW_ENV_SECRETS);
1001 }
1002 drop(env_guard);
1003 assert!(handle.dev_store_path.is_none());
1004 assert!(handle.using_env_fallback);
1005 Ok(())
1006 }
1007
1008 fn write_secrets_pack(dir: &Path, name: &str, backend_config: &str) -> anyhow::Result<PathBuf> {
1009 fs::create_dir_all(dir)?;
1010 let pack_path = dir.join(name);
1011 let file = File::create(&pack_path)?;
1012 let mut zip = ZipWriter::new(file);
1013 let options: FileOptions<'_, ()> = FileOptions::default();
1014 zip.start_file("assets/secrets_backend.json", options)?;
1015 zip.write_all(backend_config.as_bytes())?;
1016 zip.finish()?;
1017 Ok(pack_path)
1018 }
1019
1020 fn secrets_pack_dir(bundle_root: &Path, tenant: &str, team: &str) -> PathBuf {
1021 let canonical_team = secrets_manager::canonical_team(Some(team)).into_owned();
1022 bundle_root
1023 .join("providers")
1024 .join("secrets")
1025 .join(tenant)
1026 .join(canonical_team)
1027 }
1028
1029 fn random_secret_value() -> String {
1030 let mut bytes = [0u8; 32];
1031 rand::rng().fill(&mut bytes);
1032 let encoded = URL_SAFE_NO_PAD.encode(bytes);
1033 format!("TEST_OPAQUE_{encoded}")
1034 }
1035}