Skip to main content

http_handle/
tenant_isolation.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (c) 2026 Sebastien Rousseau
3
4//! Multi-tenant configuration isolation and secret-provider helpers.
5
6use crate::error::ServerError;
7use std::collections::HashMap;
8use std::sync::RwLock;
9
10/// Tenant identifier.
11///
12/// # Examples
13///
14/// ```rust
15/// use http_handle::tenant_isolation::TenantId;
16/// let t = TenantId("acme".into());
17/// assert_eq!(t.0, "acme");
18/// ```
19///
20/// # Panics
21///
22/// This type does not panic.
23#[derive(Clone, Debug, PartialEq, Eq, Hash)]
24pub struct TenantId(pub String);
25
26/// Per-tenant configuration document.
27///
28/// # Examples
29///
30/// ```rust
31/// use http_handle::tenant_isolation::TenantConfig;
32/// let cfg = TenantConfig::default();
33/// assert!(cfg.settings.is_empty());
34/// ```
35///
36/// # Panics
37///
38/// This type does not panic.
39#[derive(Clone, Debug, Default, PartialEq, Eq)]
40pub struct TenantConfig {
41    /// Arbitrary tenant settings.
42    pub settings: HashMap<String, String>,
43}
44
45/// Thread-safe tenant config store with strict tenant keying.
46///
47/// # Examples
48///
49/// ```rust
50/// use http_handle::tenant_isolation::TenantConfigStore;
51/// let _store = TenantConfigStore::default();
52/// assert_eq!(1, 1);
53/// ```
54///
55/// # Panics
56///
57/// This type does not panic.
58#[derive(Debug, Default)]
59pub struct TenantConfigStore {
60    data: RwLock<HashMap<TenantId, TenantConfig>>,
61}
62
63impl TenantConfigStore {
64    /// Writes tenant config snapshot.
65    ///
66    /// # Examples
67    ///
68    /// ```rust
69    /// use http_handle::tenant_isolation::{TenantConfig, TenantConfigStore, TenantId};
70    /// let store = TenantConfigStore::default();
71    /// let _ = store.set_config(TenantId("acme".into()), TenantConfig::default());
72    /// assert_eq!(1, 1);
73    /// ```
74    ///
75    /// # Errors
76    ///
77    /// Returns an error when the underlying lock is poisoned.
78    ///
79    /// # Panics
80    ///
81    /// This function does not panic.
82    pub fn set_config(
83        &self,
84        tenant: TenantId,
85        config: TenantConfig,
86    ) -> Result<(), ServerError> {
87        let mut guard = self.data.write().map_err(|_| {
88            ServerError::Custom("tenant store poisoned".into())
89        })?;
90        let _ = guard.insert(tenant, config);
91        Ok(())
92    }
93
94    /// Returns a cloned tenant config snapshot.
95    ///
96    /// # Examples
97    ///
98    /// ```rust
99    /// use http_handle::tenant_isolation::{TenantConfigStore, TenantId};
100    /// let store = TenantConfigStore::default();
101    /// let _ = store.get_config(&TenantId("acme".into()));
102    /// assert_eq!(1, 1);
103    /// ```
104    ///
105    /// # Errors
106    ///
107    /// Returns an error when the underlying lock is poisoned.
108    ///
109    /// # Panics
110    ///
111    /// This function does not panic.
112    pub fn get_config(
113        &self,
114        tenant: &TenantId,
115    ) -> Result<Option<TenantConfig>, ServerError> {
116        let guard = self.data.read().map_err(|_| {
117            ServerError::Custom("tenant store poisoned".into())
118        })?;
119        Ok(guard.get(tenant).cloned())
120    }
121}
122
123/// External secret provider contract for tenant-scoped lookup.
124///
125/// # Examples
126///
127/// ```rust
128/// use http_handle::tenant_isolation::SecretProvider;
129/// # let _ = std::any::TypeId::of::<&dyn SecretProvider>();
130/// assert_eq!(1, 1);
131/// ```
132///
133/// # Panics
134///
135/// Trait usage does not panic by itself.
136pub trait SecretProvider: Send + Sync + std::fmt::Debug {
137    /// Fetches secret for tenant and key.
138    fn get_secret(
139        &self,
140        tenant: &TenantId,
141        key: &str,
142    ) -> Result<Option<String>, ServerError>;
143}
144
145/// Environment-backed secret provider using strict tenant-key namespace.
146///
147/// # Examples
148///
149/// ```rust
150/// use http_handle::tenant_isolation::EnvSecretProvider;
151/// let _provider = EnvSecretProvider::new("HTTP_HANDLE_SECRET");
152/// assert_eq!(1, 1);
153/// ```
154///
155/// # Panics
156///
157/// This type does not panic.
158#[derive(Clone, Debug)]
159pub struct EnvSecretProvider {
160    prefix: String,
161}
162
163impl EnvSecretProvider {
164    /// Creates provider with prefix used in env keys.
165    ///
166    /// # Examples
167    ///
168    /// ```rust
169    /// use http_handle::tenant_isolation::EnvSecretProvider;
170    /// let _provider = EnvSecretProvider::new("HTTP_HANDLE_SECRET");
171    /// assert_eq!(1, 1);
172    /// ```
173    ///
174    /// # Panics
175    ///
176    /// This function does not panic.
177    pub fn new(prefix: impl Into<String>) -> Self {
178        Self {
179            prefix: prefix.into(),
180        }
181    }
182
183    fn env_key(&self, tenant: &TenantId, key: &str) -> String {
184        let tenant_norm =
185            tenant.0.replace('-', "_").to_ascii_uppercase();
186        let key_norm = key.replace('-', "_").to_ascii_uppercase();
187        format!("{}_{}_{}", self.prefix, tenant_norm, key_norm)
188    }
189}
190
191impl SecretProvider for EnvSecretProvider {
192    fn get_secret(
193        &self,
194        tenant: &TenantId,
195        key: &str,
196    ) -> Result<Option<String>, ServerError> {
197        let env_key = self.env_key(tenant, key);
198        Ok(std::env::var(env_key).ok())
199    }
200}
201
202/// In-memory secret provider useful for local development/testing.
203///
204/// # Examples
205///
206/// ```rust
207/// use http_handle::tenant_isolation::StaticSecretProvider;
208/// let _provider = StaticSecretProvider::default();
209/// assert_eq!(1, 1);
210/// ```
211///
212/// # Panics
213///
214/// This type does not panic.
215#[derive(Clone, Debug, Default)]
216pub struct StaticSecretProvider {
217    data: HashMap<(TenantId, String), String>,
218}
219
220impl StaticSecretProvider {
221    /// Adds a tenant-scoped secret value.
222    ///
223    /// # Examples
224    ///
225    /// ```rust
226    /// use http_handle::tenant_isolation::{StaticSecretProvider, TenantId};
227    /// let _provider = StaticSecretProvider::default().with_secret(TenantId("acme".into()), "token", "abc");
228    /// assert_eq!(1, 1);
229    /// ```
230    ///
231    /// # Panics
232    ///
233    /// This function does not panic.
234    pub fn with_secret(
235        mut self,
236        tenant: TenantId,
237        key: impl Into<String>,
238        value: impl Into<String>,
239    ) -> Self {
240        let _ = self.data.insert((tenant, key.into()), value.into());
241        self
242    }
243}
244
245impl SecretProvider for StaticSecretProvider {
246    fn get_secret(
247        &self,
248        tenant: &TenantId,
249        key: &str,
250    ) -> Result<Option<String>, ServerError> {
251        Ok(self.data.get(&(tenant.clone(), key.to_string())).cloned())
252    }
253}
254
255/// Tenant-scoped secret accessor.
256///
257/// # Examples
258///
259/// ```rust
260/// use http_handle::tenant_isolation::{StaticSecretProvider, TenantScopedSecrets};
261/// let provider = StaticSecretProvider::default();
262/// let _secrets = TenantScopedSecrets::new(provider);
263/// assert_eq!(1, 1);
264/// ```
265///
266/// # Panics
267///
268/// This type does not panic.
269#[derive(Debug)]
270pub struct TenantScopedSecrets<P: SecretProvider> {
271    provider: P,
272}
273
274impl<P: SecretProvider> TenantScopedSecrets<P> {
275    /// Creates a tenant-scoped secret accessor.
276    ///
277    /// # Examples
278    ///
279    /// ```rust
280    /// use http_handle::tenant_isolation::{StaticSecretProvider, TenantScopedSecrets};
281    /// let _s = TenantScopedSecrets::new(StaticSecretProvider::default());
282    /// assert_eq!(1, 1);
283    /// ```
284    ///
285    /// # Panics
286    ///
287    /// This function does not panic.
288    pub fn new(provider: P) -> Self {
289        Self { provider }
290    }
291
292    /// Reads tenant secret.
293    ///
294    /// # Examples
295    ///
296    /// ```rust
297    /// use http_handle::tenant_isolation::{StaticSecretProvider, TenantId, TenantScopedSecrets};
298    /// let s = TenantScopedSecrets::new(StaticSecretProvider::default());
299    /// let _ = s.read(&TenantId("acme".into()), "token");
300    /// assert_eq!(1, 1);
301    /// ```
302    ///
303    /// # Errors
304    ///
305    /// Returns provider-specific errors for secret lookup failures.
306    ///
307    /// # Panics
308    ///
309    /// This function does not panic.
310    pub fn read(
311        &self,
312        tenant: &TenantId,
313        key: &str,
314    ) -> Result<Option<String>, ServerError> {
315        self.provider.get_secret(tenant, key)
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn tenant_store_is_isolated() {
325        let store = TenantConfigStore::default();
326        let tenant_a = TenantId("alpha".into());
327        let tenant_b = TenantId("beta".into());
328        store
329            .set_config(
330                tenant_a.clone(),
331                TenantConfig {
332                    settings: [("mode".into(), "strict".into())]
333                        .into_iter()
334                        .collect(),
335                },
336            )
337            .expect("set");
338        assert_eq!(
339            store
340                .get_config(&tenant_a)
341                .expect("get")
342                .expect("cfg")
343                .settings
344                .get("mode"),
345            Some(&"strict".to_string())
346        );
347        assert!(store.get_config(&tenant_b).expect("get").is_none());
348    }
349
350    #[test]
351    fn static_secret_provider_is_tenant_scoped() {
352        let provider = StaticSecretProvider::default()
353            .with_secret(TenantId("alpha".into()), "db_password", "a1")
354            .with_secret(TenantId("beta".into()), "db_password", "b1");
355        let scoped = TenantScopedSecrets::new(provider);
356        assert_eq!(
357            scoped
358                .read(&TenantId("alpha".into()), "db_password")
359                .expect("read"),
360            Some("a1".to_string())
361        );
362        assert_eq!(
363            scoped
364                .read(&TenantId("beta".into()), "db_password")
365                .expect("read"),
366            Some("b1".to_string())
367        );
368    }
369
370    #[test]
371    fn env_secret_provider_namespaces_keys() {
372        let provider = EnvSecretProvider::new("HTTP_HANDLE_SECRET");
373        let tenant = TenantId("alpha-team".into());
374        let key = "api_token";
375        let env_key = "HTTP_HANDLE_SECRET_ALPHA_TEAM_API_TOKEN";
376        let value = "secret-value";
377        // Safety: this test writes and removes a single process env var in a
378        // short scope and does not spawn threads that concurrently mutate env.
379        unsafe { std::env::set_var(env_key, value) };
380        let got = provider.get_secret(&tenant, key).expect("read");
381        assert_eq!(got, Some(value.to_string()));
382        // Safety: paired cleanup for the key set above in the same test scope.
383        unsafe { std::env::remove_var(env_key) };
384    }
385
386    #[test]
387    fn env_secret_provider_returns_none_when_missing() {
388        let provider = EnvSecretProvider::new("HTTP_HANDLE_SECRET");
389        let got = provider
390            .get_secret(&TenantId("missing".into()), "api_token")
391            .expect("read");
392        assert!(got.is_none());
393    }
394
395    #[test]
396    fn tenant_store_write_poison_maps_to_error() {
397        let store = TenantConfigStore::default();
398        let _ = std::panic::catch_unwind(|| {
399            let _guard = store.data.write().expect("lock");
400            panic!("poison");
401        });
402        let err = store
403            .set_config(TenantId("t1".into()), TenantConfig::default())
404            .expect_err("must fail");
405        assert!(err.to_string().contains("poisoned"));
406    }
407
408    #[test]
409    fn tenant_store_read_poison_maps_to_error() {
410        let store = TenantConfigStore::default();
411        let _ = std::panic::catch_unwind(|| {
412            let _guard = store.data.write().expect("lock");
413            panic!("poison");
414        });
415        let err = store
416            .get_config(&TenantId("t1".into()))
417            .expect_err("must fail");
418        assert!(err.to_string().contains("poisoned"));
419    }
420}