Skip to main content

rullst_core/storage/
tenant.rs

1use std::path::{Component, Path};
2
3use super::{Storage, StorageError, normalized_object_key};
4use crate::security::TenantContext;
5
6/// Storage facade permanently bound to one authenticated tenant context.
7///
8/// Object keys are placed below `tenants/<tenant_id>/`; callers cannot escape
9/// that prefix through absolute paths, parent components, or backslashes. The
10/// tenant identifier itself must be exactly one normal path segment: object
11/// operations fail with [`StorageError::PathTraversal`] when it is empty,
12/// contains `/` or `\`, or consists only of dots. The wrapper provides
13/// namespace isolation, while membership authorization and backend bucket
14/// policy remain application and deployment responsibilities.
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub struct TenantStorage {
18    storage: Storage,
19    tenant_id: String,
20}
21
22impl TenantStorage {
23    /// Binds a storage engine to a tenant already validated by authentication.
24    pub fn from_context(storage: Storage, context: &TenantContext) -> Self {
25        Self {
26            storage,
27            tenant_id: context.tenant_id.clone(),
28        }
29    }
30
31    /// Returns the authenticated tenant identifier bound to this instance.
32    pub fn tenant_id(&self) -> &str {
33        &self.tenant_id
34    }
35
36    /// Returns the backend object key confined below the tenant namespace.
37    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    /// Stores bytes below this instance's immutable tenant prefix.
44    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    /// Retrieves bytes only from this instance's immutable tenant prefix.
51    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    /// Resolves a URL only after applying this instance's tenant prefix.
56    pub fn url(&self, relative_path: &str) -> Result<String, StorageError> {
57        self.storage.url(&self.object_key(relative_path)?)
58    }
59}
60
61/// Re-checks that the tenant identifier is a single normal path segment.
62///
63/// [`TenantContext::tenant_id`] is a public field, so the storage root must not
64/// rely on construction-time validation alone: `.` would otherwise collapse
65/// `tenants/./<path>` into another tenant's root.
66fn 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    // TM-TENANT-04
91    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    // TM-TENANT-04
132    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        // `TenantContext::tenant_id` is public, so it can change after validation.
141        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}