Skip to main content

http_handle/
tenant_isolation.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (c) 2023 - 2026 HTTP Handle
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)]
320// Test-only env-var mutations (`std::env::set_var` / `remove_var`) need
321// `unsafe` under Rust 2024. Each call site below is a paired write +
322// cleanup inside a single test scope and is documented at the use site.
323#[allow(unsafe_code)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn tenant_store_is_isolated() {
329        let store = TenantConfigStore::default();
330        let tenant_a = TenantId("alpha".into());
331        let tenant_b = TenantId("beta".into());
332        store
333            .set_config(
334                tenant_a.clone(),
335                TenantConfig {
336                    settings: [("mode".into(), "strict".into())]
337                        .into_iter()
338                        .collect(),
339                },
340            )
341            .expect("set");
342        assert_eq!(
343            store
344                .get_config(&tenant_a)
345                .expect("get")
346                .expect("cfg")
347                .settings
348                .get("mode"),
349            Some(&"strict".to_string())
350        );
351        assert!(store.get_config(&tenant_b).expect("get").is_none());
352    }
353
354    #[test]
355    fn static_secret_provider_is_tenant_scoped() {
356        let provider = StaticSecretProvider::default()
357            .with_secret(TenantId("alpha".into()), "db_password", "a1")
358            .with_secret(TenantId("beta".into()), "db_password", "b1");
359        let scoped = TenantScopedSecrets::new(provider);
360        assert_eq!(
361            scoped
362                .read(&TenantId("alpha".into()), "db_password")
363                .expect("read"),
364            Some("a1".to_string())
365        );
366        assert_eq!(
367            scoped
368                .read(&TenantId("beta".into()), "db_password")
369                .expect("read"),
370            Some("b1".to_string())
371        );
372    }
373
374    #[test]
375    fn env_secret_provider_namespaces_keys() {
376        let provider = EnvSecretProvider::new("HTTP_HANDLE_SECRET");
377        let tenant = TenantId("alpha-team".into());
378        let key = "api_token";
379        let env_key = "HTTP_HANDLE_SECRET_ALPHA_TEAM_API_TOKEN";
380        let value = "secret-value";
381        // Safety: this test writes and removes a single process env var in a
382        // short scope and does not spawn threads that concurrently mutate env.
383        unsafe { std::env::set_var(env_key, value) };
384        let got = provider.get_secret(&tenant, key).expect("read");
385        assert_eq!(got, Some(value.to_string()));
386        // Safety: paired cleanup for the key set above in the same test scope.
387        unsafe { std::env::remove_var(env_key) };
388    }
389
390    #[test]
391    fn env_secret_provider_returns_none_when_missing() {
392        let provider = EnvSecretProvider::new("HTTP_HANDLE_SECRET");
393        let got = provider
394            .get_secret(&TenantId("missing".into()), "api_token")
395            .expect("read");
396        assert!(got.is_none());
397    }
398
399    #[test]
400    fn tenant_store_write_poison_maps_to_error() {
401        let store = TenantConfigStore::default();
402        let _ = std::panic::catch_unwind(|| {
403            let _guard = store.data.write().expect("lock");
404            panic!("poison");
405        });
406        let err = store
407            .set_config(TenantId("t1".into()), TenantConfig::default())
408            .expect_err("must fail");
409        assert!(err.to_string().contains("poisoned"));
410    }
411
412    #[test]
413    fn tenant_store_read_poison_maps_to_error() {
414        let store = TenantConfigStore::default();
415        let _ = std::panic::catch_unwind(|| {
416            let _guard = store.data.write().expect("lock");
417            panic!("poison");
418        });
419        let err = store
420            .get_config(&TenantId("t1".into()))
421            .expect_err("must fail");
422        assert!(err.to_string().contains("poisoned"));
423    }
424}