rullst_core/cache/
tenant.rs1use std::sync::Arc;
2
3use super::{Cache, CacheError};
4use crate::security::TenantContext;
5
6const MAX_LOGICAL_KEY_BYTES: usize = 256;
7
8#[derive(Clone)]
14#[non_exhaustive]
15pub struct TenantCache {
16 cache: Cache,
17 tenant_id: String,
18}
19
20impl TenantCache {
21 pub fn from_context(cache: Cache, context: &TenantContext) -> Self {
23 Self {
24 cache,
25 tenant_id: context.tenant_id.clone(),
26 }
27 }
28
29 pub fn tenant_id(&self) -> &str {
31 &self.tenant_id
32 }
33
34 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 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 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 pub async fn has(&self, logical_key: &str) -> Result<bool, CacheError> {
59 self.cache.has(&self.namespaced_key(logical_key)?).await
60 }
61
62 pub async fn forget(&self, logical_key: &str) -> Result<(), CacheError> {
64 self.cache.forget(&self.namespaced_key(logical_key)?).await
65 }
66
67 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 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}