Skip to main content

greentic_operator/
secrets_manager.rs

1use std::borrow::Cow;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow};
7
8use crate::operator_log;
9use crate::secrets_backend::{self, SecretsBackendKind};
10
11const OVERRIDE_ENV: &str = "GREENTIC_SECRETS_MANAGER_PACK";
12const DEFAULT_SECRETS_DIR: &str = "providers/secrets";
13
14#[derive(Clone, Debug)]
15pub struct SecretsManagerSelection {
16    pub scope: SelectedKind,
17    pub pack_path: Option<PathBuf>,
18    pub reason: String,
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum SelectedKind {
23    TenantTeam,
24    Tenant,
25    Default,
26    Override,
27    None,
28}
29
30impl SecretsManagerSelection {
31    pub fn description(&self) -> String {
32        match &self.pack_path {
33            Some(path) => format!("{} (pack={})", self.reason, path.display()),
34            None => self.reason.clone(),
35        }
36    }
37
38    pub fn kind(&self) -> Result<SecretsBackendKind> {
39        if let Some(pack_path) = &self.pack_path {
40            secrets_backend::backend_kind_from_pack(pack_path)
41        } else {
42            Ok(SecretsBackendKind::DevStore)
43        }
44    }
45}
46
47pub fn canonical_team<'a>(team: Option<&'a str>) -> Cow<'a, str> {
48    match team
49        .map(|value| value.trim())
50        .filter(|trimmed| !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("default"))
51    {
52        Some(value) => Cow::Borrowed(value),
53        None => Cow::Borrowed("_"),
54    }
55}
56
57pub fn select_secrets_manager(
58    bundle_root: &Path,
59    tenant: &str,
60    team: &str,
61) -> Result<SecretsManagerSelection> {
62    if let Some(override_path) = resolve_override(bundle_root)? {
63        return Ok(SecretsManagerSelection {
64            scope: SelectedKind::Override,
65            pack_path: Some(override_path.clone()),
66            reason: format!("override secrets manager pack {}", override_path.display()),
67        });
68    }
69
70    let candidate_dirs = [
71        (
72            SelectedKind::TenantTeam,
73            bundle_root
74                .join(DEFAULT_SECRETS_DIR)
75                .join(tenant)
76                .join(team),
77        ),
78        (
79            SelectedKind::Tenant,
80            bundle_root.join(DEFAULT_SECRETS_DIR).join(tenant),
81        ),
82        (SelectedKind::Default, bundle_root.join(DEFAULT_SECRETS_DIR)),
83    ];
84
85    for (kind, dir) in &candidate_dirs {
86        if let Some(pack) = find_best_pack(dir).context("scan secrets manager packs")? {
87            return Ok(SecretsManagerSelection {
88                scope: *kind,
89                pack_path: Some(pack.clone()),
90                reason: match kind {
91                    SelectedKind::TenantTeam => "tenant/team secrets manager pack".to_string(),
92                    SelectedKind::Tenant => "tenant secrets manager pack".to_string(),
93                    SelectedKind::Default => "default secrets manager pack".to_string(),
94                    _ => "secrets manager pack".to_string(),
95                },
96            });
97        }
98    }
99
100    Ok(SecretsManagerSelection {
101        scope: SelectedKind::None,
102        pack_path: None,
103        reason: "no secrets manager pack found".to_string(),
104    })
105}
106
107fn resolve_override(bundle_root: &Path) -> Result<Option<PathBuf>> {
108    let value = match env::var_os(OVERRIDE_ENV) {
109        Some(value) => value,
110        None => return Ok(None),
111    };
112    let candidate = PathBuf::from(value);
113    let resolved = if candidate.is_absolute() {
114        candidate
115    } else {
116        bundle_root.join(candidate)
117    };
118    if !resolved.exists() {
119        return Err(anyhow!(
120            "override secrets manager pack {} not found",
121            resolved.display()
122        ));
123    }
124    Ok(Some(resolved))
125}
126
127fn find_best_pack(dir: &Path) -> Result<Option<PathBuf>> {
128    if !dir.is_dir() {
129        return Ok(None);
130    }
131    let mut packs = Vec::new();
132    for entry in fs::read_dir(dir).with_context(|| format!("read secrets dir {}", dir.display()))? {
133        let entry = entry?;
134        let path = entry.path();
135        if path
136            .extension()
137            .and_then(|ext| ext.to_str())
138            .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
139            .unwrap_or(false)
140            && path.is_file()
141        {
142            packs.push(path);
143        }
144    }
145    if packs.is_empty() {
146        return Ok(None);
147    }
148    packs.sort();
149    if packs.len() > 1 {
150        operator_log::warn(
151            module_path!(),
152            format!(
153                "multiple secrets manager packs found in {}; using {}",
154                dir.display(),
155                packs[0]
156                    .file_name()
157                    .and_then(|name| name.to_str())
158                    .unwrap_or("unknown")
159            ),
160        );
161    }
162    Ok(Some(packs.into_iter().next().unwrap()))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::env;
169    use tempfile::tempdir;
170
171    #[test]
172    fn canonical_team_maps_default_and_empty_to_underscore() {
173        assert_eq!(canonical_team(Some("default")), "_");
174        assert_eq!(canonical_team(Some("")), "_");
175        assert_eq!(canonical_team(Some("team")), "team");
176    }
177
178    #[test]
179    fn selects_tenant_team_over_tenant_and_default() {
180        let dir = tempdir().unwrap();
181        let base = dir.path().join(DEFAULT_SECRETS_DIR);
182        fs::create_dir_all(base.join("tenant").join("team")).unwrap();
183        fs::create_dir_all(base.join("tenant")).unwrap();
184        fs::create_dir_all(&base).unwrap();
185        let team_pack = base.join("tenant").join("team").join("foo.gtpack");
186        fs::write(&team_pack, "").unwrap();
187        let tenant_pack = base.join("tenant").join("bar.gtpack");
188        fs::write(&tenant_pack, "").unwrap();
189        let default_pack = base.join("default.gtpack");
190        fs::write(&default_pack, "").unwrap();
191        let selection = select_secrets_manager(dir.path(), "tenant", "team").unwrap();
192        assert_eq!(selection.scope, SelectedKind::TenantTeam);
193        assert_eq!(
194            selection.pack_path.unwrap().file_name().unwrap(),
195            "foo.gtpack"
196        );
197    }
198
199    #[test]
200    fn override_env_wins() {
201        let _env_guard = crate::test_env_lock().lock().unwrap();
202        let dir = tempdir().unwrap();
203        let alt = dir.path().join("alt.gtpack");
204        fs::write(&alt, "").unwrap();
205        unsafe {
206            env::set_var(OVERRIDE_ENV, alt.strip_prefix(dir.path()).unwrap());
207        }
208        let selection = select_secrets_manager(dir.path(), "tenant", "team").unwrap();
209        unsafe {
210            env::remove_var(OVERRIDE_ENV);
211        }
212        assert_eq!(selection.scope, SelectedKind::Override);
213        assert_eq!(selection.pack_path.unwrap(), alt);
214    }
215}