use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct TenantContext {
pub tenant_id: String,
}
impl TenantContext {
pub fn try_new(tenant_id: impl Into<String>) -> Result<Self, TenantContextError> {
let tenant_id = tenant_id.into();
validate_tenant_id(&tenant_id)?;
Ok(Self { tenant_id })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TenantMembership {
tenant_ids: Vec<String>,
default_tenant_id: Option<String>,
}
impl TenantMembership {
pub fn try_new<I, S>(tenant_ids: I) -> Result<Self, TenantContextError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut tenant_ids = tenant_ids
.into_iter()
.map(Into::into)
.collect::<Vec<String>>();
if tenant_ids.is_empty() {
return Err(TenantContextError::EmptyMembership);
}
for tenant_id in &tenant_ids {
validate_tenant_id(tenant_id)?;
}
tenant_ids.sort_unstable();
tenant_ids.dedup();
Ok(Self {
tenant_ids,
default_tenant_id: None,
})
}
pub fn with_default(
mut self,
tenant_id: impl Into<String>,
) -> Result<Self, TenantContextError> {
let tenant_id = tenant_id.into();
validate_tenant_id(&tenant_id)?;
if !self.tenant_ids.iter().any(|allowed| allowed == &tenant_id) {
return Err(TenantContextError::TenantNotInMembership(tenant_id));
}
self.default_tenant_id = Some(tenant_id);
Ok(self)
}
pub fn select(&self, tenant_id: &str) -> Result<TenantContext, TenantContextError> {
validate_tenant_id(tenant_id)?;
if !self.tenant_ids.iter().any(|allowed| allowed == tenant_id) {
return Err(TenantContextError::TenantNotInMembership(
tenant_id.to_string(),
));
}
TenantContext::try_new(tenant_id)
}
pub fn default_context(&self) -> Option<TenantContext> {
let selected = self
.default_tenant_id
.as_deref()
.or_else(|| (self.tenant_ids.len() == 1).then(|| self.tenant_ids[0].as_str()))?;
TenantContext::try_new(selected).ok()
}
}
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum TenantContextError {
#[error("invalid tenant identifier `{0}`")]
InvalidTenantId(String),
#[error("authenticated tenant membership is empty")]
EmptyMembership,
#[error("tenant `{0}` is not present in the authenticated membership")]
TenantNotInMembership(String),
}
fn validate_tenant_id(tenant_id: &str) -> Result<(), TenantContextError> {
if tenant_id.is_empty()
|| tenant_id.len() > 128
|| !tenant_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
{
return Err(TenantContextError::InvalidTenantId(tenant_id.to_string()));
}
Ok(())
}
fn apply_authenticated_default(req: &mut Request) {
if req.extensions().get::<TenantContext>().is_some() {
return;
}
let default_context = req
.extensions()
.get::<TenantMembership>()
.and_then(TenantMembership::default_context);
if let Some(context) = default_context {
req.extensions_mut().insert(context);
}
}
#[cfg_attr(mutants, mutants::skip)]
pub async fn tenant_guard_middleware(mut req: Request, next: Next) -> Response {
apply_authenticated_default(&mut req);
next.run(req).await
}
#[cfg_attr(mutants, mutants::skip)]
pub async fn strict_tenant_guard_middleware(mut req: Request, next: Next) -> Response {
apply_authenticated_default(&mut req);
if req.extensions().get::<TenantContext>().is_some() {
next.run(req).await
} else {
(
StatusCode::UNAUTHORIZED,
"Authenticated tenant context is required",
)
.into_response()
}
}