rullst_core/cache/
tenant.rs1use 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#[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> {
40 validate_logical_key(logical_key)?;
41 Ok(tenant_namespaced_name(&self.tenant_id, logical_key))
42 }
43
44 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 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 pub async fn has(&self, logical_key: &str) -> Result<bool, CacheError> {
63 self.cache.has(&self.namespaced_key(logical_key)?).await
64 }
65
66 pub async fn forget(&self, logical_key: &str) -> Result<(), CacheError> {
68 self.cache.forget(&self.namespaced_key(logical_key)?).await
69 }
70
71 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 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 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}