Skip to main content

greentic_operator/
secrets_gate.rs

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                // Fallback: if team-specific secret not found, try team="_" (wildcard).
78                // Secrets saved at tenant-level (no team) live under "_" but runtime
79                // may read with a specific team from the routing context.
80                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
112/// If `path` is `secrets://env/tenant/TEAM/provider/key` and TEAM != "_",
113/// return the same URI with TEAM replaced by "_".
114fn 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; // Already wildcard, no fallback needed
123    }
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
296/// Build the canonical secrets URI for the provided identity.
297pub 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/// Check that the required secrets for the provider exist.
371#[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}