Skip to main content

rullst_core/cache/
tenant.rs

1use std::sync::Arc;
2
3use super::{Cache, CacheError};
4use crate::security::TenantContext;
5
6const MAX_LOGICAL_KEY_BYTES: usize = 256;
7
8/// Cache facade permanently bound to one authenticated tenant context.
9///
10/// Every operation derives the backend key from the immutable tenant identity.
11/// The wrapper intentionally has no global `flush`; callers may only forget
12/// explicit keys inside their tenant namespace.
13#[derive(Clone)]
14#[non_exhaustive]
15pub struct TenantCache {
16    cache: Cache,
17    tenant_id: String,
18}
19
20impl TenantCache {
21    /// Binds a cache to a tenant already validated by authentication.
22    pub fn from_context(cache: Cache, context: &TenantContext) -> Self {
23        Self {
24            cache,
25            tenant_id: context.tenant_id.clone(),
26        }
27    }
28
29    /// Returns the authenticated tenant identifier bound to this instance.
30    pub fn tenant_id(&self) -> &str {
31        &self.tenant_id
32    }
33
34    /// Returns the canonical backend key within this instance's tenant namespace.
35    pub fn namespaced_key(&self, logical_key: &str) -> Result<String, CacheError> {
36        validate_logical_key(logical_key)?;
37        Ok(format!("tenants:{}:{logical_key}", self.tenant_id))
38    }
39
40    /// Retrieves a value only from this instance's tenant namespace.
41    pub async fn get(&self, logical_key: &str) -> Result<Option<Arc<String>>, CacheError> {
42        self.cache.get(&self.namespaced_key(logical_key)?).await
43    }
44
45    /// Stores a value only inside this instance's tenant namespace.
46    pub async fn put(
47        &self,
48        logical_key: &str,
49        value: &str,
50        ttl_secs: Option<u64>,
51    ) -> Result<(), CacheError> {
52        self.cache
53            .put(&self.namespaced_key(logical_key)?, value, ttl_secs)
54            .await
55    }
56
57    /// Checks for a value only inside this instance's tenant namespace.
58    pub async fn has(&self, logical_key: &str) -> Result<bool, CacheError> {
59        self.cache.has(&self.namespaced_key(logical_key)?).await
60    }
61
62    /// Removes a value only from this instance's tenant namespace.
63    pub async fn forget(&self, logical_key: &str) -> Result<(), CacheError> {
64        self.cache.forget(&self.namespaced_key(logical_key)?).await
65    }
66
67    /// Retrieves a tenant-scoped value or computes and caches it.
68    pub async fn remember<F, Fut>(
69        &self,
70        logical_key: &str,
71        ttl_secs: u64,
72        compute: F,
73    ) -> Result<Arc<String>, CacheError>
74    where
75        F: FnOnce() -> Fut,
76        Fut: std::future::Future<Output = Result<String, CacheError>>,
77    {
78        self.cache
79            .remember(&self.namespaced_key(logical_key)?, ttl_secs, compute)
80            .await
81    }
82}
83
84fn validate_logical_key(key: &str) -> Result<(), CacheError> {
85    if key.is_empty() || key.len() > MAX_LOGICAL_KEY_BYTES {
86        return Err(CacheError::InvalidKey(
87            "key must contain 1 to 256 bytes".to_string(),
88        ));
89    }
90    if !key.bytes().all(|byte| {
91        byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')
92    }) {
93        return Err(CacheError::InvalidKey(
94            "key contains unsupported characters".to_string(),
95        ));
96    }
97    Ok(())
98}
99
100#[cfg(test)]
101#[allow(clippy::expect_used)]
102mod tests {
103    use super::*;
104    use crate::security::TenantMembership;
105
106    #[tokio::test]
107    // TM-TENANT-04
108    async fn identical_keys_are_isolated_by_authenticated_tenant_context() {
109        let membership = TenantMembership::try_new(["school-alpha", "school-beta"])
110            .expect("valid tenant membership");
111        let alpha_context = membership.select("school-alpha").expect("alpha membership");
112        let beta_context = membership.select("school-beta").expect("beta membership");
113        let cache = Cache::memory();
114        let alpha = TenantCache::from_context(cache.clone(), &alpha_context);
115        let beta = TenantCache::from_context(cache, &beta_context);
116
117        alpha
118            .put("leaderboard:course:1", "alpha", Some(60))
119            .await
120            .expect("alpha cache write");
121        beta.put("leaderboard:course:1", "beta", Some(60))
122            .await
123            .expect("beta cache write");
124
125        assert_eq!(
126            alpha
127                .get("leaderboard:course:1")
128                .await
129                .expect("alpha cache read")
130                .as_deref()
131                .map(String::as_str),
132            Some("alpha")
133        );
134        assert_eq!(
135            beta.get("leaderboard:course:1")
136                .await
137                .expect("beta cache read")
138                .as_deref()
139                .map(String::as_str),
140            Some("beta")
141        );
142        assert_eq!(
143            alpha
144                .namespaced_key("leaderboard:course:1")
145                .expect("alpha key"),
146            "tenants:school-alpha:leaderboard:course:1"
147        );
148        assert!(matches!(
149            alpha.get("school beta\nsecret").await,
150            Err(CacheError::InvalidKey(_))
151        ));
152    }
153}