Skip to content

Commit 28b6450

Browse files
committed
WIP feat: create multi-tenant database example
1 parent 017ffce commit 28b6450

File tree

12 files changed

+272
-7
lines changed

12 files changed

+272
-7
lines changed

Cargo.lock

Lines changed: 58 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ members = [
1111
"sqlx-postgres",
1212
"sqlx-sqlite",
1313
"examples/mysql/todos",
14+
"examples/postgres/axum-multi-tenant",
1415
"examples/postgres/axum-social-with-tests",
1516
"examples/postgres/chat",
1617
"examples/postgres/files",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "multi-tenant"
3+
version.workspace = true
4+
license.workspace = true
5+
edition.workspace = true
6+
repository.workspace = true
7+
keywords.workspace = true
8+
categories.workspace = true
9+
authors.workspace = true
10+
11+
[dependencies]
12+
accounts = { path = "accounts" }
13+
payments = { path = "payments" }
14+
15+
sqlx = { path = "../../..", version = "0.8.3", features = ["runtime-tokio", "postgres"] }
16+
17+
[lints]
18+
workspace = true
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Axum App with Multi-tenant Database
2+
3+
This example project involves three crates, each owning a different schema in one database,
4+
with their own set of migrations.
5+
6+
* The main crate, an Axum app.
7+
* Owns the `public` schema (tables are referenced unqualified).
8+
* `accounts`: a subcrate simulating a reusable account-management crate.
9+
* Owns schema `accounts`.
10+
* `payments`: a subcrate simulating a wrapper for a payments API.
11+
* Owns schema `payments`.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "accounts"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
sqlx = { workspace = true, features = ["postgres", "time"] }
8+
argon2 = { version = "0.5.3", features = ["password-hash"] }
9+
tokio = { version = "1", features = ["rt", "sync"] }
10+
11+
uuid = "1"
12+
thiserror = "1"
13+
rand = "0.8"

examples/postgres/axum-multi-tenant/accounts/migrations/01_setup.sql

Whitespace-only changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
create table account
2+
(
3+
account_id uuid primary key default gen_random_uuid(),
4+
email text unique not null,
5+
password_hash text not null,
6+
created_at timestamptz not null default now(),
7+
updated_at timestamptz
8+
);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[migrate]
2+
create-schemas = ["accounts"]
3+
migrations-table = "accounts._sqlx_migrations"
4+
5+
[macros.table-overrides.'accounts.account']
6+
'account_id' = "crate::AccountId"
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
use std::error::Error;
2+
use argon2::{password_hash, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
3+
4+
use password_hash::PasswordHashString;
5+
6+
use sqlx::{PgConnection, PgTransaction};
7+
use sqlx::types::Text;
8+
9+
use uuid::Uuid;
10+
11+
use tokio::sync::Semaphore;
12+
13+
#[derive(sqlx::Type)]
14+
#[sqlx(transparent)]
15+
pub struct AccountId(pub Uuid);
16+
17+
18+
pub struct AccountsManager {
19+
hashing_semaphore: Semaphore,
20+
}
21+
22+
#[derive(Debug, thiserror::Error)]
23+
pub enum CreateError {
24+
#[error("email in-use")]
25+
EmailInUse,
26+
General(#[source]
27+
#[from] GeneralError),
28+
}
29+
30+
#[derive(Debug, thiserror::Error)]
31+
pub enum AuthenticateError {
32+
#[error("unknown email")]
33+
UnknownEmail,
34+
#[error("invalid password")]
35+
InvalidPassword,
36+
General(#[source]
37+
#[from] GeneralError),
38+
}
39+
40+
#[derive(Debug, thiserror::Error)]
41+
pub enum GeneralError {
42+
Sqlx(#[source]
43+
#[from] sqlx::Error),
44+
PasswordHash(#[source] #[from] argon2::password_hash::Error),
45+
Task(#[source]
46+
#[from] tokio::task::JoinError),
47+
}
48+
49+
impl AccountsManager {
50+
pub async fn new(conn: &mut PgConnection, max_hashing_threads: usize) -> Result<Self, GeneralError> {
51+
sqlx::migrate!().run(conn).await?;
52+
53+
AccountsManager {
54+
hashing_semaphore: Semaphore::new(max_hashing_threads)
55+
}
56+
}
57+
58+
async fn hash_password(&self, password: String) -> Result<PasswordHash, GeneralError> {
59+
let guard = self.hashing_semaphore.acquire().await
60+
.expect("BUG: this semaphore should not be closed");
61+
62+
// We transfer ownership to the blocking task and back to ensure Tokio doesn't spawn
63+
// excess threads.
64+
let (_guard, res) = tokio::task::spawn_blocking(move || {
65+
let salt = argon2::password_hash::SaltString::generate(rand::thread_rng());
66+
(guard, Argon2::default().hash_password(password.as_bytes(), &salt))
67+
})
68+
.await?;
69+
70+
Ok(res?)
71+
}
72+
73+
async fn verify_password(&self, password: String, hash: PasswordHashString) -> Result<(), AuthenticateError> {
74+
let guard = self.hashing_semaphore.acquire().await
75+
.expect("BUG: this semaphore should not be closed");
76+
77+
let (_guard, res) = tokio::task::spawn_blocking(move || {
78+
(guard, Argon2::default().verify_password(password.as_bytes(), &hash.password_hash()))
79+
}).await.map_err(GeneralError::from)?;
80+
81+
if let Err(password_hash::Error::Password) = res {
82+
return Err(AuthenticateError::InvalidPassword);
83+
}
84+
85+
res.map_err(GeneralError::from)?;
86+
87+
Ok(())
88+
}
89+
90+
pub async fn create(&self, txn: &mut PgTransaction, email: &str, password: String) -> Result<AccountId, CreateError> {
91+
// Hash password whether the account exists or not to make it harder
92+
// to tell the difference in the timing.
93+
let hash = self.hash_password(password).await?;
94+
95+
// language=PostgreSQL
96+
sqlx::query!(
97+
"insert into accounts.account(email, password_hash) \
98+
values ($1, $2) \
99+
returning account_id",
100+
email,
101+
Text(hash) as Text<PasswordHash<'static>>,
102+
)
103+
.fetch_one(&mut *txn)
104+
.await
105+
.map_err(|e| if e.constraint() == Some("account_account_id_key") {
106+
CreateError::EmailInUse
107+
} else {
108+
GeneralError::from(e).into()
109+
})
110+
}
111+
112+
pub async fn authenticate(&self, conn: &mut PgConnection, email: &str, password: String) -> Result<AccountId, AuthenticateError> {
113+
let maybe_account = sqlx::query!(
114+
"select account_id, password_hash as \"password_hash: Text<PasswordHashString>\" \
115+
from accounts.account \
116+
where email_id = $1",
117+
email
118+
)
119+
.fetch_optional(&mut *conn)
120+
.await
121+
.map_err(GeneralError::from)?;
122+
123+
let Some(account) = maybe_account else {
124+
// Hash the password whether the account exists or not to hide the difference in timing.
125+
self.hash_password(password).await.map_err(GeneralError::from)?;
126+
return Err(AuthenticateError::UnknownEmail);
127+
};
128+
129+
self.verify_password(password, account.password_hash.into())?;
130+
131+
Ok(account.account_id)
132+
}
133+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[package]
2+
name = "payments"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
sqlx = { workspace = true, features = ["postgres", "time"] }

0 commit comments

Comments
 (0)