Skip to main content

mongreldb_core/
auth.rs

1//! User, role, and credential management for MongrelDB's catalog-level auth.
2//!
3//! Users and roles are stored in the engine's `Catalog` struct (alongside
4//! procedures and triggers), persisted via `catalog::write_atomic`. This
5//! module provides the types and the Argon2id password hashing layer.
6//!
7//! The daemon (`mongreldb-server`) authenticates HTTP requests via HTTP Basic
8//! auth (username:password) when `--auth-users` is enabled. Each authenticated
9//! request carries a [`Principal`] in its extensions; permission checks use
10//! [`Database::check_permission`].
11
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use subtle::ConstantTimeEq;
15
16/// Stored verifier for MySQL 8's `caching_sha2_password` fast-auth exchange.
17///
18/// This is SHA256(SHA256(password)), never the plaintext password.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct MysqlCachingSha2Verifier {
21    stage2: [u8; 32],
22}
23
24impl MysqlCachingSha2Verifier {
25    pub fn from_password(password: &str) -> Self {
26        let stage1 = Sha256::digest(password.as_bytes());
27        Self {
28            stage2: Sha256::digest(stage1).into(),
29        }
30    }
31
32    pub fn verify(&self, nonce: &[u8], proof: &[u8]) -> bool {
33        if proof.is_empty() {
34            return bool::from(self.stage2.ct_eq(&Self::from_password("").stage2));
35        }
36        let Ok(proof) = <&[u8; 32]>::try_from(proof) else {
37            return false;
38        };
39        let mask = Sha256::new()
40            .chain_update(self.stage2)
41            .chain_update(nonce)
42            .finalize();
43        let mut stage1 = [0_u8; 32];
44        for (output, (proof, mask)) in stage1.iter_mut().zip(proof.iter().zip(mask)) {
45            *output = proof ^ mask;
46        }
47        let candidate: [u8; 32] = Sha256::digest(stage1).into();
48        bool::from(candidate.ct_eq(&self.stage2))
49    }
50}
51
52/// A stored user with Argon2id-hashed credentials.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct UserEntry {
55    /// Stable, monotonic user id.
56    pub id: u64,
57    /// Unique username (case-sensitive).
58    pub username: String,
59    /// Argon2id PHC string (includes salt + params; verifiable via
60    /// `PasswordVerifier::verify`).
61    #[serde(skip_serializing_if = "String::is_empty", default)]
62    pub password_hash: String,
63    /// SCRAM-SHA-256 verifier for native challenge/response authentication.
64    /// Older catalogs and callers that supplied only a PHC hash have none.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub scram_sha_256: Option<crate::security_hardening::ScramVerifier>,
67    /// MySQL wire compatibility verifier. Authentication still produces the
68    /// same live catalog principal and requires TLS.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub mysql_caching_sha2: Option<MysqlCachingSha2Verifier>,
71    /// Role names granted to this user.
72    #[serde(default)]
73    pub roles: Vec<String>,
74    /// Bypasses all permission checks (full admin).
75    #[serde(default)]
76    pub is_admin: bool,
77    /// Epoch at which the user was created.
78    pub created_epoch: u64,
79}
80
81/// A named collection of permissions.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct RoleEntry {
84    /// Unique role name (case-sensitive).
85    pub name: String,
86    /// Permissions granted to this role.
87    #[serde(default)]
88    pub permissions: Vec<Permission>,
89    /// Epoch at which the role was created.
90    pub created_epoch: u64,
91}
92
93/// A permission granted to a role. Mirrors SQL `GRANT` / `REVOKE`.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(tag = "kind", rename_all = "snake_case")]
96pub enum Permission {
97    /// All permissions on all tables (`GRANT ALL`).
98    All,
99    /// `SELECT` on a specific table.
100    Select { table: String },
101    /// `INSERT` on a specific table.
102    Insert { table: String },
103    /// `UPDATE` on a specific table.
104    Update { table: String },
105    /// `DELETE` on a specific table.
106    Delete { table: String },
107    /// `SELECT` limited to named columns.
108    SelectColumns { table: String, columns: Vec<String> },
109    /// `INSERT` limited to named columns.
110    InsertColumns { table: String, columns: Vec<String> },
111    /// `UPDATE` limited to named columns.
112    UpdateColumns { table: String, columns: Vec<String> },
113    /// DDL: `CREATE` / `DROP` / `ALTER TABLE`.
114    Ddl,
115    /// Admin: `CREATE USER` / `GRANT` / `REVOKE` / `CREATE ROLE`.
116    Admin,
117}
118
119impl Permission {
120    /// Check whether this permission satisfies a required permission.
121    ///
122    /// `All` satisfies every non-admin permission (DDL + all table-level
123    /// operations) but does **not** satisfy `Admin` — user/role management
124    /// is gated behind `is_admin = true` on the principal, not grantable via
125    /// `Permission::All` (spec §9 decision 2). `Select { table: "*" }` is
126    /// not wildcarded (it matches a table literally named `*`).
127    pub fn satisfies(&self, required: &Permission) -> bool {
128        match (self, required) {
129            // All grants every non-admin permission.
130            (Permission::All, Permission::Admin) => false,
131            (Permission::All, _) => true,
132            (Permission::Admin, Permission::Admin) => true,
133            (Permission::Ddl, Permission::Ddl) => true,
134            (Permission::Select { table: a }, Permission::Select { table: b }) => a == b,
135            (Permission::Insert { table: a }, Permission::Insert { table: b }) => a == b,
136            (Permission::Update { table: a }, Permission::Update { table: b }) => a == b,
137            (Permission::Delete { table: a }, Permission::Delete { table: b }) => a == b,
138            (
139                Permission::SelectColumns {
140                    table: a,
141                    columns: granted,
142                },
143                Permission::SelectColumns {
144                    table: b,
145                    columns: required,
146                },
147            )
148            | (
149                Permission::InsertColumns {
150                    table: a,
151                    columns: granted,
152                },
153                Permission::InsertColumns {
154                    table: b,
155                    columns: required,
156                },
157            )
158            | (
159                Permission::UpdateColumns {
160                    table: a,
161                    columns: granted,
162                },
163                Permission::UpdateColumns {
164                    table: b,
165                    columns: required,
166                },
167            ) => a == b && required.iter().all(|column| granted.contains(column)),
168            _ => false,
169        }
170    }
171}
172
173impl std::fmt::Display for Permission {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self {
176            Permission::All => write!(f, "ALL"),
177            Permission::Admin => write!(f, "ADMIN"),
178            Permission::Ddl => write!(f, "DDL"),
179            Permission::Select { table } => write!(f, "SELECT ON {table}"),
180            Permission::Insert { table } => write!(f, "INSERT ON {table}"),
181            Permission::Update { table } => write!(f, "UPDATE ON {table}"),
182            Permission::Delete { table } => write!(f, "DELETE ON {table}"),
183            Permission::SelectColumns { table, columns } => {
184                write!(f, "SELECT ({}) ON {table}", columns.join(", "))
185            }
186            Permission::InsertColumns { table, columns } => {
187                write!(f, "INSERT ({}) ON {table}", columns.join(", "))
188            }
189            Permission::UpdateColumns { table, columns } => {
190                write!(f, "UPDATE ({}) ON {table}", columns.join(", "))
191            }
192        }
193    }
194}
195
196/// The authenticated identity for a single HTTP request. Injected by the
197/// auth middleware into request extensions.
198#[derive(Debug, Clone)]
199pub struct Principal {
200    /// Immutable catalog identity. Username reuse must not revive a principal.
201    pub user_id: u64,
202    /// User generation paired with `user_id`.
203    pub created_epoch: u64,
204    pub username: String,
205    pub is_admin: bool,
206    pub roles: Vec<String>,
207    /// All permissions from all roles the user belongs to, pre-resolved.
208    pub permissions: Vec<Permission>,
209}
210
211impl Principal {
212    /// Check whether this principal has the required permission.
213    pub fn has_permission(&self, required: &Permission) -> bool {
214        if self.is_admin {
215            return true;
216        }
217        self.permissions.iter().any(|p| p.satisfies(required))
218    }
219
220    pub fn column_access(&self, table: &str, operation: ColumnOperation) -> ColumnAccess {
221        if self.is_admin
222            || self
223                .permissions
224                .iter()
225                .any(|permission| matches!(permission, Permission::All))
226        {
227            return ColumnAccess::All;
228        }
229        let full = self
230            .permissions
231            .iter()
232            .any(|permission| match (operation, permission) {
233                (ColumnOperation::Select, Permission::Select { table: granted })
234                | (ColumnOperation::Insert, Permission::Insert { table: granted })
235                | (ColumnOperation::Update, Permission::Update { table: granted }) => {
236                    granted == table
237                }
238                _ => false,
239            });
240        if full {
241            return ColumnAccess::All;
242        }
243        let mut columns = Vec::new();
244        for permission in &self.permissions {
245            let grant = match (operation, permission) {
246                (
247                    ColumnOperation::Select,
248                    Permission::SelectColumns {
249                        table: granted,
250                        columns,
251                    },
252                )
253                | (
254                    ColumnOperation::Insert,
255                    Permission::InsertColumns {
256                        table: granted,
257                        columns,
258                    },
259                )
260                | (
261                    ColumnOperation::Update,
262                    Permission::UpdateColumns {
263                        table: granted,
264                        columns,
265                    },
266                ) if granted == table => Some(columns),
267                _ => None,
268            };
269            if let Some(grant) = grant {
270                for column in grant {
271                    if !columns.contains(column) {
272                        columns.push(column.clone());
273                    }
274                }
275            }
276        }
277        if columns.is_empty() {
278            ColumnAccess::Denied
279        } else {
280            ColumnAccess::Columns(columns)
281        }
282    }
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub enum ColumnOperation {
287    Select,
288    Insert,
289    Update,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum ColumnAccess {
294    All,
295    Columns(Vec<String>),
296    Denied,
297}
298
299// ── Password hashing (Argon2id) ──────────────────────────────────────────
300
301/// Hash a password using Argon2id with a fresh random salt.
302///
303/// Returns a PHC string that encodes the algorithm, version, parameters, salt,
304/// and hash — suitable for storage as `UserEntry::password_hash` and verifiable
305/// via [`verify_password`].
306pub fn hash_password(password: &str) -> Result<String, String> {
307    use argon2::{
308        password_hash::{PasswordHasher, SaltString},
309        Algorithm, Argon2, Version,
310    };
311    use getrandom::getrandom;
312    // Reuse the same OWASP-minimum parameters as the encryption KEK derivation.
313    let params = argon2::Params::new(19 * 1024, 2, 1, None).map_err(|e| e.to_string())?;
314    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
315    // Generate salt via getrandom (always available in core, no feature gate).
316    let mut salt_bytes = [0u8; 32];
317    getrandom(&mut salt_bytes).map_err(|e| e.to_string())?;
318    let salt = SaltString::encode_b64(&salt_bytes).map_err(|e| e.to_string())?;
319    let hash = argon2
320        .hash_password(password.as_bytes(), &salt)
321        .map_err(|e| e.to_string())?;
322    Ok(hash.to_string())
323}
324
325/// Build independently salted SCRAM-SHA-256 verifier material.
326pub fn scram_verifier(password: &str) -> Result<crate::security_hardening::ScramVerifier, String> {
327    let mut salt = [0_u8; 16];
328    getrandom::getrandom(&mut salt).map_err(|error| error.to_string())?;
329    crate::security_hardening::ScramVerifier::from_password(
330        password,
331        &salt,
332        crate::security_hardening::SCRAM_SHA_256_MIN_ITERATIONS,
333    )
334    .map_err(|error| error.to_string())
335}
336
337/// Verify a password against a stored PHC hash. Returns `Ok(true)` on match,
338/// `Ok(false)` on mismatch, `Err` on malformed hash.
339pub fn verify_password(password: &str, phc_hash: &str) -> Result<bool, String> {
340    use argon2::{password_hash::PasswordVerifier, Argon2};
341    let parsed_hash =
342        argon2::PasswordHash::new(phc_hash).map_err(|e| format!("malformed hash: {e}"))?;
343    Ok(Argon2::default()
344        .verify_password(password.as_bytes(), &parsed_hash)
345        .is_ok())
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn password_hash_round_trip() {
354        let password = "correct horse battery staple";
355        let hash = hash_password(password).unwrap();
356        assert!(verify_password(password, &hash).unwrap());
357        assert!(!verify_password("wrong password", &hash).unwrap());
358    }
359
360    #[test]
361    fn caching_sha2_verifier_accepts_only_matching_proof() {
362        let verifier = MysqlCachingSha2Verifier::from_password("password");
363        let nonce = b"12345678901234567890";
364        let stage1 = Sha256::digest(b"password");
365        let stage2 = Sha256::digest(stage1);
366        let mask = Sha256::new()
367            .chain_update(stage2)
368            .chain_update(nonce)
369            .finalize();
370        let proof = stage1
371            .iter()
372            .zip(mask)
373            .map(|(left, right)| left ^ right)
374            .collect::<Vec<_>>();
375        assert!(verifier.verify(nonce, &proof));
376        assert!(!verifier.verify(nonce, &[0; 32]));
377    }
378
379    #[test]
380    fn permission_satisfies() {
381        // All satisfies DDL and table-level permissions...
382        assert!(Permission::All.satisfies(&Permission::Ddl));
383        assert!(Permission::All.satisfies(&Permission::Select { table: "t".into() }));
384        assert!(Permission::All.satisfies(&Permission::Insert { table: "t".into() }));
385        // ...but NOT Admin (spec §9 decision 2 — only is_admin grants admin).
386        assert!(!Permission::All.satisfies(&Permission::Admin));
387        // Exact table match.
388        assert!(Permission::Select { table: "t".into() }
389            .satisfies(&Permission::Select { table: "t".into() }));
390        assert!(
391            !Permission::Select { table: "t".into() }.satisfies(&Permission::Select {
392                table: "other".into()
393            })
394        );
395        // Cross-kind never satisfies.
396        assert!(!Permission::Select { table: "t".into() }
397            .satisfies(&Permission::Insert { table: "t".into() }));
398        // Ddl does not satisfy Admin either.
399        assert!(!Permission::Ddl.satisfies(&Permission::Admin));
400    }
401
402    #[test]
403    fn principal_admin_bypasses_checks() {
404        let principal = Principal {
405            user_id: 0,
406            created_epoch: 0,
407            username: "admin".into(),
408            is_admin: true,
409            roles: vec![],
410            permissions: vec![],
411        };
412        assert!(principal.has_permission(&Permission::Admin));
413        assert!(principal.has_permission(&Permission::Select {
414            table: "anything".into()
415        }));
416    }
417}