use std::sync::Arc;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use sqlx::Database;
use crate::sql::sqlx;
use crate::tenancy::{
session::SessionSecret, ChainResolver, DefaultTenantDb, Org, TenancyError, TenantConn,
TenantPools,
};
pub struct TenantContext<DB: Database = DefaultTenantDb> {
pub pools: Arc<TenantPools<DB>>,
pub resolver: ChainResolver,
pub session_secret: SessionSecret,
pub operator_secret: SessionSecret,
}
type ConnSource<DB> = Arc<
dyn Fn() -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<TenantConn<DB>, TenancyError>> + Send>,
> + Send
+ Sync,
>;
enum TenantConnCell<DB: Database> {
Ready(TenantConn<DB>),
Deferred(ConnSource<DB>),
}
pub struct Tenant<DB: Database = DefaultTenantDb> {
pub org: Org,
conn: TenantConnCell<DB>,
pool: crate::sql::Pool,
}
impl<DB: Database> Tenant<DB> {
pub async fn pool_conn(&mut self) -> Result<&mut sqlx::pool::PoolConnection<DB>, TenancyError> {
self.ensure_conn().await?;
match &mut self.conn {
TenantConnCell::Ready(conn) => Ok(conn),
TenantConnCell::Deferred(_) => {
unreachable!("ensure_conn just populated the connection")
}
}
}
async fn ensure_conn(&mut self) -> Result<(), TenancyError> {
let source = match &self.conn {
TenantConnCell::Ready(_) => return Ok(()),
TenantConnCell::Deferred(source) => source.clone(),
};
self.conn = TenantConnCell::Ready(source().await?);
Ok(())
}
pub async fn into_conn(mut self) -> Result<TenantConn<DB>, TenancyError> {
self.ensure_conn().await?;
match self.conn {
TenantConnCell::Ready(conn) => Ok(conn),
TenantConnCell::Deferred(_) => {
unreachable!("ensure_conn just populated the connection")
}
}
}
#[must_use]
pub fn pool(&self) -> &crate::sql::Pool {
&self.pool
}
#[cfg(any(feature = "sqlite", feature = "mysql"))]
fn deferred(org: Org, pool: sqlx::Pool<DB>) -> Self
where
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let erased = crate::sql::Pool::from(pool.clone());
let source: ConnSource<DB> = Arc::new(move || {
let pool = pool.clone();
Box::pin(async move { Ok(TenantConn::database(pool.acquire().await?)) })
});
Self {
org,
pool: erased,
conn: TenantConnCell::Deferred(source),
}
}
}
#[cfg(all(feature = "sqlite", any(test, feature = "test_utils")))]
impl Tenant<sqlx::Sqlite> {
#[must_use]
pub fn for_test_sqlite(org: Org, pool: sqlx::SqlitePool) -> Self {
Self::deferred(org, pool)
}
}
#[cfg(all(feature = "mysql", any(test, feature = "test_utils")))]
impl Tenant<sqlx::MySql> {
#[must_use]
pub fn for_test_mysql(org: Org, pool: sqlx::MySqlPool) -> Self {
Self::deferred(org, pool)
}
}
#[cfg(all(feature = "postgres", any(test, feature = "test_utils")))]
impl Tenant<sqlx::Postgres> {
pub async fn for_test_pools(
org: Org,
pools: &Arc<TenantPools<sqlx::Postgres>>,
) -> Result<Self, TenancyError> {
Self::from_pools(org, pools).await
}
}
#[cfg(feature = "postgres")]
impl Tenant<sqlx::Postgres> {
async fn from_pools(
org: Org,
pools: &Arc<TenantPools<sqlx::Postgres>>,
) -> Result<Self, TenancyError> {
let pool = pools.scoped_pool_dyn(&org).await?;
let (pools, held_org) = (Arc::clone(pools), org.clone());
let source: ConnSource<sqlx::Postgres> = Arc::new(move || {
let (pools, org) = (Arc::clone(&pools), held_org.clone());
Box::pin(async move { pools.acquire(&org).await })
});
Ok(Self {
org,
conn: TenantConnCell::Deferred(source),
pool,
})
}
}
pub struct TenantScope {
pub org: Org,
pool: crate::sql::Pool,
}
impl TenantScope {
#[must_use]
pub fn pool(&self) -> &crate::sql::Pool {
&self.pool
}
#[cfg(all(test, feature = "sqlite"))]
pub(crate) fn for_test(org: Org, pool: crate::sql::Pool) -> Self {
Self { org, pool }
}
}
impl<DB: Database> From<Tenant<DB>> for TenantScope {
fn from(t: Tenant<DB>) -> Self {
Self {
org: t.org,
pool: t.pool,
}
}
}
#[cfg(feature = "postgres")]
impl Tenant<sqlx::Postgres> {
pub async fn conn(&mut self) -> Result<&mut sqlx::PgConnection, TenancyError> {
self.ensure_conn().await?;
match &mut self.conn {
TenantConnCell::Ready(conn) => Ok(conn),
TenantConnCell::Deferred(_) => {
unreachable!("ensure_conn just populated the connection")
}
}
}
}
#[derive(Debug)]
pub enum TenantRejection {
MissingContext,
NotFound,
Internal(String),
}
impl IntoResponse for TenantRejection {
fn into_response(self) -> Response {
use crate::api_errors::ApiError;
match self {
Self::MissingContext => ApiError::logged(
StatusCode::INTERNAL_SERVER_ERROR,
"extractors::tenant",
"rustango::server::Builder did not run — Tenant extractor cannot find \
TenantContext or DatabaseTenantContext for its backend",
),
Self::NotFound => ApiError::not_found("tenant not found"),
Self::Internal(msg) => {
ApiError::logged(StatusCode::INTERNAL_SERVER_ERROR, "extractors::tenant", msg)
}
}
.into_response()
}
}
#[cfg(test)]
mod rejection_tests {
use super::*;
#[tokio::test]
async fn an_internal_rejection_withholds_its_cause() {
let _env = crate::error::test_env::lock();
let r =
TenantRejection::Internal("pool timed out on registry-db:5432".into()).into_response();
assert_eq!(r.status(), StatusCode::INTERNAL_SERVER_ERROR);
let b = axum::body::to_bytes(r.into_body(), 1 << 16).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["error"], "internal_error");
assert!(!v.to_string().contains("registry-db"), "{v}");
}
}
#[cfg(all(test, feature = "sqlite"))]
mod one_context_tests {
use super::*;
use crate::extractors::{DatabaseTenant, DatabaseTenantContext};
use crate::tenancy::{BackendKind, DatabasePools, OrgResolver};
struct Fixed(Org);
#[async_trait::async_trait]
impl OrgResolver for Fixed {
async fn resolve(
&self,
_: &Parts,
_: &crate::sql::Pool,
) -> Result<Option<Org>, TenancyError> {
Ok(Some(self.0.clone()))
}
}
fn org(slug: &str) -> Org {
Org {
slug: slug.into(),
storage_mode: "database".into(),
backend_kind: "sqlite".into(),
database_url: Some("sqlite::memory:".into()),
..crate::testkit::org()
}
}
fn secret() -> SessionSecret {
SessionSecret::from_bytes(vec![7u8; 32])
}
async fn sqlite_ctx(slug: &str) -> Arc<TenantContext<sqlx::Sqlite>> {
let reg = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
Arc::new(TenantContext {
pools: Arc::new(TenantPools::new(reg)),
resolver: ChainResolver::new().push(Fixed(org(slug))),
session_secret: secret(),
operator_secret: secret(),
})
}
fn parts() -> Parts {
axum::http::Request::new(()).into_parts().0
}
async fn auth_slug(p: &Parts) -> String {
crate::tenancy::middleware::request_org(p, &p.extensions)
.await
.expect("a context is mounted")
.unwrap()
.expect("an org")
.slug
}
#[tokio::test]
async fn database_tenant_serves_the_tenant_auth_resolved() {
let mut p = parts();
p.extensions.insert(sqlite_ctx("full").await);
p.extensions
.insert(Arc::new(DatabaseTenantContext::<sqlx::Sqlite> {
pools: Arc::new(DatabasePools::new(BackendKind::Sqlite)),
resolver: ChainResolver::new().push(Fixed(org("other"))),
session_secret: secret(),
operator_secret: secret(),
registry: crate::sql::Pool::connect("sqlite::memory:").await.unwrap(),
}));
assert_eq!(auth_slug(&p).await, "full");
let got = DatabaseTenant::<sqlx::Sqlite>::from_request_parts(&mut p, &())
.await
.map(|t| t.org.slug);
assert!(
matches!(
got,
Err(crate::extractors::DatabaseTenantRejection::MissingContext)
),
"auth resolved `full`, the handler got {got:?}"
);
}
#[cfg(feature = "postgres")]
#[tokio::test]
async fn sqlite_tenant_serves_the_tenant_auth_resolved() {
let reg = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://nobody@127.0.0.1:1/none")
.unwrap();
let mut p = parts();
p.extensions
.insert(Arc::new(TenantContext::<sqlx::Postgres> {
pools: Arc::new(TenantPools::new(reg)),
resolver: ChainResolver::new().push(Fixed(org("pg"))),
session_secret: secret(),
operator_secret: secret(),
}));
p.extensions.insert(sqlite_ctx("sqlite").await);
assert_eq!(auth_slug(&p).await, "pg");
let got = Tenant::<sqlx::Sqlite>::from_request_parts(&mut p, &())
.await
.map(|t| t.org.slug);
assert!(
matches!(got, Err(TenantRejection::MissingContext)),
"auth resolved `pg`, the handler got {got:?}"
);
}
}
async fn resolved_org(
mounted: super::MountedTenantContext<'_>,
parts: &Parts,
) -> Result<Org, TenantRejection> {
mounted
.resolve(parts)
.await
.map_err(|e| TenantRejection::Internal(e.to_string()))?
.ok_or(TenantRejection::NotFound)
}
impl<S> FromRequestParts<S> for TenantScope
where
S: Send + Sync,
{
type Rejection = TenantRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let mounted = super::MountedTenantContext::of(&parts.extensions)
.ok_or(TenantRejection::MissingContext)?;
let org = resolved_org(mounted, parts).await?;
let pool = mounted
.pool_for(&org)
.await
.map_err(|e| TenantRejection::Internal(e.to_string()))?;
Ok(Self { org, pool })
}
}
#[cfg(feature = "postgres")]
impl<S> FromRequestParts<S> for Tenant<sqlx::Postgres>
where
S: Send + Sync,
{
type Rejection = TenantRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let mounted = super::MountedTenantContext::of(&parts.extensions)
.ok_or(TenantRejection::MissingContext)?;
let ctx = mounted
.full::<sqlx::Postgres>()
.ok_or(TenantRejection::MissingContext)?
.clone();
let org = resolved_org(mounted, parts).await?;
Tenant::from_pools(org, &ctx.pools)
.await
.map_err(|e| TenantRejection::Internal(e.to_string()))
}
}
#[cfg(any(feature = "sqlite", feature = "mysql"))]
async fn deferred_tenant<DB>(parts: &mut Parts) -> Result<Tenant<DB>, TenantRejection>
where
DB: Database,
crate::sql::Pool: From<sqlx::Pool<DB>>,
{
let internal = |e: TenancyError| TenantRejection::Internal(e.to_string());
let mounted = super::MountedTenantContext::of(&parts.extensions)
.ok_or(TenantRejection::MissingContext)?;
let full = mounted.full::<DB>().cloned();
let db = mounted.database::<DB>().cloned();
if full.is_none() && db.is_none() {
return Err(TenantRejection::MissingContext);
}
let org = resolved_org(mounted, parts).await?;
let pool = if let Some(ctx) = full {
#[cfg_attr(not(feature = "postgres"), allow(irrefutable_let_patterns))]
let crate::tenancy::TenantPool::Database { pool } = ctx
.pools
.database_pool_for_org(&org)
.await
.map_err(internal)?
else {
unreachable!("database_pool_for_org rejects schema-mode")
};
(*pool).clone()
} else {
let ctx = db.ok_or(TenantRejection::MissingContext)?;
let pool = ctx.pools.pool_for_org(&org).await.map_err(internal)?;
pool.pool().clone()
};
Ok(Tenant::deferred(org, pool))
}
#[cfg(feature = "sqlite")]
impl<S> FromRequestParts<S> for Tenant<sqlx::Sqlite>
where
S: Send + Sync,
{
type Rejection = TenantRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
deferred_tenant(parts).await
}
}
#[cfg(feature = "mysql")]
impl<S> FromRequestParts<S> for Tenant<sqlx::MySql>
where
S: Send + Sync,
{
type Rejection = TenantRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
deferred_tenant(parts).await
}
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use super::*;
#[tokio::test]
async fn for_test_sqlite_conn_and_pool_agree() {
let sq = sqlx::SqlitePool::connect("sqlite:file:tft_agree?mode=memory&cache=shared")
.await
.unwrap();
let mut t = Tenant::for_test_sqlite(crate::testkit::org(), sq);
crate::sql::raw_execute_pool(t.pool(), "CREATE TABLE t (id INTEGER)", Vec::new())
.await
.unwrap();
sqlx::query("INSERT INTO t VALUES (1)")
.execute(&mut **t.pool_conn().await.unwrap())
.await
.unwrap();
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM t")
.fetch_one(t.pool().as_sqlite().unwrap())
.await
.unwrap();
assert_eq!(n, 1);
}
}