Skip to main content

rullst_core/cache/
tenant.rs

1use std::sync::Arc;
2
3use super::{Cache, CacheError};
4use crate::security::{TenantContext, tenant_namespaced_name};
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    ///
36    /// The key is `tenants:<tenant>:<logical_key>`. In the tenant segment `%`
37    /// is written as `%25` and `:` as `%3A`, so a logical key containing `:`
38    /// can never alias another tenant's namespace.
39    pub fn namespaced_key(&self, logical_key: &str) -> Result<String, CacheError> {
40        validate_logical_key(logical_key)?;
41        Ok(tenant_namespaced_name(&self.tenant_id, logical_key))
42    }
43
44    /// Retrieves a value only from this instance's tenant namespace.
45    pub async fn get(&self, logical_key: &str) -> Result<Option<Arc<String>>, CacheError> {
46        self.cache.get(&self.namespaced_key(logical_key)?).await
47    }
48
49    /// Stores a value only inside this instance's tenant namespace.
50    pub async fn put(
51        &self,
52        logical_key: &str,
53        value: &str,
54        ttl_secs: Option<u64>,
55    ) -> Result<(), CacheError> {
56        self.cache
57            .put(&self.namespaced_key(logical_key)?, value, ttl_secs)
58            .await
59    }
60
61    /// Checks for a value only inside this instance's tenant namespace.
62    pub async fn has(&self, logical_key: &str) -> Result<bool, CacheError> {
63        self.cache.has(&self.namespaced_key(logical_key)?).await
64    }
65
66    /// Removes a value only from this instance's tenant namespace.
67    pub async fn forget(&self, logical_key: &str) -> Result<(), CacheError> {
68        self.cache.forget(&self.namespaced_key(logical_key)?).await
69    }
70
71    /// Retrieves a tenant-scoped value or computes and caches it.
72    pub async fn remember<F, Fut>(
73        &self,
74        logical_key: &str,
75        ttl_secs: u64,
76        compute: F,
77    ) -> Result<Arc<String>, CacheError>
78    where
79        F: FnOnce() -> Fut,
80        Fut: std::future::Future<Output = Result<String, CacheError>>,
81    {
82        self.cache
83            .remember(&self.namespaced_key(logical_key)?, ttl_secs, compute)
84            .await
85    }
86}
87
88fn validate_logical_key(key: &str) -> Result<(), CacheError> {
89    if key.is_empty() || key.len() > MAX_LOGICAL_KEY_BYTES {
90        return Err(CacheError::InvalidKey(
91            "key must contain 1 to 256 bytes".to_string(),
92        ));
93    }
94    if !key.bytes().all(|byte| {
95        byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/')
96    }) {
97        return Err(CacheError::InvalidKey(
98            "key contains unsupported characters".to_string(),
99        ));
100    }
101    Ok(())
102}
103
104#[cfg(test)]
105#[allow(clippy::expect_used)]
106mod tests {
107    use super::*;
108    use crate::security::TenantMembership;
109
110    #[tokio::test]
111    // TM-TENANT-04
112    async fn identical_keys_are_isolated_by_authenticated_tenant_context() {
113        let membership = TenantMembership::try_new(["school-alpha", "school-beta"])
114            .expect("valid tenant membership");
115        let alpha_context = membership.select("school-alpha").expect("alpha membership");
116        let beta_context = membership.select("school-beta").expect("beta membership");
117        let cache = Cache::memory();
118        let alpha = TenantCache::from_context(cache.clone(), &alpha_context);
119        let beta = TenantCache::from_context(cache, &beta_context);
120
121        alpha
122            .put("leaderboard:course:1", "alpha", Some(60))
123            .await
124            .expect("alpha cache write");
125        beta.put("leaderboard:course:1", "beta", Some(60))
126            .await
127            .expect("beta cache write");
128
129        assert_eq!(
130            alpha
131                .get("leaderboard:course:1")
132                .await
133                .expect("alpha cache read")
134                .as_deref()
135                .map(String::as_str),
136            Some("alpha")
137        );
138        assert_eq!(
139            beta.get("leaderboard:course:1")
140                .await
141                .expect("beta cache read")
142                .as_deref()
143                .map(String::as_str),
144            Some("beta")
145        );
146        assert_eq!(
147            alpha
148                .namespaced_key("leaderboard:course:1")
149                .expect("alpha key"),
150            "tenants:school-alpha:leaderboard:course:1"
151        );
152        assert!(matches!(
153            alpha.get("school beta\nsecret").await,
154            Err(CacheError::InvalidKey(_))
155        ));
156    }
157
158    #[tokio::test]
159    // TM-TENANT-04
160    async fn colon_in_tenant_or_logical_key_cannot_alias_another_namespace() {
161        let membership =
162            TenantMembership::try_new(["a", "a:b", "acme"]).expect("valid tenant membership");
163        let cache = Cache::memory();
164        let a = TenantCache::from_context(cache.clone(), &membership.select("a").expect("a"));
165        let a_b = TenantCache::from_context(cache.clone(), &membership.select("a:b").expect("a:b"));
166        let acme = TenantCache::from_context(cache, &membership.select("acme").expect("acme"));
167
168        assert_eq!(a.namespaced_key("b:c").expect("a key"), "tenants:a:b:c");
169        assert_eq!(a_b.namespaced_key("c").expect("a:b key"), "tenants:a%3Ab:c");
170        assert_eq!(
171            acme.namespaced_key("leaderboard:x").expect("acme key"),
172            "tenants:acme:leaderboard:x"
173        );
174
175        a.put("b:c", "tenant-a", Some(60))
176            .await
177            .expect("tenant a write");
178        assert!(!a_b.has("c").await.expect("tenant a:b probe"));
179        assert_eq!(a_b.get("c").await.expect("tenant a:b read"), None);
180
181        a_b.put("c", "tenant-a:b", Some(60))
182            .await
183            .expect("tenant a:b write");
184        assert_eq!(
185            a.get("b:c")
186                .await
187                .expect("tenant a read")
188                .as_deref()
189                .map(String::as_str),
190            Some("tenant-a")
191        );
192    }
193}