rullst_core/storage/
tenant.rs1use std::path::{Component, Path};
2
3use super::{Storage, StorageError, normalized_object_key};
4use crate::security::TenantContext;
5
6#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub struct TenantStorage {
18 storage: Storage,
19 tenant_id: String,
20}
21
22impl TenantStorage {
23 pub fn from_context(storage: Storage, context: &TenantContext) -> Self {
25 Self {
26 storage,
27 tenant_id: context.tenant_id.clone(),
28 }
29 }
30
31 pub fn tenant_id(&self) -> &str {
33 &self.tenant_id
34 }
35
36 pub fn object_key(&self, relative_path: &str) -> Result<String, StorageError> {
38 let tenant = tenant_root_segment(&self.tenant_id)?;
39 let path = normalized_object_key(relative_path)?;
40 Ok(format!("tenants/{tenant}/{path}"))
41 }
42
43 pub async fn put(&self, relative_path: &str, bytes: &[u8]) -> Result<(), StorageError> {
45 self.storage
46 .put(&self.object_key(relative_path)?, bytes)
47 .await
48 }
49
50 pub async fn get(&self, relative_path: &str) -> Result<Vec<u8>, StorageError> {
52 self.storage.get(&self.object_key(relative_path)?).await
53 }
54
55 pub fn url(&self, relative_path: &str) -> Result<String, StorageError> {
57 self.storage.url(&self.object_key(relative_path)?)
58 }
59}
60
61fn tenant_root_segment(tenant_id: &str) -> Result<&str, StorageError> {
67 let mut components = Path::new(tenant_id).components();
68 let single_normal = matches!(
69 (components.next(), components.next()),
70 (Some(Component::Normal(segment)), None) if segment.to_str() == Some(tenant_id)
71 );
72 if !single_normal
73 || tenant_id.contains(['/', '\\', '\0'])
74 || tenant_id.bytes().all(|byte| byte == b'.')
75 {
76 return Err(StorageError::PathTraversal(
77 "tenant identifier is not a single path segment".to_string(),
78 ));
79 }
80 Ok(tenant_id)
81}
82
83#[cfg(test)]
84#[allow(clippy::expect_used, clippy::unwrap_used)]
85mod tests {
86 use super::*;
87 use crate::security::TenantMembership;
88
89 #[tokio::test]
90 async fn identical_keys_are_isolated_by_authenticated_tenant_context() {
92 let suffix = uuid::Uuid::new_v4();
93 let root = std::env::temp_dir().join(format!("rullst-tenant-storage-{suffix}"));
94 let membership = TenantMembership::try_new(["school-alpha", "school-beta"])
95 .expect("valid tenant membership");
96 let alpha_context = membership.select("school-alpha").expect("alpha membership");
97 let beta_context = membership.select("school-beta").expect("beta membership");
98 let storage = Storage::local(root.to_string_lossy());
99 let alpha = TenantStorage::from_context(storage.clone(), &alpha_context);
100 let beta = TenantStorage::from_context(storage, &beta_context);
101
102 alpha
103 .put("courses/1/lesson.txt", b"alpha")
104 .await
105 .expect("alpha write");
106 beta.put("courses/1/lesson.txt", b"beta")
107 .await
108 .expect("beta write");
109
110 assert_eq!(
111 alpha.get("courses/1/lesson.txt").await.expect("alpha read"),
112 b"alpha"
113 );
114 assert_eq!(
115 beta.get("courses/1/lesson.txt").await.expect("beta read"),
116 b"beta"
117 );
118 assert_eq!(
119 alpha.object_key("courses/1/lesson.txt").expect("alpha key"),
120 "tenants/school-alpha/courses/1/lesson.txt"
121 );
122 assert!(matches!(
123 alpha.get("../school-beta/secret.txt").await,
124 Err(StorageError::PathTraversal(_))
125 ));
126
127 std::fs::remove_dir_all(root).expect("tenant storage cleanup");
128 }
129
130 #[tokio::test]
131 async fn tenant_ids_that_are_not_one_path_segment_are_rejected() {
133 let suffix = uuid::Uuid::new_v4();
134 let root = std::env::temp_dir().join(format!("rullst-tenant-segment-{suffix}"));
135 let storage = Storage::local(root.to_string_lossy());
136 let acme_context = TenantContext::try_new("acme").expect("valid tenant");
137 let acme = TenantStorage::from_context(storage.clone(), &acme_context);
138 acme.put("secret.txt", b"acme").await.expect("acme write");
139
140 for tenant_id in ["", ".", "..", "...", "acme/x", "acme\\x", "/acme", "acme/"] {
142 let mut context = acme_context.clone();
143 context.tenant_id = tenant_id.to_string();
144 let tenant = TenantStorage::from_context(storage.clone(), &context);
145 assert!(
146 matches!(
147 tenant.object_key("acme/secret.txt"),
148 Err(StorageError::PathTraversal(_))
149 ),
150 "{tenant_id:?}"
151 );
152 assert!(matches!(
153 tenant.get("acme/secret.txt").await,
154 Err(StorageError::PathTraversal(_))
155 ));
156 assert!(matches!(
157 tenant.put("acme/secret.txt", b"overwrite").await,
158 Err(StorageError::PathTraversal(_))
159 ));
160 }
161 assert_eq!(acme.get("secret.txt").await.expect("acme read"), b"acme");
162
163 assert_eq!(
164 acme.object_key("courses/1/lesson.txt").expect("acme key"),
165 "tenants/acme/courses/1/lesson.txt"
166 );
167 let colon = TenantStorage::from_context(
168 storage,
169 &TenantContext::try_new("acme:prod").expect("valid tenant"),
170 );
171 assert_eq!(
172 colon.object_key("lesson.txt").expect("colon key"),
173 "tenants/acme:prod/lesson.txt"
174 );
175
176 std::fs::remove_dir_all(root).expect("tenant storage cleanup");
177 }
178}