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

            
14
use crate::db::{DBError, admin_database_url, execute_raw, migrate_url};
15
use rust_i18n::t;
16
use sqlx::types::Uuid;
17

            
18
/// Postgres SQLSTATE for `duplicate_database`.
19
const 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.
37
351
pub async fn create_user_database(user_id: Uuid) -> Result<String, DBError> {
38
351
    provision_user_database(user_id)
39
351
        .await
40
351
        .map(|provisioned| provisioned.database_name)
41
351
}
42

            
43
/// A provisioned tenant database, and whether this call is what created it.
44
pub 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`].
56
390
pub async fn provision_user_database(user_id: Uuid) -> Result<Provisioned, DBError> {
57
390
    let database_name = database_name(user_id);
58
390
    let dsn = dsn_for(&database_name)?;
59

            
60
390
    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
390
    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
390
    }
77

            
78
390
    Ok(Provisioned {
79
390
        database_name,
80
390
        created,
81
390
    })
82
390
}
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.
89
195
pub async fn store_user_private_key(
90
195
    database_name: &str,
91
195
    private_pem_b64: &str,
92
195
) -> 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
195
    let pool = sqlx::postgres::PgPoolOptions::new()
96
195
        .max_connections(1)
97
195
        .acquire_timeout(std::time::Duration::from_secs(10))
98
195
        .connect(&dsn_for(database_name)?)
99
195
        .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
195
    let result = sqlx::query(
104
195
        "INSERT INTO user_auth_keys (singleton, private_key) VALUES (TRUE, $1) \
105
195
         ON CONFLICT (singleton) DO UPDATE SET private_key = excluded.private_key",
106
195
    )
107
195
    .bind(private_pem_b64)
108
195
    .execute(&pool)
109
195
    .await;
110
195
    pool.close().await;
111
195
    result.map(|_| ()).map_err(DBError::Sqlx)
112
195
}
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.
122
39
pub async fn drop_user_database(user_id: Uuid) -> Result<(), DBError> {
123
39
    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
39
    execute_raw(&format!("DROP DATABASE IF EXISTS \"{database_name}\"")).await
127
39
}
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.
131
429
fn database_name(user_id: Uuid) -> String {
132
429
    format!("nomi_user_{}", user_id.simple())
133
429
}
134

            
135
/// Postgres SQLSTATE for `insufficient_privilege`.
136
const 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.
143
390
async 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
390
    let stmt = format!("CREATE DATABASE \"{database_name}\"");
147
390
    let Err(DBError::Sqlx(err)) = execute_raw(&stmt).await else {
148
351
        return Ok(true);
149
    };
150
39
    match err.as_database_error().and_then(|db| db.code()).as_deref() {
151
39
        Some(DUPLICATE_DATABASE) => {
152
39
            log::debug!("{}", t!("Per-user database already exists, reusing"));
153
39
            Ok(false)
154
        }
155
        Some(INSUFFICIENT_PRIVILEGE) => Err(DBError::NoCreateDb),
156
        _ => Err(DBError::Sqlx(err)),
157
    }
158
390
}
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.
175
1131
pub(crate) fn dsn_for(database_name: &str) -> Result<String, DBError> {
176
1131
    check_database_name(database_name)?;
177
1131
    swap_database(&admin_database_url()?, database_name)
178
1131
}
179

            
180
/// The names `dsn_for` will accept.
181
///
182
/// # Errors
183
/// [`DBError::UnsafeDatabaseName`] for anything outside `[a-z0-9_]{1,63}`.
184
1287
pub(crate) fn check_database_name(database_name: &str) -> Result<(), DBError> {
185
1287
    if is_plain_database_name(database_name) {
186
1287
        Ok(())
187
    } else {
188
        Err(DBError::UnsafeDatabaseName(database_name.to_owned()))
189
    }
190
1287
}
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.
200
1295
fn is_plain_database_name(name: &str) -> bool {
201
1295
    !name.is_empty()
202
1294
        && name.len() <= 63
203
1293
        && name
204
1293
            .chars()
205
49236
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
206
1295
}
207

            
208
1135
fn swap_database(base: &str, database_name: &str) -> Result<String, DBError> {
209
1135
    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
1135
    let (before_query, query) = match rest.split_once('?') {
214
2
        Some((head, q)) => (head, format!("?{q}")),
215
1133
        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
1135
    let authority = before_query.split('/').next().unwrap_or(before_query);
221

            
222
1135
    Ok(format!("{scheme}://{authority}/{database_name}{query}"))
223
1135
}
224

            
225
#[cfg(test)]
226
mod tests {
227
    use super::{is_plain_database_name, swap_database};
228

            
229
    #[test]
230
1
    fn a_provisioned_name_is_plain() {
231
1
        assert!(is_plain_database_name(
232
1
            "nomi_user_0e78f0a8b2c74df1bc4eb463faa44e91"
233
        ));
234
1
        assert!(is_plain_database_name("nomisync"));
235
1
    }
236

            
237
    #[test]
238
1
    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
1
        assert!(!is_plain_database_name("x?host=elsewhere"));
242
1
        assert!(!is_plain_database_name("x&user=postgres"));
243
1
        assert!(!is_plain_database_name("a/b"));
244
1
        assert!(!is_plain_database_name("Mixed_Case"));
245
1
        assert!(!is_plain_database_name(""));
246
1
        assert!(!is_plain_database_name(&"a".repeat(64)));
247
1
    }
248

            
249
    #[test]
250
1
    fn swap_database_replaces_path_component() {
251
1
        let out = swap_database("postgres://ray@localhost/maindb", "nomi_user_abc").unwrap();
252
1
        assert_eq!(out, "postgres://ray@localhost/nomi_user_abc");
253
1
    }
254

            
255
    #[test]
256
1
    fn swap_database_preserves_credentials_port_and_query() {
257
1
        let out = swap_database(
258
1
            "postgres://u:p@host:5432/maindb?sslmode=require",
259
1
            "nomi_user_abc",
260
        )
261
1
        .unwrap();
262
1
        assert_eq!(
263
            out,
264
            "postgres://u:p@host:5432/nomi_user_abc?sslmode=require"
265
        );
266
1
    }
267

            
268
    #[test]
269
1
    fn swap_database_handles_dsn_without_path_but_with_query() {
270
1
        let out = swap_database("postgres://user@host?sslmode=require", "nomi_user_abc").unwrap();
271
1
        assert_eq!(out, "postgres://user@host/nomi_user_abc?sslmode=require");
272
1
    }
273

            
274
    #[test]
275
1
    fn swap_database_handles_dsn_without_path_or_query() {
276
1
        let out = swap_database("postgres://user@host:5432", "nomi_user_abc").unwrap();
277
1
        assert_eq!(out, "postgres://user@host:5432/nomi_user_abc");
278
1
    }
279
}