Skip to main content

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}