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 `users.db_name` DSN. This module creates that database, brings it up to
5//! the (DDL-only) schema with the shared migration set, and returns its DSN so
6//! the caller can persist it in `users.db_name`.
7//!
8//! Provisioning reuses the admin `DATABASE_URL` connection — Postgres allows a
9//! connection to create a *different* database — so no separate maintenance DSN
10//! is required. The only prerequisite is that the admin role holds `CREATEDB`.
11
12use crate::db::{DBError, admin_database_url, execute_raw, migrate_url};
13use rust_i18n::t;
14use sqlx::types::Uuid;
15
16/// Postgres SQLSTATE for `duplicate_database`.
17const DUPLICATE_DATABASE: &str = "42P04";
18
19/// Creates and migrates a dedicated database for `user_id`, returning its DSN.
20///
21/// Idempotent on the `CREATE DATABASE` step: a pre-existing database (SQLSTATE
22/// `42P04`) is treated as success so a retried provision after a mid-way
23/// failure reuses the same deterministically-named database rather than
24/// erroring. The returned DSN is what the caller stores in `users.db_name`.
25///
26/// # Errors
27///
28/// - [`DBError::MissingUrl`] if `DATABASE_URL` is unset.
29/// - [`DBError::NoCreateDb`] if the role lacks `CREATEDB`.
30/// - [`DBError::Sqlx`] / [`DBError::Migration`] on other failures.
31pub async fn create_user_database(user_id: Uuid) -> Result<String, DBError> {
32    let base = admin_database_url()?;
33    let db_name = database_name(user_id);
34    let dsn = swap_database(&base, &db_name)?;
35
36    let created = create_database(&db_name).await?;
37
38    // If migration fails after *this call* created the database, drop it so a
39    // transient failure doesn't leak an orphaned (un-migrated) per-user
40    // database. We must NOT drop when we reused a pre-existing database
41    // (42P04): a retry whose migration fails would otherwise destroy data we
42    // didn't create.
43    if let Err(err) = migrate_url(&dsn).await {
44        if created && let Err(drop_err) = drop_user_database(user_id).await {
45            log::error!(
46                "{}",
47                t!("Failed to drop partially-provisioned database after migration error: %{e}",
48                       e = drop_err : {})
49            );
50        }
51        return Err(err);
52    }
53
54    Ok(dsn)
55}
56
57/// Stores a user's RSA private signing key into their (already-provisioned)
58/// per-user database at `dsn`. The private key never leaves that database.
59///
60/// # Errors
61/// [`DBError::Sqlx`] on connect or insert failure.
62pub async fn store_user_private_key(dsn: &str, private_pem_b64: &str) -> Result<(), DBError> {
63    let pool = sqlx::postgres::PgPoolOptions::new()
64        .max_connections(1)
65        .acquire_timeout(std::time::Duration::from_secs(10))
66        .connect(dsn)
67        .await?;
68    // Upsert on the singleton constraint: the per-user DB holds exactly one
69    // signing key for its owner, enforced at the DB level (see 0005), so
70    // concurrent calls collide on `singleton` rather than creating duplicates.
71    let result = sqlx::query(
72        "INSERT INTO user_auth_keys (singleton, private_key) VALUES (TRUE, $1) \
73         ON CONFLICT (singleton) DO UPDATE SET private_key = excluded.private_key",
74    )
75    .bind(private_pem_b64)
76    .execute(&pool)
77    .await;
78    pool.close().await;
79    result.map(|_| ()).map_err(DBError::Sqlx)
80}
81
82/// Drops a user's database, used as compensating cleanup when a step *after*
83/// provisioning (e.g. the `users` row insert) fails and would otherwise orphan
84/// the just-created database. Best-effort and idempotent (`IF EXISTS`).
85///
86/// # Errors
87/// [`DBError::Sqlx`] if the `DROP DATABASE` itself errors (e.g. the database is
88/// still in use); callers typically log and continue rather than failing the
89/// original error path.
90pub async fn drop_user_database(user_id: Uuid) -> Result<(), DBError> {
91    let db_name = database_name(user_id);
92    // `db_name` is `nomi_user_<hex>` (only `[a-z0-9_]`), so the quoted
93    // identifier is injection-safe.
94    execute_raw(&format!("DROP DATABASE IF EXISTS \"{db_name}\"")).await
95}
96
97/// Deterministic, injection-safe database name for a user. Derived purely from
98/// the UUID's hex (no hyphens), so a retry targets the same database.
99fn database_name(user_id: Uuid) -> String {
100    format!("nomi_user_{}", user_id.simple())
101}
102
103/// Postgres SQLSTATE for `insufficient_privilege`.
104const INSUFFICIENT_PRIVILEGE: &str = "42501";
105
106/// Issues `CREATE DATABASE` on the admin connection. Returns `true` if this call
107/// created the database, `false` if it already existed (SQLSTATE `42P04`, the
108/// retry-safe reuse path — the caller still migrates, which is idempotent). An
109/// insufficient-privilege error maps to [`DBError::NoCreateDb`]. The boolean
110/// lets the caller compensate-drop ONLY databases it actually created.
111async fn create_database(db_name: &str) -> Result<bool, DBError> {
112    // `db_name` is `nomi_user_<hex>` — only `[a-z0-9_]`, so the quoted
113    // identifier cannot be abused. CREATE DATABASE forbids bind parameters.
114    let stmt = format!("CREATE DATABASE \"{db_name}\"");
115    let Err(DBError::Sqlx(err)) = execute_raw(&stmt).await else {
116        return Ok(true);
117    };
118    match err.as_database_error().and_then(|db| db.code()).as_deref() {
119        Some(DUPLICATE_DATABASE) => {
120            log::debug!("{}", t!("Per-user database already exists, reusing"));
121            Ok(false)
122        }
123        Some(INSUFFICIENT_PRIVILEGE) => Err(DBError::NoCreateDb),
124        _ => Err(DBError::Sqlx(err)),
125    }
126}
127
128/// Replaces the database (path) component of a Postgres DSN, preserving scheme,
129/// credentials, host, port, and query parameters.
130///
131/// Parses `scheme://authority[/path][?query]`. The authority ends at the first
132/// `/` or `?` (whichever comes first), so a DSN with no path but a query
133/// (`postgres://user@host?sslmode=require`) is handled correctly — the query is
134/// preserved and the new db name is inserted as the path.
135fn swap_database(base: &str, db_name: &str) -> Result<String, DBError> {
136    let (scheme, rest) = base.split_once("://").ok_or(DBError::MissingUrl)?;
137
138    // Split off the query first so a query-only DSN (no path) doesn't fold the
139    // query into the authority.
140    let (before_query, query) = match rest.split_once('?') {
141        Some((head, q)) => (head, format!("?{q}")),
142        None => (rest, String::new()),
143    };
144
145    // Whatever path the original DSN had is dropped; the authority is the part
146    // before the first '/'.
147    let authority = before_query.split('/').next().unwrap_or(before_query);
148
149    Ok(format!("{scheme}://{authority}/{db_name}{query}"))
150}
151
152#[cfg(test)]
153mod tests {
154    use super::swap_database;
155
156    #[test]
157    fn swap_database_replaces_path_component() {
158        let out = swap_database("postgres://ray@localhost/maindb", "nomi_user_abc").unwrap();
159        assert_eq!(out, "postgres://ray@localhost/nomi_user_abc");
160    }
161
162    #[test]
163    fn swap_database_preserves_credentials_port_and_query() {
164        let out = swap_database(
165            "postgres://u:p@host:5432/maindb?sslmode=require",
166            "nomi_user_abc",
167        )
168        .unwrap();
169        assert_eq!(
170            out,
171            "postgres://u:p@host:5432/nomi_user_abc?sslmode=require"
172        );
173    }
174
175    #[test]
176    fn swap_database_handles_dsn_without_path_but_with_query() {
177        let out = swap_database("postgres://user@host?sslmode=require", "nomi_user_abc").unwrap();
178        assert_eq!(out, "postgres://user@host/nomi_user_abc?sslmode=require");
179    }
180
181    #[test]
182    fn swap_database_handles_dsn_without_path_or_query() {
183        let out = swap_database("postgres://user@host:5432", "nomi_user_abc").unwrap();
184        assert_eq!(out, "postgres://user@host:5432/nomi_user_abc");
185    }
186}