rullst_core/security/
tenant_guard.rs1use axum::{
4 extract::Request,
5 http::StatusCode,
6 middleware::Next,
7 response::{IntoResponse, Response},
8};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct TenantContext {
14 pub tenant_id: String,
16}
17
18impl TenantContext {
19 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#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TenantMembership {
33 tenant_ids: Vec<String>,
34 default_tenant_id: Option<String>,
35}
36
37impl TenantMembership {
38 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 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 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 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#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum TenantContextError {
102 #[error("invalid tenant identifier `{0}`")]
104 InvalidTenantId(String),
105 #[error("authenticated tenant membership is empty")]
107 EmptyMembership,
108 #[error("tenant `{0}` is not present in the authenticated membership")]
110 TenantNotInMembership(String),
111}
112
113pub(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
130pub(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#[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#[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 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 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}