Skip to main content

mvm_core/
secret_binding.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5/// A secret binding maps an environment variable to a target domain,
6/// optionally specifying which HTTP header carries the credential.
7///
8/// When injected into a microVM, the secret value is written to the
9/// secrets drive (readable only by the guest agent). A placeholder
10/// value is set in the guest environment so tools that check for the
11/// variable's existence pass their preflight checks.
12///
13/// Combined with [`NetworkPolicy`](crate::network_policy::NetworkPolicy)
14/// allowlists, secrets can only be sent to their bound domains.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct SecretBinding {
17    /// Environment variable name (e.g., `OPENAI_API_KEY`).
18    pub env_var: String,
19    /// Domain this secret is scoped to (e.g., `api.openai.com`).
20    pub target_host: String,
21    /// HTTP header name for the credential. Defaults to `Authorization`.
22    #[serde(default = "default_header")]
23    pub header: String,
24    /// The secret value. If `None`, read from the host environment.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub value: Option<String>,
27}
28
29fn default_header() -> String {
30    "Authorization".to_string()
31}
32
33/// Placeholder value set in guest env vars so tools pass existence checks.
34pub const PLACEHOLDER_PREFIX: &str = "mvm-managed:";
35
36impl SecretBinding {
37    pub fn new(env_var: impl Into<String>, target_host: impl Into<String>) -> Self {
38        Self {
39            env_var: env_var.into(),
40            target_host: target_host.into(),
41            header: default_header(),
42            value: None,
43        }
44    }
45
46    pub fn with_header(mut self, header: impl Into<String>) -> Self {
47        self.header = header.into();
48        self
49    }
50
51    pub fn with_value(mut self, value: impl Into<String>) -> Self {
52        self.value = Some(value.into());
53        self
54    }
55
56    /// Resolve the secret value: use the explicit value if set,
57    /// otherwise read from the host environment.
58    pub fn resolve_value(&self) -> anyhow::Result<String> {
59        if let Some(ref v) = self.value {
60            Ok(v.clone())
61        } else {
62            std::env::var(&self.env_var).map_err(|_| {
63                anyhow::anyhow!(
64                    "secret {:?} not set in host environment and no explicit value provided",
65                    self.env_var
66                )
67            })
68        }
69    }
70
71    /// Generate the placeholder value for the guest environment.
72    pub fn placeholder(&self) -> String {
73        format!("{}{}", PLACEHOLDER_PREFIX, self.env_var)
74    }
75
76    /// Generate a secret file entry for the secrets drive.
77    /// The file is named after the env var (lowercase, dots replaced).
78    pub fn secret_filename(&self) -> String {
79        self.env_var.to_lowercase().replace('.', "_")
80    }
81}
82
83impl fmt::Display for SecretBinding {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        write!(f, "{}:{}", self.env_var, self.target_host)?;
86        if self.header != "Authorization" {
87            write!(f, ":{}", self.header)?;
88        }
89        Ok(())
90    }
91}
92
93/// Parse a secret binding from CLI syntax:
94/// - `KEY:host` — read KEY from env, inject as Authorization header to host
95/// - `KEY:host:header` — custom header name
96/// - `KEY=value:host` — explicit value
97/// - `KEY=value:host:header` — explicit value + custom header
98impl FromStr for SecretBinding {
99    type Err = anyhow::Error;
100
101    fn from_str(s: &str) -> Result<Self, Self::Err> {
102        // Split on first ':' to get key_part and rest
103        let (key_part, rest) = s
104            .split_once(':')
105            .ok_or_else(|| anyhow::anyhow!("expected KEY:host or KEY=value:host, got {:?}", s))?;
106
107        // key_part is either "KEY" or "KEY=value"
108        let (env_var, value) = if let Some((k, v)) = key_part.split_once('=') {
109            (k.to_string(), Some(v.to_string()))
110        } else {
111            (key_part.to_string(), None)
112        };
113
114        if env_var.is_empty() {
115            anyhow::bail!("empty environment variable name in {:?}", s);
116        }
117
118        // rest is either "host" or "host:header"
119        let (target_host, header) = if let Some((h, hdr)) = rest.split_once(':') {
120            (h.to_string(), hdr.to_string())
121        } else {
122            (rest.to_string(), default_header())
123        };
124
125        if target_host.is_empty() {
126            anyhow::bail!("empty target host in {:?}", s);
127        }
128
129        Ok(Self {
130            env_var,
131            target_host,
132            header,
133            value,
134        })
135    }
136}
137
138/// Resolved secret bindings ready for injection into a microVM.
139/// Contains the actual secret values (resolved from env or explicit).
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ResolvedSecrets {
142    pub bindings: Vec<ResolvedBinding>,
143}
144
145/// A single resolved secret binding with its actual value.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ResolvedBinding {
148    pub env_var: String,
149    pub target_host: String,
150    pub header: String,
151    pub value: String,
152}
153
154impl ResolvedSecrets {
155    /// Resolve all bindings, reading values from environment where needed.
156    pub fn resolve(bindings: &[SecretBinding]) -> anyhow::Result<Self> {
157        let resolved = bindings
158            .iter()
159            .map(|b| {
160                let value = b.resolve_value()?;
161                Ok(ResolvedBinding {
162                    env_var: b.env_var.clone(),
163                    target_host: b.target_host.clone(),
164                    header: b.header.clone(),
165                    value,
166                })
167            })
168            .collect::<anyhow::Result<Vec<_>>>()?;
169        Ok(Self { bindings: resolved })
170    }
171
172    /// Generate secret files for the secrets drive.
173    /// Each binding produces a JSON file with the secret metadata + value.
174    pub fn to_secret_files(&self) -> Vec<(String, String)> {
175        self.bindings
176            .iter()
177            .map(|b| {
178                let filename = b.env_var.to_lowercase().replace('.', "_");
179                let content = serde_json::json!({
180                    "env_var": b.env_var,
181                    "target_host": b.target_host,
182                    "header": b.header,
183                    "value": b.value,
184                });
185                (filename, content.to_string())
186            })
187            .collect()
188    }
189
190    /// Generate placeholder environment variable entries for the config drive.
191    /// These let tools pass "is API key set?" checks without exposing real values.
192    pub fn placeholder_env_vars(&self) -> Vec<(String, String)> {
193        self.bindings
194            .iter()
195            .map(|b| {
196                (
197                    b.env_var.clone(),
198                    format!("{}{}", PLACEHOLDER_PREFIX, b.env_var),
199                )
200            })
201            .collect()
202    }
203
204    /// Generate a manifest summarizing which secrets are bound to which domains.
205    /// Written to the config drive for the guest agent to read on boot.
206    pub fn manifest_json(&self) -> String {
207        let entries: Vec<serde_json::Value> = self
208            .bindings
209            .iter()
210            .map(|b| {
211                serde_json::json!({
212                    "env_var": b.env_var,
213                    "target_host": b.target_host,
214                    "header": b.header,
215                    "secret_file": b.env_var.to_lowercase().replace('.', "_"),
216                })
217            })
218            .collect();
219        serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn parse_simple_binding() {
229        let b: SecretBinding = "OPENAI_API_KEY:api.openai.com".parse().unwrap();
230        assert_eq!(b.env_var, "OPENAI_API_KEY");
231        assert_eq!(b.target_host, "api.openai.com");
232        assert_eq!(b.header, "Authorization");
233        assert!(b.value.is_none());
234    }
235
236    #[test]
237    fn parse_with_header() {
238        let b: SecretBinding = "ANTHROPIC_KEY:api.anthropic.com:x-api-key".parse().unwrap();
239        assert_eq!(b.env_var, "ANTHROPIC_KEY");
240        assert_eq!(b.target_host, "api.anthropic.com");
241        assert_eq!(b.header, "x-api-key");
242    }
243
244    #[test]
245    fn parse_with_value() {
246        let b: SecretBinding = "MY_KEY=sk-123:api.example.com".parse().unwrap();
247        assert_eq!(b.env_var, "MY_KEY");
248        assert_eq!(b.value, Some("sk-123".to_string()));
249        assert_eq!(b.target_host, "api.example.com");
250    }
251
252    #[test]
253    fn parse_with_value_and_header() {
254        let b: SecretBinding = "KEY=val:host.com:x-token".parse().unwrap();
255        assert_eq!(b.env_var, "KEY");
256        assert_eq!(b.value, Some("val".to_string()));
257        assert_eq!(b.target_host, "host.com");
258        assert_eq!(b.header, "x-token");
259    }
260
261    #[test]
262    fn parse_missing_host() {
263        assert!("KEY".parse::<SecretBinding>().is_err());
264    }
265
266    #[test]
267    fn parse_empty_key() {
268        assert!(":host.com".parse::<SecretBinding>().is_err());
269    }
270
271    #[test]
272    fn parse_empty_host() {
273        assert!("KEY:".parse::<SecretBinding>().is_err());
274    }
275
276    #[test]
277    fn display_simple() {
278        let b = SecretBinding::new("KEY", "host.com");
279        assert_eq!(b.to_string(), "KEY:host.com");
280    }
281
282    #[test]
283    fn display_with_header() {
284        let b = SecretBinding::new("KEY", "host.com").with_header("x-token");
285        assert_eq!(b.to_string(), "KEY:host.com:x-token");
286    }
287
288    #[test]
289    fn placeholder() {
290        let b = SecretBinding::new("OPENAI_API_KEY", "api.openai.com");
291        assert_eq!(b.placeholder(), "mvm-managed:OPENAI_API_KEY");
292    }
293
294    #[test]
295    fn serde_roundtrip() {
296        let b = SecretBinding::new("KEY", "host.com")
297            .with_header("x-token")
298            .with_value("secret");
299        let json = serde_json::to_string(&b).unwrap();
300        let parsed: SecretBinding = serde_json::from_str(&json).unwrap();
301        assert_eq!(parsed, b);
302    }
303
304    #[test]
305    fn serde_without_value_omits_field() {
306        let b = SecretBinding::new("KEY", "host.com");
307        let json = serde_json::to_string(&b).unwrap();
308        assert!(!json.contains("value"));
309    }
310
311    #[test]
312    fn resolve_value_explicit() {
313        let b = SecretBinding::new("NONEXISTENT_VAR", "host.com").with_value("explicit");
314        assert_eq!(b.resolve_value().unwrap(), "explicit");
315    }
316
317    #[test]
318    fn resolve_value_from_env() {
319        unsafe { std::env::set_var("MVM_TEST_SECRET_42", "from-env") };
320        let b = SecretBinding::new("MVM_TEST_SECRET_42", "host.com");
321        assert_eq!(b.resolve_value().unwrap(), "from-env");
322        unsafe { std::env::remove_var("MVM_TEST_SECRET_42") };
323    }
324
325    #[test]
326    fn resolve_value_missing_env() {
327        let b = SecretBinding::new("DEFINITELY_NOT_SET_XYZ", "host.com");
328        assert!(b.resolve_value().is_err());
329    }
330
331    #[test]
332    fn resolved_secrets_files() {
333        let resolved = ResolvedSecrets {
334            bindings: vec![ResolvedBinding {
335                env_var: "OPENAI_API_KEY".to_string(),
336                target_host: "api.openai.com".to_string(),
337                header: "Authorization".to_string(),
338                value: "sk-test".to_string(),
339            }],
340        };
341        let files = resolved.to_secret_files();
342        assert_eq!(files.len(), 1);
343        assert_eq!(files[0].0, "openai_api_key");
344        assert!(files[0].1.contains("sk-test"));
345    }
346
347    #[test]
348    fn resolved_secrets_placeholders() {
349        let resolved = ResolvedSecrets {
350            bindings: vec![
351                ResolvedBinding {
352                    env_var: "KEY_A".to_string(),
353                    target_host: "a.com".to_string(),
354                    header: "Authorization".to_string(),
355                    value: "val-a".to_string(),
356                },
357                ResolvedBinding {
358                    env_var: "KEY_B".to_string(),
359                    target_host: "b.com".to_string(),
360                    header: "x-token".to_string(),
361                    value: "val-b".to_string(),
362                },
363            ],
364        };
365        let placeholders = resolved.placeholder_env_vars();
366        assert_eq!(placeholders.len(), 2);
367        assert_eq!(placeholders[0].0, "KEY_A");
368        assert_eq!(placeholders[0].1, "mvm-managed:KEY_A");
369    }
370
371    #[test]
372    fn resolved_secrets_manifest() {
373        let resolved = ResolvedSecrets {
374            bindings: vec![ResolvedBinding {
375                env_var: "KEY".to_string(),
376                target_host: "host.com".to_string(),
377                header: "x-token".to_string(),
378                value: "secret".to_string(),
379            }],
380        };
381        let manifest = resolved.manifest_json();
382        let parsed: Vec<serde_json::Value> = serde_json::from_str(&manifest).unwrap();
383        assert_eq!(parsed.len(), 1);
384        assert_eq!(parsed[0]["env_var"], "KEY");
385        assert_eq!(parsed[0]["target_host"], "host.com");
386        // Manifest should NOT contain the actual secret value
387        assert!(parsed[0].get("value").is_none());
388    }
389}