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}