Skip to main content

rullst_core/security/
tenant_guard.rs

1//! Multi-tenant isolation guard middleware and request extension context.
2
3use axum::{
4    extract::Request,
5    http::StatusCode,
6    middleware::Next,
7    response::{IntoResponse, Response},
8};
9
10/// Represents an active tenant selected by trusted authentication middleware.
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct TenantContext {
14    /// The unique identifier of the tenant or organization.
15    pub tenant_id: String,
16}
17
18impl TenantContext {
19    /// Creates a validated [`TenantContext`] from a trusted identity claim.
20    ///
21    /// Identifiers are 1 to 128 bytes of ASCII letters, digits, `-`, `_`, `.`
22    /// and `:`, and must not consist only of dots.
23    pub fn try_new(tenant_id: impl Into<String>) -> Result<Self, TenantContextError> {
24        let tenant_id = tenant_id.into();
25        validate_tenant_id(&tenant_id)?;
26        Ok(Self { tenant_id })
27    }
28}
29
30/// Membership claims established by authentication middleware.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TenantMembership {
33    tenant_ids: Vec<String>,
34    default_tenant_id: Option<String>,
35}
36
37impl TenantMembership {
38    /// Creates a validated membership set from authenticated claims.
39    pub fn try_new<I, S>(tenant_ids: I) -> Result<Self, TenantContextError>
40    where
41        I: IntoIterator<Item = S>,
42        S: Into<String>,
43    {
44        let mut tenant_ids = tenant_ids
45            .into_iter()
46            .map(Into::into)
47            .collect::<Vec<String>>();
48        if tenant_ids.is_empty() {
49            return Err(TenantContextError::EmptyMembership);
50        }
51        for tenant_id in &tenant_ids {
52            validate_tenant_id(tenant_id)?;
53        }
54        tenant_ids.sort_unstable();
55        tenant_ids.dedup();
56        Ok(Self {
57            tenant_ids,
58            default_tenant_id: None,
59        })
60    }
61
62    /// Selects a default tenant that must belong to this authenticated identity.
63    pub fn with_default(
64        mut self,
65        tenant_id: impl Into<String>,
66    ) -> Result<Self, TenantContextError> {
67        let tenant_id = tenant_id.into();
68        validate_tenant_id(&tenant_id)?;
69        if !self.tenant_ids.iter().any(|allowed| allowed == &tenant_id) {
70            return Err(TenantContextError::TenantNotInMembership(tenant_id));
71        }
72        self.default_tenant_id = Some(tenant_id);
73        Ok(self)
74    }
75
76    /// Selects a tenant only when it belongs to the authenticated identity.
77    pub fn select(&self, tenant_id: &str) -> Result<TenantContext, TenantContextError> {
78        validate_tenant_id(tenant_id)?;
79        if !self.tenant_ids.iter().any(|allowed| allowed == tenant_id) {
80            return Err(TenantContextError::TenantNotInMembership(
81                tenant_id.to_string(),
82            ));
83        }
84        TenantContext::try_new(tenant_id)
85    }
86
87    /// Returns the authenticated default tenant, or the sole membership when
88    /// exactly one tenant is available.
89    pub fn default_context(&self) -> Option<TenantContext> {
90        let selected = self
91            .default_tenant_id
92            .as_deref()
93            .or_else(|| (self.tenant_ids.len() == 1).then(|| self.tenant_ids[0].as_str()))?;
94        TenantContext::try_new(selected).ok()
95    }
96}
97
98/// Invalid or unauthorized tenant identity state.
99#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum TenantContextError {
102    /// Tenant identifiers must use a bounded, unambiguous character set.
103    #[error("invalid tenant identifier `{0}`")]
104    InvalidTenantId(String),
105    /// An authenticated identity must belong to at least one tenant.
106    #[error("authenticated tenant membership is empty")]
107    EmptyMembership,
108    /// The selected tenant is not present in the authenticated identity claims.
109    #[error("tenant `{0}` is not present in the authenticated membership")]
110    TenantNotInMembership(String),
111}
112
113/// Validates the bounded tenant identifier character set.
114///
115/// Identifiers made only of dots (`.`, `..`, `...`) are rejected because they
116/// are relative path components rather than tenant names.
117pub(crate) fn validate_tenant_id(tenant_id: &str) -> Result<(), TenantContextError> {
118    if tenant_id.is_empty()
119        || tenant_id.len() > 128
120        || tenant_id.bytes().all(|byte| byte == b'.')
121        || !tenant_id
122            .bytes()
123            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
124    {
125        return Err(TenantContextError::InvalidTenantId(tenant_id.to_string()));
126    }
127    Ok(())
128}
129
130/// Builds the canonical `tenants:<tenant>:<logical>` backend name shared by
131/// tenant-bound cache keys, realtime channels and presence rooms.
132///
133/// Only the tenant segment is encoded: `%` becomes `%25` and `:` becomes
134/// `%3A`. The segment therefore never contains the `:` delimiter, so distinct
135/// `(tenant, logical)` pairs always yield distinct names even when the logical
136/// name contains `:`. Identifiers without `%` or `:` keep their previous name.
137pub(crate) fn tenant_namespaced_name(tenant_id: &str, logical_name: &str) -> String {
138    const PREFIX: &str = "tenants:";
139    let mut name = String::with_capacity(PREFIX.len() + tenant_id.len() + 1 + logical_name.len());
140    name.push_str(PREFIX);
141    for c in tenant_id.chars() {
142        match c {
143            '%' => name.push_str("%25"),
144            ':' => name.push_str("%3A"),
145            other => name.push(other),
146        }
147    }
148    name.push(':');
149    name.push_str(logical_name);
150    name
151}
152
153fn apply_authenticated_default(req: &mut Request) {
154    if req.extensions().get::<TenantContext>().is_some() {
155        return;
156    }
157    let default_context = req
158        .extensions()
159        .get::<TenantMembership>()
160        .and_then(TenantMembership::default_context);
161    if let Some(context) = default_context {
162        req.extensions_mut().insert(context);
163    }
164}
165
166/// Propagates tenant context established by trusted authentication middleware.
167///
168/// Client-controlled tenant headers are deliberately ignored. An upstream
169/// authentication layer must insert [`TenantContext`] or [`TenantMembership`]
170/// into request extensions after validating claims and membership.
171#[cfg_attr(mutants, mutants::skip)]
172pub async fn tenant_guard_middleware(mut req: Request, next: Next) -> Response {
173    apply_authenticated_default(&mut req);
174    next.run(req).await
175}
176
177/// Strict guard that rejects requests without authenticated tenant context.
178#[cfg_attr(mutants, mutants::skip)]
179pub async fn strict_tenant_guard_middleware(mut req: Request, next: Next) -> Response {
180    apply_authenticated_default(&mut req);
181    if req.extensions().get::<TenantContext>().is_some() {
182        next.run(req).await
183    } else {
184        (
185            StatusCode::UNAUTHORIZED,
186            "Authenticated tenant context is required",
187        )
188            .into_response()
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    // TM-TENANT-04
198    fn dot_only_tenant_ids_are_rejected() {
199        for tenant_id in [".", "..", "..."] {
200            assert_eq!(
201                TenantContext::try_new(tenant_id),
202                Err(TenantContextError::InvalidTenantId(tenant_id.to_string()))
203            );
204            assert!(TenantMembership::try_new([tenant_id]).is_err());
205        }
206        for tenant_id in ["acme", "acme.eu", ".acme", "acme.", "acme:prod", "a-b_c"] {
207            assert!(TenantContext::try_new(tenant_id).is_ok(), "{tenant_id}");
208        }
209    }
210
211    #[test]
212    // TM-TENANT-04
213    fn tenant_namespaced_names_are_injective() {
214        assert_eq!(
215            tenant_namespaced_name("acme", "leaderboard:x"),
216            "tenants:acme:leaderboard:x"
217        );
218        assert_eq!(tenant_namespaced_name("a", "b:c"), "tenants:a:b:c");
219        assert_eq!(tenant_namespaced_name("a:b", "c"), "tenants:a%3Ab:c");
220        assert_eq!(tenant_namespaced_name("a%3Ab", "c"), "tenants:a%253Ab:c");
221        assert_ne!(
222            tenant_namespaced_name("a", "b:c"),
223            tenant_namespaced_name("a:b", "c")
224        );
225        assert_ne!(
226            tenant_namespaced_name("a:b", "c"),
227            tenant_namespaced_name("a%3Ab", "c")
228        );
229    }
230}