Skip to main content

microsandbox_network/secrets/
handle.rs

1//! Live-swappable view of the active secrets configuration.
2//!
3//! The proxy layers load the current [`SecretsConfig`] snapshot when they build a per-connection
4//! [`SecretsHandler`](super::handler::SecretsHandler), so swapping the snapshot here makes
5//! rotation, removal, and allowed-host updates take effect for all future guest connections
6//! without restarting the sandbox. In-flight connections keep the snapshot they started with.
7
8use std::sync::{Arc, RwLock};
9
10use super::config::{HostPattern, SecretsConfig};
11
12//--------------------------------------------------------------------------------------------------
13// Types
14//--------------------------------------------------------------------------------------------------
15
16/// Shared handle to the secrets configuration consumed by the network stack.
17///
18/// Cloning is cheap; all clones observe the same swappable snapshot.
19#[derive(Clone)]
20pub struct SecretsHandle {
21    inner: Arc<RwLock<Arc<SecretsConfig>>>,
22}
23
24/// A live secrets update that could not be applied.
25///
26/// Errors carry secret identities only, never values.
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum SecretsUpdateError {
29    /// No secret with this name exists in the active configuration.
30    #[error("no secret named {name} is configured")]
31    UnknownSecret {
32        /// Secret identity (the guest environment variable name).
33        name: String,
34    },
35
36    /// An allowed-host update would leave the secret with no allowed hosts.
37    #[error("secret {name}: at least one allowed host is required")]
38    MissingAllowedHosts {
39        /// Secret identity (the guest environment variable name).
40        name: String,
41    },
42}
43
44//--------------------------------------------------------------------------------------------------
45// Methods
46//--------------------------------------------------------------------------------------------------
47
48impl SecretsHandle {
49    /// Create a handle over the boot-time secrets configuration.
50    pub fn new(config: SecretsConfig) -> Self {
51        Self {
52            inner: Arc::new(RwLock::new(Arc::new(config))),
53        }
54    }
55
56    /// Load the current snapshot.
57    pub fn load(&self) -> Arc<SecretsConfig> {
58        self.inner.read().expect("secrets lock poisoned").clone()
59    }
60
61    /// Replace the value of an existing secret. The guest-visible placeholder
62    /// and host allow-list are unchanged.
63    pub fn rotate_value(&self, name: &str, value: String) -> Result<(), SecretsUpdateError> {
64        self.update(name, |entry| {
65            entry.value = zeroize::Zeroizing::new(value);
66        })
67    }
68
69    /// Stop resolving and injecting a secret for future connections. Removing
70    /// an already-absent secret is a no-op: the goal state is reached.
71    pub fn remove(&self, name: &str) {
72        let mut guard = self.inner.write().expect("secrets lock poisoned");
73        if !guard.secrets.iter().any(|entry| entry.env_var == name) {
74            return;
75        }
76        let mut config = (**guard).clone();
77        config.secrets.retain(|entry| entry.env_var != name);
78        *guard = Arc::new(config);
79    }
80
81    /// Replace the allowed hosts of an existing secret.
82    pub fn set_allowed_hosts(
83        &self,
84        name: &str,
85        hosts: &[String],
86    ) -> Result<(), SecretsUpdateError> {
87        if hosts.is_empty() {
88            return Err(SecretsUpdateError::MissingAllowedHosts {
89                name: name.to_string(),
90            });
91        }
92        let hosts: Vec<HostPattern> = hosts.iter().map(|host| HostPattern::parse(host)).collect();
93        self.update(name, |entry| entry.allowed_hosts = hosts)
94    }
95
96    /// Swap in a new snapshot with `mutate` applied to the named secret.
97    fn update(
98        &self,
99        name: &str,
100        mutate: impl FnOnce(&mut super::config::SecretEntry),
101    ) -> Result<(), SecretsUpdateError> {
102        let mut guard = self.inner.write().expect("secrets lock poisoned");
103        let mut config = (**guard).clone();
104        let entry = config
105            .secrets
106            .iter_mut()
107            .find(|entry| entry.env_var == name)
108            .ok_or_else(|| SecretsUpdateError::UnknownSecret {
109                name: name.to_string(),
110            })?;
111        mutate(entry);
112        *guard = Arc::new(config);
113        Ok(())
114    }
115}
116
117//--------------------------------------------------------------------------------------------------
118// Tests
119//--------------------------------------------------------------------------------------------------
120
121#[cfg(test)]
122mod tests {
123    use super::super::config::{SecretEntry, SecretInjection};
124    use super::*;
125
126    fn config_with_secret(name: &str, value: &str) -> SecretsConfig {
127        SecretsConfig {
128            secrets: vec![SecretEntry {
129                env_var: name.to_string(),
130                value: zeroize::Zeroizing::new(value.to_string()),
131                source: None,
132                placeholder: format!("$MSB_{name}"),
133                allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
134                injection: SecretInjection::default(),
135                on_violation: None,
136                require_tls_identity: true,
137            }],
138            ..SecretsConfig::default()
139        }
140    }
141
142    #[test]
143    fn rotate_swaps_value_for_future_loads() {
144        let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
145        let before = handle.load();
146
147        handle.rotate_value("API_KEY", "new".into()).unwrap();
148
149        // The pre-rotation snapshot is untouched; new loads see the new value.
150        assert_eq!(before.secrets[0].value.as_str(), "old");
151        assert_eq!(handle.load().secrets[0].value.as_str(), "new");
152        assert_eq!(handle.load().secrets[0].placeholder, "$MSB_API_KEY");
153    }
154
155    #[test]
156    fn rotate_unknown_secret_is_an_error() {
157        let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
158
159        assert_eq!(
160            handle.rotate_value("MISSING", "new".into()),
161            Err(SecretsUpdateError::UnknownSecret {
162                name: "MISSING".into()
163            })
164        );
165    }
166
167    #[test]
168    fn remove_drops_entry_and_is_idempotent() {
169        let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
170
171        handle.remove("API_KEY");
172        handle.remove("API_KEY");
173
174        assert!(handle.load().secrets.is_empty());
175    }
176
177    #[test]
178    fn set_allowed_hosts_replaces_patterns() {
179        let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
180
181        handle
182            .set_allowed_hosts("API_KEY", &["*.example.org".into(), "one.test".into()])
183            .unwrap();
184
185        assert_eq!(
186            handle.load().secrets[0].allowed_hosts,
187            vec![
188                HostPattern::Wildcard("*.example.org".into()),
189                HostPattern::Exact("one.test".into()),
190            ]
191        );
192    }
193
194    #[test]
195    fn set_allowed_hosts_rejects_empty_list() {
196        let handle = SecretsHandle::new(config_with_secret("API_KEY", "old"));
197
198        assert_eq!(
199            handle.set_allowed_hosts("API_KEY", &[]),
200            Err(SecretsUpdateError::MissingAllowedHosts {
201                name: "API_KEY".into()
202            })
203        );
204        assert_eq!(handle.load().secrets[0].allowed_hosts.len(), 1);
205    }
206}