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

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

            
16
/// Postgres SQLSTATE for `duplicate_database`.
17
const 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.
31
243
pub async fn create_user_database(user_id: Uuid) -> Result<String, DBError> {
32
243
    let base = admin_database_url()?;
33
243
    let db_name = database_name(user_id);
34
243
    let dsn = swap_database(&base, &db_name)?;
35

            
36
243
    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
243
    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
243
    }
53

            
54
243
    Ok(dsn)
55
243
}
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.
62
108
pub async fn store_user_private_key(dsn: &str, private_pem_b64: &str) -> Result<(), DBError> {
63
108
    let pool = sqlx::postgres::PgPoolOptions::new()
64
108
        .max_connections(1)
65
108
        .acquire_timeout(std::time::Duration::from_secs(10))
66
108
        .connect(dsn)
67
108
        .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
108
    let result = sqlx::query(
72
108
        "INSERT INTO user_auth_keys (singleton, private_key) VALUES (TRUE, $1) \
73
108
         ON CONFLICT (singleton) DO UPDATE SET private_key = excluded.private_key",
74
108
    )
75
108
    .bind(private_pem_b64)
76
108
    .execute(&pool)
77
108
    .await;
78
108
    pool.close().await;
79
108
    result.map(|_| ()).map_err(DBError::Sqlx)
80
108
}
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.
90
pub 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.
99
243
fn database_name(user_id: Uuid) -> String {
100
243
    format!("nomi_user_{}", user_id.simple())
101
243
}
102

            
103
/// Postgres SQLSTATE for `insufficient_privilege`.
104
const 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.
111
243
async 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
243
    let stmt = format!("CREATE DATABASE \"{db_name}\"");
115
243
    let Err(DBError::Sqlx(err)) = execute_raw(&stmt).await else {
116
216
        return Ok(true);
117
    };
118
27
    match err.as_database_error().and_then(|db| db.code()).as_deref() {
119
27
        Some(DUPLICATE_DATABASE) => {
120
27
            log::debug!("{}", t!("Per-user database already exists, reusing"));
121
27
            Ok(false)
122
        }
123
        Some(INSUFFICIENT_PRIVILEGE) => Err(DBError::NoCreateDb),
124
        _ => Err(DBError::Sqlx(err)),
125
    }
126
243
}
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.
135
247
fn swap_database(base: &str, db_name: &str) -> Result<String, DBError> {
136
247
    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
247
    let (before_query, query) = match rest.split_once('?') {
141
2
        Some((head, q)) => (head, format!("?{q}")),
142
245
        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
247
    let authority = before_query.split('/').next().unwrap_or(before_query);
148

            
149
247
    Ok(format!("{scheme}://{authority}/{db_name}{query}"))
150
247
}
151

            
152
#[cfg(test)]
153
mod tests {
154
    use super::swap_database;
155

            
156
    #[test]
157
1
    fn swap_database_replaces_path_component() {
158
1
        let out = swap_database("postgres://ray@localhost/maindb", "nomi_user_abc").unwrap();
159
1
        assert_eq!(out, "postgres://ray@localhost/nomi_user_abc");
160
1
    }
161

            
162
    #[test]
163
1
    fn swap_database_preserves_credentials_port_and_query() {
164
1
        let out = swap_database(
165
1
            "postgres://u:p@host:5432/maindb?sslmode=require",
166
1
            "nomi_user_abc",
167
        )
168
1
        .unwrap();
169
1
        assert_eq!(
170
            out,
171
            "postgres://u:p@host:5432/nomi_user_abc?sslmode=require"
172
        );
173
1
    }
174

            
175
    #[test]
176
1
    fn swap_database_handles_dsn_without_path_but_with_query() {
177
1
        let out = swap_database("postgres://user@host?sslmode=require", "nomi_user_abc").unwrap();
178
1
        assert_eq!(out, "postgres://user@host/nomi_user_abc?sslmode=require");
179
1
    }
180

            
181
    #[test]
182
1
    fn swap_database_handles_dsn_without_path_or_query() {
183
1
        let out = swap_database("postgres://user@host:5432", "nomi_user_abc").unwrap();
184
1
        assert_eq!(out, "postgres://user@host:5432/nomi_user_abc");
185
1
    }
186
}