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}