server/provision.rs
1//! Per-user database provisioning.
2//!
3//! Each user owns a dedicated Postgres database, reached via `userpool` from
4//! the *name* in `users.database_name`. This module creates that database,
5//! brings it up to the (DDL-only) schema with the shared migration set, and
6//! returns its name for the caller to persist. The DSN is composed at
7//! connection time by [`dsn_for`]; a DSN in that column would put a live
8//! credential in a row (ADR 0032).
9//!
10//! Provisioning reuses the admin `DATABASE_URL` connection — Postgres allows a
11//! connection to create a *different* database — so no separate maintenance DSN
12//! is required. The only prerequisite is that the admin role holds `CREATEDB`.
13
14use crate::db::{DBError, admin_database_url, execute_raw, migrate_url};
15use rust_i18n::t;
16use sqlx::types::Uuid;
17
18/// Postgres SQLSTATE for `duplicate_database`.
19const DUPLICATE_DATABASE: &str = "42P04";
20
21/// Creates and migrates a dedicated database for `user_id`, returning its NAME.
22///
23/// The name — never a DSN — is what the caller stores in
24/// `users.database_name`. A DSN in that column would put a live credential in
25/// a row that any `SELECT *` prints; see ADR 0032.
26///
27/// Idempotent on the `CREATE DATABASE` step: a pre-existing database (SQLSTATE
28/// `42P04`) is treated as success so a retried provision after a mid-way
29/// failure reuses the same deterministically-named database rather than
30/// erroring.
31///
32/// # Errors
33///
34/// - [`DBError::MissingUrl`] if `DATABASE_URL` is unset.
35/// - [`DBError::NoCreateDb`] if the role lacks `CREATEDB`.
36/// - [`DBError::Sqlx`] / [`DBError::Migration`] on other failures.
37pub async fn create_user_database(user_id: Uuid) -> Result<String, DBError> {
38 provision_user_database(user_id)
39 .await
40 .map(|provisioned| provisioned.database_name)
41}
42
43/// A provisioned tenant database, and whether this call is what created it.
44pub struct Provisioned {
45 pub database_name: String,
46 /// `false` when an existing database was reused (SQLSTATE `42P04`). A
47 /// caller compensating for a later failure must not drop in that case: the
48 /// data is not ours to destroy.
49 pub created: bool,
50}
51
52/// [`create_user_database`], reporting whether the database is newly created.
53///
54/// # Errors
55/// As [`create_user_database`].
56pub async fn provision_user_database(user_id: Uuid) -> Result<Provisioned, DBError> {
57 let database_name = database_name(user_id);
58 let dsn = dsn_for(&database_name)?;
59
60 let created = create_database(&database_name).await?;
61
62 // If migration fails after *this call* created the database, drop it so a
63 // transient failure doesn't leak an orphaned (un-migrated) per-user
64 // database. We must NOT drop when we reused a pre-existing database
65 // (42P04): a retry whose migration fails would otherwise destroy data we
66 // didn't create.
67 if let Err(err) = migrate_url(&dsn).await {
68 if created && let Err(drop_err) = drop_user_database(user_id).await {
69 log::error!(
70 "{}",
71 t!("Failed to drop partially-provisioned database after migration error: %{e}",
72 e = drop_err : {})
73 );
74 }
75 return Err(err);
76 }
77
78 Ok(Provisioned {
79 database_name,
80 created,
81 })
82}
83
84/// Stores a user's RSA private signing key into their (already-provisioned)
85/// per-user database, named by `database_name`. The private key never leaves that database.
86///
87/// # Errors
88/// [`DBError::Sqlx`] on connect or insert failure.
89pub async fn store_user_private_key(
90 database_name: &str,
91 private_pem_b64: &str,
92) -> Result<(), DBError> {
93 // Takes the database name, not a DSN: composing it here keeps connection
94 // strings — and the credentials in them — inside this module.
95 let pool = sqlx::postgres::PgPoolOptions::new()
96 .max_connections(1)
97 .acquire_timeout(std::time::Duration::from_secs(10))
98 .connect(&dsn_for(database_name)?)
99 .await?;
100 // Upsert on the singleton constraint: the per-user DB holds exactly one
101 // signing key for its owner, enforced at the DB level (see 0005), so
102 // concurrent calls collide on `singleton` rather than creating duplicates.
103 let result = sqlx::query(
104 "INSERT INTO user_auth_keys (singleton, private_key) VALUES (TRUE, $1) \
105 ON CONFLICT (singleton) DO UPDATE SET private_key = excluded.private_key",
106 )
107 .bind(private_pem_b64)
108 .execute(&pool)
109 .await;
110 pool.close().await;
111 result.map(|_| ()).map_err(DBError::Sqlx)
112}
113
114/// Drops a user's database, used as compensating cleanup when a step *after*
115/// provisioning (e.g. the `users` row insert) fails and would otherwise orphan
116/// the just-created database. Best-effort and idempotent (`IF EXISTS`).
117///
118/// # Errors
119/// [`DBError::Sqlx`] if the `DROP DATABASE` itself errors (e.g. the database is
120/// still in use); callers typically log and continue rather than failing the
121/// original error path.
122pub async fn drop_user_database(user_id: Uuid) -> Result<(), DBError> {
123 let database_name = database_name(user_id);
124 // `database_name` is `nomi_user_<hex>` (only `[a-z0-9_]`), so the quoted
125 // identifier is injection-safe.
126 execute_raw(&format!("DROP DATABASE IF EXISTS \"{database_name}\"")).await
127}
128
129/// Deterministic, injection-safe database name for a user. Derived purely from
130/// the UUID's hex (no hyphens), so a retry targets the same database.
131fn database_name(user_id: Uuid) -> String {
132 format!("nomi_user_{}", user_id.simple())
133}
134
135/// Postgres SQLSTATE for `insufficient_privilege`.
136const INSUFFICIENT_PRIVILEGE: &str = "42501";
137
138/// Issues `CREATE DATABASE` on the admin connection. Returns `true` if this call
139/// created the database, `false` if it already existed (SQLSTATE `42P04`, the
140/// retry-safe reuse path — the caller still migrates, which is idempotent). An
141/// insufficient-privilege error maps to [`DBError::NoCreateDb`]. The boolean
142/// lets the caller compensate-drop ONLY databases it actually created.
143async fn create_database(database_name: &str) -> Result<bool, DBError> {
144 // `database_name` is `nomi_user_<hex>` — only `[a-z0-9_]`, so the quoted
145 // identifier cannot be abused. CREATE DATABASE forbids bind parameters.
146 let stmt = format!("CREATE DATABASE \"{database_name}\"");
147 let Err(DBError::Sqlx(err)) = execute_raw(&stmt).await else {
148 return Ok(true);
149 };
150 match err.as_database_error().and_then(|db| db.code()).as_deref() {
151 Some(DUPLICATE_DATABASE) => {
152 log::debug!("{}", t!("Per-user database already exists, reusing"));
153 Ok(false)
154 }
155 Some(INSUFFICIENT_PRIVILEGE) => Err(DBError::NoCreateDb),
156 _ => Err(DBError::Sqlx(err)),
157 }
158}
159
160/// Replaces the database (path) component of a Postgres DSN, preserving scheme,
161/// credentials, host, port, and query parameters.
162///
163/// Parses `scheme://authority[/path][?query]`. The authority ends at the first
164/// `/` or `?` (whichever comes first), so a DSN with no path but a query
165/// (`postgres://user@host?sslmode=require`) is handled correctly — the query is
166/// preserved and the new db name is inserted as the path.
167/// Composes the connection string for a per-user database.
168///
169/// The single place a tenant DSN is built. Callers hold names; only this
170/// function turns one into something connectable, from the process's own
171/// `DATABASE_URL`.
172///
173/// # Errors
174/// [`DBError::MissingUrl`] if `DATABASE_URL` is unset or malformed.
175pub(crate) fn dsn_for(database_name: &str) -> Result<String, DBError> {
176 check_database_name(database_name)?;
177 swap_database(&admin_database_url()?, database_name)
178}
179
180/// The names `dsn_for` will accept.
181///
182/// # Errors
183/// [`DBError::UnsafeDatabaseName`] for anything outside `[a-z0-9_]{1,63}`.
184pub(crate) fn check_database_name(database_name: &str) -> Result<(), DBError> {
185 if is_plain_database_name(database_name) {
186 Ok(())
187 } else {
188 Err(DBError::UnsafeDatabaseName(database_name.to_owned()))
189 }
190}
191
192/// Whether a name is safe to interpolate into a DSN.
193///
194/// The value comes from `users.database_name`, and only `database_name()` (hex
195/// from a uuid) and `current_database()` ever write it — but this composes a
196/// connection string, and sqlx honours parameters like `?host=` inside one. A
197/// row reading `x?host=elsewhere` would redirect the admin credential, so the
198/// name is checked where it is *used* rather than trusted because of where it
199/// is supposed to come from.
200fn is_plain_database_name(name: &str) -> bool {
201 !name.is_empty()
202 && name.len() <= 63
203 && name
204 .chars()
205 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
206}
207
208fn swap_database(base: &str, database_name: &str) -> Result<String, DBError> {
209 let (scheme, rest) = base.split_once("://").ok_or(DBError::MissingUrl)?;
210
211 // Split off the query first so a query-only DSN (no path) doesn't fold the
212 // query into the authority.
213 let (before_query, query) = match rest.split_once('?') {
214 Some((head, q)) => (head, format!("?{q}")),
215 None => (rest, String::new()),
216 };
217
218 // Whatever path the original DSN had is dropped; the authority is the part
219 // before the first '/'.
220 let authority = before_query.split('/').next().unwrap_or(before_query);
221
222 Ok(format!("{scheme}://{authority}/{database_name}{query}"))
223}
224
225#[cfg(test)]
226mod tests {
227 use super::{is_plain_database_name, swap_database};
228
229 #[test]
230 fn a_provisioned_name_is_plain() {
231 assert!(is_plain_database_name(
232 "nomi_user_0e78f0a8b2c74df1bc4eb463faa44e91"
233 ));
234 assert!(is_plain_database_name("nomisync"));
235 }
236
237 #[test]
238 fn a_name_that_could_redirect_the_connection_is_refused() {
239 // sqlx honours parameters inside a connection string, so a directory row
240 // carrying one would send the admin credential somewhere else.
241 assert!(!is_plain_database_name("x?host=elsewhere"));
242 assert!(!is_plain_database_name("x&user=postgres"));
243 assert!(!is_plain_database_name("a/b"));
244 assert!(!is_plain_database_name("Mixed_Case"));
245 assert!(!is_plain_database_name(""));
246 assert!(!is_plain_database_name(&"a".repeat(64)));
247 }
248
249 #[test]
250 fn swap_database_replaces_path_component() {
251 let out = swap_database("postgres://ray@localhost/maindb", "nomi_user_abc").unwrap();
252 assert_eq!(out, "postgres://ray@localhost/nomi_user_abc");
253 }
254
255 #[test]
256 fn swap_database_preserves_credentials_port_and_query() {
257 let out = swap_database(
258 "postgres://u:p@host:5432/maindb?sslmode=require",
259 "nomi_user_abc",
260 )
261 .unwrap();
262 assert_eq!(
263 out,
264 "postgres://u:p@host:5432/nomi_user_abc?sslmode=require"
265 );
266 }
267
268 #[test]
269 fn swap_database_handles_dsn_without_path_but_with_query() {
270 let out = swap_database("postgres://user@host?sslmode=require", "nomi_user_abc").unwrap();
271 assert_eq!(out, "postgres://user@host/nomi_user_abc?sslmode=require");
272 }
273
274 #[test]
275 fn swap_database_handles_dsn_without_path_or_query() {
276 let out = swap_database("postgres://user@host:5432", "nomi_user_abc").unwrap();
277 assert_eq!(out, "postgres://user@host:5432/nomi_user_abc");
278 }
279}